DTO enum 필드 사용

김민우·2023년 12월 23일
0

잡동사니

목록 보기
18/22

DTO에서 특정 데이터를 제한하여 입력받아야 하는 경우가 있다. 여러 가지 검증 방법을 생각할 수 있겠지만 보통 enum을 활용할 것이다. 최근 진행한 프로젝트에서 이를 적용하면서 직면한 문제점과 이를 해결한 방법을 정리해보았다.

DTO 값을 제한해야 되는 경우


결제 관련 데이터를 받는 상황을 생각해보자. 결제 수단은 아래와 같다.

  • 신용카드
  • 계좌 이체
  • 간편 결제
  • 상품권

값을 4가지로 제한되므로 아래 열게체를 활용하여 요청을 받을 수 있다.

PaymentType.java

public enum PaymentType {
    CREDIT_CARD("신용 카드"),
    ACCOUNT_TRANSFER("계좌 이체"),
    EASY_PAYMENT("간편 결제"),
    GIFT_CARD("상품권");

    private final String method;

    PaymentType(final String method) {
        this.method = method;
    }
}

PaymentRequest.java

@Getter
@Setter
@NoArgsConstructor
public class PaymentRequest {
    private int amount;
    private PaymentType type;
}

PaymentController.java


public class PaymentController {
    @PostMapping("/payment")
    public ResponseEntity<?> pay(@RequestBody final PaymentRequest paymentRequest) {
        // 결제 비지니스 로직 수행
    }
}

아래와 같이 JSON 데이터 값과 enum 값이 일치하면 알맞는 객체로 바인딩이 된다.

{
	"amount" : "10000",
  	"type" : "GIFT_CARD"
}

이게 어떻게 가능한걸까? Spring Boot에서 제공하는 컨버터 클래스가 이를 해준다. 덕분에 사용자는 enum 상수 이름에 해당하지 않는 값을 제한할 수 있으며 어려운 과정 없이 알맞는 enum 인스턴스를 얻을 수 있다.

enum 필드 핸들링


Spring Boot에서 요청 데이터를 enum으로 바인딩하는 방법은 여러 가지가 있다. 각자의 장/단점을 살펴보자.

1. 기본 enum 컨버터 클래스

Spring Boot는 별도의 컨버터 클래스에서 Enum.valueOf() 메서드를 통해 알맞는 인스턴스를 제공한다.

StringToEnumConverterFactory.java

final class StringToEnumConverterFactory implements ConverterFactory<String, Enum> {

	@Override
	public <T extends Enum> Converter<String, T> getConverter(Class<T> targetType) {
		return new StringToEnum(ConversionUtils.getEnumType(targetType));
	}


	private static class StringToEnum<T extends Enum> implements Converter<String, T> {

		private final Class<T> enumType;

		StringToEnum(Class<T> enumType) {
			this.enumType = enumType;
		}

		@Override
		@Nullable
		public T convert(String source) {
			if (source.isEmpty()) {
				// It's an empty enum identifier: reset the enum value to null.
				return null;
			}
			return (T) Enum.valueOf(this.enumType, source.trim());
		}
	}

}

이 방식은 만능이 아니다. 앞/뒤 공백을 제거하는 trim() 메서드는 포함되지만 대/소문자를 구분한다. 따라서, 대/소문자까지 일치하지 않는다면 변환에 실패한다.

{
  	"amount" : "10000",
	"type" : "gift_card"
}

2. @JsonCreator 사용

메시지 바디를 통해 데이터를 전달받는 경우 컨트롤러 메서드에서 @RequestBody를 사용하여 객체를 생성한다.

참고
@RequestBody는 요청 Body 데이터를
MappingJackson2HttpMessageConverter를 통해 변환한다. 이 과정에서 ObjectMapper가 사용되며 기본 생성자 + Setter 를 통해 객체를 생성한다.

따라서, @RequestBody가 붙은 클래스에 기본 생성자 + Setter가 없다면 데이터가 바인딩되지 않는다.

@JsonCreator는 기본 생성자 + Setter 조합 대신 객체를 생성할 수 있게 해주는 어노테이션이다. 생성자나 팩토리 메서드에 이 어노테이션을 붙이면 Jackson은 객체 생성시 해당 메서드를 통해 객체를 생성한다.

PaymentType.java (@JsonCreator 적용)

public enum PaymentType {
    CREDIT_CARD("신용 카드"),
    ACCOUNT_TRANSFER("계좌 이체"),
    EASY_PAYMENT("간편 결제"),
    GIFT_CARD("상품권");

    private final String method;

    PaymentType(final String method) {
        this.method = method;
    }

    @JsonCreator
    public static PaymentType of(final String parameter) {
        return Arrays.stream(values())
                .filter(type -> type.method.equals(parameter))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("결제 유형이 잘못되었습니다."));
    }
}

