API 예외 처리
HTML 페이지의 경우 지금까지 설명했던 것 처럼 4xx, 5xx와 같은 오류 페이지만 있으면 대부분의 문제를 해결할 수 있다. 그런데 API의 경우에는 생각할 내용이 더 많다.
오류 페이지는 단순히 고객에게 오류 화면을 보여주고 끝이지만, API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.
지금부터 API의 경우 어떻게 예외 처리를 하면 좋은지 알아보자.
API도
오류 페이지(객체)에서 설명했던 것 처럼
처음으로 돌아가서 서블릿 오류 페이지(객체) 방식을 사용해보자.
(스프링부트에서 제공하는 기본에러페이지,BasicErrorController를 사용하지않고,
서블릿만 이용한 방법을 해보자
(직접 에러페이지(객체)를 생성, 등록하고 에러페이지(객체)의 경로를 request하는 Controller까지 직접 만들어서 뷰를 렌더링하고 응답했었다.)
그 방법을 이용해서 API서버니까 Controller에서 뷰말고 JSON을 응답해보자.)
(예외가 발생하면 was까지 예외가 전달되고, was는 해당 예외가 매핑된 에러페이지(객체)를 찾아서 그 경로로
request(요청)을 한다. 그 request를 받아 처리하는 컨트롤러에서 API서버니까 Controller에서 뷰말고 JSON을 응답해보자)
WebServerCustomizer
@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
@Override
public void customize(ConfigurableWebServerFactory factory) {
ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404"); //404에러가 났을경우 /error-page/400의 경로를 호출한다.( 서버주소 + /error-page/400의 URL을 요청한다는뜻)
ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
ErrorPage errorPageEX = new ErrorPage(RuntimeException.class, "/error-page/500"); //RuntimeException가 발생했을경우에도 설정가능
factory.addErrorPages(errorPage404, errorPage500, errorPageEX); //에러페이지들을 등록한다.
}
}
해당 파일을 다시 스프링빈으로 등록시켜서, 에러페이지들을 등록한다.
이제 WAS에 예외가 전달되거나, response.sendError() 가 호출됬을때 예외와 맞는 에러페이지가 등록되어있다면
해당 에러 페이지 경로가 호출(request)된다.
ApiExceptionController - API 예외 컨트롤러
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable String id) {
if (id.equals("ex")) { // ex라는 아아디가 반환되면
throw new RuntimeException("잘못된 사용자"); // 예외를 발생시켲고
}
return new MemberDto(id, "hello" + id); // 아니라면 멤버를 반환해준다.
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
단순히 회원을 조회하는 기능을 하나 만들었다.
예외 테스트를 위해 URL에 전달된 id 의 값이 ex 이면 예외가 발생하도록 코드를 심어두었다.
( 경로변수의 값으로 "ex"가 들어오면 예외발생하는 api이다.)
결과확인
id를 spring으로 (정상 로직 일때)
컨트롤러메소드(HandlerMethod)에서 반환을 객체로 해주고,
해당 컨트롤러는 @RestController이므로 httpmessageConverter로 인해 객체가 json으로 변환되어
응답메시지바디에 들어가게 되서 이런 결과를 확인할 수 있다.
id가 ex일때 ( 예외가 발생하게끔)
오류가 발생하면 우리가 미리 만들어둔 오류 페이지 HTML이 반환된다.
(그 이유는 RuntimeException를 처리할 에러페이지(객체)를 만들어서 등록해놨기 때문이다.
그래서 RuntimeException가 발생시 해당 에러페이지(객체)의 경로를 request해서
그 경로를 매핑한 핸들러메소드(컨트롤러의 메소드)가 실행됬기 때문이다.
이것은 기대하는 바가 아니다. 클라이언트는 정상 요청이든, 오류 요청이든 JSON이 반환되기를 기대한다.
웹 브라우저가 아닌 이상 HTML을 직접 받아서 할 수 있는 것은 별로 없다
웹브라우저와 통신하는게 아니고 (반환으로 뷰를 줘야하는게 아니고)
API서버로서 JSON데이터로 주고받아야하니까 예외가 발생했을때도 JSON이 반환되야한다.
문제를 해결하려면 오류 페이지 컨트롤러도 JSON 응답을 할 수 있도록 수정해야 한다.
ErrorPageController - API 응답 추가
@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); // ERROR_EXCEPTION = "jakarta.servlet.error.exception";
//WAS 는 오류 페이지를 단순히 다시 요청(request)만 하는 것이 아니라, 오류 정보를 request 의 attribute 에 추가해서 넘겨준다.
//그 오류정보중 예외를 꺼내서 객체에 담았다.
result.put("status", request.getAttribute(ERROR_STATUS_CODE)); //오류정보중 상태코드를 꺼내서 Map에 넣어준다.
result.put("message", ex.getMessage());
//아래 코드는 상태코드를 받아오는 코드이다. 이 클래스 맨위에 상수로 지정해놓은값들이 RequestDispatcher안에 동일하게 들어있다.
// 나중에 사용할때는 이런식으로 가져오면 될듯하다.
Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
// Integer statusCheck = (Integer) request.getAttribute(ERROR_STATUS_CODE); <--- 결론적으로 이 코드와 같은코드이다.
return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
}
보면 Mapping정보는 같다. 하지만 produces라는 조건이 추가되어있다. 이 조건으로 인해
어떤경우는 위의 메소드가 실행되어 html이 응답으로 나가고
어떤경우는 아래 메소드가 실행되어 JSON으로 응답이 나가게 된다.
아래를 참고하면 요청매핑할때 조건들에 대해 알수있다.
produces가 있으면 이 메소드는 해당 ContentType(MediaType)으로 반환한다는것이다.
클라이언트는 헤더에 Accept라고 자신이 받을 수 있는 ContentType(MediaType)에 대해 적어놓는데
HTTP Request가 왔을때 요청의 헤더중 Accept를 확인해서 application/json을 받을 수 있다면
produces가 application/json인 메소드가 실행"될 수있다."
실행되는 메소드는 조건에 따라 다르다.
(Accept가 */* 인데 html이 반환되는 메소드가 실행되는 이유는
produces 설정하지않은 메소드와 produces를 설정한 매핑url정보가 같은 2개의 메소드가 존재한다면
produces가 설정된 메소드는 HTTP Request의 accept가 produces로 설정한 ContentType(MediaType) 일때만
실행되고 나머지 ContentType들은 produces 설정하지않은 메소드가 실행된다.
결과 확인
이렇게 함으로서, html응답이 필요한경우, JSON응답이 필요한경우를 다 처리할 수 있었다.
API 예외 처리 - 스프링 부트 기본 오류 처리
스프링부트에서 제공하는 기본에러페이지(객체, 경로는 /error)와 기본에러페이지 경로를 requestMapping한 Controller인 BasicErrorController를 이용해서 API 예외를 처리해보자.
(API 예외는 응답도 JSON으로 나가게끔 해야한다.)
각 예외에 대한 에러페이지를 만들어놓은것을 스프링빈으로 등록하지않는다. (스프링 부트 기본 오류 처리를 보기위함)
이렇게 해놓으면 따로 등록한 에러페이지(객체)가 없으므로 예외가 발생시 기본 에러페이지(객체)가 선택될것이다.
그러면 기본 에러페이지(객체)의 경로인 /error 경로에 매핑된 BasicErrorController가 호출될것이다.
BasicErrorController안에는 매핑 핸들러메소드가 2개가 있다.
하나는 Accept가 text/html인 경우 ModelAndView를 반환하여 뷰를 렌더링 해주기 위함이고
나머지 하나는 그 이외의 ContentType인 경우 ResponseEntity를 이용해 JSON형식으로 반환해주기 위함이다.
(HttpEntity, ResponseEntity는 http응답메시지바디에 JSON형식으로 값을 넣어주는 역할로서, @ResponseBody가 없어도 HttpMessageConverter로 인해 값을 JSON으로 변환시켜 응답메시지바디에 넣을 수 있다. (값을 넣어주는 방법은
HttpServletResponse 객체의 Writer를 이용하는것같다., 그리고 서블릿 컨테이너의 서블릿이 HttpServletResponse의 내용을 이용해서 http응답메시지를 생성해준다.)
API 예외 처리도 스프링 부트가 제공하는 기본 오류 방식을 사용할 수 있다.
( JSON형식으로 반환해주는 매핑 핸들러메소드를 이용해서)
스프링 부트가 제공하는 BasicErrorController 코드를 보자.
/error 라는 동일한 경로를 처리하는 errorHtml() , error() 두 메서드를 확인할 수 있다.
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {
BasicErrorController자체에 /error라는 경로가 매핑되어있다 ( 기본 에러 페이지 경로, 수정가능함)
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
errorHtml() 은 produces = MediaType.TEXT_HTML_VALUE 라는 매핑조건을 이용해서
클라이언트 요청의 Accept 해더 값이 text/html 인 경우에는 errorHtml() 을 호출해서 ModelAndView를 반환한다.
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
error()은 그외 ContentType 경우에 호출되고
ResponseEntity를 이용하여 값을JSON 데이터를 변환해서 HTTP Body에 넣고 응답한다.
(값을 넣어주는 방법은HttpServletResponse 객체의 Writer를 이용하는것같다., 그리고 서블릿 컨테이너의 서블릿이 HttpServletResponse의 내용을 이용해서 http응답메시지를 생성해준다.)
실행 결과
오류를 발생시킬때
@Slf4j
@Controller
public class ServletExController {
@GetMapping("/error-ex")
public void errorEx() {
throw new RuntimeException("예외 발생!");
}
http://localhost:8080/error-ex URL를 이용할것이다.
일반적으로 웹브라우저에서 저 URL를 호출하면
다음과 같은 화면이 나온다. 그 이유는
웹브라우저는
Request 헤더의 Accept를 보면 가장먼저 text/html가 나와있다. ( 우선순위가 높다)
그러면 BasicErrorController안에 있는 매핑핸들러메소드중 ModelAndView를 반환하는 메소드가 실행될것이다.
그 메소드는 뷰의 논리 이름으로 예외에 대한 상태코드(http status code)랑 기본 에러페이지 경로인 (/error)를 합쳐서
만들어준다. 그래서 따로 에러페이지 등록하지않고, http status code를 가지는 html 파일을 만들어주면
해당 뷰를 찾아서 알아서 렌더링해서 응답해줬던 것이다. (여기서는 500.html이 찾아져서 렌더링된 결과)
PostMan으로 Accept를 수정해가며 요청한다면?
1. accept가 application/json 일때
BasicErrorController의
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
는 실행되지않는다. (Accpet이 application/json 이므로)
( 매핑조건을 가지지않는 일반 메소드와 produces 매핑조건을 가진 메소드가 둘다 존재한다면 produces 매핑조건을 가진 메소드는 http요청헤더의 Accept가 produces에 적힌 ContentType(MediaType)가 일치해야 실행되고 나머지 타입들은 매핑조건을 가지지않는 일반 메소드가 실행된다.)
그러므로
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
HttpStatus status = getStatus(request);
if (status == HttpStatus.NO_CONTENT) {
return new ResponseEntity<>(status);
}
Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
return new ResponseEntity<>(body, status);
}
가 실행되서 응답으로 JSON형식으로 나가게 된다. (반환이 ResponseEntity니까)
그리고 응답된 JSON 내용을 보면
이런 구성을 가지고있는데, 이건 스프링부트에서 지원하는 기본 구성이다. ( 예외에 대한 JSON응답 기본 틀)
[BasicErrorController는 에러에 대한 정보를 넣어서 JSON을 만들어준다.]
(was에서 에러페이지(객체)의 경로를 request(요청)할때 HttpServletRequest안에 에러정보를 담아서 주기 때문에
BasicErrorController에서 그 에러정보를 꺼내 다음과 같은 기본구성JSON응답을 만들어 보여주는것이다.)
[ 이 값들은 BasicErrorController가 담아주는것이고, 스프링설정으로 값을 더 추가할 수 있다. 밑에 나옴]
2. accept가 text/html 일때
당연히 웹브라우저에서 요청하는것처럼 html로 응답이 온다 (매핑조건이 없는 기본매핑 메소드가 실행됬기때문)
3. accept가 */* 일때
*/* 는 어떤 ContentType이라도 받는다는 의미이다.
( 매핑조건을 가지지않는 일반 메소드와 produces 매핑조건을 가진 메소드가 존재한다면 produces 매핑조건을 가진 메소드는 http요청헤더의 Accept가 produces에 적힌 ContentType(MediaType)가 일치해야 실행된다.)
라고 했으니 produces 매핑조건을 가진 메소드와 일치한 Accept를 가진 Http요청이 아닌 나머지 http요청은
매핑조건을 가지지않는 일반 메소드
즉,
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
HttpStatus status = getStatus(request);
if (status == HttpStatus.NO_CONTENT) {
return new ResponseEntity<>(status);
}
Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
return new ResponseEntity<>(body, status);
}
이 메소드가 처리하게 된다.
BasicErrorController는 에러에 대한 정보를 넣어서 JSON을 만들어준다.
그래서 스프링설정을 좀 추가하면 JSON에 나타나는 에러정보를 더 확인할 수 있다.
(이전에 에러페이지할때 했던것과 동일하다. 거길 참고할것)
server.error.include-exception=true
server.error.include-message=always
server.error.include-stacktrace=on_param
server.error.include-binding-errors=always
정보가 추가된것을 확인할 수 있다.
물론 오류 메시지는 이렇게 막 추가하면 보안상 위험할 수 있다. 간결한 메시지만 노출하고, 로그를 통해서 확인하자.
(서버 개발자는 개발중 오류는 로그로 확인하면 되니까)
예외에 대한 Html 페이지처리는 BasicController
API서버에서 예외에 대한 처리는 @ExceptionHandler
BasicErrorController를 확장하면 JSON 메시지도 변경할 수 있다.
(ErrorController 인터페이스를 상속 받아서 구현하거나 BasicErrorController 상속 받아서 기능을 추가하면됨)
그런데 API 오류는 조금 뒤에 설명할 @ExceptionHandler 가 제공하는 기능을 사용하는 것이 더 나은 방법이므로
지금은 BasicErrorController 를 확장해서 JSON 오류 메시지를 변경할 수 있다 정도로만 이해해두자.
스프링 부트가 제공하는 BasicErrorController는 HTML 페이지를 제공하는 경우에는 매우 편리하다.
4xx, 5xx 등등 모두 잘 처리해준다. 그런데 API 오류 처리는 다른 차원의 이야기이다.
API 마다, 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해야 할 수도 있다.
예를 들어서
회원과 관련된 API에서 예외가 발생할 때 응답과, 상품과 관련된 API에서 발생하는 예외에 따라
그 결과가 달라질 수 있다.
결과적으로 매우 세밀하고 복잡하다.
따라서 이 방법은 ( == BasicErrorController를 이용해서 에러페이지를 제공하는것 )
HTML 화면을 처리할 때 사용하고,
API 오류 처리는 뒤에서 설명할 @ExceptionHandler 를 사용하자.
그렇다면 복잡한 API 오류는 어떻게 처리해야하는지 지금부터 하나씩 알아보자.
'인프런 > 스프링 MVC 2편' 카테고리의 다른 글
18) API 예외처리 (3) (0) | 2023.02.12 |
---|---|
17) API 예외 처리(2) (0) | 2023.02.10 |
15) 예외 처리와 오류 페이지 (0) | 2023.02.09 |
14) 로그인 처리2 - 인터셉터 , ArgumentResolver 활용 (0) | 2023.02.08 |
13) 로그인 처리2 - 필터 (0) | 2023.02.08 |
댓글