Home [Spring] API서버 Error Handling 전략
Post
Cancel

[Spring] API서버 Error Handling 전략

기존에 사용했던 Error handling 전략

모든 에러를 처리할 수 있는 슈퍼 Exception을 만들어 사용했습니다. 이 Exception에는 ErrorCode가 포함됩니다. 이 ErrorCode를 통해 이 Exception을 처리할 때 어떤 status code와 메시지를 보여줄지 정합니다.

1
2
3
4
5
@RequiredArgsConstructor
@Getter
public class CommonException extends RuntimeException {
    private final ErrorCode errorCode;
}

이 방식의 문제점은 모두 같은 Exception 클래스로 처리된다는 점, 그리고 Exception을 생성하면서 매번 ErrorCode를 선택하는 작업까지 필요하다는 점입니다.

1
2
3
4
5
6
7
8
9
10
11
12
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
    ACCESS_TOKEN_REQUIRED(HttpStatus.UNAUTHORIZED, "액세스 토큰이 필요한 요청입니다."),
    NOT_GRANTED(HttpStatus.FORBIDDEN, "해당 권한이 없습니다."),
    INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."),
    EXPIRED_TOKEN(HttpStatus.NOT_ACCEPTABLE, "만료된 토큰입니다."),
    INVALID_TYPE(HttpStatus.NOT_ACCEPTABLE, "잘못된 형식입니다.");

    private final HttpStatus status;
    private final String message;
}

가능한 모든 메시지를 하드코딩없이 외부로 빼고싶었습니다. 이유는 아래와 같습니다.

  • 코드와 메시지의 관심사 분리
  • 체계적인 메시지 관리
    • errors, messages등으로 그룹화나 주석을 포함할 수 있다.
  • 메시지 응집도를 높이기
    • 분산되어 하드코딩 되어있던 메시지들을 모아서 관리할 수 있다.

ErrorCode에 포함된 메시지도 Spring의 국제화 기능을 사용해서 resource 파일로 뺄 수 없을까? 고민했습니다.

개선된 Error handling 전략

공통 Exception을 상속받는 방식

기존에 하나의 클래스만 사용해서 ErrorHandling을 했지만, 이를 여러 클래스로 나누어 Exception을 관리했습니다. 이때 사용되는 모든 Exception은 국제화 기능을 포함한 LocalizedMessageException을 상속받습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Getter
public class LocalizedMessageException extends RuntimeException {

    private final HttpStatus status;
    private final String messageId;
    private final Object[] arguments;

    public LocalizedMessageException(HttpStatus status, String messageId, Object... arguments) {
        super(messageId);
        this.status = status;
        this.messageId = messageId;
        this.arguments = arguments;
    }
}

// 사용 예시
public class InvalidTokenException extends LocalizedMessageException {
    public InvalidTokenException() {
        super(HttpStatus.UNAUTHORIZED, "invalid.token");
    }
}

// Argument 사용 예시
public class IllegalParamterException extends LocalizedMessageException {
    public IllegalParamterException(String paramName) {
        super(HttpStatus.UNAUTHORIZED, "illegal.param", paramName);
    }
}

필요에 따라서 argument를 받아 message를 조립할 수 있습니다. 생성자를 호출해서 메시지 build에 필요한 정보들을 담고 있습니다.

이런 방식으로 ErrorCode대신 공통 Exception을 상속받는 방식으로 설계한 이유는 아래와 같습니다.

  • message별로 원하는 argument만을 깔끔하게 받아 message에 포함시킬 수 있다.
    • ErrorCode를 넘기는 방식에서는 argument를 건내는 방식을 일반화해야하므로 Object[]로 받을 수 밖에 없다.
  • 상속 관계를 표현할 수 있고, 이로 인해 유연한 에러 처리가 가능하다.
    • 그룹화가 된다는 의미이다. 상속 관계를 이용해 Auth와 관련된 exception만 특수 처리할 수도 있다.
  • 필요시 내부 로직을 추가하여 유연함을 확보할 수 있다.
    • 클래스로 구분되므로 method, constructor등에서 필요한 로직을 작성할 수 있다.
  • 객체 생성시 ErrorCode가 아닌, 의미 있는 값만 전달하여 코드를 더 명확하게 보이게 하는 효과가 있다.
    • 위의 예시처럼 Object[]가 아닌 명확한 parameter로 받을 수 있고, new CustomException(ErrorCode.INVALID_TOKEN)보다는 new InvalidTokenException()이 더 명확하고 간결해보인다.

이렇게 Exception을 구성하였고, 이 Exception을 처리하기 위해서 ControllerAdvisor가 필요합니다. 저는 이렇게 ControllerAdvisor를 만들었습니다.

Message를 번역하는 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
public class ControllerAdvisor {

    private final MessageSource messageSource;

    @ExceptionHandler
    protected ResponseEntity<ErrorResponseDto> localizedException(LocalizedMessageException e, Locale locale) {
        return ResponseEntity.status(e.getStatus()).body(new ErrorResponseDto(messageSource, locale, e));
    }

    @ExceptionHandler
    protected ResponseEntity<ErrorResponseDto> exception(Exception e) {
        return ResponseEntity.internalServerError().body(new ErrorResponseDto(e));
    }
}

LocalizedMessageException를 받으면, Message를 load하고 Dto를 통해 API로 뿌려주는 로직입니다. 그 외의 Exception은 처리되지 않은 Exception으로, internalServerError를 뿌립니다.

