Spring Data JPA의 숨겨진 함정: 삭제 후 삽입 시 중복 키 오류
게시글 수정 기능에서 태그를 변경한 후 저장하면 오류가 발생하는 문제가 있었습니다. 이 문제를 해결한 후 내용을 정리해봤습니다.
Spring Data JPA의 숨겨진 함정: 삭제 후 삽입 시 중복 키 오류
TL;DR: Spring Data JPA의 메서드 이름 기반 삭제(
deleteByXxx)는 조회 후 엔티티 삭제 경로로 동작합니다. Hibernate는 flush 시 ActionQueue에서 INSERT를 DELETE보다 먼저 실행할 수 있어, 같은 트랜잭션 내에서 삭제 직후 동일한 유니크 키로 삽입하면 중복 키 오류가 발생합니다. 해결책은@Query를 사용한 JPQL 벌크 삭제입니다. 단,clearAutomatically옵션 사용 시 주의가 필요합니다.
문제 상황
게시글 수정 기능에서 태그를 변경하면 다음과 같은 오류가 발생했습니다:
org.springframework.dao.DataIntegrityViolationException: could not execute statement [Duplicate entry '11310-gcp' for key 'unified_post_tag.uk_post_tag']
재현 조건:
- 게시글에
[java, spring, gcp]태그가 있음 - 게시글 수정 시 동일한 태그
[java, spring, gcp]를 그대로 저장 - 중복 키 오류 발생!
분명히 기존 태그를 삭제하고 새 태그를 삽입하는 로직인데, 왜 중복 키 오류가 발생할까요?
코드 분석
태그 동기화 로직
/** * 태그 정규화 테이블 동기화 * 전략: 전체 삭제 후 재삽입 (태그 최대 5개이므로 단순 전략 채택) */ private void syncTagsInNormalizedTable(UnifiedPost post, List<String> tags) { // 1. 기존 태그 삭제 postTagRepository.deleteByPostId(post.getId()); // 2. 새 태그 저장 if (tags == null || tags.isEmpty()) { return; } List<UnifiedPostTag> postTags = tags.stream() .map(tag -> UnifiedPostTag.of(post, tag)) .collect(Collectors.toList()); postTagRepository.saveAll(postTags); // 💥 여기서 중복 키 오류! }
단순해 보이는 코드입니다. 삭제 후 삽입인데 왜 문제가 될까요?
Repository 코드
@Repository public interface UnifiedPostTagRepository extends JpaRepository<UnifiedPostTag, Long> { @Modifying(clearAutomatically = true, flushAutomatically = true) void deleteByPostId(Long postId); // 메서드 이름 기반 삭제 }
@Modifying에 flushAutomatically = true까지 설정했는데... 문제가 뭘까요?
원인: 파생 삭제와 Hibernate ActionQueue
파생 삭제(Derived Delete)의 동작 방식
Spring Data JPA의 deleteByPostId()는 메서드 이름을 파싱하여 쿼리를 생성하는 파생 삭제(Derived Delete) 방식입니다. 이 방식은 @Modifying을 붙여도 벌크 삭제로 변환되지 않습니다.
파생 삭제는 항상 다음 순서로 동작합니다:
// 1. SELECT로 삭제할 엔티티 조회 List<UnifiedPostTag> tags = em.createQuery( "SELECT t FROM UnifiedPostTag t WHERE t.post.id = :postId" ).getResultList(); // 2. 각 엔티티에 대해 remove() 호출 → 삭제 액션을 ActionQueue에 등록 for (UnifiedPostTag tag : tags) { em.remove(tag); // EntityDeleteAction 큐에 추가 }
핵심: @Modifying을 붙여도 파생 삭제는 여전히 "조회 → 엔티티 삭제" 경로를 탑니다. 벌크 삭제로 바뀌지 않습니다.
Hibernate ActionQueue의 실행 순서
Hibernate는 flush 시점에 ActionQueue에 쌓인 작업들을 특정 순서로 실행합니다:
┌─────────────────────────────────────────────────────────────────┐ │ Hibernate ActionQueue 실행 순서 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 1. OrphanRemovalAction (고아 객체 제거) │ │ 2. EntityInsertAction (INSERT) ← 삽입이 먼저! │ │ 3. EntityUpdateAction (UPDATE) │ │ 4. CollectionRemoveAction (컬렉션 삭제) │ │ 5. CollectionUpdateAction (컬렉션 수정) │ │ 6. CollectionRecreateAction (컬렉션 재생성) │ │ 7. EntityDeleteAction (DELETE) ← 삭제가 나중! │ │ │ └─────────────────────────────────────────────────────────────────┘
INSERT가 DELETE보다 먼저 실행됩니다! 이것이 문제의 핵심입니다.
실제 발생하는 상황
┌─────────────────────────────────────────────────────────────────┐ │ 트랜잭션 타임라인 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 1. deleteByPostId() 호출 │ │ └─ SELECT 실행 → 3개 엔티티 조회 │ │ └─ 각 엔티티에 remove() 호출 │ │ └─ EntityDeleteAction 3개가 ActionQueue에 등록 │ │ │ │ 2. saveAll() 호출 │ │ └─ 3개 새 엔티티 persist() │ │ └─ EntityInsertAction 3개가 ActionQueue에 등록 │ │ │ │ 3. 트랜잭션 커밋 → flush 발생 │ │ └─ ActionQueue 실행 순서에 따라: │ │ └─ INSERT 먼저 실행 💥 → 중복 키 오류! │ │ └─ DELETE는 실행되지 못함 │ │ │ └─────────────────────────────────────────────────────────────────┘
flushAutomatically = true가 안 먹힌 이유
flushAutomatically = true는 JPQL/Native 쿼리 실행 전에 flush를 수행합니다. 하지만 파생 삭제는 JPQL 쿼리가 아니라 엔티티 삭제 경로를 타기 때문에:
- 파생 삭제는 먼저 SELECT로 엔티티를 조회
- 각 엔티티에
remove()를 호출하여 삭제 액션을 ActionQueue에 등록 - 이후 같은 flush에서 INSERT와 DELETE의 실행 순서가 ActionQueue 우선순위에 따라 결정
- INSERT가 먼저 실행되어 유니크 키 충돌 발생
결론: flushAutomatically는 파생 삭제를 벌크 삭제로 만들지 못합니다.
해결 방법
방법 1: JPQL 벌크 삭제 (권장)
@Repository public interface UnifiedPostTagRepository extends JpaRepository<UnifiedPostTag, Long> { /** * 게시글의 태그 삭제 (벌크 삭제) * * ⚠️ 주의: 메서드 이름 기반 삭제(deleteByPostId)는 "조회 → 엔티티 삭제" 경로로 동작하여 * Hibernate ActionQueue에서 INSERT가 DELETE보다 먼저 실행될 수 있음. * JPQL 벌크 삭제를 사용하면 즉시 DELETE 쿼리가 DB에 전송됨. * * ⚠️ clearAutomatically 사용 금지! * true로 설정하면 영속성 컨텍스트가 클리어되어 같은 트랜잭션 내 * 다른 엔티티(예: UnifiedPost)의 변경사항이 DB에 반영되지 않음 */ @Modifying(flushAutomatically = true) @Query("DELETE FROM UnifiedPostTag t WHERE t.post.id = :postId") void deleteByPostId(@Param("postId") Long postId); }
JPQL 벌크 삭제의 장점:
- DB에 직접 DELETE 쿼리 전송 (ActionQueue 우회)
- 쿼리 1회로 모든 데이터 삭제 (N+1 문제 없음)
- 실행 순서가 코드 순서와 일치
JPQL 벌크 삭제의 주의점:
- 영속성 컨텍스트를 우회하므로, 삭제된 엔티티가 1차 캐시에 남아있을 수 있음
@EntityListeners의@PreRemove,@PostRemove이벤트가 발생하지 않음clearAutomatically = true사용 시 주의 필요 (아래 "추가 함정" 섹션 참고)
방법 2: 명시적 flush() 호출
파생 삭제를 유지하면서 문제를 해결하려면, 삭제 후 명시적으로 flush를 호출합니다:
private void syncTagsInNormalizedTable(UnifiedPost post, List<String> tags) { // 1. 기존 태그 삭제 postTagRepository.deleteByPostId(post.getId()); // 2. 즉시 flush하여 DELETE를 먼저 실행 entityManager.flush(); // 3. 새 태그 저장 if (tags != null && !tags.isEmpty()) { List<UnifiedPostTag> postTags = tags.stream() .map(tag -> UnifiedPostTag.of(post, tag)) .collect(Collectors.toList()); postTagRepository.saveAll(postTags); } }
장점: 엔티티 이벤트(@PreRemove 등)가 정상 발생
단점: 쿼리 수가 여전히 많음 (SELECT 1 + DELETE N + INSERT N)
방법 3: orphanRemoval을 활용한 컬렉션 관리
부모 엔티티에서 자식 컬렉션을 직접 관리하는 방식입니다:
@Entity public class UnifiedPost { @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) private List<UnifiedPostTag> tags = new ArrayList<>(); public void replaceTags(List<String> newTagNames) { // 기존 태그 제거 (orphanRemoval이 DELETE 처리) this.tags.clear(); // 새 태그 추가 newTagNames.forEach(name -> this.tags.add(UnifiedPostTag.of(this, name)) ); } }
장점:
- JPA가 알아서 삭제/삽입 관리
- 도메인 모델이 응집력 있게 유지됨
단점:
- 부모 엔티티를 항상 로드해야 함
- 대량 데이터에서 성능 이슈 가능
방법 비교
| 방법 | 쿼리 수 | 엔티티 이벤트 | 영속성 컨텍스트 | 권장 상황 |
|---|---|---|---|---|
| JPQL 벌크 삭제 | 1 | ❌ 발생 안 함 | 우회 | 대부분의 경우 |
| 명시적 flush | N+1 | ✅ 정상 발생 | 정상 동작 | 이벤트 리스너 필요 시 |
| orphanRemoval | N+1 | ✅ 정상 발생 | 정상 동작 | 도메인 모델 응집도 중시 |
추가 함정: clearAutomatically의 위험성
JPQL 벌크 삭제로 문제를 해결한 뒤, 또 다른 함정에 빠졌습니다.
증상
글 수정 시 태그는 정상 저장되지만, 글의 상태(status)가 변경되지 않는 문제가 발생했습니다:
@Transactional public PostResponse updatePost(Long postId, PostUpdateRequest request, Long userId) { UnifiedPost post = findPostWithBoardOrThrow(postId); post.updateContent(request.title(), request.content()); // ✅ 반영됨 post.updateTags(request.tags()); // ✅ 반영됨 syncTagsInNormalizedTable(post, request.tags()); // 태그 동기화 post.changeStatus(PostStatus.PUBLISHED); // ❌ 반영 안 됨! return PostResponse.from(post); }
로그에는 Draft published via updatePost가 출력되었지만, DB에는 여전히 DRAFT 상태였습니다.
원인
@Modifying(clearAutomatically = true)가 영속성 컨텍스트를 클리어하여, 같은 트랜잭션 내 다른 엔티티(post)가 detached 상태가 되었습니다:
┌─────────────────────────────────────────────────────────────────┐ │ clearAutomatically = true의 부작용 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 1. post.updateContent(...) → 영속성 컨텍스트에서 변경됨 │ │ 2. post.updateTags(...) → 영속성 컨텍스트에서 변경됨 │ │ 3. syncTagsInNormalizedTable(...) │ │ └─ deleteByPostId() 실행 │ │ └─ clearAutomatically = true 💥 │ │ └─ 영속성 컨텍스트 클리어! │ │ └─ post 엔티티가 detached 상태가 됨 │ │ 4. post.changeStatus(PUBLISHED) │ │ └─ detached 상태의 엔티티 수정 │ │ └─ DB에 반영되지 않음! 💥 │ │ │ └─────────────────────────────────────────────────────────────────┘
해결
clearAutomatically = true를 제거합니다:
// ❌ 위험: 다른 엔티티가 detached 상태가 됨 @Modifying(clearAutomatically = true, flushAutomatically = true) // ✅ 안전: flushAutomatically만 사용 @Modifying(flushAutomatically = true)
clearAutomatically 사용 가이드
| 상황 | clearAutomatically | 이유 |
|---|---|---|
| 벌크 삭제 후 같은 엔티티를 다시 조회 | true | 1차 캐시 불일치 방지 |
| 벌크 삭제 후 다른 엔티티를 계속 수정 | false | 다른 엔티티가 detached 되는 것 방지 |
| 트랜잭션 마지막에 벌크 삭제 | true | 이후 작업 없으므로 안전 |
| 트랜잭션 중간에 벌크 삭제 | false | 이후 작업에 영향 방지 |
권장: 기본적으로 clearAutomatically = false를 유지하고, 삭제된 엔티티를 다시 조회해야 하는 경우에만 true 사용
테스트 코드
문제가 재발하지 않도록 테스트 코드를 추가했습니다:
@Nested @DisplayName("태그 듀얼 라이트 테스트") class TagDualWrite { @Test @DisplayName("같은 태그로 수정해도 중복 키 오류가 발생하지 않아야 한다") void updatePost_withSameTags_shouldNotCauseDuplicateKeyError() { // Given: 태그가 있는 게시글 List<String> originalTags = Arrays.asList("java", "spring", "gcp"); UnifiedPost post = createPostWithTags(originalTags); Long postId = post.getId(); // When: 동일한 태그로 수정 PostUpdateRequest request = new PostUpdateRequest( "Updated Title", "Updated Content", originalTags, // 같은 태그! null, null ); // Then: 중복 키 오류 없이 성공 assertDoesNotThrow(() -> postService.updatePost(postId, request, author.getId()) ); // 추가 검증: 태그 테이블에 정확히 3개만 존재하는지 확인 List<String> savedTags = postTagRepository.findTagNamesByPostId(postId); assertThat(savedTags) .hasSize(3) .containsExactlyInAnyOrder("java", "spring", "gcp"); } @Test @DisplayName("DRAFT 상태의 글 수정 시 PUBLISHED로 전환되어야 한다") void updatePost_withDraftStatus_shouldPublish() { // Given: DRAFT 상태의 게시글 UnifiedPost draftPost = createDraftPostWithTags(Arrays.asList("java")); Long postId = draftPost.getId(); assertThat(draftPost.getStatus()).isEqualTo(PostStatus.DRAFT); // When: 글 수정 PostUpdateRequest request = new PostUpdateRequest( "Title", "Content", Arrays.asList("java", "spring"), null, null ); postService.updatePost(postId, request, author.getId()); // Then: PUBLISHED 상태로 변경되어야 함 UnifiedPost updatedPost = postRepository.findById(postId).orElseThrow(); assertThat(updatedPost.getStatus()).isEqualTo(PostStatus.PUBLISHED); } @Test @DisplayName("태그 일부 변경 시 정상 동작해야 한다") void updatePost_withPartialTagChange_shouldWork() { // Given List<String> originalTags = Arrays.asList("java", "spring"); UnifiedPost post = createPostWithTags(originalTags); Long postId = post.getId(); // When: 일부 태그 변경 (spring → kotlin) List<String> newTags = Arrays.asList("java", "kotlin"); PostUpdateRequest request = new PostUpdateRequest( "Title", "Content", newTags, null, null ); postService.updatePost(postId, request, author.getId()); // Then: 변경된 태그가 정확히 반영되었는지 확인 List<String> savedTags = postTagRepository.findTagNamesByPostId(postId); assertThat(savedTags) .hasSize(2) .containsExactlyInAnyOrder("java", "kotlin") .doesNotContain("spring"); // 삭제된 태그가 없는지 확인 } }
교훈
1. 파생 삭제는 "조회 → 엔티티 삭제" 경로를 탄다
// ⚠️ 이 메서드들은 모두 SELECT → remove() 경로로 동작 void deleteByUserId(Long userId); void deleteByPostId(Long postId); void deleteByOrderId(Long orderId); // @Modifying을 붙여도 벌크 삭제로 변환되지 않음! @Modifying void deleteByPostId(Long postId); // 여전히 조회 후 삭제
2. Hibernate ActionQueue에서 INSERT가 DELETE보다 먼저 실행된다
ActionQueue 실행 순서: INSERT → UPDATE → DELETE 같은 유니크 키로 삭제 후 삽입하면 충돌!
3. "삭제 후 재삽입" 패턴에서는 JPQL 벌크 삭제를 사용하라
// ✅ 안전한 패턴: JPQL 벌크 삭제 @Modifying(flushAutomatically = true) @Query("DELETE FROM Entity e WHERE e.parentId = :parentId") void deleteByParentId(@Param("parentId") Long parentId);
4. clearAutomatically = true는 신중하게 사용하라
// ❌ 위험: 같은 트랜잭션 내 다른 엔티티가 detached 됨 @Modifying(clearAutomatically = true, flushAutomatically = true) // ✅ 안전: 필요한 경우에만 clearAutomatically 사용 @Modifying(flushAutomatically = true)
규칙: 트랜잭션 중간에 벌크 삭제를 수행하면서 다른 엔티티를 계속 수정해야 한다면, clearAutomatically = false를 유지하라.
5. 벌크 삭제의 트레이드오프를 이해하라
| 특성 | 파생 삭제 | JPQL 벌크 삭제 |
|---|---|---|
| 영속성 컨텍스트 | 정상 동작 | 우회 |
| 엔티티 이벤트 | ✅ 발생 | ❌ 발생 안 함 |
| Cascade | ✅ 동작 | ❌ 동작 안 함 |
| 쿼리 수 | N+1 | 1 |
6. 성능도 개선된다
| 태그 5개 삭제 시 | 파생 삭제 | JPQL 벌크 |
|---|---|---|
| SELECT 쿼리 | 1회 | 0회 |
| DELETE 쿼리 | 5회 | 1회 |
| 총 쿼리 수 | 6회 | 1회 |
쿼리 수 83% 감소!
결론
Spring Data JPA의 파생 삭제(deleteByXxx)는 편리하지만, 내부적으로 "조회 후 엔티티 삭제" 경로를 탑니다. Hibernate는 flush 시점에 ActionQueue의 우선순위에 따라 INSERT를 DELETE보다 먼저 실행하므로, 같은 트랜잭션에서 삭제 후 동일한 유니크 키로 재삽입하면 충돌이 발생합니다.
해결책:
- 대부분의 경우: JPQL 벌크 삭제 (
@Query("DELETE FROM ...")) - 엔티티 이벤트가 필요한 경우: 명시적
flush()호출 - 도메인 모델 응집도 중시:
orphanRemoval = true활용
주의사항:
clearAutomatically = true는 같은 트랜잭션 내 다른 엔티티에 영향을 줄 수 있음- 트랜잭션 중간에 벌크 삭제를 사용할 때는
clearAutomatically = false가 안전
이번 버그들을 통해 JPA의 쓰기 지연 전략, ActionQueue의 실행 순서, 파생 쿼리와 벌크 연산의 차이, 그리고 영속성 컨텍스트 관리의 중요성에 대해 깊이 이해하게 되었습니다.
참고 자료
- Hibernate User Guide - Flushing
- Hibernate ActionQueue Source Code
- Spring Data JPA - Derived Delete Queries
- Vlad Mihalcea - JPA Entity Lifecycle
작성일: 2026-02-05
수정일: 2026-02-05 (clearAutomatically 위험성 추가)
환경: Spring Boot 3.2, Spring Data JPA 3.2, Hibernate 6.4, MySQL 8.0
댓글
댓글을 작성하려면 이 필요합니다.