1. 서블릿 API 오류 처리
API 방식 오류 처리
이전 글에서는 4xx, 5xx 등 Http 상태코드에 따라 오류 페이지를 클라이언트에 보여주는 형식으로 처리했다. 상황에 따라 보여줄 페이지만 설정하면 되므로 상대적으로 간단한 방식이라 할 수 있다. 그러나, API 통신의 경우를 생각해보자. 만약 서버-서버간 API 통신을 한다고 생각해보면, HTML로 표현된 페이지를 보내서 통신을 할 수는 없다. 어떤 데이터 양식(ex. json 객체)으로 통신을 할지 규약을 정하고, 그에 맞게 데이터를 보내주어야 한다.
서블릿의 API 방식 오류 처리
이전 글에 이어서 진행을 했다면, WebServerCustomizer 클래스가 주석처리 되어있을 것이다. 주석을 풀고, 다시 Bean으로 등록하여 서블릿에 의해 API 방식의 오류가 처리되는 방식을 학습해보자. 이렇게 에러 페이지들을 등록해주면, 앞서 배웠듯이 Exception이 WAS에 전달되거나, response.sendError()를 통해 에러 페이지가 호출되게 된다.
다음과 같이 ApiExceptionController를 작성한다. 매핑된 url로 요청이 왔을 때, PathVariable에 따라 분기되는 Controller이다. View가 아니라 객체를 응답하는 형태이기 때문에 @RestController로 처리해준다.
java/hello/exception/api/ApiExceptionController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable 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;
}
}
|
cs |
PathVariable이 spring인 경우
json 형태의 객체가 응답으로 온 것을 확인할 수 있다. 요청 Content-Type이 application/json이기 때문이다.(Accept 헤더가 없거나, */* 인 경우에도 json 형태로 오긴한다. postman이나 spring에서 알아서 처리해주는 것 같다)
PathVariable이 ex인 경우
의도한대로 RuntimeException이 발생한다. 그러나 HTML 오류 페이지가 응답으로 출력되는 것을 확인할 수 있다. 이것은 해당 URL로 요청이 가면, RuntimeException이 발생하고, 위쪽에서 등록한 WebServerCustomizer에 의해 "error-page/500"이 호출된 후, ErrorPageController에서 해당 페이지 View가 호출되어 출력되는 것이다. 앞에서 언급한 바와 같이, API 통신에서 이런 에러가 발생하면 안된다.
ErrorPageController 일부. "/error-page/500"의 View로 이동하게 된다.
에러 발생 시 컨트롤러에서 API 응답
그렇다면, API 형태로 응답을 보내주기 위해서 ErrorPageController에서 body에 응답 객체를 담아주면 된다. 따라서 ErrorPageController에 다음 코드를 추가한다.
produces=MediaType.APPLICATION_JSON_VALUE
"error-page/500" URL로 메서드를 1개 더 추가해주었다. 그러나 이전에 배운 것처럼, produces 속성을 주어서 MediaType.APPLICATION_JSON_VALUE를 추가하였다. 이렇게 하면 요청의 헤더에서 Accept 부분이 application/json인 경우 해당 메서드가 우선적으로 선택되어 요청을 처리하게 된다.
RequestDispatcher.ERROR_STATUS_CODE
요청에서 각종 정보를 getAttribute를 통해서 가져올 수 있다. static으로 등록한 javax.servlet.error 내의 각종 정보들을 가져온다. 이중에서도 ReqeustDispatcher.ERROR_STATUS_CODE 부분은 객체에 대고 바로 String으로 된 경로를 조회하는 경우라 약간 생소할 수 있다.
ResponseEntity
API 방식으로 응답값을 전송해주기 위해서 ResponseEntity에 Map 객체인 result와 HttpStatus의 code값을 담아준다.
java/hello/exception/servlet/ErrorPageController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
@Slf4j
@Controller
public class ErrorPageController {
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";
@RequestMapping("/error-page/404")
public String errorPage404(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 404");
printErrorInfo(request);
return "error-page/404";
}
@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));
}
|
cs |
JSON 객체 형태로 에러 메시지가 잘 출력되는 것을 확인할 수 있다.
다시 한번 정리해보면, 원래 return 문에서 View 경로를 지정하여 HTML 페이지를 반환해주었는데, ResponseEntity 등을 이용하여 body 객체를 반환하여 API 방식의 에러 송출이 가능하게 되었다.
2. 스프링 부트 API 오류 처리
서블릿 방식이 아니라 스프링 부트가 제공하는 기본 처리 방식을 확인해보자. 서블릿 방식을 이용하는 WebServerCustomizer 클래스는 다시 주석처리한다.
그리고 postman으로 요청을 보내면 된다. 다만, 요청에 의한 결과값을 json으로 받아올 수 있도록 Header의 Accept 부분을 application/json으로 설정하여 보내주어야 한다.
다시 과정을 생각해보자.
"api/members/ex" 주소로 요청 -> 아래 Controller에서 RuntimeException 발생 -> WAS -> WAS에서 에러 페이지 요청 -> Controller. 스프링부트 이므로 BasicErrorController-> BasicErrorController에서 자동 View 또는 API 매칭 후 반환
java/hello/exception/api/ApiExceptionController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable 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;
}
}
|
cs |
그래서, BasicErrorController에서 다음과 같은 json 형태의 응답값을 반환해준다.
만약, 요청 헤더의 Accept를 html/text 형식으로 요청하면 html View를 반환한다.
BasicErrorController의 내부 메서드를 보면, 2가지로 분기되어있는 것을 확인할 수 있다. 즉, 우리가 이전 섹션에서 서블릿을 이용할 때, "error-page/500" URL로 왔을 때 2가지로 분기한 것과 같이 컨트롤러의 메서드가 요청 헤더에 따라 분기되어 있는 것이다.
3. HandlerExceptionResolver
필요성
서버에서 에러가 발생했을 때, 스프링 부트가 제공하는 API 방식의 기본 오류 처리를 하면 오류에 대한 전달 내용을 변경하거나 상세히 전달하는 것이 어렵다. 예를 들어서 사용자의 입력이 잘못되어 IllegalArgumentException이 발생하는 경우를 가정해보자. "bad" url로 요청이 오면 해당 예외와 함께, 클라이언트의 잘못이므로 400 에러로 표기하고 싶은 경우이다.
java/hello/exception/api/ApiExceptionController.java의 일부
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
if(id.equals("bad")) {
throw new IllegalArgumentException("잘못 입력된 값");
}
...중략
}
|
cs |
응답 결과
500 에러가 응답값으로 오는 것을 확인할 수 있다. 이를 해결하기 위해 다음 섹션의 HandlerExceptionResolver를 사용한다.
HandlerExceptionResolver 작동 방식
HandlerExceptionResolver는 서버에서 인식한 에러를 해결하고 정상적인 응답으로 표시할 수 있도록 해준다. 에러 발생 시 스프링에서 적용하는 스프링 인터셉터의 단계를 다시 떠올려보자. 핸들러에서 예외가 발생하면, postHandle은 호출되지 않고, WAS 쪽으로 예외가 전달된다고 배웠다.
그렇기 때문에, 따로 예외처리가 되지 않고 바로 WAS 쪽으로 넘어가서 500 에러로 표기되는 것이다. 이 과정의 중간에 HandlerExceptionResolver가 끼어들어서, 발생한 예외를 처리하고, WAS로 전달할 방식을 제어하는 개념을 적용한다.
ExceptionResolver를 적용하면 아래 도식처럼 변경된다.
HandlerExceptionResolver 적용
HandlerExceptionResolver를 implements 받는 MyHandlerExceptionResolver를 작성한다.
response.sendError 구문
우리가 의도했던대로, IllegalArgumentException이 발생한 경우 즉 핸들러에서 Exception이 발생한 경우 강제로 서블릿의 response.sendError를 추가한다. 이렇게하면, 서블릿이 리턴된다. 이후 WAS가 서블릿 오류 페이지를 찾아서 다시 내부 호출하게 되며, 스프링 부트의 기본 설정에 의한 "/error"가 호출되어 에러 정보를 클라이언트에서 확인할 수 있는 것이다.
return new ModelAndView() 구문
이 구문에서 빈 ModelAndView 객체를 반환하였다. 이렇게 빈 객체를 반환하면 뷰를 렌더링하지않고, WAS로는 정상흐름으로 인지된다.
return null 구문
try 구문에 걸리지 않고 IllegalArgumentException이 아닌 다른 에러로 오는 경우, null이 반환되도록 했다. 이 경우에는 다음 ExceptionResolver를 찾아서 실행한다. 더 이상 ExceptionHandler가 없으면 발생한 예외를 서블릿 밖으로 전달하여 WAS에 그대로 전달된다. 따라서 500 기본 에러가 발생하게 된다.
※ try - catch 구문은 response.sendError 시 발생하는 IOException 때문에 적용된 것이다.
java/hello/exception/resolver/MyHandlerExceptionResolver.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
log.info("call resolver", 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;
}
}
|
cs |
빈 등록 : HandlerExceptionResolver
extendHAndlerExceptionResolvers 메서드를 Override 해준다.
java/hello/exception/WebConfig.java
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@Configuration
public class WebConfig implements WebMvcConfigurer {
...중략
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
...중략
}
|
cs |
정리
정리하자면, HandlerExceptionResolver는 반환값에 따라 동작이 다르게 된다.
- 빈 ModelAndView : 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다.
- ModelAndView 지정 : View, Model 정보를 직접 지정하여 반환하면 뷰를 렌더링 한다.
- null 반환 : 다음 ExceptionHandler를 실행하며, ExceptionHandler가 더 없으면 예외를 서블릿 밖으로 던진다.
이에 따라 예외 상태 코드를 변환하거나, 뷰를 반환하여 클라이언트에게 에러 페이지를 보여주거나, response.getWriter().println("...") 등으로 HTTP 응답 바디에 직접 데이터를 넣어주어 API 방식으로 처리할 수 있게 된다.
4. 예외 바로 처리하기
HandlerExceptionResolver에서 예외 처리 후 응답
HandlerExceptionResolver가 에러를 받아와서 처리를 한다고 해도, response.sendError를 통해서 서블릿에 전달하고, 그 전달된 정보를 WAS가 읽어서 다시 내부 호출로 BasicErrorController까지 가는 구조였다.
WAS <- 서블릿 <- Handler(예외 발생)
WAS가 서블릿의 .sendError 인지 -> 서블릿 -> BasicErrorController -> View
굳이 이럴 필요가 없다. 예외 발생 후 서블릿에 전달 전, HandlerExceptionResolver에서 바로 WAS에 Http 응답 정보를 쏴주면 된다.
바로 처리 후 응답 해보기
예외를 하나 만들어서, 해당 예외는 바로 응답이 되도록 만들어본다. RuntimeException을 상속받고, 모든 메서드를 Override 한다.
java/hello/exception/exception/UserException.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
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);
}
}
|
cs |
다음으로 ApiController에서 "user-ex" 매핑 정보를 추가한다.
java/hello/exception/api/ApiExceptionController.java의 일부
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
if(id.equals("bad")) {
throw new IllegalArgumentException("잘못 입력된 값");
}
if (id.equals("user-ex")) {
throw new UserException("사용자 오류");
}
...중략
}
|
cs |
예외를 직접 처리하는 HandlerExceptionResolver 작성
이제 예외를 직접 처리하는 HandlerExceptionResolver를 작성해보자. 이전에 작성했던 HandlerExceptionResolver와 크게 다르지 않다.
그러나, 위에서 언급한 바와 같이 response.sendError() 구문을 사용하는 것이 아니라 response에 직접 값들을 넣어주는 것을 확인할 수 있다. 그리고 "application/json" 타입으로 Header의 Accept 속성이 온 경우 빈 ModelAndView를 넘겨서 Exception이 전달되도록 했고, 그게 아닌 경우 View 경로를 넘겨서 이전에 작성했던 스프링부트의 기본 에러 페이지 경로를 호출할 수 있도록 하였다.
objectMapper는 errorResult로 작성된 객체 정보를 String으로 변환하여 response의 Body에 넣어주기 위해서 사용하였다.
java/hello/exception/resolver/UserHandlerExceptionResolver.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
@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 {
return new ModelAndView("error/500");
}
}
} catch (IOException e) {
log.error("resolver ex", e);
}
return null;
}
}
|
cs |
요청을 보내보면, 예외의 클래스 정보와 Message 정보가 담겨서 Response로 출력된 것을 볼 수 있다.
그리고 콘솔창에서도, 실제 서버에서 DispatcherServlet 등을 거쳐서 복잡하게 Error를 출력하는 것이 아니라, UserException 발생 -> UserHandlerExceptionResolver -> Response로 바로 응답으로 처리된 것을 확인할 수 있다.
참조
1. 인프런_스프링 MVC 2편 - 백엔드 웹개발 핵심 기술_김영한 님 강의
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2
'Programming-[Backend] > Spring' 카테고리의 다른 글
[스프링 웹MVC-2] 18. 타입 컨버터 - ConversionService (2) | 2021.09.15 |
---|---|
[스프링 웹MVC-2] 17. API 예외 처리 - 스프링 ExceptionResolver (0) | 2021.09.13 |
[스프링 웹MVC-2] 15. 예외 - 인터셉터와 스프링 부트의 오류 페이지 (0) | 2021.09.08 |
[스프링 웹MVC-2] 14. 예외 - 서블릿 오류 페이지 (0) | 2021.09.06 |
[스프링 웹MVC-2] 13. 로그인 - 스프링 인터셉터, ArgumentResolver 활용 (0) | 2021.09.05 |