전역 예외 핸들러

앞에서 유효성 검사를 추가한 뒤 실제로 요청을 보내보면,
잘못된 값이 들어왔을 때 예외가 발생하는 것을 확인할 수 있다.
이 자체는 나쁜 일이 아니다. 오히려 잘못된 입력을 제대로 막아냈다는 뜻이다.
문제는 그 다음이다.
예외가 발생했을 때 클라이언트 입장에서는 왜 요청이 실패했는지, 무엇을 고쳐야 하는지 알기 어려운 경우가 많다.
단순히 500 에러만 받는다면, 사용자는 “서버가 고장 났나?” 정도로만 느낄 수 있고 실제 문제 원인은 알 수 없다.
우리가 흔히 아는 예외 처리 방식은 try-catch다. 물론 이 방식도 자바에서는 유효한 예외 처리 수단이다.
하지만 웹 애플리케이션에서는 모든 컨트롤러나 서비스 안에
일일이 try-catch를 넣는 방식이 오히려 코드를 더 지저분하게 만들 수 있다.
특히 지금처럼 유효성 검사 실패처럼 “클라이언트가 잘못된 요청을 보낸 경우”는,
서비스 내부에서 억지로 복구하는 것보다 적절한 HTTP 상태 코드와 에러 메시지로 응답하는 것이 더 중요하다.
이럴 때 필요한 것이 전역 예외 핸들러다.
전역 예외 핸들러는 컨트롤러까지 올라온 예외를 한 곳에서 잡아서,
클라이언트가 이해할 수 있는 형태의 응답으로 바꿔주는 역할을 한다.
즉, 예외를 없애는 것이 아니라 “예외를 응답으로 번역하는 과정”이라고 보면 된다.
@RestControllerAdvice로 유효성 검사 예외 처리
스프링에서는 전역적으로 예외를 처리하기 위해 @RestControllerAdvice를 사용할 수 있다.
이 어노테이션이 붙은 클래스는 여러 컨트롤러에서 발생한 예외를 공통으로 처리하는 역할을 한다.
예를 들어 도메인 객체의 Bean Validation 검사에서 발생하는 ConstraintViolationException을 처리하고 싶다면,
다음과 같이 핸들러를 만들 수 있다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<String> handleConstraintViolationException(
ConstraintViolationException ex
){
String errorMessage = "에러 메시지";
return new ResponseEntity<>(errorMessage, HttpStatus.BAD_REQUEST);
}
}
이 코드는 ConstraintViolationException이 발생했을 때,
단순한 문자열과 함께 400 Bad Request를 응답으로 돌려준다.
여기서 핵심은 “예외가 났다”는 사실을 500 에러로 방치하지 않고,
클라이언트 요청이 잘못되었다는 의미의 400으로 바꿔주는 것이다.
하지만 단순히 "에러 메시지" 같은 문자열 하나만 내려주는 것은 충분히 친절하지 않다.
실제로는 어떤 필드가 어떤 규칙을 위반했는지까지 보여주는 것이 훨씬 좋다.
이를 위해 예외 객체가 가지고 있는 정보를 꺼내서 가공할 수 있다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorMessage> handleConstraintViolationException(
ConstraintViolationException ex
){
Set<ConstraintViolation<?>> constraintViolations = ex.getConstraintViolations();
List<String> errors = constraintViolations.stream()
.map(
constraintViolation ->
constraintViolation.getPropertyPath() + ", " + constraintViolation.getMessage()
)
.toList();
ErrorMessage errorMessage = new ErrorMessage(errors);
return new ResponseEntity<>(errorMessage, HttpStatus.BAD_REQUEST);
}
}
이렇게 하면 유효성 검사에 실패한 필드 이름과 에러 메시지를 모아서 응답 바디에 담아줄 수 있다.
예를 들어 price, 0 이상이어야 합니다 같은 메시지 형태로 내려줄 수 있는 것이다.
여기서 ErrorMessage라는 클래스를 따로 만드는 이유는, 가능한 한 일관된 응답 구조를 유지하기 위해서다.
에러가 날 때마다 문자열 하나를 던지는 방식보다, errors라는 필드를 가진 객체 형태로 응답하면
클라이언트도 파싱하기 쉽고, 나중에 확장하기도 편하다.
public class ErrorMessage {
private List<String> errors;
public ErrorMessage(List<String> errors){
this.errors = errors;
}
public List<String> getErrors(){
return errors;
}
}
에러 메시지를 리스트로 가지는 이유는, 유효성 검사 실패가 하나만 발생하는 것이 아니라
여러 필드에서 동시에 발생할 수도 있기 때문이다.
예를 들어 이름도 비어 있고 가격도 음수라면, 하나의 요청에서 여러 오류를 함께 알려주는 것이 더 자연스럽다.
컨트롤러 바인딩 예외
컨트롤러에서 @Valid @RequestBody ProductDto를 사용하다가 발생하는 예외는
보통 MethodArgumentNotValidException이다.
이 예외는 요청 바디를 DTO로 바인딩하고 검증하는 과정에서 발생하며,
내부적으로 가지고 있는 정보 구조가 ConstraintViolationException과는 다르다.
그래서 처리 코드도 달라진다.
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorMessage> handleMethodArgumentNotValidException(
MethodArgumentNotValidException ex
){
List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors();
List<String> errors = fieldErrors.stream()
.map(
fieldError ->
fieldError.getField() + ", " + fieldError.getDefaultMessage()
)
.toList();
ErrorMessage errorMessage = new ErrorMessage(errors);
return new ResponseEntity<>(errorMessage, HttpStatus.BAD_REQUEST);
}
여기서는 getBindingResult().getFieldErrors()를 통해 필드 단위 오류 목록을 꺼내고,
각 필드 이름과 기본 메시지를 조합해 에러 응답을 만든다.
예를 들어 name, null일 수 없습니다 같은 메시지를 내려줄 수 있다.
이 부분에서 중요한 것은, 예외 처리 코드는 예외마다 조금씩 다를 수밖에 없다는 점이다.
예외 타입이 다르면 그 예외 안에 어떤 정보가 들어 있는지도 다르고, 따라서 꺼내는 방법도 달라진다.
그래서 실무에서는 어떤 예외를 만났을 때, “이 예외가 어떤 정보를 가지고 있지?”를 먼저 확인하고,
그 정보를 어떻게 응답 바디로 가공할지 고민해야 한다.
grep.app 같은 곳에서 예외 클래스 이름으로 검색해보면, 다른 사람들이 어떻게 핸들링하는지 참고할 수 있다.
예외 처리 전략
예외 처리 전략을 이야기할 때 기본이 되는 개념이 Checked Exception과 Unchecked Exception이다.
자바에서는 예외를 크게 이 두 가지로 나누어 생각할 수 있다.
먼저 Checked Exception은 컴파일러가 처리를 강제하는 예외다.
즉, 이런 예외를 던질 수 있는 메서드를 호출하면 try-catch로 직접 처리하거나, t
hrows를 사용해 바깥으로 넘겨야 한다. 그렇지 않으면 컴파일 자체가 되지 않는다.
대표적으로 Exception 클래스를 직접 상속받는 예외들이 여기에 해당한다.
반대로 Unchecked Exception은 예외 처리가 강제되지 않는다.
보통 RuntimeException을 상속받는 예외들이 여기에 해당한다.
이런 예외는 발생할 수 있지만, 컴파일러가 try-catch나 throws를 강제하지는 않는다.
그래서 코드 흐름이 훨씬 덜 번잡해진다.
물론 둘 다 장단점이 있다. Checked Exception은 “이 메서드는 이런 예외를 낼 수 있다”는 사실을
컴파일 단계에서 더 명시적으로 드러낼 수 있다.
하지만 실무에서는 오히려 이 장점이 불편으로 이어지는 경우가 많다.
실제로 발생한 예외를 중간 로직에서 의미 있게 처리할 수 없는 경우가 많기 때문이다.
특히 유효성 검사 실패나 요청 오류처럼, 결국 클라이언트가 잘못된 입력을 수정해야만 해결되는 문제라면
서비스나 레포지토리 중간에서 억지로 try-catch를 넣는 것이 오히려 코드만 복잡하게 만들 수 있다.
그래서 많은 경우에는 Unchecked Exception을 사용하고,
정말 필요한 지점에서만 전역적으로 핸들링하는 방식이 더 자연스럽다.
지금 하고 있는 전역 예외 처리도 바로 그런 관점과 잘 맞는다.
예외는 위로 올라오게 두고, 컨트롤러 바깥에서 한 번에 잡아서 적절한 HTTP 응답으로 바꿔주는 것이다.
'공부 기록 > 이것이 백엔드 개발이다 With Java' 카테고리의 다른 글
| EP21. 상품 관리 애플리케이션에 데이터베이스 연동하기 2편 (0) | 2026.04.23 |
|---|---|
| EP20. 상품 관리 애플리케이션에 데이터베이스 연동하기 1편 (0) | 2026.04.22 |
| EP18. 상품 관리 애플리케이션에 유효성 검사 추가하기 1편 (0) | 2026.04.20 |
| EP17. List를 이용한 상품 관리 애플리케이션 만들기 5편 (0) | 2026.04.10 |
| EP16. List를 이용한 상품 관리 애플리케이션 만들기 4편 (0) | 2026.04.10 |