@RequestBody를 통해 데이터를 바인딩하는 경우 예외 핸들링이 간편해진다. 추가로, 매개변수에 trim() 이나 toUpperCase() 메서드를 적용하여 요청값을 더욱 간편하게 변환을 할 수 있다. 바인딩이 실패한다면 기본값 설정도 가능하다.

그러나, 이는 쿼리 파라미터를 객체로 바인딩하는 경우(@ModelAttribute)엔 불가능하다. 그리고 enum 클래스마다 일일히 메서드를 추가해야 하는 번거로움이 있다.

참고
@ModelAttributeModelAttributeMethodProcessor를 사용하여 클래스 내 적절한 생성자를 찾아 객체를 바인딩한다.

3. Converter<S, T>

Spring Boot가 제공하는 컨버터 대신 별도의 컨버터 클래스를 사용할 수 있다.

MyEnumConverter.java

public class MyEnumConverter implements Converter<String, PaymentType> {
    @Override
    public PaymentType convert(final String source) {
        try {
            return PaymentType.valueOf(source);
        } catch (IllegalArgumentException e) {
            throw new IllegalArgumentException("결제 유형이 잘못되었습니다.");
        }
    }
}

이후, 설정 클래스에 만든 컨버터를 등록하면 된다.

WebConfig.java

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(final FormatterRegistry registry) {
        WebMvcConfigurer.super.addFormatters(registry);
        registry.addConverter(new MyEnumConverter());
    }
}

마찬가지로 trim(), toUpperCase() 등의 메서드 적용이 가능하다. 그러나, enum 마다 해당 컨버터 클래스를 작성해야 하는 번거로움이 있다.

@RequestBody, @ModelAttribute 마다 발생하는 예외가 다르다. 컨버팅 실패 시 IllegalArgumentException이 발생하지만 최종적으로 발생하는 예외가 다르기에 AOP를 통한 예외 핸들링이 힘들다.

  • @ModelAttribute : MethodArgumentNotValidException
  • @RequestBody, @RequestParam : Converter에서 발생시키는 예외

어떤 방법을 사용할까?

결론적으로 어떤 방식으로 요청 데이터를 바인딩하느냐에 따라 사용할 수 있는 방식이 제한된다. 모든 방법을 수용할 수 있는 Converter 클래스는 예외 메시지 핸들링이 힘들다는 단점이 있다.

예외 핸들링이 편하다는 이유로 도입하는 것에 대해 다시 생각해볼 필요가 있다. enum 클래스마다 별도의 작업을 해야하는 번거로움은 확장성을 고려한다면 좋지 않은 선택이다. 이 부분이 걱정된다면 기본 제공하는 컨버터를 사용해도 좋을 것이다.

참고
최근 진행한 프로젝트에서는 기본 컨버터를 활용하였고 예외 메시지를 파싱하여 파라미터를 얻은 후 아래와 같은 별도의 enum을 통해 예외 메시지를 제공했다.

public enum ParamErrorCode {
    INVALID_PAGE("page", "페이지 값이 잘못되었습니다."),
    INVALID_CAFE_ID("studycafeId", "카페 id가 잘못되었습니다."),
    INVALID_DATE("date", "날짜가 잘못되었습니다."),
    INVALID_START_DATE("startDate", "시작 날짜가 잘못되었습니다."),
    INVALID_END_DATE("endDate", "마감 날짜가 잘못되었습니다."),
    INVALID_START_TIME("starTime", "시작 시간이 잘못되었습니다."),
    INVALID_END_TIME("endTime", "마감 시간이 잘못되었습니다."),
    INVALID_HASH_TAGS("hashtags", "해시 테그가 잘못되었습니다."),
    INVALID_CONVENIENCES("conveniences", "편의 시설이 잘못되었습니다."),
    INVALID_SORT_TYPE("sortType", "정렬 기준이 잘못되었습니다."),
    INVALID_REVIEW_TYPE("reviewType", "리뷰 필터 조건이 잘못되었습니다.");
    ...
   
    private String parameter;
    private String message;
  
    ParamErrorCode(final String parameter, final String message) {
    	this.parameter = parameter;
        this.message = message;
    }
}

필드를 enum으로 둬야할까?


일단, DTO에 enum 필드를 둔다는 건 Domain과 DTO의 결합도가 높아진다는 단점이 있다. 추후 장애가 발생할 가능이 크므로 이 둘의 결합도를 낯줘야 한다.

위의 PaymentType 값이 DB에 저장된다고 생각해보자. 레이어드 아키텍쳐(layered architecture)에서 영속 계층(persistence layer)이 표현 계층(presentaion layer)까지 영향을 미치는 상태가 된다. 즉, 계층간 의존성 문제가 발생한다.

