프로젝트를 많이 해보면서, 예외처리의 구조나 가장 좋은 예외처리? 방식에 대해서 연구해본 적이 없고 그냥 전역 예외처리를 최대한 해보는 것 말고는 없었던 것 같아서 정리해본 글이다.
조금 두서없음주의..
throw하는 순간 Throwable 객체가 생기고, 네이티브 메서드 fillInStackTrace()가 현재 call Stack을 캡쳐한다. 여기서 많은 비용이 발생한다. DispatcherServlet.doDispatch() -> 내부에 try catch 존재
└─ HandlerAdapter.handle()
└─ 리플렉션으로 컨트롤러 메서드 invoke
└─ @Controller의 getMember()
└─ service.findMember()
└─ repository.findById()
└─ throw new RuntimeException("잘못된 사용자")
catch가 없으면 예외는 콜스택을 거슬러 올라가며 **스택 언와인딩된다. 아무도 못 잡으면 그 스레드가 실행 중이던 메서드 밖까지 다 빠져나간다. 서블릿 컨테이너 레벨에서는 WAS까지 예외가 전파되면, 등록된 에러페이지를 상태코드나 예외타입에 따라 찾아서 재요청한다.
이 재요청은 필터 -> 서블릿 -> 인터셉터를 다시 통과한다.
HandlerExceptionResolver라는게 있는데,
WAS까지 넘어가는 예외를 500이 아니라 다른 상태코드로 처리하고 싶다면? 컨트롤러 밖으로 예외가 던져진다면 예외를 해결하고 동작을 새로 정의할 수 있는 방법을 제공해준다.
즉 기존에는 디스패쳐서블릿에서 prehandle로 인터셉터에서 사전 체크를 먼저 한다.
그러고 HandlerAdapter에 컨트롤러 실행을 하도록 위임을 한다.
그런데 예외가 발생하면 컨트롤러 메서드 실행 중에 예외를 throw하게된다.
-> 여기서는 예외 전달이 안된다. 자바 메서드 콜스택 언와인딩으로, posthandle이 호출되지도 않고, afterCompletion(ex)는 finally 개념으로 호출된다.
즉 디스패쳐서블릿이 잡지 못한 것이고, WAS까지 예외가 전달된다는 것이다.
그러면 결국 500예외로 내려진다.
문제점

디스패쳐 서블릿 내부의 doDispatch()에서는 컨트롤러 호출을 할 때, 다음과 같이 try-catch로 감싸고 있다.
Exception dispatchException = null;
try {
mv = ha.handle(request, response, handler);
} catch (Exception ex) {
dispatchException = ex; // 예외를 일단 여기서 잡는다.
}
// WAS로 던지기 전에 마지막 기회를 준다
if (dispatchException != null) {
mv = processHandlerException(request, response, handler, dispatchException);
// 등록된 HandlerExceptionResolver들을 순서대로 돌려서
// resolveException()이 null 아닌 값을 반환하면 그걸 정상 결과로 취급한다.
}
if (mv == null && dispatchException != null) {
throw dispatchException; // 아무도 해결을 못하면 WAS로 던진다.
}
예외 전달과 WAS로 다시 던지는 부분 사이에 끼어든다. 리졸버 중에서 하나라도 ModelAndView를 반환하면, 다시 WAS 로 돌아가서 필->서->인 이렇게 돌아가지 않고, 디스패쳐서블릿이 WAS에 넘기기 전에 자기가 처리 가능한지? 물어보는 훅을 만들어서 여기서 예외가 나더라도 정상 응답으로 변환하는 것이다.
즉 WAS로 예외가 전달되는 과정을 스킵한다.
즉 자바 try catch 처럼 보면
preHandle ≈ try 블록 진입 전 준비
handle ≈ try { 컨트롤러 실행 }
postHandle ≈ try 블록이 예외 없이 끝났을 때만 실행되는 코드 (성공 경로 전용)
afterCompletion ≈ finally { 성공이든 실패든 반드시 정리, ex를 파라미터로 받아 로깅 가능 }
자바에서 try-finally가 발생 여부랑 상관없이 실행되는 것처럼, postHandle은 언제나 실행되는 게 아니고, ExcpetionResolver가 있다면 성공이든 실패든 finally가 실행되게 되어있는것이다.

