본문 바로가기
공부/Spring

[Spring MVC](14) API 예외 처리

by 다음에바꿔야지 2024. 2. 20.

HTML 페이지는 4xx, 5xx 와 같은 오류 페이지만 있으면 대부분의 문제를 해결할 수 있다.

하지만 API의 경우에는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.

 

API 예외 처리 학습을 위해 서블릿 오류 페이지 방식을 사용해보겠다.

 

WebServerCustomizer 다시 동작

package hello.exception;

import org.springframework.boot.web.server.ConfigurableWebServerFactory;
import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;

@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);
    }
}

 

ApiExceptionController - API 예외 컨트롤러

package hello.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("잘못된 사용자");
        }
        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 테스트는 Postman을 통해 테스트를 진행한다.

 

API를 요청했는데, 정상의 경우 JSON 형식으로 데이터가 반환된다. 하지만 오류가 발생하면 미리 만들어두었던 오류 페이지 HTML이 반환된다. 문제 해결을 위해선 오류 페이지 컨트롤러도 JSON으로 응답을 할 수 있도록 수정해야 한다.

 

 

ErrorPageController - API 응답 추가

    @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, HttpStatusCode.valueOf(statusCode));
    }

produces = MediaType.APPLICATION_JSON_VALUE

: 클라이언트가 요청하는 HTTP Header의 Accept값이 application/json 일때 해당 메서드가 호출된다.

 

응답 데이터를 위해 Map을 만들고, status, message 키에 값을 할당했다. Jackson 라이브러리는 Map을 JSON 구조로 변환할 수 있다.

ResponseEntity를 사용해서 응답하기 때문에 메시지 컨버터가 동작하면서 클라이언트에 JSON이 반환된다.

 

Postman을 통해 다시 테스트해보니, JSON이 반환되는 것을 확인할 수 있었다.

 

 

스프링 부트 기본 오류 처리

스프링 부트가 제공하는 BasicErrorController 를 통해 API 예외 처리도 기본 오류 방식을 사용할 수 있다.

BasicErrorController 를 사용하도록 'WebServerCustomizer' 의 '@Component' 를 주석처리 하고 포스트맨을 돌려보니, JSON이 정상적으로 반환되었다.(항상 오류 메시지는 모든 내용을 보여주지 말고 간결한 메시지만 노출해야 한다.)

하지만 BasicErrorController는 HTML 페이지를 제공하는 단순한 경우에만 사용해야한다.

복잡한 API의 경우, 예외에 따라 요청 결과가 달라질 수 있기 때문에 API 오류 처리는 @ExceptionHandler를 사용해야한다.

 

 

HandlerExceptionResolver

스프링 MVC는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우, 예외를 해결하고 동작을 새로 정의할 수 있는 HandlerExceptionResolver를 제공한다.

 

MyHandlerExceptionResolver

package hello.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) {
        if (ex instanceof IllegalArgumentException) {
            try {
                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;
    }
}

*handler: 핸들러(컨트롤러) 정보

*Exception ex: 핸들러(컨트롤러)에서 발생한 예외

 

ExceptionResolver가 ModelAndView를 반환하는 이유는 마치 try, catch를 하듯이, Exception을 처리해서 정상 흐름처럼 변경하는 것이 목적이다. 여기서는 IllegalArgumentException이 발생하면 response.sendError(404)를 호출해서 HTTP 상태 코드를 400으로 지정하고, 빈 ModelAndView를 반환한다.

*빈 ModelAndView: new ModelAndView()처럼 빈 ModelAndView를 반환하면 뷰를 렌더링하지 않고, 정상 흐름으로 서블릿이 리턴된다.

*ModelAndView 지정: ModelAndView에서 View, Model 등의 정보를 지정해서 반환하면 뷰를 렌더링한다.

*null: 다음 ExceptionResolver를 찾아서 실행한다. 만약 처리할 ExceptionResolver가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.

 

ExceptionResolver 활용

1. 예외 상태 코드 변환
  * 예외를 'response.sendError(xxx)' 호출로 변경해서 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임
  * 이후 WAS는 서블릿 오류 페이지를 찾아서 내부 호출, 예를 들어서 스프링 부트가 기본으로 설정한 `/ error` 가 호출됨
2. 뷰 템플릿 처리
  * ModelAndView에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링 해서 고객에게 제공
3. API 응답 처리
  * response.getWriter().println("hello"); 처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능. 여기에 JSON 으로 응답하면 API 응답 처리를 할 수 있다.

 

WebConfig - 수정

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

 

 

HandlerExceptionResolver 활용

예외가 발생하면 WAS까지 예외가 던져지고, 다시 /error를 호출하는 과정은 너무 복잡하다. 이런 복잡한 과정없이 문제를 갈끔하게 해결해보자.

 

먼저 사용자 정의 예외를 하나 추가해본다.

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);
    }
}

마찬가지로, ApiExceptionController 에 예외를 추가해주었다.

이 예외를 처리하는 UserHandlerExceptionResolver를 만들어보자.

 

UserHandlerExceptionResolver

package hello.exception.resolver;

import com.fasterxml.jackson.databind.ObjectMapper;
import hello.exception.exception.UserException;
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 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 {
                    // TEXT/HTML
                    return new ModelAndView("error/500");
                }
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        return null;
    }
}

 

 

