[업무일지] noRollbackFor 로 막았다가 2주 뒤에 되돌린 롤백 에러
아무것도 쓰지 않는 조회 API 에서 롤백 에러가 났습니다.
noRollbackFor를 붙여서 막았는데, 2주 뒤에 그걸 되돌리고 다르게 고쳤습니다. 원인은 트랜잭션 설정이 아니라 예외를 불리언 대신 쓴 코드였습니다.
마이페이지 계약 조회 API 입니다. @Transactional(readOnly = true) 가 붙어 있고 쿼리만 몇 개 날립니다. 쓰기가 없어요. 그런데 여기서 롤백 에러가 났습니다.
Java 21, Spring Boot 3.2.x 환경입니다.
조회만 하는데 롤백 에러가 났다
처음엔 어디서 저장을 하는 줄 알고 호출 그래프를 따라갔습니다. 없었습니다. 그러다 예외 메시지를 다시 봤는데 UnexpectedRollbackException 이었고, 설명이 트랜잭션이 이미 rollback-only 로 표시됐다는 얘기였습니다.
표시를 누가 했느냐가 문제였습니다. 코드는 이런 모양이었습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Java 21, Spring Boot 3.2.x
@Transactional(readOnly = true)
public ContractResponse searchContract(Long userId) {
User user = userService.findByIdOrThrow(userId);
// 이용정지·이용보류 회원이면 계약 조회가 불필요하다
if (isSuspendedUser(user)) {
return ContractResponse.suspended();
}
return buildContractResponse(user);
}
private boolean isSuspendedUser(User user) {
try {
userValidator.validateSuspendedUser(user);
} catch (BusinessException e) {
return true;
} catch (Exception e) {
throw new InternalServerException(e.getMessage(), e);
}
return false;
}
그리고 검증기 쪽입니다.
1
2
3
4
5
6
7
8
9
@Transactional(readOnly = true)
public void validateSuspendedUser(User user) {
if (AuthType.BLOCKED == user.getIsAuth()) {
throw new BusinessException(USER_SUSPENDED);
}
if (blockListRepository.existsByUserIdAndStatus(user.getId(), "Y")) {
throw new BusinessException(USER_SUSPENDED);
}
}
예외를 불리언 대신 쓰고 있었다
정지 회원이면 예외를 던지고, 부르는 쪽이 그걸 잡아서 true 로 바꿉니다. 사실상 불리언인데 통로가 예외인 거죠.
문제는 validateSuspendedUser 도 @Transactional 이고 다른 빈이라 프록시를 타고 들어간다는 겁니다. 전파 속성이 기본값이라 새 트랜잭션을 만들지 않고 바깥 트랜잭션에 참여합니다. 그리고 런타임 예외가 그 경계를 벗어나는 순간 스프링이 공유 트랜잭션에 rollback-only 를 찍습니다.
isSuspendedUser 는 private 이라 프록시를 안 탑니다. 그래서 예외를 잡아서 정상 리턴하고, 바깥은 아무 일 없었다는 듯이 커밋을 시도합니다. 그때 스프링이 “롤백하라고 표시된 트랜잭션을 커밋할 수 없다”고 막는 겁니다.
정리하자니 좀 허탈했습니다. 예외를 잡았으니 없던 일이 될 거라고 생각했는데, 경계를 지나가는 것 자체가 흔적을 남기고 있었습니다.
일단 noRollbackFor 로 막았다
급하게 나가야 하는 상황이라 트랜잭션 설정으로 막았습니다.
1
2
@Transactional(readOnly = true, noRollbackFor = BusinessException.class)
public ContractResponse searchContract(Long userId) {
에러는 안 났습니다. 커밋도 이렇게 남겼고요 — fix: 사용자 조회 트랜잭션 에러 수정.
2주 뒤에 되돌렸다
계속 걸렸습니다. 이 애노테이션을 보고 나중에 누가 “왜 조회 API 에 noRollbackFor 가 붙어 있지” 하고 물으면 답이 “예외를 불리언처럼 쓰고 있어서요” 입니다. 그게 답이 되면 안 되잖아요.
그래서 검증기가 예외를 안 던지게 바꿨습니다.
1
2
3
4
5
6
7
@Transactional(readOnly = true)
public boolean checkSuspendedUser(User user) {
if (AuthType.BLOCKED == user.getIsAuth()) {
return true;
}
return blockListRepository.existsByUserIdAndStatus(user.getId(), "Y");
}
부르는 쪽에서 try-catch 와 isSuspendedUser 를 통째로 지웠고, noRollbackFor 도 뗐습니다. 테스트도 doThrow 에서 when...thenReturn 으로 바뀌었습니다. 테스트가 더 읽기 쉬워진 게 이 변경이 맞다는 신호였습니다.
이름도 validate 에서 check 로 바꿨습니다. validate 는 “아니면 막는다”는 뜻이고 check 는 “물어본다”는 뜻이라, 하는 일이 달라졌으니 이름도 달라져야 했습니다.
남은 생각
트랜잭션 전파와 rollback-only 는 찾아보면 다 나오는 얘기입니다. 제가 실제로 얻은 건 다른 쪽이었습니다.
noRollbackFor 나 propagation 을 만지고 싶어질 때는 대개 코드 구조가 이상한 겁니다. 저 옵션들은 진짜 필요한 자리가 있지만, 제 경우엔 “예외를 제어 흐름으로 쓰고 있다”는 신호를 트랜잭션 설정으로 덮은 것에 가까웠습니다. 증상이 사라져서 고친 줄 알았고요.
그리고 이건 판단하기 애매한 종류입니다. 6월 2일에 붙인 게 잘못이었냐 하면 그날은 그게 맞았다고 생각합니다. 나가야 했으니까요. 다만 그때 남긴 게 커밋 메시지 한 줄뿐이라, 2주 동안 아무도 안 봤으면 그대로 남았을 겁니다. 급하게 덮을 때는 되돌릴 지점을 같이 남겨야 한다는 걸 이때 배웠습니다.
한계도 있습니다. 같은 패턴이 다른 서비스에도 있는지는 이때 전부 훑지 않았습니다. catch (BusinessException 으로 그레핑해보면 몇 군데 더 나오는데, 지금 문제가 된 경로만 고치고 넘어갔습니다.