[업무일지] 자동완성이 골라준 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 을 통째로 받는 핸들러는 여전히 있습니다. 내부 오류를 분리했을 뿐이고, 그 아래에서 뭐가 터지는지는 로그를 봐야 압니다. 예외 계층을 제대로 나누는 건 그때 하지 않았고, 지금도 그 상태입니다.