
📖 배경
우리 프로젝트는 Member와 Talk 모두 soft delete로 설계한다. 멤버가 탈퇴하더라도 프로필만 “알 수 없음”으로 노출하고, 현장톡 메시지 자체는 보존해야 하기 때문이다. 따라서 다음과 같이 매핑했다.
- 🧑🤝🧑 Member: 탈퇴하더라도 db상엔 정보 존재
- 💬 Talk: 메시지는 삭제하지 않고 보존
// Member
@SQLDelete(sql = "UPDATE members SET deleted_at = now() WHERE member_id = ?")
// Talk
@SQLDelete(sql = "UPDATE talks SET deleted_at = now() WHERE talk_id = ?")
@SQLRestriction("deleted_at IS NULL") // 조회 시 자동 필터링
⚠️ 문제 상황
테스트 시나리오는 다음과 같았다.
- firstEnterMember: 채팅방에 들어왔고, 정상적으로 채팅을 조회하는 사람
- expectedMessageWriter: 채팅방에 채팅을 남긴 사람
- soonLeftMember: 곧 탈퇴할 사람이지만 채팅방에 채팅 남긴 사람
기대했던 동작은, soonLeftMember이 탈퇴하고 톡을 조회했을 때, soonLeftMember이 남겼던 메세지가 조회되어야 하는 것이었다.
@DisplayName("위로 슬라이드 하는 과정에서 이전 채팅 기록 가져오기 - 소프트 딜리트")
@Test
void findFirstPage_whenCursorExists_returnsLeftMember_softDelete() {
// given
int limit = 2;
Long cursorId = null;
Team expectedHomeTeam = teamRepository.findByTeamCode("LT").orElseThrow();
Member firstEnterMember = memberFactory.save(
builer -> builer.team(expectedHomeTeam)); // 처음 들어온 사람 (정상적으로 채팅방 조회하는 사람)
Stadium expectedStadium = stadiumRepository.findByShortName("사직구장").orElseThrow();
Team expectedAwayTeam = teamRepository.findByTeamCode("HH").orElseThrow();
Game game = gameFactory.save(builder -> builder
.homeTeam(expectedHomeTeam)
.awayTeam(expectedAwayTeam)
.stadium(expectedStadium));
Member expectedMessageWriter = memberFactory.save(builder -> builder.team(expectedAwayTeam)); // 정상적인 멤버, 톡1개남김
talkFactory.save(builder -> builder.member(expectedMessageWriter).game(game));
Member soonLeftMember = memberFactory.save(
builder -> builder.team(expectedAwayTeam)); // 곧 탈퇴할 멤버 (soft deleted), 톡1개남김
Talk remainedTalkByLeftMember = talkFactory.save(builder -> builder.member(soonLeftMember).game(game));
memberRepository.delete(soonLeftMember);
// when
TalkCursorResult result = talkService.findTalksExcludingReported(
game.getId(),
cursorId,
limit,
firstEnterMember.getId()
);
// then
assertSoftly(softAssertions -> {
...
});
}
해당 테스트에서 아래 부분을 주의해서 봐주길 바란다.
Talk remainedTalkByLeftMember = talkFactory.save(builder -> builder.member(soonLeftMember).game(game));
memberRepository.delete(soonLeftMember);
원래는 이 부분에서 soonLeftMember가 잘 삭제되었고, 테스트가 잘 통과했다. 하지만 soft delete로 바꾼 후 실행하니 아래 예외가 터졌다.

즉, Hibernate가 “Member를 먼저 저장하라”고 말했다.
이 원인을 알기 위해 먼저 JPA 엔티티가 가지는 상태를 알아야 한다.
JPA 엔티티 상태
JPA에서 엔티티는 단순히 “있다/없다”로만 다뤄지지 않는다. 영속성 컨텍스트라는 관리 단위를 기준으로 네 가지 상태를 가진다.
바로 비영속(Transient), 영속(Managed), 삭제 예약(Removed), 준영속(Detached) 이다.
🟡 비영속(Transient)
비영속(Transient) 상태는 new 키워드로 객체를 만든 시점이다.
예를 들어 new Member("fora")라고 객체를 하나 만들면, 이 시점에서는 JPA가 이 객체를 전혀 모른다. 아직 persist()도 하지 않았고 DB에도 저장되지 않았으니, 단순히 메모리에만 존재한다. 이 상태를 Transient라고 한다.
🟢 영속(Managed)
persist()를 호출하면 엔티티는 영속(Managed) 상태가 된다. 영속 상태가 되면 JPA가 해당 엔티티를 1차 캐시에 보관하고, 변경을 추적한다. 즉, 필드 값을 바꾸기만 해도 flush 시점에 자동으로 UPDATE SQL이 생성된다.
예를 들어 member.setName("승연리")라고 값을 바꾸면, 별도의 save 호출 없이 flush 시점에 자동으로 반영된다. 이것이 바로 변경 감지(Dirty Checking)다.
🔴 삭제 예약(Removed)
반대로, remove()를 호출하면 엔티티는 삭제 예약(Removed) 상태가 된다. 이 상태에서는 flush 시점에 DELETE SQL(혹은 soft delete라면 UPDATE SQL)이 실행된다. 하지만 중요한 점은, 이 상태에서도 엔티티는 여전히 영속성 컨텍스트 안에 남아 있다는 것이다. 따라서 다른 엔티티가 이 멤버를 참조하고 있으면 flush 과정에서 “삭제된 걸 참조하면 안 되지 않냐?”라며 예외가 발생할 수 있다.
⚪ 준영속(Detached)
마지막으로, clear()나 detach()를 호출하면 엔티티는 준영속(Detached) 상태로 바뀐다.
예를 들어, persist()로 저장한 멤버를 clear한 뒤 이름을 바꿔도 JPA는 더 이상 이 엔티티를 추적하지 않는다. flush 시점에 SQL도 발생하지 않는다. Detached 상태의 엔티티를 다시 관리하고 싶다면 em.merge()를 써야 한다.
🔍 원인 분석
원인은 바로 Member soft delete 때문이었다.
- memberRepository.delete(soonLeftMember)를 호출하면, Hibernate는 soonLeftMember를 Removed 상태로 바꾼다. soft delete라 실제 SQL은 UPDATE deleted_at = now()지만, 영속성 컨텍스트 내부에서는 여전히 삭제 예약(Removed)으로 취급된다.
- 그런데 같은 영속성 컨텍스트 안에는 아직 Managed 상태의 Talk이 존재한다. 이 Talk은 soonLeftMember를 참조하고 있다.
- flush가 발생하면 Hibernate는 영속성 컨텍스트를 디비랑 동기화하면서 관계 그래프의 일관성을 검사한다.
- 규칙: Managed 엔티티는 Removed/Transient 엔티티를 참조할 수 없음.
- 결과: Talk이 이미 삭제 예약된 Member를 참조하고 있으니, Member를 먼저 save해야 한다는 식의 예외가 발생.
🛠️ 해결 과정
이를 해결하기 위해 처음에는 이렇게 수정했다.
Talk remainedTalkByLeftMember = talkFactory.save(builder -> builder.member(soonLeftMember).game(game));
memberRepository.delete(soonLeftMember);
em.flush();
em.clear();
테스트 코드가 통과하겠다고 생각했지만, 여전히 같은 오류가 발생했다.
이 해답은 flush의 특성에 있다.
flush는 영속성 컨텍스트의 변경 내용을 db에 반영하지만, 컨텍스트 자체는 비우지 않는다. 그럼 이 역할은 누가 할까?
clear가 호출되어야만 영속성 컨텍스트가 비워지고, 엔티티들이 Detached 상태가 된다.
따라서 Member를 delete하기 전에 이미 flush+clear 해주지 않으면, 영속성 컨텍스트 안에 Talk <-> soonLeftMember의 관계가 남아 있어 충돌이 계속 발생한다.
✅ 최종 해결
이 것을 깨닫고 다음에는 테스트코드를 이렇게 작성했다.
em.flush();
em.clear();
Talk remainedTalkByLeftMember = talkFactory.save(builder -> builder.member(soonLeftMember).game(game));
memberRepository.delete(soonLeftMember);
em.flush();
em.clear();
이제서야 테스트가 통과됐다.

🎓 배운 점
- flush는 DB 동기화만 할 뿐, 영속성 컨텍스트의 참조 관계를 해제하지 않는다.
- clear가 있어야만 컨텍스트가 비워져 안전하게 삭제를 진행할 수 있다.
- soft delete를 delete()로 구현하면 이런 문제가 반복적으로 발생할 수 있다.
이번 경험으로 “트랜잭션은 하나의 작업 단위”라는 말이 왜 중요한지 다시 느꼈다. flush/clear의 역할을 몸소 겪으면서, 레벨2때 배웠던 이론이 실제 문제 해결에 도움된다는 것도 흥미로웠다. 이후 soft delete 관련 테스트를 작성할 때는 flush와 clear를 꼭 염두에 두고 설계해야 할 것이다.
'공뷰 > Spring' 카테고리의 다른 글
| [Spring, HikariCP] 커넥션 풀 고갈로 인한 서버 병목 해결하기 (0) | 2025.11.08 |
|---|---|
| [Spring] 야구보구의 실시간 기능, 배포 후 발생한 SSE 문제 정리(nginx 설정) (2) | 2025.10.05 |
| [Spring] 야구보구 홈 화면 SSE(Server-Sent Events)로 실시간 팬 현황 갱신하기 (5) | 2025.09.15 |
| [Spring] 야구보구의 로깅 시스템 구축하기 (6) | 2025.08.28 |
| [Spring] 스프링부트 개발 환경에 따른 application.yml 환경 분리(local, dev, prod) (1) | 2025.07.27 |