🎈논문 요약 플랫폼(캡스톤) - 3(트러블 슈팅-트랜잭션 롤백)


OAuth 로그인 트러블 슈팅: 트랜잭션 롤백과 saveOrUpdate 리팩터링

OAuth 로그인 흐름을 구현하면서 예상치 못한 트랜잭션 문제와 save/update 로직의 불명확함이 있어 해결한 과정을 담았다.

문제 상황

1. “Transaction silently rolled back” 에러 발생

OAuth 로그인 후, 서버에서 JWT 토큰을 발급하려는 시점에 다음과 같은 에러를 마주했다.

1
2
3
4
5
{
  "code": "IA-0001",
  "message": "Transaction silently rolled back because it has been marked as rollback-only"
}

-> 흠.. 로그를 분석해본결과 OAuth 플로우 자체는 성공했는데, 최종 사용자 저장 과정에서 트랜잭션이 롤백되면서 서버 응답이 실패했다.

2. saveOrUpdate 로직의 문제점 발견

문제를 쫒아가다보니, 사용자 저장 로직에서 save를 호출할 때 이중으로 DB 조회가 일어나는 구조가 있었고, 1번 문제가 생기는 지점이기도 했다.

원인 분석

메커니즘 깊이 분석

이유를 알기 위해선 Spring의 @Transactional 트랜잭션 처리 과정을 알아야한다.

2025-04-29-025544.png => 핵심은 “RuntimeException”이 발생하는 순간 rollback-only 표시가 무조건 된다는 것이다. catch를 잡든 정상 리턴하든 관계가 없다.

  • 참여 중인 트랜잭션이 실패하면 기본정책이 전역롤백 이기 때문에 마지막 순간에 rollbackexception을 던진다.

    globalRollbackOnParticipationFailure 속성 (주석)
    Set whether to globally mark an existing transaction as rollback-only after a participating transaction failed.
    Default is “true”: If a participating transaction (e.g. with PROPAGATION_REQUIRED or PROPAGATION_SUPPORTS encountering an existing transaction) fails, the transaction will be globally marked as rollback-only. The only possible outcome of such a transaction is a rollback: The transaction originator cannot make the transaction commit anymore.
    참여 중인 트랜잭션이 실패한 후에 기존 트랜잭션을 전역적으로 rollback-only로 마킹할 것인지 설정
    디폴트는 true
    PROPAGATION_REQUIRED 또는 PROPAGATION_SUPPORTS 인 참여 중인 트랜잭션이 실패하면, 그 트랜잭션은 전역적으로 rollback-only로 마킹됨
    이런 트랜잭션은 결과적으로 롤백되고 최초의 트랜잭션관리자도 그 트랜잭션을 커밋시킬 수 없음

rollback-only 마킹은 트랜잭션 상태 자체를 변형시킨다는 사실이 중요하다

  • 이후 메서드가 catch 해서 정상적으로 끝나더라고
  • 트랜잭션 상태는 이미 “rollback-only”다
  • 메서드가 끝나는 순간 Spring은 “rollback-only”인 트랜잭션인데 commit하려고 하네?”를 감지한다.
  • 그래서 “Transaction silently rolled back because it has been marked as rollback-only” 이 에러를 터뜨린다.

    트랜잭션 롤백 이유

기존 나의 코드 구조는 이랬다.

1
2
3
4
5
6
@Transactional
public TokenDto processOAuthCallback(AuthProvider provider, String code) {
    ...
    Member member = saveOrUpdateMember(memberDetails);
    ...
}
  • @Transactional이 processOAuthCallback에 걸려있고 이 안에서 saveOrUpdateMember(memberDetails) 호출된다.

saveOrUpdateMember() 내부에서는 2025-04-29-005244.png

  • memberService.exists() 호출 -> exists는 사용자가 없으면 MemberNotFoundException(RuntimeException)을 던진다.
  • 이걸 try-catch로 잡고 save 흐름으로 넘어가서 새로운 사용자를 생성한다. 표면상으로는 코드 흐름은 catch로 잘 처리했지만 문제가 생겼다.

  • RunTimeException을 던지면 트랜잭션은 rollback-only로 설정된다.
  • 그런데 saveOrUpdateMember()는 이 예외를 catch하고 정상 흐름처럼 save()를 시도한다.
  • 하지만 이미 트랜잭션은 이미 “commit 불가” 상태이기 때문에 save가 끝나더라도 결국 rollback이 발생한다.
  • 최종적으로 processOAuthCallback()리턴 시점에서 rollback되고 “Transaction silently rolled back” 예외 발생 하게 된것이다.

    해결 방법

    saveOrUpdateMember() 수정

  • exists()메서드 대신에 Optional을 사용해서 존재 여부를 확인하고
  • 예외를 터뜨리지 않고 자연스럽게 분기 처리하기로 했다.

2025-04-29-023751.png 2025-04-29-023711.png

Optional로 바꾼 이유

  • 트랜잭션 내부에서는 정상 흐름이어야 한다.
    • 정상 흐름 : 존재하면 업데이트, 없으면 생성
    • 비정상 흐름 : 예외 발생 -> Optional을 사용하면 예외 발생 없이 존재 여부를 자연스럽게 if-else로 분기할 수 있고, 트랜잭션은 정상 흐름만 따라간다. 그래서 rollback-only 마킹 자체가 일어나지 않는다 -> 정상적으로 트랜잭션이 commit 될 수 있다.

@Transactional 안에서는 절대 예외를 던져서 정상 흐름을 분기시키면 안된다. 없는 경우는 Optional을 사용해서 자연스럽게 분기하는 구조를 만들어야 rollback-only 문제가 발생하지 않는다.

마무리

이번 트러블 슈팅을 통해 @Transactional안에서는 RuntimeException 발생을 최대한 조심해야 한다는 것과 Optional을 활용해서 예외를 제어할 수 있다는 것을 배웠다. 트랜잭션 안에서는 정상 흐름을 유지하도록 하고 예외 발생 시 rollback을 염두에 둬야 한다는 것