영한님 강의 듣고 @GeneratedValue 남발하다 나락간 썰 풉니다

DevSeoRex·2024년 2월 5일
7

오늘은 우아한 남형제들 프로젝트를 진행하면서 CQRS 읽기 전용 서비스를 개발하는 도중에 만난 JPA merge 이슈를 공유하고자 합니다.

🧐 @GeneratedValue를 쓸 수 없다?!

엔티티를 만들때 습관적으로 @GeneratedValue를 사용하다가 못쓰는 상황이 생기자 바로 문제가 발생해버렸습니다.

JPA 엔티티 클래스를 작성하면 보통 PK(기본키)DB에서 직접 생성해주는 값을 사용하는 것이 일반적이므로 @GeneratedValue를 사용하는 경우가 많았습니다.

이번 우아한 남형제들 프로젝트의 읽기 전용 서비스의 요구사항은 각 MSA 서비스의 데이터의 변경이 있을때 Kafka를 통해 비동기로 데이터 동기화가 이뤄져야 하는 것이였습니다.

읽기 전용 서비스의 입장에서는 모든 데이터가 채워진 엔티티를 자신의 DB에 저장하는 일을 해야하는 것입니다.

그렇다면 @GeneratedValue 대신 @Id 애너테이션만 붙여서 직접 식별자를 지정해주는 방식으로 데이터를 저장해야 합니다.

@Entity(name = "ORDERS")
data class OrderJpaEntity(

    @Id
    @Column(name = "ORDER_ID")
    val orderId: Long,

    @Column(name = "MEMBER_ID")
    val memberId: Long,

    @Column(name = "ADDRESS")
    val address: String,

    @Column(name = "STORE_ID")
    val storeId: Long,

    @Column(name = "TOTAL_PRICE")
    val totalPrice: Int,

    @Column(name = "ORDER_STATUS")
    @Enumerated(EnumType.STRING)
    var orderStatus: OrderStatus,

    @Column(name = "DEL_STATUS")
    val delStatus: Boolean,

    @Column(name = "ORDER_REG_DATE")
    val regDate: LocalDateTime,

    @Column(name = "ORDER_MOD_DATE")
    val modDate: LocalDateTime
)

생성일수정일도 다른 서비스에서 저장된 내용을 그대로 사용해야 데이터 정합성이 깨지지 않기 때문에, 자동 생성을 하고 있지 않습니다.

@Entity(name = "ORDER_ITEM")
class OrderItemJpaEntity(

    @Id
    @Column(name = "ORDER_ITEM_ID")
    val orderItemId: Long,

    @Column(name = "ORDER_ID")
    val orderId: Long,

    @Column(name = "ITEM_ID")
    val itemId: Long,

    @Column(name = "ITEM_PRICE")
    val itemPrice: Int,

    @Column(name = "ITEM_NAME")
    val itemName: String,

    @Column(name = "ITEM_QUANTITY")
    val itemQuantity: Int,

    @Column(name = "ITEM_TOTAL_PRICE")
    val itemTotalPrice: Int,

    @Column(name = "DEL_STATUS")
    val delStatus: Boolean,

    @Column(name = "ORDER_ITEM_REG_DATE")
    val regDate: LocalDateTime,

    @Column(name = "ORDER_ITEM_MOD_DATE")
    val modDate: LocalDateTime
)

주문을 저장할때 함께 저장되는 주문 상품도 같은 방식으로 구현되어 있습니다.
이제 주문 1건주문 상품 4개를 담아서 저장해보겠습니다.


5건의 데이터를 저장하기 위해서 조회 5건 + 저장 5건 총 10개의 쿼리가 나가는 것을 볼 수 있습니다. 누가 봐도 비효율적이겠죠?

🤩 Step1. 문제 파악하기

새로운 데이터를 저장하기 위해 저장하려는 데이터 개수 만큼의 조회 쿼리가 나가는 것은 비효율적입니다.

JPA 에서는 제가 저장하려고 할때 굳이 조회를 하고 나서 저장하는 걸까요?
결론부터 말씀드리자면, JPA가 엔티티를 저장할 때 새로 생성된 엔티티인지 구분하는 규칙때문에 그렇습니다.

OrderRepository는 JpaRepository를 상속하고, JpaRepositoryCrudRepository를 상속하고 있습니다.

우리가 사용하는 save() 함수는 CrudRepository에 정의되어 있습니다.
우리가 실제 사용하는 save() 함수는 SimpleJpaRepository가 구현하고 있습니다.

save() 함수에서는 entityInformationisNew() 함수로 새로운 엔티티인지 확인하고, 새로운 엔티티가 아니라면 merge()를 호출합니다.

EntityInformation은 인터페이스며, 저장할 엔티티가 Persistable 인터페이스를 구현하지 않는다면 JpaMetamodelEntityInformation을 기본적으로 사용합니다.

JpaMetamodelEntityInformation은 상위 클래스의 isNew() 함수를 호출합니다.
AbstractEntityInformation 클래스의 isNew() 함수의 구현부는 아래와 같습니다.

