Spring Boot에는 다양한 예외 처리 방식을 제공하여 애플리케이션에서 예외가 발생했을 경우 적절하게 대응할 수 있습니다. 이전 아이돔은 각 도메인마다 성공 코드와 예외 코드를 하나의 파일로 관리하고 있었습니다. 이는 가독성 문제와 유지보수 문제를 야기할 수 있었습니다. 이를 개선하기 위해 헥사고날 아키텍처에 맞게 도메인별로 예외 코드를 유지하면서 ErrorResponse 와 SuccessResponse를 효율적으로 재사용하여 공통된 에러 응답을 체계적으로 관리하고자 예외 처리 방법과 동작 방법에 대해 알아보고 적용했습니다.
예외 발생 시 요청 전달 흐름
Request → WAS(톰캣) → 필터 → 서블릿(디스패치 서블릿) → 인터셉터 → 컨트롤러 → 컨트롤러(예외발생) → 인터셉터 → 서블릿(디스패치 서블릿) → 필터 → WAS(톰캣) → WAS(톰캣) → 필터 → 서블릿(디스패치 서블릿) → 인터셉터 → 컨트롤러(BasicErrorController)
ExceptionHandlerExceptionResolver는 에러 응답을 위한 Controller나 ControllerAdvice에 있는 @ExceptionHandler를 처리합니다.@ExceptionHandler가 있는지 검사합니다.@ExceptionHandler에서 처리가 가능하다면 처리하고, 그렇지 않으면 ControllerAdvice로 넘어갑니다.ControllerAdvice 안에 적합한 @ExceptionHandler가 있는지 검사하고, 없으면 다음 처리기로 넘어갑니다.ResponseStatusExceptionResolver는 HTTP 상태 코드를 지정하는 @ResponseStatus 또는 ResponseStatusException을 처리합니다.@ResponseStatus가 있는지 또는 ResponseStatusException인지 검사합니다.ServletResponse의 sendError()로 예외를 서블릿까지 전달하고, 서블릿이 BasicErrorController로 요청을 전달합니다.DefaultHandlerExceptionResolver는 스프링 내부의 기본 예외들을 처리합니다.ExceptionResolver가 없다면 예외가 서블릿까지 전달되고, 서블릿은 SpringBoot가 진행한 자동 설정에 따라 BasicErrorController로 요청을 다시 전달합니다.예를 들어, 서블릿에서 Exception이나 Response.sendError() 메서드가 호출되면, 해당 예외는 WAS로 전파됩니다. 이후 WAS는 해당 예외를 처리하기 위해 설정된 오류 페이지를 찾게 됩니다.
RuntimeException 예외가 WAS까지 전달되면, WAS는 설정된 오류 페이지 정보를 확인합니다. 만약 RuntimeException에 대한 오류 페이지로 "/error/500"이 지정되어 있다면, WAS는 오류 페이지를 출력하기 위해 내부에서 "/error-page/500"을 다시 요청합니다. 이후 해당 페이지가 사용자에게 보여지게 됩니다.
@ControllerAdvice 및 @ExceptionHandler를 사용한 전역 예외 처리
ExceptionHandlerExceptionResolver 내부의 ExceptionHandlerCache에서 관리하게 됩니다. 초기에 ExceptionHandlerExceptionResolver가 생성될 때, InitExceptionHandlerAdviceCache 메서드를 통해 ControllerAdvice에 정의된 ExceptionHandler에 해당하는 HandlerMethodResolver가 캐시에 등록됩니다. 이는 getExceptionHandlerMothod 메서드에서 확인할 수 있는데, 여기서는 Controller에서 정의한 ExcpetionHandler를 우선하여 조회합니다.
@ControllerAdvice@ExceptionHandler 메서드를 포함할 수 있습니다.@ExceptionHandler@ControllerAdvice 어노테이션이 붙은 클래스 내부에서 사용됩니다.ControllerAdvice를 사용함으로써 얻을 수 있는 이점
- 하나의 클래스로 모든 컨트롤러에 대해 전역적으로 예외 처리가 가능해집니다.
- 직접 정의한 에러 응답을 일관성 있게 클라이언트에게 내려줄 수 있습니다.
- 별도의 try-catch문이 없어 코드의 가독성이 높아집니다.
ControllerAdvice 사용시 주의해야 할 점
- 한 프로젝트 당 하나의 ControllerAdvice만 관리하는 것이 좋습니다.
- 만약 여러 ControllerAdvice가 필요하다면 BasePackages나 어노테이션 등을 지정해야합니다.
- 직접 구현한 Exception 클래스들은 한 공간에서 관리합니다.
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleException(Exception e) {
return ResponseEntity.status(INTERNAL_SERVER_ERROR).body("에러 메시지 : " + e.getMessage());
}
}
RespnseEntityExceptionHandler를 확장한 커스텀 예외 처리
Spring은 스프링 예외를 미리 처리해둔 ResponseEntityExceptionHandler를 추상 클래스로 제공하고 있습니다. 이 클래스에는 스프링 예외에 대한 ExceptionHandler가 모두 구현되어 있으므로 @ControllerAdvice 클래스가 이를 상속받도록 함으로써 예외 처리를 담당할 수 있습니다. 만약 @ControllerAdvice 클래스가 ResponseEntityExceptionHandler를 상속받지 않는다면, 스프링 예외는 기본적으로 DefaultHandlerExceptionResolver에 의해 처리됩니다. 이 경우에는 예외 처리가 달라지므로 클라이언트가 일관되지 못한 에러 응답을 받을 수 있습니다. 또한, 기본적으로 에러 메시지를 반환하지 않기 때문에, 스프링 예외에 대한 에러 응답을 제공하려면 handlerExceptionInternal 메서드를 오버라이딩하여 구현해야 합니다.
@CoontrollerAdvice
public class CustomResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(NotFoundPostException.class)
public final ResponseEntity<Object> NotFoundPostException(NotFoundPostException e) {
return ResponseEntity.status(e.getExceptionCode());
}
}
RestControllerAdvice를 이용한 RESTful 예외 처리
@ControllerAdvice와 유사하지만 반환값이 @ResponseBody로 처리됩니다. 즉, 메서드의 반환값이 HTTP 응답으로 직접 전송됩니다.RESTful API에서 발생하는 예외에 대한 처리를 담당합니다.@RestControllerAdvice
public class RestControllerExceptionHandler {
@ExceptionHandler(CustomException.class)
public ResponseEntity<String> handleRestException(CustomException e) {
return ResponseEntity.status(INTERNAL_SERVER_ERROR).body("에러 메시지 : " + e.getMessage());
}
}
특정 컨트롤러 메서드에 @ExceptionHandler를 직접 사용
ExceptionHandlerExceptionResolver 내부의 ExceptionHandlerCache에서 관리됩니다. 특이한 점은 getExceptionHandlerMethod()를 호출할 때 캐시를 조회하고, 캐시에 없는 경우에만 캐시에 등록한다는 것입니다. 즉, Controller에 정의된 ExceptionHandler는 해당 예외가 발생할 때에만 객체가 생성됩니다.
예외를 처리할 메서드에 붙이는 어노테이션으로, 한 메서드가 여러 예외를 처리하려면 ()안에 배열로 작성해야 한다.
@RestController
public class MyController {
@ExceptionHandler(CustomException.class)
public ResponseEntity<String> handleSpecificException(CustomException e) {
return ResponseEntity.status(BAD_REQUEST).body("에러 메시지 : " + e.getMessage());
}
}
Exception에 ResponseStatus 어노테이션 추가
ResponseStatusExceptionResolver에서 예외 처리를 수행합니다.
HandlerExceptionResolverComposite.resolveException() 메서드에서 resolver를 순회할 때, 우선적으로 ExceptionHandlerExceptionResolver를 순회합니다. 이 방식은 전역적인 예외처리가 아닌 특정 컨트롤러에서 발생하는 예외에 대해서만 처리합니다.
@ResponseStatus를 추가하여 특정 HTTP 상태 코드를 설정할 수 있습니다.@ResponseStatus(HttpStatus.BAD_REQUEST)
public class MyException extends RuntimeException {
//예외 클래스
}
public void activateLike(){
if(this.status == LikePostStatus.ACTIVE){
throw new IllegalStateException("이미 좋아요 한 글은 좋아요가 불가능합니다.");
}
}
장점
단점
// CustomExceptionCode
@Getter
@RequiredArgsConstructor
public enum CustomExceptionCode{
FILED_REQUIRED(BAD_REQUEST, "입력은 필수 입니다."),
INVALID_TARGET_SELF(BAD_REQUEST, "본인은 해당 요청의 설정 대상이 될 수 없습니다."),
INVALID_MESSAGE_BODY(BAD_REQUEST, "요청 바디의 형식이 잘못되었습니다."),
}
//ErrorResponse
public record ErrorResponse(String responseCode, String responseMessage, @JsonIgnore HttpStatus status) {
@Builder
public ErrorResponse {
}
public static ErrorResponse of(CustomExceptionCode code) {
return ErrorResponse.builder()
.status(code.getStatus())
.responseCode(code.getName())
.responseMessage(code.getMessage())
.build();
}
public static ErrorResponse of(HttpStatus status, String responseMessage, String responseCode) {
return ErrorResponse.builder()
.responseCode(responseCode)
.responseMessage(responseMessage)
.status(status).build();
}
}
@Getter
@AllArgsConstructor
public class BaseException extends RuntimeException{
private final CustomExceptionCode code;
}
//Handler
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(BaseException.class)
protected ResponseEntity<ErrorResponse> handleCustomException(BaseException exception) {
ErrorResponse error = ErrorResponse.of(exception.getCode());
return ResponseEntity.status(error.status()).body(error);
}
}
장점
error.getMessage()등으로 처리할 수도 있습니다.단점
확장 가능한 열거타입 사용하기
@RestControlerAdvice를 사용하여 전역적으로 발생하는 예외를 처리하고, @ExceptionHandler와 ResponseEntityExceptionHandler를 상속받아 특정 예외를 잡아서 하나의 메서드에서 공통적으로 처리했습니다. 또한, 특정 예외 메시지를 전달하고 어느 부분에서 예외가 발생했는지 파악하기 위해 커스텀 예외 방식을 택했습니다.
각 도메인에 따라 ResponseCode를 개별적으로 관리하고 ErrorResponse와 SuccessResponse를 일관되게 처리하기 위한 방법과 또 새로운 도메인 추가로 인한 응답 코드를 관리해야할 때의 확장성에 대한 고민이 있었습니다. 이러한 고민 끝에 Enum타입에 인터페이스를 추상화하여 사용하기로 결정 했습니다.
이 방식을 사용했을 때 다음과 같은 장점이 있습니다.
![]() | ![]() |
|---|
RuntimeException을 상속 받고 BaseResponseCode를 필드로 갖고 있습니다.

