Spring Boot에서 트랜잭션 경계를 잘못 설계하면 발생하는 문제

· 약 8분 읽기 · 조회 1 #Spring Boot#트랜잭션#JPA#성능#백엔드

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

유리 상자 안에서 장부를 펴 둔 직원이 전화를 붙든 채 모래시계를 보며 기다리고, 상자 밖에는 서류를 든 직원들이 줄을 선 은행 사무실

먼저 알아야 할 동작 원리

Spring의 선언적 트랜잭션은 프록시가 메서드 호출을 가로채서 시작하고 끝낸다. 여기서 대부분의 실수가 나온다.

동작 결과
프록시를 거친 호출만 트랜잭션이 적용된다 같은 클래스 안에서 this.method()로 부르면 적용되지 않는다
기본 롤백 대상은 RuntimeException과 Error 체크 예외를 던지면 커밋된다
기본 전파는 REQUIRED 바깥 트랜잭션이 있으면 그 안에 합류한다
트랜잭션 동안 DB 커넥션 하나를 잡고 있다 경계가 길수록 커넥션 점유도 길다

문제 1: 트랜잭션 안에서 외부 API를 부른다

주문 저장 → 결제 API 호출 → 재고 차감을 한 메서드에 넣고 @Transactional을 붙이는 경우가 흔하다. 결제 API가 3초 걸리면 그 3초 동안 DB 커넥션과 행 락을 잡고 있다.

잘못된 경계에서는 결제 API 호출 시간까지 트랜잭션이 커넥션을 잡고 있지만, 고친 경계에서는 DB 작업만 짧게 커밋하고 결제 API는 커밋 뒤에 호출하는 예시 타임라인

동시 요청이 몰리면 커넥션 풀(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개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기