API 처리 중 실패 상황에 대해 어떻게 처리할 것인가 고민이 있었습니다.
예를 들어,
이런 여러 상황이 있고, 각 상황에 맞춰 클라이언트에게 응답을 해줘야 클라이언트는 무엇때문에 요청이 실패했는지 알 수 있겠죠.
// 컨트롤러 코드
@PostMapping("/join")
public ResponseEntity join(@RequestBody @Valid UserJoinRequestDTO userJoinRequestDTO) {
boolean flag = userService.join(userJoinRequestDTO);
if (!flag) {
return new ResponseEntity(HttpStatus.CONFLICT);
}
return new ResponseEntity(HttpStatus.OK);
}
// 서비스 코드
@Override
public boolean join(UserJoinRequestDTO userJoinRequestDTO) {
if (isEmailDuplicate(userJoinRequestDTO.getEmail())) {
return false; // 이메일이 중복될 경우 false 반환
}
if (isNicknameDuplicate(userJoinRequestDTO.getNickname())) {
return false; // 닉네임이 중복될 경우 false 반환
}
...
return true;
}
이 코드는 회원가입에 대한 초기 코드입니다. 서비스 클래스에서 boolean 값을 리턴하여 이 boolean 값을 통해 컨트롤러에서는 실패 상황을 체크합니다.
(이외에도 null 값을 리턴하여 컨트롤러에서 체크하는 코드도 있습니다.)
이렇게 모든 실패 상황을 null이나 boolean 과 같이 리턴값을 통해 처리하는 방식은 문제가 있습니다.
이 문제를 해결하기 위해 각 예외 상황에 따른 처리 전략이 필요했습니다.
우선 예외 상황을 두가지로 분리했습니다.
위 두가지 상황에 맞춰서 예외 처리 전략을 세웠습니다.
비즈니스 로직에 대한 예외의 경우 최상위 Business Exception이 있습니다. 그리고 이를 상속받는 NicknameDuplicatedException, EmailDuplicatedException, 등등 에 세부 비즈니스 예외가 있습니다.
JwtException, DataAccessException과 같은 예외들은 이미 자바에서 예외를 제공해줍니다. 그래서 이 예외를 그대로 활용하기로 했습니다. DataAccessException과 같이 이미 DB 예외를 추상화해놓은 런타임 예외는 그대로 사용합니다. 하지만, checked Exception은 런타임 예외로 포장해서 예외를 처리하도록 합니다.
위와 같은 여러 예외가 있습니다. 이런 예외들은 서버쪽에서 try catch나 throws를 활용하여 예외를 처리하고 복구 하는 것보다 바로 바로 클라이언트에게 어떤 예외가 났다고 통보하는 것이 낫다고 생각합니다. 따라서 대부분의 예외를 runtimeException으로 처리하도록 했습니다.
그래서 만약 자바에서 제공하는 예외들이 checked Exception이면 이를 runtime Exception 으로 중첩해서 처리하도록 했습니다.
하지만, 이후에 예외를 처리해서 무언가 대응 할 필요한 경우가 생기면 checked Exception을 활용해야 할 듯 합니다.
오류에는 3가지가 존재한다.
특정한 컨트롤러에 예외처리할 때 사용하는 어노테이션으로, 이 때 발생한 예외는 ExceptionHanderExceptionResolver에 의해 처리가 된다.
Dispatcher Servlet에는 DI로 확장 가능한 여러 전략이 있는데 그 중에 이 Exception관련 Resolver도 있다.
예외 발생시 Dispatcher Servlet이 이 예외 관련 Resolver에게 예외 처리를 위임한다.
ExceptionHandler는 특정 컨트롤러에만 예외가 처리되므로 다른 컨트롤러에서도 중복된 에러 처리 코드가 있을 수 있다.
문제를 해결하기 위해서는 @ControllerAdvice를 사용하는 것이다. 이것은 여러 컨트롤러에 대해 전역적으로 ExceptionHandler를 적용할 수 있도록 해준다. AOP 방식으로 동작해서 예외를 처리한다고 나와있다.
주의 사항은
예외들은 예외 관련 Resolver에 의해서 처리된다.
컨트롤러가 예외가 던져졌을 때
1. 컨트롤러 안에 있는 @ExceptionHandler가 있는지 검사.있으면 에러 처리
2. @ControllerAdvice를 찾고 안에 @ExceptionHandler가 있는지 검사.에러처리.
3. @ControllerAdvice를 찾고 안에 @ResponseStatus가 있는지 검사.에러처리.
스프링은 기본적인 예외처리방식이 BasicErrorController로, RespoonseStatus예외는 이 BasicErrorController를 거친다. 에러 응답을 직접 반환하지 않기 때문이다.
이번 예외 처리시에 ControllerAdvice를 활용하여 예외를 처리합니다. ControllerAdvice에서 BusinessException 하나만 handler를 두면, 통일감 있는 비즈니스 예외를 처리할 수 있습니다.
아래와 같은 코드로 controllerAdvice를 구현합니다.
발생하는 모든 세부 비즈니스 예외는 handleBusinessException 에서 동일하게 처리됩니다.
@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {
...
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ErrorResponseDTO> handleBusinessException(BusinessException e) {
ErrorCode errorCode = e.getErrorCode();
ErrorResponseDTO response = ErrorResponseDTO.of(errorCode);
return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
}
}
@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ErrorResponseDTO> handleBusinessException(BusinessException e) {
ErrorCode errorCode = e.getErrorCode();
log.info(errorCode.getMessage());
ErrorResponseDTO response = ErrorResponseDTO.of(errorCode);
return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
}
@ExceptionHandler(InvalidJwtException.class)
protected ResponseEntity<ErrorResponseDTO> handleJwtException(InvalidJwtException e) {
ErrorResponseDTO response = ErrorResponseDTO.of(HttpStatus.FORBIDDEN.value(), e.getMessage());
return new ResponseEntity<>(response, HttpStatus.FORBIDDEN);
}
}
위와 같이 전역레벨에서 예외를 처리합니다.
회원가입 시에 중복 닉네임을 썻으면
응답시에는
{
status : 409,
message : "닉네임이 중복되었습니다."
}
위와 같이 클라이언트에게 전달이 되어야 합니다.
여러 예외 상황에 맞는 status code와 message가 다양하게 존재할 텐데 이런 것들을 한 곳에서 관리할 공간으로 ErrorCode라는 enum을 사용합니다.
public enum ErrorCode {
NICKNAME_DUPLICATED(409, "닉네임이 중복되었습니다"),
EMAIL_DUPLICATED(409, "이메일이 중복되었습니다"),
INVALID_EMAIL_TOKEN(400, "이메일 토큰이 유효하지 않습니다"),
NOT_MATCHED_PASSWORD(400, "비밀번호가 일치하지 않습니다"),
NOT_MATCHED_ID(403, "ID가 일치하지 않습니다");
private int status;
private String message;
ErrorCode(int status, String message) {
this.status = status;
this.message = message;
}
...
}
@Getter
@Setter
public class ErrorResponseDTO {
private int status;
private String message;
...
}
응답시 전달할 DTO 클래스입니다. status code와 message를 담고 있습니다.
public class NicknameDuplicatedException extends BusinessException {
@Override
public ErrorCode getErrorCode() {
return ErrorCode.NICKNAME_DUPLICATED;
}
}
세부 비즈니스 예외들은 위와 같이 BusinessException을 상속받도록 합니다.
public class InvalidJwtException extends RuntimeException {
public InvalidJwtException(Throwable e) {
super(e);
}
}
Jwt와 관련해서 MalformedJwtException 등 여러 예외가 있습니다. 이들을 런타임 예외로 중첩하여 사용합니다.
Throwable은 에러 메시지나 에러 경로 등을 활용할만한 여러 메서드가 제공되기 때문에 사용했습니다.
@Override
public void join(UserJoinRequestDTO userJoinRequestDTO) {
if (isEmailDuplicate(userJoinRequestDTO.getEmail())) {
throw new EmailDuplicatedException(); // 이메일이 중복 될 경우
}
if (isNicknameDuplicate(userJoinRequestDTO.getNickname())) {
throw new NicknameDuplicatedException(); // 닉네임이 중복 될 경우
}
...
}
초기 코드에 비해 어떤 예외 상황이 발생했는지 클라이언트도, 개발자도 모두 알 수 있게 되었습니다.
토비의 스프링 4장 예외
https://enterprisecraftsmanship.com/posts/error-handling-exception-or-result/
저도 옛날?에 비슷한 고민을 했던 기억이 나서 당시에 즐겨찾기 해두었던 링크 공유드려요