|

Spring Boot @Transactional 위치가 헷갈릴 때: 서비스 계층에서 경계를 잡는 법

Spring Boot @Transactional 위치가 헷갈릴 때: 서비스 계층에서 경계를 잡는 법
@Transactional은 메서드 하나가 아니라 하나의 업무 단위를 어디서 보장할지 정하는 문제다

Spring Boot @Transactional은 “어디에 붙이면 동작하나”보다 “어디까지를 하나의 업무 단위로 볼 것인가”가 더 중요합니다.

회원 가입 중 사용자 저장은 성공했는데 포인트 지급은 실패하면 곤란합니다. 이런 경우 두 작업은 따로 성공하면 안 되고 함께 성공하거나 함께 실패해야 합니다. 그 경계를 잡는 것이 트랜잭션입니다.

Spring 공식 문서의 @Transactional 설명을 기준으로, 서비스 계층에서 어떻게 판단하면 좋은지 보겠습니다.


@Transactional 위치를 정하는 기준

Spring Boot @Transactional 위치 판단 카드
@Transactional은 데이터 변경이 하나의 업무 단위로 성공하거나 실패해야 하는 경계에 둔다

Spring Boot @Transactional은 업무 경계에 둔다

대부분의 Spring Boot 애플리케이션에서는 Controller가 요청을 받고, Service가 업무 규칙을 실행하고, Repository가 DB 접근을 맡습니다. 이때 트랜잭션 경계는 보통 Service의 public 업무 메서드에 둡니다. Java 코드에서 의도를 드러내는 방식이 궁금하다면 Java Optional 글과 함께 읽어도 좋습니다.

@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final PaymentRepository paymentRepository;

    @Transactional
    public void createOrder(CreateOrderCommand command) {
        Order order = orderRepository.save(command.toOrder());
        paymentRepository.save(Payment.pending(order));
    }
}

이 메서드는 주문 저장과 결제 기록 생성을 하나의 업무 단위로 묶습니다. 중간에 예외가 나면 전체를 되돌리는 것이 자연스럽습니다.


Repository마다 붙이면 왜 애매해질까

Repository 메서드마다 @Transactional을 붙이면 각 DB 호출은 보호될 수 있습니다. 하지만 여러 Repository 호출이 하나의 업무 단위로 묶여야 하는 상황에서는 경계가 너무 작아집니다.

트랜잭션은 DB 호출 하나가 아니라 업무 흐름 하나를 보호해야 합니다. 그래서 Repository에 흩뿌리기보다 Service에서 “여기서 시작해서 여기서 끝난다”를 명확히 잡는 편이 읽기 쉽습니다.

예를 들어 주문 생성, 재고 차감, 쿠폰 사용 기록 저장이 한 요청 안에서 일어난다면 각각의 Repository가 아니라 이 흐름을 조율하는 Service 메서드가 경계가 됩니다. 그래야 중간 실패 시 어디까지 되돌릴지 설명할 수 있습니다.


Controller에 두는 것은 왜 조심해야 할까

Controller는 HTTP 요청과 응답을 다루는 곳입니다. 여기에 트랜잭션을 두면 요청 파라미터 검증, 응답 변환, 외부 API 호출 같은 작업까지 같은 경계 안으로 들어가기 쉽습니다.

트랜잭션이 길어지면 DB 연결을 오래 잡고 있을 수 있고, 실패 지점도 흐려집니다. Controller는 얇게 두고, 실제 업무 단위를 Service로 넘기는 편이 추적하기 쉽습니다.

@PostMapping("/orders")
public ResponseEntity<Void> create(@RequestBody CreateOrderRequest request) {
    orderService.createOrder(request.toCommand());
    return ResponseEntity.ok().build();
}

이 구조에서는 Controller가 웹 계층 역할만 하고, 트랜잭션 경계는 orderService.createOrder() 안에서 잡힙니다.


조회 전용 메서드는 readOnly를 검토한다

데이터를 바꾸지 않는 조회 메서드라면 @Transactional(readOnly = true)를 검토할 수 있습니다. 이것은 “이 메서드는 변경 작업이 아니다”라는 의도를 드러냅니다.

