관리 메뉴

나구리의 개발공부기록

API 예외처리 - 시작, 스프링 부트 기본 오류 처리, HandlerExceptionResolver 시작 및 활용 본문

인프런 - 스프링 완전정복 코스 로드맵/스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술

API 예외처리 - 시작, 스프링 부트 기본 오류 처리, HandlerExceptionResolver 시작 및 활용

소소한나구리 2024. 9. 8. 15:48

  출처 : 인프런 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 (유료) / 김영한님  
  유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용  
https://inf.run/GMo43


1. API 예외처리 - 시작

  • HTML 페이지의 경우 오류 페이지만 있으면 대부분의 문제를 해결할 수 있음
  • API의 경우에는 고객에게 보여주는 오류 화면이 아니고 상황에 맞는 요구 스펙을 서로 정의하는 것처럼, 오류도 글로벌한 스펙이 있는 것이 아니기 때문에 클라이언트와 서버가 각 오류 상황에 맞는 오류 응답 스펙을 정하고 JSON으로 데이터를 내려 주어야 함

1) 서블릿 오류 페이지 방식을 활용한 API 오류 처리

(1) WebServerCustomizer 다시 동작

  • 예외 처리와 오류 페이지 강의에서 작성해 두었던 WebServerSustomizer 클래스의 @Component 애노테이션을 다시 주석을 해제 시켜서 동작하도록 변경

(2) ApiExceptionController 추가

  • 회원을 조회하는 간단한 컨트롤러, id의 값이 ex이면 예외가 발생함
package hello.exception.api;

@Slf4j
@RestController
public class ApiExceptionController {

    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {

        // id == ex 일때 RuntimeException 발생
        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으로 localhost:8080/api/members/ex에 get요청을 보내면 (HTTP Header에 Accept가 allication/json 으로 보내야함) 기존에 만들어 둔 에러페이지가 보여짐
  • API를 요청했으니 정상적일 경우 API로 JSON 형식으로 데이터가 반환되는데, 에러페이지는 HTML로 반환되는 것은 기대하는 바가 아니며 웹 브라우저가 아닌이상 HTML을 직접 받아서 할 수 있는 것은 별로 없음
  • 이를 해결하려면 컨트롤러도 JSON 응답을 할 수 있도록 수정해주어야 함
좌) 정상요청 / 우) html로 반환된 에러 페이지

(3) ErrorPageController에 errorPage500Api() 추가

  • @RequestMapping()의 옵션에 produces를 MediaType.APPLICATION_JSON_VALUE로 지정하여 JSON으로 응답 타입을 지정
  • 해당 메서드를 생성할 때 기존의 RequestMapping("/error-page-/500)이 달린 메서드를 지우지 않고 추가로 작성했는데, 동일한 경로로 요청이 되어도 자세한것을 우선순위로 선정하기 때문에 새로만든 errorPage500Api()가 먼저 호출됨
// produces, 클라이언트가 보내는 Accept 타입에 따라 응답 타입을 지정해줌
@RequestMapping(value="/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletRequest httpServletRequest,
                                                           HttpServletResponse httpServletResponse) {
    log.info("API errorPage 500");
    // JSON 변환을 위한 HashMap -> result 값 저장하면 됨
    Map<String, Object> result = new HashMap<>();

    // 예외를 변수에 저장
    Exception ex = (Exception)httpServletRequest.getAttribute(ERROR_EXCEPTION);
    result.put("status", httpServletRequest.getAttribute(ERROR_STATUS_CODE));   // 에러 상태 코드
    result.put("message", ex.getMessage()); // 예외 메세지

    Integer statusCode = (Integer)httpServletRequest.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);// 오류 상태 코드
    // ResponseEntity = response body에 바로 데이터를 집어 넣음
    return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
}
  • ErrorPageController에 해당 컨트롤러를 추가하면 정상적으로 오류정보가 JSON으로 응답됨
  • HashMap은 순서를 보장하지 않아서 출력 순서는 바뀔 수 있음(result에 put으로 값을 저장 시)
  • HttpStatus.valueOf(statusCode): 우측 상단에 빨간색으로 보여지는 500 Internal Server Error로 표시된 내용
