핵심 한 줄
13편에서
REQUIRES_NEW로 트랜잭션을 “남”으로 떼는 법을 배웠다. 그런데 그걸 실무에서 쓰려니 세 번 배신당했다 — ①같은 클래스에서 부르면 프록시를 안 거쳐 전파가 통째로 무시되고(감사 로그가count=0으로 같이 사라진다), ②부분만 떼려NESTED를 썼더니 JPA가 “세이브포인트 못 쓴다”며 실행 자체를 거부하고, ③checked예외를 던졌는데 롤백이 아니라 커밋됐다.@Transactional은 내가 붙인 대로가 아니라 프록시·물리 트랜잭션·예외 종류가 정한 대로 움직인다.
시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)
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.설정 한 줄 바꿨더니 세 번 터졌다 · 17.두 번 뺐는데 한 번만 빠졌다 · 18.분명히 REQUIRES_NEW인데 같이 사라졌다 · 19.손으로 안 띄워도 되는 테스트 · … 그리고 계속
1. 이름만 붙이고 넘어간 자리
13편은 전파(propagation)에 이름을 붙이는 편이었다. REQUIRED는 한 몸, REQUIRES_NEW는 남. 그리고 마지막 문장에서 이렇게 못 박았다 — “스프링의 마법은 전부 빈과 빈 사이(프록시)에서만 작동한다.”
그 문장은 멋지지만, 사실 경고였다. 프록시 경계에서만 작동한다면, 그 경계를 안 지키는 순간 마법은 조용히 꺼진다. 이번 편은 그 “조용히 꺼지는” 순간을 세 번 밟는다. 전부 REQUIRES_NEW를 실무처럼 쓰려다 데인 자리다. 그리고 전부 테스트로 count를 찍어 확인했다 — 말이 아니라 숫자로.
2. 첫 번째 배신 — 같은 클래스에서 부르면 안 먹는다
13편의 감사 로그 코드엔 사실 숨은 질문이 있었다. 왜 log()를 본체 서비스 안에 안 두고 굳이 별도 빈 AuditService로 뺐을까? 그냥 같은 클래스 안 메서드로 뒀어도 되지 않나?
안 된다. 같은 클래스 안에 두고 this로 불러봤다.
@Transactional // 바깥: 롤백된다
public void orderProcessSelfCall() {
this.logInSameClass("self-call"); // ← 같은 클래스 메서드를 this로 직접 호출
throw new RuntimeException();
}
@Transactional(propagation = Propagation.REQUIRES_NEW) // 독립 tx가 되고 싶지만…
public void logInSameClass(String message) {
auditLogRepository.save(new AuditLog(message));
}log()(별도 빈) 버전은 바깥이 롤백돼도 count=1로 살아남았다. 그런데 이 this.logInSameClass() 버전은?
assertThat(auditLogRepository.count()).isEqualTo(0); // 같이 사라졌다count=0. REQUIRES_NEW를 분명히 붙였는데 독립 tx가 안 열렸고, 바깥 롤백에 딸려 같이 사라졌다.
이유는 프록시다. @Transactional은 스프링이 내 빈을 프록시로 한 겹 감싸서 호출을 가로채 앞뒤로 트랜잭션을 여닫는 것이다. 그런데 프록시는 바깥에서 들어오는 호출만 가로챈다. 같은 클래스 안에서 this.메서드()로 부르면 프록시를 건너뛰고 실제 객체끼리 직접 호출하는 거라, @Transactional이 통째로 무시된다. 전파도 당연히 없다.
→ 13편이 log()를 별도 빈으로 뺀 게 우연이 아니라 정답이었다. auditService.log()는 빈 경계(프록시)를 넘으니까 REQUIRES_NEW가 살아난 것이다.
3. 그래서 — 프록시를 거치게 하는 세 가지 길
같은 클래스에서 전파를 살리려면, 어떻게든 프록시를 거치게 만들면 된다. 세 가지 길이 있다.
① 별도 빈으로 분리 — 다른 빈을 부르면 그 호출은 프록시를 거친다. 13편의 AuditService가 이것이고, 실무 정석이다. 책임 분리도 자연스럽다.
② self 주입 — 자기 자신을 프록시로 주입받아 self.메서드()로 부른다.
@Autowired @Lazy // @Lazy 없으면 생성 시점 순환참조로 터진다
private OrderProcessService self;
@Transactional
public void orderProcessSelfInjection() {
self.logInSameClass("self-injection"); // this가 아니라 self(프록시) → count=1
throw new RuntimeException();
}동작한다(count=1). 하지만 “이 클래스가 자기 트랜잭션 경계를 스스로 다시 그어야 한다”는 건 응집이 안 맞는다는 냄새다. 되도록 ①로 푼다.
③ TransactionTemplate — 프록시와 무관하게, 트랜잭션 경계를 코드로 직접 긋는다.
TransactionTemplate independent = new TransactionTemplate(txManager);
independent.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
independent.executeWithoutResult(status ->
auditLogRepository.save(new AuditLog("programmatic"))); // count=1여기서 setPropagationBehavior(REQUIRES_NEW)를 빼먹으면 함정이다 — TransactionTemplate 기본값도 REQUIRED라, 안 그으면 바깥 tx에 합류해 같이 롤백된다. 세밀한 제어가 필요할 때 쓰는 실무용 도구다.
세 길 모두 count=1로 로그가 되살아났다. 공통점은 하나 — 프록시를 거치거나, 아예 프록시 없이 경계를 직접 그었다.
4. 두 번째 배신 — “부분만 떼기”는 JPA에서 막혀 있다
REQUIRES_NEW는 트랜잭션을 완전히 남으로 만든다. 그런데 가끔은 그 정도까진 필요 없고, “바깥은 살리되 이 안쪽 시도만 취소” 하고 싶을 때가 있다. 대량 배치에서 실패한 항목 하나만 건너뛰기처럼. 그럴 때 쓰라고 있는 게 NESTED다.
REQUIRES_NEW= 물리적으로 완전 별개인 트랜잭션. 바깥이 죽어도 안쪽은 이미 커밋돼 생존.NESTED= 같은 물리 트랜잭션 안에 세이브포인트(중간 저장점)를 찍는다. 안쪽이 실패하면 세이브포인트까지만 되감고 바깥은 계속.
그래서 log()를 NESTED로 바꿔 돌려봤다. 그런데 예상 못 한 게 튀어나왔다. count를 보기도 전에 실행이 거부됐다.
org.springframework.transaction.NestedTransactionNotSupportedException:
JpaDialect does not support savepoints - check your JPA provider's capabilities
세이브포인트는 JDBC 커넥션 레벨 기능인데, 우리 스택의 JpaTransactionManager와 그 밑 HibernateJpaDialect는 세이브포인트를 지원하지 않는다고 선언한다(Hibernate가 flush 타이밍을 자기가 관리해서 세이브포인트 모델과 안 맞는다). 순수 JDBC 스택(DataSourceTransactionManager)이었다면 됐을 것이다.
→ 실무 결론: JPA/Hibernate를 쓰는 대부분의 스프링 앱에서 NESTED는 기본으로 막혀 있다. 그래서 “본체가 실패해도 남아야 하는 로그·이력”은 실무에서 REQUIRES_NEW를 쓴다. 13편이 REQUIRES_NEW를 고른 건, 또 한 번, 우연이 아니었다.
5. 세 번째 배신 — 예외를 던졌는데 저장돼 있었다
지금까지 롤백을 일으킨 건 전부 RuntimeException이었다. 그런데 @Transactional 메서드에서 checked 예외(throws Exception)를 던지면 어떻게 될까? “예외가 났으니 당연히 롤백되겠지.”
@Transactional
public void logThenCheckedFail(String message) throws Exception {
auditLogRepository.save(new AuditLog(message));
throw new Exception("checked fail"); // checked
}assertThat(auditLogRepository.count()).isEqualTo(1); // ← 예외가 났는데 저장이 남았다count=1. 예외는 위로 올라갔는데 DB는 커밋됐다. 스프링의 기본 롤백 규칙이 이렇게 갈리기 때문이다.
- unchecked(
RuntimeException,Error) → 롤백 ✅ - checked(
Exception,IOException등) → 커밋 ⚠️
“예외 = 롤백”이 아니라 “unchecked = 롤백”이었다. 이 관례는 오래된 것으로(EJB 시절, unchecked는 예기치 못한 시스템 오류라 롤백, checked는 “예상된 대안 흐름”이라 커밋 유지), 스프링이 기본값으로 이어받았다. 문제는 요즘 우리가 checked 예외를 그런 의미로 안 쓴다는 것이다 — 그래서 방심하면 데이터가 반쯤 저장된 채 남는다.
고치는 법은 규칙을 명시적으로 넓히는 것이다.
@Transactional(rollbackFor = Exception.class) // checked도 롤백 대상에 포함
public void logThenCheckedFailWithRollbackFor(String message) throws Exception {
auditLogRepository.save(new AuditLog(message));
throw new Exception("checked fail");
}assertThat(auditLogRepository.count()).isEqualTo(0); // 이제 롤백된다rollbackFor = Exception.class 한 줄로 count가 1 → 0으로 뒤집힌다.
6. @Transactional은 선언이 아니라 조건부다
세 번의 배신은 표면이 다르지만 뿌리가 같다. @Transactional을 붙였다고 내 뜻대로 되는 게 아니라는 것.
| 배신 | 내가 기대한 것 | 실제로 정한 것 |
|---|---|---|
| 자기호출 | REQUIRES_NEW로 독립 | 프록시 경계를 안 넘어 무시 (count=0) |
| NESTED | 세이브포인트로 부분 롤백 | 물리 tx의 세이브포인트 지원 여부 (JPA 거부) |
| checked 예외 | 예외니까 롤백 | 예외 종류(unchecked만 기본 롤백) (count=1) |
셋을 관통하는 한 문장. @Transactional은 프록시가 걸고(→ 경계를 안 넘으면 없다), 물리 트랜잭션 경계와 예외 종류가 롤백을 좌우한다. 애노테이션은 선언이 아니라, 세 가지 숨은 조건 위에서만 발동하는 조건부 약속이다. 13편이 “프록시에서만 작동한다”고 경고했고, 이번 편이 그 경고를 세 번 실물로 확인했다.
다음
값 객체 하나에서 시작해, 트랜잭션의 경계(13편)와 그 경계가 배신하는 세 지점(이번 편)까지 왔다. 17편에서 “다음은 JVM으로 내려간다”고 했지만, 내려가기 전에 매일 쓰는 @Transactional의 바닥을 한 번 더 다지기로 했다 — 기초에 충실한 게 결국 빠른 길이니까.
이 과정에서 새 벽 하나가 드러났다. checked냐 unchecked냐가 롤백을 가른다면, 예외를 어떻게 설계하고 처리할 것인가는 그 자체로 하나의 카타다(커스텀 예외 계층, 전역 처리, 예외 → HTTP 상태 번역). 백로그에 적어뒀다.
이 시리즈는 계속 이어진다.