포스트

[업무일지] 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-catchisSuspendedUser 를 통째로 지웠고, noRollbackFor 도 뗐습니다. 테스트도 doThrow 에서 when...thenReturn 으로 바뀌었습니다. 테스트가 더 읽기 쉬워진 게 이 변경이 맞다는 신호였습니다.

이름도 validate 에서 check 로 바꿨습니다. validate 는 “아니면 막는다”는 뜻이고 check 는 “물어본다”는 뜻이라, 하는 일이 달라졌으니 이름도 달라져야 했습니다.

남은 생각

트랜잭션 전파와 rollback-only 는 찾아보면 다 나오는 얘기입니다. 제가 실제로 얻은 건 다른 쪽이었습니다.

noRollbackForpropagation 을 만지고 싶어질 때는 대개 코드 구조가 이상한 겁니다. 저 옵션들은 진짜 필요한 자리가 있지만, 제 경우엔 “예외를 제어 흐름으로 쓰고 있다”는 신호를 트랜잭션 설정으로 덮은 것에 가까웠습니다. 증상이 사라져서 고친 줄 알았고요.

그리고 이건 판단하기 애매한 종류입니다. 6월 2일에 붙인 게 잘못이었냐 하면 그날은 그게 맞았다고 생각합니다. 나가야 했으니까요. 다만 그때 남긴 게 커밋 메시지 한 줄뿐이라, 2주 동안 아무도 안 봤으면 그대로 남았을 겁니다. 급하게 덮을 때는 되돌릴 지점을 같이 남겨야 한다는 걸 이때 배웠습니다.

한계도 있습니다. 같은 패턴이 다른 서비스에도 있는지는 이때 전부 훑지 않았습니다. catch (BusinessException 으로 그레핑해보면 몇 군데 더 나오는데, 지금 문제가 된 경로만 고치고 넘어갔습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.