[Spring] @Transactional 동작 원리: 전파(propagation), 롤백 규칙, REQUIRES_NEW
![[Spring] @Transactional 동작 원리: 전파(propagation), 롤백 규칙, REQUIRES_NEW](https://blog.kakaocdn.net/dna/Yfa4G/dJMcadCaFFd/AAAAAAAAAAAAAAAAAAAAABmiYsiewqJjSly8PjeZPLRQ4MqzHvKPW9dCy7kNCz4l/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1788188399&allow_ip=&allow_referer=&signature=ElllthGO86JeiqzHPDaKPCAfgGI%3D)
[Spring] @Transactional 동작 원리: 전파(propagation), 롤백 규칙, REQUIRES_NEW
@Transactional은 한 줄이면 트랜잭션이 되니 편한데, 그만큼 "왜 이러지?" 순간도 많아요. 예외를 잡았는데 롤백되고요. 예외가 났는데 커밋되고요. 내부 호출에선 아예 안 먹고요. 전부 동작 원리 — 프록시·전파·롤백 규칙 — 세 가지에서 나오는 일이에요.
@Transactional이 실제로 뭘 하는지, 전파 속성(REQUIRED vs REQUIRES_NEW)과 그 유명한 UnexpectedRollbackException, checked 예외가 롤백 안 되는 규칙, readOnly까지 한 번에 짚어요. Spring 함정 시리즈의 기둥이 되는 글이에요.
01. 실체는 프록시예요

스프링은 @Transactional이 붙은 빈을 그대로 주입하지 않고, 그 빈을 감싼 프록시를 주입해요. 호출이 프록시를 거치면서 ① 트랜잭션 시작(커넥션 획득) → ② 실제 메서드 실행 → ③ 정상이면 commit, 예외면 rollback이 일어나는 거예요.
이 사실에서 함정 몇 개가 바로 따라 나와요. 프록시를 안 거치면 아무 일도 안 일어나요. 같은 클래스 안에서 this.method()로 부르는 자기호출, private 메서드, final 메서드가 그 경우예요(이건 다음 편에서 정면으로 다뤄요). 오늘은 프록시를 제대로 거쳤을 때의 이야기 — 전파와 롤백이에요.
02. 전파, 트랜잭션 안에서 트랜잭션을 부르면
서비스 A의 트랜잭션 메서드가 서비스 B의 트랜잭션 메서드를 불러요. B는 새 트랜잭션을 열까요, A에 합류할까요? 이걸 정하는 게 전파(propagation)예요.

REQUIRED (기본값), 합류
이미 트랜잭션이 있으면 합류하고, 없으면 새로 만들어요. A→B가 한 덩어리가 되니 "둘 다 성공 아니면 둘 다 취소"가 자연스럽게 보장돼요. 대부분의 경우 이게 맞는 동작이에요.
그런데 여기에 그 유명한 함정이 있어요. B에서 RuntimeException이 나면, B의 프록시가 공유 트랜잭션에 rollback-only 마킹을 해요. A가 그 예외를 try-catch로 잡고 "B는 실패해도 괜찮아, 난 계속할래" 하고 커밋하려 하면 — 이미 rollback-only라 커밋이 불가능해요. 그래서 UnexpectedRollbackException이 터져요. "예외를 잡았는데 왜 롤백돼요?"의 정체예요. 같은 트랜잭션에 합류한 이상, 내부의 실패는 잡는다고 없던 일이 안 돼요.
REQUIRES_NEW, 분리
기존 트랜잭션을 잠시 멈추고 완전히 새 트랜잭션을 열어요. B의 커밋/롤백이 A와 독립이에요. "본 작업이 실패해도 시도 이력은 남겨야 한다"(이력·감사 로그), "부가 작업 실패가 본 작업을 못 막게 한다"(알림 발송 기록) 같은 운명 분리가 필요할 때 써요.
주의 두 가지예요. 첫째, 커넥션을 2개 점유해요 — A 것 하나, B 것 하나. REQUIRES_NEW가 깊이 중첩되면 커넥션 풀이 빨리 말라요. 둘째, A가 나중에 롤백돼도 B는 이미 커밋돼 있어요. 그게 의도여야 해요.
나머지 전파(SUPPORTS, MANDATORY, NESTED 등)는 존재만 알아두면 돼요. 실무의 99%는 REQUIRED와 REQUIRES_NEW예요.
03. 롤백 규칙, checked 예외는 커밋돼요
의외로 많이 모르는 규칙이에요. 스프링의 기본 롤백 대상은 RuntimeException과 Error뿐이에요.

@Transactional
public void process() throws IOException {
orderRepository.save(order);
fileService.write(order); // IOException(checked) 발생!
}
// → 예외는 던져졌지만 save는 커밋된다 😱
checked 예외(IOException, 커스텀 Exception 상속 예외)가 던져져도 커밋돼요. "예외가 났는데 데이터는 들어가 있어요"라는 기괴한 상황이 여기서 나와요. 역사적으로 "checked 예외 = 호출자가 처리할 수 있는 비즈니스 상황"이라는 EJB 시절 관례에서 온 규칙이에요.
해법은 둘 중 하나예요. @Transactional(rollbackFor = Exception.class)로 명시하거나, 팀 컨벤션으로 비즈니스 예외를 전부 RuntimeException 상속으로 통일하는 거예요. 후자가 애너테이션 누락 위험이 없어 더 견고해요. Spring Framework 6.2부터는 @EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)로 전역 기본 자체를 바꿀 수도 있어요.
04. readOnly = true, 단순 표식이 아니에요
조회 메서드에 @Transactional(readOnly = true)를 붙이는 건 문서화 이상의 효과가 있어요. JPA에서는 flush 모드가 바뀌어 더티 체킹용 스냅샷 비교를 건너뛰니 메모리·CPU가 절약돼요. 레플리카 DB로 읽기를 라우팅하는 구성에서는 이 플래그가 라우팅 기준이 되기도 하고요.
다만 "쓰기를 막아주는 안전장치"로 과신하면 안 돼요 — DB·드라이버에 따라 쓰기가 실제로 차단되지 않을 수 있어요. 의도 표현 + 최적화 힌트로 이해하는 게 정확해요.
05. 자주 만나는 문제
UnexpectedRollbackException이 나요
REQUIRED로 합류한 내부 트랜잭션의 예외를 바깥에서 잡은 거예요. 운명을 분리하고 싶으면 내부를 REQUIRES_NEW로, 같이 죽어야 하면 잡지 말고 같이 롤백돼요.
예외가 났는데 데이터가 저장돼 있어요
checked 예외예요. rollbackFor를 명시하거나 RuntimeException으로 바꿔요.
REQUIRES_NEW를 썼더니 커넥션 풀이 말라요
중첩 호출마다 커넥션이 2개씩 점유되는 구조예요. 정말 운명 분리가 필요한 지점에만 좁게 쓰고, 이벤트 기반 분리(다음다음 편의 @TransactionalEventListener)도 대안이에요.
@Transactional을 붙였는데 아예 트랜잭션이 없어요
프록시를 안 거친 거예요 — 자기호출·private·다른 스레드. 다음 편에서 정면으로 다뤄요.
정리
@Transactional의 본질은 한 문장이에요 — 프록시가 시작과 커밋/롤백을 감싼다. 여기서 나머지가 다 따라 나와요. 전파 기본값 REQUIRED는 합류라서, 내부 예외를 try-catch로 잡아도 rollback-only에 걸려 UnexpectedRollbackException이 터져요. 운명을 떼고 싶으면 REQUIRES_NEW지만 커넥션을 2개 쓴다는 값을 치르고요. 롤백 대상은 RuntimeException뿐이라 checked 예외는 rollbackFor로 명시하거나 RuntimeException으로 통일해야 하고, 조회는 readOnly로 최적화 힌트를 줘요.
프록시가 무력화되는 자기호출 함정은 바로 다음 글에서 정면으로 다뤄요. 같은 지반 위의 JPA 트랜잭션 경계 이야기(영속성 컨텍스트·OSIV)도 함께 보면 그림이 맞물려요.
출처: Spring Framework — Declarative Transaction Management · Spring — @Transactional Settings
'백엔드 > 프레임워크 & 언어' 카테고리의 다른 글
| [Spring] 애너테이션이 안 먹을 때 트러블슈팅: 트랜잭션, 캐시, 비동기 증상별 진단 가이드 (0) | 2026.08.05 |
|---|---|
| [Spring] 순환 참조 해결과 빈 초기화 함정: 생성자 주입, @Lazy, @PostConstruct 주의점 (1) | 2026.08.04 |
| [Spring] @Async 함정 정리: 스레드풀 설정, 예외 증발, 트랜잭션·컨텍스트 미전파 (0) | 2026.08.03 |
| [Spring] @TransactionalEventListener 정리: AFTER_COMMIT, 이벤트와 트랜잭션 타이밍 (0) | 2026.07.31 |
| [Spring] @Transactional이 안 먹는 이유: 프록시 자기호출, private, 내부 호출 해결 (0) | 2026.07.30 |
📚 같이 보면 좋은
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."