@Transactional(readOnly = true)
public OrderDetail getOrderDetail(Long orderId) {
    return orderRepository.findDetail(orderId)
        .orElseThrow(OrderNotFoundException::new);
}

다만 readOnly는 모든 상황에서 성능이 마법처럼 좋아지는 옵션으로 보면 안 됩니다. 데이터 접근 기술과 설정에 따라 의미가 달라질 수 있으므로, 먼저 의도 표현과 변경 방지 관점으로 이해하는 것이 안전합니다.


rollback 기준도 함께 정해야 한다

@Transactional을 붙이면 모든 실패가 같은 방식으로 처리된다고 생각하기 쉽습니다. 하지만 어떤 예외에서 rollback할지, 어떤 예외는 비즈니스 결과로 처리할지 기준이 필요합니다.

예를 들어 결제 승인 실패는 주문 실패로 처리해야 할 수 있지만, 알림 발송 실패는 주문 자체를 되돌릴 이유가 아닐 수 있습니다. 이런 경우 알림 발송을 같은 트랜잭션 안에 넣을지, 이벤트나 별도 후처리로 뺄지 판단해야 합니다.

  • 반드시 함께 성공해야 하는 DB 변경은 같은 트랜잭션에 둔다
  • 실패해도 본 업무를 되돌릴 필요가 없는 부가 작업은 분리한다
  • 외부 API 호출은 트랜잭션 안에 오래 넣지 않는 쪽을 먼저 검토한다

propagation은 처음부터 복잡하게 볼 필요 없다

@Transactional을 공부하다 보면 propagation 옵션이 바로 나옵니다. 하지만 처음부터 모든 전파 옵션을 외우면 오히려 판단이 흐려집니다. 대부분의 서비스 메서드는 기본값인 REQUIRED 감각으로 시작해도 충분합니다.

REQUIRED는 이미 트랜잭션이 있으면 참여하고, 없으면 새로 시작하는 방식으로 이해하면 됩니다. 문제는 이 기본 흐름을 모른 채 REQUIRES_NEW 같은 옵션을 급하게 쓰는 경우입니다.

기본 판단

1. 이 작업이 기존 업무 트랜잭션과 함께 성공/실패해야 하나?
   -> 기본 REQUIRED 흐름이 자연스럽다.

2. 본 업무가 실패해도 별도 기록은 남아야 하나?
   -> 별도 트랜잭션이나 이벤트/로그 저장 전략을 검토한다.

3. 외부 API 호출 결과에 따라 DB 변경이 달라지나?
   -> 트랜잭션을 너무 오래 열지 않도록 흐름을 나눈다.

즉 propagation은 옵션 이름을 외우기보다 실패 단위를 어떻게 나눌지 정한 뒤에 보는 것이 좋습니다.


self-invocation 오해를 조심한다

Spring 공식 문서는 proxy mode에서 외부 호출이 프록시를 통과할 때 트랜잭션 인터셉트가 일어난다고 설명합니다. 그래서 같은 클래스 안에서 this.someTransactionalMethod()처럼 내부 호출하면 기대와 다르게 동작할 수 있습니다.

@Service
public class UserService {
    public void register() {
        // 같은 객체 내부 호출이면 프록시를 거치지 않을 수 있다
        createPointHistory();
    }

    @Transactional
    public void createPointHistory() {
        // ...
    }
}

이 문제를 피하려면 트랜잭션 경계를 처음부터 외부에서 호출되는 서비스 메서드에 두거나, 역할을 다른 서비스로 분리하는 방식이 더 명확합니다.


정리

Spring Boot @Transactional은 붙이는 위치보다 경계가 중요합니다. 한 업무가 여러 DB 작업을 포함한다면, 그 업무를 대표하는 Service 메서드가 자연스러운 시작점입니다.

Repository 호출 하나씩이 아니라 사용자 관점의 업무 단위를 기준으로 보면 @Transactional 위치가 훨씬 덜 헷갈립니다.

함께보면 좋은 글