BaseResponseCode 인터페이스를 implements합니다.
public enum PostResponseCode implemts BaseResponseCode {
NOT_FOUND_POST(404, "게시글을 찾을 수 없습니다."),
ALREADY_POST_EXISTS(409, "게시글이 이미 존재합니다.");
private final HttpStatus status;
private final String message;
@Override
public int getCode() {
return code;
}
@Override
public String getMessage() {
return message;
}
@Override
public String getName() {
return name;
}
SuccessResponse, ErrorResponse와 같은 클래스들이 BaseResponseCode 하나로 응답을 넘겨주어 실제 사용자에게 JSON 형식으로 보여주기 위한 에러 응답, 성공 응답 형식을 지정해주었습니다예외 처리를 구현하는 과정에서 Enum 타입을 확장하여 응답 코드를 관리하고, 각 도메인에 맞는 예외 코드를 구분하여 커스텀 예외처리 방법을 선택했습니다. 이러한 선택은 도메인 마다 응답 코드를 관리하고 코드의 일관성과 가독성을 높이고 클라이언트에게 명확하고 유용한 정보를 제공하여 디버깅 및 문제 해결을 용이 위함이었습니다. 또한, 각 예외 코드에 대응하는 메시지를 인터페이스로 추상화하여 관리하여 유지보수성을 향상시켰습니다. 이를 통해 코드의 관리가 용이해지고, 프로젝트의 아키텍처에 맞게 예외 처리를 구현할 수 있었습니다.
참고 자료