제육's 휘발성 코딩
article thumbnail
반응형

서블릿 API 예외 처리

package hello.exception.sevlet;

import org.springframework.boot.web.server.ConfigurableWebServerFactory;
import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;

@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {

  @Override
  public void customize(ConfigurableWebServerFactory factory) {
    ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404");
    ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
    ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");
    factory.addErrorPages(errorPage404, errorPage500, errorPageEx);
  }
}
  • 이전 포스팅에서 만들었던 WebServerCustomizer를 다시 사용하기 위해 @Component 어노테이션을 다시 적용시키자. response.sendError() 가 호출되면 등록한 경로가 호출된다.
package hello.exception.api;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
public class ApiExceptionController {

    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if(id.equals("ex")) {
            throw new RuntimeException("잘못된 사용자");
        }
        return new MemberDto(id, "hello " + id);
    }

    @Data
    @AllArgsConstructor
    static class MemberDto {
        private String memberId;
        private String name;
    }
}
  • 레포지토리 없이 회원을 조회하는 기능을 사용해보자. id값이 ex이면 예외가 발생하도록 하였다.

image

  • 정상요청에는 Json응답이 오지만 오류에 대한 요청은 HTML이 반환되었다. 즉, 오류 페이지 컨트롤러도 JSON 응답을 할 수 있도록 수정해야 한다.
@RequestMapping("/error-page/500")
public String errorPage500(HttpServletRequest request, HttpServletResponse response) {
  log.info("errorPage 500");
  printErrorInfo(request);
  return "error-page/500";
}

@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletRequest request, HttpServletResponse response) {
  log.info("API errorPage 500");

  Map<String, Object> result = new HashMap<>();
  Exception ex = (Exception) request.getAttribute(ERROR_EXCEPTION);
  result.put("status", request.getAttribute(ERROR_STATUS_CODE));
  result.put("message", ex.getMessage());

  Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
  return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
}
  • 다음과 같이 기존의 에러 컨트롤러에 Http Header의 Accept 값이 application/json이면 해당 메서드를 호출하게 하여 Map으로 받아서 JSON으로 구조로 변환할 수 있도록 적용하자.

 

스프링 부트 API 예외 처리

image

API 예외 처리도 스프링 부트가 제공하는 기본 오류 방식을 사용할 수 있다. 스프링 부트가 제공하는 BasicErrorController 코드를 살펴보자.

@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse
                              response) {}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
  • BasicErrorController/error 를 처리하는 두 가지 메서드가 있다. 클라이언트 요청 Accept 헤더 값이 text/html인 경우 errorHtml()을 호출해서 View를 제공하고 그 외는 JSON 데이터를 반환한다.
server.error.include-binding-errors=always
server.error.include-exception=true 
server.error.include-message=always 
server.error.include-stacktrace=always
  • 다음 옵션들을 설정하면 더 자세한 오류 정보를 추가할 수 있다.

BasicErrorController를 확장하면 JSON 메시지도 변경할 수 있다. 그런데 API 오류는 발생하는 예외에 따라 그 결과가 달라질 수 있다. 즉, @ExceptionHandler가 제공하는 기능을 사용하는 것이 더 나은 방법이다.

HTML 화면을 처리할 때 BasicErrorController를 사용하고 API 오류를 처리할 때 @ExceptionHandler를 사용하자.

 

서블릿 방식 HandlerExceptionResolver

@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
  if(id.equals("ex")) {
    throw new RuntimeException("잘못된 사용자");
  }
  if (id.equals("bad")) {
    throw new IllegalArgumentException("잘못된 입력 값");
  }
  return new MemberDto(id, "hello " + id);
}
  • 다음과 같이 오류가 발생하면 500에러가 발생한다. bad일 때 다른 상태코드를 나타내고 싶을 때를 알아보자.

예외가 발생해서 서블릿을 넘어 WAS까지 예외가 전달되면 HTTP 상태코드가 500으로 처리된다. 발생하는 예외에 따라서 다른 상태코드도 처리하고 싶은 경우 HandlerExceptionResolver에 대해 알아야한다. 줄여서 ExceptionResolver라고 한다.

image

  • 예외가 처리되지 않고 그대로 WAS로 전달된다. DispatcherSevlet에서 인터셉터를 통해 preHandle, postHandle, afterCompletion 등의 처리를 한다.

