개발자로 후회없는 삶 살기

spring PART.API 예외처리 본문

[백엔드]/[spring | 학습기록]

spring PART.API 예외처리

몽이장쥰 2023. 4. 27. 00:36

서론

※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.

https://www.inflearn.com/roadmaps/373

 

우아한형제들 최연소 기술이사 김영한의 스프링 완전 정복 - 인프런 | 로드맵

Spring, MVC 스킬을 학습할 수 있는 개발 · 프로그래밍 로드맵을 인프런에서 만나보세요.

www.inflearn.com

 

본론

- api 예외 처리

예외와 오류 페이지를 처리하는 경우는 2가지가 있습니다. 하나는 지금처럼 웹 페이지를 처리하는 경우로 그냥 404 페이지 예쁘게 보여주면 됩니다. 근데 복잡한 문제는 api 오류 처리입니다. 지금 문제가 생겼을 때 json 스펙을 정해서 어떤 형식으로 보낼 건지 정의하고 json을 만들어야 합니다.


- 목표

api 예외는 어떻게 처리해야 할까요? 화면 예외는 그냥 예쁜 뷰만 보여주면 됩니다. 근데 api 예외의 경우 생각을 더 많이 해야합니다. 안드로이드나 아이폰에서 서버로 예외를 호출할 수 있고 기업 간에 시스템 통신도 할 수 있기에 각 오류 상황에 맞는 오류 응답 스펙을 api를 호출하는 곳과 서버와 서로 약속을 해야하고 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");
        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);
    }
}

// 예외 호출 웹 앱
@Slf4j
@RestController
public class ApiExceptionController {
    @GetMapping("/api/members/{id}")
    public MemberDTO memberDTO(@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;
    }
}

얘가 예전에 예외가 was에 전달되면 여기 등록한 예외 페이지 경로로 호출해주는 것이었습니다. api 패키지를 만들고 예외를 호출할 컨트롤러를 만듭니다. api이기 때문에 rest로 만들고 맵핑 메서드를 만들고 @Data DTO 클래스를 하나 만들고 이것을 주고 받을 것입니다. id를 url로 받고 만약 id가 'ex'와 같으면 런타임 에러를 보낼 것이고 그게 아니면 DTO 객체를 반환할 것입니다.

 

-> 실행

postman을 열고 맵핑 url을 호출하면 spring으로 id를 주면 DTO를 json으로 잘 반환됩니다. rest니깐 뷰가 아니라 responsebody로 응답 바디에 객체가 json으로 바뀌어서 반환됩니다.

 

예외를 위해서 ex를 id로 하면 html이 나옵니다. 이러면 안됩니다. 우리는 예외가 발생해도 json으로 예외를 보내줘야합니다.

 

// WebServerCustomizer 
ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");

// 오류 페이지 컨트롤러
@RequestMapping("/error-page/500")
public String errorPage500(HttpServletRequest request, HttpServletResponse response) {
    log.info("errorPage 500");
    printErrorInfo(request);
    return "error-page/500";
}

html은 이전에 웹 브라우저에 예쁜 뷰를 보여주려고 WebServerCustomizer에 등록한 것이 예외가 발생하면 was에서 에러 페이지 컨트롤러를 호출한 것이 뜬 것입니다.

 

그니깐 서버 입장에서는 뷰 페이지를 보여주는 것이 정상입니다. 근데 클라 입장에서는 api json 통신해야하는데 html이면 의미없는 데이터입니다. 우리는 오류 페이지 컨트롤러도 json 응답을 할 수 있도록 수정해야 합니다. 그니깐 이전에 만든 오류 페이지를 보여주는 컨트롤러에 json을 반환하도록 새로운 맵핑 메서드를 만들면 됩니다.

 

 

-> 오류를 보여주는 새로운 맵핑 메서드

@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletRequest request, HttpServletResponse response) {
    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));
}

produces에 미디어 타입에 json를 쓰면 클라가 보내는 req의  accept 헤더에 타입에 따라서 클라가 accept를 json이라고 하면 같은 url이라도 지금 미디어 타입을 json이라고 한 맵핑 메서드가 먼저 호출됩니다. 같은 맵핑 호출인데 json을 반환하기 위해 이렇게 하는 것입니다. 실제 basic에서도 같은 맵핑 url인데 accept에 따라서 호출하는 맵핑 메서드가 달라집니다.

 

json을 반환할 것이라서 ResponseEntity를 반환하고 맵을 제네릭으로 할 것입니다. > map을 만들고 result에 값을 막 넣으면 이게 json으로 변환이 될 것입니다.

 

