들어가며….

스프링에서 트랜잭션을 사용하다 보면, “예외를 잡았는데 왜 커밋이 안 되고 롤백이 될까?” 하는 상황을 만나게 됩니다.
특히 org.springframework.transaction.UnexpectedRollbackException은 회원 가입, 주문 처리처럼 주요 로직에 부가 기능(로그, 이력 저장 등)을 붙이다 보면 한 번쯤 경험하게 되는 예외입니다.
이번 글에서는 이 예외가 왜 발생하는지, 그리고 어떻게 해결할 수 있는지 살펴보겠습니다.

문제 상황

회원 가입 시 다음과 같은 두 가지 동작을 합니다.

  1. 회원 저장
  2. 회원 가입 이력 저장

이력 저장은 부가 기능이므로, 실패하더라도 회원 저장은 성공하기를 기대했습니다.
그래서 이력 저장 로직을 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는 트랜잭션 경계 안에서 예외가 전파될 때 발생

+ Recent posts