포스트

[업무일지] 자동완성이 골라준 JAX-RS 예외, 6일 만의 교체

스프링 부트 앱인데 내부 오류를 JAX-RS 예외로 던지고 있었습니다. 이름이 InternalServerErrorException 이라 딱 맞아 보였고 자동완성에 떠서 그냥 골랐거든요. 6일 뒤에 같은 이름의 클래스를 직접 만들어서 전부 갈아치웠습니다.

입사하고 세 달쯤 됐을 때입니다. 새로 세우는 API 서버라 규격을 잡는 일이 많았고, 그중 하나가 예외 처리였습니다. Java 21, Spring Boot 3.2.x 환경입니다.

500 을 따로 잡아야 했다

그때까지는 핸들러가 Exception 하나로 다 받고 있었습니다. 비즈니스 예외는 따로 있었는데, 그 외에 터지는 건 전부 한 덩어리였고요.

문제는 로그였습니다. 외부 연동 실패, 토큰 파싱 실패, S3 업로드 실패가 전부 같은 자리에서 같은 모양으로 찍히니까 원인 구분이 안 되더라고요. 그래서 “이건 서버 내부 오류”라고 명시적으로 표시하는 예외를 하나 두고, 그것만 따로 잡아서 로깅하려고 했습니다.

import 를 자동완성으로 골랐다

InternalServerErrorException 을 치니까 자동완성에 떴습니다. 이름이 정확히 원하는 거였고요. 그대로 골라서 여기저기 뿌렸습니다.

1
2
3
4
import jakarta.ws.rs.InternalServerErrorException;

// 토큰 유틸, 암호화 유틸, 레디스 저장소, 외부 연동 서비스 …
throw new InternalServerErrorException(e.getMessage(), e);

핸들러에도 등록했습니다.

1
@ExceptionHandler(value = {Exception.class, InternalServerErrorException.class})

돌아갔습니다. 그게 문제였습니다.

jakarta.ws.rs 가 뭔지 나중에 봤다

jakarta.ws.rs 는 JAX-RS 입니다. 스프링 MVC 와는 다른 웹 스택의 표준 API 거든요. 저는 스프링 부트로 컨트롤러를 짜고 있는데 예외만 다른 스택 것을 쓰고 있었던 겁니다.

그리고 이 클래스는 그냥 RuntimeException 이 아닙니다. WebApplicationException 을 상속하고 응답 객체를 들고 있습니다. 생성하는 순간 JAX-RS 의 Response 가 하나 만들어집니다. 저는 그걸 안 쓰고 스프링 ResponseEntity 로 따로 응답을 만들고 있었으니 그냥 낭비였죠.

더 걸린 건 의존성이었습니다. build.gradle 을 열어보니 jakarta.ws.rs 를 선언한 데가 없었습니다. 다른 라이브러리가 끌고 온 전이 의존성이었던 거죠. 선언하지 않은 패키지를 코드에서 쓰고 있었다는 뜻입니다. 그 라이브러리를 걷어내거나 버전을 올리면 컴파일이 깨질 수 있는 상태였습니다.

직접 정의했다

6일 뒤에 같은 이름으로 만들었습니다. 17줄입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class InternalServerException extends RuntimeException {

    public InternalServerException(String message) {
        super(message);
    }

    public InternalServerException(Throwable cause) {
        super(ErrorCode.INTERNAL_SERVER_ERROR.getMessage(), cause);
    }

    public InternalServerException(String message, Throwable cause) {
        super(message, cause);
    }
}

생성자 세 개를 둔 건 호출부 사정이 달랐기 때문입니다. 메시지만 있는 곳, 원인 예외만 있는 곳, 둘 다 있는 곳이 다 있었습니다. 원인만 넘기는 데서 기본 메시지를 에러 코드에서 꺼내오게 한 게 그나마 설계라고 할 수 있는 부분입니다.

핸들러도 Exception 과 묶어놓지 않고 분리했습니다.

1
2
3
4
5
@ExceptionHandler(value = InternalServerException.class)
public ResponseEntity<CommonResponse<Void>> internalServerException(Exception ex) {
    log.error("internalServerException caught: {}", ex.getMessage(), ex);
    ...
}

남은 생각

돌아가는 코드였습니다. 테스트도 통과했고 응답도 정상이었어요. 그래서 이건 버그를 고친 게 아니라 잘못 고른 걸 되돌린 것입니다.

제가 얻은 건 두 가지입니다.

예외는 도메인 계약입니다. 이 앱에서 “내부 서버 오류”가 무엇인지는 이 앱이 정하는 것이고, 그걸 다른 스택의 클래스에 얹으면 그 클래스가 가진 의미가 같이 따라 들어옵니다. 이름이 맞는다고 내 계약이 되는 건 아니었던 겁니다.

그리고 build.gradle 에 없는 패키지를 import 했으면 그 자리에서 멈춰야 합니다. 자동완성은 클래스패스에 있는 걸 다 보여주는데, 클래스패스에 있는 것과 우리가 쓰기로 한 것은 다릅니다. 전이 의존성으로 들어온 API 를 직접 쓰는 건 남의 집 물건을 쓰는 거고, 그 집이 이사 가면 없어집니다.

한계도 남았습니다. Exception 을 통째로 받는 핸들러는 여전히 있습니다. 내부 오류를 분리했을 뿐이고, 그 아래에서 뭐가 터지는지는 로그를 봐야 압니다. 예외 계층을 제대로 나누는 건 그때 하지 않았고, 지금도 그 상태입니다.

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