eelseungmin

[Spring] Enum으로 요청, 검증, 응답하기

by eelseungmin

배경

꼭 Java가 아닌 다른 언어를 사용하더라도 Enum을 많이들 활용하는데 이는 다음과 같은 장점이 있기 때문일 것이다.

  • 문자열과 비교해, IDE의 적극적인 지원을 받을 수 있다.
    • 자동완성, 오타검증, 텍스트 리팩토링 등등
  • 허용 가능한 값들을 제한할 수 있다.
  • 리팩토링시 변경 범위가 최소화된다.
    • 내용의 추가가 필요하더라도, Enum 코드 외에 수정할 필요가 없다.

나도 위와 같은 장점을 기대하고 REST API 개발에 Enum을 적극적으로 활용하고 있다.

 

프로젝트에서 사용하는 엔티티 하나가 Enum 타입의 필드를 포함하고 있는데, 이 때문에 Request Body로 사용하는 DTO 내부에 Enum 타입의 필드를 받아야 할 필요가 생겼다.

 

Enum으로 요청 받기 - RequestBody에 Enum 필드가 포함된 경우

@Schema(description = "리뷰 등록 요청 DTO")
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class CreateReviewRequest {

    @Schema(description = "평점")
    @NotNull(message = "rating은 필수입니다.")
    @Min(value = 1, message = "최소 평점은 1입니다.")
    @Max(value = 5, message = "최대 평점은 5입니다.")
    private Integer rating;

    @Schema(description = "음식점 분위기 목록")
    @Size(min = 1, message = "vibes는 하나 이상의 값이 필요합니다.")
    @Valid
    private List<VibeRequest> vibes;

    @Schema(description = "몇 명과 함께 갔나요?")
    @PositiveOrZero(message = "participants는 0 이상의 양수여야 합니다.")
    private Integer participants;

    @Schema(description = "룸이 있었나요?", example = "YES", allowableValues = {"YES", "NO", "UNKNOWN"})
    private HasRoom hasRoom; // 문제의 필드

    @Schema(description = "리뷰", example = "양 많고 사장님이 친절합니다.")
    @Size(max = 100, message = "100자 이내로 입력해주세요.")
    private String content;
}

위처럼 HasRoom이라는 Enum을 사용했다. 물론 Enum 타입으로 요청을 받는 게 오답이라는 것은 아니다.

문제는 이 상태에서 Enum에 허용되지 않은 값으로 요청을 보냈을 때 발생한다.(당시 캡처를 해두지 않아 인용문으로 대체한다.)

 

문제점

org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error:

위와 같은 예외가 발생하는데, 이 예외는 반드시 Enum 값이 잘못된 게 아니더라도 발생할 여지가 있다.

즉 에러 메시지를 정제하기 힘들어질 뿐만 아니라 Bean Validation을 활용하고 있다면 위 예외를 처리하기 위해 기존의 ExceptionHandler를 추가로 수정해야 한다는 번거로움도 있다.

 

Enum 대신 String으로 요청 받기

@Schema(description = "리뷰 등록 요청 DTO")
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class CreateReviewRequest {

    @Schema(description = "평점")
    @NotNull(message = "rating은 필수입니다.")
    @Min(value = 1, message = "최소 평점은 1입니다.")
    @Max(value = 5, message = "최대 평점은 5입니다.")
    private Integer rating;

    @Schema(description = "음식점 분위기 목록")
    @Size(min = 1, message = "vibes는 하나 이상의 값이 필요합니다.")
    @Valid
    private List<VibeRequest> vibes;

    @Schema(description = "몇 명과 함께 갔나요?")
    @PositiveOrZero(message = "participants는 0 이상의 양수여야 합니다.")
    private Integer participants;

    @Schema(description = "룸이 있었나요?", example = "YES", allowableValues = {"YES", "NO", "UNKNOWN"})
    @EnumValidation(
            enumClass = HasRoom.class,
            message = "'YES', 'NO, 'UNKNOWN'만 입력해주세요."
    )
    private String hasRoom; // String으로 바뀌었다.

    @Schema(description = "리뷰", example = "양 많고 사장님이 친절합니다.")
    @Size(max = 100, message = "100자 이내로 입력해주세요.")
    private String content;
}

