Spring Boot로 개발하면 누구나 @Transactional을 써 보게 된다. 메서드 위에 붙이는 @이름 꼴의 표시를 애너테이션이라고 하는데, 그 자체로는 아무 일도 하지 않고 Spring이 읽어서 무엇을 할지 정한다. @Transactional은 「이 메서드를 트랜잭션 하나로 묶어 달라」는 표시다. 하지만 「메서드에 붙이면 트랜잭션이 된다」고만 알고 있으면 충분하지 않다.
실무에서 자주 마주치는 「분명히 @Transactional을 붙였는데 왜 롤백이 안 되지?」라는 문제는 대부분 @Transactional이 어떻게 동작하는지 모르기 때문에 생긴다.
이 글은 실제로 토론하며 정리한 내용을 바탕으로 @Transactional의 함정들과 실무에서 어떻게 대처할지를 적는다.
토론 요약: @Transactional의 5가지 함정
토론에서 찾은 @Transactional의 주요 함정은 다섯 개다. 아래에서 「트랜잭션이 걸린다」는 말은 그 메서드가 시작될 때 트랜잭션이 열리고, 끝날 때 커밋이나 롤백으로 닫힌다는 뜻이다.
- 자기 호출(Self-invocation) 문제 - 같은 클래스 안에서 부르면 트랜잭션이 안 걸린다
- 예외 타입에 따른 롤백 차이 - Checked 예외는 기본적으로 롤백되지 않는다
- 복잡한 예외 처리 로직 - try-catch 때문에 코드가 읽기 어려워진다
- 전파(Propagation) 속성을 잘못 이해하는 것 - 트랜잭션 메서드가 다른 트랜잭션 메서드를 부를 때 어디까지가 한 묶음인지 헷갈린다
- 과도한 복잡성 - 모든 속성을 다 쓰려다가 코드가 복잡해진다
함정 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가 아닌지를 주석으로 적어 두었나?
핵심 정리
- @Transactional은 프록시 기반 - 외부 호출일 때만 동작한다
- Exception 타입 주의 - Checked 예외는 기본적으로 롤백되지 않는다
- Checked 예외가 있으면 명시적인 설정 - try-catch로 바꿔 던지기보다
rollbackFor속성을 쓴다 - 나머지는 기본값이 정답 - REQUIRED와 기본 설정만으로도 대부분 충분하다. 3번은 Checked 예외가 있을 때만 더하는 예외다
- 복잡함은 피하기 - 모든 Propagation 속성을 다 쓸 필요는 없다
마치며
@Transactional은 강력한 도구지만, 제대로 이해하지 못하고 쓰면 버그가 생긴다.
특히 이런 문제들이다.
- 자동 롤백이 안 되는 문제
- Self-invocation으로 트랜잭션이 빠지는 문제
- 복잡한 예외 처리
이런 문제는 모두 @Transactional의 동작 원리를 제대로 알면 쉽게 피할 수 있다.
실무에서는 기본값을 믿고, 필요할 때만 속성을 더하는 방식이 가장 안전하다.
겪어 본 @Transactional 함정이 있다면 댓글로 알려 달라
@Transactional을 쓰다가 예상치 못한 문제를 겪었거나, 자신만의 트랜잭션 관리 요령이 있다면 댓글로 나눠 주면 좋겠다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기