오류 페이지는 단순히 고객에게 오류 화면을 보여주고 끝이지만, API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.
WebServerCustomizer
가 다시 사용되도록 @Component 애노테이션 주석을 풀어주었다.
ApiExceptionController
package com.example.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;
}
}
Postman 정상 테스트
Postman 예외 발생 테스트
ErrorPageController API 응답 추가
@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletResponse response, HttpServletRequest request){
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));
}
오류
코드대로 실행 했는데 오류 메시지가 안뜨는 오류가 있었다.
ex.getMessage가 null이다. request.getAttribute(ERROR_EXCEPTION); 요 부분에서 request를 못 받는 것 같았다.//RequestDispatcher 상수로 정의되어 있음 public static final String ERROR_EXCEPTION = "javax.servlet.error.exception"; public static final String ERROR_EXCEPTION_TYPE = "javax.servlet.error.exception_type"; public static final String ERROR_MESSAGE = "javax.servlet.error.message"; public static final String ERROR_REQUEST_URI = "javax.servlet.error.request_uri"; public static final String ERROR_SERVLET_NAME = "javax.servlet.error.servlet_name"; public static final String ERROR_STATUS_CODE = "javax.servlet.error.status_code";
Spring Boot 3.xx 버전 이상부터는 javax -> jakarta 로 변경해야 된다는 것을 알았다. 변경 후 테스트 해보니 잘 실행되었다.
POST 요청
WebServerCustomizer에 @Component를 다시 주석 처리하고 스프링 부트의 기본 오류처리를 어떻게하나 확인해보자.
스프링 부트가 제공하는 BasicErrorController
코드를 보자
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse
response) {}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
errorHtml()
: produces = MediaType.TEXT_HTML_VALUE
: 클라이언트 요청의 Accept해더 값이 text/html인 경우에는 errorHtml()을 호출해서 view를 제공한다.error()
: 그외 경우에 호출되고 ResponseEntity
로 HTTP Body에 JSON 데이터를 반환한다.다음 옵션들을 설정하면 더 자세한 오류 정보를 추가할 수 있다.
오류 메시지는 이렇게 막 추가하면 보안상 위험할 수 있다. 간결한 메시지만 노출하고, 로그를 통해서 확인하자.
목표
ApiExceptionController - 수정
@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번이다
HandlerExceptionResolver
직접 적용해보자
MyHandlerExceptionResolver
package com.example.exception.resolver;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
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;
}
}
IllegalArgumentException
이 발생하면 response.sendError(400)
를 호출해서 HTTP 상태 코드를 400으로 지정하고, 빈 ModelAndView
를 반환한다.WebConfig 등록
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
POSTMAN 확인
-> 500 error 에서 400 error로 변경 된 것을 확인가능
반환 값에 따른 동작 방식
HandlerExceptionResolver
의 반환 값에 따른 DispatcherServlet
의 동작 방식은 다음과 같다.
new ModelAndView()
처럼 빈 ModelAndView
를 반환하면 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다.ModelAndView
에 View
,Model
등의 정보를 지정해서 반환하면 뷰를 렌더링 한다.null
을 반환하면, 다음 ExceptionResolver
를 찾아서 실행한다. 만약 처리할 수 있으면ExceptionResolver
가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.예외가 발생하면 WAS까지 예외가 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 /error
를 호출하는 과정은 생각해보면 너무 복잡하다. ExceptionResolver
를 활용하면 예외가 발생했을 때 이런 복잡한 과정 없이 여기에서 문제를 깔끔하게 해결할 수 있다.
UserException
package com.example.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);
}
}
ApiExceptionController - 예외 추가
@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);
}
예외 처리하는 UserHandlerExceptionResolver를 만들어보자
UserHandlerExceptionResolver
package com.example.exception.resolver;
import com.example.exception.exception.UserException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
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 400");
String accetpHeader = request.getHeader("accept");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST); // 400
if("application/json".equals(accetpHeader)){ // json 이면
Map<String, Object> errorResult = new HashMap<>();
errorResult.put("ex", ex.getClass());
errorResult.put("message", ex.getMessage());
String result = objectMapper.writeValueAsString(errorResult); // json를 문자열로
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
값이 application/json
이면 JSON으로 오류를 내려주고, 그 외 경우에는 error/500에 있는 HTML 오류 페이지를 보여준다.
WebConfig도 추가해줬다.
POSTMAN 확인
ACCEPT : application/json
ACCEPT : text/html
직접 ExceptionResolver
를 구현하려고 하니 상당히 복잡하다. 지금부터 스프링이 제공하는 ExceptionResolver
들을 알아보자.
스프링 부트가 기본으로 제공하는 ExceptionResolver
는 다음과 같다.
1. ExceptionHandlerExceptionResolver
2. ResponseStatusExceptionResolver
3. DefaultHandlerExceptionResolver
-> 우선순위가 가장 낮음
ResponseStatusExceptionResolver
는 예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 한다.
@ResponseStatus
가 달려있는 예외ResponseStatusException
예외BadRequestException
package com.example.exception.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}
BadRequestException
예외가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver
예외가 해당 애노테이션을 확인해서 오류 코드를 HttpStatus.BAD_REQUEST
(400)으로 변경하고, 메시지도 담는다.
ApiExceptionController - 추가
@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
throw new BadRequestException();
}
상태 코드가 400으로 바뀌어있다.
reason
을 MessageSource
에서 찾는 기능도 제공한다.
messages.properties
error.bad=잘못된 요청 오류입니다. 메시지 사용
reason = "error.bad"로 변경
ApiExceptionController - 추가
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}
@ResponseStatus
는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다. (애노테이션을 직접 넣어야 하는데, 내가 코드를 수정할 수 없는 라이브러리의 예외 코드 같은 곳에는 적용할 수 없다.) 추가로 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다. DefaultHandlerExceptionResolver
에 대해서 알아보자
DefaultHandlerExceptionResolver
는 스프링 내부에서 발생하는 스프링 예외를 해결한다.TypeMismatchException
이 발생하는데, 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다.DefaultHandlerExceptionResolver
는 이것을 500 오류가 아니라 HTTP 상태 코드 400 오류로 변경한다.ApiExceptionController - 추가
@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data) {
return "ok";
}
TypeMismatchException
가 발생한다.HTML 화면 오류 vs API 오류
BasicErrorController
를 사용하는게 편하다.BasicErrorController
를 사용하거나 HandlerExceptionResolver
를 직접 구현하는 방식으로 API 예외를 다루기는 쉽지 않다.(예외 상황에 단순히 오류 화면을 보여주는 것이 아니라, 예외에 따라서 각각 다른 데이터를 출력해야 할 수도 있기 때문에)@ExceptionHandler
@ExceptionHandler
라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공하는데, 이것이 바로 ExceptionHandlerExceptionResolver
이다.ExceptionHandlerExceptionResolver
를 기본으로 제공하고, 기본으로 제공하는 ExceptionResolver
중에 우선순위도 가장 높다. 실무에서 API 예외 처리는 대부분 이 기능을 사용한다.예제로 알아보자
ErrorResult
package com.example.exception.exhandler;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
ApiExceptionV2Controller (ApiController와 비슷)
package com.example.exception.exhandler;
import com.example.exception.exception.UserException;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
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 ApiExceptionV2Controller {
// 이 컨트롤러 안에서 IllegalArgumentException 오류가 나타나면 잡아줌.
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(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;
}
}
POSTMAN 테스트
실행 흐름
IllegalArgumentException
예외가 컨트롤러 밖으로 던져진다.ExceptionResolver
가 작동한다. 가장 우선순위가 높은 ExceptionHandlerExceptionResolver
가 실행된다.ExceptionHandlerExceptionResolver
는 해당 컨트롤러에 IllegalArgumentException
을 처리할 수 있는 @ExceptionHandler
가 있는지 확인한다.illegalExHandle()
를 실행한다. @RestController
이므로 illegalExHandle()
에도 @ResponseBody
가 적용된다. 따라서 HTTP 컨버터가 사용되고, 응답이 다음과 같은 JSON으로 반환된다.@ResponseStatus(HttpStatus.BAD_REQUEST)
를 지정했으므로 HTTP 상태 코드 400으로 응답한다.ApiExceptionV2Controller 코드 추가
@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);
}
@ExceptionHandler
에 예외를 지정하지 않으면 해당 메서드 파라미터 예외를 사용한다. 여기서는 UserException
을 사용한다.ResponseEntity
를 사용해서 HTTP 메시지 바디에 직접 응답한다. 물론 HTTP 컨버터가 사용된다.POSTMAN 테스트
ApiExceptionV2Controller 코드 추가
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
throw new RuntimeException("잘못된 사용자")
이 코드가 실행되면서, 컨트롤러 밖으로 RuntimeException
이 던져진다.RuntimeException
은 Exception
의 자식 클래스이다. 따라서 이 메서드가 호출된다.@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
로 HTTP 상태 코드를 500으로 응답한다.POSTMAN 테스트
우선순위
스프링의 우선순위는 항상 자세한 것이 우선권을 가진다. 예를 들어서 부모, 자식 클래스가 있고 다음과 같이 예외가 처리된다.
@ExceptionHandler(부모예외.class)
public String 부모예외처리()(부모예외 e) {}
@ExceptionHandler(자식예외.class)
public String 자식예외처리()(자식예외 e) {}
@ExceptionHandler
를 사용해서 예외를 깔끔하게 처리할 수 있게 되었지만, 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다. @ControllerAdvice
또는 @RestControllerAdvice
를 사용하면 둘을 분리할 수 있다.
ExControllerAdvice
package com.example.exception.exception.advice;
import com.example.exception.exception.UserException;
import com.example.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", "내부 오류");
}
}
ApiExceptionV2Controller
에 있는 @ExceptionHandler
를 모두 ExControllerAdvice로 옮겼고 ApiExceptionV2Controller에 @ExceptionHandler를 모두 제거 하였다.
-> POSTMAN 테스트 결과 모두 동일하게 적용되었다.
@ControllerAdvice
@ControllerAdvice
는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler
, @InitBinder
기능을@ControllerAdvice
에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)@RestControllerAdvice
는 @ControllerAdvice
와 같고, @ResponseBody
가 추가되어 있다. @Controller
, @RestController
의 차이와 같다.대상 컨트롤러 지정 방법
// Target all Controllers annotated with @RestController
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}
// Target all Controllers within specific packages
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}
// Target all Controllers assignable to specific classes
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}
https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-controller-advice (스프링 공식 문서 참고)
참고
김영한: 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술(인프런)
Github - https://github.com/b2b2004/Spring_MVC