image

  • ExceptionResolver가 존재하면 중간에 예외 해결을 시도한다. 이 때 해결할 수 있다면 정상처리를 도와준다. 예외를 해결해도 postHandle()은 호출되지 않는다.
package hello.exception.resolver;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {

  @Override
  public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
    try {
      if (ex instanceof IllegalArgumentException) {
        log.info("IllegalArgumentException resolver to 400");
        response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
        return new ModelAndView();
      }
    } catch (IOException e) {
      log.error("resolver ex", e);
    }

    return null;
  }
}
  • HandlerExceptionResolver의 반환 값에 따른 DispatcherSevlet의 동작 방식은 다음과 같다.
    • 빈 ModelAndView : 뷰를 렌더링하지 않고, 정상 흐름으로 서블릿 리턴
    • ModelAndView 지정 : View, Model 등의 정보를 저장해서 반환하면 뷰를 렌더링
    • null : 다음 ExceptionResolver를 찾아서 실행. 처리할 수 없으면 예외처리 실패 후 서블릿 밖으로 던진다.
  • 예외 상태 코드 변환
    • response.sendError() 를 호출해서 오류를 처리하도록 위임. 이 때 WAS는 서블릿 오류 페이지를 찾아서 내부 호출 (/error 등)
  • 템플릿 처리 : ModelAndView에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링
  • API 응답 처리 : response.getWriter().println("hello") 처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능하다. JSON 응답처리도 가능
@Configuration
public class WebConfig implements WebMvcConfigurer {

  @Override
  public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
    resolvers.add(new MyHandlerExceptionResolver());
  }
}
  • 다음과 같이 등록해주면 오류 상태코드가 변경되는 것을 확인할 수 있다.

 

서블릿 방식 HandlerExceptionResolver 활용

package hello.exception.exception;

public class UserException extends RuntimeException{

  public UserException() {
    super();
  }

  public UserException(String message) {
    super(message);
  }

  public UserException(String message, Throwable cause) {
    super(message, cause);
  }

  public UserException(Throwable cause) {
    super(cause);
  }

  protected UserException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
    super(message, cause, enableSuppression, writableStackTrace);
  }
}
  • 다음과 같이 커스텀 예외처리를 RuntimeException을 상속받아서 생성하자.
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
  if(id.equals("ex")) {
    throw new RuntimeException("잘못된 사용자");
  }
  if (id.equals("bad")) {
    throw new IllegalArgumentException("잘못된 입력 값");
  }
  if (id.equals("user-ex")) {
    throw new UserException("사용자 오류");
  }
  return new MemberDto(id, "hello " + id);
}
  • 사용자 오류에 생성한 커스텀 예외처리를 적용시키자.
package hello.exception.resolver;

import com.fasterxml.jackson.databind.ObjectMapper;
import hello.exception.exception.UserException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {
    private final ObjectMapper objectMapper = new ObjectMapper();
    @Override
    public ModelAndView resolveException(HttpServletRequest request,
                                         HttpServletResponse response, Object handler, Exception ex) {
        try {
            if (ex instanceof UserException) {
                log.info("UserException resolver to 400");
                String acceptHeader = request.getHeader("accept");
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                if ("application/json".equals(acceptHeader)) {
                    Map<String, Object> errorResult = new HashMap<>();
                    errorResult.put("ex", ex.getClass());
                    errorResult.put("message", ex.getMessage());
                    String result =
                            objectMapper.writeValueAsString(errorResult);
                    response.setContentType("application/json");
                    response.setCharacterEncoding("utf-8");
                    response.getWriter().write(result);
                    return new ModelAndView();
                } else {
                    //TEXT/HTML
                    return new ModelAndView("error/500");
                }
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        return null;
    }
}
  • HTTP 요청 헤더의 ACCEPT 값이 json 이면 JSON 오류를 그 외는 오류 페이지를 반환하도록 설정할 수 있다.
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver>
                                            resolvers) {
  resolvers.add(new MyHandlerExceptionResolver());
  resolvers.add(new UserHandlerExceptionResolver());
}
  • 새로운 HandlerExceptionResolver를 생성했기 때문에 WebConfig에 추가 등록하자.

 

스프링 방식 ExceptionResolver

스프링 부트가 기본으로 제공하는 ExceptionResolver는 다음과 같다.

HandlerExceptionResolverComposite에 다음 순서로 등록

  • ExceptionHandlerExceptionResolver : @ExceptionHandler를 처리 (대부분 API 예외는 여기서 처리 가능)
  • ResponseStatusExceptionResolver : Http 상태 코드 지정
  • DefaultHandlerExceptionResolver : 스프링 내부 기본 예외를 처리

 

ResponseStatusExceptionResolver

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException{
}
  • 다음과 같이 @ResponseStatus를 등록하여 예외를 400으로 변경하자. 내부적으로 response.sendError(statusCode, resolvedReason)을 호출하고 WAS에서 다시 오류 페이지를 내부 요청한다.
@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
  throw new BadRequestException();
}
  • 400 오류 상태코드가 반환되는 것을 확인할 수 있다.
