일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- 알고리즘
- DP
- boj
- 동적계획법
- greedy
- Backtracking
- dynamic programming
- 부분수열의합
- 백트래킹
- 스프링
- programmers
- 깊이우선탐색
- 해시맵
- 네트워크
- 우선순위큐
- DynamicProgramming
- 프로그래머스
- 너비우선탐색
- 브루트포스
- BFS
- 그리디
- DFS
- 구현
- 이분탐색
- 백준
- Algorithm
- Spring
- ReactiveProgramming
- Network
- JPA
- Today
- Total
옌의 로그
@TransactionalEventListener(AFTER_COMMIT)에서 INSERT 쿼리가 증발한 이유: REQUIRED와 REQUIRES_NEW의 결정적 차이 본문
@TransactionalEventListener(AFTER_COMMIT)에서 INSERT 쿼리가 증발한 이유: REQUIRED와 REQUIRES_NEW의 결정적 차이
dev-yen 2025. 10. 9. 01:46
최근 실무에서(이 포스팅의 연장선이다 ㅎㅋㅋ), 아래와 같은 이벤트 리스너 코드를 작성했는데 이상한 현상을 발견했다.
@Slf4j
@RequiredArgsConstructor
@Component
public class MessageEventHandler {
private final MessageRepository messageRepository;
@Transactional
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT,
fallbackExecution = true
)
public void handleCreateTemplateMessageEvent(MessageByTemplateRequest request) {
log.info("[handleCreateTemplateMessageEvent] event received: {}", request);
messageRepository.save(...); // INSERT 쿼리가 안 나감!
}
}
- 로그는 정상적으로 출력됨
- 조회 쿼리는 찍힘 (상위 트랜잭션에서 발생한)
- INSERT 쿼리는 아무리 기다려도 안 나감 (실제로 저장도 안됨 ㅠㅠ)
원인) 트랜잭션 전파(propagation) 때문, , ,
REQUIRED (기본값) | 기존 트랜잭션이 있으면 참여, 없으면 새로 생성 | 일반적인 서비스 로직 |
REQUIRES_NEW | 항상 새 트랜잭션 생성, 기존 트랜잭션은 일시 중단됨 | 이벤트 리스너, 로깅, 감사 로그 |
NESTED | 기존 트랜잭션 안에 Savepoint 생성, 내부 실패 시 롤백 가능 | 트랜잭션 내 일부만 롤백하고 싶을 때 |
SUPPORTS | 트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 실행 | 단순 조회 메서드 |
NOT_SUPPORTED | 트랜잭션이 있으면 중단하고 비트랜잭션 방식으로 실행 | 외부 시스템 호출 등 |
MANDATORY | 반드시 기존 트랜잭션이 있어야 함, 없으면 예외 발생 | 트랜잭션 환경에서만 호출되어야 할 로직 |
NEVER | 트랜잭션이 있으면 예외 발생 | 트랜잭션 없이 실행되어야 할 경우 |
실무에서 자주 쓰이는 옵션
1. REQUIRED (기본값)
- 가장 일반적인 옵션
- 기존 트랜잭션에 합류 → 같은 트랜잭션 범위에서 동작
@Transactional // REQUIRED가 기본
public void orderService() {
userRepository.save(...); // 같은 트랜잭션으로 묶임
paymentRepository.save(...);
}
2. REQUIRES_NEW
- 기존 트랜잭션과 완전히 분리된 새로운 트랜잭션을 시작
- 호출 메서드에서 예외가 발생해도 기존 트랜잭션에 영향 없음
- 알림, 로깅, 외부 통신, @TransactionalEventListener(AFTER_COMMIT) 같은 곳에 자주 사용
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAuditLog(...) {
auditLogRepository.save(...); // 독립 트랜잭션
}
3. NESTED
- 부모 트랜잭션 안에서 Savepoint를 만들어 두고, 내부 트랜잭션만 롤백 가능
- DB가 savepoint를 지원해야 함 (예: MySQL은 지원)
@Transactional(propagation = Propagation.NESTED)
public void processWithRetry() {
try {
riskyOperation(); // 실패해도 전체 롤백 안함
} catch (Exception e) {
// nested 트랜잭션만 롤백
}
}
주의사항
- REQUIRES_NEW는 별도의 트랜잭션이므로, 기존 트랜잭션에서 롤백되어도 영향을 받지 않음
- NESTED는 마치 트랜잭션 내부에서 undo checkpoint를 두는 것처럼 동작함
- @Async는 아예 다른 스레드에서 동작하므로, 트랜잭션 전파가 되지 않음
@Transactional 기본 전파 옵션은 REQUIRED
→ 기존 트랜잭션이 있으면 참여하고, 없으면 새로 생성
그런데 TransactionalEventListener의 AFTER_COMMIT은?
→ 이미 기존 트랜잭션이 끝난 후에 호출됨
즉, 아래 상황이 되는 것
- @Transactional 메서드 → 비즈니스 로직 수행 + 커밋
- 커밋 후 @TransactionalEventListener(AFTER_COMMIT) 발동
> TransactionSynchronizationManager.isSynchronizationActive() = false
> 현재 스레드에 활성화된 트랜잭션 동기화 컨텍스트가 없음을 의미 - @Transactional(REQUIRED) 동작 불가 (AOP 프록시 무력화)
- 결국 save()는 영속성 컨텍스트는 있으나 flush/commit되지 않음 → INSERT 누락
@Transactional(REQUIRED)는 왜 실패?
@TransactionalEventListener는 트랜잭션 종료 후 TransactionSynchronizationManager의 콜백을 통해 리스너 메서드를 직접 실행한다. 이 방식은 @Transactional을 처리하는 Spring AOP 프록시 레이어를 우회하게 되는데,,
따라서 REQUIRED에 명시된 "없으면 새로 생성" 로직(AOP가 담당)이 실행될 기회조차 얻지 못하고, 리스너 내부는 트랜잭션 없는(비트랜잭션) 환경으로 남게 된다.
(EntityManager 메서드 정리)
persist(Object entity) | 엔티티를 영속 상태로 만듦 (INSERT 예정) |
merge(Object entity) | 준영속 상태나 새로운 객체를 병합하여 영속 상태로 만듦 |
remove(Object entity) | 영속 상태의 엔티티를 삭제 (DELETE 예정) |
find(Class<T> clazz, ID) | PK 기준으로 DB에서 엔티티를 조회함 |
getReference(Class<T>, ID) | 프록시 객체(Lazy)로 조회함 (실제 조회는 필요 시) |
flush() | 영속성 컨텍스트의 변경 내용을 DB에 즉시 반영 (SQL 실행) |
clear() | 영속성 컨텍스트를 초기화 (모든 엔티티 준영속화) |
detach(Object entity) | 특정 엔티티를 영속성 컨텍스트에서 분리 (준영속 상태) |
contains(Object entity) | 해당 엔티티가 영속성 컨텍스트에 존재하는지 확인 |
close() | EntityManager 종료 (스프링에서는 직접 호출 X) |
createQuery(String jpql) | JPQL 쿼리를 실행할 수 있는 Query 객체 생성 |
각 메서드 상세 설명 및 예시
1. persist()
영속성 컨텍스트에 엔티티를 등록 → 트랜잭션 커밋 시 INSERT 실행
Member member = new Member("micky");
em.persist(member); // INSERT 예정
2. merge()
준영속 상태의 엔티티를 다시 영속성 컨텍스트에 병합
Member detachedMember = ...; // 준영속 상태
Member merged = em.merge(detachedMember); // 병합 후 영속 상태
3. remove()
영속 상태의 엔티티를 삭제 예약
Member member = em.find(Member.class, 1L);
em.remove(member); // DELETE 예정
4. find() vs getReference()
Member m1 = em.find(Member.class, 1L); // 즉시 조회
Member m2 = em.getReference(Member.class, 1L); // 프록시 리턴 (Lazy)
5. flush()
DB에 쿼리를 즉시 반영하지만, 트랜잭션은 아직 끝나지 않음
em.persist(entity);
em.flush(); // INSERT 쿼리 즉시 실행
6. clear()
모든 엔티티를 영속성 컨텍스트에서 분리
em.clear(); // 1차 캐시 초기화
7. detach()
특정 엔티티만 영속성 컨텍스트에서 분리
em.detach(member); // 준영속 상태가 됨
8. contains()
해당 객체가 영속성 컨텍스트에 관리되고 있는지 확인
em.contains(member); // true or false
flush vs clear vs detach 차이
flush() | DB에 SQL 즉시 반영 | 쿼리 실행되지만 트랜잭션은 유지됨 | 강제로 쿼리 날려야 할 때 |
clear() | 전체 detach | 영속성 컨텍스트 초기화 | 강제 초기화 필요 시 |
detach() | 단일 엔티티 detach | 해당 엔티티만 분리 | 특정 객체 변경 막고 싶을 때 |
결국 save()는 영속성 컨텍스트는 있으나 flush/commit되지 않음 ? < 이게 무슨말인지 알아보자
영속성 컨텍스트 생명주기 (JpaRepository 기준)
- 비영속 (new/transient)
- 아직 EntityManager에 의해 관리되지 않음 (DB에도 없음)
- new User("micky")
- 영속 (managed)
- EntityManager가 관리 중. 트랜잭션 커밋 시 DB 반영
- userRepository.save(user) → 내부에서 em.persist(user)
- 준영속 (detached)
- 더 이상 영속성 컨텍스트에서 관리되지 않음
- em.detach(user) 또는 em.clear()
- 삭제 (removed)
- 삭제 상태로 표시됨. 커밋 시 DELETE
- userRepository.delete(user) → 내부에서 em.remove(user)
userRepository.save(user);
save()때 발생하는 동작
- SimpleJpaRepository 구현체가 동작
- 내부적으로 EntityManager의 persist() 또는 merge() 호출
- 현재 트랜잭션이 있으면 해당 영속성 컨텍스트에 user 객체 등록 (Managed 상태)
- 트랜잭션이 커밋될 때 flush() → DB에 INSERT 쿼리 전송 → commit()
// 대략적인 구조
@Override
@Transactional
public <S extends T> S save(S entity) {
if (isNew(entity)) {
em.persist(entity); // 영속화
return entity;
} else {
return em.merge(entity); // 병합
}
}
저장 로직 생명주기 흐름 예시
@Transactional
public void createUser() {
User user = new User("micky"); // 비영속
userRepository.save(user); // 영속 상태로 전환
// 아직 DB 반영은 안 됨 → 영속성 컨텍스트에만 있음
// flush() 또는 트랜잭션 커밋 시 DB INSERT 실행
}
new User() | 비영속 | 메모리에만 있음 |
save() 호출 | 영속 | 영속성 컨텍스트에 등록됨 (DB X) |
flush or commit | DB 반영 | SQL INSERT 실행됨 |
주의할 점
- 트랜잭션 밖에서는 save()만 호출해도 바로 DB 반영됨 (flush 발생)
- JpaRepository의 save() 메서드 자체도 @Transactional이 적용된 메서드이므로
- 트랜잭션이 없으면 영속성 컨텍스트 생명주기는 짧아지고, 의미도 달라짐 → 가능한 한 트랜잭션 안에서 작업
- save(), find(), delete()는 모두 EntityManager 기반이므로, 영속성 컨텍스트에 반영됨
Flush가 일어나는 시점 (자동 or 수동)
(위에서 계속 얘기했지만ㅎㅎ) JPA는 EntityManager.persist()나 save()를 호출해도 바로 INSERT 쿼리를 날리지 않는다.
→ 대신 엔티티를 영속성 컨텍스트(Persistence Context) 에 저장해두고, flush() 시점에 쿼리를 날린다.
즉, flush는 영속성 컨텍스트의 변경 내용을 DB에 반영(동기화) 하는 동작이다.
1. 트랜잭션 커밋 직전
→ @Transactional 메서드가 끝나며 정상 커밋되기 직전, 자동으로 flush 발생 (가장 일반적이고 실무에서 자주 보는 케이스)
2. JPQL, Criteria Query, Native Query 실행 전
→ 쿼리 실행 전에 dirty checking된 변경사항을 DB에 반영하기 위해 자동 flush 발생
memberRepository.save(member); // 아직 쿼리 안 나감
List<Member> members = memberRepository.findAll(); // flush 발생 → SELECT 전에 INSERT 됨
3. EntityManager.flush() 명시적으로 호출 시
→ 개발자가 직접 flush 강제 수행 가능
entityManager.flush(); // 강제 flush
4. FlushModeType이 AUTO일 경우 (기본값)
→ 위 1~3 조건에 따라 자동 flush
→ 필요 시 FlushModeType.COMMIT으로 변경 가능 (성능 최적화 시)
주의: flush는 "DB 반영"이지 "commit"이 아니다
flush는 SQL 실행까지만 수행하고, DB에 실제 반영은 트랜잭션 커밋 시점에 이루어진다.
즉, flush만 하면 DB에 INSERT 쿼리는 날아가지만, 트랜잭션이 롤백되면 → 이 INSERT도 무효 처리됨
해결 방법
1. @Transactional(propagation = REQUIRES_NEW)로 트랜잭션 새로 시작
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT,
fallbackExecution = true
)
public void handleCreateTemplateMessageEvent(MessageByTemplateRequest request) {
messageRepository.save(...); // 이제 INSERT 쿼리 나감
}
- REQUIRES_NEW는 무조건 새 트랜잭션을 시작한다.
- 기존 트랜잭션이 끝난 이후(AFTER_COMMIT)에도 안전하게 트랜잭션 생성 가능.
REQUIRES_NEW는 왜.. 성공하는걸까..?
@Transactional(REQUIRES_NEW)는 "호출자의 트랜잭션 상태와 무관하게 무조건 새로운 독립적인 트랜잭션을 시작하라"는 가장 강력한 요구를 담고 있다
- Spring의 트랜잭션 처리 메커니즘은 REQUIRES_NEW의 독립성과 강제성을 보장하기 위해, 트랜잭션 동기화(Synchronization) 상태에 덜 의존하고 독립적인 트랜잭션 생성 경로를 따르도록 설계되어 있다.
- 비록 AFTER_COMMIT 콜백으로 호출되더라도, Spring의 이벤트 처리 로직은 REQUIRES_NEW가 감지되면 AOP 프록시를 통해 새로운 트랜잭션 경계를 강제로 설정하는 방식으로 작동
- 이 새로운 트랜잭션 내에서 messageRepository.save()가 실행되고, 리스너 메서드가 종료되면 독립적으로 commit/flush가 발생하여 데이터가 DB에 정상적으로 저장
2. 트랜잭션 필요 없으면 아예 @Transactional 제거
- 단순 로그만 출력하거나 외부 요청 정도만 한다면 트랜잭션 불필요
- 오히려 @Transactional이 쓸데없는 기대를 만들 수 있음
fallbackExecution = true는 ?
- 이 옵션은 트랜잭션이 없는 상황에서도 이벤트를 실행할 수 있게 해준다.
- 테스트 코드처럼 @Transactional이 없는 컨텍스트에서 이벤트를 발행할 때 유용함.
하지만 실무에서는 거의 항상 AFTER_COMMIT을 트랜잭션 내부에서 발행하므로
명시적으로 REQUIRES_NEW를 지정하는 게 더 중요하다
정리된 전체 코드 예시
@Component
@RequiredArgsConstructor
@Slf4j
public class MessageEventHandler {
private final MessageRepository messageRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT,
fallbackExecution = true
)
public void handleCreateTemplateMessageEvent(MessageByTemplateRequest request) {
log.info("[handleCreateTemplateMessageEvent] saving message: {}", request);
messageRepository.save(
Message.builder()
.userId(request.getUserId())
.title("템플릿 메시지")
.content(request.getContent())
.build()
);
}
}
마무리하며...
프록시 기반 동작하는 것들은 정말 어려운 것 같다. 생각한대로 동작을 안한달까..ㅋㅋㅋ
AOP 기반 애너테이션은 트랜잭션의 시점과 컨텍스트에 따라 예민하게 반응하니,
꼼꼼하게 트랜잭션 전파 속성과 생명주기를 고려해야함을 느꼈다 . .
'스터디 > 스프링' 카테고리의 다른 글
Querydsl transform() 사용 시 발생할 수 있는 커넥션 누수 이슈 (0) | 2025.10.03 |
---|---|
[AOP] 비동기 저장을 안전하게? @Async, @Transactional, @TransactionalEventListener (2) | 2025.09.22 |
[JPA] @OneToMany 연관관계, List vs Set 뭐가 더 나을까? (0) | 2025.09.17 |
Redis 캐시 저장, DTO 직렬화/역직렬화 깨짐 해결기 (3) | 2025.09.12 |
[Spring] Swagger에서 ErrorCode Enum 자동화하기 (1) | 2025.08.27 |