MOMSOFT / 기술 블로그
@Transactional을 잘못 걸어 생기는 사고들
애너테이션 한 줄은 붙는 순간 끝나는 설정이 아니라, 프록시·커넥션·예외 규칙이 얽힌 계약입니다. 그 계약이 깨지는 지점과 점검 순서를 짚습니다.
프로젝트 문의하기 →결론부터: 대부분의 사고는 "트랜잭션이 안 걸려서"가 아니라 "생각과 다르게 걸려서" 생깁니다
@Transactional은 붙이기 쉽고, 붙인 뒤에는 잘 동작하는 것처럼 보입니다. 문제는 평상시에 드러나지 않는다는 데 있습니다. 트래픽이 몰릴 때, 외부 API가 느려질 때, 예외가 하필 체크 예외일 때 — 그때 한 번에 터집니다. 그리고 터진 뒤에 로그를 보면 대개 애플리케이션 코드에는 아무 잘못이 없어 보입니다. 롤백돼야 할 것이 커밋됐거나, 커밋돼야 할 것이 조용히 사라졌거나, 커넥션 풀이 말라서 요청 전체가 멈췄을 뿐입니다.
이 글의 입장은 분명합니다. 트랜잭션 애너테이션은 "선언"이 아니라 "프록시·커넥션·예외 규칙이 맞물린 계약"이고, 그 계약 조건을 모르면 붙이지 않는 편이 안전합니다. 아래에서는 실제 장애로 이어지는 다섯 가지 경로를 메커니즘 수준에서 설명하고, 코드 리뷰에서 무엇을 어떤 순서로 보면 되는지, 그리고 마지막으로 트랜잭션을 걸지 말아야 하는 경우를 정리합니다. 실무 기준을 한 줄로 줄이면 "경계는 유스케이스 하나에 걸고, 그 안에는 DB 작업만 두고, 짧게 유지한다"입니다. 나머지는 이 원칙을 못 지킬 때 필요한 지식입니다.
1. 프록시를 안 타는 경우 — self-invocation
스프링의 선언적 트랜잭션은 기본적으로 프록시로 동작합니다. 빈을 주입받은 쪽이 실제 객체가 아니라 프록시를 들고 있고, 프록시가 메서드 호출을 가로채 트랜잭션을 시작한 뒤 대상 객체에 위임합니다. 여기서 핵심은 가로채기는 "외부에서 프록시를 통해 들어온 호출"에만 일어난다는 점입니다.
@Service
public class OrderService {
public void placeAll(List<OrderRequest> reqs) {
for (OrderRequest r : reqs) {
place(r); // this.place(...) — 프록시를 거치지 않음
}
}
@Transactional
public void place(OrderRequest r) {
// 트랜잭션이 시작되지 않는다
}
}
placeAll 안에서의 place(r)는 this.place(r)입니다. 프록시는 이미 placeAll 진입 시점에 역할을 끝냈고, 그 안쪽 호출은 대상 객체 내부에서 벌어지는 일이라 인터셉터가 개입할 수 없습니다. 결과적으로 @Transactional은 아무 일도 하지 않습니다.
그다음에 벌어지는 일은 무엇을 쓰느냐에 따라 갈립니다. Spring Data JPA의 리포지터리는 그 자체가 트랜잭션 경계를 갖고 있어서, save 같은 호출이 호출 단위로 각각 커밋됩니다. 즉 "실패하면 전부 되돌아가겠지"라고 기대한 반복문이 실제로는 성공한 앞부분만 남깁니다. 반대로 엔티티 필드만 바꾸고 저장 호출을 하지 않은 코드라면, 트랜잭션이 없어 변경 감지가 아예 동작하지 않으므로 아무것도 반영되지 않습니다. 어느 쪽이든 부분 반영된 데이터를 나중에 손으로 정리하게 되는 전형적인 경로입니다.
같은 원리로 안 걸리는 것들
- private 메서드: 프록시 방식에서는 어떤 설정으로도 가로챌 수 없습니다.
protected·패키지 전용 메서드는 프록시 방식(JDK 동적 프록시 / CGLIB)과 스프링 버전에 따라 처리가 달라졌으니, "된다/안 된다"를 기억으로 단정하지 말고 쓰는 버전의 문서와 작은 테스트로 확인하십시오. 안전한 습관은 트랜잭션 경계 메서드를 public으로 두는 것입니다. - 생성자·
@PostConstruct안에서의 호출: 빈 초기화 도중에는 자기 자신 호출이 그대로 대상 객체로 들어가고, 다른 빈을 부르더라도 초기화 순서에 따라 프록시가 완성되기 전일 수 있습니다. 초기화 시점에 DB 작업이 필요하다면 별도 빈이나 애플리케이션 이벤트로 미루는 편이 낫습니다. - 스케줄러·이벤트 리스너가 같은 클래스의 메서드를 직접 부르는 경우: 위와 동일합니다.
- 새 스레드에서 실행되는 코드: 트랜잭션 컨텍스트는 스레드 로컬에 묶입니다.
@Async나 직접 만든 스레드로 넘어가는 순간 바깥 트랜잭션은 따라가지 않습니다. 반대로 그 안에서@Transactional빈을 호출하면 완전히 별개의 트랜잭션이 시작됩니다. - 인터페이스에만 애너테이션을 단 경우: 자바 애너테이션은 인터페이스에서 구현 클래스로 상속되지 않습니다. 스프링 문서도 인터페이스가 아니라 구현 클래스에 붙이기를 권합니다. 프록시 방식에 따라 동작할 수도 있지만, 방식이 바뀌는 순간 조용히 깨지는 의존이라 애초에 만들지 않는 게 낫습니다.
해결 순서
- 경계를 옮긴다. 가장 좋은 답입니다. 트랜잭션 경계는 "유스케이스 하나"에 붙어야 하므로, 위 예시라면
placeAll쪽이 경계인지place하나가 경계인지를 먼저 정하고 그 자리에 붙입니다. - 빈을 분리한다. 반복 단위로 각각 트랜잭션이 필요하다면
place를 다른 빈으로 빼서 주입받아 호출합니다. 클래스가 하나 늘지만 의도가 코드에 드러납니다. - 자기 자신 주입. 스프링 4.3부터 자기 참조 주입이 허용되어 프록시를 통해 자신을 호출할 수 있습니다. 동작하지만 읽는 사람이 이유를 알기 어려워, 주석 없이는 쓰지 않는 편이 낫습니다.
AopContext.currentProxy()는exposeProxy설정이 필요하고 코드가 AOP 인프라에 묶입니다. 마지막 수단입니다.
실무적으로는 1번과 2번으로 거의 다 해결됩니다. 3, 4번이 필요해졌다면 대개 그 클래스가 너무 많은 책임을 지고 있다는 신호입니다.
2. readOnly에 대한 오해
@Transactional(readOnly = true)는 "쓰기를 막는 장치"가 아닙니다. 힌트입니다. 무엇에 대한 힌트인지 나눠서 봐야 합니다.
영속성 컨텍스트에 대한 힌트
JPA/하이버네이트 조합에서 readOnly 트랜잭션은 세션의 플러시 모드를 사실상 수동으로 바꿉니다. 즉 더티 체킹 결과가 커밋 시점에 자동으로 flush되지 않습니다. 여기서 가장 흔한 사고가 나옵니다. 조회 전용이라고 생각해 readOnly를 붙인 메서드 안에서 엔티티 필드를 바꾸면, 예외도 로그도 없이 변경이 그냥 사라집니다. "분명히 값을 바꿨는데 DB에 반영이 안 된다"는 제보의 상당수가 이 경우입니다. 조용히 실패하기 때문에 발견이 늦고, 발견됐을 땐 이미 며칠치 데이터가 어긋나 있습니다.
반대로 일부만 써지는 경우도 있습니다. 식별자 생성 전략이 IDENTITY면 새 엔티티를 영속화하는 순간 키를 받기 위해 INSERT가 즉시 나가야 합니다. 플러시 모드가 수동이어도 이 INSERT는 시도됩니다. 그다음은 커넥션 쪽에 달렸습니다. 드라이버가 읽기 전용 플래그를 흘려보내면 INSERT는 그대로 성공하고, 드라이버가 그 플래그를 실제 트랜잭션 읽기 전용 설정으로 옮기는 DB라면 그 자리에서 예외가 납니다. 앞의 경우가 더 나쁩니다. "부모 행은 들어갔는데 자식 업데이트는 사라진" 절반의 성공이 만들어지고, 전부 실패했다면 즉시 눈에 띄었을 문제가 아무 신호 없이 지나갑니다.
커넥션·드라이버에 대한 힌트
스프링은 readOnly 트랜잭션에서 JDBC 커넥션에 읽기 전용 플래그를 전달합니다. 다만 이 플래그를 실제로 강제하는지는 DB와 드라이버, 그리고 드라이버 옵션에 따라 다릅니다. 어떤 조합은 무시하고, 어떤 조합은 세션이나 트랜잭션 수준의 읽기 전용 설정으로 바꿔 쓰기 시도를 거부합니다. 따라서 "readOnly를 붙였으니 실수로 쓰는 일은 없다"도, "readOnly를 붙여도 쓰기는 그냥 된다"도 둘 다 이식성 없는 가정입니다. 정말로 쓰기를 막고 싶다면 애너테이션이 아니라 읽기 전용 권한만 가진 DB 계정으로 막아야 합니다.
읽기/쓰기 라우팅과의 조합
마스터/리플리카 라우팅을 붙인 시스템에서는 대개 현재 트랜잭션의 readOnly 여부로 데이터소스를 고릅니다. 이 구조에서 readOnly는 "복제 지연을 감수하겠다"는 선언이 됩니다. 쓰기 직후 같은 요청 흐름에서 readOnly 메서드로 조회하면, 방금 쓴 값이 안 보일 수 있습니다. 결제 직후 상태 조회, 가입 직후 프로필 조회 같은 곳에서 재현이 안 되는 버그로 나타납니다.
여기엔 구현상의 함정이 하나 더 있습니다. 라우팅 판단은 "현재 트랜잭션이 읽기 전용인가"를 물어보는 시점에 일어나는데, 커넥션을 트랜잭션 시작 시점에 바로 가져오는 구성이면 그 판단이 이미 끝난 뒤라 라우팅 키가 의도와 다르게 잡힐 수 있습니다. 그래서 이런 구조는 보통 커넥션 획득을 첫 쿼리 시점까지 늦추는 래퍼(스프링의 LazyConnectionDataSourceProxy 같은)를 함께 씁니다. 라우팅을 붙였는데 항상 한쪽으로만 간다면 여기부터 확인하십시오.
중첩되면 무시된다
전파 속성이 REQUIRED인 메서드가 이미 열린 트랜잭션에 참여하면, 그 메서드에 적힌 readOnly와 격리 수준은 적용되지 않습니다. 바깥 트랜잭션의 설정이 그대로 유지됩니다(트랜잭션 매니저의 검증 옵션을 켜면 예외로 알려주게 만들 수는 있지만 기본값은 조용히 무시하는 쪽입니다). 즉 readOnly = true가 붙어 있어도 호출 경로에 따라 실제로는 읽기/쓰기 트랜잭션일 수 있습니다.
반대 방향이 더 위험합니다. readOnly 조회 메서드가 바깥이 되고 그 안에서 쓰기 메서드를 부르면, 안쪽에 readOnly = false가 적혀 있어도 바깥 설정이 이깁니다. 플러시가 안 나가거나 커넥션이 거부해서, "이 메서드는 단독 테스트에선 되는데 저 화면에서 부르면 저장이 안 된다"는 형태로 나타납니다. 애너테이션만 보고 "이 메서드는 읽기 전용"이라고 판단하면 안 되는 이유입니다.
정리하면 readOnly는 성능 힌트이자 라우팅 신호이지 안전장치가 아닙니다. 붙일 값어치는 충분하지만, 붙였다는 사실로부터 무엇도 보장받지는 마십시오.
3. 트랜잭션 안에서 외부 API를 호출하면 무슨 일이 생기는가
이건 단일 코드의 버그가 아니라 시스템 전체를 멈추는 종류의 실수입니다. 메커니즘은 단순합니다.
@Transactional
public void confirm(Long orderId) {
Order order = repo.findById(orderId).orElseThrow();
order.confirm();
pgClient.capture(order); // 외부 결제사 HTTP 호출
mailSender.send(order); // 메일 발송
fileStore.writeReceipt(order); // 파일 IO
}
트랜잭션이 살아 있는 동안 커넥션은 그 스레드에 묶여 있습니다(정확히는 첫 쿼리로 커넥션을 잡은 뒤 커밋·롤백까지). 외부 호출이 응답하지 않으면 커넥션도 그만큼 붙잡혀 있습니다. 외부 API가 평소보다 느려지는 순간, 동시 요청 수만큼 커넥션이 잠기고, 커넥션 풀은 풀 사이즈에서 멈춥니다. 그다음부터는 결제와 무관한 모든 요청이 커넥션을 못 받아 대기합니다. 장애 원인은 외부 결제사인데, 증상은 "서비스 전체 다운"으로 나타납니다.
여기에 세 가지가 더 겹칩니다.
- 락 점유 시간이 길어집니다. 그 트랜잭션이 잡은 행 잠금이 외부 호출 시간만큼 유지되므로, 같은 행을 건드리는 다른 트랜잭션이 줄줄이 대기하다 락 타임아웃으로 실패합니다.
- 외부 작업은 롤백되지 않습니다. 결제 승인을 보내고 나서 DB 커밋이 실패하면, 돈은 나갔는데 주문은 없는 상태가 됩니다. 반대로 메일이 나간 뒤 롤백되면 "취소된 주문의 완료 메일"이 고객에게 갑니다. 트랜잭션은 DB만 되돌립니다.
- 커밋 전에 발행한 메시지가 먼저 소비됩니다. 트랜잭션 안에서 큐에 이벤트를 넣거나 캐시를 갱신하면, 소비자가 아직 커밋되지 않은 데이터를 읽으러 옵니다. 조회 실패나 옛 값으로 이어지는데, 부하가 낮을 땐 커밋이 먼저 끝나 재현되지 않습니다. 부하가 오를수록 실패율이 오르는 형태라 원인을 찾기 어렵습니다.
어떻게 바꾸는가
- 경계를 쪼갭니다. "DB 상태 변경"과 "외부 호출"을 분리하고, 트랜잭션은 DB 구간에만 겁니다. 외부 호출은 트랜잭션 밖에서 수행합니다.
- 커밋 이후로 미룹니다.
@TransactionalEventListener의 커밋 이후 단계로 옮기면 롤백된 작업의 부수 효과가 나가는 일을 막을 수 있습니다. 단, 그 시점에는 원래 트랜잭션이 이미 끝났으므로 거기서 다시 DB를 쓰려면 새 트랜잭션이 필요합니다. 이걸 놓치면 "리스너 안의 저장이 반영되지 않는" 두 번째 사고로 이어집니다. - 되돌릴 수 없는 작업은 기록으로 남깁니다. 같은 트랜잭션 안에 "보낼 메시지" 행을 남기고, 별도 프로세스가 그 행을 읽어 실제 발송하는 구조(아웃박스)로 가면 DB 커밋과 외부 발송의 원자성 문제를 실용적인 수준까지 낮출 수 있습니다. 대신 최소 1회 발송이 되므로 수신 측 멱등성이 전제입니다.
- 외부 호출에는 반드시 타임아웃을 겁니다. 연결 타임아웃과 읽기 타임아웃을 모두 지정합니다. 트랜잭션 밖으로 뺐더라도, 타임아웃 없는 HTTP 호출은 스레드를 잡아먹어 결국 같은 형태의 장애를 만듭니다.
여기서 자주 오해하는 게 @Transactional(timeout = ...)입니다. 이 값은 대체로 DB 쪽 작업에 남은 시간을 계산해 넘기는 방식으로 동작합니다. 이미 실행 중인 HTTP 호출을 중단시키지 못하고, 다음 쿼리를 낼 때가 되어서야 시간 초과를 알립니다. 즉 트랜잭션 타임아웃은 늘어진 트랜잭션을 발견하는 장치이지, 외부 호출을 끊는 장치가 아닙니다. 끊는 일은 HTTP 클라이언트 설정이 합니다.
파일 IO, 대용량 이미지 변환, 압축, 암복호화 같은 CPU·디스크 작업도 같은 기준으로 봅니다. 판단 기준은 "외부인가"가 아니라 "트랜잭션 시간이 이 작업 때문에 예측 불가능해지는가"입니다.
4. 롤백 규칙 — 예외 종류에 따라 결과가 갈립니다
스프링의 기본 롤백 규칙은 다음과 같습니다. 판단은 프록시를 빠져나가는 예외를 기준으로 이루어집니다.
| 예외 종류 | 기본 동작 |
|---|---|
| RuntimeException 및 하위 | 롤백 |
| Error 및 하위 | 롤백 |
| 체크 예외(Exception 하위, RuntimeException 아님) | 커밋 |
세 번째 줄이 사고의 근원입니다. 도메인 예외를 체크 예외로 설계한 코드베이스에서, 예외를 던졌으니 당연히 롤백됐으리라 믿고 넘어갑니다. 실제로는 커밋됩니다. 컨트롤러는 에러 응답을 내려보내고 DB에는 데이터가 남습니다. 사용자 화면에는 실패, 데이터에는 성공이 기록되는 상태입니다.
해결은 둘 중 하나입니다. 도메인 예외를 언체크 예외로 통일하거나, rollbackFor를 명시하는 것입니다. 팀 차원에서는 앞쪽을 규칙으로 정하는 편이 낫습니다. 애너테이션 속성은 빠뜨리기 쉽고, 빠뜨렸을 때 아무도 모르기 때문입니다. 굳이 속성으로 간다면 클래스 레벨에 한 번 선언해 두고 메서드마다 반복하지 않는 쪽이 실수가 적습니다.
예외를 잡아먹으면 롤백은 없습니다
당연해 보이지만 실수는 반복됩니다. 트랜잭션 메서드 안에서 try-catch로 예외를 처리하고 정상 흐름으로 복귀하면, 프록시 입장에서는 아무 예외도 던져지지 않았으므로 커밋합니다. 로그만 남기고 삼키는 catch 블록이 트랜잭션 안에 있다면 의심하십시오. 정말로 "이 실패는 무시하고 커밋해도 된다"가 맞다면, 왜 그런지를 주석으로 남겨야 다음 사람이 지우지 않습니다.
UnexpectedRollbackException — 가장 헷갈리는 형태
이 패턴은 원리를 알기 전까지 절대 이해되지 않습니다.
@Transactional // (A) 바깥 트랜잭션
public void process() {
for (Item i : items) {
try {
itemService.handle(i); // (B) REQUIRED — A에 "참여"
} catch (Exception e) {
log.warn("건너뜀", e); // 실패는 무시하고 계속
}
}
} // 여기서 UnexpectedRollbackException
(B)는 새 트랜잭션을 만들지 않고 (A)에 참여합니다. (B) 안에서 롤백 대상 예외가 나면 스프링은 공유 트랜잭션에 rollback-only 표시를 남깁니다. 바깥에서 예외를 잡아 무시해도 그 표시는 지워지지 않습니다. 결국 (A)가 커밋하려는 순간 "롤백만 가능한 트랜잭션"이라며 UnexpectedRollbackException이 터지고, 성공했던 항목까지 전부 롤백됩니다.
이 동작은 참여 실패 시 전체를 롤백 표시하는 트랜잭션 매니저의 기본 정책에서 나옵니다. 그 정책을 끄는 설정이 있긴 하지만, 끄면 "일부는 실패했는데 전체는 커밋되는" 더 나쁜 상태를 허용하게 되므로 권하지 않습니다.
"일부 실패는 건너뛰고 나머지는 저장한다"는 요구사항을 이 구조로 구현하면 반드시 실패합니다. 실패를 격리하려면 트랜잭션 자체를 분리해야 합니다. 즉 반복문을 트랜잭션 밖에 두고 항목 처리 메서드에만 트랜잭션을 걸거나(이때 두 메서드는 서로 다른 빈에 있어야 합니다 — 1번 문제로 되돌아갑니다), 정말 필요하다면 REQUIRES_NEW를 쓰되 아래의 대가를 감수해야 합니다.
5. 전파 속성은 무엇을 기준으로 고르는가
전파 속성 목록을 외우는 건 의미가 없습니다. 실무에서 던져야 할 질문은 하나입니다. "이 안쪽 작업은 바깥 작업과 운명을 같이해야 하는가?"
| 속성 | 동작 | 쓰는 상황 |
|---|---|---|
| REQUIRED (기본) | 있으면 참여, 없으면 시작 | 대부분. 바깥과 운명을 같이하는 작업 |
| REQUIRES_NEW | 바깥을 보류하고 새 트랜잭션 시작 | 바깥이 롤백돼도 남아야 하는 기록(감사 로그, 실패 이력) |
| NESTED | 세이브포인트 기반 부분 롤백 | 드묾. 매니저 설정과 세이브포인트 지원을 먼저 확인 |
| SUPPORTS | 있으면 참여, 없으면 트랜잭션 없이 | 조회 유틸 성격의 메서드 |
| NOT_SUPPORTED | 있으면 보류하고 트랜잭션 없이 실행 | 긴 조회·배치성 작업을 트랜잭션에서 떼어낼 때 |
| MANDATORY | 없으면 예외 | 반드시 상위 경계 안에서만 불려야 하는 내부 연산 |
| NEVER | 있으면 예외 | 트랜잭션 안에서 절대 부르면 안 되는 작업 |
REQUIRES_NEW의 대가
REQUIRES_NEW는 커넥션을 하나 더 씁니다. 바깥 트랜잭션이 커넥션을 쥔 채 보류되고, 안쪽이 풀에서 새 커넥션을 가져갑니다. 즉 요청 하나가 커넥션 두 개를 동시에 점유합니다. 풀 크기가 N일 때 동시 요청 N개가 모두 바깥 트랜잭션을 열고 안쪽 커넥션을 기다리면, 아무도 반납하지 않고 아무도 받지 못하는 교착이 생깁니다. 평소 부하에서는 안 나오고 피크에서만 나오는 종류의 장애입니다. REQUIRES_NEW를 쓰기로 했다면 풀 사이즈 산정에 이 배수를 반영해야 합니다.
또 하나, REQUIRES_NEW 안에서는 바깥 트랜잭션이 아직 커밋하지 않은 데이터를 볼 수 없습니다. 별개의 트랜잭션이니 당연한 일이지만, "방금 저장한 걸 왜 못 읽지"라는 형태로 헤매게 됩니다. 더 나쁜 경우는 바깥이 이미 잠근 행을 안쪽이 건드리는 상황입니다. 서로 다른 트랜잭션이므로 DB는 봐주지 않고, 한 요청이 자기 자신을 기다리다 락 타임아웃으로 끝납니다.
NESTED는 확인하고 쓰십시오
NESTED는 세이브포인트에 의존합니다. 트랜잭션 매니저가 중첩 트랜잭션을 허용하도록 설정돼 있어야 하고, 그 아래 다이얼렉트·드라이버가 세이브포인트를 지원해야 합니다. JPA 환경에서는 이 조건이 기본으로 만족되지 않는 경우가 있어, 기대와 달리 예외로 끝나기도 합니다. "부분 롤백이 되겠지"라고 믿고 붙이기보다 작은 통합 테스트로 실제 동작을 확인하고 쓰는 편이 낫습니다.
기본값을 벗어날 때는 이유를 남기십시오
REQUIRED가 아닌 값을 쓴다는 건 "이 작업은 바깥과 운명이 다르다"는 설계 결정입니다. 나중에 읽는 사람은 그 이유를 알 수 없으므로, 한 줄 주석이나 커밋 메시지로 근거를 남겨야 합니다. 근거를 못 적겠다면 십중팔구 REQUIRED로 충분합니다.
실제로 터지는 조합
각 항목 하나만으로도 문제지만, 장애는 대개 둘 이상이 겹칠 때 발생합니다.
조합 1: self-invocation + 배치 루프
한 클래스 안에서 반복문이 트랜잭션 메서드를 부르는 구조입니다. 트랜잭션이 아예 안 걸리므로 중간에 실패하면 앞부분만 반영됩니다. 재실행하면 이번엔 중복이 생깁니다. 멱등성이 없는 배치라면 여기서부터는 수작업 복구입니다.
조합 2: readOnly + 마스터/리플리카 + 쓰기 직후 조회
쓰기 트랜잭션이 끝난 직후 readOnly 조회가 리플리카로 가고, 복제가 아직 안 따라왔습니다. 응답에 옛 데이터가 실려 나갑니다. 재현율이 낮아 "가끔 이상하다"로 방치되다가, 정산이나 상태 전이 로직에서 문제가 됩니다. 쓰기 직후 확인이 반드시 필요한 흐름은 조회도 쓰기 경로로 보내야 합니다.
조합 3: 긴 트랜잭션 + 외부 API 지연 + 좁은 커넥션 풀
가장 파괴적인 조합입니다. 외부 의존성 한 곳의 지연이 커넥션 풀을 통해 서비스 전체로 번집니다. 여기에 OSIV(영속성 컨텍스트를 뷰 렌더링까지 열어 두는 설정)까지 켜져 있으면 지연 로딩 쿼리가 요청 후반부에 흩어져 나가고, 구성에 따라 커넥션 점유 구간도 함께 길어집니다. 다만 이 설정을 끄는 건 지연 로딩 사용 패턴을 함께 손봐야 하는 변경이라, 커넥션 지표를 확인한 뒤 결정하는 게 순서입니다.
조합 4: 체크 예외 + 부분 성공을 기대한 루프
체크 예외라 롤백이 안 되고, 그렇다고 REQUIRED 참여 구조에서 예외를 잡으면 이번엔 커밋 시점에 전량 롤백이 납니다. 두 실패 모드가 코드 리뷰에서 동시에 보이지 않기 때문에, 한쪽을 고치면 다른 쪽이 튀어나옵니다.
점검 순서 체크리스트
기존 코드베이스를 훑을 때는 아래 순서가 효율적입니다. 위쪽일수록 발견 비용 대비 위험이 큽니다.
- 같은 클래스 안에서
@Transactional메서드를 호출하는 곳을 전부 찾습니다. 프록시를 안 타므로 즉시 수정 대상입니다. - public이 아닌 메서드에 붙은
@Transactional을 찾습니다. private은 확실히 무효이고, 나머지는 프록시 방식에 따라 달라지므로 어느 쪽이든 경계를 public으로 정리하는 게 낫습니다. - 트랜잭션 메서드 안의 HTTP 클라이언트·메시지 발행·파일 IO·메일 발송을 찾습니다. 경계 밖으로 빼거나 커밋 이후로 미룹니다.
- 트랜잭션 안에서 예외를 삼키는 catch를 찾습니다. 의도된 것인지 확인하고, 의도됐다면 왜 롤백이 필요 없는지 주석으로 남깁니다.
- 체크 예외를 던지는 트랜잭션 메서드를 찾습니다. 롤백 정책이 명시돼 있는지 봅니다.
- readOnly 메서드 안의 상태 변경을 찾습니다. 엔티티 setter 호출, 저장 호출이 있는지 봅니다. 반대로 readOnly 메서드가 쓰기 메서드를 호출하는 경로도 함께 봅니다.
- REQUIRES_NEW 사용처를 모두 세고, 커넥션 풀 사이즈와 대조합니다.
- 커밋 이후 리스너 안의 DB 쓰기가 새 트랜잭션 위에서 도는지 확인합니다. 아니라면 그 저장은 반영되지 않습니다.
- 트랜잭션 타임아웃이 설정돼 있는지 봅니다. 무제한으로 열리는 트랜잭션은 장애 시 회복을 어렵게 만듭니다.
- 데이터소스가 둘 이상이면 각 애너테이션이 어떤 트랜잭션 매니저를 쓰는지 확인합니다.
- 테스트에서
@Transactional로 롤백시키는 구조가 실제 커밋 동작을 가리고 있지 않은지 확인합니다. 테스트 메서드가 트랜잭션을 열어 주면 self-invocation도, 커밋 이후 리스너 문제도 테스트에서는 드러나지 않습니다. 이 영역은 실제 커밋이 일어나는 테스트를 최소 몇 개는 갖고 있어야 합니다.
이 목록은 대부분 정적 검색으로 후보를 뽑고 사람이 판단하는 방식으로 진행됩니다. 자동화하려 하기보다는, 새 코드가 들어올 때 리뷰 체크리스트로 쓰는 편이 비용 대비 효과가 큽니다.
언제는 하지 말아야 하는가
여기까지 읽고 나면 트랜잭션을 더 촘촘히 관리해야 할 것 같지만, 반대 방향의 실수도 그만큼 흔합니다.
1. 모든 서비스 메서드에 기계적으로 붙이지 마십시오
클래스 레벨에 @Transactional을 걸고 끝내는 관행은, 조회만 하는 메서드에도 쓰기 트랜잭션을 열고 커넥션을 잡습니다. 트래픽이 커지면 이것만으로 커넥션 압박이 생깁니다. 쓰기가 없는 메서드에는 트랜잭션이 필요 없거나, 필요해도 readOnly로 충분합니다. 여러 쿼리 사이의 일관된 스냅샷이 필요한 경우가 아니라면, 단건 조회에 트랜잭션을 여는 것은 비용만 남습니다.
2. 트랜잭션으로 원자성을 흉내 내지 마십시오
DB 하나의 범위를 넘어가는 순간 트랜잭션은 원자성을 보장하지 못합니다. 외부 결제, 메시지 브로커, 다른 서비스의 API가 얽힌 흐름을 @Transactional 하나로 묶으려는 시도는 실패합니다. 그런 경우에는 보상 트랜잭션, 아웃박스, 멱등 처리 같은 설계로 가야 합니다. 애너테이션으로 풀 문제가 아닙니다. 분산 트랜잭션(2PC)을 꺼내기 전에, 정말 그 수준의 정합성이 요구되는지부터 되물으십시오. 대부분은 "언젠가는 맞춰진다 + 중복 안전"으로 충분합니다.
3. 긴 배치를 하나의 트랜잭션으로 묶지 마십시오
수십만 건을 한 트랜잭션에 넣으면 언두 영역과 락이 계속 쌓이고, 실패 시 롤백 자체가 오래 걸려 복구가 더 어려워집니다. 청크 단위로 끊고 재실행 가능하게 만드는 것이 정답입니다. 이때 필요한 건 트랜잭션 속성이 아니라 진행 지점 기록과 멱등성입니다.
4. 문제를 이해하기 전에 REQUIRES_NEW를 붙이지 마십시오
UnexpectedRollbackException을 만났을 때 검색해서 나오는 첫 번째 처방이 REQUIRES_NEW입니다. 증상은 사라지지만, 커넥션을 두 배로 쓰는 구조와 "바깥이 롤백돼도 안쪽은 남는" 의미 변화를 떠안게 됩니다. 먼저 경계를 다시 그려 보고, 그래도 필요하면 그때 씁니다.
5. 성능 문제를 트랜잭션 속성으로 풀려 하지 마십시오
느린 이유가 쿼리나 인덱스인데 격리 수준을 낮추거나 readOnly를 붙여 해결하려는 시도는, 성능은 거의 안 오르고 정합성만 잃습니다. 측정이 먼저입니다. 실행 계획과 대기 지표를 보지 않은 채 격리 수준을 내리는 건, 원인을 모른 채 안전장치만 떼는 일과 같습니다.
6. 필요 없는 곳에 트랜잭션 매니저를 늘리지 마십시오
데이터소스가 여럿이 되면 어떤 트랜잭션 매니저가 걸렸는지 코드만 보고 알기 어려워집니다. 정말 분리가 필요한 게 아니라면 하나로 유지하고, 분리했다면 애너테이션에 매니저를 명시하십시오. 기본값에 의존한 채 두 개를 운영하면 언젠가 엉뚱한 커넥션 위에서 커밋됩니다.
7. 읽기 전용 부하를 리플리카로 흘려보내기 전에
라우팅은 부하를 나누는 좋은 수단이지만, 도입하는 순간 "이 조회는 몇 초 전 데이터여도 되는가"를 화면 단위로 판단해야 하는 부담이 생깁니다. 그 판단을 할 준비가 안 됐다면, 인덱스와 쿼리부터 정리하는 편이 이득이 큽니다.
정리
@Transactional은 편의를 위해 복잡성을 숨긴 기능입니다. 숨겨진 부분이 프록시(어떻게 가로채는가), 커넥션(무엇을 얼마나 점유하는가), 예외 규칙(무엇이 롤백을 유발하는가) 셋입니다. 사고는 예외 없이 이 셋 중 하나에서 나옵니다.
그래서 실무 기준은 단순하게 잡을 수 있습니다. 트랜잭션 경계는 유스케이스 하나에 붙이고, 그 안에는 DB 작업만 두고, 짧게 유지합니다. 이 세 가지만 지켜도 위에 나열한 사고의 대부분은 발생 자체가 불가능해집니다. 나머지는 그 원칙을 지킬 수 없는 예외 상황에서만 필요한 지식입니다.
마지막으로 한 가지. 트랜잭션 관련 사고는 발생 시점과 발견 시점의 간격이 길다는 공통점이 있습니다. 조용히 어긋난 데이터는 몇 주 뒤 정산이나 통계에서 발견되고, 그때는 원인 코드를 고치는 일보다 어긋난 데이터를 되돌리는 일이 더 큰 작업이 되어 있습니다. 그래서 이 영역은 사후 디버깅보다 사전 리뷰의 투자 수익이 압도적으로 높습니다. 위 체크리스트를 팀 리뷰 항목에 넣는 것만으로도 충분히 값을 합니다.
같은 주제의 다른 글
맘소프트는 19년 넘게 금융 · 엔터프라이즈 · 모바일 시스템을 만들어 왔습니다. 여기 적는 글은 그 과정에서 실제로 부딪히고 해결한 것들입니다.