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 타입을 확장하여 응답 코드를 관리하고, 각 도메인에 맞는 예외 코드를 구분하여 커스텀 예외처리 방법을 선택했습니다. 이러한 선택은 도메인 마다 응답 코드를 관리하고 코드의 일관성과 가독성을 높이고 클라이언트에게 명확하고 유용한 정보를 제공하여 디버깅 및 문제 해결을 용이 위함이었습니다. 또한, 각 예외 코드에 대응하는 메시지를 인터페이스로 추상화하여 관리하여 유지보수성을 향상시켰습니다. 이를 통해 코드의 관리가 용이해지고, 프로젝트의 아키텍처에 맞게 예외 처리를 구현할 수 있었습니다.
참고 자료