프로젝트를 진행하다보면 다양한 오류를 만난다. 대부분 사소한 것들이지만, 그중에는 기술의 원리를 이해하고 있어야만 원인을 파악할 수 있는 것들도 있다.
이번에 프로젝트를 진행하면서 @OnDelete(CASCADE)를 사용하다가 TransientObjectException 등 예상치 못한 여러 문제에 부딪혔다. 단순한 JPA Cascade와는 전혀 다른 동작 원리 때문에 생긴 문제였다. 이번 글에서는 이 예외가 발생한 이유와 해결 방법을 공유하고자 한다.
삭제 전파를 위한 여러가지 선택지 중 OnDelete를 사용한 이유는 양방향 연관관계를 맺지 않고 현재의 코드에서 가장 적은 변경으로 삭제 전파를 이용하기 위함이었다.
이 글의 대상 독자
- 하이버네이트 OnDelete를 이용한 CASCADE 제약조건을 건 개발자
- 프로젝트 진행 중 TransientObjectException을 맞딱드린 개발자
- JPA에 대한 얕은 지식이 있는 개발자
배경
우리 팀은 DataJpaTest 혹은 @SpringBootTest와 @Transactional로 서비스 레이어를 테스트하고 있다. 지금 설명할 코드는 @SpringBootTest와 @Transactional을 사용했다.
문제가 발생한 테스트는 특정 엔티티를 삭제할 때 그 엔티티와 연관관계를 맺고 있는 여러 엔티티가 한 번에 삭제되는지 확인하기 위해서 작성했다. (이 글에서는 이해하기 쉽게 두 개의 엔티티만 사용해서 설명한다.) 모든 연관관계에는 CASCADE 설정이 추가되어 있었다.
유의할 것은 OnDelete 설정을 통한 CASCADE는 JPA 내에서 처리되는 것이 아니라 데이터베이스 상에서만 처리된다는 것이다. 하이버네이트의 고유한 기능이기 때문에 JPA 내부 동작을 처리하지 않고 DDL의 제약조건만을 수정하게 된다.
@ManyToOne
@JoinColumn(nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Mentoring mentoring;

문제 발생 1 - TransientObjectException
이 테스트코드를 실행시켰을 때 다음과 같은 예외가 터졌다.
@Transactional
@DisplayName("관리자가 멘토링을 삭제할 수 있다.")
@Test
void deleteByAdmin() {
// given
...
mentoringRepository.save(mentoring);
categoryMentoringRepository.save(categoryMentoring1_1);
categoryMentoringRepository.save(categoryMentoring2_1);
// when
mentoringService.deleteMentoringByAdmin(adminLoginId, mentoringId);
// then
assertThatThrownBy(() -> mentoringService.getMentoringWithRelations(mentoringId))
.isInstanceOf(MentoringNotFoundException.class);
assertThat(categoryMentoringRepository.findTitlesByMentoringId(mentoringId))
.isEmpty();
}
org.hibernate.TransientObjectException: persistent instance references an unsaved transient instance of 'fittoring.mentoring.business.model.Mentoring' (save the transient instance before flushing)
TransientObjectException는 영속화 된 엔티티가 영속화되지 않은 엔티티를 참조하고 있을 때 발생하는 예외이다. 단순히 로그로만 확인한 문제는 Mentoring이 영속화되지 않았는데, 영속화된 엔티티가 이를 참조하고 있어서 생기는 문제였다.
앞선 코드를 보면 Repository를 이용하여 save()하고 있는 모습을 확인할 수 있다. JpaRepository의 save()는 persist() 혹은 merge()를 내포하고 있다. persist()의 경우 호출 시점에 이미 영속화 상태가 된다.

mentoringRepository.save(mentoring);
categoryMentoringRepository.save(categoryMentoring1_1);
categoryMentoringRepository.save(categoryMentoring2_1);
원인 분석
그렇다면 무엇이 원인이었을까? 그 아래의 코드를 살펴보자.
예외가 터진 것은 categoryMentoringRepository.findTitlesMyMentoringId(mentoringId) 부분이었다. 즉, CategoryMentoring이 Mentoring을 참조하는 상황에서 Mentoring이 영속 상태가 아니기 때문에 발생한 문제라고 추측할 수 있었다. removed 상태인 Mentoring을 CategoryMentoring이 참조하는 상태였기 때문이다.
// then
assertThatThrownBy(() -> mentoringService.getMentoringWithRelations(mentoringId))
.isInstanceOf(MentoringNotFoundException.class);
assertThat(categoryMentoringRepository.findTitlesByMentoringId(mentoringId))
.isEmpty();
Transactional이 테스트 최상위 메서드에 묶여있기 때문에 JPA가 delete 쿼리 실행 시 곧바로 flush()하지 않고 1차 캐시에 있는 데이터를 removed 상태로 변경한다.

이 때 then 절의 조회가 flush를 트리거하기 때문에 이 시점에 mentoring이 1차 캐시에서 사라지게 된다.

그러면서 1차 캐시에 혼자 남게된 categoryMentoring에서 TransientObjectException이 발생하게 된 것이다.
TransientObjectException은 영속성 컨텍스트에서 removed 상태의 엔티티도 더 이상 영속적으로 취급하지 않기 때문에, 이를 참조하는 다른 엔티티가 flush 과정에서 비영속(Transient) 참조처럼 간주되어 발생했다.
기술적인 배경:
1. Hibernate @OnDelete는 DB에서만 작동하며 1차 캐시에는 영향이 없다.
2. JPA delete()는 곧바로 DB에 DELETE를 날리지 않고, 엔티티 상태를 removed로만 마킹한다.
4. removed 상태의 부모를 참조하는 자식이 있으면 flush() 시 참조 무결성 검사에서 TransientObjectException 발생 가능하다.
해결 방법
이러한 문제를 해결하기 위해서 delete 쿼리 실행 전 clear()를 통해 1차 캐시를 비우는 동작을 추가했다. 이렇게 하면 1차 캐시에 아무 값도 남지 않게 된다. 따라서 1차 캐시와 DB의 제약조건 차이를 극복할 수 있을 것이라고 생각했다.
문제 발생 2 - 엔티티가 삭제되지 않음
clear() 추가 후 실행 결과는 다음과 같았다.
Expecting empty but was: ["카테고리1", "카테고리2"]
java.lang.AssertionError:
Expecting empty but was: ["카테고리1", "카테고리2"]
TransientObjectException는 사라졌지만 테스트에 실패했다. 어떻게 된 일일까? Mentoring이 삭제될 때 CategoryMentoring은 삭제되지 않은 것일까?
원인 분석
원인을 파악하기 위해서 clear() 동작을 포함한 지금까지의 흐름을 정리해봤다.
다섯 단계의 흐름으로 코드를 나눌 수 있다. 순서대로 살펴보자.


가장 먼저 엔티티를 저장한다.

다음으로 1차 캐시의 값을 비운다.

세번째 과정이 가장 의심스러웠다. 1차 캐시에 엔티티가 존재하지 않을 때 JpaRepository의 delete()는 어떤 식으로 엔티티를 삭제할까?
추측하는 두 가지 방법이 있었다.
- 1차 캐시에 엔티티가 없다면 merge() 후에 조회된 엔티티를 removed 상태로 변경한다.
- 1차 캐시에 엔티티가 없다면 DB에 곧바로 delete 쿼리를 날린다.
처음에는 1차 캐시에 엔티티가 없다면 DB에 곧바로 쿼리를 날릴거라고 예상했다.
그러나 문제가 발생했다면 높은 확률로 1차 캐시와 연관된 첫 번째 방식으로 동작했을 것이다. 따라서 첫번째 방식으로 삭제한다고 가정하고 이어나가보자. 첫번째 테스트는 성공했다.
removed로 마킹된 상태이기 때문에 조회해도 아무 결과가 나오지 않는 것은 자연스럽다. 1차 캐시에 removed된 상태를 보여주면 되기 때문이다.

그렇다면 categoryMentoring을 조회하면 어떻게 될까?

앞서 OnDelete 설정을 통한 CASCADE는 JPA 내에서 처리되는 것이 아니라 데이터베이스 상에서만 처리된다고 이야기했다.
clear() 이후 delete()를 호출하면, 하이버네이트는 mentoring만 다시 영속화(merge)한 뒤 removed 로 마킹한다. 이때 자식들은 1차 캐시에 없으므로 JPA 전파(cascade=REMOVE)는 작동하지 않는다.
getMentoringWithRelations(..)가 @Transactional(readOnly = true) 로 실행되면서 세션 FlushMode가 MANUAL로 낮아진다. 그 결과 삭제 결과는 1차 캐시에만 남아 있고 DB로는 flush되지 않으며, 따라서 부모 DELETE 쿼리가 발생하지 않는다. 부모 DELETE가 DB에 도달하지 않았으니 DB의 ON DELETE CASCADE도 발동하지 않고, categoryMentoring이 그대로 조회된다.
@Transactional(readOnly = true)는 내부적으로 flush를 생략하도록 하여 성능을 높인다.
그러나 이 경우 delete로 마킹된 엔티티가 실제 DB에 반영되지 않아서, DB 레벨의 ON DELETE CASCADE 가 작동하지 않는다.
이 글을 보고있는 여러분이라면 이 문제를 어떻게 해결할 것인가?
해결 방법
이러한 문제 역시 DB와 1차 캐시의 불일치로 인해 생기는 문제이다. flush()를 delete()가 아닌 그 외의 코드에 암묵적으로 의존하게 하기 때문에 DB에 실제 반영되는 시점을 파악하기 어렵다는 문제도 있다.
따라서 delete() 직후 수동으로 flush()를 하는 것으로 문제를 해결할 수 있었다.
@Transactional
@DisplayName("관리자가 멘토링을 삭제할 수 있다.")
@Test
void deleteByAdmin() {
// given
...
mentoringRepository.save(mentoring);
categoryMentoringRepository.save(categoryMentoring1_1);
categoryMentoringRepository.save(categoryMentoring2_1);
em.clear();
// when
mentoringService.deleteMentoringByAdmin(adminLoginId, mentoringId);
em.flush();
// then
assertThatThrownBy(() -> mentoringService.getMentoringWithRelations(mentoringId))
.isInstanceOf(MentoringNotFoundException.class);
assertThat(categoryMentoringRepository.findTitlesByMentoringId(mentoringId))
.isEmpty();
}
배운 점
이러한 문제가 발생한 원인은 flush()의 발생 시점을 제대로 파악하지 않은 채 코드를 작성했기 때문이다. 따라서 동일한 문제를 예방하기 위해서는 FlushMode의 변경 시점과 flush() 발생 시점을 명확히 파악할 필요가 있다.
한가지 깨달은 점은 @Transactional(readOnly = true)는 성능 최적화를 위해 flush를 억제하기 때문에 크게 신경쓰지 않고 사용했다. 하지만 삭제 직후와 같이 DB 반영이 필요한 시점에서는 예상치 못한 불일치가 생길 수 있다. 따라서 테스트나 삭제 로직에서는 readOnly 트랜잭션을 조심해야 한다는 점이다.
문제 해결을 어렵게 했던 또 하나의 원인은 JPA Cascade와 DB Cascade 차이였다. cascade=REMOVE는 JPA 영속성 컨텍스트 내 엔티티에만 영향을 준다. 반면 @OnDelete(CASCADE)는 DB 외래 키 제약 조건으로만 작동한다. 둘을 혼동하면 “왜 자식이 안 지워지지?” 같은 상황에 빠진다. (나도 빠졌다)
또한 지금과 같이 테스트코드에서 트랜잭션이 간접적으로 문제를 끼친 경우 트랜잭션 롤백에만 의존하지 말고, 필요하다면 직접 DB를 초기화하거나 em.flush()/em.clear()를 적절히 호출해 상태를 제어하는 것이 바람직하다!
우리 팀도 이번 사건을 통해 트랜잭션 롤백에 의존하지 않고 직접 DB를 초기화하는 방식으로 테스트코드를 변경했다.
네 줄 요약
- @OnDelete(CASCADE)는 DB에서만 동작한다. JPA 영속성 컨텍스트와는 별개다.
- @Transactional(readOnly = true)는 flush를 억제하므로 delete 이후 DB 반영에 주의해야 한다.
- 삭제 로직에서는 flush 시점을 명확히 제어해야 한다.
- JPA cascade와 DB cascade는 전혀 다른 개념이다! 혼동하지 말 것.
