프로젝트에서 JPA를 사용하여 엔티티를 영속할 때, 연관 관계를 맺은 다른 엔티티의 @OneToMany
의 Cascade를 사용하여 영속하였다.
그리고 저장 시 발생하는 쿼리를 봤더니 다음과 같은 3개의 쿼리가 발생하고 있었다.
# 1
select
p1_0.id,
p1_0.other_column
from
news p1_0
where
p1_0.id=?
# 2
select
null,
p1_0.other_column
from
news p1_0
where
p1_0.id=?
# 3
insert
into
content
(news_id, id)
values
(?, default)
여기서 문제가 되는 쿼리는 #2
이다.
select
null,
p1_0.other_column
from
news p1_0
where
p1_0.id=?
#1
, #3
쿼리는 당연히 발생하는 쿼리지만, #2
의 경우 이미 #1
에서 News
를 조회하여 조회할 필요가 없는데 조회하는 쿼리가 발생했다.
게다가 특이하게 식별자의 컬럼만 null을 조회하는 쿼리였다.
문제가 발생한 코드는 다음과 같다.
class TranslationCommandService {
@Transactional
fun translate(command: TranslationCommand) {
val (newsId, sourceLanguage, targetLanguage) = command
val news = newsRepository.findByIdAndLanguage(newsId, sourceLanguage)
val content = news.getContentByLanguage(sourceLanguage)
translatorClient.requestTranslate(content, targetLanguage)
.subscribe { it: Content ->
eventPublisher.publishEvent(NewsAppendContentEvent(newsId, it))
}
}
}
...
class NewsAppendContentEventListener {
@Async
@EventListener
@Transactional
fun appendContentEventHandler(event: NewsAppendContentEvent) {
val (newsId, content) = event
try {
val news = newsRepository.getOrThorw(newsId)
news.addContent(content)
} catch (e: Exception) {
log.error { "뉴스에 컨텐츠를 추가하는 중 예외가 발생했습니다. ${e.message}" }
}
}
}
코드의 흐름은 News
에서 특정 언어에 대한 Content
를 가져온 뒤 WebClient
를 사용하여 번역 API에 요청을 보내고, 번역된 Content
를 NewsAppendContentEvent
이벤트를 발행시켜서 기존 News
에 새롭게 추가한다.
그리고 News
, Content
엔티티의 연관 관계는 다음과 같다.
@Entity
class News(
@Id
private val id: UUID = UUID.randomUUID(),
@OneToMany(cascade = [CascadeType.PERSIST], mappedBy = "news")
private val contents: MutableList<Content> = mutableListOf(),
...
) : Persistable<UUID> {
fun addContent(content: Content) {
validation(content)
contents.add(content)
}
}
@Entity
class Content(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@ManyToOne(fetch = FetchType.LAZY)
private val news: News,
...
) {
...
}
비즈니스 로직에서 News
와 Content
의 생명 주기가 같기 때문에, Content
를 영속할 때 굳이 ContentRepository
를 만들지 않고 @OneToMany
의 Cascade를 사용하여 영속하였다.
이렇게 하면 Content
를 영속할 때 검증 로직을 도메인에 구현할 수 있으니, 응집도를 높이기 위함도 있다.
또한, News
는 식별자를 @GeneratedValue
를 사용하지 않고, 서버에서 직접 UUID를 할당하여 생성해 주었다.
@GeneratedValue
를 사용하지 않은 이유는 제약이 너무 많아서였다.
첫 번째로 식별자를 알고 싶으면 데이터베이스에 무조건 저장이 되어야 했다.
두 번째로 엔티티의 식별자가 nullable한 값이므로 null 처리를 해야 하는 로직이 강제되었다.
Content
는 @GeneratedValue
를 사용했는데, 이유는 Content
의 식별자를 사용할 일이 없기 때문이다.
따라서 식별자를 자주 사용해야 하는 News
는 식별자를 어플리케이션에서 생성하도록 하였고, 식별자를 사용할 일이 없는 Content
는 @GeneratedValue
를 사용했다.
그리고 News
의 영속 과정에서 merge()
가 되는 것을 막기 위해 Persistable
인터페이스를 구현하였다.
처음엔 WebClient
를 사용한 비동기 처리 때문이 아닌지 의심했었다.
이전에 WebClient
사용 경험이 없어서, JPA 처럼 사용하기 전 주의해야 하는 사항이 있을까 싶어서였다.
따라서 테스트 코드로 직접 로직을 호출하여 문제가 발생하는지 확인했다.
@Test
fun test() {
val news = News()
newsRepository.save(news)
val content = Content(news, "blah blah")
val event = NewsAppendContentEvent(news.id, content)
newsAppendContentEventListener.appendContentEventHandler(event)
}
하지만 결과를 확인해 보니, 여전히 쿼리 3개가 발생하고 있었고 비동기 처리 때문에 발생하는 문제가 아니었다.
그다음은 News
에 식별자를 미리 할당해 주고 있으니, persist()
가 아닌 merge()
가 호출이 되어서 그런 게 아닌가 싶었다.
하지만 News
엔티티에는 Persistable
인터페이스를 구현하여 엔티티가 비영속 상태일 때 merge()
호출이 되지 않도록 구현을 한 상태였다.
또한 merge()
에서 발생하는 쿼리는 다음과 같다.
select
p1_0.id,
p1_0.other_column
from
news p1_0
where
p1_0.id=?
식별자에 컬럼에 대해 null을 조회하는 것이 아닌 p1_0.id
와 같이 정상적으로 식별자를 조회해 온다.
따라서 merge()
또한 원인이 아니었다.
가장 의심스러운 두 가지가 원인이 아니었다면, 남아있는 가장 유력한 원인은 News
식별자를 직접 할당해 주고 있는 것이었다.
따라서 다음과 같이 @GeneratedValue
어노테이션을 사용하여 식별자를 자동으로 할당을 해주도록 변경했다.
@Entity
class News(
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private val id: UUID? = null,
) {
...
}
그리고 결과를 확인하니, 놀랍게도 @GeneratedValue
를 사용하니 문제의 쿼리가 발생하지 않았다.
select
p1_0.id,
p1_0.other_column
from
news p1_0
where
p1_0.id=?
insert
into
content
(news_id, id)
values
(?, default)
@GeneratedValue
가 붙은 것이 대체 뭐에 영향을 줄까 곰곰히 생각하니 예전에 작성했던 포스팅이 떠올랐다.
지금 포스팅을 다시 보니, 너무 두서 없이 작성한 것 같다. 나중에 수정을 해야할 듯하다 😂
포스팅에 내용 중 @GeneratedValue
의 유무에 따라 영속 상태를 구분 짓는 것이 다르다고 한 내용이 있다.
Cascade 과정에서 연관 관계를 맺은 엔티티의 식별자에 @GeneratedValue
가 없을 때, JPA는 엔티티가 영속 상태인지 구분하기 위해 데이터베이스에 조회하는 과정이 필요하다.
Cascade 과정에서 Hibernate는 Cascade.cascade()
메서드에서 action.requiresNoCascadeChecking()
메서드를 호출한다.
이때 action
객체가 PERSIST_ON_FLUSH
일 때 true
가 반환되어 PERSIST_ON_FLUSH.noCascade()
메서드가 호출된다.
public final class Cascade {
public static <T> void cascade(...) throws HibernateException {
...
if (action.requiresNoCascadeChecking()) {
action.noCascade(
eventSource,
parent,
persister,
type,
i
);
}
...
}
바로 여기가 Cascade
시점에 연관 관계를 맺은 엔티티가 비영속 상태이면 TransientPropertyValueException
예외를 던지는 부분이다.
public static final CascadingAction<PersistContext> PERSIST_ON_FLUSH = new BaseCascadingAction<>() {
...
@Override
public void noCascade(...) {
if (propertyType.isEntityType()) {
final Object child = persister.getValue(parent, propertyIndex);
if (child != null
&& !isInManagedState(child, session)
&& !isHibernateProxy(child)) {
final String childEntityName = ((EntityType)propertyType).getAssociatedEntityName(session.getFactory());
if (ForeignKeys.isTransient(childEntityName, child, null, session)) {
String parentEntityName = persister.getEntityName();
String propertyName = persister.getPropertyNames()[propertyIndex];
throw new TransientPropertyValueException(
"object references an unsaved transient instance - save the transient instance before flushing",
childEntityName,
parentEntityName,
propertyName
);
}
}
}
}
...
}
그리고 noCascade()
메서드에서 ForeignKeys.isTransient()
메소드를 호출한다.
child
객체가 1차 캐시에 있으면isInManagedState()
메서드에서true
가 반환되어ForeignKeys.isTransient()
메서드가 호출되지 않는다.
isTransient()
메서드의 Javadoc은 다음과 같이 설명되어 있다.
Is this instance, which we know is not persistent, actually transient?
If assumed is non-null, don't hit the database to make the determination, instead assume that value; the client code must be prepared to "recover" in the case that this assumed result is incorrect.
Cascade 시점에 연관 관계를 맺은 엔티티가 영속 상태가 아닐 때 정말 비영속 상태인지 확인하는 메서드이다. 즉, 준영속 상태인지 판단한다.
ForeignKeys.isTransient()
메서드의 코드는 다음과 같다.
public final class ForeignKeys {
...
public static boolean isTransient(String entityName, Object entity, Boolean assumed, SharedSessionContractImplementor session) {
if (entity == LazyPropertyInitializer.UNFETCHED_PROPERTY) {
// an unfetched association can only point to
// an entity that already exists in the db
return false;
}
// let the interceptor inspect the instance to decide
Boolean isUnsaved = session.getInterceptor().isTransient(entity);
if (isUnsaved != null) {
return isUnsaved;
}
// let the persister inspect the instance to decide
final EntityPersister persister = session.getEntityPersister(entityName, entity);
isUnsaved = persister.isTransient(entity, session);
if (isUnsaved != null) {
return isUnsaved;
}
// we use the assumed value, if there is one, to avoid hitting
// the database
if (assumed != null) {
return assumed;
}
// hit the database, after checking the session cache for a snapshot
final Object[] snapshot = session.getPersistenceContextInternal().getDatabaseSnapshot(
persister.getIdentifier(entity, session),
persister
);
return snapshot == null;
}
...
}
여기서 조건문을 타면서, 엔티티가 정말 비영속 상태가 아닌지 판단한다.
첫 번째 조건문인 entity == LazyPropertyInitializer.UNFETCHED_PROPERTY
는 지연 로딩이 되었는지 확인하는 조건문이다.
해당 엔티티는 지연 로딩 상태가 아니므로 무시된다.
두 번째 조건문에서 session.getInterceptor().isTransient(entity)
메서드를 호출할 때 해당 메서드는 다음과 같이 구현되어 있다.
default Boolean isTransient(Object entity) {
return null;
}
따라서 해당 조건문은 무시된다.
그리고 다음 조건문인 persister.isTransient(entity, session)
메서드가 있다.
해당 메서드가 바로 엔티티의 식별자 생성 전략에 따라 쿼리를 날리게 하는 원인의 핵심이다!
public abstract class AbstractEntityPersister implements ... {
@Override
public Boolean isTransient(Object entity, SharedSessionContractImplementor session) throws HibernateException {
final Object id = getIdentifier(entity, session);
// we *always* assume an instance with a null
// identifier or no identifier property is unsaved!
if (id == null) {
return true;
}
// check the version unsaved-value, if appropriate
if (isVersioned()) {
...
}
// check the id unsaved-value
final Boolean result = identifierMapping.getUnsavedStrategy().isUnsaved(id);
if (result != null) {
return result;
}
// check to see if it is in the second-level cache
if (session.getCacheMode().isGetEnabled() && canReadFromCache()) {
final EntityDataAccess cache = getCacheAccessStrategy();
final String tenantId = session.getTenantIdentifier();
final Object ck = cache.generateCacheKey(id, this, session.getFactory(), tenantId);
final Object ce = CacheHelper.fromSharedCache(session, ck, getCacheAccessStrategy());
if (ce != null) {
return false;
}
}
return null;
}
}
isTransient
메서드에서 identifierMapping.getUnsavedStrategy().isUnsaved(id)
메서드를 호출하는데, 생성 전략의 유무에 따라 getUnsavedStrategy()
메서드의 반환 객체가 다르다.
IdentifierValue
는 어플리케이션 로딩 시점에 생성되는데, UnsavedValueFactory
클래스에서 엔티티의 @GeneratedValue
에 따라 생성된다.
public class UnsavedValueFactory {
public static IdentifierValue getUnsavedIdentifierValue(...) {
final String unsavedValue = bootIdMapping.getNullValue();
if (unsavedValue == null) {
if (getter != null && templateInstanceAccess != null) {
final Object templateInstance = templateInstanceAccess.get();
final Object defaultValue = getter.get(templateInstance);
return new IdentifierValue(defaultValue);
}
...
}
...
else if ("undefined".equals(unsavedValue)) {
return IdentifierValue.UNDEFINED;
}
...
}
}
@GeneratedValue
가 설정되어 있는 엔티티는, unsavedValue
가 null으로 반환되고, defaultValue
도 null이 반환되어 새로운 IdentifierValue
인스턴스를 가진다.
@GeneratedValue
가 설정되어 있지 않은 엔티티는 unsavedValue
가 undefined
으로 반환되어, IdentifierValue.UNDEFINED
인스턴스를 가진다.
IdentifierValue.UNDEFINED
는 정적 변수로 선언된 무명 클래스이며, 다음과 같이 isUnsaved()
메서드가 재정의 되어 있다.
public static final IdentifierValue UNDEFINED = new IdentifierValue() {
@Override
public Boolean isUnsaved(Object id) {
LOG.trace( "ID unsaved-value strategy UNDEFINED" );
return null;
}
...
}
IdentifierValue
클래스의 기본 isUnsaved()
구현은 다음과 같이 되어 있다.
public class IdentifierValue implements UnsavedValueStrategy {
...
@Override
public Boolean isUnsaved(Object id) {
LOG.tracev( "ID unsaved-value: {0}", value );
return id == null || id.equals( value );
}
...
}
따라서 @GeneratedValue
가 설정되어 있다면 result
가 False
가 반환되고, @GeneratedValue
가 설정되어 있지 않다면 result
가 null
이 반환된다.
2차 캐시 활성화에 따라 result가 False로 반환될 수 있다!
즉, @GeneratedValue
의 유무에 따라 ForeignKeys.isTransient()
에서 분기가 갈라지게 된다.
public final class ForeignKeys {
...
public static boolean isTransient(String entityName, Object entity, Boolean assumed, SharedSessionContractImplementor session) {
...
isUnsaved = persister.isTransient(entity, session);
if (isUnsaved != null) {
// @GeneratedValue가 설정되면 여기서 return 된다.
return isUnsaved;
}
// we use the assumed value, if there is one, to avoid hitting
// the database
if (assumed != null) {
return assumed;
}
// hit the database, after checking the session cache for a snapshot
final Object[] snapshot = session.getPersistenceContextInternal().getDatabaseSnapshot(
persister.getIdentifier(entity, session),
persister
);
return snapshot == null;
}
...
}
assumed 매개변수는
action.noCascade()
메서드 호출에서 인자로 null이 넘어오므로 무시된다.
그리고 session.getPersistenceContextInternal().getDatabaseSnapshot()
메서드를 호출하면 문제의 쿼리가 발생한다.
public class StatefulPersistenceContext implements PersistenceContext {
...
@Override
public Object[] getDatabaseSnapshot(Object id, EntityPersister persister) throws HibernateException {
final EntityKey key = session.generateEntityKey(id, persister);
final Object cached = entitySnapshotsByKey == null ? null : entitySnapshotsByKey.get key);
if (cached != null) {
return cached == NO_ROW ? null : (Object[]) cached;
}
else {
final Object[] snapshot = persister.getDatabaseSnapshot(id, session);
if (entitySnapshotsByKey == null) {
entitySnapshotsByKey = CollectionHelper.mapOfSize(INIT_COLL_SIZE);
}
entitySnapshotsByKey.put(key, snapshot == null ? NO_ROW : snapshot);
return snapshot;
}
}
...
}
정확히는 persister.getDatabaseSnapshot(id, session)
해당 메서드를 호출할 때이다.
이름부터가 데이터베이스에 스냅샷을 가져온다고 되어 있다...
아무튼 이렇게 @GeneratedValue
의 유무에 따라 왜 정체불명의 SELECT 쿼리가 발생하는지 알 수 있었다.
생각해보면 당연할 수 있는 것이, 식별자가 자동으로 할당되지 않는 준영속 상태의 엔티티가 연관 관계를 맺고 있다면, JPA는 이 엔티티가 식별자가 있더라도 정말 준영속 상태인지, 비영속 상태인지 알 방법이 없다.
따라서 SELECT 쿼리를 날려 준영속 상태인지 확인을 해보는 것이다..!
비즈니스 로직에서 News
는 준영속 상태인 것이 확실하니까 SELECT 쿼리를 날리지 않아도 된다.
그렇다고 Hibernate 코드를 뜯어고치는 것은 불가능하므로, 다른 대안을 생각해야 했다.
News
의 식별자에 @GeneratedValue
를 사용하면 가장 빠르게 해결할 수 있겠지만, 식별자를 사용할 때 null 핸들링이 필요하므로 이 방법은 최후의 보루로 남겨두기로 했다.
생각해 보면 Content
를 저장하는 시점에 Content
의 News
를 트랜잭션 범위에 포함시키면 noCascade()
에서 ForeignKeys.isTransient()
메서드를 호출하지 않으므로 SELECT 쿼리가 발생할 일이 없다.
즉, 비즈니스 로직을 조금만 수정하면 된다.
class TranslationCommandService {
@Transactional
fun translate(command: TranslationCommand) {
val (newsId, sourceLanguage, targetLanguage) = command
val news = newsRepository.findByIdAndLanguage(newsId, sourceLanguage)
val content = news.getContentByLanguage(sourceLanguage)
translatorClient.requestTranslate(content.toRequest(targetLanguage)) // Content -> DTO
.subscribe { it: TranslateResponse -> // DTO
eventPublisher.publishEvent(NewsAppendContentEvent(newsId, it))
}
}
private fun Content.toRequest(targetLanguage: Language): TranslateRequest {
...
}
}
...
class NewsAppendContentEventListener {
@Async
@EventListener
@Transactional
fun appendContentEventHandler(event: NewsAppendContentEvent) {
val (newsId, translateResponse) = event
try {
val news = newsRepository.getOrThorw(newsId)
val content = translateResponse.toContent(news) // DTO -> Entity
news.addContent(content)
} catch (e: Exception) {
log.error { "뉴스에 컨텐츠를 추가하는 중 예외가 발생했습니다. ${e.message}" }
}
}
}
이렇게 구현하면, Content
의 News
가 1차 캐시에 있으므로, 준영속 상태인지, 비영속 상태인지 판단될 일이 없다!
또한, 이전 구현에서는 DTO가 엔티티를 알고 있게 되므로, 클린 아키텍쳐 관점에서 취약한 구조로 되어 있었다.
그리고 한 가지 방법이 더 있는데, Content
가 News
엔티티를 연관 관계로 직접 참조하는 것이 아닌, 식별자로 간접적으로 참조하게 하면 된다.
@Entity
class News(
...
@OneToMany(cascade = [CascadeType.PERSIST], mappedBy = "newsId")
private val contents: MutableList<Content> = mutableListOf()
...
)
@Entity
class Content(
...
@JoinColumn(name = "news_id", nullable = false)
val newsId: UUID = newsId
...
)
이제 연관 관계를 가진 News
는 엔티티가 아니므로, noCascade()
메서드 호출이 무시된다.
public static final CascadingAction<PersistContext> PERSIST_ON_FLUSH = new BaseCascadingAction<>() {
...
@Override
public void noCascade(...) {
if (propertyType.isEntityType()) { // false
...
}
}
...
}
Content
에서 News
엔티티는 외래키의 주인을 설정하기 위한 필드일 뿐이다.
따라서 나는 위의 두 방법을 모두 적용하였다.
엔티티를 효과적으로 영속하려고 Cascade를 사용하였는데, 알 수 없는 SELECT 쿼리가 발생하였다.
덕분에 몇 시간 동안 디버깅으로 삽질하며 Hibernate의 내부 코드를 많이 들여다보았다.
우테코를 수료하고 간만에 프레임워크 코드를 깊숙히 들여다보는 경험을 했는데, 디버깅 기능이 정말 큰 도움이 되었다.
예전 스프링 코드를 뜯어봤을 때 디버깅 기능을 사용할 줄 몰라서 직접 메소드를 파헤치다 중간에 포기했던 경험이 생각난다. 😂
그래도 덕분에 이전에 실험적으로만 알았던 @GeneratedValue
의 유무에 따라 비영속 상태인지, 영속 상태인지 구분하는 기준을 코드로 확인할 수 있었다.
추상화가 잘 되어있는 코드는 그만큼 구현 코드가 매우 복잡한 것 같다.
(사실 Hibernate 코드가 정말 더러워서 중간에 포기할까 싶기도 했다. 😂)
원인을 찾고, 해결 방안을 찾아보니, 결론은 클린 아키텍쳐를 신경 쓰지 않은 나의 문제였다. 😂😂😂
의존성을 최소화하고 코드를 잘 작성하는 것이 정말 중요한 능력이라고 다시 한번 느낄 수 있었다.
오.. 내부 디버깅까지 해서 이유를 찾아낸 것 멋지네요. 평소에 GeneratedValue를 습관적으로 쓰다 보니 전혀 몰랐던 내용입니다. 하나 배우고 갑니다!