[Springboot] Json 문자열을 Enum 값으로 변환/검증하기

종미(미아)·2024년 5월 27일
9

🌱 Spring

목록 보기
7/9
post-thumbnail

들어가며

우테코 레벨2 미션 방탈출예약프로그램에서 예약 도메인의 예약 상태는 아래와 같이 Enum으로 표현된다.

public enum ReservationStatus {
    BOOKING,
    WAITING
}

예약을 생성하는 POST API의 명세는 아래와 같다.

POST /reservations HTTP/1.1
content-type: application/json
cookie: token=eyJhbGciOiJIUzI1Ni

{
    "date": "2030-4-18",
    "timeId": 1,
    "themeId": 1,
    "status": "WAITING"
}

이때 status 값은 예약 상태를 의미한다. WAITING이면 예약 대기, BOOKING이면 예약 요청이다. request body에서 역직렬화 될 dto는 어떻게 만들어야 할까?

public record ReservationSaveRequest(
        @NotNull(message = "예약 날짜는 비어있을 수 없습니다.")
        LocalDate date,
        @NotNull(message = "예약 시간 Id는 비어있을 수 없습니다.")
        Long timeId,
        @NotNull(message = "테마 Id는 비어있을 수 없습니다.")
        Long themeId,
        @NotNull(message = "예약 상태는 비어있을 수 없습니다.")
        ReservationStatus status) {

    public Reservation toModel(Theme theme, ReservationTime time, Member member) {
        return new Reservation(member, date, time, theme, status);
    }
}

jackson 라이브러리에서 문자열 "WAITNG"을 ReservationStatus.WAITNG으로 변환해준다. 예외처리도 간단하게 아래와 같이 할 수 있다.

    @Override
    public ResponseEntity<Object> handleHttpMessageNotReadable(HttpMessageNotReadableException e, HttpHeaders headers,
                                                               HttpStatusCode status, WebRequest request) {
        if (e.getCause() instanceof InvalidFormatException invalidFormatException) {
            String errorMessage = getMismatchedInputExceptionMessage(invalidFormatException);
            return ResponseEntity.badRequest()
                    .body(new ErrorResponse(errorMessage));
        }
        return ResponseEntity.badRequest()
                .body(new ErrorResponse(e.getMessage()));
    }

    private String getMismatchedInputExceptionMessage(InvalidFormatException e) {
        String type = e.getTargetType().toString();
        String fieldNames = e.getPath()
                .stream()
                .map(Reference::getFieldName)
                .collect(Collectors.joining(", "));
        return fieldNames + String.format("(type: %s) 필드의 값이 잘못되었습니다.", type);
    }

어떤 값이 잘못되었는지 정도는 예외 응답으로 줄 수 있다. 아래는 @MockMvc로 테스트한 결과이다.

ConstraintValidator를 도입한 이유

결론부터 말하자면 ReservationStatus 클래스 자체를 DTO로 받지 않고 String으로 받은 후 ConstraintValidator로 문자열 값을 검증했다. String이 아닌 다른 타입들(Long 등)은 jackson 역직렬화 과정에서 발생하는 HttpMessageNotReadableException에 대해 예외 처리를 해주었지만 이번에는 다르게 처리한 이유는 ReservationStatus는 도메인 객체이기 때문이다.

다른 엔티티들(역시 도메인 객체)도 바로 dto에서 역직렬화를 시킬 수 있지만, 그렇게 하지 않은 이유를 생각해보면 위와 비슷할 것이다. 도메인 객체가 뷰에 노출되고, 서로 의존하는 관계를 갖는건 좋지 않다고 판단했다.

ConstraintValidator로 검증해보자

이미 어노테이션 몇 개를 사용하고 있어서 의존성 설정은 마친 상태였다. Jakarta bean validation에 대한 의존성을 설정해주자. 구현체는 하이버네이트다.

# build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'

참고로 많이 쓰이는 @NotNull, @NotBlank와 같은 어노테이션들도 이 패키지에 속한다.

검증 대상에 붙일 어노테이션 만들기

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {ReservationStatusFormatValidator.class})
public @interface ReservationStatusFormat {

    String message() default "유효하지 않은 예약 상태(ReservationStatus) 값입니다.";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

@Constraint을 사용한다. 위에 나와 있듯이 세 가지 속성을 반드시 포함해주어야 한다.

  1. String message() default [...];
    which should default to an error message key made of the fully-qualified class name of the constraint followed by .message. For example "{com.acme.constraints.NotSafe.message}"

  2. Class<?>[] groups() default {};
    for user to customize the targeted groups

  3. Class<? extends Payload>[] payload() default {};
    for extensibility purposes

Validator 만들기

ConstraintValidator 인터페이스를 구현한 validator를 만든다.

public class ReservationStatusFormatValidator implements ConstraintValidator<ReservationStatusFormat, String> {