엔티티의 기본 키 타입에 따라서 신규 엔티티 판단 규칙이 조금 다릅니다.

  • 기본형(정수 타입): 0인 경우, 신규 엔티티로 판단합니다.
  • 참조형: null인 경우, 신규 엔티티로 판단합니다.

따라서 isNew() 함수가 false를 반환하기 때문에 else 블럭이 실행되게 됩니다.

영속성 컨텍스트에 존재하지 않는 엔티티를 merge() 함수 호출을 위해 제공하면 조회 후 합병을 진행하기 때문에 select 쿼리와 insert 쿼리가 같이 나갑니다.

그러면 이 문제를 어떻게 해결할 수 있을까요?

😐 @GeneratedValue 사용하기

@Entity(name = "ORDERS")
data class OrderJpaEntity(

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "PRIMARY_ID")
    val primaryId: Long? = null,
    
    @Column(name = "ORDER_ID")
    val orderId: Long,

    @Column(name = "MEMBER_ID")
    val memberId: Long,

    @Column(name = "ADDRESS")
    val address: String,

    @Column(name = "STORE_ID")
    val storeId: Long,

    @Column(name = "TOTAL_PRICE")
    val totalPrice: Int,

    @Column(name = "ORDER_STATUS")
    @Enumerated(EnumType.STRING)
    var orderStatus: OrderStatus,

    @Column(name = "DEL_STATUS")
    val delStatus: Boolean,

    @Column(name = "ORDER_REG_DATE")
    val regDate: LocalDateTime,

    @Column(name = "ORDER_MOD_DATE")
    val modDate: LocalDateTime
)

orderIdPK가 아닌 일반 컬럼으로 사용하고, 자동 생성되는 primaryIdPK로 사용하는 방법을 적용해보았습니다.

orderId는 주문 API에서 조건절에 사용되기도 하고, 많은 테이블조인을 할 수 있는 핵심 컬럼이므로 PK로 사용하는 것이 맞다고 판단이 들었습니다.

따라서, 이 방식은 프로젝트와 맞지 않다고 생각이 들었습니다.

😎 Persistable 인터페이스 구현하기

Persistable 인터페이스를 구현하면 isNew()getId() 함수를 오버라이딩해서 직접 새로운 엔티티를 구분하는 규칙을 구현할 수 있습니다.

@Entity(name = "ORDERS")
data class OrderJpaEntity(

    @Id
    @Column(name = "ORDER_ID")
    val orderId: Long,

    @Column(name = "MEMBER_ID")
    val memberId: Long,

    @Column(name = "ADDRESS")
    val address: String,

    @Column(name = "STORE_ID")
    val storeId: Long,

    @Column(name = "TOTAL_PRICE")
    val totalPrice: Int,

    @Column(name = "ORDER_STATUS")
    @Enumerated(EnumType.STRING)
    var orderStatus: OrderStatus,

    @Column(name = "DEL_STATUS")
    val delStatus: Boolean,

    @Column(name = "ORDER_REG_DATE")
    val regDate: LocalDateTime,

    @Column(name = "ORDER_MOD_DATE")
    val modDate: LocalDateTime

    ) : ReadModelBaseEntity(), Persistable<Long> {
    fun updateOrderStatus(orderStatus: OrderStatus) {
        this.orderStatus = orderStatus
    }

    override fun getId() = orderId
    override fun isNew() = readModelRegDate == null
}

regDatemodDate 필드가 이미 존재하지만, 주문 서비스에서 저장된 일시를 저장하기 때문에 읽기 전용 서비스도 시스템 컬럼을 따로 두었습니다.

@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class ReadModelBaseEntity(

    @CreatedDate
    @Column(name = "REG_DATE")
    var readModelRegDate: LocalDateTime? = null,

    @LastModifiedDate
    @Column(name = "MOD_DATE")
    var readModelModDate: LocalDateTime? = null
)

readModelRegDatenull일 경우, 새로운 엔티티로 판단하도록 했습니다.

select 쿼리가 더 이상 나가지 않고, insert 쿼리만 나가는 것을 볼 수 있습니다.

😡 느낀점 - JPA 내부에 대한 이해 부족

JPA를 사용한지 8개월이 넘었지만, JPA 내부 동작 원리에 대한 이해 부족으로 원인을 쉽게 파악하지 못했습니다.

PK 값을 직접 지정해 본적이 없어서, @GeneratedValue를 사용하지 않으면 어떤 문제가 생기는지에 대한 이해가 부족했었던 것 같습니다.

이번 기회에 JPA와 조금 더 친해진 것 같아서 기분이 매우 좋습니다.
문제를 해결할 수 있는 방법은 많지만, 우리 프로젝트에 맞는 방향인지 생각하는 것이 중요한 것 같습니다.

오늘 JPA 내부를 함께 살펴보신 여러분은!

오늘도 읽어주셔서 감사합니다.

🙇

참고한 레퍼런스

3개의 댓글

comment-user-thumbnail
2024년 2월 6일

잘 읽었습니다~

2개의 답글