Spring Boot로 개발하면 누구나 @Transactional을 써 보게 된다. 메서드 위에 붙이는 @이름 꼴의 표시를 애너테이션이라고 하는데, 그 자체로는 아무 일도 하지 않고 Spring이 읽어서 무엇을 할지 정한다. @Transactional은 「이 메서드를 트랜잭션 하나로 묶어 달라」는 표시다. 하지만 「메서드에 붙이면 트랜잭션이 된다」고만 알고 있으면 충분하지 않다.

실무에서 자주 마주치는 「분명히 @Transactional을 붙였는데 왜 롤백이 안 되지?」라는 문제는 대부분 @Transactional이 어떻게 동작하는지 모르기 때문에 생긴다.

이 글은 실제로 토론하며 정리한 내용을 바탕으로 @Transactional의 함정들과 실무에서 어떻게 대처할지를 적는다.


토론 요약: @Transactional의 5가지 함정

토론에서 찾은 @Transactional의 주요 함정은 다섯 개다. 아래에서 「트랜잭션이 걸린다」는 말은 그 메서드가 시작될 때 트랜잭션이 열리고, 끝날 때 커밋이나 롤백으로 닫힌다는 뜻이다.

  1. 자기 호출(Self-invocation) 문제 - 같은 클래스 안에서 부르면 트랜잭션이 안 걸린다
  2. 예외 타입에 따른 롤백 차이 - Checked 예외는 기본적으로 롤백되지 않는다
  3. 복잡한 예외 처리 로직 - try-catch 때문에 코드가 읽기 어려워진다
  4. 전파(Propagation) 속성을 잘못 이해하는 것 - 트랜잭션 메서드가 다른 트랜잭션 메서드를 부를 때 어디까지가 한 묶음인지 헷갈린다
  5. 과도한 복잡성 - 모든 속성을 다 쓰려다가 코드가 복잡해진다

함정 1: Self-Invocation 문제 (프록시 패턴)

문제 상황

@Transactional이 동작하는 핵심은 Spring AOP의 프록시다. Spring은 프로그램이 켜지면서 객체들을 만들 때 @Transactional이 붙은 클래스를 찾으면, 그 객체를 대신 받아 주는 프록시 객체를 하나 더 만든다. 프록시는 겉모습이 원본과 같아서, 다른 클래스가 필드에 이 객체를 넣어 달라고 하면(아래 해결 방법의 @Autowired가 그 요청이다) Spring은 원본이 아니라 프록시를 넣어 준다. 그래서 바깥에서 메서드를 부르면 먼저 프록시가 받는다. 프록시는 트랜잭션을 연 뒤 원본의 메서드를 부르고, 원본이 끝나면 커밋이나 롤백을 한다.

  • 외부에서 호출 → 손에 든 것이 프록시이므로 프록시를 거쳐 원본이 불린다 → 트랜잭션 O
  • 같은 클래스 안에서 호출 → 이미 원본 안으로 들어와 있으므로 this(원본 자신)로 바로 불린다 → 트랜잭션 X

Spring 공식 문서도 같은 말을 한다. 프록시 방식(기본값. 따로 설정하지 않았다면 이 글의 모든 예제가 이 방식이다)에서는 프록시를 거쳐 들어오는 외부 호출만 가로채므로, 같은 객체 안에서 자기 메서드를 부르면 그 메서드에 @Transactional이 붙어 있어도 실제 트랜잭션이 생기지 않는다(Spring 문서 — Using @Transactional). 같은 문서는 자기 호출까지 트랜잭션으로 감싸고 싶으면 AspectJ 방식을 고려하라고 하는데, 이 글은 그 방식을 다루지 않는다.

코드 예제

@Service
public class UserService {

    // @Transactional이 없다. 바깥(예: 요청을 받는 Controller)이 이 메서드를 부른다
    public void registerUser(User user) {
        saveUser(user);  // 이 호출은 프록시를 거치지 않는다
    }

