
예외처리 상황을 분류하기
에러 처리를 하기전에, 우리는 API 사용자와 웹사이트 이용자는 각각 다른 환경과 요구사항을 가지고 있기 때문에, 사용자에 따라 에러 메시지 형식을 다르게 제공해야 한다.
예를 들어, 게시물 등록 api를 사용자가 테스트한다고 했을때, 에러가 난다면 어떻게 에러 메시지를 보여줘야 할까?
"Error 404, 죄송합니다. 요청하신 웹페이지를 찾을 수 없습니다." 라는 메세지를 표시하는 웹페이지를 보여주는 게 맞을까?
당연히 API 사용자 입장에선 황당할 것이다.
잘못된 매개변수 형식이라면, 어떤 매개변수가 어떤 데이터 타입으로 와서 오류가 났고, 어떤 데이터 타입으로 요청해야 오류가 나지 않는지,
요청한 매개변수가 중복된 unique contraint에 위반된다면 어떤 필드가 위반되는지,
잘못되었다면 무엇이 잘못되었는지 정확히 알려주는 데이터 형식을 원할 것이다.
반대의 경우, 웹 사이트 이용자도 황당할 것이다. 일반 사용자 입장에서 어떤 매개변수가 잘못되었는지, 데이터 타입 변환이 필요하다던지와 같은 메시지는 이해하지도 못할 뿐 더러 필요하지도 않을 것이다.
스프링 부트에서 오류 페이지를 보여주는 방법
스프링 부트는 ErrorPage를 자동으로 /error 경로로 등록하여 기본 오류 페이지로 사용하며, 별도로 상태 코드나 예외를 설정하지 않으면 기본 오류 페이지로 설정한다.
또한, 서블릿 외부에서 예외가 발생하거나 response.sendError(...)가 호출되면 모든 오류가 자동으로 /error로 이동하고,
BasicErrorController라는 컨트롤러를 자동으로 등록하여, ErrorPage에서 등록한 /error 경로를 매핑하고 처리한다.
이제 오류가 발생했을 때 오류 페이지로 /error 를 기본 요청하므로,
스프링 부트가 자동 등록한 BasicErrorController는 이 경로를 기본으로 받는다.
BasicErrorController 는 기본적인 로직이 모두 개발되어 있으므로,
우리가 할일은 오류 페이지 화면만 BasicErrorController가 제공하는 룰과 우선순위에 따라서 등록하면 된다.
정적 HTML이면 정적 리소스, 뷰 템플릿을 사용해서 동적으로 오류 화면을 만들고 싶으면 뷰 템플릿 경로에 오류 페이지 파일을 만들어서 넣어두기만 하면 된다.
뷰 선택 우선순위
BasicErrorController의 처리 순서를 알아보고 우선 순위에 따라 뷰를 등록할 수 있도록 한다.
- 뷰 템플릿
- resources/templates/error/500.html
- resources/templates/error/5xx.html
- 정적 리소스( static , public )
- resources/static/error/400.html
- resources/static/error/404.html
- resources/static/error/4xx.html
- 적용 대상이 없을 때 뷰 이름( error )
- resources/templates/error.html
해당 경로 위치에 HTTP 상태 코드 이름의 뷰 파일을 넣어두면 된다.
뷰 템플릿이 정적 리소스보다 우선순위가 높고, 404, 500처럼 구체적인 것이 5xx처럼 덜 구체적인 것 보다 우선순위가 높다.
API 예외 처리
API 예외 처리는 어떻게 해야할까? HTML 페이지의 경우 지금까지 설명했던 것 처럼 4xx, 5xx와 같은 오류 페이지만 있으면 대부분의 문제를 해결할 수 있다.
그런데 API의 경우에는 생각할 내용이 더 많다. 오류 페이지는 단순히 고객에게 오류 화면을 보여주고 끝이지만, API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다
API 마다, 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해야 할 수도 있다.
예를 들어서 회원과 관련된 API에서 예외가 발생할 때 응답과, 상품과 관련된 API에서 발생하는 예외에 따라 그 결과가 달라질 수 있다.
결과적으로 매우 세밀하고 복잡하다.
BasicErrorController는 클라이언트 요청의 Accept 해더 값이 text/html 인 경우에는 errorHtml() 을 호출해서 view를 제공하지만, 그외 경우에는 ResponseEntity 로 HTTP Body에 JSON 데이터를 반환하므로 API 예외 처리 또한 수행할 수 있으므로 사용해도 되지만,
이 방법 은 HTML 화면을 처리할 때 사용하고, API 오류 처리는 뒤에서 설명할 @ExceptionHandler 를 사용하자.
HandlerExceptionResolver
스프링 MVC는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공한다.
컨트롤러 밖으로 던져진 예외를 해결하고, 동작 방식을 변경하고 싶으면 HandlerExceptionResolver 를 사용하면 된다. 줄여서 ExceptionResolver 라 한다.
컨트롤러에서 exception이 발생했을때의 흐름을 정리하자면
- WAS(/error-ex, dispatchType=REQUEST) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러
- WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
- WAS 오류 페이지 확인
- WAS(/error/500, dispatchType=ERROR) -> 필터(x) -> 서블릿 -> 인터셉터(x) -> 컨트 롤러(/error/500) -> View
위와 같은 순서로 진행이 되는데 ExceptionResovler가 적용되기 전에는 WAS까지 그대로 예외가 전달되기 때문에 WAS에서 다시 에러페이지를 찾기위해 BasicErrorController로 에러를 전달한다고 설명했었다.
하지만 예외가 발생하면 WAS까지 예외가 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 /error 를 호출하는 과정은 생각해보면 너무 복잡하다.
ExceptionResolver 를 활용하면 예외가 발생했을 때 이런 복잡한 과정 없이 여기에서 문제를 깔끔하게 해결할 수 있다.
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);
}
}
@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;
}
}
@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;
}
}
RuntimException을 상속받는 사용자 정의 UserException을 추가하고 ApiExceptionController 에서 UserException이 발생하면 UserException을 처리하는 UserHandlerExceptionResolver에서
HTTP 요청 해더의 ACCEPT 값이 application/json 이면 JSON으로 오류를 내려주고, 그 외 경우에는 error/ 500에 있는 HTML 오류 페이지를 보여준다.
ACCEPT : application/json
{
"ex": "hello.exception.exception.UserException",
"message": "사용자 오류"
}
ACCEPT : text/html
<!DOCTYPE HTML>
<html>
...
</html>
정리
ExceptionResolver 를 사용하면 컨트롤러에서 예외가 발생해도 ExceptionResolver 에서 예외를 처리해버린 다.
따라서 예외가 발생해도 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리는 끝이 난다. 결과적으로 WAS 입장에서는 정상 처리가 된 것이다.
이렇게 예외를 이곳에서 모두 처리할 수 있다는 것이 핵심이다. 서블릿 컨테이너까지 예외가 올라가면 복잡하고 지저분하게 추가 프로세스가 실행된다.
반면에 ExceptionResolver 를 사용하면 예외처리가 상당히 깔끔해진다. 그런데 직접 ExceptionResolver 를 구현하려고 하니 상당히 복잡하다.
지금부터 스프링이 제공하는 대표적인 ExceptionResolver를 알아보자.
@ExceptionHandler
스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler 라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공하는데, 이것이 바로 ExceptionHandlerExceptionResolver 이다.
스프링은 ExceptionHandlerExceptionResolver 를 기본으로 제공하고, 기본으로 제공하는 ExceptionResolver 중에 우선순위도 가장 높다. 실무에서 API 예외 처리는 대부분 이 기능을 사용한다.
예제로 알아보자.
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
예외가 발생했을 때 API 응답으로 사용하는 객체를 정의하고, 해당 객체를 ResponseEntity에 담아 보낼 것이다.
@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 예외 처리 방법
@ExceptionHandler 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다. 해당 컨 트롤러에서 예외가 발생하면 이 메서드가 호출된다.
@ExceptionHandler 에 지정한 부모 클래스는 자식 클래스까지 처리할 수 있다. 따라서 자식예외가 발생하면 부모 예외처리() , 자식예외처리() 둘다 호출 대상이 된다
하지만, 스프링의 우선순위는 항상 자세한 것이 우선권을 가지므로 자식예외처리()가 호출된다.
다중 예외 처리
다음과 같이 다양한 예외를 한번에 처리할 수 있다.
@ExceptionHandler({AException.class, BException.class})
public String ex(Exception e) {
log.info("exception e", e);
}
예외 생략
@ExceptionHandler에 예외를 생략할 수 있다. 생략하면 메서드 파라미터의 예외가 지정된다.
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {}
IllegalArgumentException 처리예제
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
실행 흐름
- 컨트롤러를 호출한 결과 IllegalArgumentException 예외가 컨트롤러 밖으로 던져진다.
- 예외가 발생했으므로 ExceptionResolver 가 작동한다. 가장 우선순위가 높은 ExceptionHandlerExceptionResolver 가 실행된다.
- ExceptionHandlerExceptionResolver 는 해당 컨트롤러에 IllegalArgumentException 을 처리 할 수 있는 @ExceptionHandler 가 있는지 확인한다.
- illegalExHandle() 를 실행한다. @RestController 이므로 illegalExHandle() 에도 @ResponseBody 가 적용된다. 따라서 HTTP 컨버터가 사용되고, 응답이 다음과 같은 JSON으로 반환된다.
- @ResponseStatus(HttpStatus.BAD_REQUEST) 를 지정했으므로 HTTP 상태 코드 400으로 응답한다
이와 같은 방식으로
"예를 들어서 회원과 관련된 API에서 예외가 발생할 때 응답과, 상품과 관련된 API에서 발생하는 예외에 따라 그 결과가 달라질 수 있다." 와 같은 문제를 처리할 수 있는데,
예를 들어 같은 RuntimeException이라도 상품에서 발생한 오류와, 회원에서 발생한 오류를 다르게 처리할려면
전의 예제와 같이 RuntimeException을 상속받은 UserException과
RuntimeException을 상속받은 ProductException와 같은 예외 클래스를 사용자의 정의 예외 클래스로 만든 후
각자 해당하는 컨트롤러에서 UserException과 ProductException을 예외를 던지고 싶을때 던진후
@ExceptionHandler을 선언한 UserException과 ProductException을 처리하는 메소드에서 처리하게 하면 해당 문제를 처리할 수 있다.
만약 UserException과 ProductException에 해당하지 않는 RuntimeException이 터진다면 RuntimeException 은 Exception 의 자식 클래스이므로 exHandle() 메소드가 해당 RuntimeException을 처리할 것이다.
※ErrorResult를 반환하지 않고 return new ModelAndView("error"); 와 같이 ModelAndView를 응답하면 오류 화면을 응답하는데 사용할 수 도 있다.
@ControllerAdvice - 예외 처리 코드 분리하기
@ExceptionHandler 를 사용해서 예외를 깔끔하게 처리할 수 있게 되었지만, 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다.
@ControllerAdvice 또는 @RestControllerAdvice 를 사용하면 둘을 분리할 수 있다.
@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {
@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", "내부 오류");
}
}
후에 ApiExceptionV2Controller 코드에 있는 @ExceptionHandler를 모두 제거해 주면 정상 코드와 예외 처리 코드를 완벽하게 분리할 수 있다.
@ControllerAdvice
- @ControllerAdvice 는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler , @InitBinder 기능 을 부여해주는 역할을 한다.
- @ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)
- @RestControllerAdvice 는 @ControllerAdvice 와 같고, @ResponseBody 가 추가되어 있다. @Controller , @RestController 의 차이와 같다.
대상 컨트롤러 지정 방법
// Target all Controllers annotated with @RestController
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}
// Target all Controllers within specific packages
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}
// Target all Controllers assignable to specific classes
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}
스프링 공식 문서 예제에서 보는 것 처럼 특정 애노테이션이 있는 컨트롤러를 지정할 수 있고, 특정 패키지를 직접 지정 할 수도 있다.
패키지 지정의 경우 해당 패키지와 그 하위에 있는 컨트롤러가 대상이 된다. 그리고 특정 클래스를 지정할 수도 있다. 대상 컨트롤러 지정을 생략하면 모든 컨트롤러에 적용된다.
출처
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 - 인프런
웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있
www.inflearn.com
'Spring' 카테고리의 다른 글
효율적인 JDBC 프로그래밍을 위한 DataSource, Connection Pool 기술 - 2 (1) | 2024.03.13 |
---|---|
효율적인 JDBC 프로그래밍을 위한 DataSource, Connection Pool 기술 - 1 (0) | 2024.03.13 |
Spring Security를 활용하여 JWT 발급, 자체 로그인, OAuth2 구현하기 - 3 (0) | 2024.03.07 |
Spring Security를 활용하여 JWT 발급, 자체 로그인, OAuth2 구현하기 - 2 (0) | 2024.03.06 |
Spring Security를 활용하여 JWT 발급, 자체 로그인, OAuth2 구현하기 - 1 (2) | 2024.03.05 |

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!