기존 방식과의 차이는 Enum 타입이었던 필드가 String으로 바뀌었고, 현재 Bean Validation 방식으로 대부분의 필드를 검증하고 있기 때문에 일관성도 가져가기 위해 Custom Validator를 사용했다는 점이다.

 

Enum 검증 구현

아마 Custom Validator 구현에서 어려움을 느낀 분들도 있을 것이다. 그럴 땐 스프링 개발자들이 남긴 양질의 코드를 참고해 보자.

나 같은 경우는 Bean Validation의 하나인 @NotNull을 참조하였다.

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(List.class)
@Documented
@Constraint(validatedBy = { })
public @interface NotNull {

   String message() default "{jakarta.validation.constraints.NotNull.message}";

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

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

   /**
    * Defines several {@link NotNull} annotations on the same element.
    *
    * @see jakarta.validation.constraints.NotNull
    */
   @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
   @Retention(RUNTIME)
   @Documented
   @interface List {

      NotNull[] value();
   }
}
  • @Target: 어노테이션을 적용할 범위를 지정한다.
  • @Retention(Runtime): Runtime 시 접근이 가능하다.
  • @Constraint: 유효성 검증을 적용할 때 사용할 클래스를 지정한다.

 

위와 유사하게 구현해 보자.

@Constraint(validatedBy = EnumValidator.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EnumValidation {

    String message() default "Invalid value. This is not permitted.";

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

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

    Class<? extends java.lang.Enum<?>> enumClass();
}

EnumValidator라는 클래스를 구현해서 해당 클래스에서 검증을 진행하고, DTO 내 필드에만 사용할 것이기 때문에 타겟을 필드로 지정했다. 또한 Enum 종류에 따라 유동적으로 검증을 적용하기 위해 enumClass 속성도 추가해 주었다.

요청 값의 대소문자 구분을 무시하고 싶다면 ignoreCase라는 속성도 추가해서 사용하면 된다.

 

다음은 EnumValidator 클래스를 보자.

public class EnumValidator implements ConstraintValidator<EnumValidation, String> {

    private Set<String> enumNames;

    @Override
    public void initialize(EnumValidation constraintAnnotation) {
        enumNames = Stream.of(constraintAnnotation.enumClass().getEnumConstants())
                .map(Enum::name)
                .collect(Collectors.toSet());
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return enumNames.contains(value);
    }
}

ConstraintValidator 인터페이스를 구현하면 된다. 제네릭 타입의 왼쪽에는 아까 작성한 어노테이션의 이름을, 오른쪽에는 검증을 적용할 필드의 타입을 적으면 된다.

 

내부엔 private으로 Set을 하나 선언해 주었다. contains 메서드의 시간복잡도가 O(1)로 매우 빠르기 때문에 채택했다.

Enum의 name(name은 Enum에 선언된 필드의 이름을 말한다. HasRoom이라는 Enum에 YES, NO라는 값이 있다면 이를 name이라고 한다.)을 전부 Set에 집어넣은 뒤, Set의 contains를 사용해 들어온 값을 검증하는 구조이다.

 

내가 작성한 API 중 하나의 쿼리스트링에 여러 개의 값을 받아와야 하는 API가 있어 컨트롤러를 다음과 같이 작성하고, List 타입도 검증할 수 있도록 EnumListValidator까지 추가로 작성했다.

@GetMapping("/api/place")
public ResponseEntity<ApiSuccessResponse<SearchPlaceResponse>> searchPlace(
        @PositiveOrZero(message = "위도는 0 또는 양수여야 합니다.") @RequestParam(defaultValue = "37.505098") Double latitude,
        @PositiveOrZero(message = "경도는 0 또는 양수여야 합니다.") @RequestParam(defaultValue = "127.032941") Double longitude,
        @EnumValidation(enumClass = FoodType.class, message = "음식에 허용된 값만 입력해주세요.") @Size(min = 1, message = "음식은 입력 시 최소 1개의 값이 필요합니다.")
        @RequestParam(required = false) List<String> food,
        @EnumValidation(enumClass = VibeType.class, message = "분위기에 허용된 값만 입력해주세요.") @Size(min = 1, message = "분위기는 입력 시 최소 1개의 값이 필요합니다.")
        @RequestParam(required = false) List<String> vibe,
        @PositiveOrZero(message = "페이지 번호는 0 또는 양수여야 합니다.") @RequestParam(defaultValue = "0") Integer page
) {
	(생략)
}
@Constraint(validatedBy = {EnumValidator.class, EnumListValidator.class})
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface EnumValidation {
	(생략)
}
public class EnumListValidator implements ConstraintValidator<EnumValidation, List<String>> {

    private Set<String> enumNames;

    @Override
    public void initialize(EnumValidation constraintAnnotation) {
        enumNames = Stream.of(constraintAnnotation.enumClass().getEnumConstants())
                .map(Enum::name)
                .collect(Collectors.toSet());
    }

    @Override
    public boolean isValid(List<String> values, ConstraintValidatorContext context) {
        if (values == null) {
            return true;
        }

        return values.stream().map(String::toUpperCase).allMatch(enumNames::contains);
    }
}

 

전부 구현한 뒤 맨 처음에 했던 것처럼 Enum에 선언되지 않은 값으로 요청을 던져보았다.

이번엔 @NotNull 등 Reqeust Body의 필드를 검증할 때 발생하던 것과 같은 MethodArgumentNotValidException이 발생한다.

해당 예외는 Bean Validation을 ControllerAdvice에서 전역 예외 처리를 하기 위해 이미 코드를 작성한 상태이다.

 

테스트도 정상적으로 통과되는 것을 확인할 수 있다.

 

Enum으로 요청받기 - Query String에 Enum이 포함된 경우

String으로 받은 뒤 Enum으로 변환하는 코드가 컨트롤러에 들어가도 괜찮겠지만, 그런 코드가 Enum을 Query String에 사용할 때마다 반복적으로 들어가는 사태를 피하고 싶은 사람들이 대부분일 것이다. 다음과 같이 해보자.

 

Converter 구현

1. org.springframework.core.convert.converter.Converter의 구현 클래스를 만든다.

public class FoodTypeRequestConverter implements Converter<String, FoodType> {

    @Override
    public FoodType convert(String source) {
        return FoodType.valueOf(source.toUpperCase());
    }
}

 

2. org.springframework.web.servlet.config.annotation.WebMvcConfigurer를 구현한 클래스에 addFormatters를 재정의한 메서드를 만든다.

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new VibeTypeRequestConverter());
        registry.addConverter(new FoodTypeRequestConverter());
    }
}