    @Transactional
    public void saveUser(User user) {
        userRepository.save(user);

        // 예외가 나도 롤백이 안 된다
        if (user.getAge() < 0) {
            throw new IllegalArgumentException("Invalid age");
        }
    }
}
  • @Service — 이 클래스를 Spring이 만들어 들고 있는 객체(빈)로 등록하라는 표시다(@Component의 한 종류다). @Transactional이 붙은 메서드가 있으니 Spring은 이 빈의 프록시를 만든다
  • registerUser — 바깥에서 불리는 메서드다. 바깥의 예로 든 Controller는 요청을 제일 먼저 받아 Service에게 일을 넘기는 클래스다. 바깥은 프록시를 손에 들고 있으니 프록시가 먼저 받지만, 이 메서드에는 @Transactional이 없으므로 트랜잭션을 열지 않고 원본의 registerUser를 그냥 부른다
  • saveUser(user) — 원본 안에서 부른 호출이다. 프록시를 다시 지나가지 않으니 saveUser의 @Transactional은 아무도 읽지 않는다
  • userRepository.save(user) — Repository는 DB에 저장하고 꺼내는 일을 맡는 객체다. 필드 선언은 예제를 짧게 하려고 뺐다
  • throw new IllegalArgumentException(...) — 나이가 음수면 예외를 던진다. 트랜잭션이 열리지 않았으니 앞의 save를 같이 되돌려 줄 묶음이 없다

registerUser에서 saveUser를 부르는 것은 this.saveUser()와 같다. 프록시를 거치지 않으므로 @Transactional이 적용되지 않는다.

해결 방법

먼저, 같은 클래스 안에서 메서드만 나누는 것은 해결이 아니다. 아래 코드는 트랜잭션이 필요한 부분을 savePayment로 떼어 냈지만, processPayment가 같은 클래스의 savePayment를 부르므로 위의 registerUser → saveUser와 똑같은 자기 호출이다. savePayment의 @Transactional은 여전히 무시된다.

@Service
public class PaymentService {
    @Autowired
    private PaymentRepository paymentRepository;

    @Transactional
    public void savePayment(Payment payment) {
        paymentRepository.save(payment);
    }

    public void processPayment(Payment payment) {
        savePayment(payment);  // 같은 클래스 안의 호출 — 프록시를 거치지 않는다
    }
}
  • @Autowired — 이 필드에 필요한 빈을 Spring이 찾아서 넣어 달라는 표시다. 그래서 new를 쓰지 않아도 값이 들어온다(의존성 주입)
  • savePayment(payment) — this.savePayment(payment)와 같다. 이미 원본 안이므로 프록시가 끼어들 틈이 없다

해결은 트랜잭션 메서드를 별도 클래스로 옮기는 것이다.

@Service
public class TransactionalUserService {
    @Transactional
    public void saveUser(User user) {
        // 트랜잭션 안에서 할 작업
    }
}

@Service
public class UserService {
    @Autowired
    private TransactionalUserService transactionalUserService;

    public void registerUser(User user) {
        transactionalUserService.saveUser(user);  // 외부 호출
    }
}
  • TransactionalUserService — 트랜잭션이 필요한 saveUser만 들고 있는 클래스다. @Transactional이 있으니 Spring이 이 클래스의 프록시를 만든다
  • @Autowired private TransactionalUserService transactionalUserService — 여기에 들어오는 것은 원본이 아니라 그 프록시다. 이 블로그에서 다른 예제 클래스 OrderService를 실제로 돌려 주입된 객체의 클래스 이름을 찍어 보니, OrderService가 아니라 OrderService$$SpringCGLIB$$0이 나왔다. 내가 쓴 적 없는 이 클래스는 Spring이 실행 중에 만든 클래스, 곧 프록시다(CGLIB은 Spring이 그 클래스를 만들 때 쓰는 도구 이름이다). 여기서도 똑같이 TransactionalUserService의 프록시가 들어온다
  • transactionalUserService.saveUser(user) — 다른 객체(프록시)의 메서드를 부르는 것이므로 프록시가 먼저 받아 트랜잭션을 열고 원본의 saveUser를 부른다

