핵심 한 줄
기본
@Transactional은 얽힌 모든 작업을 한 트랜잭션으로 묶는다 — 한 곳이 실패하면 전부 롤백(all-or-nothing). 그런데 감사 로그처럼 “본체가 실패해도 남아야” 하는 것은 어쩌나.REQUIRES_NEW로 트랜잭션을 남으로 떼어낸다. 그리고 한 몸인 트랜잭션엔 악랄한 함정이 있다 — 안쪽이 던진 예외를 바깥이catch로 삼켜도, 커밋하는 순간UnexpectedRollbackException이 터진다. “반은 커밋 반은 롤백”은 존재할 수 없기 때문이다.
시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)
00.카타 서문 · 01.돈을 primitive로 두면 언젠가 샌다 · 02.불변식은 누가 지키나 · 03.동시에 사면 초과판매된다 · 04.두 번 취소해도 한 번만 · 05.save가 INSERT가 아니었다 · 06.기다리게 하지 말고 거절하라 · 07.트랜잭션 안에서 HTTP를 부르지 마라 · 08.응답을 못 받았다는 실패가 아니다 · 09.요청 하나에 쿼리 1+N · 10.5만 행을 1행으로 · 11.같은 걸 두 번 묻지 않기 · 12.가짜는 내가 시킨 답만 한다 · 13.분명히 잡았는데 터졌다 · 14.5개 주세요 했는데 5만 개를 읽었다 · 15.순수하게 만들었더니 저장이 사라졌다 · 16.설정 한 줄 바꿨더니 세 번 터졌다 · … 그리고 계속
1. 이미 쓰고 있었는데 이름을 몰랐다
7편에서 환불을 세 조각으로 쪼갰다 — TX1(READY 커밋) → 트랜잭션 밖 HTTP → TX2(확정). 그때 TX1과 TX2는 별개의 트랜잭션이었다. 왜 별개였을까? 그땐 “오케스트레이터에 @Transactional을 안 붙였으니까”라고만 알았다.
그 뒤에 숨은 규칙의 이름이 **전파(propagation)**다. 전파란 @Transactional 메서드가 호출될 때 트랜잭션을 어떻게 할지 정하는 규칙이다 — 진행 중인 트랜잭션이 있으면 거기 합류할까, 아니면 새로 시작할까.
기본값은 REQUIRED다.
REQUIRED = 진행 중인 트랜잭션이 있으면 합류, 없으면 새로 시작.
오케스트레이터에 트랜잭션이 없으니, 그 안에서 부른 두 메서드는 “진행 중인 게 없네 → 새로” 를 각각 실행해 각자의 트랜잭션이 됐다. 반대로 오케스트레이터에 @Transactional을 붙였다면? 두 호출은 REQUIRED라서 그 하나에 전부 합류 — TX1·TX2가 한 덩어리가 되고, 커밋은 맨 끝에 한 번뿐이라 “HTTP 전에 READY 커밋”이 깨졌을 것이다. 전파를 몰랐어도, 이미 전파에 기대어 설계하고 있었다.
2. 한 몸 — REQUIRED의 공동 운명
REQUIRED로 묶인 트랜잭션은 운명 공동체다. 안이든 밖이든 한 곳이 롤백하면 전부 롤백된다. 대부분의 경우 이게 옳다 — 주문 저장과 재고 차감이 한 트랜잭션이면, 재고 차감이 실패했는데 주문만 남는 참사를 막아준다.
그런데 이 “한 몸” 원칙이 불편해지는 순간이 있다.
3. 남으로 떼어내기 — REQUIRES_NEW
“이 작업을 시도했다”는 감사 로그를 남긴다고 하자. 본체 작업이 실패해서 롤백되더라도, 그 로그만은 남아야 한다. 시도한 흔적까지 지워지면 나중에 무슨 일이 있었는지 추적할 수 없으니까. 그런데 REQUIRED로 로그를 남기면, 본체가 롤백될 때 로그도 한 몸이라 같이 사라진다.
여기서 REQUIRES_NEW가 필요하다.
REQUIRES_NEW = 부모 트랜잭션을 잠시 중단하고, 완전히 독립된 트랜잭션을 새로 열어 따로 커밋한다.
말이 아니라 롤백으로 확인했다. 감사 로그 빈은 REQUIRES_NEW, 바깥 서비스는 로그를 남긴 뒤 일부러 터진다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(String message) {
auditLogRepository.save(new AuditLog(message));
}@Transactional // 바깥: 부모 트랜잭션
public void orderProcess() {
auditService.log("order-process");
throw new RuntimeException(); // 일부러 롤백
}assertThatThrownBy(() -> orderProcessService.orderProcess())
.isInstanceOf(RuntimeException.class);
assertThat(auditLogRepository.count()).isEqualTo(1); // 살아남았다바깥이 롤백했는데도 로그는 count = 1. REQUIRES_NEW라 독립 트랜잭션에서 이미 커밋됐기 때문이다. log의 전파를 REQUIRED로 한 줄만 바꾸면? 같은 테스트가 count = 0으로 뒤집히며 깨진다. 한 몸(REQUIRED)과 남(REQUIRES_NEW)의 차이가 숫자 하나로 갈린다.
4. 분명히 잡았는데 터졌다
이제 반대 방향의 함정이다. 이번엔 안쪽이 터지는데, 바깥이 그걸 try/catch로 삼키고 아무 일 없던 듯 진행하려 한다.
@Transactional // 안쪽: REQUIRED (바깥과 한 몸)
public void logThenFail(String message) {
auditLogRepository.save(new AuditLog(message));
throw new RuntimeException("inner fail");
}@Transactional
public void processSwallowing() {
try {
auditService.logThenFail("swallow");
} catch (RuntimeException e) {
// 잡았으니 괜찮겠지... 하고 계속 진행
}
} // ← 커밋하려는 순간 터진다분명히 예외를 catch했다. 그런데 실행하면 processSwallowing 자체가 UnexpectedRollbackException을 던지고, 감사 로그는 count = 0이다.
assertThatThrownBy(() -> orderProcessService.processSwallowing())
.isInstanceOf(UnexpectedRollbackException.class);
assertThat(auditLogRepository.count()).isEqualTo(0);이유는 rollback-only 낙인이다. 안쪽(REQUIRED)이 예외를 던지는 순간, 스프링은 공유 트랜잭션에 “이건 롤백 예정”이라는 낙인을 찍는다. 바깥이 예외를 삼켜도 그 낙인은 안 지워진다. 바깥이 정상 종료하며 커밋을 시도하는 순간, 스프링이 막아선다 — “롤백 예정인 트랜잭션을 커밋하라고?” 그리고 UnexpectedRollbackException을 던진다.
왜 이렇게까지 막을까. 한 몸인 트랜잭션에서 반은 커밋(바깥) 반은 롤백(안쪽) 은 성립할 수 없기 때문이다. 논리적 모순을 허용하느니, 스프링은 전체를 롤백으로 확정한다. 같은 트랜잭션 안에서는, 예외를 잡았다고 그 뒤를 “복구”할 수 없다.
이건 사실 4편에서 이미 만난 벽이다. 그때 “영속성 예외가 나면 트랜잭션이 rollback-only가 되어 같은 트랜잭션에서 복구가 안 되니, 선점을 별도 빈으로 격리한다”고 했다. 그 벽에 이제 이름이 붙었다.
5. 스위치는 프록시 경계에 있다
한 가지가 남는다. 4번의 함정을 켜고 끄는 스위치는 무엇인가? 놀랍게도 안쪽 메서드의 @Transactional 유무다.
- 안쪽에
@Transactional이 없으면 → 그 예외는 그냥 평범한 예외다. 바깥catch가 잡고 끝, 바깥은 멀쩡히 커밋한다. 트랩이 안 터진다. - 안쪽에
@Transactional이 있으면 → 그 프록시가 예외를 보고 rollback-only를 찍는다. 트랩이 터진다.
rollback-only 낙인은 @Transactional 프록시 경계를 지날 때만 찍히기 때문이다. 그래서 같은 클래스 안에서 this.logThenFail()을 부르면 프록시를 안 거쳐 전파 자체가 무시된다 — 3·4·6·11편에서 계속 나온 그 self-invocation 함정과 똑같은 얼굴이다. 전파도, 낙인도, 캐시도, 트랜잭션도 — 스프링의 마법은 전부 빈과 빈 사이(프록시)에서만 작동한다. 이 한 문장이 시리즈 내내 반복된 함정의 뿌리였다.
다음
값 객체 하나에서 시작해, 이제 트랜잭션의 경계와 운명까지 왔다. 한 몸으로 묶을지(REQUIRED) 남으로 뗄지(REQUIRES_NEW), 그리고 한 몸인 이상 예외를 잡아도 되돌릴 수 없다는 것. 앞 편들에서 무심코 기대던 규칙이, 이번 편에서 이름과 메커니즘을 얻었다.
카타는 계속된다. 아직 손대지 않은 벽 — 날짜 경계, 할인 배분의 잔돈, 상태머신의 전이 가드 — 이 각각 또 하나의 “여기서 이렇게 틀렸다”가 될 것이다.
이 시리즈는 계속 이어진다.