req에서 위에 상수로 정의한 에러를 받아서 ex로 캐스팅하고 result에 키는 status, 벨류는 위에 상수로 정의한 에러 상태 코드를 넣습니다. 또 message도 예외에서 받은 메세지를 넣어줍니다. ResponseEntity에 http 상태 코드도 req에서 받아서 넣어줍니다. 지금 이 작업은 맵핑 메서드에서 json으로 반환할 오류 메세지를 만드는 과정입니다.

 

이 map을 responseEntity에 넣어서 return하면 rest니깐 바로 바디에 json으로 박힙니다.

 

이제 다시 postman을 쓰면 잘 동작합니다. 메세지는 예외에서 꺼낸 메세지이고 이 메세지는 예전에 뷰에서 예외를 터트리는 컨트롤러로 인해 예외가 발생했을 때 was에서 다시 등록된 예외 뷰를 보여주는 컨트롤러를 호출해서 예쁜 페이지를 보여줬는데 그때 그 예외를 발생시키는 컨트롤러처럼 아까 위에서 만든 맵핑 메서드에서 Runtime 예외를 보낼 때 넣은 "잘못된 사용자"입니다.

 

> 이게 was가 다시 /error-page/500을 호출해서 뷰를 보여주는 컨트롤러를 호출해야 하는데 이제는 미디어 타입을 봐서 json을 반환하는 맵핑 메서드를 호출해서 json을 반환하는 것입니다. 중요한 것은 map에 메세지와 status가 put되어서 나온 것입니다. 이렇게 그냥 map에 에러를 담고 보내면 되는 것인데 스펙을 정하는게 힘든 것입니다. 또한 등록하고 호출하는 것이 너무 복잡한데 스프링으로 자동화 해보겠습니다.

 

- 스프링 부트 기본 오류 처리

api 예외 처리도 스프링이 basic 컨트롤러를 사용하여 단순화 할 수 있습니다. 다시 서블릿 오류 페이지를 등록하는 WebServerCustomizer 주석 처리합니다. api 방식도 스프링이 자동으로 basic 컨트롤러로 웹 어플에서 예외가 발생하면 예외 메세지도 다 등록해주고 예외 메세지를 보여주는 컨트롤러도 다 /error로 호출해주는 것을 제공합니다.

 

> 뷰 예외처리를 좀 더 강화해보자면 서블릿과 스프링이 예외가 발생하는 것은 웹 어플에서 컨트롤러가 하는 것은 똑같습니다. 근데 서블릿은 WebServerCustomizer로 서블릿이 뜰 때 was에 예외가 오면 어떤 뷰 페이지를 보여주는 컨트롤러를 호출할 지 다 등록을 해야했고 그에 맞는 뷰를 보여주기 위한 컨트롤러도 다 작성해야했는데 스프링에서는 등록도 알아서해주고 뷰 호출 컨트롤러도 알아서 해줬습니다. 단 2가지만 기억하면 됩니다. 하나, 웹 앱에서 오류가 발생하는 것은 똑같습니다. 둘, 스프링이 등록과 뷰 페이지 보여주는 컨트롤러를 작성하고 호출하는 것을 자동화 해줍니다.

 

-> 스프링 방식

ex로 예외를 발생하면 아까 ex가 id로 오면 런타임 오류를 한다고 했으니 예외가 터질 것입니다. 그러면 원래는 was까지 가서 등록된 것을 봐서 에러 메세지를 보여주는 컨트롤러를 호출해야 했습니다. 근데 스프링에서는 등록하는 것을 basic이 다 해줍니다. 실행해보면 basic이 json 형식의 데이터를 딱 내려줍니다.

 

> 이제 WebServerCustomizer로 하는 서블릿 방식을 잊고 생각해보면 accept를 text/html로 하면 예전에 만든 500 html을 보여주고 app/json이라고 하면 json 오류를 반환하도록 다 basic에 개발이 되어있습니다.

 

basic에 보면 html이면 이거를 실행하고 모델 뷰를 반환하고 그게 4xx이고

 

그 외 나머지인 경우 responseEntity를 씁니다. 이게 http 메세지 바디에 데이터를 직접 넣어주는 것입니다.

 

@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletRequest request, HttpServletResponse response) {
    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));
}

보면 map에 데이터를 담아서 반환하는 우리가 작성한 was가 호출하는 예외를 보여주는 컨트롤러와 똑같습니다. basic이 이런 것도 다 해주는 것입니다.

 

-> 설명

1. errorHtml : produce가 html이라서 클라의 요청이 accept에 text/html인 경우 호출되어서 모델 뷰를 반환하고 이 뷰가 4xx입니다.

2. error : 그 외 경우 호출되고 json을 바디에 반환합니다. ★ 결론은 예외가 터지면 요청 accept가 html이면 4xx가 되고 json이면 json이 반환되도록 스프링이 다 해줍니다. was에서 에러 메세지를 등록해주고 /error를 호출해서 basic에서 맵핑 메서드를 호출해주는 것까지 다 스프링이 해주는 것입니다.

-> 정리

basic은 뷰를 반환할 때는 진짜 편리합니다. 그냥 4xx만 만들면 끝이었습니다. 근데 api 오류는 완전 다른 차원의 얘기입니다. 왜냐면 api마다 예외마다 서로 다른 응답 결과를 출력해야할 수도 있습니다. 예를 들어서 회원과 관련된 api에서 예외가 발생하는 응답과 상품 관련 예외 응답은 다른 스타일로 json을 정의하고 스펙을 다르게 정의해야할 수도 있는 것입니다. 따라서 baisc은 화면에서만 사용하고 api는 exceptionHandler를 사용합니다.

 

- HandlerExceptionResolver

예외가 발생해서 was까지 갔다면 http 상태코드가 500이 됩니다. (서버 내부 예외는 무조건 500입니다.) 근데 발생하는 예외에 따라서 뷰에서 한 것처럼 4xx, 400 등 다른 상태 코드로 처리하고 싶습니다. 그리고 오류 메세지 등도 api마다 다르게 처리하고 싶습니다.

 

- 상태 코드 변환

@GetMapping("/api/members/{id}")
public MemberDTO memberDTO(@PathVariable("id") String id) {
    if(id.equals("ex")) {
        throw new RuntimeException("잘못된 사용자");
    }
    if(id.equals("bad")) {
        throw new IllegalArgumentException("잘못된 사용자");
    }

    return new MemberDTO(id, "hello" + id);
}

예를 들어서 ILLegalArgumentException을(사용자가 argument를 잘못 보낸 상황) 처리하지 못해서 컨트로러 밖으로 넘어가는 일이 발생하면 HTTP 상태 코드를 400으로 처리하고 싶습니다. 즉, 서버가 아니라 클라가 잘못한 것으로 처리하고 싶습니다. 근데 이런 서버 내부 예외는 무조건 500으로 처리되어서 이걸 바꾸고 싶습니다.

 

맵핑 메서드에 PathVariable에 사용자가 id에 bad라고 절대로 넘기면 안 된다고 스펙으로 설정한 경우라고 생각하면 400으로 보내고 싶은 것입니다.

 

postman으로 bad라고 보내면 당연히 500이라고 뜹니다. 예외가 서버내부, 컨트롤러 내부에서 터지면 was 입장에서는 무조건 500입니다. 스프링은 컨트롤러 밖으로 예외가 던져진 경우 예외를 해결하고 동작을 새로 정의할 수 있는 방법을 제공합니다. HandlerExceptionResolver를 쓰면 됩니다.

 

-> 그림
1) 적용 

was에서 디스패쳐 서블릿을 호출하고 프리 호출하고 어댑터에서 핸들러 호출하고 포스트 안 되고 에프터 호출되고 was에게 예외가 전달되고 500으로 예외를 만들고 json으로 basic이 반환합니다.

 

2) 적용 후

컨트에서 예외가 전달되어서 포스트는 당연히 호출 안되고 대신에 ER이 있으면 이게 호출이 됩니다. 그래서 예외를 해결하려고 시도를 합니다. 해결이 되면(if instanceof) 그냥 랜더하고 에프터를 호출하고 정상 응답으로 나갈 수 있습니다. ER은 예외를 해결해주는 해결사라고 보면됩니다. 

 

- ER 인터페이스

public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            if(ex instanceof IllegalArgumentException) {
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
                return new ModelAndView();
            }
        } catch (IOException e) {
            
        }
        return null;
    }
}

새로운 클래스를 만들고 resolveException 메서드를 구현해야 합니다. req, resp, handler, ex를 받고 모델 뷰를 반환합니다. 이런 ex이 넘어왔어 그러면 예를 보고 정상적인 모델 뷰로 반환하는 것입니다. (해결사) 

> 그림을 보면 컨트롤러에서 예외가 발생한 후에 이 예외를 ER에 넣어주는데 만약 ex가 ill이면 400 오류로 응답할 거야라고 정의할 것입니다. resp.sendError로 ill 500에러를 여기서 덮어버리는 것입니다. sendError는 상태코드와 에러를 넣을 수 있습니다. sendError가 IO예외를 받아서 try문으로 감싸고 모델 뷰를 반환합니다. 모델뷰를 빈 값으로 반환하면 정상정인 흐름으로 was까지 정상처리로 반환이 됩니다. > 이렇게 되면 예외를 먹어버리는 것 입니다. 그러다가 was까지 가면 resp에 400 에러로 온 것으로 was는 인지하는 것입니다. null로 리턴하면 원래 터졌던 500에러가 was로 갑니다.

 

@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
    resolvers.add(new MyHandlerExceptionResolver());
}

이것을 WebConf에 등록해야합니다. extendHandlerExceptionResolvers를 재정의해서 add하면 됩니다.

 

-> 실행

post man으로 호출해보면 400 에러로 바뀌었습니다. 

 

-> 설명

얘를 구현하면 디스패쳐 서블릿이 ER에게 해결할 수 있는지 시도해보라고 물어봅니다. er이 모델뷰를 정상반환하면 정상흐름으로 바뀝니다. 근데 모델 뷰에 새거라서 아무것도 없어서 에프터 호출되고 뷰 랜더링 안합니다. was는 근데 sendError가 호출되니 오류 등록된 것을 뒤지고 /error를 다시 호출합니다. 그렇게 400에러가 발생합니다. 이름 그대로 예외를 정상으로 해결하는 것이 목적입니다.

-> 반환값의 따른 동작 방식

1) 빈 모델 뷰 : 뷰를 렌더링하지 않고 정상흐름으로 was까지 갑니다.

2) 모델 뷰 지정 : 뷰와 모델을 입력하면 뷰를 랜더링합니다. ex) error/500

3) null : 다음 ER를 찾습니다. ER을 여러 개 등록할 수 있는데 만약 처리할 수 있는 ER이 없으면 그냥 처음에 발생한 예외가 was로 갑니다. ER을 3개 등록했는데 3개가 null을 반환하면 was까지 그냥 500으로 갑니다.

- ER 활용

1) 예외 상태 코드 변환 : sendError로 예외를 덮어 버리는 것으로 예외를 발생시키는 방법이 2가지로 실제 웹 앱에서 예외가 터지는 것과 sendError였는데 was에서 sendError가 있으면 was가 서블릿 오류 페이지나 api를 /error로 호출합니다. ER 또한 예외 api를 등록하는 것과 호출될 맵핑 메서드를 작성하는 것을 하지 않아도 동작하는 것을 보니 basic 방식을 사용하는 것입니다.

2) 뷰 템플릿 처리 : 모델 뷰를 체워서 새로운 오류 화면을 랜더링할 수 있습니다.

3) api 응답 처리 : resp.getWriter하면 http 응답 바디에 직접 데이터를 넣어주는 것도 가능하고 여기에 json을 넣으면 api 응답을 스펙에 약속한대로 가능합니다.

 

- HandlerExceptionResolver api 예외처리

지금까지 배운 ER을 쓰지 않은 기본 스프링이 제공하는 방식은 컨트에서 예외가 발생하면 was까지 가서 basic 컨트를 다시 호출하는데 이 과정이 너무 복잡합니다. ER이 발생하면 이런 복잡한 과정 없이 ER에서 정상처리로 다 끝을 낼 수 있습니다.

 

-> 사용자 지정 예외

public class UserException extends RuntimeException {

Runtime 예외를 상속받는 사용자 지정 예외를 만듭니다. 그리고 id를 받아서 예외를 발생시키는 컨트롤러(웹 앱)에 user-ex가 id로 사용되면 사용자 지정 예외를 발생 시킵니다.

 

> 실행해보면 당연히 user-ex를 id로 주면 500 오류가 뜹니다. 이 방법을 쓴 후 로그를 보면 was까지 갔다가 다시 /error로 basic으로 내려오고 또 맵핑 메서드가 실행이 되고 basic에서 json을 map으로 만들어서 결과를 내는 복잡한 결과를 보입니다. 그래서 이제는 깔끔하게 ER에서 끝을 내보겠습니다.

 

-> 끝내기

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) {
                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.getWriter().write(result);
                    response.setContentType("application/json");
                    response.setCharacterEncoding("utf-8");

                    return new ModelAndView();
                } else {
                    return new ModelAndView("error/500");
                }
            }
        } catch (IOException e) {

        }
        return null;
    }
}

새로운 ER을 구현합니다. 이것으로 사용자 예외를 was까지 안 가고 여기서 끝낼 것입니다. try문을 만들고 User 예외만 여기서 끝내도록 할 것이므로 만약 예외가 User면 처리합니다.

 