함정 2: Exception 타입에 따른 롤백 차이

문제 상황

많이들 모르는 사실이 있다. Spring은 예외 타입에 따라 롤백 여부가 달라진다.

  • RuntimeException (NullPointerException, IllegalArgumentException 등) → 롤백 O
  • Checked Exception (Exception, IOException 등) → 롤백 X

롤백하지 않는다는 말은 곧 커밋한다는 말이다. 메서드가 Checked 예외를 던지며 끝나도, 프록시는 그 트랜잭션을 정상으로 끝난 것처럼 커밋하고 예외는 그대로 부른 쪽으로 넘긴다. Spring 문서도 기본 설정에서는 RuntimeException과 그 하위 예외(그리고 Error)에만 롤백하고, 트랜잭션 메서드가 던진 Checked 예외는 롤백을 일으키지 않는다고 적는다(Spring 문서 — Rolling Back a Declarative Transaction).

이는 Spring의 설계 철학 때문인데, Checked 예외를 「예상 가능한 오류」로 보기 때문이다.

왜 이렇게 설계했을까?

이를 이해하려면 먼저 Java 예외의 계층 구조를 알아야 한다.

Throwable
├── Error (시스템 오류)
└── Exception
    ├── Checked Exception (IOException, SQLException 등)
    └── RuntimeException
        ├── NullPointerException
        ├── IllegalArgumentException
        └── 등등...

Exception을 바로 상속한 예외가 Checked 예외이고, RuntimeException을 상속한 예외가 Unchecked 예외다. 둘의 차이는 용어 페이지에 있다. Error는 메모리가 바닥나는 것처럼 프로그램이 계속 돌 수 없는 실패다.

Spring의 설계 원칙:

1) Checked Exception = 「예상된 상황의 실패」

// 메일 서버에 연결하지 못하면 IOException이 날 수 있다는 걸 미리 알고 있다
public void sendPaymentEmail(Payment payment) throws IOException {
    // 개발자가 미리 예상하고 처리 전략을 정했을 확률이 높다
    // 「메일 전송 실패 → 사용자에게 알림」 같은 처리를 할 수 있다
}
  • throws IOException — 이 메서드가 IOException을 던질 수 있다고 선언한다. IOException은 파일이나 네트워크처럼 프로그램 밖과 주고받다가 실패할 때 나는 Checked 예외라서, 메일을 보내다 실패해도 이 예외가 날 수 있다

  • 컴파일할 때 처리를 강제한다. 이 메서드를 부르는 쪽이 try-catch로 잡거나 자기도 throws를 적지 않으면 컴파일이 안 된다
  • 그러니 Spring은 개발자가 처리 방법을 이미 정해 두었다고 가정하고, 트랜잭션을 대신 되돌리지 않는다
  • 비즈니스 로직상 「예상된 상황」일 가능성이 높다

2) RuntimeException = 「프로그래밍 오류」

// NullPointerException은 개발자가 의도하지 않은 오류다
public void saveUser(User user) {
    user.getName();  // user가 null이면 예상 밖의 상황이다
}
  • 컴파일할 때 처리를 강제하지 않는다
  • 개발자가 의도하지 않은 오류 = 버그
  • 도중까지 한 작업이 맞는 상태라고 믿을 수 없으므로 무조건 롤백해야 한다

비유로 이해하기

Checked Exception: 「예상된 상황의 실패」
├─ 「메일 서버에 연결하지 못했어」 (IOException)
├─ 「DB가 오류를 돌려줬어」 (SQLException)
└─ → 개발자가 대처 방법을 알고 있을 거야
   → 트랜잭션을 롤백할지 말지는 개발자가 정해야 해

RuntimeException: 「뭔가 잘못됐어」 (= 버그)
├─ 「null을 참조했어」 (NullPointerException)
├─ 「잘못된 값을 전달했어」 (IllegalArgumentException)
└─ → 이건 예상 밖의 오류야
   → 데이터를 저장하면 안 돼
   → 무조건 롤백해야 해

