스프링 부트 3.2.1 버전을 기준으로 작성됨
API 예외 처리의 경우 단순 오류 화면을 보여주는 것이 아닌,
각 오류 상황에 맞는 오류 스펙을 정하고, JSON으로 데이터를 보내야한다.
// BasicErrorController
// view 제공
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse
response) {}
//JSON 제공
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
/error
동일한 경로를 처리하는 errorHtml()
, error()
두 메서드
errorHtml()
: produces = MediaType.TEXT_HTML_VALUE
: 클라이언트 요청의 Accept
해더 값이 text/html
인 경우에는 errorHtml()
을 호출해서 view를 제공한다.
error()
: 그외 경우에 호출되고 ResponseEntity
로 HTTP Body에 JSON 데이터를 반환한다.
다음 옵션들을 설정하면 더 자세한 오류 정보를 추가할 수 있다.
server.error.include-binding-errors=always
server.error.include-exception=true
server.error.include-message=always
server.error.include-stacktrace=always
API는 예외마다 서로 다른 응답 결과를 출력해야할 경우가 있다.
단순히 error()로는 불가능
예외 발생 -> WAS -> HTTP Status Code = 500으로 처리됨
예외에 따라 400, 404등 다른 상태코드로, 오류 메시지, 형식등을 API마다 다르게 처리하는 법
@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);
}
api/members/bad 접근시
500 error code
스프링 MVC는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결, 동작을 새로 정의할 수 있는 방법을 제공
줄여서 ExceptionResolver
예외가 해결되도 postHandle()은 호출되지 않는다.
@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.info("resolver ex", e);
}
return null;
}
}
// 기본 설정을 유지하면서 추가
// 등록 해야 사용 가능
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
빈 ModelAndView
: new ModelAndView() 처럼 빈 ModelAndView 를 반환하면 뷰를 렌더링 하지 않고,
정상 흐름으로 서블릿이 리턴된다.
ModelAndView 지정
: ModelAndView 에 View , Model 등의 정보를 지정해서 반환하면 뷰를 렌더링 한다.
null
: null 을 반환하면, 다음 ExceptionResolver 를 찾아서 실행한다. 만약 처리할 수 있는 ExceptionResolver 가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.
예외 상태 코드 변환
/error
의 오류 페이지 뷰 템플릿 처리
API 응답 처리
스프링 부트 기본 제공 ExceptionResolver
1. ExceptionHandlerExceptionResolver
2. ResponseStatusExceptionResolver
(상태코드 지정)
3. DefaultHandlerExceptionResolver
(스프링 내부 기본 예외 처리 )
예외에 따라서 HTTP 상태 코드를 지정해주는 역할
처리 목록
@ResponseStatus
가 달려있는 예외
ResponseStatusException
예외
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}
@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
throw new BadRequestException();
}
BadRequestException
예외가 컨트롤러 밖으로 던져지면 ResponseStatusExceptionResolver
예외가 해당 애노테이션을 확인해서 오류 코드를 HttpStatus.REQUES로 변경하고 메시지도 담는다.
ResponseStatusExceptionResolver
도 결국 response.sendError()를 호출하는 것이다..
reason을 MessageSource에서 찾는 기능도 제공
// 수정 불가 라이브러리 같은 곳에 적용 가능
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}
/MessageSource 또는 직접 메시지 입력 가능
스프링 내부에서 발생하는 스프링 예외를 해결
파라미터 바인딩 시점에 타입 불일치시 TypeMismatchException
발생 -> 서블릿 컨테이너까지 오류가 올라가고 결국 500 오류 발생
하지만 이건 클라이언트가 요청 정보를 잘못 호출해서 발생하는 문제이다.
해당 Resolver는 상태 코드를 500 오류가 아닌 400오류로 변경한다.
결과적으로는 response.sendError()를 통해서 문제를 해결한다.
@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data) {
return "ok";
}
실행해보면 400 , Bad Request 가 응답된다.
이름 그대로 기본예외처리 리졸버이다.
API 예외 처리의 어려운 점
HandlerExceptionResolver에서 API 응답에 필요없는 ModelAndView를 반환해야하는 점
API 응답을 위해서 HttpServletResponse 에 직접 응답 데이터를 넣어줘야하는 점
특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어려움
//ErrorREsult 는 code와 message 필드를 가진 객체
@Slf4j
@RestController
public class ApiExceptionV2Controller {
@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", "내부 오류");
}
@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({AException.class, BException.class})
public String ex(Exception e) {
log.info("exception e", e);
}
// 예외 생략, 생략하면 메서드 파라미터의 예외가 지정
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {}
ExceptionResolver
작동 (우선순위대로 리졸버 적용)ExceptionHandlerExceptionResolver
실행@ExceptionHandler
가 있는지 확인@RestControllerAdvice
: @ResponseBody 적용
@ControllerAdvice
@Slf4j
@RestControllerAdvice(basePackageClasses = ApiExceptionV3Controller.class)
public class ExControllerAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(IllegalArgumentException e) {
log.error("[exceptionHandler] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandler(UserException e) {
log.error("[exceptionHandler] 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 exHandler(Exception e) {
log.error("[exceptionHandler] ex", e);
return new ErrorResult("EX", "내부 오류");
}
}
@ControllerAdvice
는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler
, @InitBinder
기능을 부여해주는 역할을 한다.
@ControllerAdvice
에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)
// 특정 애노테이션 지정
@ControllerAdvice(annotations = RestController.class)
// 패키지 지정
@ControllerAdvice("org.example.controllers")
// 특정 클래스 지정
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
// 생략시 모든 컨트롤러(글로벌) 적용이 된다.
결론은
@ExceptionHandler
와@ControllerAdvice
를 조합해서 사용하면 된다.
🔖 학습내용 출처