OAuth 로그인 트러블 슈팅: 트랜잭션 롤백과 saveOrUpdate 리팩터링
OAuth 로그인 흐름을 구현하면서 예상치 못한 트랜잭션 문제와 save/update 로직의 불명확함이 있어 해결한 과정을 담았다.
문제 상황
1. “Transaction silently rolled back” 에러 발생
OAuth 로그인 후, 서버에서 JWT 토큰을 발급하려는 시점에 다음과 같은 에러를 마주했다.
1 |
|
-> 흠.. 로그를 분석해본결과 OAuth 플로우 자체는 성공했는데, 최종 사용자 저장 과정에서 트랜잭션이 롤백되면서 서버 응답이 실패했다.
2. saveOrUpdate 로직의 문제점 발견
문제를 쫒아가다보니, 사용자 저장 로직에서 save를 호출할 때 이중으로 DB 조회가 일어나는 구조가 있었고, 1번 문제가 생기는 지점이기도 했다.
원인 분석
메커니즘 깊이 분석
이유를 알기 위해선 Spring의 @Transactional 트랜잭션 처리 과정을 알아야한다.
=> 핵심은 “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 |
|
- @Transactional이 processOAuthCallback에 걸려있고 이 안에서 saveOrUpdateMember(memberDetails) 호출된다.
- 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을 사용해서 존재 여부를 확인하고
- 예외를 터뜨리지 않고 자연스럽게 분기 처리하기로 했다.
Optional로 바꾼 이유
- 트랜잭션 내부에서는 정상 흐름이어야 한다.
- 정상 흐름 : 존재하면 업데이트, 없으면 생성
- 비정상 흐름 : 예외 발생 -> Optional을 사용하면 예외 발생 없이 존재 여부를 자연스럽게 if-else로 분기할 수 있고, 트랜잭션은 정상 흐름만 따라간다. 그래서 rollback-only 마킹 자체가 일어나지 않는다 -> 정상적으로 트랜잭션이 commit 될 수 있다.
@Transactional 안에서는 절대 예외를 던져서 정상 흐름을 분기시키면 안된다. 없는 경우는 Optional을 사용해서 자연스럽게 분기하는 구조를 만들어야 rollback-only 문제가 발생하지 않는다.
마무리
이번 트러블 슈팅을 통해 @Transactional안에서는 RuntimeException 발생을 최대한 조심해야 한다는 것과 Optional을 활용해서 예외를 제어할 수 있다는 것을 배웠다. 트랜잭션 안에서는 정상 흐름을 유지하도록 하고 예외 발생 시 rollback을 염두에 둬야 한다는 것