SQLException은 자바 프로그램이 DB와 이야기하다 DB가 오류를 돌려주면 받는 예외다. 위 비유에서 「롤백할지 말지 정한다」는 것은 Spring이 아니라 개발자다. 아래 해결 방법 1~3이 그 정하는 방법이다.

코드 예제

@Service
public class PaymentService {

    @Transactional
    public void processPayment(Payment payment) throws IOException {
        paymentRepository.save(payment);  // DB에 저장한다

        // Checked Exception 발생
        sendPaymentEmail(payment);  // IOException 발생
    }

    private void sendPaymentEmail(Payment payment) throws IOException {
        // 이메일 전송 로직...
    }
}
  • paymentRepository.save(payment) — 결제 정보를 DB에 저장한다(필드 선언은 앞 예제와 같아서 뺐다)
  • sendPaymentEmail(payment) — 메일 전송이 실패해 IOException을 던진다고 하자. processPayment도 throws IOException이 있으니 예외는 그대로 프록시까지 올라간다
  • 프록시는 올라온 예외가 Checked 예외이므로 롤백하지 않고 커밋한다

이 상황에서:

  • 결제 정보는 DB에 저장된다 (커밋됨)
  • 이메일 전송은 실패한다
  • 트랜잭션은 롤백되지 않는다

결과적으로 DB에는 결제가 남아 있고, 이메일만 실패한 상태가 된다. 결제와 메일이 반드시 함께 성공해야 하는 업무라면 이건 문제다. 고객은 결제 완료 안내를 못 받았는데 결제는 확정된 것이다. 반대로 「메일은 실패해도 결제는 살려야 한다」는 업무라면 이 결과가 오히려 맞다. 아래 해결 방법 1, 2는 앞의 경우, 3은 뒤의 경우를 위한 것이다.

해결 방법

1) rollbackFor 속성으로 명시 (권장):

@Transactional(rollbackFor = Exception.class)
public void processPayment(Payment payment) throws IOException {
    paymentRepository.save(payment);
    sendPaymentEmail(payment);  // IOException 발생 시 롤백된다
}
  • @Transactional(rollbackFor = Exception.class) — 애너테이션 괄호 안에 이름 = 값을 적으면 그 애너테이션의 설정(속성)을 바꾼다. rollbackFor는 「이 예외가 나면 롤백하라」는 설정이고, 값 Exception.class는 Exception 클래스 자체를 가리키는 표기다(클래스 리터럴). Spring 문서에 따르면 던진 예외가 지정한 클래스와 같거나 그 하위 클래스면 걸린다. 그래서 Exception을 적으면 IOException 같은 Checked 예외도 롤백 대상이 된다
  • sendPaymentEmail(payment) — 여기서 IOException이 나면 이제 앞의 save까지 되돌려진다

2) Unchecked Exception으로 변환:

@Transactional
public void processPayment(Payment payment) {
    paymentRepository.save(payment);

    try {
        sendPaymentEmail(payment);
    } catch (IOException e) {
        // RuntimeException으로 바꿔 던지면 자동 롤백된다
        throw new RuntimeException("Failed to send email", e);
    }
}
  • catch (IOException e) — 메일 전송에서 난 Checked 예외를 잡는다
  • throw new RuntimeException("Failed to send email", e) — 같은 실패를 RuntimeException에 담아 다시 던진다. 두 번째 인자 e는 처음 예외를 원인으로 품게 해서, 나중에 기록을 볼 때 진짜 원인을 잃지 않는다. 프록시는 RuntimeException을 받으니 롤백한다

3) 비즈니스 로직으로 분리 (복잡도가 높을 때):

@Slf4j
@Service
public class PaymentService {
    @Autowired
    private EmailService emailService;

    @Transactional
    public void processPayment(Payment payment) {
        paymentRepository.save(payment);
        // 이메일은 트랜잭션 밖에서 처리
    }