api 방식은 accept가 json인지 html인지에 따라 다르게 동작하는데 여기서 한 번에 처리할 수 있습니다. 400 에러로 줄것이기에 상태코드를 400으로 줍니다. accept 헤더를 꺼내서 json이면 map을 만들어서 서블릿에서 예외를 보여줄 맵핑 메서드를 만든 것처럼 put으로 어떤 예외가 발생했는지(getClass)와 메시지를 넣습니다. 이 map을 이제 resp에 넣어야합니다. 모델 뷰를 반환해하므로 응답 타입과 인코딩 타입 이런 것을 다 resp에 직접 set으로 셋팅을 해야합니다. (String이면 뷰를 반환하는 거라서 스프링이 다 해주는데 이 경우는 다 직접 set으로 넣어줘야합니다.)

 

getWriter를 하면 body에 직접 데이터가 들어가는데 map을 넣으려면 json을 string으로 바꿔야하기에 객체를 문자로 바꿔주는 objmapper가 필요합니다. 이렇게 하면 응답 바디에 json이 들어갑니다. 마지막으로 빈 모델로 리턴하면 예외는 먹어버리면서 정상처리가 되어서 was까지 resp이 전달이 됩니다. 그리고 resp에 write한 json이 출력됩니다. 그 외 html이 넘어오면 모델 뷰에 뷰 네임을 반환합니다. 이건 templates/error/500입니다.

 

-> 결과

 

같은 호출인데 html을 accept하면 error/500이 resp에 담기고 

 

json을 accept하면 만든 map이 String으로 바뀌어서 resp에 담깁니다. 이렇게하여 api 방식으로 통신을 할 때 서버간에 api 스펙을 약속을 해야한다고 했는데 지금 ex와 message로 약속을 하여 오류를 주고 받은 것 입니다.

 

로그가 아무것도 남지 않습니다. was로 가서 다시 컨테이너로 돌아오고 그런게 없고 여기서 끝이납니다. 컨트롤러에서 예외가 터져서 디스패쳐 서블릿이 ER에게 예외를 해결할 수 있냐고 물어봅니다. 해결을 해버리고 정상적인 모델 뷰를 반환해서 was까지 정상처리가 되고 sendError도 아닌 에러 코드도 덮어버려서 정상적으로 클라에게 response 메세지가 갑니다. 정말로 ER에서 해결을 해버린 것입니다. 그런데 이를 직접 구현하는 것이 상당히 복잡합니다. 지금부터 스프링이 제공하는 ER을 알아보겠습니다.

 

- 스프링이 제공하는 HandlerExceptionResolver

스프링이 ER을 기본으로 3개 등록해줍니다. 이 3개를 순서대로 컨트롤러 호출시 예외가 발생하면 ER을 돌려보고 해결할 수 있는지 시도합니다. 첫번째에서 처리가 되면 2, 3을 시도하지 않고 처리가 안 되면 2, 3 순서대로 시도합니다.

 

1. ResponseStatusExceptionResolver

http 상태 코드를 지정해줍니다. 예전에 500을 400으로 바꿀 때처럼 예만 붙여놓으면 자동으로 상태 코드를 바꿔줍니다. > 두가지 경우를 처리합니다. 예외에 @ResponseStatus가 달려있거나 ResponseStatusException 예외가 발생한 경우 처리해줍니다.

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException{
}

> 예외를 하나 만듭니다.  BadRequest입니다. 런타임을 상속받습니다. 얘가 터지면 원래는 500 예외가 터져야하는데 이 예외에 @ResonseStatus를 붙이고 code = 상태 코드 400을 붙이고 reasen을 오류 메세지로 줄 수 있습니다. 이전에 UserException 만든 것과 같은 것입니다.

 

@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
    throw new BadRequestException();
}

> 저거를 호출하기위해 api 맵핑 메서드를 하나 만듭니다. throw 아까 만든 badrequest 예외를 호출합니다. 

 

-> 실행

맵핑 메서드로 예외를 호출해보면 당연히 500이 떠야하는데 400 에러로 바뀝니다. BadRequestEx가 터지면 ResponseStatusExceptionResolver가 이 예외를 처리할 수 있나 시도합니다. 해결할 수 있어서 상태 코드를 400으로 바꾸고 sendError를 새로운 상태 코드로 호출하고 새로운 모델 뷰를 반환합니다.

 

그러면 was까지 올라가서 was에서 sendError로 basic의 /error를 호출해서 상태 400으로 가는 겁니다. 이 상태를 Not Found로 바꾸면 404에러가 뜹니다.

 

text/html로 하면 basic의 모델 뷰를 반환하는 맵핑 메서드를 호출합니다. 얘는 예외를 sendError로 바꿔서 재호출하는 것을 알아야합니다.

 

-> 추가 기능
1) 메세지 기능

