@Transactional은 붙이기는 쉽지만 경계를 잘못 잡으면 운영에서 가장 찾기 어려운 장애를 만든다. 경계가 너무 넓으면 커넥션 풀이 마르고 락이 길어지고, 너무 좁거나 아예 없으면 절반만 저장된 데이터가 남는다. 이 글은 Spring Boot에서 트랜잭션 경계를 잘못 설계했을 때 실제로 생기는 문제 일곱 가지와 원인, 고치는 방법을 정리한 것이다.

먼저 알아야 할 동작 원리
Spring의 선언적 트랜잭션은 프록시가 메서드 호출을 가로채서 시작하고 끝낸다. 여기서 대부분의 실수가 나온다.
| 동작 | 결과 |
|---|---|
| 프록시를 거친 호출만 트랜잭션이 적용된다 | 같은 클래스 안에서 this.method()로 부르면 적용되지 않는다 |
기본 롤백 대상은 RuntimeException과 Error |
체크 예외를 던지면 커밋된다 |
기본 전파는 REQUIRED |
바깥 트랜잭션이 있으면 그 안에 합류한다 |
| 트랜잭션 동안 DB 커넥션 하나를 잡고 있다 | 경계가 길수록 커넥션 점유도 길다 |
문제 1: 트랜잭션 안에서 외부 API를 부른다
주문 저장 → 결제 API 호출 → 재고 차감을 한 메서드에 넣고 @Transactional을 붙이는 경우가 흔하다. 결제 API가 3초 걸리면 그 3초 동안 DB 커넥션과 행 락을 잡고 있다.