    public void sendPaymentEmail(Payment payment) {
        try {
            emailService.send(payment);
        } catch (IOException e) {
            log.error("Failed to send email", e);
            // 로그만 남기고 진행
        }
    }
}
  • processPayment — 결제 저장만 트랜잭션으로 묶는다. 메일은 여기서 부르지 않는다
  • sendPaymentEmail — 메일을 보내고, 실패하면 로그를 남긴 뒤 넘어간다. log는 클래스에 붙인 @Slf4j가 만들어 주는 필드다
  • 이 메서드는 바깥(예: Controller)이 paymentService.processPayment(payment)로 결제를 커밋한 다음에 paymentService.sendPaymentEmail(payment)로 따로 부른다. 그러면 메일이 실패해도 이미 커밋된 결제에는 영향이 없다

함정 3: 예외 처리와 가독성

문제 상황

위의 「Unchecked Exception으로 변환」하는 방법은 동작하지만, 코드가 복잡해진다.

@Transactional
public void processPayment(Payment payment) {
    paymentRepository.save(payment);

    try {
        sendPaymentEmail(payment);
    } catch (IOException e) {
        throw new RuntimeException("Failed to send email", e);
    }
}

Checked 예외를 던지는 메서드를 부를 때마다 이런 try-catch가 하나씩 붙는다. 그러면 원래 하려던 업무 절차가 예외 처리 코드에 묻혀 읽기 어려워진다.

최선의 실무 해법

명시적인 설정이 가장 깔끔하다.

@Transactional(rollbackFor = Exception.class)
public void processPayment(Payment payment) throws IOException {
    paymentRepository.save(payment);
    sendPaymentEmail(payment);
}
  • 코드가 깔끔하다 — try-catch 없이 업무 절차 두 줄만 남는다
  • 의도가 명확하다 — 「예외가 나면 롤백」이 애너테이션 한 곳에 적혀 있다
  • 예외 처리가 숨지 않는다 — throws IOException이 남아 있어서, 이 메서드를 부르는 쪽이 try-catch로 받거나 다시 throws로 넘기도록 컴파일러가 계속 강제한다

함정 4: Propagation 속성과 중첩 트랜잭션

Spring의 Propagation 속성

트랜잭션 메서드 A가 다른 트랜잭션 메서드 B를 부르면, B가 시작될 때는 이미 A가 연 트랜잭션이 있다. 이때 B가 A의 트랜잭션에 끼어들지, 따로 하나를 새로 열지를 정하는 것이 전파 속성이다. 아래 표의 「있으면」은 「부른 쪽이 연 트랜잭션이 이미 있으면」이라는 뜻이다.

Spring은 7가지 전파 속성을 제공하지만, 실무에서는 2가지만 주로 쓴다.

속성 설명 사용도
REQUIRED 트랜잭션 있으면 사용, 없으면 생성 (기본값) 매우 높음
REQUIRES_NEW 항상 새로운 트랜잭션 생성 높음
SUPPORTS 있으면 사용, 없으면 트랜잭션 없이 실행 낮음
NOT_SUPPORTED 트랜잭션 없이 실행 (있으면 잠시 멈춰 둠) 낮음
MANDATORY 트랜잭션 필수 (없으면 예외) 낮음
NEVER 트랜잭션 금지 (있으면 예외) 낮음
NESTED 중첩 트랜잭션 (savepoint 사용) 낮음

코드 예제

@Service
public class PaymentService {
    @Autowired
    private OrderService orderService;

    @Transactional
    public void processPayment(Payment payment) {
        paymentRepository.save(payment);
        try {
            orderService.updateOrder(payment);
        } catch (RuntimeException e) {
            // 주문 갱신이 실패해도 결제는 그대로 진행한다
        }
    }
}

