커스텀 어노테이션으로 Enum 값 검사하기 ( feat. request body의 Enum vs String)

qpwoeiru·2024년 9월 2일
0
post-thumbnail

먼저 request로 온 Enum 값을 커스텀 어노테이션으로 검사하게 된 배경을 간단하게 설명하고자 한다.

Request Body에서 Enum은 Enum vs String

request의 body로 요청을 보낼 때, 서버단에서 enum 타입으로 DTO 필드를 지정해줘도 결국 클라이언트쪽에서 요청하는 건 string으로 입력된다. json도 결국 본질적으로는 string의 일종인 데이터 형태이기 때문이다.
그렇다면 request body로 enum 값을 받을 때 DTO에 enum으로 받아야할까, String으로 받아야할까?

간단하게 각 방법에 대해 장단점을 비교하자면 아래와 같다.

1. Enum

  • 장점 : 값만 제대로 들어온다면 알아서 Json parsing을 통해 서버의 enum 값과 매칭해줘서 편하다.
  • 단점 : 값이 잘못 들어왔을 때 HttpMessageNotReadableException이 발생하는데, 이 때 발생하는 에러가 핸들링 하기 까다롭다.
    해당 에러는 enum 값이 잘못된 경우 외에도 HttpMessageConverter 메서드가 실패할 때 발생하기에 핸들링 하기엔 케이스가 너무 다양하다.

2. String

  • 장점 : 에러 처리가 쉽다. 커스텀으로 에러 핸들링이 가능하다.
  • 단점 : 서버에서 Enum으로 사용하기 위해 따로 매핑해주는 메서드나 로직이 구현되어야 한다.

나는 값의 안전성을 중요하게 생각하는데, enum에서 에러 처리가 어렵다는 단점을 크게 여겼기에 String으로 받기로 결정했다.


@ValidEnum 어노테이션 만들기

@RequestBody로 받을 DTO의 형태는 아래와 같다.

public record UpdateGroupMemberRoleRequest(@NotNull(message = "스터디 그룹 고유 아이디는 필수 입력입니다.") Long studyGroupId,
										   @NotNull(message = "스터디 그룹 멤버의 고유 아이디는 필수 입력입니다.") Long memberId,
										   @NotBlank(message = "변경할 역할은 필수 입력입니다.") RoleOfGroupMember role) {
}

이제 저 RoleOfGroupMember enum을 String 타입으로 바꾸고자 한다.

public record UpdateGroupMemberRoleRequest(@NotNull(message = "스터디 그룹 고유 아이디는 필수 입력 입니다.") Long studyGroupId,
										   @NotNull(message = "스터디 그룹 멤버의 고유 아이디는 필수 입력 입니다.") Long memberId,
										   @NotBlank(message = "변경할 역할은 필수 입력 입니다.") String role) {
}

이렇게 바꾸었을 때 해당 role 값이 RoleOfGroupMember에 유효한 값인지를 확인하는 로직이 필요하다.

ValidEnum

먼저 @ValidEnum이라는 어노테이션으로 만들기 위해 @interface를 만들어준다.