여기서는 예시를 들기 위해서 이렇게 작성했지만, Bean Validation을 사용할 경우에는 BindException를 추가로 handling해주거나 필요에 따라 맞춰서 사용하시면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Getter
public class ErrorResponseDto {
    private final String timestamp;
    private final HttpStatus status;
    private final String code;
    private final String message;

    private ErrorResponseDto(String timestamp, HttpStatus status, String code, String message) {
        this.timestamp = timestamp;
        this.status = status;
        this.code = code;
        this.message = message;
    }

    public static ErrorResponseDto fromLocalizedException(MessageSource messageSource, Locale locale, LocalizedMessageException e) {
        String timestamp = LocalDateTime.now().toString();
        HttpStatus status = e.getStatus();
        String code = e.getClass().getSimpleName();
        String message = messageSource.getMessage(e.getMessageId(), e.getArguments(), locale);
        return new ErrorResponseDto(timestamp, status, code, message);
    }

    public static ErrorResponseDto fromException(Exception e) {
        String timestamp = LocalDateTime.now().toString();
        HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
        String code = e.getClass().getSimpleName();
        String message = e.getMessage();
        return new ErrorResponseDto(timestamp, status, code, message);
    }
}

그리고 MessageId를 message로 번역하기 위해서는 MessageSource가 필요합니다. MessageSource를 주입받고 실제 메시지를 번역하는 로직은 ErrorResponseDto에 포함되어있습니다. fromLocalizedException로 messagesource, locale, messageId를 넘겨주면 내부에서 번역하고 dto에 저장합니다.

이렇게 생성 메서드를 구성한 이유는 아래와 같습니다.

  1. message 번역 처리를 공통 메서드(여기서는 생성자)로 묶고싶었기 때문입니다.
    • 나중에 Filter에서도 동일한 처리를 합니다.
    • ErrorResponseDto에 번역된 message를 바로 받게 해두면, 매번 ErrorResponseDto를 생성할 때마다 직접 번역하는 과정이 필요합니다.
  2. 같은 String타입에 이름도 message, messageId로 거의 비슷해서 개발자가 실수로 message를 번역하지 않고 넣어도 컴파일 타임에 알아차리기 힘듭니다.
    • ErrorResponseDto(String messageId) 와 같은 생성자 시그니처라면, message를 넣는게 아닌가? 하고 헷갈리는 경우가 은근 많습니다.

물론 이렇게 구성하면 ErrorResponseDto 구현이 순수해지지 않는다는 단점이 있습니다. (Dto는 그 자체로 Data를 운반하는 역할인데, 여기서 message번역 로직까지..?)

그리고 사실 ErrorResponseDto는 이 프로그램에서 딱 2곳(ControllerAdvisor, ExceptionHandlerFilter)에서만 생성하고, 번역하는 코드 자체도 2~3줄 내외이므로 크게 중복되지는 않습니다. 결국 취향 차이로 갈릴 문제인 것 같습니다.

Filter단의 오류도 받아야 한다.

위에서는 ControllerAdvisor만 보여드렸습니다. 문제는 JWT로 Authorization을 확인하는 로직이 필터에 있다는 점입니다. ControllerAdvisor는 Controller의 exception들만 처리할 수 있습니다. Filter에서 발생하는 excecption은 따로 Exception을 처리하는 filter를 두어 해결해야합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Slf4j
@Component
@RequiredArgsConstructor
public class ExceptionHandlerFilter extends OncePerRequestFilter {

    private final ObjectMapper objectMapper = new ObjectMapper();
    private final MessageSource messageSource;


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } catch (LocalizedMessageException e) {
            makeLocalizedExceptionResponse(response, request.getLocale(), e);
        } catch (Exception e) {
            makeExceptionResponse(response, e);
        }
    }

    // LocalizedMessageException를 처리
    private void makeLocalizedExceptionResponse(HttpServletResponse response, Locale locale, LocalizedMessageException e) throws IOException {
        sendResponse(new ErrorResponseDto(messageSource, locale, e));
    }

    // 그 외의 Exception을 처리
    private void makeExceptionResponse(HttpServletResponse response, Exception e) throws IOException {
        sendResponse(new ErrorResponseDto(e));
    }

    // 실제 response를 설정하는 함수
    private static void sendResponse(HttpServletResponse response, Exception e) throws IOException {
        response.setStatus(e.getStatus().value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        ErrorResponseDto exceptionDto = new ErrorResponseDto(messageSource, locale, e);
        String exceptionMessage = objectMapper.writeValueAsString(exceptionDto);
        response.getWriter().write(exceptionMessage);
    }
}

이런 방식으로 Filter단에서 발생하는 에러를 받아 직접 처리합니다. 이 Filter는 물론 Jwt 인증을 시도하는 filter 앞에 있어야합니다. 그래야 jwt 인증 filter에서 던지는 exception을 여기서 받을 수 있습니다.

1
2
3
// 생략
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(exceptionHandlerFilter, JwtAuthenticationFilter.class) // 이렇게 인증 필터 앞에 설치

이 Filter에서는 Dto를 바로 반환할 수 없고, 직접 writer에 작성해서 보내야합니다. objectMapper를 활용해서 json텍스트로 변환해 write하면 됩니다.

This post is licensed under CC BY 4.0 by the author.

[Spring] Lombok @Builder 생성자에 사용할 때 default값 설정하기

[CS] Blocking, Non-Blocking vs Sync, Async