들어가며….
스프링에서 트랜잭션을 사용하다 보면, “예외를 잡았는데 왜 커밋이 안 되고 롤백이 될까?” 하는 상황을 만나게 됩니다.
특히 org.springframework.transaction.UnexpectedRollbackException은 회원 가입, 주문 처리처럼 주요 로직에 부가 기능(로그, 이력 저장 등)을 붙이다 보면 한 번쯤 경험하게 되는 예외입니다.
이번 글에서는 이 예외가 왜 발생하는지, 그리고 어떻게 해결할 수 있는지 살펴보겠습니다.
문제 상황
회원 가입 시 다음과 같은 두 가지 동작을 합니다.
- 회원 저장
- 회원 가입 이력 저장
이력 저장은 부가 기능이므로, 실패하더라도 회원 저장은 성공하기를 기대했습니다.
그래서 이력 저장 로직을 try-catch로 감싸 두었죠.
하지만 결과는 예상과 달랐습니다.
회원 저장도 실패하면서 UnexpectedRollbackException이 발생했습니다.
기대 vs 실제를 정리하면 다음과 같습니다.
- 기대: 회원 저장은 성공, 이력 저장만 실패
- 실제: UnexpectedRollbackException 발생 → 회원도 저장되지 않음
코드 예시
Member 엔티티
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Table(name = "member")
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private LocalDateTime createDateTime;
// ... 생략
}
MemberSignupHistory 엔티티
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Table(name = "member_signup_history")
@Entity
public class MemberSignupHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long userId;
private LocalDateTime createDateTime;
public MemberSignupHistory(Member member) {
this.userId = member.getId();
this.createDateTime = LocalDateTime.now();
}
// ... 생략
}
MemberSignupHistoryService - 회원가입 이력 저장 서비스
@Slf4j
@RequiredArgsConstructor
@Service
public class MemberSignupHistoryService {
private final MemberRegistrationHistoryRepository memberRegistrationHistoryRepository;
@Transactional
public void saveSignupHistory(Member member) {
memberRegistrationHistoryRepository.save(new MemberSignupHistory(member));
if (true) {
throw new RuntimeException("회원가입 이력 저장 시 예외 발생");
}
}
}
MemberSignupService - 회원가입 서비스
@Slf4j
@RequiredArgsConstructor
@Service
public class MemberSignupService {
private final MemberRepository memberRepository;
private final MemberSignupHistoryService memberSignupHistoryService;
@Transactional
public Long signupMember() {
// 회원 저장
Member member = memberRepository.save(new Member());
try {
// 로그 저장을 실패하더라도 진행
memberSignupHistoryService.saveSignupHistory(member);
} catch (Exception e) {
log.error(e.getMessage());
}
return member.getId(); // UnexpectedRollbackException 발생
}
}
왜 이런일이 발생할까?
문제의 원인은 @Transactional의 동작 원리에 있습니다.
스프링 트랜잭션은 트랜잭션 경계 안에서 예외가 던져지면 rollback-only 마킹을 해둡니다.
이 상태에서는 커밋이 불가능합니다.
즉, try-catch로 예외를 잡더라도 이미 트랜잭션은 rollback-only 상태인 것이죠.
최종적으로 커밋을 시도할 때 스프링은 이렇게 반응합니다:
“rollback-only인데 커밋하려 하네? → UnexpectedRollbackException 발생!”
핵심: 예외를 잡았다고 해서 트랜잭션이 살아나는 것은 아닙니다.
해결 방법
방법 1. 이력 저장을 별도 트랜잭션으로 실행
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveSignupHistory(Member member) {
memberRegistrationHistoryRepository.save(new MemberSignupHistory(member));
if (true) {
throw new RuntimeException("회원가입 이력 저장 시 예외 발생");
}
}
- REQUIRES_NEW는 새로운 트랜잭션을 시작합니다.
- 따라서 실패하더라도 메인 트랜잭션에는 영향을 주지 않습니다.
방법 2. 이력 저장을 try…catch 처리
@Transactional
public void saveSignupHistory(Member member) {
try {
memberRegistrationHistoryRepository.save(new MemberSignupHistory(member));
if (true) {
throw new RuntimeException("회원가입 이력 저장 시 예외 발생");
}
} catch (Exception e) {
...
}
}
- 이력 저장 과정에서 발생한 예외를 내부에서 처리하고 외부로 던지지 않습니다.
- 따라서 외부 트랜잭션은 rollback-only 마킹되지 않고 정상적으로 커밋됩니다.
방법 3. 비동기 처리 (이력 저장을 메시지 큐나 이벤트로 처리)
- 회원 저장이 끝난 후 이벤트를 발행합니다.
- 별도 Consumer나 비동기 핸들러에서 이력 저장을 처리합니다.
- 실패하더라도 메인 트랜잭션에는 영향을 주지 않습니다.
방법 4. @Transactional 어노테이션 제거
public void saveSignupHistory(Member member) {
try {
memberRegistrationHistoryRepository.save(new MemberSignupHistory(member));
if (true) {
throw new RuntimeException("회원가입 이력 저장 시 예외 발생");
}
} catch (Exception e) {
...
}
}
- @Transactional 어노테이션이 제거되면 MemberSignupHistoryService.saveSignupHistory(…) 호출 시 트랜잭션이 전파되지 않기 때문에 예외가 발생하더라도 rollback-only 마킹이 되지 않습니다.
- rollback-only 마킹의 핵심은 트랜잭션 경계 안에서 예외가 전파될 경우입니다!
정리
- 스프링 트랜잭션은 내부에서 예외가 발생하면 rollback-only로 마킹합니다.
- try-catch로 잡아도 이미 트랜잭션은 커밋할 수 없습니다.
- 따라서 보조 기능(로그, 이력 등)이 실패해도 메인 로직을 살리고 싶다면
- 별도 트랜잭션(REQUIRES_NEW)
- 내부 예외 처리
- 비동기/이벤트 처리 등을 고려해야 합니다.
- 중요 : rollback-only는 트랜잭션 경계 안에서 예외가 전파될 때 발생
'IT > 백엔드 필수 교양' 카테고리의 다른 글
| AWS EC2 젠킨스 CI/CD를 구축하고 ECR 배포 파이프라인 생성하기 (3) | 2024.09.11 |
|---|---|
| [JDK] 가비지 컬렉션(garbage collection)에 대한 기초적인 지식 (1) | 2024.08.24 |
| [JPA] 엔티티 상속 전략 활용 예제 (0) | 2024.08.22 |
| [AWS] SQS (Simple Queue Service)와 Spring 연동하기 (0) | 2024.08.14 |
| [Java] 스프링에서 자주 사용하는 디자인 패턴 - 전략 패턴 (0) | 2024.08.05 |