    @Override
    public void initialize(ReservationStatusFormat constraintAnnotation) {
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
        if (value == null || value.isBlank()) { // (1)
            return true;
        }
        List<String> reservationStatusNames = Arrays.stream(ReservationStatus.values())
                .map(ReservationStatus::name)
                .toList();
        return reservationStatusNames.stream()
                .anyMatch(reservationStatusName -> isConvertible(reservationStatusName, value));
    }

    public boolean isConvertible(String reservationStatusName, String value) {
        return value.trim().equalsIgnoreCase(reservationStatusName); // (2)
    }
}

(1) String 값이 Null이거나 공백이라면 @NotBlank 어노테이션으로 검증할 것이므로 검증 대상에서 제외한다.
(2) 대소문자 구분 없이 String 값이 ReservationStatus로 변환할 수 있는지 판단한다.

DTO와 Controller에 적용하기

String 필드 위에 어노테이션을 붙인다.

public record ReservationSaveRequest(
        @NotNull(message = "예약 날짜는 비어있을 수 없습니다.")
        LocalDate date,
        @NotNull(message = "예약 시간 Id는 비어있을 수 없습니다.")
        Long timeId,
        @NotNull(message = "테마 Id는 비어있을 수 없습니다.")
        Long themeId,
        @NotBlank(message = "예약 상태는 비어있을 수 없습니다.")
        @ReservationStatusFormat
        String status) {

    public Reservation toModel(Theme theme, ReservationTime time, Member member) {
        String validStatusValue = status.toUpperCase().trim();
        return new Reservation(member, date, time, theme, ReservationStatus.from(validStatusValue));
    }
}

Dispatcher Servlet에서 @Valid로 검증 대상을 확인하면서 argument resolve 과정을 거치기 때문에 @Valid도 꼭 붙여준다.

// ReservationController.java
@PostMapping
public ResponseEntity<ReservationResponse> createReservation(@RequestBody @Valid ReservationSaveRequest request) {
    // 생략

테스트하기

DispatcherServelt - Controller - Validator만 테스트하면 되므로 @MockMvc@WebMVcTest를 사용해 Web layer에서 테스트했다.

    @ParameterizedTest
    @ValueSource(strings = {"\"booking\"", "\" booking\"", "\"BOOKING \"", "\"BOOKINg\""})
    @DisplayName("예약 POST 요청 시 예약 상태 값을 검증하여 유효한 경우 상태코드 201을 반환한다.")
    void crestReservationWithReservationStatusValidation(String statusValue) throws Exception {
        // given
        String request = """
                {
                    "status": "valid",
                    "date": "2023-03-08",
                    "timeId": 1,
                    "themeId": 1
                }
                """;
        ReservationTime expectedTime = new ReservationTime(1L, MIA_RESERVATION_TIME);
        Theme expectedTheme = WOOTECO_THEME(1L);
        Reservation expectedReservation = MIA_RESERVATION(expectedTime, expectedTheme, USER_MIA(1L), BOOKING);

        BDDMockito.given(bookingManageService.scheduleRecentReservation(any()))
                .willReturn(expectedReservation);
        BDDMockito.given(reservationTimeService.findById(anyLong()))
                .willReturn(expectedTime);
        BDDMockito.given(themeService.findById(anyLong()))
                .willReturn(expectedTheme);

        // when & then
        mockMvc.perform(post("/reservations")
                        .cookie(COOKIE)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(request.replace("\"valid\"", statusValue)))
                .andDo(print())
                .andExpect(status().isCreated());
    }
    
    @Test
    @DisplayName("유효하지 않은 예약 상태 값으로 예약 POST 요청 시 상태코드 400을 반환한다.")
    void createReservationWithInvalidReservationStatus() throws Exception {
        // given
        String invalidDateFormatRequest = """
                {
                    "status": "invalid",
                    "date": "2023-03-08",
                    "timeId": 1,
                    "themeId": 1
                }
                """;

        // when & then
        mockMvc.perform(post("/reservations")
                        .contentType(MediaType.APPLICATION_JSON)
                        .cookie(COOKIE)
                        .content(invalidDateFormatRequest))
                .andDo(print())
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.message").exists());
    }

논외지만 이렇게 web layer만 떼서 테스트할 필요가 분명히 있는 경우들을 발견할 때마다 기분이 좋아진다. ㅎㅎ 요즘 슬라이싱 테스트에 꽤 공들이고 있기 때문이다.

소감

최근 프로그래밍하면서 매번 확인하는 규칙이 '기술을 써야 할 이유가 있으면 쓰자'이다. 스프링부트에서 이미 다 제공되는 편리한 기능이면 굳이 커스텀하지 않았는데, 이번에는 확실한 나만의 근거가 있어 도입한 기능이었다. 기술 자체를 습득하는 것보다 이런저런 고민을 하고, 사이드이펙트를 고려하는 게 재밌는 것 같다.

출처

[1]

@Constraint

[2]

ConstraintValidator

[3]

Spring 소스 코드

profile
BE 개발자 지망생 🪐

0개의 댓글