DTO 필드로 Enum을 두어도 괜찮을까?

Alex·2024년 9월 14일

Binder프로젝트

목록 보기
9/18

DTO enum 필드 사용

@Getter
@RequiredArgsConstructor
public class SearchDto {

    private final BinType type;

    @NotNull
    private final Double latitude;

    @NotNull
    private final Double longitude;

    @NotNull
    private final Integer radius;

}

현재 검색을 할 때 쓰레기통의 타입을 Enum으로 제한해서 값을 가져오고 있다.
우선, 고민하게 된 이유는 DTO에서 비즈니스 로직을 검증하는 것을 줄이는 게 좋다고 생각했기 때문이다.

DTO에서 값 검증을 할 수는 있지만 이건 정말 데이터 형식과 관련된 것들이 많다.
검색 타입을 쓰레기통의 타입(음료수,담배꽁초,일반,재활용)에 한정해놓은 것도 비즈니스 로직이다.

그렇다면, 이걸 DTO에서 해도 될까?

Enum과 DTO

현재 SearchDto로 type을 보낼 때 String 값으로 보내면 이걸 통해서 Enum으로 변형된다.

다만, 이 방식은 대소문자를 구별해서 보내지 않으면 예외가 뜬다.
General이라고 하지 않고 general이라고 하면

이런 예외가 발생한다.

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

생성자나 팩토리 메서드에 이 어노테이션을 붙이면 Jackson은 객체 생성시 해당 메서드를 통해 객체를 생성한다. 이걸 팩토리메서드나 생성자에 붙여주면 된다.

public class EnumValidator implements ConstraintValidator<EnumValue, String> {

    private EnumValue enumValue;

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

    @Override
    public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
        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());
    }
}


@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;
}

@Getter
@RequiredArgsConstructor
public class SearchDto {

    @EnumValue(enumClass = BinType.class, message = "쓰레기통 타입이 잘못됐습니다.", ignoreCase = true)
    private final String type;

    @NotNull
    private final Double latitude;

    @NotNull
    private final Double longitude;

    @NotNull
    private final Integer radius;

}

근데 이렇게 하니까 오히려... 프론트단에서 어떤 값만 받을 수 있는지를 알기 어렵다는 단점이 있었다.

다만 Enum을 DTO에 쓸 때의 문제?는 한번쯤 생각해보면 좋을 거 같다.

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)에서 하면 계층간 의존성 문제도 해결된다.

profile
답을 찾기 위해서 노력하는 사람

0개의 댓글