@Service
public class OrderService {
    @Autowired
    private OrderRepository orderRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void updateOrder(Payment payment) {
        orderRepository.update(payment);
        throw new RuntimeException("에러 발생!");
    }
}
  • processPayment — 기본값 REQUIRED라서, 이 메서드가 불릴 때 트랜잭션이 하나 열린다(트랜잭션 1)
  • orderService.updateOrder(payment) — 다른 클래스(의 프록시)를 부르므로 updateOrder의 @Transactional이 읽힌다
  • @Transactional(propagation = Propagation.REQUIRES_NEW) — propagation 속성에 REQUIRES_NEW를 골랐다. 트랜잭션 1을 잠시 멈춰 두고 새 트랜잭션(트랜잭션 2)을 연다
  • orderRepository.update(payment) — 주문 정보를 DB에서 고친다. 이 작업은 트랜잭션 2에 속한다
  • throw new RuntimeException(...) — RuntimeException이니 트랜잭션 2는 롤백된다
  • catch (RuntimeException e) — processPayment가 그 예외를 잡아서 더 위로 올리지 않는다. 그래서 트랜잭션 1은 정상으로 끝난다

결과:

  • OrderService의 updateOrder가 새로운 트랜잭션에서 실행된다
  • updateOrder의 에러로 OrderService의 변경사항은 롤백된다
  • PaymentService의 processPayment(결제 저장)는 커밋된다 (독립적인 트랜잭션)

try-catch가 없으면 결과가 달라진다. updateOrder가 던진 RuntimeException이 processPayment 밖으로 그대로 나가고, 프록시는 RuntimeException을 받았으니 트랜잭션 1도 롤백한다. REQUIRES_NEW가 보장하는 것은 「안쪽 트랜잭션이 롤백되든 커밋되든 바깥 트랜잭션의 커밋 · 롤백은 그것에 끌려가지 않는다」는 것까지다(Spring 문서 — Transaction Propagation). 예외가 바깥 메서드까지 올라가는 것은 자바의 예외 규칙이라 따로 막아야 한다.

updateOrder가 기본값 REQUIRED였다면 try-catch가 있어도 결제까지 되돌려진다. REQUIRED는 새 트랜잭션을 열지 않고 트랜잭션 1에 끼어든다. 그래서 updateOrder가 예외로 끝나면 Spring은 트랜잭션 1에 「끝날 때 커밋하지 말라」는 롤백 표시를 붙인다. Spring 문서도 REQUIRED에서는 안쪽에서 붙인 롤백 표시가 바깥 트랜잭션이 실제로 커밋될 수 있는지에 영향을 준다고 적는다. processPayment가 예외를 잡아 삼키고 정상으로 끝나도 트랜잭션 1은 롤백되고, Spring은 그 사실을 UnexpectedRollbackException으로 알린다. 주문 갱신만 실패시키고 결제는 살리려면 안쪽이 따로 열린 트랜잭션이어야 하고, 그래서 이 예제에 REQUIRES_NEW가 필요하다.


실무 적용: 올바른 @Transactional 사용법

1단계: 기본값으로 설계하기

// 대부분의 경우 이렇게 쓰면 된다
@Service
public class UserService {

    @Transactional  // 기본값 REQUIRED. Checked 예외를 던지지 않으면 rollbackFor도 필요 없다
    public void createUser(User user) {
        userRepository.save(user);
        // 관련 로직들...
    }
}

왜 기본값이 좋을까?

  • 가장 직관적이고 명확하다
  • 대부분의 비즈니스 로직에 맞는다
  • 복잡한 설정을 피할 수 있다

2단계: Exception 처리 명시하기

1단계의 기본값에 하나만 더하는 경우가 이것이다. 메서드가 Checked 예외를 던질 수 있으면(throws가 붙어 있으면) rollbackFor를 명시한다. 함정 2에서 「권장」이라고 한 것도 이 경우다.

@Transactional(rollbackFor = Exception.class)
public void processPayment(Payment payment) throws IOException {
    paymentRepository.save(payment);
    emailService.send(payment);  // IOException 발생 시 롤백
}