@ResponseStatus(code = HttpStatus.NOT_FOUND, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException{
}

 

프로페티스에 server.error.include-message=always를 하면 json에 메세지가 담깁니다.

 

reason을 MessageSource에서 찾는 기능도 제공합니다. reason을 "error.bad"라고 하고 메세지 파일에 error.bad=잘못된 요청 오류입니다. 메시지 사용 하면 사용할 수 있습니다.

@ResponseStatus(code = HttpStatus.NOT_FOUND, reason = "error.bad")
public class BadRequestException extends RuntimeException{
}

이 핸들러가 호출될 때 메세지 소스를 뒤져서 reason에 넣습니다.

 

 

2) ResponseStatusException

@ResonseStatus는 개발자가 예외를 직접 만들고 직접 붙여야만 ResponseStatusExceptionResolver를 쓸 수 있는 것인데 라이브러리의 예외는 적용할 수 없습니다. 이때 사용합니다.

@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
    throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "error.bad", new RuntimeException());
}


새로운 컨트롤러를 만들고 ResponseStatusException를 발생시키고 404를 넣고 메세지를 넣고 진짜 라이브러리 예외를 넣으면 됩니다.

2. DefaultHandlerExceptionResolver

스프링 내부 예외를 처리합니다. 대표적으로 파라미터 바인딩 시점에 타입이 안 맞으면 스프링이 이때 typemismatchException을 터트리고 결과적으로 was에서 500 에러가 터집니다. 근데 이런 파라미터 바인딩 에러는 서버 잘못이 아니라 클라 잘못입니다. 따라서 400 에러를 보여야하는데 스프링은 DefaultHandlerExceptionResolver로 typemismatchException이 발생하면 500 오류가 아니고 자동으로 400 오류로 처리해줍니다.

 

-> 구현

@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data) {
    return "OK";
}

새로운 맵핑 메서드를 만들고 @RequestParam Integer data하고 url에 ?data=qqq를 하면 예외가 터집니다. 근데 결과를 보면 400 bad request가 뜹니다. 스프링 프레임워크가 이미 400으로 바꿔주는 겁니다.

로그만 봐도 DefaultHandlerExceptionResolver가 있습니다.

 

여기에 가보면 typemismatch를 감지하고 400에러로 바꿔줍니다. 또한 이 내부를 보면 거의 모든 예외가 다 들어있습니다. 스프링의 내부 예외를 다양하게 다 처리해주는 것이 DefaultHandlerExceptionResolver로 이고 예외가 터질 때마다 이 DefaultHandlerExceptionResolver가 api 스펙에 맞춰서 아까 500을 400으로 바꿔주는 것처럼 다 변환을 해 줍니다.

 

 

3. ExceptionHandlerExceptionResolver

HandlerExceptionResolver를 직접 쓰는 것은 어렵습니다. 해결사 역할은 하지만 모델 뷰를 반환하기에 바디에 데이터를 콱 넣는 api 방식에 잘 맞지 않습니다. 이를 위해서 스프링이 @ExceptionHandler를 만들었습니다. 최종적으로 이것을 쓰기 위해서 지금까지 학습을 한 것입니다.

 

-> HTML 화면 오류 vs API 오류

또한 뷰 오류를 제공하는 오류는 Basic을 쓰면 굉장히 쉽게 4xx만 만들면 됩니다. 근데 api는 각 시스템마다 응답의 모양도 다르고 스펙도 다르고 예외상황에 따라서 단순히 오류 화면을 보여주는 것이 아니라, 예외 상황에 따라 각각 다른 데이터를 출력해야할 수도 있습니다. 그리고 같은 예외라도 어떤 컨트롤러에서 발생했는가에 따라서 다른 예외 응답을 내려줘야할 수 있습니다. 한마디로 굉장히 세밀한 제어가 필요합니다.

 

> HandlerExceptionResolver를 씀으로써 api를 미세 제어 했었지만 이것을 직접 구현하는 방법은 굉장히 번거롭습니다. 또한 필요없는 모델 뷰를 반환하는 것이 필요 없는데 json을 반환하기 위해서 Objmapper쓰고 set으로 response에 데이터 다 넣고 했습니다. 과거에 서블릿을 사용하던 것과 유사하게 작성해야합니다. 

+ 또한 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어렵습니다. 원래는 같은 예외라도 회원 처리 컨트에서 발생하는 런타임 에러와 상품 관련 컨트에서 발생하는 런타임 에러를 서로 다른 방식으로 처리를 하는데 지금은 isinstanceof로 같은 에러면 동일하게 처리합니다.

 

- @ExceptionHandler

