티스토리 뷰
JPA EntityListener에서 ConcurrentModificationException이 발생하는 이유와 권장 가이드 기반 해결 전략
ebson 2026. 1. 23. 20:49
JPA EntityListener 라이프사이클 이해와 권장 가이드를 따르는 접근
JPA EntityListener는 엔티티의 생명주기 이벤트에 반응할 수 있는 공식적인 확장 지점입니다. 엔티티가 저장되거나 수정되는 시점에 후처리를 결합할 수 있다는 점에서 편리해 보이지만, 실제 운영 환경에서는 이 기능이 예상치 못한 예외로 이어지는 경우가 있습니다. 대표적인 사례가 ConcurrentModificationException입니다.
이 글은 JPA EntityListener 사용 중 발생한 ConcurrentModificationException을 출발점으로 삼아, 이 문제가 왜 발생했는지, 그리고 JPA EntityListener 라이프사이클과 공식 문서에서 제시하는 권장 가이드를 따를 때 어떤 해결 전략이 도출되는지를 정리합니다. 설명은 첨부된 버그픽스 보고서와 설계서, 그리고 JPA와 Hibernate, Spring의 공식 문서에 근거하여 진행합니다.
JPA EntityListener의 호출 시점과 라이프사이클 특성
JPA EntityListener는 엔티티의 상태 전환 과정에서 호출됩니다. @PrePersist, @PostPersist, @PreUpdate, @PostUpdate와 같은 콜백은 트랜잭션 커밋 시점과 직접적으로 동일하지 않으며, 영속성 컨텍스트가 엔티티 변경 사항을 처리하는 중간 단계에서 실행됩니다.
JPA 스펙에 따르면, 이러한 라이프사이클 콜백은 영속성 컨텍스트가 flush 작업을 수행하는 과정에서도 호출될 수 있습니다. flush는 단순히 SQL을 실행하는 단계가 아니라, 엔티티와 연관 컬렉션의 변경 사항을 비교하고 내부 상태를 정리하는 과정까지 포함합니다. 이 시점의 엔티티는 아직 “완전히 안정된 상태”라고 가정할 수 없습니다.
이 특성은 EntityListener가 단순히 상태 변화를 감지하는 용도로 설계되었음을 의미합니다. 엔티티의 내부 구조나 연관 컬렉션을 적극적으로 수정하는 로직을 포함하기에는, 호출 시점이 지나치게 JPA 내부 동작과 밀접하게 결합되어 있습니다.
ConcurrentModificationException이 발생한 구조적 배경
첨부된 버그픽스 보고서에서 문제는 엔티티 수정 이후 호출되는 EntityListener 내부에서 연관 컬렉션을 변경하는 코드로부터 시작되었습니다. 해당 로직은 비즈니스 관점에서는 자연스러운 후처리였지만, 실행 시점은 JPA flush 과정과 겹쳐 있었습니다.
Hibernate ORM 공식 문서에 따르면, flush 과정에서는 컬렉션의 현재 상태와 스냅샷을 비교하기 위해 내부적으로 반복(iteration)이 수행됩니다. 이 반복이 진행 중인 컬렉션을 동일한 스레드에서 수정하면, Java 컬렉션 프레임워크의 동작 원리에 따라 ConcurrentModificationException이 발생합니다.
중요한 점은 이 예외가 멀티스레드 환경에서만 발생하는 문제가 아니라는 사실입니다. 단일 스레드 내에서도, 동일 컬렉션을 순회하면서 구조를 변경하면 예외는 충분히 발생할 수 있습니다. 따라서 이 문제는 동시성 제어 실패라기보다, JPA 라이프사이클과 EntityListener의 사용 범위를 오해한 설계에서 비롯된 문제로 이해하는 것이 타당합니다.
EntityListener에 비즈니스 로직을 두는 것의 한계
JPA와 Hibernate ORM 공식 문서에서는 EntityListener 사용 시 몇 가지 암묵적인 전제를 전제로 합니다. EntityListener는 엔티티의 생명주기 이벤트에 반응할 수는 있지만, 그 내부에서 복잡한 비즈니스 로직을 수행하는 것은 권장되지 않습니다.
그 이유는 명확합니다. EntityListener는 트랜잭션 경계를 직접 제어하지 않으며, 호출 시점 또한 flush와 같은 내부 처리 과정과 겹칠 수 있습니다. 이 상태에서 다른 엔티티를 수정하거나 연관 컬렉션을 변경하면, JPA 구현체의 내부 상태 관리와 충돌할 가능성이 높아집니다.
즉, EntityListener는 “무언가를 처리하기 위한 계층”이 아니라, “무언가가 발생했음을 감지할 수 있는 지점”에 가깝습니다. 이 차이를 인식하지 못하면, 예외는 언제든지 다시 발생할 수 있습니다.
실행 시점 조정만으로는 충분하지 않은 이유
초기 대응으로 흔히 선택되는 해결책은 실행 시점을 뒤로 미루는 방식입니다. 실제로 Spring 환경에서는 트랜잭션 커밋 이후 로직을 실행하기 위해 TransactionSynchronization을 활용할 수 있습니다. 이는 트랜잭션이 성공적으로 커밋된 이후에 후처리를 수행할 수 있도록 보장합니다.
이 방식은 flush 과정과의 충돌을 제거하는 데에는 효과적입니다. 그러나 공식 문서 기준으로 보면, 이는 문제의 근본 원인을 완전히 제거한 해결책이라고 보기는 어렵습니다. EntityListener가 여전히 비즈니스 처리를 시작하는 주체로 남아 있기 때문입니다.
JPA EntityListener 권장 가이드의 핵심은, EntityListener를 가능한 한 얇게 유지하라는 데 있습니다. 실행 시점을 조정하더라도, EntityListener가 비즈니스 의미를 해석하고 후속 처리를 결정하는 구조는 여전히 권장 범위를 벗어납니다.
권장 가이드를 따른 해결 전략의 방향
첨부된 설계서에서는 이 한계를 인식하고, 해결 전략의 방향을 “시점 조정”이 아니라 “책임 이동”으로 설정했습니다. 즉, EntityListener에서 직접 후처리를 시작하는 구조를 제거하고, 엔티티 변경에 따른 비즈니스 로직을 서비스 레이어에서 명시적으로 수행하도록 변경했습니다.
이 접근은 JPA와 Spring 공식 문서의 역할 분리 철학과 일치합니다. 트랜잭션 경계는 서비스 레이어에서 관리되고, 엔티티 변경에 따른 의미 해석 역시 애플리케이션 레이어의 책임으로 명확히 드러납니다. EntityListener는 더 이상 핵심 흐름의 출발점이 아니라, 필요하다면 부수적인 감지 역할만 수행하게 됩니다.
이로써 ConcurrentModificationException 문제는 단순히 “다시 발생하지 않도록 막은 상태”가 아니라, 발생할 수 없는 구조로 전환됩니다.
EntityListener를 어떻게 사용해야 하는가
이 사례가 보여주는 결론은 단순합니다. JPA EntityListener는 여전히 유용하지만, 그 사용 범위는 명확히 제한되어야 합니다. 엔티티 상태 변화 자체를 기록하거나, 변경 사실을 외부로 전달하기 위한 최소한의 역할에는 적합합니다. 그러나 엔티티 라이프사이클에 비즈니스 의미를 결합하는 순간, JPA 내부 동작과 충돌할 가능성은 급격히 높아집니다.
공식 문서가 제시하는 권장 가이드는, 이러한 충돌 가능성을 사전에 차단하기 위한 설계 기준으로 이해하는 것이 적절합니다.
마무리
ConcurrentModificationException은 단순한 구현 실수로 보일 수 있지만, 실제로는 JPA EntityListener의 라이프사이클과 역할을 어디까지 신뢰할 수 있는지에 대한 설계 질문으로 이어집니다. 이 글에서 정리한 해결 전략은 특정 기술을 우회적으로 사용하는 방법이 아니라, JPA EntityListener 권장 가이드를 설계 수준에서 그대로 따랐을 때 도출되는 자연스러운 결과입니다.
EntityListener를 사용하고 있다면, 예외를 피하는 방법뿐 아니라, 그 책임이 적절한 위치에 있는지 함께 점검해볼 필요가 있습니다. 그것이 장기적으로 가장 안정적인 해결 전략이 됩니다.
'WORK-RELATED' 카테고리의 다른 글
| Spring Batch 도입이 CPU 처리량과 메모리 사용에 미치는 구조적 영향 (0) | 2026.01.27 |
|---|---|
| API 서버에서 @Scheduled 배치 처리가 성능 문제로 이어지는 지점과 Spring Batch 도입 판단 기준 (0) | 2026.01.27 |
| [JAVA] JVM 리소스 로깅 유틸리티 클래스 구현 및 활용 (0) | 2025.12.26 |
| [Spring Batch] 스프링배치 멀티스레딩 환경에서 스레드 안정성 (1) | 2025.12.09 |
| [Spring Batch] 병렬처리 구현과 실무 사례 (1) | 2025.08.29 |
- Total
- Today
- Yesterday
- mybatis
- Hot Key 문제
- 트랜잭션 관리
- spring batch 5
- 스레드 생명주기
- InterruptedException
- Cache Avalanche
- Spring Batch
- Java Performance
- 캐시 장애
- Initialization-on-Demand Holder Idiom
- 백엔드 성능 튜닝
- Double-Checked Locking
- Redis 캐시 전략
- Enum 기반 싱글톤
- TTL 설계
- 동시성처리
- Cache Aside
- 트래픽 처리
- Cache Penetration
- DB 트랜잭션
- Redis 성능 개선
- DB 인덱스 성능
- 백엔드 성능 설계
- 캐시와 인덱스
- 백엔드 아키텍처
- Eager Initialization
- 백엔드 성능
- Redis vs DB
- 캐시 성능 비교
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |

