ExceptionResolver

디스페처 서블릿은 컨트롤러에서 발생한 예외를 처리하기 위한 ExceptionResolver를 제공한다.

여기서 처리되지 않고 WAS까지 예외가 전달되면 뷰 템플릿을 찾기 위해 내부적으로 다시 컨트롤러까지 호출되는 일이 발생한다. 이는 컨트롤러는 물론, 필터와 인터셉터까지 재호출이 되기 때문에 비효율적인 동작이 추가되어 버린다.

ExceptionResolver는 이 현상을 방지하고자 컨트롤러에서 발생한 예외를 해결하기 위해 ViewResolver나 HttpConverter이 호출되기 전에 ExceptionResolver를 호출하여 적절한 처리를 하게 한다.

인터셉터를 통해 컨트롤러에서 발생한 예외처리를 하려고 하면 preHandler나 afterCompletion에서 해결을 해야하는데, preHandler는 컨트롤러에서 예외가 발생하면 아예 호출이 되지 않고 afterCompletion은 뷰 리졸버가 호출된 이후에 호출이 되기 때문에 적절하지 않은 방식이다.

HandlerExceptionResolver

package org.springframework.web.servlet;

public interface HandlerExceptionResolver {

    @Nullable
    ModelAndView resolveException(
            HttpServletRequest request, 
            HttpServletResponse response, 
            @Nullable Object handler, 
            Exception ex
    );
}

공식 Docs

ExceptionResolver의 인터페이스이다.

자신만의 예외처리를 하고 싶다면 위의 인터페이스를 상속받아서 resolveException 메서드를 구현하면 된다.

  • ModelAndView resolveException
    • 해당 메서드는 ModelAndView를 반환하는데, 3가지 방식으로 반환할 수 있으며 각각에 따라 동작 방식이 다르다.
    • new ModelAndView()
      • 비어있는 ModelAndView를 반환
      • 뷰를 렌더링하지 않고, response.sendError, response.getWrite().write() 같은 메서드를 통해 클라이언트에게 값을 전달해야 한다.
    • new ModelAndView(”error/404.html”)
      • ModelAndView에 실제 값을 담아서 반환
      • ViewResolver를 통해 해당 뷰를 렌더링한다.
    • null
      • 해당 예외는 이 Resolver 구현체에서 처리할 수 없음을 의미한다.
      • 다음 ExceptionResolver를 찾아서 실행한다.
      • 모든 ExceptionResolver에서 null을 반환한 경우, 기존에 발생한 예외를 WAS까지 전달하게 된다.
  • Object handler
    • 핸들러(컨트롤러) 정보가 전달된다.
  • Exception ex
    • 핸들러(컨트롤러)에서 발생한 예외가 전달된다.

커스텀 ExceptionResolver를 등록하기

@Configuration
public class ExceptionResolverConfig implements WebMvcConfigurer {
    @Override
    public void extendHandlerExceptionResolvers(
            List<HandlerExceptionResolver> resolvers
    ) {
        resolvers.add(new MyHandlerExceptionResolver());
    }
}

WebMvcConfigurer 인터페이스를 상속받아 extendHandlerExceptionResolvers 메서드를 오버라이딩해 등록할 수 있다.

Spring이 기본적으로 제공하는 ExceptionResolver

기본적으로 제공하는 ExceptionResolver는 다음 3가지 방식의 Resolver를 지원한다.

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver

이 들은 위에 나타난 순서대로 처리된다.

ExceptionHandlerExceptionResolver를 제외한 다른 2개의 Resolver는 내부적으로 response.sendError를 통해 전달하기 때문에 WAS에서는 다시 오류페이지(/error)를 요청하는 로직이 실행된다.

ResponseStatusExceptionResolver

ExceptionResolver의 2순위 Resolver이다. 이 Resolver는 다음 2가지의 예외를 처리한다.

  • @ResponseStatus
  • ResponseStatusException 예외

@ResponseStatus

@ResponseStatus(
        code = HttpStatus.BAD_REQUEST,
        reason = "잘못된 요청 오류"
)
public class CustomException extends Exception {}

공식 Docs

자신의 예외에 @ResponseStatus 애노테이션을 사용하면 해당 예외가 발생했을때 어떤 방식으로 처리할지 정할 수 있다.

또한, 컨트롤러의 메서드에도 애노테이션을 사용할 수 있는데, 이 경우에는 해당 요청의 기본 응답을 설정할 수 있는 기능을 제공한다.

  • code, value
    • 응답 코드를 설정한다.
    • HttpStatus enum을 이용해 미리 선언되어있는 코드를 설정하면 된다.
    • 기본값: HttpStatus.INTERNAL_SEVER_ERROR
  • reason
    • 해당 예외에 대한 설명을 작성한다.
    • errors.properties에 등록된 에러 코드를 입력하여 메시징 기능을 사용할 수 있다.
    • 기본값: ""

ResponseStatusException

package org.springframework.web.server;

public class ResponseStatusException extends ErrorResponseException {

    public ResponseStatusException(
            HttpStatusCode status
    ) { ... }

    public ResponseStatusException(
            HttpStatusCode status, 
            @Nullable String reason
    ) { ... }

    public ResponseStatusException(
            int rawStatusCode, 
            @Nullable String reason, 
            @Nullable Throwable cause
    ) { ... }

    public ResponseStatusException(
            HttpStatusCode status, 
            @Nullable String reason, 
            @Nullable Throwable cause
    ) { ... }
}
@GetMapping("/exception")
public String exception() {
    throw new ResponseStatusException(
            HttpStatus.NOT_FOUND,
            "잘못된 요청 오류",
            new IllegalArgumentException()
    );
}

공식 Docs

ResponseStatusException을 발생시켜 어떤 방식으로 처리할지 정할 수 있다.

  • status, rawStatusCode
    • 응답 코드를 설정한다.
    • rawStatusCode는 단순히 숫자(200, 400 등)을 입력하는 곳이다.
    • status는 HttpStatus enum을 입력하는 곳이다.
  • reason
    • 해당 예외에 대한 설명을 작성한다.
    • errors.properties에 등록된 에러 코드를 입력하여 메시징 기능을 사용할 수 있다.
  • cause
    • 설명을 위한 예외를 설정하거나, 추가적인 예외를 중첩시켜야할 때 사용되는 변수이다.

DefaultHandlerExceptionResolver

public class DefaultHandlerExceptionResolver extends AbstractHandlerExceptionResolver {
    // ...
    
    protected ModelAndView handleTypeMismatch(
            TypeMismatchException ex,
            HttpServletRequest request, 
            HttpServletResponse response, 
            @Nullable Object handler
    ) throws IOException {

        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
        return new ModelAndView();
    }
    
    // ...
}

공식 Docs

ExceptionResolver 중 3순위 Resolver이다. 가장 마지막에 처리되는 Resolver이기 때문에 여러 기본적인 예외에 대한 처리가 작성되어있다.

위에 작성한 코드의 경우, 파라미터 바인딩 실패(TypeMismatchException)에 대한 처리를 담당하는 부분이다.

이외에도 여러 예외에 대한 처리가 기술되어있으니 자세한 내용은 공식 Docs에서 확인해보면 된다.

ExceptionHandlerExceptionResolver

ExceptionResolver 중 1순위로 처리되는 Resolver이다.

컨트롤러 내부 메소드에 @ExceptionHandler 애노테이션을 사용하여 해당 예외에 대한 처리를 진행할 수 있다.

컨트롤러 내부 메소드에 사용하게 되면 해당 컨트롤러에서 발생한 예외에 한해 적용되며, 후술할 @ControllerAdvice 애노테이션을 사용하여 전역 예외 리졸버로 사용하거나, 특정 컨트롤러에 한한 예외 리졸버로 작성하여 파일을 분리할 수 있다.

반환값에 따른 컨버터가 적용되기 때문에 ModelAndView를 반드시 반환할 필요가 없어진다.

@ExceptionHandler

package org.springframework.web.bind.annotation;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Reflective(ExceptionHandlerReflectiveProcessor.class)
public @interface ExceptionHandler {

    Class<? extends Throwable>[] value() default {};

}
@Slf4j
@RestController
@RequestMapping("/api")
public class ApiExceptionController {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandler(
            IllegalArgumentException e
    ) {
        log.error("illegalExHandler call: {}", e.toString());
        return new ErrorResult("BAD", e.getMessage());
    }

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandler(
            UserException e
    ) {
        log.error("userExHandler call: {}", e.toString());
        ErrorResult result = new ErrorResult("USER-EX", e.getMessage());
        return new ResponseEntity<ErrorResult>(result, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler
    public ErrorResult exHandler(
            Exception e
    ) {
        log.error("exHandler call: {}", e.toString());
        return new ErrorResult("EX", e.getMessage());
    }

    @GetMapping("/members/{id}")
    public MemberDto getMember(
            @PathVariable String id
    ) {
        switch (id) {
            case "ex" -> throw new RuntimeException("잘못된 사용자");
            case "bad" -> throw new IllegalArgumentException("잘못된 입력 값");
            case "user-ex" -> throw new UserException("사용자 오류");
        }
        return new MemberDto(id, "hello " + id);
    }
}

공식 Docs

여러 예외에 대한 세부적인 처리가 가능하며, 컨트롤러 클래스단에 작성하게 되면 해당 클래스의 메서드에서 발생한 예외만 처리하게 된다.

  • value
    • 어떤 예외에 대한 처리를 진행할지 지정할 수 있다.
      • value = RuntimeException.class
    • 배열 형태로 입력하면 여러 예외에 대한 처리를 한 번에 지정할 수 있다.
    • 특별히 지정하지 않는 경우, 해당 애노테이션을 붙인 메서드의 Argument에 작성된 예외를 지정하게 된다.

부모 예외는 자식 예외를 모두 처리할 수 있다. 이에 대한 순서는 다음과 같다.

  1. 컨트롤러 클래스단 자식 예외 처리
  2. 컨트롤러 클래스단 부모 예외 처리
  3. 전역 자식 예외 처리
  4. 전역 부모 예외 처리
  5. ResponseStatusExceptionResolver 실행

@ControllerAdvice

package org.springframework.web.bind.annotation;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface ControllerAdvice {

    @AliasFor("basePackages")
    String[] value() default {};

    @AliasFor("value")
    String[] basePackages() default {};

    Class<?>[] basePackageClasses() default {};

    Class<?>[] assignableTypes() default {};

    Class<? extends Annotation>[] annotations() default {};

}
/**
 * 특정 애노테이션이 있는 컨트롤러
 * - @RestController
 */
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}

/**
 * 특정 패키지
 * - org.example.controllers 패키지
 */
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}

/**
 * 특정 클래스
 */
@ControllerAdvice(
        assignableTypes = {
                ControllerInterface.class,
                AbstractController.class
        }
)
public class ExampleAdvice3 {}

공식 Docs

컨트롤러 클래스 안에서 ExceptionHandler를 작성하면 실제로 컨트롤러 역할을 진행하는 메서드와 예외 처리를 진행하는 메서드가 섞이게 된다. 이는 바람직한 코드 작성법이 아니다. (단일 책임 원칙 위배)

그래서 Spring은 @ControllerAdvice 애노테이션을 통해 예외 처리 메서드를 분리할 수 있도록 기능을 제공한다.

특정 패키지, 클래스, 애노테이션에서 발생하는 예외를 처리할지 지정할 수 있는 기능을 제공하며, 아무것도 지정하지 않으면 모든 예외에 대한 처리를 담당하게 된다. (전역 Advice)

  • value, basePackages
    • 패키지 단위로 설정한다.
    • "org.example.controllers"
  • basePackageClasses, assignableTypes
    • 인터페이스 또는 클래스 단위로 설정한다.
    • ControllerInterface.class
  • annotations
    • 애노테이션 단위로 설정한다.
    • RestController.class
profile
백엔드 개발자 지망생

0개의 댓글