17장. 프레임워크가 숨긴 트랜잭션 경계

만화로 보는 요약 — 먼저 읽어보세요
@Transactional은 프록시가 긋는 경계다 → 내부 호출·checked 예외·외부 호출에서 경계가 깨진다 → 트랜잭션은 짧고 명시적으로 잡는다.
면접 실전 질문: ① self-invocation에서 트랜잭션이 열리지 않는 이유 ② checked 예외가 기본으로 커밋되는 이유와 rollbackFor ③ 외부 API 호출을 트랜잭션 밖으로 빼야 하는 이유
배경 — @Transactional 한 줄의 배신
지금까지 우리는 트랜잭션을 BEGIN … COMMIT으로 직접 그었어요. 그런데 실무 Spring에서는
그 경계를 대부분 애노테이션 한 줄로 긋죠.
@Transactional
public void checkout(Order order) { ... }깔끔합니다. 너무 깔끔해서, 이 한 줄이 어디서 시작하고 어디서 끝나는지를 잊게 만들어요.
5장에서 이미 “checked 예외 함정”을 예고했죠. 이 장은 그 함정들 — @Transactional이
조용히 배신하는 세 지점 — 을 정면으로 다룹니다.
스토리 — 프록시라는 문지기
먼저 @Transactional이 어떻게 동작하는지 알아야 함정이 보여요. 비밀은 프록시(proxy)
입니다. Spring은 @Transactional이 붙은 빈을 대리인(proxy)으로 감쌉니다. 외부에서
그 메서드를 호출하면, 진짜 객체가 아니라 문지기(프록시)가 먼저 받아요.
호출자 → [프록시] → BEGIN → 진짜 객체.메서드() → COMMIT/ROLLBACK
↑ 문지기가 여기서 트랜잭션을 열고 닫는다문지기는 메서드에 들어가기 직전 트랜잭션을 열고, 나온 직후 커밋(또는 롤백)합니다. 그런데 이 “문지기가 가로채는” 구조 때문에, 세 가지 배신이 생겨요.
배신 ①: self-invocation — 안에서 부르면 문지기를 건너뛴다
같은 클래스 안에서 @Transactional 메서드를 자기 자신이 호출하면?
public void outer() {
inner(); // ← 프록시를 거치지 않는 내부 호출!
}
@Transactional
public void inner() { ... } // 여기 붙은 트랜잭션이... 안 열린다outer()가 inner()를 부를 때, 그 호출은 프록시를 안 거칩니다. 이미 진짜 객체
안이거든요. 문지기를 지나치니 트랜잭션도 안 열려요. inner()에 @Transactional을
분명히 붙였는데 트랜잭션 애노테이션이 통째로 무시됩니다(메서드는 돌지만 트랜잭션만
안 열려요). 주니어가 “왜 롤백이 안 되지?” 하고
밤새우는 대표적 함정이에요.
배신 ②: checked 예외는 롤백 안 된다
5장에서 예고한 그 함정입니다. @Transactional의 기본 롤백 정책은 이래요.
- unchecked 예외(
RuntimeException)와Error→ 롤백 - checked 예외(
Exception중RuntimeException이 아닌 것) → 커밋 (!)
@Transactional
public void save() throws IOException { // checked 예외
repository.save(data);
if (bad) throw new IOException("실패!"); // 롤백될 줄 알았지만... 커밋된다!
}롤백을 기대하고 checked 예외를 던졌는데 데이터는 저장돼 버려요. 부분 저장, 바로 이 책이 1장부터 싸워온 그 부분 실패입니다. 막으려면 명시적으로 지정해야 해요.
@Transactional(rollbackFor = Exception.class) // 모든 예외에 롤백배신 ③: 트랜잭션 안의 외부 호출 — 커넥션을 인질로 잡는다
이게 제일 무섭습니다. 5장에서 미뤄둔 그 문제예요.
@Transactional
public void checkout(Order order) {
inventory.decrease(order); // DB 작업
paymentApiClient.pay(order); // ← 외부 결제사 HTTP 호출 (2초 걸림)
orderRepository.save(order); // DB 작업
}트랜잭션이 열리고 첫 DB 작업이 나가면 커넥션 하나를 빌려서 메서드 끝까지 쥡니다. 그런데 중간의 외부 결제 호출이 2초 걸리면? 그 2초 동안 커넥션을 놀리면서 붙잡고 있어요. 트래픽이 몰리면 이런 커넥션들이 쌓여 커넥션 풀이 바닥납니다. 그럼 새 요청은 커넥션을 못 얻어 대기하다 죽고, 서비스 전체가 마비돼요. 16장 데드락이 “시끄러운 재앙” 이었다면, 이건 에러도 없이 서서히 목을 조르는 조용한 재앙입니다.
커넥션만 문제가 아닙니다. 롤백은 더 얄궂어요. 결제는 외부에서 이미 성공했는데 뒤의 DB 작업이 실패해 롤백되면? DB는 되돌아가도 결제는 안 돌아옵니다. 돈은 빠져나갔는데 주문은 없는, 5장 결제 스토리의 그 악몽이 재현되죠.
핵심 — 경계는 짧게, 명시적으로
세 배신의 교훈은 하나로 모입니다. 트랜잭션 경계를 의식하라. @Transactional은
마법이 아니라 프록시가 긋는 선이고, 그 선은 짧고 순수해야 합니다.
- 외부 호출은 트랜잭션 밖으로. 결제·이메일·다른 서비스 API 호출은 트랜잭션 경계 바깥에서 하세요. DB 작업만 트랜잭션으로 짧게 묶고, 외부 호출은 그 전이나 후로 뺍니다.
- 트랜잭션은 짧게. 16장 데드락 예방과 같은 원칙이에요. 락을 짧게 쥘수록 안전합니다. 트랜잭션 안에서 무거운 계산이나 대기(외부 I/O)를 하지 마세요.
- 경계를 명시하라. self-invocation을 피하고(트랜잭션이 필요한 메서드를 다른 빈으로
분리해 프록시를 거치게), 롤백 정책을 확인하고(
rollbackFor), “이 메서드의 트랜잭션은 어디서 열려 어디서 닫히나”를 항상 의식합니다.
결국 트랜잭션은 짧고 정직한 묶음일 때 가장 강합니다. @Transactional 한 줄 뒤에
프록시·커넥션·락·로그가 전부 얽혀 돌아간다는 걸 아는 개발자와, “그냥 붙이면 되는 마법”
으로 아는 개발자의 코드는 장애 앞에서 갈립니다.
정리
@Transactional은 프록시가 긋는 경계다. 프록시가 메서드를 가로채 트랜잭션을 열고 닫는다.- 세 배신:
- ① self-invocation — 같은 클래스 내부 호출은 프록시를 안 거쳐 트랜잭션이 안 열린다.
- ② checked 예외 — 기본은 unchecked만 롤백, checked는 커밋. →
rollbackFor지정. - ③ 트랜잭션 안 외부 호출 — 커넥션을 오래 쥐어 풀 고갈(조용한 재앙) + 롤백해도 외부 작업은 안 돌아온다.
- 원칙: 외부 호출은 트랜잭션 밖으로, 트랜잭션은 짧게, 경계를 명시적으로.
생각해볼 질문: 지금까지 우리는 트랜잭션을 하나의 DB 안에서만 이야기했어요.
@Transactional도, 락도, WAL도 전부 한 데이터베이스가 전제였죠. 그런데 요즘 시스템은
서비스가 여러 개로 쪼개지고, 데이터도 여러 DB에 흩어져 있습니다. 하나의 약속이 여러
DB에 걸쳐야 한다면? 마지막 장, 경계를 넘는 트랜잭션으로 갑니다.