@Transactional의 커밋과 롤백은 "예외가 났는가"로 결정되지 않는다. 예외를 잡았는데도 롤백되고, 예외가 났는데도 커밋되는 경우가 있다. 이 글은 회원가입 예제에서 코드를 직접 고쳐가며, 사람이 실제로 겪는 순서 그대로 그 규칙을 발견해 나간다.
실험 구조
회원가입이 회원(MEMBER)을 저장(본 작업)한 뒤 가입 이력(SIGNUP_HISTORY)을 저장하는 구조다.
@Service
@RequiredArgsConstructor
public class SignupService {
private final MemberRepository memberRepository;
private final SignupHistoryService historyService;
// 케이스별로 이 메서드를 조금씩 바꿔가며 실험한다
@Transactional
public void signup(String email) {
memberRepository.save(Member.of(email)); // ① 본 작업: 회원 저장
historyService.record(email); // ② 이력 저장
}
}
- 각 케이스는 POST /api/signup/v1 ~ /v9 로 실행한다. 모든 요청에 email 파라미터가 필수다.
- 결과는 GET /api/signup/members(커밋된 회원), GET /api/signup/histories(커밋된 이력)로 확인한다.
- H2 인메모리라 재기동 전까지 커밋된 행이 쌓이므로, 케이스마다 다른 email로 실행하면 관찰이 깔끔하다.
이력 저장은 이력을 남긴 직후 후속 처리에서 오류가 나 예외를 던지는 상황을 다룬다.
1. 예외를 잡지 않으면 롤백된다
일단 회원을 저장하고 이력 저장을 호출하게 두고 실행한다. 이력 저장이 예외를 던진다.
POST /api/signup/v1
// SignupService
@Transactional
public void signup(String email) {
memberRepository.save(Member.of(email));
historyService.record(email); // 예외를 아무도 잡지 않는다
}
// SignupHistoryService
@Transactional // 기본 REQUIRED
public void record(String email) {
signupHistoryRepository.save(SignupHistory.of(email));
throw new RuntimeException("이력 후속 처리 실패");
}
- 무엇을 하면: 회원을 저장하고 이력 저장을 호출한다. 이력 저장이 RuntimeException을 던지고, 이 예외를 아무도 잡지 않는다.
- 결과: 회원 롤백, 이력 롤백. HTTP 500.
- 왜: 예외를 잡지 않으면 회원가입 메서드가 예외로 종료된다. 스프링이 그 예외를 보고 회원가입 트랜잭션을 롤백해 회원과 이력이 함께 사라진다. 예외가 나면 롤백된다. 여기까지는 직관과 같다.
2. 예외를 잡아도 롤백된다
이력 저장이 실패한 것뿐이니 회원가입은 성공시키고 싶다. 예외를 try/catch로 잡고 로그만 남긴다. 그런데 기대와 달리 회원까지 사라진다.
POST /api/signup/v2
@Transactional
public void signup(String email) {
memberRepository.save(Member.of(email));
try {
historyService.record(email);
} catch (RuntimeException e) {
log.warn("이력 저장 실패, 무시하고 진행: {}", e.getMessage());
}
}
- 무엇을 하면: 이력 저장이 던진 RuntimeException을 잡아 로그만 남기고 정상 종료한다.
- 결과: 회원 롤백, 이력 롤백. HTTP 500 (UnexpectedRollbackException).
- 왜: 이력 저장은 회원가입 트랜잭션에 참여한 상태다. 참여한 메서드가 롤백 대상 예외를 던지면 스프링은 그 자리에서 되돌리지 않고 트랜잭션에 "커밋 금지, 롤백만 가능"이라는 표시(rollback-only 마킹)를 찍는다. 이 마킹은 바깥에서 예외를 잡아도 지워지지 않는다. 회원가입이 정상 종료해 커밋을 시도하는 순간, 마킹 때문에 커밋이 거부되고 UnexpectedRollbackException과 함께 전체가 롤백된다.
로그를 보면 마킹이 찍히는 순간이 그대로 보인다.
JpaTransactionManager : Creating new transaction with name [SignupService.signup]
JpaTransactionManager : Participating in existing transaction ← 이력 저장, 참여
JpaTransactionManager : Participating transaction failed
- marking existing transaction as rollback-only ← 마킹!
여기서 동작 원리 세 가지를 정리한다.
- 참여(REQUIRED): @Transactional 메서드가 다른 @Transactional 메서드를 호출하면, 안쪽은 새 트랜잭션을 만들지 않고 이미 열린 바깥 트랜잭션에 참여한다. 기본 전파속성 REQUIRED("있으면 참여, 없으면 새로 시작")의 동작이다. 둘이 하나의 트랜잭션을 공유한다.
- 롤백은 마킹으로 예약된다: 참여한 메서드가 롤백 대상 예외(기본은 RuntimeException과 Error)를 던지면, 즉시 되돌리는 대신 rollback-only 마킹을 남긴다. 참여자는 트랜잭션의 주인이 아니라 마음대로 롤백할 수 없기 때문이다. 마킹은 한 번 찍히면 지워지지 않는다.
- 커밋 여부는 최초 트랜잭션이 끝날 때 결정된다: 트랜잭션을 처음 연 메서드가 끝나면 스프링이 커밋을 시도하고, 마킹이 있으면 커밋을 거부하며 UnexpectedRollbackException을 던진다.
흐름으로 보면 이렇다.
signup() [트랜잭션 시작 — 주인]
├─ 회원 저장 (참여)
├─ record() (참여)
│ └─ RuntimeException!
│ └─ rollback-only 마킹 ★ 여기서 롤백 확정 ★
├─ catch { 로그만 남김 } ← 잡았지만 마킹은 그대로
└─ 정상 종료 → 커밋 시도 → 마킹 발견 → 커밋 거부 → 전체 롤백
핵심은 예외를 잡았느냐가 아니라 마킹이 남았느냐다. 이력 저장 실패가 회원가입을 롤백시키지 않게 하려면, 이력 저장을 본 트랜잭션에서 분리해 마킹이 남지 않게 해야 한다.
3. 이력 저장을 본 트랜잭션에서 분리한다
분리하는 방법은 여러 가지고, 분리할 작업이 DB 작업이냐, 외부 호출이냐, 커밋 후 후처리냐에 따라 고른다.
DB 작업이라면 전파속성으로 트랜잭션 경계를 바꾼다. 전파속성은 안쪽 메서드가 바깥 트랜잭션과 어떻게 엮일지를 정한다.
전파속성 동작 안쪽 실패 시
| REQUIRED (기본) | 바깥 트랜잭션에 참여. 한 몸 | 바깥까지 롤백 (지금까지 본 것) |
| REQUIRES_NEW | 바깥을 잠시 멈추고 완전히 별개의 트랜잭션을 연다 | 서로 독립적으로 커밋/롤백 |
| NESTED | 새 트랜잭션 없이, 같은 트랜잭션 안에 savepoint를 찍고 진행 | savepoint까지만 되돌리고 바깥은 유지. 단, 바깥이 롤백되면 안쪽도 함께 롤백 |
savepoint는 DB(JDBC) 기능이다. 트랜잭션 하나 안에서 중간 지점을 표시해 두고, 나중에 그 지점까지만 되돌릴 수 있게 한다. 트랜잭션 전체를 되돌리는 롤백과 달리 일부만 되돌린다.
3-1. REQUIRES_NEW — 이력 저장을 별도 트랜잭션으로
이력 저장에 REQUIRES_NEW를 준다. 이력 저장은 본 트랜잭션을 잠시 멈추고 자기만의 트랜잭션에서 실행된다.
POST /api/signup/v3
// SignupHistoryService
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String email) {
signupHistoryRepository.save(SignupHistory.of(email));
throw new RuntimeException("이력 후속 처리 실패");
}
// SignupService — v2와 동일하게 잡는다
@Transactional
public void signup(String email) {
memberRepository.save(Member.of(email));
try {
historyService.record(email);
} catch (RuntimeException e) {
log.warn("이력 저장 실패, 무시하고 진행: {}", e.getMessage());
}
}
- 무엇을 하면: REQUIRES_NEW로 분리된 이력 저장이 RuntimeException을 던지고, 본 작업은 이 예외를 잡아 로그만 남긴다.
- 결과: 회원 커밋, 이력 롤백. HTTP 200. — 드디어 의도대로다.
- 왜: 이력 저장이 별도 트랜잭션에서 실행되므로, 실패하면 그 별도 트랜잭션만 롤백된다. 별도 트랜잭션이라 회원가입 트랜잭션에는 rollback-only 마킹을 남기지 않는다. 회원가입이 예외를 잡아 정상 종료하므로 회원은 커밋되고, 실패한 이력만 취소된다. 단, 트랜잭션이 분리되는 만큼 DB 커넥션을 하나 더 쓴다는 점은 알고 사용해야 한다.
3-2. REQUIRES_NEW — 본 작업이 실패해도 이력은 남긴다
분리는 반대 방향으로도 쓴다. 이력을 REQUIRES_NEW로 먼저 커밋해 두면, 이후 본 작업이 실패해 롤백되더라도 이력은 그대로 남는다. 감사 로그처럼 본 작업의 성패와 무관하게 남겨야 하는 기록에 쓴다.
POST /api/signup/v4
@Transactional
public void signup(String email) {
memberRepository.save(Member.of(email));
historyService.record(email); // 성공 → 즉시 커밋
throw new RuntimeException("본 작업 후속 처리 실패");
}
// SignupHistoryService — 이번엔 이력 저장이 성공한다
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String email) {
signupHistoryRepository.save(SignupHistory.of(email));
}
- 무엇을 하면: REQUIRES_NEW 이력 저장이 성공해 즉시 커밋되고, 그 뒤 본 작업이 RuntimeException을 던진다.
- 결과: 회원 롤백, 이력 커밋. HTTP 500.
- 왜: REQUIRES_NEW로 실행된 이력 저장은 자기 트랜잭션에서 이미 커밋을 끝냈다. 이후 회원가입 트랜잭션이 예외로 롤백되어 회원은 사라지지만, 이미 커밋된 이력은 다른 트랜잭션의 결과라 영향을 받지 않는다.
3-3. NESTED — savepoint로 일부만 되돌리기
이력 저장에 NESTED를 준다. 별도 트랜잭션이 아니라 savepoint 기반으로, 이력 저장만 savepoint까지 되돌리고 본 작업은 유지하려는 방식이다.
POST /api/signup/v5
@Transactional(propagation = Propagation.NESTED)
public void record(String email) {
signupHistoryRepository.save(SignupHistory.of(email));
throw new RuntimeException("이력 후속 처리 실패");
}
- 무엇을 하면: NESTED로 지정된 이력 저장을 호출하고, 본 작업은 발생하는 예외를 잡아 로그만 남긴다.
- 결과: 회원 커밋, 이력 없음(이력 저장 미실행). HTTP 200.
- 왜: JpaTransactionManager는 NESTED를 기본으로 허용하지 않는다(nestedTransactionAllowed = false). 이력 저장 트랜잭션을 시작하려는 단계에서 NestedTransactionNotSupportedException(RuntimeException 계열)이 난다. 이력 저장 본문은 실행조차 되지 않고, 회원가입이 이 예외를 잡아 회원만 커밋된다. 의도한 "부분 롤백"이 아니라 "실행 자체가 안 된 것"이라는 점에 주의 — 결과만 보면 3-1과 비슷해 보여서 더 위험하다.
- 왜 JPA는 기본으로 막아뒀나: savepoint 롤백은 DB(JDBC) 행만 되돌린다. JPA는 그 위에 영속성 컨텍스트(엔티티를 관리하는 메모리 캐시)가 있는데, savepoint 롤백은 이 메모리 상태를 자동으로 되돌리지 않아 DB와 메모리가 어긋날 수 있다. 안전이 보장되지 않아 스프링이 기본은 끄고 필요하면 켜도록(opt-in) 뒀다. nestedTransactionAllowed = true로 켜면 Hibernate가 JDBC 커넥션을 노출해 동작하되, 세션 불일치는 사용자가 관리해야 한다. DataSourceTransactionManager(JdbcTemplate/MyBatis)는 위에 영속성 컨텍스트가 없어 savepoint 롤백이 항상 정확하므로 기본으로 켜져 있다.
3-4. @TransactionalEventListener(AFTER_COMMIT) — 본 커밋 후 실행
본 작업이 커밋된 뒤에 이력 저장을 실행한다. 이력 저장이 실패해도 본 커밋에는 영향이 없다.
@Transactional
public void signup(String email) {
memberRepository.save(Member.of(email));
eventPublisher.publishEvent(new SignupCompletedEvent(email)); // 이벤트만 발행
}
@Component
public class SignupHistoryListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW) // ★ 없으면 저장이 조용히 유실된다
public void onSignupCompleted(SignupCompletedEvent event) {
signupHistoryRepository.save(SignupHistory.of(event.email()));
}
}
리스너는 본 트랜잭션이 커밋된 뒤에 호출된다. 이 시점에는 본 트랜잭션이 이미 끝나 마킹 대상이 없으므로, 리스너에서 예외가 나도 회원 커밋은 되돌려지지 않는다.
한 가지 함정이 있다. AFTER_COMMIT 시점에는 원래 트랜잭션의 자원(커넥션)이 아직 스레드에 붙어 있지만 트랜잭션 자체는 이미 완료된 상태라, 리스너 안에서 그냥 DB 저장을 하면 참여할 트랜잭션이 없어 변경이 커밋되지 않고 조용히 유실될 수 있다. 스프링 공식 문서도 이 경우 REQUIRES_NEW로 새 트랜잭션을 열라고 명시한다. 위 코드에 REQUIRES_NEW가 붙어 있는 이유다.
3-5. 호출 순서 분리 — 본 트랜잭션을 끝낸 뒤 호출자에서 실행
본 @Transactional 메서드를 먼저 끝내(커밋) 트랜잭션 경계를 닫은 다음, 호출자에서 이력 저장을 실행한다.
// 트랜잭션이 없는 조율(orchestration) 계층
public void signup(String email) {
memberSignupService.saveMember(email); // 여기서 커밋되고 트랜잭션이 닫힌다
signupHistoryService.record(email); // 트랜잭션 밖(또는 자기 트랜잭션)에서 실행
}
이력 저장이 본 트랜잭션 밖에서 돌아 마킹할 트랜잭션이 없으므로, 실패해도 회원 커밋과 무관하다. 구조가 단순하고 명시적이라, 두 작업의 독립성을 코드만 봐도 알 수 있다는 것이 장점이다.
3-6. @Async / 메시지 큐 — 별도 스레드·프로세스로 분리
이력 저장을 별도 스레드나 다른 프로세스로 넘겨 본 트랜잭션과 완전히 떼어낸다.
@Async // @EnableAsync 필요
@Transactional
public void record(String email) {
signupHistoryRepository.save(SignupHistory.of(email));
}
@Async는 별도 스레드에서 실행된다. 트랜잭션은 스레드에 묶이므로(스레드 로컬), 다른 스레드에서 도는 이력 저장은 본 트랜잭션과 자연스럽게 다른 트랜잭션 경계를 갖는다. 메시지 큐(Kafka 등)로 넘기면 아예 다른 프로세스에서 처리되어 본 작업과 독립적으로 성공/실패한다. 단, 별도 스레드의 예외는 호출자에게 전파되지 않으므로 실패를 감지하려면 별도의 처리(AsyncUncaughtExceptionHandler, 재시도 등)가 필요하고, @Async 역시 뒤에서 볼 프록시 방식이라 셀프 호출하면 무시된다.
무엇을 언제 고르나
상황 선택
| DB 작업이고, 실패해도 본 작업을 살려야 한다 | REQUIRES_NEW(독립 커밋). JDBC 환경이면 NESTED(부분 롤백)도 가능 |
| 본 작업의 성패와 무관하게 반드시 남길 기록(감사 로그) | REQUIRES_NEW로 먼저 커밋 (3-2) |
| 외부 호출(HTTP, 알림 등) | 트랜잭션 안에서 하지 말 것. 호출 순서 분리(3-5) 또는 @Async·큐(3-6) |
| 본 커밋이 성공한 뒤에만 실행하면 되는 후처리 | @TransactionalEventListener(AFTER_COMMIT) (3-4) |
4. 예외 종류와 롤백 규칙에 따른 변형
실험을 이어가며 만나는 것들이다. 2장에서 "롤백 대상 예외"라고 했는데, 무엇이 롤백 대상인지는 롤백 규칙이 정한다. 기본 규칙은 스프링 코드에 그대로 있다.
// DefaultTransactionAttribute
@Override
public boolean rollbackOn(Throwable ex) {
return (ex instanceof RuntimeException || ex instanceof Error);
}
4-1. CheckedException을 던지는 경우
POST /api/signup/v6
@Transactional
public void record(String email) throws SignupFailedException {
signupHistoryRepository.save(SignupHistory.of(email));
throw new SignupFailedException("이력 후속 처리 실패"); // Checked 예외
}
- 무엇을 하면: 이력 저장이 CheckedException(SignupFailedException)을 던지고, 본 작업은 이를 잡아 로그만 남긴다.
- 결과: 회원 커밋, 이력 커밋. HTTP 200.
- 왜: 기본 롤백 대상은 RuntimeException과 Error뿐이다. CheckedException은 기본 롤백 대상이 아니라 rollback-only 마킹이 되지 않는다. 본 작업이 예외를 잡아 정상 종료하므로 회원과, 예외가 나기 직전까지 저장된 이력까지 모두 커밋된다. "예외가 났는데 왜 커밋되지?"라는 반대 방향의 함정이다.
4-2. rollbackFor — CheckedException도 롤백시키기
POST /api/signup/v7
@Transactional(rollbackFor = Exception.class)
public void record(String email) throws SignupFailedException {
signupHistoryRepository.save(SignupHistory.of(email));
throw new SignupFailedException("이력 후속 처리 실패");
}
- 무엇을 하면: 이력 저장에 rollbackFor = Exception.class를 걸고 CheckedException을 던지게 한다. 본 작업은 이를 잡아 로그만 남긴다.
- 결과: 회원 롤백, 이력 롤백. HTTP 500 (UnexpectedRollbackException).
- 왜: rollbackFor로 롤백 대상이 CheckedException까지 넓어진다. 이력 저장이 예외를 던지면 참여한 트랜잭션이 rollback-only로 마킹된다. 같은 예외인데 v6과 정반대 결과 — 이후는 v2와 동일하다. 본 작업이 잡아도 커밋 시점에 전체 롤백된다.
4-3. noRollbackFor — RuntimeException이어도 롤백 막기
POST /api/signup/v8
@Transactional(noRollbackFor = RuntimeException.class)
public void record(String email) {
signupHistoryRepository.save(SignupHistory.of(email));
throw new RuntimeException("이력 후속 처리 실패");
}
- 무엇을 하면: 이력 저장에 noRollbackFor = RuntimeException.class를 걸고 RuntimeException을 던지게 한다. 본 작업은 이를 잡아 로그만 남긴다.
- 결과: 회원 커밋, 이력 커밋. HTTP 200.
- 왜: noRollbackFor로 해당 예외가 롤백 대상에서 빠진다. 이력 저장이 RuntimeException을 던져도 rollback-only 마킹이 되지 않는다. 예외로 로그는 남지만 본 작업이 잡아 정상 종료하므로 회원과 이력이 모두 커밋된다. 단, 예외가 난 시점의 어중간한 상태가 그대로 커밋되므로 "이 예외는 정말 롤백이 필요 없다"는 확신이 있을 때만 쓸 것.
5. 셀프 호출 — 분리가 적용되지 않는다
3장에서 본 REQUIRES_NEW 분리는 프록시를 거쳐야 적용된다. @Transactional의 실제 일(트랜잭션 시작, 마킹, 커밋/롤백)은 내 코드가 아니라 스프링이 빈을 감싸서 만든 프록시가 하기 때문이다. 같은 빈 안에서 this로 호출하면 프록시를 거치지 않아 분리가 일어나지 않는다.
POST /api/signup/v9
@Service
public class SignupService {
@Transactional
public void signup(String email) {
memberRepository.save(Member.of(email));
try {
record(email); // this.record() — 프록시를 안 거친다!
} catch (RuntimeException e) {
log.warn("이력 저장 실패, 무시하고 진행: {}", e.getMessage());
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW) // 통째로 무시됨
public void record(String email) {
signupHistoryRepository.save(SignupHistory.of(email));
throw new RuntimeException("이력 후속 처리 실패");
}
}
- 무엇을 하면: 한 빈 안에서 회원을 저장한 뒤, 같은 빈의 REQUIRES_NEW 이력 저장 메서드를 this로 호출한다. 이력 저장이 RuntimeException을 던지고, 이를 잡아 로그만 남긴다.
- 결과: 회원 커밋, 이력 커밋. HTTP 200.
- 왜: 같은 빈 내부의 this 호출은 프록시를 거치지 않아 이력 저장 메서드의 REQUIRES_NEW가 무시되고, 분리가 일어나지 않는다. 트랜잭션 경계가 없으니 예외를 잡아줄 인터셉터도 없어 마킹할 주체가 없다. 그 save와 throw는 회원가입 트랜잭션 안의 평범한 자바 코드일 뿐이고, 본 작업이 예외를 잡아 정상 종료하므로 회원과 실패했어야 할 이력까지 모두 커밋된다.
3-1(v3)과 코드 의도는 같은데 결과가 다르다는 점이 이 케이스의 무서운 부분이다. 예외도 안 나고 HTTP 200이라 겉으로는 멀쩡해 보이지만, 격리 의도는 조용히 무력화됐다. 여기서 알 수 있는 사실 — "같은 트랜잭션 + 예외"가 롤백을 만드는 게 아니라, 인터셉터(프록시)의 마킹이 롤백을 만든다. 트랜잭션 경계가 필요하면 반드시 별도 빈으로 분리해 주입받아 호출해야 한다.
6. 한눈에 보는 정리
6-1. 케이스별 결과표
케이스 조건 회원 이력 HTTP / 예외
| v1 | 참여 + Runtime + 안 잡음 | ✗ 롤백 | ✗ 롤백 | 500 RuntimeException |
| v2 | 참여 + Runtime + 잡음 | ✗ 롤백 | ✗ 롤백 | 500 UnexpectedRollbackException |
| v3 | REQUIRES_NEW 실패 + 잡음 | ✓ 커밋 | ✗ 롤백 | 200 |
| v4 | REQUIRES_NEW 성공 + 본 작업 실패 | ✗ 롤백 | ✓ 커밋 | 500 RuntimeException |
| v5 | NESTED (JPA 기본 설정) | ✓ 커밋 | ✗ 미실행 | 200 |
| v6 | 참여 + Checked + 잡음 | ✓ 커밋 | ✓ 커밋 | 200 |
| v7 | 참여 + Checked + rollbackFor + 잡음 | ✗ 롤백 | ✗ 롤백 | 500 UnexpectedRollbackException |
| v8 | 참여 + Runtime + noRollbackFor + 잡음 | ✓ 커밋 | ✓ 커밋 | 200 |
| v9 | 셀프 호출 REQUIRES_NEW + 잡음 | ✓ 커밋 | ✓ 커밋(오염) | 200 |
6-2. 판단 흐름
예외가 났을 때 결과를 예측하는 순서다.
예외 발생
│
├─ ① 그 메서드 호출이 프록시를 거쳤는가?
│ 아니오 → 트랜잭션 로직 자체가 없음. 그냥 자바 예외 ·········· v9
│ 예 ↓
├─ ② 트랜잭션이 분리되어 있는가? (REQUIRES_NEW 등)
│ 예 → 자기 트랜잭션만 독립적으로 커밋/롤백 ··············· v3, v4
│ 아니오(참여) ↓
├─ ③ 롤백 규칙상 롤백 대상 예외인가?
│ 아니오 → 마킹 없음. 잡으면 커밋된다 ····················· v6, v8
│ 예 ↓
└─ ④ rollback-only 마킹
→ 바깥에서 잡아도 커밋 시점에 전체 롤백 ··············· v2, v7
→ 잡지 않으면 원래 예외로 롤백 ·································· v1
마무리
아홉 케이스를 관통하는 규칙은 결국 두 가지다.
- 경계 — 예외가 트랜잭션 경계(프록시)를 지나갔는가. 경계가 없으면(v9) 마킹도 격리도 없고, 경계가 분리되어 있으면(v3, v4) 서로 영향이 없다.
- 마킹 — 참여 트랜잭션의 경계를 롤백 대상 예외가 빠져나갔는가. 빠져나갔다면(v2, v7) 바깥에서 무엇을 해도 전체 롤백이다.
실무적인 결론 하나. 참여 트랜잭션의 예외를 바깥에서 잡는 코드는 의도대로 동작하지 않는다(v2). 실패를 허용해야 하는 로직이라면 처음부터 경계를 분리하고(3장), 그게 아니라면 잡지 말고 그대로 던지는 것(v1)이 가장 깔끔하다.
참고
'Backend study > Spring Boot' 카테고리의 다른 글
| 자바에서 코드를 조작하는 방법: 바이트코드, 리플렉션, 프록시, 애노테이션 프로세서 (0) | 2026.08.29 |
|---|---|
| Spring WebFlux와 리액티브 프로그래밍 정리 (0) | 2026.08.02 |
| Spring boot 시작하기 (5) | 2024.09.27 |
| Spring boot란? (0) | 2024.09.27 |