JPA (Hibernate)

[JPA (Hibernate)] @EmbeddedId이 선언된 Entity 조회 시, 객체가 덮어써지는 이슈

DevHyo 2023. 10. 3. 19:41

문제

사내에서 개발하던 중, @EmbeddedId가 선언된 Entity가 있었고, 해당 Entity는 @EmbeddedId를 기준으로 여러 개의 Row를 갖고 있는 상황이었다. 이때, Entity를 리스트로 조회하게 되면 아래와 같이 중복된 것처럼 보이는 조회 결과를 반환했다. (테이블과 소스 코드는 다시 재구성했다.)

audit_revision 테이블
CREATE TABLE `audit_revision` (
    rev BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY,
    created_datetime DATETIME(6) NULL
);
AuditRevision 엔티티
@Entity
@RevisionEntity
@Table(name = "audit_revision")
class AuditRevision {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    @RevisionNumber
    val rev: Long? = null

    @RevisionTimestamp
    val createdDatetime: LocalDateTime? = null
}
shop_audit 테이블 (Hash 기반의 파티션 테이블)
CREATE TABLE `shop_audit` (
    service_type VARCHAR(20) NOT NULL,
    id BIGINT NOT NULL,
    rev BIGINT NOT NULL,
    revtype TINYINT NOT NULL,
    `name` TINYTEXT NOT NULL,
    PRIMARY KEY (service_type , id , rev)
) PARTITION BY HASH (id) PARTITIONS 100;
ShopAudit 엔티티
@Entity
@Table(name = "shop_audit")
class ShopAudit(
    @EmbeddedId
    val shopId: ShopId, // ShopId를 복합키로 사용하고 있다.

    @Column(nullable = false)
    val rev: Long,

    @Column(nullable = false)
    val revtype: Int,

    @Column(nullable = false)
    val name: String,
) {

    override fun toString(): String =
        "ShopAudit(rev=$rev, revtype=$revtype, id=${shopId.id}, serviceType=${shopId.serviceType}, name=$name)"
}
ShopId 복합키
@Embeddable
data class  ShopId(
    @Column(name = "service_type", nullable = false)
    @Enumerated(STRING)
    val serviceType: ShopServiceType,

    @Column(nullable = false)
    val id: Long,
) : Serializable

위와 같이 테이블 및 엔티티가 구성되어 있을 때, 영속성 컨텍스트 환경에서 아래의 쿼리가 실행될 경우

SELECT *
FROM `shop_audit`
WHERE id = 1 AND service_type = 'SERVICE_A'
ORDER BY rev DESC
LIMIT 5;

테이블에서는 정상적으로 아래와 같이 5건의 결과가 조회된다.

정상적으로 5건의 결과가 조회된다.

하지만, Spring Boot Application에서 JPA를 통해 조회하게 되면, 아래와 같은 결과를 확인할 수 있었다.

똑같은 Row가 중복된 상태로 조회된다.

빠르게 시도했던 방법

ShopAudit 엔티티 내부에 hashcode()와 equals()를 재정의 하지 않아서 발생한 것 같아 아래와 같이 코드를 재정의 했다. rev 필드는 Primary Key이면서, 고유한 값을 갖고 있기 때문에 rev 필드를 통해 hashcode(), equals()가 처리되도록 구성했다. 역시나 문제는 해결되지 않았다. (지금 생각해 보면 안 되는 것이 너무 당연하다.)

@Entity
@Table(name = "shop_audit")
class ShopAudit(
	// 기존 코드 생략...
) {
	// 기존 코드 생략..
    
    override fun hashCode(): Int = Objects.hash(rev)

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        val otherShopAudit: ShopAudit = (other as? ShopAudit) ?: return false
        // - '스레드 == 트랜잭션 == 영속성 컨텍스트' 범위내에서는 id 값만 같아도 같은 엔티티 객체로 본다.
        // - 영속성 컨텍스트는 REPEATABLE READ로 동작한다.
        return this.rev == otherShopAudit.rev
    }
}

원인

위의 시도대로 처리가 되지 않자 여러 문서들과 JPA 책을 보면서, 다음과 같은 해답을 얻을 수 있었다.

영속성 컨텍스트에서 Entity에 대한 동일성은 오로지 식별자를 통해서 이루어진다.

 

즉, @Id, @EmbeddedId를 기준으로 동일성을 식별하는데, 특히 @EmbeddedId를 사용한다면 hashcode(), equals()를 통해 식별된다는 것을 확인할 수 있었다. 해당 내용은 JPA를 공부했을 때부터 정리했던 내용인데, 아쉽게도 잊어버리고 있었다. 아무튼 원인을 다시 분석해 보자면, 다음과 같다.

hashcode(), equals() 처리 과정

hashcode() 값이 같아서 true를 반환하고, equals() 결과도 true를 반환하면 값이 같은 객체라고 판단한다. 이때, HashTable은 아래와 같이 처리된다.

HashTable, Bucket, LinkedList

즉, 값이 같은 객체가 이미 있다면, 기존 객체를 덮어쓰고, 값이 다른 객체라면 Bucket에 있는 LinkedList에 추가한다. 그래서 내가 마주했던 문제는 아래와 같이 shopId 필드의 값이 같은 객체가 5건이나 있었고, 1건씩 기존 객체를 덮어썼기 때문에 발생했던 문제이다.

  1. shopId(id= 1, serviceType=SERVICE_A)
  2. shopId(id= 1, serviceType=SERVICE_A)
  3. shopId(id= 1, serviceType=SERVICE_A)
  4. shopId(id= 1, serviceType=SERVICE_A)
  5. shopId(id= 1, serviceType=SERVICE_A)

해결

물론 다른 방법들이 있을 수 있지만, 나는 아래와 같이 Entity 소스 코드를 수정해서 해결했다.

수정된 ShopAudit 엔티티
@Entity
@Table(name = "shop_audit")
class ShopAudit(
    @Id
    @Column(nullable = false)
    val rev: Long,

    @Column(nullable = false)
    val revtype: Int,

    @Column(nullable = false)
    val id: Long, // id 필드 작성

    @Column(name = "service_type", nullable = false)
    @Enumerated(STRING)
    val serviceType: ShopServiceType, // serviceType 필드 작성

    @Column(nullable = false)
    val name: String,
) {

    override fun toString(): String =
        "ShopAudit(rev=$rev, revtype=$revtype, id=${id}, shopServiceType=${serviceType}, name=$name)"
}

shopId 필드를 제거하고, shopId에 있었던 id, serviceType 필드를 선언했다. 그리고 rev 필드는 AuditRevision 엔티티에 의해서 PK가 자동 생성 되기 때문에 rev 필드를 @Id 식별자로 명시했다. 영속성 컨텍스트는 rev 필드만 보고 동일성을 판단할 수 있기 때문에 더 이상 문제가 발생하지 않을 거라 생각했다.

 

참고로 위의 내용과 관계는 없지만 조금 적어보자면, Entity는 @Id 식별자를 기준으로 1차 캐시에 저장되어 있기 때문에 hashcode(), equals() 메서드와 무관하게 영속성 컨텍스트는 @Id 식별자를 기준으로 동일성을 판단한다. (Entity의 Equals와 HashCode를 오버라이드 해도 될까?)

결과

더 이상 값이 덮여 쓰이는 현상이 발생하지 않고, 아래와 같이 정상적으로 조회되었다.

정상적으로 조회되는 ShopAudit 리스트

정리

hashcode(), equals(), 영속성 컨텍스트, @Id, @EmbeddedId 식별자를 다시 복습할 수 있는 계기가 되었고, 내가 해결한 방법 말고도 다른 방법이 있을 수 있기 때문에 무조건 이렇게 해결해야 하는 것은 아니다.