개인적으로 DTO에 값을 제한하기 위해 enum 필드가 있는건 좋지 않다고 생각한다. DTO는 배송과정 중 택배 상자에 해당하는 역할이다. @NotNull, @Size와 같은 검증 어노테이션은 상자에 경고 스티커를 붙이는 행동이지만, enum 필드를 적용하는건 마치 상자가 내용물을 검증하는 느낌이든다.

DTO는 변환되야 하는 enum 정보만 알고 있으면 되고, DTO에서 직접적으로 enum의 메서드를 호출할 일은 없다. (DTO는 단순히 택배 상자)

String으로 그냥 두고 검증 어노테이션을 사용하자.

우리가 enum 필드는 두는 이유는 단순히 값을 제한하기 위해서다. enum을 사용하는 가장 큰 이유는 관련된 여러 가지 데이터와 로직을 묶어 다형성을 활용하기 위함이다. 이를 고려하지 않고 단순히 값 검증에만 enum을 사용하기 때문에 앞서 언급한 의존성 문제 등 다양한 문제가 발생한다.

일반적으로 DTO 검증은 검증 어노테이션이나 비지니스 레이어(business layer)에서 진행한다. 위와 같이 객체를 생성/변환 메서드에서 검증을 하기엔 메서드의 책임이 너무 많아진다.

특히, 검증 어노테이션을 활용하면 예외 핸들링이 편해진다. 요청 데이터 방식과 상관없이 @MethodArgumentNotValidException 이 발생하므로 AOP로 예외를 처리하는 경우 이 예외 클래스를 핸들링하면 된다. 또한, @ModelAttribute 의 경우 BindingResult 를 통해 간단히 예외를 핸들링할 수 있다.

그러면 값을 String 으로 받고 검증 어노테이션을 통해 enum에 있는 값과 일치하는지 여부만 판단하는건 어떨까? 필요에 따라 enum으로 변환해야 한다면 비지니스 레이어(business layer)에서 하면 계층간 의존성 문제도 해결된다.

EnumValue.java

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumValidator.class)
public @interface EnumValue {
    Class<? extends Enum<?>> enumClass();
    String message() default "";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    boolean ignoreCase() default false;
}

새로 만든 검증 어노테이션이다. 이는 특정 enum에 의존하지 않고 모든 enum에 대해 사용이 가능하다. 메서드마다 역할은 아래와 같다.

  • enumClass() : 검증할 enum 클래스
  • message() : 예외 메시지 설정
  • ignoreCase() : 대/소문자 구분 여부

EnumValidator.java

public class EnumValidator implements ConstraintValidator<EnumValue, String> {
    private EnumValue enumValue;

    @Override
    public void initialize(final EnumValue constraintAnnotation) {
        this.enumValue = constraintAnnotation;
    }

    @Override
    public boolean isValid(final String value, final ConstraintValidatorContext context) {
        final Enum<?>[] enumConstants = this.enumValue.enumClass().getEnumConstants();
        if (enumConstants == null) {
            return false;
        }

        return Arrays.stream(enumConstants)
                .anyMatch(enumConstant -> convertible(value, enumConstant) || convertibleIgnoreCase(value, enumConstant));
    }

    private boolean convertibleIgnoreCase(final String value, final Enum<?> enumConstant) {
        return this.enumValue.ignoreCase() && value.trim().equalsIgnoreCase(enumConstant.name());
    }

    private boolean convertible(final String value, final Enum<?> enumConstant) {
        return value.trim().equals(enumConstant.name());
    }
}

어노테이션에 설정한 값들을 통해 특정 enum으로 변환 가능한지 여부를 판단한다. 특히, ignoreCase() 값에 따라 대/소문자 구분 여부를 추가로 확인한다.

PaymentRequest.java

@Getter
@Setter
@NoArgsConstructor
public class PaymentRequest {
    private int amount;

    @EnumValue(enumClass = PaymentType.class, message = "결제 유형이 잘못되었습니다.", ignoreCase = true)
    private String type;
}

결론


다시 한번 말하지만 enum은 값을 제한하기 위한 용도로 사용되지 않는다. 이는 Spring Boot에서 제공하는 컨버터 클래스로 인해 가능한거지 enum 자체의 기능은 아니다.

나는 이를 당연시 여겨 평소 DTO 값을 제한해야 한다면 습관적으로 enum을 사용했다. Domain 내에서 사용하는 enum이라 할지라도 별 생각없이 DTO에 포함시켰다. 나도 모르게 계층간 의존성을 무시해버린 셈이다. 이로 인해 예외 핸들링, 계층 의존성 등 다양한 문제를 겪었다.

이를 해결하면서 Spring Boot에서 데이터를 바인딩하는 과정을 복습할 수 있었고 내가 생각하는 DTO에 대한 기준이 더욱 명확해진 것 같다.

0개의 댓글