3단계: 독립적인 트랜잭션이 필요할 때만 REQUIRES_NEW

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAuditLog(String action) {
    // 메인 트랜잭션과 별개로 실행
    // 메인 트랜잭션이 롤백되어도 로그는 남는다
    auditLogRepository.save(new AuditLog(action));
}
  • saveAuditLog — 감사 로그(누가 언제 무엇을 했는지 남기는 기록)를 저장한다
  • 「메인 트랜잭션」은 이 메서드를 부른 쪽, 예를 들어 결제를 처리하는 @Transactional 메서드가 연 트랜잭션이다
  • REQUIRES_NEW라서 감사 로그는 따로 열린 트랜잭션에서 저장되고 커밋된다. 그래서 나중에 메인 트랜잭션이 롤백되어도 로그는 남는다
  • 이 메서드도 함정 1을 그대로 따른다. 다른 클래스에서 불러야 REQUIRES_NEW가 읽힌다. 같은 클래스에서 부르면 이 설정은 무시되고 메인 트랜잭션 안에서 함께 저장된다

4단계: Self-invocation은 외부 호출로 처리

@Service
public class OrderService {
    @Autowired
    private PaymentService paymentService;

    public void createOrder(Order order) {
        // 외부 서비스 호출 - 프록시를 거친다
        paymentService.processPayment(order.getPayment());
        orderRepository.save(order);
    }
}
  • paymentService.processPayment(...) — 결제 처리를 OrderService 안에 메서드로 두고 this로 부르지 않고, PaymentService라는 다른 클래스에 두고 주입받아 부른다. 그래서 processPayment의 @Transactional이 프록시를 통해 적용된다. 함정 1의 해결 방법을 실제로 쓴 모양이다
  • orderRepository.save(order) — 주문을 DB에 저장한다
  • createOrder에는 @Transactional이 없으므로, 결제(processPayment의 트랜잭션)와 주문 저장은 한 묶음이 아니다. 두 작업을 한 묶음으로 만들려면 createOrder에도 @Transactional을 붙인다. 그러면 기본값 REQUIRED에 따라 processPayment가 그 트랜잭션에 끼어든다

체크리스트: 당신의 @Transactional 사용은 안전한가?

다음 항목을 확인한다.

  • 같은 클래스 안에서 @Transactional 메서드를 부르고 있지 않나?
  • Checked 예외가 날 수 있으면 rollbackFor = Exception.class를 썼나?
  • try-catch가 많아서 업무 절차가 묻혀 있지 않나?
  • REQUIRES_NEW를 정말 필요한 경우에만 쓰고 있나?
  • Propagation 속성을 쓸 때, 왜 기본값 REQUIRED가 아닌지를 주석으로 적어 두었나?

핵심 정리

  1. @Transactional은 프록시 기반 - 외부 호출일 때만 동작한다
  2. Exception 타입 주의 - Checked 예외는 기본적으로 롤백되지 않는다
  3. Checked 예외가 있으면 명시적인 설정 - try-catch로 바꿔 던지기보다 rollbackFor 속성을 쓴다
  4. 나머지는 기본값이 정답 - REQUIRED와 기본 설정만으로도 대부분 충분하다. 3번은 Checked 예외가 있을 때만 더하는 예외다
  5. 복잡함은 피하기 - 모든 Propagation 속성을 다 쓸 필요는 없다

마치며

@Transactional은 강력한 도구지만, 제대로 이해하지 못하고 쓰면 버그가 생긴다.

특히 이런 문제들이다.

  • 자동 롤백이 안 되는 문제
  • Self-invocation으로 트랜잭션이 빠지는 문제
  • 복잡한 예외 처리

이런 문제는 모두 @Transactional의 동작 원리를 제대로 알면 쉽게 피할 수 있다.

실무에서는 기본값을 믿고, 필요할 때만 속성을 더하는 방식이 가장 안전하다.


겪어 본 @Transactional 함정이 있다면 댓글로 알려 달라

@Transactional을 쓰다가 예상치 못한 문제를 겪었거나, 자신만의 트랜잭션 관리 요령이 있다면 댓글로 나눠 주면 좋겠다.


자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기