정상적으로 JSON으로 오류가 응답됨

2. API 예외처리 - 스프링 부트 기본 오류 처리

  • API 예외 처리도 스프링 부트가 제공하는 BasicErrorController를 사용하여 처리할 수 있음
  • 실습을 위해 WebServerCustomizer의 @Component를 주석처리하여 서블릿을 통한 예외처리를 제거하도록 설정

1) postman으로 요청

  • Header - Accept : text/html : 기존에 작성했던 오류 페이지가 나옴
  • Header - Accept : application/json : json으로 오류가 응답 됨
좌) html로 반환된 오류 / 우) json으로 반환된 오류

2) BasicErrorController

  • 스프링 부트 3.3.3 기준 교안과 BasicErrorController 구현 코드가 다르게 정의되어있으나 동작은 동일하게 함
  • 자세한 내용은 BasicErrorController에 들어가서 확인해보기 -> 기존에 오류 컨트롤러와 구성 방식이 비슷함
  • /error 경로를 처리하는 메서드가 2개가 정의되어 있음

(1) errorHtml() 메서드

  • 요청 Accept 헤더 값이 text/html인 경우 뷰를 반환

(2) error()

  • 그외의 요청에 호출되며 ResponseEntity로 HTTP Body에 JSON 데이터를 반환
@Controller

// 에러의 기본 경로가 /error로 정의 되어있음
@RequestMapping({"${server.error.path:${error.path:/error}}"})
public class BasicErrorController extends AbstractErrorController {

    // 기타 메서드들 정의

	// MediaType이 text/html인경우 동작하는 메서드가 정의 되어있음, ModelAndView로 반환
    @RequestMapping(produces = {"text/html"})
    public ModelAndView errorHtml(HttpServletRequest request,
                    HttpServletResponse response) { 
        // 동작코드 정의
    }

    // text/html이 아닌 경우에는 ResponseEntity로 반환 되도록 메서드가 정의 되어있음
    @RequestMapping
    public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
        // 동작 코드 정의
    }
	
}

 

  • 마찬가지로 옵션을 조정하여 다양한 오류 정보를 추가할 수 있지만, 보안상 위험할 수 있으니 간결한 메시지만 노출하고 로그를 통해서 확인

3) Html 페이지 오류 vs API 오류

  • BasicErrorController를 확장하면 JSON 메시지를 변경할 수 있는데, @ExceptionHandler가 제공하는 기능을 사용하는 것이 더나은 방법이므로 확장에서 JSON 오류 메시지를 변경할 수 있다는 것 정도로만 이해하면 됨
  • BasicErrorController는 HTML 페이지를 제공하는 경우 매우 편리한데 API오류 처리는 다른 차원의 문제임
  • 클라이언트와 서버가 API마다 예외가 발생했을 때 정의한 JSON 스타일이 각각 다르기 때문에 응답 결과를 다르게 출력해야 할 수 있어 세밀하고 복잡하기 때문에 BasicErrorController는 HTML 화면을 처리할 때 사용하고 API 오류는 뒤에서 설명할 @ExceptionHandler를 사용하는 것이 좋음

3. API 예외 처리 - HandlerExceptionResolver 시작

  • 예외가 발생해서 서블릿을 넘어 WAS 까지 예외가 절달되면 HTTP 상태코드가 500으로 처리되는데 발생하는 예외에 따라서 400, 404 등등 다른 상태코드로 처리할 수 있음