@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumValidator.class)
public @interface ValidEnum {
	String message() default "잘못된 enum 값입니다.";

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

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

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

Target을 정할 때 지금 상황에서는 ElementType.FIELD만 필요하지만, 추후 다양한 곳에서 사용할 수 있도록 확장하고자 ElementType.METHODElementType.PARAMETER,ElementType.FIELD을 사용했다.

message

message는 검증에 실패했을 때 default로 들어갈 메세지 값이다. 해당 어노테이션을 사용할 때 message를 아래처럼 지정할 수도 있다.

@ValidEnum(message = "올바르지 않은 enum 값입니다. : DONE, INPROGRESS, QUEUED")

groups

groups는 특정 상황에서만 검증을 하기 위해 검증을 그룹화할 때 사용한다고 한다. 나도 이해가 잘 안돼서 예시를 찾아봤었는데..
먼저 특정 상황을 구별할 빈 인터페이스로 CreateCheck, EditCheck 두 개를 생성했다고 가정한다. 그리고 아래와 같은 클래스가 있다고 가정하자.

public class CustomStatus{
	@NotBlank(groups = {CreateCheck.class, EditCheck.class})
    String name;
    @NotNull(groups = {EditCheck.class})
    Long hit;
}

이 후, 아래와 같은 컨트롤러에서 위 validation을 진행한다고 가정해보자.
(여기서는 내가 만든 @ValidEnum과 @CustomValid를 별개라고 봐주어야 한다. @CustomValid는 예시의 일부이다.)

@RestController
@RequiredArgsConstructor
public class CustomController{
	@PostMapping
    public ResponseEntity<Object> createTest(@CustomValid(groups = CreateCheck.class) @ModelAttribute CustomStatus status, Errors errors){
    	if(errors.hasError())
        	throw new RequestException("잘못된 요청입니다.",errors);
        return ResponseEntity.ok();
    }
}

이렇게 되면 위의 CustomStatus 클래스에서 CreateCheck 인터페이스가 등록된 validation만 진행한다는 의미이다. 고로 위의 경우라면 name에 대해 @NotBlank만 검증을 진행하고 hit는 @NotNull 검증이 진행되지 않을 것이다.
사실 groups는 내가 만들고자 하는 enum 검증 어노테이션에서는 잘 사용되지 않을 것 같긴 하다. 실제로도 사용되는 경우가 많이 없다고 한다.

payload

payload는 검증하면서 메타데이터를 전달할 때 사용한다. 보통 검증을 실패했을 경우에 대한 심각성 수준을 커스텀 에러 클래스로 등록하고 이를 사용해 따로 처리한다. 아래와 같은 커스텀 state 클래스가 있다고 가정하자.

public class CustomLevel{
	public static class Info implement Payload{}
    public static class Warning implement Payload{}
    public static class Error implement Payload{}
}

이를 아래와 같이 해당 검증에 실패했을 때 특정 수준의 검증 실패임을 나타내도록 등록할 수 있다.

@ValidEnum(payload = CustomLevel.Warning.class)

enumClass

enumClass는 검증하고자 하는 Enum 클래스를 지정한다. 필수 입력이다.

@ValidEnum(enumClass = StatusEnum.class)

위의 4개 중 집중적으로 사용될 것은 messageenumClass라고 생각하면 된다.

EnumValidator

이제 enum validation 로직을 위해 ConstraintValidator<ValidEnum, String>을 구현한다.
앞의 ValidEnum은 아까 만들었던 @interface로 검증할 어노테이션이고, 뒤의 String은 검증을 진행할 값 타입을 의미한다.

public class EnumValidator implements ConstraintValidator<ValidEnum, String> {
	private Class<? extends Enum<?>> enumClass;

	@Override
	public void initialize(ValidEnum constraintAnnotation) {
		this.enumClass = constraintAnnotation.enumClass();
	}

	@Override
	public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
		Object[] enumValues = this.enumClass.getEnumConstants();
		if (enumValues != null) {
			for (Object enumValue : enumValues) {
				if (value.equals(enumValue.toString())) {
					return true;
				}
			}
		}
		return false;
	}
}

initialize()에서 @ValidEnum으로부터 입력받은 enum 클래스를 여기에 등록해준다.
isValid()에서 enum 값에 대한 검증을 시작하는데, enumValue 값이 입력받은 String value와 같으면 검증된 값으로 true를 리턴한다. 끝까지 같은 값이 없으면 false를 리턴해 검증이 실패된다.


이제 @ValidEnum을 아까 DTO에 적용하면 끝이다.

public record UpdateGroupMemberRoleRequest(@NotNull(message = "스터디 그룹 고유 아이디는 필수 입력 입니다.") Long studyGroupId,
										   @NotNull(message = "스터디 그룹 멤버의 고유 아이디는 필수 입력 입니다.") Long memberId,
										   @NotBlank(message = "변경할 역할은 필수 입력 입니다.")
										   @ValidEnum(enumClass = RoleOfGroupMember.class, message = "올바른 enum 값을 입력해주세요. (ADMIN, PARTICIPANT)")
										   String role) {
}

나는 enumClassmessage만 사용해도 충분해서 두 개만 설정하고 검증을 진행했다. 검증코드가 엄청 깔끔해졌다!role에 ADMIN,PARTICIPANT가 아닌 잘못된 값을 넣으니 아까 등록한 message로 error response가 발생한다.


참고
https://m.blog.naver.com/aservmz/222823126774
https://jsy1110.github.io/2022/enum-class-validation/

0개의 댓글

관련 채용 정보