WebConfig에 UserHandlerExceptionResolver 추가

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

 

ExceptionResolver를 사용하면 컨트롤러에서 예외가 발생해도 ExceptionResolver에서 예외를 처리해버린다.

따라서 예외가 발생해도 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리는 끝난다.

결과적으로 WAS 입장에서는 정상 처리가 된 것이다. 이렇게 예외를 리졸버를 통해 모두 처리할 수 있다는 것이 핵심이다.

(서블릿 컨테이너까지 예외가 올라가면 복잡하고 지저분하게 추가 프로세스가 실행된다.)

 

직접 ExceptionResolver를 구현하지 말고, 스프링이 제공하는 ExceptionResolver를 알아보자.

 

스프링이 제공하는 ExceptionResolver

1. ExceptionHandlerExceptionResolver

: @ExceptionHandler를 처리한다. API 예외 처리는 대부분 이 기능으로 해결한다.

 

2. ResponseStatusExceptionResolver

: HTTP 상태 코드를 지정해준다. ex) @ResponseStatus(value = HttpStatus.NOT_FOUND)

 

3. DefaultHandlerExceptionResolver

: 스프링 내부 기본 예외를 처리한다.

 

가장 쉬운 ResponseStatusExceptionResolver부터 알아본다.

1. @ResponseStatus가 달려있는 예외

2. ResponseStatusException 예외

package hello.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(404)으로 변경하고, 메시지도 담는다.

sendError(400)를 호출했기 때문에 WAS에서 다시 오류 페이지(/error)를 내부 요청한다.

 

ResponseStatusException
@ResponseStatus 는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다. (애노테이션을 직접 넣어야 하는데, 내가 코드를 수정할 수 없는 라이브러리의 예외 코드 같은 곳에는 적용할 수 없다.)
추가로 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다. 이때는 ResponseStatusException 예외를 사용하면 된다.

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

 

 

DefaultHandlerExceptionResolver

대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException 이 발생하는데, 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다. 이 리졸버는 이것을 500 오류가 아니라 400 오류로 변경한다.

 

 

그런데 사실 HandlerExceptionResolver 를 직접 사용하기는 복잡하다. API 오류 응답의 경우 response 에 직접 데이터를 넣어야 해서 매우 불편하고 번거롭다. ModelAndView 를 반환해야 하는 것도 API에는 잘 맞지 않는다.
스프링은 이 문제를 해결하기 위해 @ExceptionHandler를 제공한다.

 

@ExceptionHandler

 

ErrorResult

package hello.exception.exhandler;

import lombok.AllArgsConstructor;
import lombok.Data;

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

예외가 발생했을때 API 응답으로 사용하는 객체를 정의했다.

 

ApiExceptionV2Controller

package hello.exception.api;

import hello.exception.exception.UserException;
import hello.exception.exhandler.ErrorResult;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
public class ApiExceptionV2Controller {

    @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", "내부 오류");
    }

    @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;
    }
}

@ExceptionHandler

: 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해준다. 예외가 발생하면 이 메서드가 호출된다.

예외를 생략하면 메서드 파라미터의 예외가 지정된다.

ResponseEntity

: HTTP 메시지 바디에 직접 응답한다. HTTP 응답 코드를 프로그래밍해서 동적으로 변경할 수 있다.

 

실행 흐름

1. 컨트롤러를 호출한 결과 예외(IllegalArgumentException)가 컨트롤러 밖으로 던져진다.

2. ExceptionResolver가 작동한다.

3. 해당 컨트롤러에 예외를 처리할 수 있는 @ExceptionHandler가 있는지 확인한다.

4. 해당 예외와 관련된 메서드를 실행한다. 이 로직에서는 IllegalArgumentException가 터지면 illegalExhandle()이 실행된다.

5. @RestController이므로 @ResponseBody가 적용된다. 따라서 HTTP 컨버터가 사용되고, 응답이 JSON으로 반환된다.

6. @ResponseStatus(HttpStatus.BAD_REQUEST)를 지정했으므로 HTTP 상태 코드 400으로 응답한다.

 

 

@ControllerAdvice

@ExceptionHandler를 사용해서 깔끔하게 예외 처리를 할 수 있게 되었지만, 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다. @ControllerAdvice 또는 @RestControllerAdvice를 사용하면 둘을 분리할 수 있다.

package hello.exception.exhandler.advice;

import hello.exception.exception.UserException;
import hello.exception.exhandler.ErrorResult;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

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

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandler(UserException e) {
        log.error("[exceptionHandler] 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 exHandler(Exception e) {
        log.error("[exceptionHandler] ex", e);
        return new ErrorResult("EX", "내부 오류");
    }

    @Data
    @AllArgsConstructor
    static class MemberDto {
        private String memberId;
        private String name;
    }
}

ApiExceptionV2Controller 에 있는 @ExceptionHandler 코드를 모두 제거한다.

 

@ControllerAdvice는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을 부여해주는 역할을 한다.

@ControllerAdvice에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다.

 

정리

API 예외 처리할때는 @ExceptionHandler@ControllerAdvice를 조합해 처리하자.