일련의 과정을 거치면 Query String에 해당하는 Enum이 포함되어 있을 때 알아서 convert 메서드를 호출하여 변환을 수행한다.

 

Enum name 대신 value를 JSON으로 응답하기

이제 마지막으로 Enum을 JSON으로 응답해 보자. 

 

기본적으로 Enum 타입으로 JSON 응답을 하게 되면 value가 아니라 name이 출력된다. 다음 Enum은 name 뿐만 아니라 title이라는 value도 가지고 있는데, 이 값을 응답하고 싶으면 어떻게 해야 할까?

@AllArgsConstructor
public enum VibeType {

    NOISY("시끌벅적해요"),
    TRENDY("트렌디해요"),
    GOOD_SERVICE("서비스가 좋아요"),
    QUIET("조용해요"),
    MODERN("모던해요"),
    NICE_VIEW("뷰맛집이에요");

    private final String title;

    public String getTitle() {
        return title;
    }
}

 

이 문제는 JSON 변환을 담당하는 jackson 라이브러리의 @JsonValue 어노테이션을 getter에 붙여서 해결이 가능하다.

import com.fasterxml.jackson.annotation.JsonValue;
import lombok.AllArgsConstructor;

@AllArgsConstructor
public enum VibeType {

    NOISY("시끌벅적해요"),
    TRENDY("트렌디해요"),
    GOOD_SERVICE("서비스가 좋아요"),
    QUIET("조용해요"),
    MODERN("모던해요"),
    NICE_VIEW("뷰맛집이에요");

    private final String title;

    @JsonValue
    public String getTitle() {
        return title;
    }
}

 

참조

https://techblog.woowahan.com/2527/

https://jjam89.tistory.com/241

블로그의 정보

eel.log

eelseungmin

활동하기