컨트롤러 밖으로 예외가 나가면 DispatcherServlet은 서블릿 컨테이너까지 올리기 전에 먼저 등록된 HandlerExceptionResolver 체인을 우선순위대로 돌린다.
HandlerExceptionResolverComposite가 이 순서대로 하나씩 물어보고, 누군가 null이 아닌 ModelAndView를 반환하면 그 즉시 멈추는 방식이다.
즉 하나가 처리되면 그 아래는 실행이 아예 안된다.
1번은 보통 body까지 만드는 방식으로 사용하고, 2,3번은 sendError()를 호출하고 WAS에서 랜더링하는걸 떠넘긴다.
실무에서는 커스텀으로 만드는 걸 선호하기 때문에 1번방식을 주로 사용한다.
이렇게 우선순위가 있다는 건 1번이 처리하면 2,3은 실행될 기회가 없다는 것이다. 즉 누가 응답을 완성하는지에 따라 다르다.
3번의 경우 TypeMismatchException, HttpMessageNotReadableException, MethodArgumentNotValidException 과 같은 것들을 처리하지만, 1번에서 처리해서 공통 응답으로 내려주도록 할 수 있는 것이다.
즉 내가 1번에서 처리하도록 코드를 짠다, 하면 2,3은 실행될 일이 없는 것이다.!
resolveException이라는게 호출되면 요청 -> 응답 사이클을 처리할 수가 있다.
@ExceptionAhndler 가 있는지 스캔한다. 해당 컨트롤러 클래스를 훑어서 메서드들을 예외 타입별로 캐싱을 해뒀기 때문에, ex 런타임 타입과 가장 가까운 것을 ExceptionDepthComparator로 선택한다. @RestControllerAdvice어노테이션을 붙여 예외처리를 해둔 커스텀 클래스로 가게 된다. // 도메인 예외를 정의된 상태 코드로 변환
@ExceptionHandler(DomainException.class)
public ResponseEntity<CommonResponse<Void>> handleDomainException(DomainException exception) {
ErrorCode errorCode = exception.getErrorCode();
if (errorCode.getStatus().is5xxServerError()) {
log.error("[DOMAIN_ERROR] code={}, status={}", errorCode.getCode(), errorCode.getStatus(), exception);
} else {
log.warn("[DOMAIN_ERROR] code={}, status={}, message={}",
errorCode.getCode(), errorCode.getStatus(), exception.getMessage());
}
return ResponseEntity.status(errorCode.getStatus()).body(CommonResponse.error(errorCode));
}
여기서 보면, 해당 메서드는 ResonseEntity<공통응답> 을 반환한다.
전체 흐름을 보게되면
WAS -> DispatcherServlet 까지 가면 다시 WAS로 돌아갈 필요 없이 HandlerExceptionResolver 가 예외 처리를 시도하고, 메시지 컨버터가 JSON을 바로 써서 반환한다.
@ExceptionHandler 동작방식, 사용방식일단 사용 방법은, 애노테이션을 선언하고 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다. 해당 컨트롤러에서 예외 발생 시 메서드가 호출되고, 지정한 예외 또는 예외의 자식 클래스를 모두 잡을 수 있다.
우선순위
우선순위는 항상 자세한 것이 더 우위다. 부모 -> 자식까지 처리할 수 있는데, 즉 자식 -> 부모,자식 모두 대상이 되는 것이므로 자식예외처리가 우선권을 가진다. 부모만 호출되면 부모예외처리만 호출된다.
다양한 예외를 한번에 처리하거나, 예외 생략도 가능하다. 생략하면 메서드 파라미터의 예외가 지정된다.
자바의 한계
자바의 메서드 오버로딩은 정적 바인딩이다. 여러 예외타입의 오버로드가 있어도 컴파일 타임에 '변수 선언 타입'으로 어떤게 호출될 지 결정되며, 런타임의 객체 타입으로 결정되지 않는다.
그런데 핸들러는 런타임에 호출해야 하기 때문에, 실제로 던져진 예외의 런타입 타입에 가장 가까운 핸드럴를 찾을 수가 없다.
스프링의 리플렉션으로 재구현
@ExceptionHandler에 선언된 예외 타입들을 인덱싱한다. getClass()(런타임 타입)를 보고, ExceptionDepthComparator로 상속 계층상 가장 가까운 핸들러를 선택한다. 부모,자식 둘다 매칭 시 자식이 우선순위다. Method.invoke)으로 호출되고, 파라미터/리턴값도 일반 컨트롤러와 똑같은 인프라(HandlerMethodArgumentResolver/HandlerMethodReturnValueHandler)를 재사용한다. 파라미터 채우기?
일반 컨트롤러 파라미터는, 각 자리마다 HandlerMethodArgumentResolver 체인에게 파라미터를 채울 수 있는지 순서대로 물어본다.
@PathVariable String id → PathVariableMethodArgumentResolver가 supportsParameter에서 이 파라미터에 @PathVariable이 붙어있다는 것을 확인하고 URL에서 값을 꺼내준다. HttpServletRequest request → ServletRequestMethodArgumentResolver가 타입이 HttpServletRequest인 것을 보고 그냥 현재 요청 객체를 꽂아준다.@RequestBody Dto dto → RequestResponseBodyMethodArgumentResolver가 메시지 컨버터로 파싱해서 꽂아준다.@ExceptionHandler 메서드도 이 체인을 그대로 재사용하는 방식이다.여러 컨트롤러가 공유하는 전역 처리를 하고 싶다면, @ControllerAdvice(또는 @RestControllerAdvice = @ControllerAdvice + @ResponseBody)로 분리한다.
이 클래스를 만드는 방법이 두 가지로 갈리는 편이다.
```java
@RestControllerAdvicepublic class ExControllerAdvice {```ResponseEntityExceptionHandler 상속@ExceptionHandler 를 안쓰고.. HandlerExceptionResolver 를 쓰는 방식
아예 advice 클래스도 없이 — ResponseStatusException
ResponseEntityExceptionHandler 동작방식이건 리플렉션 기반이 아니라, 평범한 자바 상속 및 오버라이딩 방식이다.
handleMethodArgumentNotValid(...) 등 개별 메서드
↓
handleExceptionInternal(ex, body, headers, status, request)
↓
createResponseEntity(body, headers, statusCode, request)
예외 타입 하나를 커스터마이징하고, 모든 예외가 handleExceptionInternal을 공통으로 지나간다.
최종 ResponseEntity로 조립된다.
단점
오버라이드 안한 예외가 있다면, 스프링 기본 구현이 ProblemDetail을 만들어서 handleExceptionInternal로 넘긴다. 즉 공통 포맷과 안 맞는 응답이 섞여 나갈 수도 있다.
handleExceptionInternal/createResponseEntity 둘 다 결국 ResponseEntity<Object>를 반환한다.
이건 상태코드+헤더+바디를 담는 불변 값 객체이다. (HttpEntity<T> 상속, T는 바디 타입)
흐름
DispatcherServlet의 HttpEntityMethodProcessor 가 타입 인식getStatusCode()/getHeaders()/getBody()를 꺼내 응답 조립HttpMessageConverter(Jackson)로 직렬화ResponseEntity<ProblemDetail> 이 아닌 이유는? 공통 응답인 CommonResponse가 있다 하면 이 두 타입은 무관하기 때문에, Object로 타입 자체를 지워둬야 바디 타입을 자유롭게 바꿔서 오버라이드 가능하다. handleExceptionInternal 오버라이드로 공통 응답으로 내려가도록 통일해야 한다. Validation 시 400으로 내려지도록 하는 예외를 오버라이드해본다고 해보자.
이건 첫번째 필드만 응답에 포함 / Validation 실패한 필드를 모두 포함 하는 방법이 있다. 문서 포함에는 validation 필드를 별도 record로 body를 만들어주는 방식이 있는데, 이러면 응답 스키마가 아예 바뀌어 버린다. 개인적으로 별로 선호하지는 않는다.!
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException exception,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request
) {
// 타입을 String,List<String> 으로 해서 에러 메시지를 여러개 담을 수 있도록 한다.
Map<String, List<String>> fieldErrors = new LinkedHashMap<>();
// 필드 에러만큼 에러를 add해준다.
for (FieldError fieldError : exception.getBindingResult().getFieldErrors()) {
fieldErrors.computeIfAbsent(fieldError.getField(), key -> new ArrayList<>())
.add(fieldError.getDefaultMessage());
}
// 맵에 global라는 값으로 응답에 추가하도록 한다.
for (ObjectError globalError : exception.getBindingResult().getGlobalErrors()) {
fieldErrors.computeIfAbsent("global", key -> new ArrayList<>())
.add(globalError.getDefaultMessage());
}
ErrorCode errorCode = CommonErrorCode.INVALID_INPUT;
return handleExceptionInternal(
exception,
CommonResponse.error(errorCode, fieldErrors),
headers,
errorCode.getStatus(),
request
);
}
조금 투머치일 수도 있지만 모든 경우를 커버한다고 가정해서 해보면 다음과 같다.
/** 애플리케이션과 Spring MVC 예외를 {@link CommonResponse} 형식으로 변환하는 전역 처리기 */
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// 도메인 예외를 정의된 상태 코드로 변환
@ExceptionHandler(DomainException.class)
public ResponseEntity<CommonResponse<Void>> handleDomainException(DomainException exception) {
ErrorCode errorCode = exception.getErrorCode();
logByStatus(errorCode, exception);
return ResponseEntity.status(errorCode.getStatus()).body(CommonResponse.error(errorCode));
}
// 요청 DTO 외부의 Bean Validation 오류를 필드별로 변환
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<CommonResponse<Map<String, List<String>>>> handleConstraintViolation(
ConstraintViolationException exception
) {
Map<String, List<String>> fieldErrors = new LinkedHashMap<>();
for (ConstraintViolation<?> violation : exception.getConstraintViolations()) {
fieldErrors.computeIfAbsent(violation.getPropertyPath().toString(), key -> new ArrayList<>())
.add(violation.getMessage());
}
ErrorCode errorCode = CommonErrorCode.INVALID_INPUT;
return ResponseEntity.status(errorCode.getStatus()).body(CommonResponse.error(errorCode, fieldErrors));
}
// DB 제약 조건 위반은 클라이언트 요청 간 상태 충돌로 변환
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<CommonResponse<Void>> handleDataIntegrityViolation(
DataIntegrityViolationException exception
) {
ErrorCode errorCode = CommonErrorCode.CONFLICT;
logByStatus(errorCode, exception);
return ResponseEntity.status(errorCode.getStatus()).body(CommonResponse.error(errorCode));
}
// 낙관적 락 예외처리
@ExceptionHandler(OptimisticLockingFailureException.class)
public ResponseEntity<CommonResponse<Void>> handleOptimisticLockingFailure(
OptimisticLockingFailureException exception
) {
ErrorCode errorCode = CommonErrorCode.CONFLICT;
logByStatus(errorCode, exception);
return ResponseEntity.status(errorCode.getStatus()).body(CommonResponse.error(errorCode));
}
// 이외의 모든 예외를 500으로 처리
@ExceptionHandler(Exception.class)
public ResponseEntity<CommonResponse<Void>> handleUnexpectedException(Exception exception) {
log.error("[ERROR] 서버 내부 오류: ", exception);
ErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR;
return ResponseEntity.status(errorCode.getStatus()).body(CommonResponse.error(errorCode));
}
// @Valid 요청 본문의 필드 검증 오류를 변환 (필드별 다중 메시지 + 전역 오류까지 수집)
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException exception,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request
) {
Map<String, List<String>> fieldErrors = new LinkedHashMap<>();
for (FieldError fieldError : exception.getBindingResult().getFieldErrors()) {
fieldErrors.computeIfAbsent(fieldError.getField(), key -> new ArrayList<>())
.add(fieldError.getDefaultMessage());
}
for (ObjectError globalError : exception.getBindingResult().getGlobalErrors()) {
fieldErrors.computeIfAbsent("global", key -> new ArrayList<>())
.add(globalError.getDefaultMessage());
}
return handleExceptionInternal(exception, fieldErrors, headers, status, request);
}
/**
* 개별 handleXXX()를 따로 오버라이드하지 않아도 여기서 일괄적으로 CommonResponse 포맷 강제
*/
@Override
protected ResponseEntity<Object> handleExceptionInternal(
Exception exception,
Object body,
HttpHeaders headers,
HttpStatusCode statusCode,
WebRequest request
) {
ErrorCode errorCode = resolveErrorCode(exception);
logByStatus(errorCode, exception);
// body가 우리가 직접 채운 값(fieldErrors 등)이면 그대로 담고,
// 부모 클래스가 기본으로 만든 ProblemDetail이면 버리고 빈 CommonResponse로 응답
Object data = (body instanceof ProblemDetail) ? null : body;
CommonResponse<Object> responseBody = (data != null)
? CommonResponse.error(errorCode, data)
: CommonResponse.error(errorCode);
return ResponseEntity.status(errorCode.getStatus()).headers(headers).body(responseBody);
}
private ErrorCode resolveErrorCode(Exception exception) {
if (exception instanceof MethodArgumentNotValidException) return CommonErrorCode.INVALID_INPUT;
if (exception instanceof HandlerMethodValidationException) return CommonErrorCode.INVALID_INPUT;
if (exception instanceof MissingServletRequestParameterException) return CommonErrorCode.INVALID_INPUT;
if (exception instanceof TypeMismatchException) return CommonErrorCode.INVALID_INPUT;
if (exception instanceof ServletRequestBindingException) return CommonErrorCode.INVALID_INPUT;
if (exception instanceof HttpMessageNotReadableException) return CommonErrorCode.MALFORMED_REQUEST;
if (exception instanceof HttpRequestMethodNotSupportedException) return CommonErrorCode.METHOD_NOT_ALLOWED;
if (exception instanceof HttpMediaTypeNotSupportedException) return CommonErrorCode.UNSUPPORTED_MEDIA_TYPE;
if (exception instanceof HttpMessageNotWritableException) return CommonErrorCode.INTERNAL_SERVER_ERROR;
return CommonErrorCode.INTERNAL_SERVER_ERROR; // 등록 안 된 예외의 최종 기본값
}
private void logByStatus(ErrorCode errorCode, Exception exception) {
if (errorCode.getStatus().is5xxServerError()) {
log.error("[ERROR] code={}, status={}", errorCode.getCode(), errorCode.getStatus(), exception);
} else {
log.warn("[ERROR] code={}, status={}, message={}",
errorCode.getCode(), errorCode.getStatus(), exception.getMessage());
}
}
}
호애애앵 넘모 머싯따 우우으으.. 해햇 호애앵 컄컄 우으.....호애앵