IllegalArgumentException은 400 Bad Request인가?
출처: 우아한형제들 기술블로그 — IllegalArgumentException은 400 Bad Request인가?
범용 예외를 400 Bad Request에 매핑하는 흔한 관행이 왜 위험한지 짚고, 상태 코드 매핑의 기준을 다시 세우는 글이다. 원문을 읽고 정리한 내용과 내 생각을 함께 적는다.
질문
스프링에서 예외와 상태 코드를 이어붙이는 가장 흔한 방법은 이렇게 생겼다.
@RestControllerAdvice
public class GlobalDefaultExceptionHandler {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResponse onException(IllegalArgumentException exception) {
...
}
}
IllegalArgumentException은 이름부터 “잘못된 인자”라서 400에 붙여두기가 자연스럽다. 비즈니스 로직에서 요청이 조건에 맞지 않으면 이 예외를 던지고 위 핸들러가 400으로 받아내는 구조를 실무에서 자주 본다.
원문의 질문은 여기서 시작한다. 이 예외는 항상 클라이언트의 잘못으로만 발생하는가. 그렇지 않다. 개발자의 실수나 내부 로직의 결함으로도 똑같이 발생한다.
4xx와 5xx를 가르는 기준
두 계열을 나누는 것은 오류의 심각도가 아니라 책임의 소재다. 4xx는 클라이언트가 잘못된 요청을 보냈다는 뜻이고, 5xx는 서버가 요청을 처리하지 못했다는 뜻이다.
| 4xx | 5xx | |
|---|---|---|
| 책임 | 클라이언트 | 서버 |
| 재시도 | 요청을 고치기 전에는 무의미 | 일시적 장애일 수 있어 backoff가 유효 |
| 대표 코드 | 400, 401, 404 | 500, 502, 503 |
재시도부터 갈린다. 4xx는 같은 요청을 몇 번 더 보내도 결과가 같으니 backoff에 의미가 없고, 요청을 고쳐야 통과한다. 5xx는 DB 연결 실패처럼 잠시 뒤에 풀릴 수 있는 문제라 간격을 두고 다시 시도할 만하다.
운영에서는 차이가 더 크다. 5xx는 모니터링과 알림이 즉시 잡아야 하는 신호다. 매핑을 틀리면 양쪽으로 대가를 치른다. 서버 오류를 4xx로 내려보내면 운영팀은 클라이언트 문제로 오인해 원인 파악이 늦어지고, 반대로 클라이언트 오류를 5xx로 올리면 필요 없는 경보가 쌓이고, 거기에 무감각해진 팀이 진짜 장애를 놓친다.
왜 400 매핑이 위험한가
IllegalArgumentException은 자바 표준 라이브러리는 물론 스프링과 하이버네이트 같은 프레임워크, 온갖 라이브러리 안에서 던져진다. 그렇다면 스프링이 이 예외를 기본 정책으로 400에 매핑해두지 않은 이유는 뭘까. 이 예외만 봐서는 클라이언트의 입력 탓인지 서버 내부 로직 탓인지 구분할 수 없어서다. 스프링 부트 이슈 트래커에도 IllegalArgumentException mapped to Status Code 500 instead of 400이라는 같은 질문이 올라와 있다.
원문이 든 예시가 명쾌하다.
// 서버 내부 로직 오류 예시: 클라이언트와 무관하게 IllegalArgumentException 발생
public void execute(Runnable task, PriorityResolver priorityResolver) {
Thread thread = new Thread(task);
int calculatedPriority = priorityResolver.get();
thread.setPriority(calculatedPriority);
thread.start();
}
Thread.setPriority(..)는 1부터 10까지만 허용하고 범위를 벗어나면 IllegalArgumentException을 던진다. PriorityResolver를 만들거나 고친 개발자가 이 제약을 몰랐다면, 요청에 아무 문제가 없어도 이 예외가 난다.
이때 IllegalArgumentException이 400에 매핑되어 있으면 서버 버그가 “클라이언트가 요청을 잘못 보냈다”는 응답으로 포장되어 나간다. 매핑이 없었다면 500이 떨어졌을 테고, 그랬다면 곧바로 서버 내부 문제로 인식되어 조치도 빨랐을 것이다.
그러면 어떻게 매핑하나
원문은 세 가지를 기준으로 제시한다.
- 예상 못 한 상황은 5xx, 명백한 클라이언트 잘못은 4xx. 스프링은 별도 매핑이 없는 예외를 500으로 응답한다. 조치하다가 원인이 클라이언트 잘못으로 판명되면 그때 4xx로 내린다. 순서가 반대여서는 안 된다.
- 비즈니스 예외와 시스템 예외를 나눈다. “최소 주문 금액 미달”처럼 입력이나 조건이 맞지 않는 상황은 400이다. DB 연결 실패, 연동 실패,
NullPointerException, 라이브러리 내부 오류는 5xx다. 예측 가능한 시스템 예외라면 대체 응답으로 5xx를 피하되 모니터링에는 잡히도록 설계하는 편이 낫다. - 범용 예외 대신 커스텀 예외를 쓴다.
IllegalArgumentException이나IllegalStateException을 매핑하지 말고, 클라이언트 잘못이 명확할 때만 던지는 예외를 정의해 거기에 400을 건다.
커스텀 예외
비즈니스 예외의 뼈대는 이렇게 잡는다.
@Getter
public class BusinessException extends RuntimeException {
// 클라이언트 개발자가 예외 상황을 식별할 수 있는 값
private final String code;
// 클라이언트 개발자가 예외 상황을 이해하는데 도움이 되는 정보
private final Map<String, Object> arguments;
public BusinessException(String code) {
this(code, null);
}
public BusinessException(String code, String message) {
super(message);
this.code = code;
this.arguments = new HashMap<>();
}
protected void addArgument(String key, Object value) {
arguments.put(key, value);
}
}
더 구체적인 상황은 상속으로 표현한다.
public class InvalidExpressionException extends BusinessException {
public InvalidExpressionException(int position, String hint) {
super("expression.Invalid", "표현식이 올바르지 않습니다.");
addArgument("position", position);
addArgument("hint", hint);
}
}
그리고 400을 거는 대상은 BusinessException 하나로 좁힌다.
@RestControllerAdvice
public class GlobalDefaultExceptionHandler {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(BusinessException.class)
public ErrorResponse onException(BusinessException exception) {
...
}
}
code와 arguments를 들고 다니는 것도 같은 맥락이다. 무엇을 어떻게 고쳐야 하는지 알려주지 못하는 4xx는 받는 쪽에서 보면 500과 다를 게 없다.
인상 깊었던 점
상태 코드를 클라이언트에게 주는 응답으로만 생각해왔는데, 이 글을 읽고 나니 운영자에게 주는 신호라는 쪽이 더 크게 보인다. 500은 사람을 부르는 소리고 400은 부르지 않는 소리다. IllegalArgumentException을 400에 넣어두는 건 예외 처리라기보다, 서버 버그가 났을 때 알림이 울리지 않도록 미리 꺼두는 설정에 가깝다.
매핑의 진짜 기준은 예외 이름이 아니라 누가 이 예외를 없앨 수 있는가다. 클라이언트가 요청을 고쳐야 사라지면 4xx고, 우리가 코드를 고쳐야 사라지면 5xx다. IllegalArgumentException이라는 이름은 이 질문에 아무 답도 주지 않는다.
그래서 범용 예외는 매핑 대상으로 부적합하다. 어디서나 던질 수 있는 예외라 누구 잘못인지를 타입 안에 담지 못한다. 커스텀 예외는 반대로 타입 자체가 책임 소재를 선언한다. BusinessException이 올라왔다는 건 우리가 클라이언트 잘못이라고 판단한 지점에서 의도적으로 던졌다는 뜻이고, 그 판단이 틀렸다면 고칠 자리도 명확하다.
500이 부끄러워서 4xx로 낮추고 싶어지는 유혹도 있다. 에러율 지표가 깔끔해지니까. 그건 문제를 고치는 게 아니라 숨기는 짓이다. 500은 고칠 대상이 있다는 정직한 신호이고, 신호를 지우면 고칠 기회도 같이 사라진다.