오늘은 우아한 남형제들
프로젝트를 진행하면서 CQRS
읽기 전용 서비스를 개발하는 도중에 만난 JPA merge
이슈를 공유하고자 합니다.
엔티티
를 만들때 습관적으로 @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개의 쿼리
가 나가는 것을 볼 수 있습니다. 누가 봐도 비효율
적이겠죠?
새로운 데이터
를 저장하기 위해 저장하려는 데이터 개수
만큼의 조회 쿼리가 나가는 것은 비효율적
입니다.
왜 JPA
에서는 제가 저장하려고 할때 굳이 조회
를 하고 나서 저장하는 걸까요?
결론부터 말씀드리자면, JPA
가 엔티티를 저장할 때 새로 생성된 엔티티
인지 구분하는 규칙
때문에 그렇습니다.
OrderRepository
는 JpaRepository를 상속하고, JpaRepository
는 CrudRepository
를 상속하고 있습니다.
우리가 사용하는 save()
함수는 CrudRepository
에 정의되어 있습니다.
우리가 실제 사용하는 save()
함수는 SimpleJpaRepository
가 구현하고 있습니다.
save()
함수에서는 entityInformation
의 isNew()
함수로 새로운 엔티티인지 확인하고, 새로운 엔티티가 아니라면 merge()
를 호출합니다.
EntityInformation
은 인터페이스며, 저장할 엔티티가 Persistable
인터페이스를 구현하지 않는다면 JpaMetamodelEntityInformation
을 기본적으로 사용합니다.
JpaMetamodelEntityInformation
은 상위 클래스의 isNew()
함수를 호출합니다.
AbstractEntityInformation
클래스의 isNew()
함수의 구현부는 아래와 같습니다.
엔티티
의 기본 키 타입에 따라서 신규 엔티티 판단 규칙
이 조금 다릅니다.
0
인 경우, 신규 엔티티
로 판단합니다.null
인 경우, 신규 엔티티
로 판단합니다.따라서 isNew()
함수가 false
를 반환하기 때문에 else
블럭이 실행되게 됩니다.
영속성 컨텍스트
에 존재하지 않는 엔티티를 merge()
함수 호출을 위해 제공하면 조회 후 합병을 진행하기 때문에 select
쿼리와 insert
쿼리가 같이 나갑니다.
그러면 이 문제
를 어떻게 해결할 수 있을까요?
@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
)
orderId
를 PK
가 아닌 일반 컬럼으로 사용하고, 자동 생성되는 primaryId
를 PK
로 사용하는 방법을 적용해보았습니다.
orderId
는 주문 API에서 조건절에 사용되기도 하고, 많은 테이블
과 조인
을 할 수 있는 핵심 컬럼이므로 PK
로 사용하는 것이 맞다고 판단
이 들었습니다.
따라서, 이 방식은 프로젝트
와 맞지 않다고 생각
이 들었습니다.
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
}
regDate
와 modDate
필드가 이미 존재하지만, 주문 서비스
에서 저장된 일시를 저장하기 때문에 읽기 전용 서비스도 시스템 컬럼을 따로 두었습니다.
@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
)
readModelRegDate
가 null
일 경우, 새로운 엔티티로 판단하도록 했습니다.
select
쿼리가 더 이상 나가지 않고, insert
쿼리만 나가는 것을 볼 수 있습니다.
JPA
를 사용한지 8개월
이 넘었지만, JPA
내부 동작 원리에 대한 이해 부족으로 원인을 쉽게 파악하지 못했습니다.
PK 값
을 직접 지정해 본적이 없어서, @GeneratedValue
를 사용하지 않으면 어떤 문제가 생기는지에 대한 이해가 부족했었던 것 같습니다.
이번 기회에 JPA
와 조금 더 친해진 것 같아서 기분이 매우 좋습니다.
문제
를 해결할 수 있는 방법은 많지만, 우리 프로젝트
에 맞는 방향인지 생각하는 것이 중요
한 것 같습니다.
오늘 JPA 내부
를 함께 살펴보신 여러분은!
오늘도 읽어주셔서 감사합니다.
잘 읽었습니다~