티스토리 뷰
서론
필자는 파이썬 백엔드 출신이며, 지난 8월 이직을 하면서 자바로 기술 스택 전환을 했다. 파이썬을 할 때엔 주로 FastAPI를 사용했는데, Spring Boot로 넘어오면서 좀 당황했던 부분 중 하나가 Enum 타입에 대한 파라미터를 처리하는 방식이었다.
{
"detail": [
{
"type": "enum",
"loc": [
"query",
"status"
],
"msg": "Input should be 'NOT_STARTED','IN_PROGRESS','DONE' or 'FAILED'",
"input": "as",
"ctx": {
"expected": "'NOT_STARTED','IN_PROGRESS','DONE' or 'FAILED'"
}
}
]
}
위 JSON은 FastAPI에서 기본적으로 제공해 주는 Enum 파라미터에 대한 에러 피드백이다. 어떤 Enum name이 존재하는지, 어떤 입력이 제공되어 에러가 발생했는지 등에 대해 한눈에 확인할 수 있는 좋은 피드백이다.
반면 Spring Boot의 경우에는,
{
"timestamp": "2024-01-21T06:08:08.429+00:00",
"status": 400,
"error": "Bad Request",
"path": "/test"
}
위와 같이 딱히 피드백 없이 Bad Request 정도만 응답으로 떨어지게 된다.
API의 에러 피드백은 최대한 유용한 정보를 제공해야 합니다. 에러 피드백은 반드시 컨슈머에게 무엇이 문제인지 알려 주어야 하며, 컨슈머가 곧바로 해결할 수 있는데 도움이 되는 정보를 제공해야 합니다.
아르노 로렛, 『웹 API 디자인』 , 영진닷컴(2020), p146.
위 책의 내용과 같이, 특히 Enum같이 엄격한 형태의 파라미터를 다루기 위해선 클라이언트가 에러의 원인을 바로바로 정확히 파악할 수 있는 상세한 피드백이 동반되어야 한다. 이를 개선하기 위한 과정을 소개해보고자 한다.
설계
우선 우리가 원하는 형태로 에러 피드백을 제공하기 위해, 전역 예외처리를 적용할 것이다. 그 후, 커스텀 Converter를 제작하여, Enum 변환 실패 시 전역 예외처리를 발생시키기 위한 형태의 Exception을 던질 것이다.
public enum Status {
NOT_STARTED, IN_PROGRESS, DONE, FAILED;
public static List<String> getNames() {
return Stream.of(Status.values())
.map(Status::name)
.toList();
}
}
이번 예제에서 사용할 Enum이다. getNames 메서드를 따로 구현해 두어, 해당 Enum이 어떤 형태의 값들을 받을 수 있는지 피드백으로 제공해줄 것이다. 이렇게 메서드로 구현해 두면 새 Enum 값이 추가되어도 피드백 메시지를 수정할 필요가 없어 편리하다.
전역 예외처리 적용
우리가 원하는 형태대로 에러 피드백을 제공하기 위해, 커스텀 RuntimeException과 Response Body 클래스를 만들고 활용할 것이다. 이번 예제에선 최대한 디테일한 부분은 패스하고 간단한 형태로 제작해보려 한다.
Error Code
@RequiredArgsConstructor
@Getter
public enum ErrorCode {
// 422
STATUS_MISMATCH(HttpStatus.UNPROCESSABLE_ENTITY,
String.format("잘못된 `status` 입니다. expected: %s (소문자 가능)", Status.getNames())
);
private final HttpStatus httpStatus;
private final String message;
}
우선, 사용자에게 제공할 에러에 대한 Status Code 및 메시지를 Enum 형태로 보관할 ErrorCode 클래스이다. 에러 메시지로 Status의 허용 가능한 값들이 제공된다.
Custom Exception
@Getter
public class CustomException extends RuntimeException {
private final ErrorCode errorCode;
private final String input;
public CustomException(ErrorCode errorCode, String input) {
this.errorCode = errorCode;
this.input = input;
}
public CustomException(String message, ErrorCode errorCode, String input) {
super(message);
this.errorCode = errorCode;
this.input = input;
}
}
Exception이 발생하면, catch 후 해당 CustomException 클래스를 다시 던질 것이다. 주로 클라이언트의 Invalid한 입력에 의해 고의 혹은 타의로 발생된 Exception을 대체하여, 상세한 피드백을 제공하는 용도로 사용된다.
Custom Error Response Body
public record ErrorResponseVo(
int status,
String code,
String input,
String message
) {
public static ResponseEntity<ErrorResponseVo> toResponseEntity(ErrorCode e, String input) {
return ResponseEntity
.status(e.getHttpStatus())
.body(new ErrorResponseVo(e.getHttpStatus().value(), e.name(), input, e.getMessage()));
}
}
에러 발생 시 제공할 Response Body의 형태를 정의한 클래스이다. 간단하게 status, Error Code, 사용자 입력과 메시지 정도만 제공하려 한다.
간단한 예제를 위해 input을 String 형태로만 고려하였으니, 실무에서는 숫자, boolean 혹은 list 등의 타입 또한 고려하여 사용하자.
Custom Exception Handling
@Slf4j
@ControllerAdvice
public class CustomExceptionHandler {
@ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponseVo> handleCustomException(CustomException e) {
log.error("An `%s` occurs!".formatted(e.getErrorCode().name()));
return ErrorResponseVo.toResponseEntity(e.getErrorCode(), e.getInput());
}
}
마지막으로, Exception을 적절히 처리해줄 Handler 클래스다. 서버 측엔 어떤 에러가 발생했는지에 대한 로그 한 줄만 남겨두고, 에러 정보를 담은 ResponseEntity를 만들어 클라이언트에 제공해 준다.
Custom Converter 제작
Converter, 꼭 만들어야 하는가?
스프링은 기본적으로 MethodArgumentResolver에서 특정 타입에 대한 컨버터가 등록되어 있음에도 컨버팅에 실패할 시, MethodArgumentTypeMismatchException을 던져주고 있다. (자세한 건 스프링 프레임워크 해당 코드 부분을 확인해보자)
@ExceptionHandler(TypeMismatchException.class)
public ResponseEntity<ErrorResponseVo> handler(TypeMismatchException e) {
if (e.getRequiredType() != null && e.getRequiredType().isEnum()) {
return ErrorResponseVo.toResponseEntity(ErrorCode.ENUM_MISMATCH, Objects.toString(e.getValue()));
}
throw e;
}
따라서 모든 Enum 타입에 대해 동일한 에러 피드백을 제공하는 게 목적이라면, 위와 같이 전역 예외처리를 적용해도 문제 될 부분은 없다. 그러나 아래와 같은 이유로 인해, 이번 예제에선 Custom Converter를 적용해보려 한다.
- 더 친절한 타입 컨버팅
- enum을 소문자로 입력하는 것을 허용한다던가, 잘못 들어간 띄어쓰기 등을 위해 trim()을 적용해 준다던가 하는 커스터마이징을 자유자재로 할 수 있다.
- 참고로, trim()은 스프링의 기본 컨버터인 StringToEnumConverterFactory에도 적용되어 있으니, 커스텀할 때 꼭 빼먹지 말자.
- enum을 소문자로 입력하는 것을 허용한다던가, 잘못 들어간 띄어쓰기 등을 위해 trim()을 적용해 준다던가 하는 커스터마이징을 자유자재로 할 수 있다.
- 특정 Enum 타입에 특화된 피드백
- 이번 예제에선 Status란 Enum을 다루고 있는데, 다른 Enum에도 의도치 않은 컨버팅과 예외 처리가 제공되는 것을 방지할 수 있다.
Converter 구현
public class StatusConverter implements Converter<String, Status> {
@Override
public Status convert(String source) {
try {
return Status.valueOf(source.toUpperCase().trim());
} catch (IllegalArgumentException e) {
throw new CustomException(ErrorCode.STATUS_MISMATCH, source);
}
}
}
우선 위와 같이, Converter의 구현체를 만들어주면 된다. 이 예제에선 입력된 source에 대해 capitalize와 trim을 적용해 주었다. 만약 Status 타입으로의 변환에 실패할 시, 아까 정의해 둔 커스텀 익셉션을 던져 전역 예외처리를 발동시킨다.
Converter 등록
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new StatusConverter());
}
}
다음으로, FormatterRegistry에 만들어둔 커스텀 컨버터를 등록해 주면 된다. 이렇게 등록된 컨버터는 FormatterRegistry의 구현체인 FormattinConversionService가 적절히 사용해 줄 것이다.
결과
{
"status": 422,
"code": "STATUS_MISMATCH",
"input": "NOT_STARTE",
"message": "잘못된 `status` 입니다. expected: [NOT_STARTED, IN_PROGRESS, DONE, FAILED] (소문자 가능)"
}
위 과정들을 통해, 이렇게 더 자세한 형태의 에러 피드백을 제공할 수 있게 되었다.
API는 항상 클라이언트 관점에서 작성되어야 한다는 것을 명심하고, 어떻게 하면 더 직관적이고 세세한 API 스펙을 제공할 수 있을 지에 대해 고민하는 백엔드 엔지니어가 되어보자. 파이팅.
- Total
- Today
- Yesterday
- 모델 추론
- ddd
- 회고
- mlops
- 백엔드
- S3+CloudFront
- Gunicorn
- 넷플릭스
- Ai
- 모델 추론 최적화
- Triton Inference Server
- AWS
- 유난한도전
- memory leak
- 조직문화
- 개발자동아리
- 토스
- Python
- 웹사이트배포
- 규칙없음
- s3
- uvicorn
- 개발자회고
- 정적웹사이트
- 사이드프로젝트
- 메모리 누수
- CloudFront
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |