이 글에서 얻는 것

  • @Transactional이 “DB 커밋/롤백”을 어떻게 제어하는지(경계/프록시/예외 규칙) 설명할 수 있습니다.
  • 전파(Propagation)와 롤백 규칙을 이해하고, 자주 하는 실수(self-invocation, 예외 삼키기)를 피할 수 있습니다.
  • 읽기 전용 트랜잭션과 격리 수준이 언제 의미가 있는지 감각을 잡습니다.

1) 서비스 레이어에서 @Transactional을 쓰는 이유

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentGateway paymentGateway;

    @Transactional
    public void placeOrder(OrderRequest request) {
        // 1. 주문 데이터 저장
        Order order = orderRepository.save(request.toEntity());

        // 2. 결제 시도 (실패 시 RuntimeException 발생)
        paymentGateway.pay(order.getId(), request.getAmount());
    }
}

핵심은 “여기부터 여기까지는 한 덩어리로 성공하거나, 실패하면 되돌린다”는 경계(boundary) 를 선언하는 것입니다. 이 경계가 있으면 중간에 예외가 터져도 “부분 저장”이 남지 않게 만들 수 있습니다.

2) 기본 동작 3가지(자주 쓰는 것만)

2-1) 전파(Propagation) 기본값은 REQUIRED

REQUIRED는 “이미 트랜잭션이 있으면 참여하고, 없으면 새로 만든다”입니다. 대부분의 서비스 메서드는 REQUIRED가 기본으로 충분합니다.

2-2) 롤백 기본값은 RuntimeException/Error

스프링은 기본적으로 RuntimeException/Error에서 롤백합니다. 즉, 비즈니스 실패는 런타임 예외로 표현하는 게 자연스럽습니다(호출부에 체크 예외가 전파되지 않게).

2-3) 예외를 “잡아먹으면” 커밋될 수 있다

다음처럼 예외를 잡고 삼키면, 트랜잭션은 성공으로 간주되어 커밋될 수 있습니다.

@Transactional
public void doSomething() {
    try {
        gateway.call();
    } catch (Exception e) {
        // 로그만 찍고 끝내면 커밋될 수도 있음
    }
}

실패로 처리해야 한다면 “예외를 다시 던지거나”, 최소한 rollback-only로 마킹해야 합니다.

3) 체크 예외(Checked Exception)와 롤백

체크 예외는 기본 롤백 대상이 아닐 수 있으니, 의도가 “실패=롤백”이면 명시가 필요합니다.

@Transactional(rollbackFor = { IOException.class })
public void uploadFile(MultipartFile file) throws IOException {
    storageService.store(file);  // IOException 발생 가능
}

실무에서는 보통:

  • “복구 가능한 체크 예외”가 아니면 런타임 예외로 감싸서 전파하고,
  • 정말 체크 예외로 두어야 한다면 rollbackFor를 명확히 합니다.

4) 읽기 전용 트랜잭션(readOnly = true)

@Transactional(readOnly = true)
public List<Order> getRecentOrders(Long userId) {
    return orderRepository.findTop10ByUserIdOrderByCreatedAtDesc(userId);
}

효과(대표):

  • JPA/Hibernate에서 flush 동작이 줄어들어 약간의 이점이 있을 수 있습니다.
  • “이 메서드는 쓰기하면 안 된다”는 의도를 코드로 드러내는 효과도 큽니다.

주의: readOnly는 DB 격리/락을 바꿔주는 마법이 아니라 “힌트/설정”에 가깝습니다.

5) 프록시/호출 경계: self-invocation이 왜 위험한가

@Service
public class UserService {

    @Transactional
    public void createUser(UserRequest request) {
        userRepository.save(request.toEntity());
        sendWelcomeMail(request.getEmail());  // ❌ 트랜잭션 적용 안 됨
    }

    @Transactional
    public void sendWelcomeMail(String email) {
        // 메일 발송 로직
    }
}

스프링의 @Transactional은 보통 “프록시(AOP)”로 적용됩니다.
같은 객체 내부에서 this.method()로 호출하면 프록시를 거치지 않아 트랜잭션이 적용되지 않을 수 있습니다.

해결 방향:

  • 트랜잭션 경계를 분리할 메서드는 다른 빈으로 분리
  • “프록시를 타야 한다”는 사실을 전제로 구조를 잡기(기능을 작게 나누기)

6) 전파 옵션은 언제 쓰나(대표만)

  • REQUIRES_NEW: 외부 트랜잭션과 분리된 “독립 커밋”이 필요할 때(로그/아웃박스 등)
    단, 남발하면 트랜잭션이 쪼개져 일관성/성능 문제가 생길 수 있습니다.
  • NESTED: DB가 savepoint를 지원할 때 부분 롤백(환경 의존)

연습(추천)

  • 의도적으로 예외를 던져 롤백되는지 확인하고, 예외를 catch해서 삼켰을 때 커밋되는지 비교해보기
  • self-invocation 케이스를 재현하고 “왜 프록시를 안 타는지”를 로그로 확인해보기
  • REQUIRES_NEW를 적용했을 때 외부 트랜잭션 롤백과 독립 커밋이 어떻게 갈리는지 실험해보기