동시 요청이 몰리면 커넥션 풀(HikariCP 기본 10개)이 금방 바닥나고, 결제와 상관없는 조회 요청까지 커넥션을 기다리다 타임아웃이 난다. 외부 API가 느려진 것이 DB 장애처럼 보이는 이유다.
고치는 법: 트랜잭션에는 DB 작업만 넣는다. 외부 호출은 커밋 뒤에 한다.
@Transactional
public Long placeOrder(OrderCommand cmd) {
Order order = orderRepository.save(Order.from(cmd));
stockService.decrease(cmd.itemId(), cmd.quantity());
events.publishEvent(new OrderPlaced(order.getId())); // 커밋 뒤에 처리
return order.getId();
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void requestPayment(OrderPlaced event) {
paymentClient.request(event.orderId()); // 트랜잭션 밖, 실패하면 보상 처리
}
커밋 뒤 처리가 실패할 수 있으니 결제 상태를 따로 두고 재시도나 보상(주문 취소)을 설계한다. 메시지 발행이 반드시 되어야 하면 같은 트랜잭션에 아웃박스 테이블에 기록하고 별도 프로세스가 보내는 방식을 쓴다.
문제 2: 경계가 없어서 절반만 저장된다
서비스 메서드에 @Transactional이 없으면 리포지토리 호출마다 각자 커밋된다. 주문은 저장됐는데 재고 차감에서 예외가 나면 주문만 남는다. 여러 저장을 묶어야 하는 유스케이스는 서비스 계층 메서드 하나를 경계로 잡는다.
문제 3: 같은 클래스 안에서 부르면 적용되지 않는다
public void importAll(List<Row> rows) {
for (Row row : rows) {
this.importOne(row); // 프록시를 거치지 않아 @Transactional 무시
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void importOne(Row row) { ... }
건별로 커밋하려던 의도와 달리 트랜잭션이 아예 없다. 경계가 필요한 메서드는 다른 빈으로 옮겨 주입받아 부른다. private 메서드에 붙인 @Transactional도 프록시가 가로챌 수 없어 동작하지 않는다.
문제 4: 체크 예외인데 커밋된다
IOException 같은 체크 예외를 던지면 기본 설정에서는 롤백하지 않는다. 파일 처리 중 실패했는데 DB에는 반영된 상태가 남는다. 업무 예외는 RuntimeException을 상속하게 하거나, 필요한 곳에 rollbackFor = Exception.class를 명시한다. 팀 규칙으로 하나를 정해 개발 표준에 적는다.
문제 5: 안쪽에서 잡은 예외가 바깥 커밋을 깬다
바깥 서비스가 안쪽 서비스(둘 다 REQUIRED)를 부르고, 안쪽에서 런타임 예외가 났는데 바깥에서 try-catch로 잡고 계속 진행하는 경우다. 안쪽 프록시가 이미 트랜잭션을 롤백 전용(rollback-only)으로 표시했기 때문에, 바깥이 커밋하려는 순간 UnexpectedRollbackException이 난다.
- 실패해도 바깥 작업은 커밋해야 한다면 안쪽을
REQUIRES_NEW로 분리한다. - 실패하면 전체를 되돌려야 한다면 예외를 잡지 말고 올려 보낸다.
문제 6: REQUIRES_NEW를 남발한다
REQUIRES_NEW는 바깥 트랜잭션을 잠시 멈추고 새 커넥션으로 별도 트랜잭션을 연다. 요청 하나가 커넥션 두 개를 잡는다. 부하가 높을 때 모든 요청이 바깥 커넥션을 잡은 채 안쪽 커넥션을 기다리면 풀이 비어 서로 기다리는 상태가 된다. 또 안쪽은 바깥이 롤백돼도 커밋이 남는다.
감사 로그처럼 "본 작업이 실패해도 남아야 하는 기록"에만 쓰고, 커넥션 풀 크기를 그만큼 여유 있게 잡는다.
문제 7: 조회 트랜잭션과 OSIV를 신경 쓰지 않는다
readOnly = true를 빼먹는다. JPA(Hibernate)에서는 읽기 전용이면 변경 감지를 위한 플러시를 하지 않아 대량 조회가 가벼워진다. 읽기·쓰기 데이터소스를 나눈 구성이면 읽기 전용 트랜잭션을 복제본으로 보내는 기준으로도 쓴다.- 트랜잭션 밖에서 지연 로딩을 건드린다.
LazyInitializationException이 난다. 서비스 안에서 필요한 연관을 페치 조인이나 DTO 조회로 미리 가져온다. - OSIV(Open Session In View)를 켠 채 둔다. Spring Boot는
spring.jpa.open-in-view가 기본으로 켜져 있고, 명시하지 않으면 시작할 때 경고를 띄운다. 켜져 있으면 응답을 만들 때까지 커넥션을 잡고 있어 문제 1과 같은 고갈이 생긴다. API 서버는false로 두고 서비스 안에서 필요한 데이터를 다 만든다.
경계를 정하는 기준
| 질문 | 답이 "예"면 |
|---|---|
| 함께 성공하거나 함께 실패해야 하는 저장이 있나 | 그 저장들을 한 서비스 메서드 경계로 묶는다 |
| 경계 안에 네트워크 호출(API, 메일, 메시지)이 있나 | 커밋 뒤로 옮긴다(이벤트, 아웃박스) |
| 실패해도 남아야 하는 기록인가 | 그것만 REQUIRES_NEW |
| 조회만 하나 | readOnly = true |
| 경계가 수백 ms를 넘나 | 나누거나 배치로 바꾼다 |
자주 하는 실수
- 컨트롤러에
@Transactional을 붙인다. 응답 직렬화까지 커넥션을 잡고, 경계가 화면 단위로 커진다. - 트랜잭션 안에서 외부 API를 부른다. 외부 지연이 DB 커넥션 고갈로 번진다.
- 자기 호출로 경계를 바꾸려 한다. 프록시를 거치지 않으면 아무 일도 일어나지 않는다.
- 체크 예외 롤백 규칙을 팀마다 다르게 쓴다. 같은 실패가 어떤 곳은 롤백, 어떤 곳은 커밋된다.
- 안쪽 예외를 잡고 계속 진행한다. 커밋 시점에
UnexpectedRollbackException으로 터진다.
점검표
- 트랜잭션 경계는 서비스 계층 유스케이스 메서드다
- 경계 안에 외부 API·메일·메시지 호출이 없다
- 커밋 뒤 처리는
@TransactionalEventListener(AFTER_COMMIT)나 아웃박스로 한다 -
@Transactional은 다른 빈에서 부르는 public 메서드에만 있다 - 체크 예외 롤백 규칙이 개발 표준에 적혀 있다
-
REQUIRES_NEW는 감사 로그 같은 예외적 용도에만 쓰고 풀 크기를 고려했다 - 조회 메서드는
readOnly = true다 - API 서버는
spring.jpa.open-in-view=false다 - 부하 시험에서 커넥션 풀 대기 시간을 함께 본다
트랜잭션 경계는 "무엇이 함께 성공하고 실패해야 하는가"만 담아야 한다. 그 밖의 것, 특히 느린 외부 호출이 경계 안에 들어오는 순간 커넥션과 락이 대가를 치른다.
댓글 0개
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.