API 예외 처리를 위해 ResponseStatusExceptionResolver를 공부하다 보면 의문이 생긴다. @ResponseStatus라는 훨씬 간결한 애노테이션이 있는데, 왜 굳이 throw new ResponseStatusException(...)이라는 긴 코드를 직접 써야 할까?
글로만 봐서는 잘 와닿지 않았던 '외부 라이브러리 제어'와 '동적 상태 코드 변경' 상황을 코드로 정리해 보았다.
가장 대표적인 상황은 외부 라이브러리를 사용할 때다. 라이브러리 내부에서 터지는 예외 클래스는 우리가 직접 @ResponseStatus 애노테이션을 붙일 수 없다. 소스 코드를 수정할 권한이 없기 때문이다.
이럴 때 catch문 안에서 ResponseStatusException으로 감싸주면, 라이브러리 예외에도 원하는 상태 코드를 입혀줄 수 있다.
package com.external.auth;
public class InvalidTokenException extends RuntimeException { ...}
public void validate(String token) {
try {
} catch (InvalidTokenException e) {
// 라이브러리 예외를 잡아서 401(Unauthorized) 상태 코드를 부여한다.
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "토큰 정보가 유효하지 않습니다.", e);
}
}
@ResponseStatus는 예외 클래스 하나당 상태 코드가 1:1로 고정된다. 하지만 비즈니스 로직을 작성하다 보면 동일한 흐름 안에서 조건에 따라 404를 줄지, 403을 줄지 결정해야 하는 순간이 온다.
@GetMapping("/api/members/{id}")
public MemberResponse getMember(@PathVariable Long id) {
Member member = repository.findById(id);
// 상황 1: 찾는 회원이 없을 때는 404 (Not Found)
if (member == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "존재하지 않는 회원입니다.");
}
// 상황 2: 회원은 있지만 정지된 상태라면 403 (Forbidden)
if (member.isRestricted()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "접근 권한이 없는 사용자입니다.");
}
return new MemberResponse(member);
}
@ResponseStatus: 내가 만든 예외 클래스에 고정된 상태 코드를 부여할 때 (정적)ResponseStatusException: 내가 못 고치는 예외를 처리하거나, 로직 안에서 상황별로 상태 코드를 바꿔야 할 때 (동적)결국 ResponseStatusException을 쓰는 이유는 '제어권'과 '유연함' 때문이다. 애노테이션 방식이 주는 깔끔함도 좋지만, 내가 통제할 수 없는 예외를 다루거나 복잡한 비즈니스 조건에 대응해야 할 때는 이 방식이 훨씬 강력한 도구가 된다.
단순히 이론으로만 보던 내용들이 실제 코드에서 어떻게 동작하는지 정리하고 나니, 스프링이 왜 이 두 가지 방식을 모두 열어두었는지 명확해졌다.