error.bad=잘못된 요청 오류입니다. 메시지 사용
  • messages.properties를 생성하고 다음과 같이 적용한 후, @ResponseStatus에 reason을 error.bad로 설정하면 메시지화가 가능하다.

 

ResponseStatusException

@ResponseStatus는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다. 즉 , 동적으로 변경하기 어렵기 때문에 이때는 ResponseStatusException 예외를 사용하면 된다.

@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
  throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}
  • 다음과 같이 기존에 있는 예외에서 원하는 오류 상태 코드와 상태 메시지를 담아서 보여줄 수 있다.

 

DefaultHandlerExceptionResolver

DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외를 해결한다. 예를 들어 파라미터 바인딩 시점에 타입이 맞지 않으면 클라이언트에서 정보를 잘못입력한 경우이지만 WAS로 전달이되면서 500에러가 나온다. 이 500 에러를 400 에러로 변경해주는 기능을 담당한다.

@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data) {
  return "ok";
}
  • 실행 결과를 보면 반환된 HTTP 상태 코드가 400인 것을 확인할 수 있다.

 

스프링 방식 @ExceptionHandler

API 예외처리를 적용할 땐 HandlerExceptionResolver를 사용하면 ModelAndView를 반환해야하고 직접 HttpServletResponse에 응답 데이터를 넣어주는 불편함이 있었다.

@ExceptionHandler가 사용하기 불편했던 ExceptionHandlerExceptionResolver를 기본으로 제공해주며 우선 순위도 ExceptionResolver 중에 가장 높다.

package hello.exception.exhandler;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class ErrorResult {
    private String code;
    private String message;
}
  • 예외 발생 시 API 응답으로 사용하기 위한 객체를 정의하자.
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
  log.error("[exceptionHandle] ex", e);
  return new ErrorResult("BAD", e.getMessage());
}

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

@Data
@AllArgsConstructor
static class MemberDto {
  private String memberId;
  private String name;
}
  • 다음과 같이 @ExceptionHandler 어노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다.
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
  log.error("[exceptionHandle] ex", e);
  ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
  return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
  • 어노테이션에 반환 객체를 생략하고 파라미터로 바인딩할 수도 있다. @ResponseEntity를 사용해서 원하는 오류 객체를 바인딩해서 반환할 수 있다.

 

ExceptionHandler에 지정한 부모 클래스는 자식 클래스 까지 처리할 수 있다. 이 때 우선 순위는 작은 단위인 자식이 우선순위를 갖게 된다.

 

@ControllerAdvice

여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을 부여해주는 역할을 한다.

대상을 지정하지 않으면, 모든 컨트롤러에 적용된다. (글로벌 기능)

@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1{}

@ControllerAdvice("org.example.controllers") 
public class ExampleAdvice2{} // 패키지 하위 모두 적용

@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3{} // 특정 클래스 지정
  • 다음과 같이 지정할 컨트롤러를 여러 방법으로 적용시킬 수 있다.
import hello.exception.exception.UserException;
import hello.exception.exhandler.ErrorResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandle(IllegalArgumentException e) {
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandle(UserException e) {
        log.error("[exceptionHandle] ex", e);
        ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
        return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
    }

    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    public ErrorResult exHandle(Exception e) {
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("EX", "내부 오류");
    }
}
  • 기존에 하던 @ExceptionHandler@ControllerAdvice를 조합하여 글로벌 처리를 할 수 있다.

 


본 포스팅은 인프런 - 김영한님의 '스프링 MVC 2편 - 백엔드 웹 개발 활용 기술'을 참고하였습니다.

반응형
profile

제육's 휘발성 코딩

@sasca37

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요! 맞구독은 언제나 환영입니다^^