JPA Cascade로 엔티티를 영속할 때 SELECT 쿼리가 발생하는 이슈

Glen·2024년 1월 3일
1

TroubleShooting

목록 보기
4/6

문제 발생

프로젝트에서 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에 요청을 보내고, 번역된 ContentNewsAppendContentEvent 이벤트를 발행시켜서 기존 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,

    ...
) {
    ...
}

비즈니스 로직에서 NewsContent 의 생명 주기가 같기 때문에, Content를 영속할 때 굳이 ContentRepository를 만들지 않고 @OneToMany의 Cascade를 사용하여 영속하였다.

이렇게 하면 Content를 영속할 때 검증 로직을 도메인에 구현할 수 있으니, 응집도를 높이기 위함도 있다.

또한, News는 식별자를 @GeneratedValue를 사용하지 않고, 서버에서 직접 UUID를 할당하여 생성해 주었다.

@GeneratedValue를 사용하지 않은 이유는 제약이 너무 많아서였다.

첫 번째로 식별자를 알고 싶으면 데이터베이스에 무조건 저장이 되어야 했다.

두 번째로 엔티티의 식별자가 nullable한 값이므로 null 처리를 해야 하는 로직이 강제되었다.

Content@GeneratedValue를 사용했는데, 이유는 Content의 식별자를 사용할 일이 없기 때문이다.

따라서 식별자를 자주 사용해야 하는 News는 식별자를 어플리케이션에서 생성하도록 하였고, 식별자를 사용할 일이 없는 Content@GeneratedValue를 사용했다.

그리고 News의 영속 과정에서 merge()가 되는 것을 막기 위해 Persistable 인터페이스를 구현하였다.

원인 추론

WebClient

처음엔 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개가 발생하고 있었고 비동기 처리 때문에 발생하는 문제가 아니었다.

merge()

그다음은 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가 설정되어 있지 않은 엔티티는 unsavedValueundefined으로 반환되어, 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가 설정되어 있다면 resultFalse가 반환되고, @GeneratedValue가 설정되어 있지 않다면 resultnull이 반환된다.

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를 저장하는 시점에 ContentNews를 트랜잭션 범위에 포함시키면 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}" }  
        }
    }
}

이렇게 구현하면, ContentNews가 1차 캐시에 있으므로, 준영속 상태인지, 비영속 상태인지 판단될 일이 없다!

또한, 이전 구현에서는 DTO가 엔티티를 알고 있게 되므로, 클린 아키텍쳐 관점에서 취약한 구조로 되어 있었다.

그리고 한 가지 방법이 더 있는데, ContentNews 엔티티를 연관 관계로 직접 참조하는 것이 아닌, 식별자로 간접적으로 참조하게 하면 된다.

@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 코드가 정말 더러워서 중간에 포기할까 싶기도 했다. 😂)

원인을 찾고, 해결 방안을 찾아보니, 결론은 클린 아키텍쳐를 신경 쓰지 않은 나의 문제였다. 😂😂😂

의존성을 최소화하고 코드를 잘 작성하는 것이 정말 중요한 능력이라고 다시 한번 느낄 수 있었다.

profile
꾸준히 성장하고 싶은 사람

3개의 댓글

comment-user-thumbnail
2024년 1월 8일

오.. 내부 디버깅까지 해서 이유를 찾아낸 것 멋지네요. 평소에 GeneratedValue를 습관적으로 쓰다 보니 전혀 몰랐던 내용입니다. 하나 배우고 갑니다!

1개의 답글
comment-user-thumbnail
2024년 2월 14일

고민하던 내용인데 깊게 탐구하신 내용 보고 많은 정보 알아갑니다!!

답글 달기