1) ApiExceptionController - 수정

  • id 값을 bad로 요청했을 때 IllegalArgumentException이 발생하도록 설정하고 요청을 보내보면 500에러를 반환됨
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {

    if (id.equals("ex")) {  // id == ex 일때 RuntimeException 발생
        throw new RuntimeException("잘못된 사용자");
    }
    if (id.equals("bad")) { // id == bad 일때 IllegalArgumentException 발생
        throw new IllegalArgumentException("잘못된 입력 값");
    }

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

2) HandlerExceptionResolver

  • HandlerExceptionResolver를 통해 예외를 해결하고 동작을 새로 정의할 수 있음
  • 줄여서 ExceptionResolver라고 함
  • ExceptionResolver를 적용하면 컨트롤러에서 예외가 발생 되었을 때 적용전과 마찬가지로 postHandler는 호출이 안되지만, 대신 ExceptionResolver가 호출되어 예외를 해결하도록 시도하고, 예외를 해결하면 이후의 호출들을 정상 응답으로 처리할 수 있음
좌) ExceptionResolver 적용 전 / 우) ExceptionResolver 적용 후

 
(1) HandlerExceptionResolver 인터페이스 구조

  • handler: 핸들러(컨트롤러)정보
  • Exception ex: 핸들러(컨트롤러)에서 발생한 예외
public interface HandlerExceptionResolver {
    @Nullable
    ModelAndView resolveException(HttpServletRequest request, 
    			HttpServletResponse response, @Nullable Object handler, Exception ex);
}

 

(2) MyHandlerExceptionResolver - ExceptionResolver를 구현한 리졸버

  • ExceptionResolver가 ModelAndView를 반환하는 이유는 try-catch를 하듯이 Exception을 처리해서 정상 흐름 처럼 변경하는 것이 목적임
  • 여기서는 IllegalArgumentException이 발생하면 response.sendError(400)을 호출해서 HTTP 상태 코드를 400으로 지정하고 빈 ModelAndView를 반환함
  • 빈 ModelAndView 반환: 뷰를 렌더링 하지 않고 정상 흐름으로 서블릿이 리턴됨
  • ModelAndView 지정 반환: 지정한 정보를 가지고 뷰를 렌더링해서 반환함
  • null 반환: 다은 ExceptionResolver를 찾아서 실행하고, 만약 처리할 수 있는 ExceptionResolver가 없으면 예외 처리가 되지 않고 기존에 발생한 예외(500)을 서블릿 밖으로 던짐
package hello.exception.resolver

import java.io.IOException;

@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {

    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response,
                                         Object handler, Exception ex) {

        try {
            // IllegalArgumentException 이면 400 오류로 반환하는 로직
            if (ex instanceof IllegalArgumentException) {
                log.info("IllegalArgumentException resolver to 400");

                /*
                 IllegalArgumentException 에러가 오면 try-catch, if 에서 정상 로직으로 처리가 됨
                 response.sendError()로 새로운 SC_BAD_REQUEST 와, 발생한 예외 메세지를 던짐
                 그러면 기존 발생한 예외는 무시되고, 서블릿 컨테이너는 새로 발생시킨 BAD_REQUEST 에러로 인식하게 됨
                */
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
                return new ModelAndView();  // 새로운 ModelAndView()를 빈값으로 반환 - 정상 응답처리
            }
        } catch (IOException e) {
            log.info("resolver ex", e);
        }
        return null;
    }
}

 

(3) ExceptionResolver활용

  • 예외 상태 코드 변환: 발생한 예외를 response.sendError()호출로 변경해 입력한 상태 코드에 따라 서블릿에서 오류를 처리하도록 위임하고 WAS는 서블릿 오류 페이지를 찾아서 내부 호출함(ex - 스프링 부트가 기본으로 설정한 /error)
  • 뷰 템플릿 처리: ModelAndView에 값을 채우면 예외에 따른 새로운 오류 화면을 뷰 렌더링 하여 고객에게 제공
  • API 응답 처리: response.getWriter().println("hello") 처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능하며 { "xx" : "yy"} 처럼 JSON으로 응답하면 API 응답 처리를 할 수 있음

3) WebConfig - 수정(작성한 ExceptionResolver 등록)

  • extendHandlerExceptionResolvers()로 등록해야만 스프링이 기본으로 등록하는 ExceptionResolver를 사용할 수 있음
  • configureHandlerExceptionResolver(...) 사용 X
@Configuration
public class WebConfig implements WebMvcConfigurer {
	
