포스트

[업무일지] noRollbackFor 가 막지 못한 조회 API 롤백 에러

아무것도 쓰지 않는 조회 API 에서 롤백 에러가 났습니다. 바깥 메서드에 noRollbackFor 를 붙였는데 효과가 없었고, 2주 뒤 같은 롤백 에러를 고치면서 구조를 바꿨습니다. 원인은 트랜잭션 설정이 아니라 예외를 불리언 대신 쓴 코드였습니다.

마이페이지 계약 조회 API 입니다. @Transactional(readOnly = true) 가 붙어 있고 쿼리만 몇 개 날립니다. 쓰기가 없어요. 그런데 여기서 롤백 에러가 났습니다.

Java 17, Spring Boot 3.3.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 17, Spring Boot 3.3.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: 사용자 조회 트랜잭션 에러 수정 이었습니다.

그런데 이 설정은 아무것도 막지 못합니다. 앞에서 본 대로 rollback-only 는 안쪽 validateSuspendedUser 의 경계에서, 안쪽 메서드의 롤백 규칙으로 찍힙니다. 안쪽에는 롤백 규칙이 따로 없으니 그대로 찍히죠. 바깥 searchContract 의 noRollbackFor 는 예외가 바깥 경계를 빠져나갈 때 평가되는데, 예외는 private isSuspendedUser 에서 잡혀서 바깥까지 나오지도 않습니다. 커밋 시점에 나는 UnexpectedRollbackException 에도 롤백 규칙은 적용되지 않고요. 막는 게 목적이었다면 예외를 던지는 안쪽 메서드에 붙여야 했습니다.

2주 뒤 검증기가 불리언을 돌려주게 바꿨다

2주 뒤 같은 롤백 에러를 다시 고치면서, 이번엔 설정을 붙일 자리 대신 구조를 봤습니다. 안쪽에 noRollbackFor 를 옮겨 붙여도 “왜 조회 API 에 이게 붙어 있지” 라는 질문의 답은 “예외를 불리언처럼 쓰고 있어서요” 가 됩니다. 그게 답이 되면 안 되잖아요.

그래서 검증기가 예외를 안 던지게 바꿨습니다.

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

설정이 효과가 없다는 걸 알려 줄 장치가 없었습니다. 테스트가 협력 객체를 목으로 바꾼 단위 테스트라 트랜잭션 프록시를 한 번도 거치지 않았거든요. 6월 2일 커밋은 테스트를 통과했고, 2주 뒤 커밋 제목도 다시 “계약 조회 시 롤백 에러 수정” 이었습니다.

같은 패턴이 다른 서비스에도 있는지는 전부 훑지 않았습니다. catch (BusinessException 으로 grep 해보면 몇 군데 더 나오는데, 문제가 된 경로만 고쳤습니다.

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