어떠한 서비스든 경우에 맞게 예외를 클라이언트에게 적절히 제공해야한다.
그래서, 이번 글에서는 API서버를 개발할 때, 예외처리 방법을 알아보도록 하겠다.
예외를 처리하는 방법은 여러가지가 있다.
포인트는 어딘가에서 발생한 예외를 잡아서 이 예외를 처리한 후, 원하는 메세지와 상태코드와 함께 클라이언트 측에 정상응답으로 전달해야한다는 것이다.
이렇게 할 수 있는 방법이 어떤 것들이 있는지 하나 하나 짚고 넘어가 보겠다.
Exception resolver를 직접 한땀 한땀 구현하는 가장 원시(?)적인 방법이다. ㅎㅎ
먼저, IllegalArgumentException
이 터졌을 때, 특정 처리를 해주는 상황이라고 가정하자.
구현 방법 자체는 간단하다.
1. HandlerExceptionResolver
를 상속받은 구현체를 만든다.
2. 이 클래스를 Exception resolver로 등록한다.
방법 자체는 직관적이고 쉽지만,,, 코드양이 굉장히 방대하다 ㅎㅎ
public class CustomExceptionResolver implements HandlerExceptionResolver {
private final ObjectMapper objMapper = new ObjectMapper();
@Override
public ModelAndView resolveException(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception exception
) {
try {
if (exception instanceof IllegalArgumentException) {
handleException(response, exception);
return new ModelAndView();
}
} catch (IOException e) {}
return null;
}
// 실제로 예외를 처리할 메서드
private handleException(HttpServletResponse response, Exception exception) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
// 클라이언트에 보여질 json객체 만들기
Map<String, Object> result = new HashMap<>();
result.put("exception", exception.getClass());
result.put("message", exception.getMessage());
String result = objMapper.writeValueAsString(result); // Object -> String
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write(result);
}
}
@Configuration
public class AppConfig implements WebMvcConfigurer {
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new CustomExceptionResolver());
}
}
이렇게 하면, 서버에서 IllegalArgumentException
이 발생했을 때 이 Resolver가 대신 처리를 해주게 되는 것이다.
또, 클라이언트에게 보여질 에러 내용은 아래와 같은 Json객체가 보여지게 된다.
{
"exception": 에러의 클래스 이름,
"message": "에러 메세지"
}
직접 HandlerExceptionResolver를 구현하는 방식은 개발자가 해줘야할 게 참 많고, 장황하다.
사실은 이런 반복적이고 귀찮은 작업을 스프링에서는 미리 다 구현을 해놓았다. ㅎㅎ
바~로 알아보자 🤭
기본적으로 스프링에서는 3가지의 기본 Exception resolver들을 제공하고 있다.
1, 2, 3 순서대로 우선순위를 갖고 있다.
즉, 1번에서 처리가 안되면 2번, 3번 순서대로 처리의 주체가 넘어가게 된다.
이것들이 어떻게 활용하는 지 알아보자.
@ExceptionHandler
를 활용하여 아주 편리하게 예외를 처리할 수 있다.
API서버에서 예외를 처리할 때, 가장 많이 쓰이기 때문에 아주 중요하다.
아래와 같은 컨트롤러가 있다고 가정해보자.
클라이언트에서 /api/ex-handler
에 api요청을하면, IllegalArgumentexception
이 발생하는 간단한 컨트롤러이다.
@RestController
public class TestController {
@GetMapping("/api/ex-handler")
public String exHandler() {
throw new IllegalArgumentException();
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalAccessException.class)
public ErrorResponse handler(IllegalArgumentException e) {
// ... 예외 처리 ...
return new ErrorResponse("Bas request", e.getMessage());
}
private static class ErrorResponse {
String code;
String message;
public ErrorResponse(String code, String message) {
this.code = code;
this.message = message;
}
}
}
이 코드를 보면, @ExceptionHandler
로 이 컨트롤러에서 발생할 IllegalArgumentException
을 잡아서 처리해주고 있다.
이런식으로 각 컨트롤러에 맞게 에러를 처리해줄 수 있다.
근데, 컨트롤러마다 에러 처리 코드를 구현해야하는 게 어떤 상황에서는 귀찮고 불필요할 수가 있다.
그럴때는 서버 전체에 적용되는 에러처리 핸들러가 필요하다.
이런 경우엔 AOP가 녹아있는 @ControllerAdvice
를 사용한다.
이 기능은 스프링 예외 처리의 화룡정점이라고 할 수 있다.
비지니스 로직과 예외 처리를 완전히 분리 시켜줌과 동시에 원하는 곳에 원하는 방식으로 예외를 처리할 수 있게 해준다.
사용방법은 사실 매우 간단하다.
예외 처리를 담당할 클래스를 만들고 그 클래스에 @RestControllerAdvice
또는 @ControllerAdvice
를 붙여주면 된다.
위의 예제 코드에 이를 적용해보자.
그러면, 아래와 같이 컨트롤러와 예외 처리 로직이 완전히 분리되게 된다.
@RestController
public class TestController {
@GetMapping("/api/ex-handler")
public String exHandler() {
throw new IllegalArgumentException();
}
}
@RestControllerAdvice
public class TestController {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalAccessException.class)
public ErrorResponse handler(IllegalArgumentException e) {
// ... 예외 처리 ...
return new ErrorResponse("Bas request", e.getMessage());
}
private static class ErrorResponse {
String code;
String message;
public ErrorResponse(String code, String message) {
this.code = code;
this.message = message;
}
}
}
이렇게만 해주면 앱을 빌드할 때, 스프링에서 @RestControllerAdvice
를 보고, 이 클래스를 예외처리 클래스로 들고 있게 된다.
에러 처리의 범위는 @ControllerAdvice
에 인자로 넘겨서 설정할 수 있고, 특정 클래스부터 특정 패키지까지 원하는 대로 설정할 수 있다.
이 Resolver가 처리할 수 있는 경우는 아래 2가지이다.
@ResponseStatus
가 붙어있는 예외가 발생한 경우ResponseStatusException
이 발생할 경우즉, @ResponseStatus
를 붙인 에러를 구현하여 코드 상에서 사용하거나, ResponseStatusException
에 상태코드와 메세지같은 것을 인자로 넘겨서 예외를 발생시키면 된다.
예시로 알아보자.
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "사용자를 찾을 수가 없어!")
public class UserNotFoundException extends RuntimeException {}
먼저 @ResponseStatus
를 붙인 에러를 만들어준다.
@RestController
public class TestController {
@GetMapping("/api/user-not-found")
public String userNotFound() {
throw new UserNotFoundException();
}
// ResponseStatusException을 사용하는 예시 (위 api와 동일하게 동작)
@GetMapping("/api/user-not-found/2")
public String userNotFound2() {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "사용자를 찾을 수 없어!", new AttributeNotFoundException());
}
}
그리고 이 예외를 특정 api에서 바로 발생시켜보자.
그러면 아래와 같이 정상적으로 예외가 보여지는 것을 확인할 수 있다.
DefaultHandlerExceptionResolver
는 그냥 두면 알아서(?) 특정 에러들을 처리하는 Resolver이다.
어떠한 에러들을 알아서 처리해주는지 공식문서를 참고하자 ㅎㅎ
예외를 처리하는 다양한 방식들을 알아보았다.
이 글이 조금이나마 도움이 되었길 바라면서 마치도록 하겠다 🤭