    // ... 기타 등록된 설정들
    
    // 작성한 핸들러 등록
    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
    }
}

 

(4) 실행 결과

  • HandlerExceptionResolver를 사용 후에 id=bad로 요청을 보내보면 500에러가 지정한 400 에러로 변경 된 것을 알 수있음
  • (500 에러 -> 서버 잘못, 400 -> 클라이언트 잘못) : 상세한 내용은 HTTP 웹 기본 지식 강의 내용 확인
좌) HandlerExceptionResolver 사용 전 - 500 반환 / 우) 사용 후 - 400 반환

4. API 예외 처리 - HandlerExceptionResolver 활용

  • 예외가 발생하면 WAS까지 예외가 던져지고 WAS에서 오류 페이지 정보를 찾아서 다시 /error를 호출하는 복잡하고 중복 호출되는 처리 과정을 ExceptionResolver를 활용하면 복잡한 과정없이 HandlerExceptionResolver에서 에러 처리를 끝내버릴 수 있음(WAS까지 가지 않음)

1) UserException 작성 - 사용자 정의 예외 

  • RuntimeException을 상속 받아서 구현
package hello.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);
    }
}

2) ApiExceptionController 예외 추가

public class ApiExceptionController {

    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {

        // ... 기존 오류들
        
        if (id.equals("user-ex")) { // id == user-ex 일때 사용자 정의 오류 발생
            throw new UserException("사용자 오류");
        }

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

3) UserHandlerExceptionResolver

  • 사용자 정의 예외를 처리하는 리졸버
  • HTTP 요청 헤더의 ACCEPT 값이 application/json이면 JSON으로 오류를 리턴하고, 그 외의 경우에는 error/500에 있는 HTML 오류 페이지를 리턴
package hello.exception.resolver;

@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {

    // JSON으로 만들기 위한 ObjectMapper
    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");

                // HTTP 헤더의 accept 가 text/html, application/json 일때 각각 처리 되도록 구현
                String acceptHeader = request.getHeader("accept");
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);    // 상태코드 400 반환

                if ("application/json".equals(acceptHeader)) {  // json 일때
                    Map<String, Object> errorResult = new HashMap<>();
                    errorResult.put("ex", ex.getClass());
                    errorResult.put("message", ex.getMessage());

                    String result = objectMapper.writeValueAsString(errorResult);// 에러 객체를 String으로 변환

                    response.setContentType("application/json");
                    response.setCharacterEncoding("UTF-8");
                    response.getWriter().write(result);

                    return new ModelAndView();  // 빈 ModelAndView 를 반환하여 정상 흐름을 반환
                } else {    // 그 예외 케이스들(text/html)
                    return new ModelAndView("error/500");   // 기존에 작성한 error/500 페이지를 반환
                }
            }
        } catch(IOException e) {
            log.info("resolver ex", e);
        }
        return null;
    }
}

4) WebConfig에 작성한 UserHandlerExceptionResolver 추가

// 작성한 핸들러 등록
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
    resolvers.add(new MyHandlerExceptionResolver());
    resolvers.add(new UserHandlerExceptionResolver());	// 추가
}

5) 실행

(1) Accept : application/json

  • json 타입으로 사용자오류가 정상적으로 처리됨

(2) Accept : text/html

  • 기존에 등록했던 error/500의 에러 페이지 html이 출력 됨

(3) 정리

  • ExceptionResolver를 사용하면 컨트롤러에서 예외가 발생해도 ExceptionResolver에서 예외를 처리해버려서 서블릿 컨테이너까지 예외가 전달되지 않고 스프링 MVC에서 예외 처리가 끝이남 -> WAS 입장에서는 정상 처리가 되고 이곳에서 모두 예외를 처리할 수 있다는 것이 핵심
  • 직접 ExceptionResolver를 구현하는 것은 복잡한데, 이것을 스프링이 제공하는 ExceptionResolver를 활용하면 편리하게 API 예외를 처리할 수 있음
좌) Accept: application/json 반환 / 우) Accept: text/html 반환