그래서 이게 나왔고 @ExceptionHandler를 달아놓으면 ExceptionHandlerExceptionResolver이 동작합니다. 스프링은 이 ER로 API 예외 처리를 대부분 처리합니다. 그니깐 얘도 ER이니 컨트롤러에서 예외가 발생하면 해결하는 것 입니다.

 

-> 구현

@Data
@AllArgsConstructor
public class ErrorResult {
    private String code;
    private String message:
}

새로은 패지키를 만들고 ErrorResult라는 바디에 넣을 json 객체를 만듭니다. 에러가 발생하면 code와 메세지만 담아서 보낼 것입니다.

 

@Slf4j
@RestController
public class ApiExceptionV2Controller {
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandler(IllegalArgumentException e) {
        log.error("[ex]", e);
        return new ErrorResult("BAD", e.getMessage());
    }

    @GetMapping("/api2/members/{id}")
    public MemberDTO memberDTO(@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;
    }
}

새로운 컨트롤러를 만들고 api니깐 rest로 하고 여기에 이전에 예외를 발생시키는 맵핑 메서드를 복사합니다. 역시 예외를 발생시키면 accept가 json이라서 json으로 응답이 오는데 지금 ER을 직접 만들어서 등록하지도 않았고 스프링이 기본 제공하는 ER은 바인딩 타입 에러는 지금 바인딩을 하지도 않앗고, 상태 코드 바꾸는 것은 @ResponseStatus를 붙여야해서 하지도 않았습니다. 따라서 ER이 해결해주지 못하고 was까지 가서 /error로 basic의 맵핑 메서드가 json을 반환한 모양입니다. 하지만 우리가 원하는 모양은 이것이 아닙니다.

 

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

 

더 깔끔하게 처리하는 것을 해보겠습니다. bad가 id로 들어오면 발생하는 ILL을 먼저 해보겠습니다. @ExceptionHandler에 ILL.class를 잡고 ErrorResult를 반환하는 메서드를 만들고 이름은 IllegalExHandler로 하고 파라미터는 ILL e로 합니다. ErrorResult는 생성자로 code와 message를 받으니 넣어주고 반환합니다. 

 

> 이건 이 컨트롤러에서 ILL 예외가 터지면 이 핸들러가 예외를 잡고 내부 로직이 호출되고 rest 컨트라서 그대로 ErrorResult가 json으로 반환됩니다. 

 

-> 설명

이게 되는 과정은 똑같습니다. 컨트롤러에서 예외가 터지면 ER에게 물어봅니다. 근데 첫번째로 해결을 시도하는 ER이 ExceptionHandlerExceptionResolver입니다. 얘가 실행이되면 얘는 지금 에러가 발생한 컨트롤러에 @ExceptionHandler가 있는지 찾아보고 있으면 해결책으로 @붙은 메서드를 대신 호출해줍니다. ExceptionHandlerExceptionResolver이 해결책으로 하는 것이 이 메서드를 호출하는 것입니다. RestController의 특성을 살려서 json을 박고 정상적인 흐름으로 바꿔서 보내버립니다.

 

> 근데 이러면 완전 정상 해결이 되어서 http 상태 코드가 200이 됩니다. 아예 정상흐름으로 바꾸고 객체도 완전히 바꾼 것이라서 정상흐름이 됩니다. 

 

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

하지만 우리는 상태 코드도 조절할 수 있으면 좋겠어서 그때는 @ResponseStatus를 붙여서 원하는 상태 코드를 넣으면 됩니다. 이거 정적인 상태 변환을 할 때 필요한 것이었는데 여기에 붙이면 됩니다. > 이렇게 하면 좋은 게 그림에서 ER로 끝이 나서 basic을 또 호출하고 그런게 아니고 끝이 난 것입니다.

 

직접 ER을 만든 것과 같은 동작 방식인데 훨씬 간단하고 에러가 발생한 컨트롤러에 작성하여 가독성도 좋습니다. 또한 반환되는 json도 객체로 만들어서 개발자 맘대로 할 수 있습니다. 이 ExceptionHandler는 작성된 컨트롤러에만 적용됩니다. 정말 ExceptionHandler를 컨트롤러처럼 만들어놨습니다.

 

 

-> 새로운 경우 1

@ExceptionHandler(UserException.class)
public ResponseEntity<ErrorResult> userExHandler(UserException e) {
    log.error("[exception]", e);
    ErrorResult errorResult = new ErrorResult("user-ex", e.getMessage());
    return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}

ResponseEntity를 써도 됩니다. 진짜 그냥 컨트롤러를 호출할 때와 똑같아집니다. 반환을 ResponseEntity로 하고 똑같이 잡을 예외를 파라미터로 넣습니다.

 

이번엔 ResponseEntity를 쓰니 ErrorResult를 생성하고 넣어주면 됩니다. ResponseEntity는 동적인 상태 코드를 반환할 수 있습니다.

 

 

-> 새로운 경우 2

@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandler(Exception e) {
    log.error("[exception]", e);
    return new ErrorResult("EX", "내부 오류");
}

파라미터에 그냥 Exception을 넣고 정적 상태를 Intenel_server_error로 500에러를 줍니다. Exception으로 하면 이 컨트롤러에서 발생하는 모든 예외를 다 잡는 것입니다.

 

먼저 디테일한 ILL과 User 예외는 위에 ExceptionHandler 메서드에서 잡히고 그 외 못 잡은 것들이 여기로 옵니다. 지금 ILL와 user는 해결했는데 런타임은 해결하지 않았으니 Runtime은 Exception에 걸리게 됩니다.

 

-> 정리

이렇게 작성한 ExceptionHandler는 그 컨트롤러에서만 적용됩니다. 참고로 지정한 예외의 자식 클래스도 다 잡을 수 있습니다.

 

-> 우선순위


항상 스프링은 자세하고 디테일한 것이 우선권을 가집니다. 하나의 컨트롤러에 부모와 자식 Handler가 같이 있으면 자식 예외에서 먼저 처리됩니다. 정말 ExceptionHandler를 컨트롤러처럼 만들어놔서 다양한 파라미터는 다 넣을 수 있습니다.

반환도 String을 넣으면 바디에 문자가 박히고 viewname을 넣으면 뷰를 반환합니다. 이때는 restController이면 안 될 것입니다. 근데 뷰 반환은 basic으로 하고 api 처리만 할 것입니다.

 

- ControllerAdvice

지금은 작성한 컨트롤러에서만 가능합니다. 다른 컨트롤러에서 사용하려면 @RestControllerAdvice를 사용하면 된다.

@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {
    @ResponseStatus(HttpStatus.BAD_GATEWAY)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandler(IllegalArgumentException e) {
        log.error("[ex]", e);
        return new ErrorResult("BAD", e.getMessage());
    }

    @ExceptionHandler(UserException.class)
    public ResponseEntity<ErrorResult> userExHandler(UserException e) {
        log.error("[exception]", e);
        ErrorResult errorResult = new ErrorResult("user-ex", e.getMessage());
        return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
    }

    @ResponseStatus(HttpStatus.OK)
    @ExceptionHandler
    public ErrorResult exHandler(Exception e) {
        log.error("[exception]", e);
        return new ErrorResult("EX", "내부 오류");
    }
}

 

ExControllerAdvice를 클래스를 만들고 @RestControllerAdvice를 붙입니다. ExControllerAdvice는 @ControllerAdvice에 responsebody가 붙은 것입니다. 이전에 만든 @ExceptionHandler 메서드들을 가져옵니다. 이렇게 되면 예외를 호출하는 부분과 해결하는 부분을 구분하게 됩니다. 

 

-> 실행

실행하면 똑같이 동작합니다. @ControllerAdvice는 모든 컨트롤러에 다 지정이 됩니다. 어떤 컨트롤러에서 예외가 발생하든 다 여기서 처리할 수 있습니다. 생략하면 모든 컨트롤러가 다 대상이 됩니다.

 

-> 대상을 지정하는 방법

글로벌하게 하지말고 특정 컨트롤러에서만 처리하고 싶으면 @RestControllerAdvice에 annotations 속성이 있습니다. annotations = RestController.class 이렇게 하면 RestController 한테만 이 예외 처리가 가능합니다.

 

> 패키지를 넣으면 이 패키지 포함 하위 컨트롤러만 예외를 처리합니다. 상품과 관련된 컨트롤러와 주문과 관련된 컨트롤러는 다른 예외처리를 해야할 텐데 그러면 @ControllerAdvice를 따로 만들고 각각 다른 ExceptionHandler를 작성해 놓으면 됩니다. 이것을 많이 사용합니다.

 

> 특정 컨트롤러 하나만 지정할 수도 있습니다. 실무에서는 예외를 공통으로 활용하는 경우가 많아서 @ControllerAdvice와 ExceptionHandelr를 진짜 많이 활용하게 됩니다.

'[백엔드] > [spring | 학습기록]' 카테고리의 다른 글

spring PART.중간점검 2  (0) 2023.04.30
spring PART.파일 업로드  (0) 2023.04.28
spring PART.예외 처리와 오류 페이지  (0) 2023.04.26
spring PART.필터  (0) 2023.04.24
spring PART.로그인, 세션관리  (0) 2023.04.23
Comments