Bean Validation 불편함 해결하기

공병주(Chris)·2023년 3월 8일
0
post-thumbnail

2023 글로벌미디어학부 졸업 작품 프로젝트 Dandi에서 헥사고날 아키텍처를 적용하면서 Bean Validation을 사용했는데요. 처음써본 건 아니지만, 헥사고날 아키텍처로의 전환 속, Bean Validation을 사용하면서 제가 느낀 불편함을 개선할 수 있는 방법에 대해 고민해보았습니다.

아래처럼 Bean Validation을 통해서 검증을 많이 하실겁니다. 개선 방법은 만들면서 배우는 클린 아키텍처에서 제공한 방식을 기반으로 합니다.

public class NicknameUpdateCommand {

    private static final String INVALID_NICKNAME_MESSAGE = "닉네임은 공백없이 (.), (-), 영어와 숫자로 이루어진 2 ~ 23자여야 합니다.";

    @NotNull(message = INVALID_NICKNAME_MESSAGE)
    @NotBlank(message = INVALID_NICKNAME_MESSAGE)
    @Pattern(regexp = "[a-zA-Z0-9-.]*", message = INVALID_NICKNAME_MESSAGE)
    @Length(min = 2, max = 23, message = INVALID_NICKNAME_MESSAGE)
    private final String nickname;

    public NicknameUpdateCommand(String nickname) {
        this.nickname = nickname;
    }

저는 위에서 2가지 불편함을 느겼습니다.

1. Controller에서만 사용 가능

정확히 말하면, 직접 검증 로직을 실행해줘야한다는 것이 맞습니다.

javax.validation의 검증 어노테이션들은 선언 자체로 검증을 실행되는 것이 아니라, 외부에서 어노테이션 정보를 보고 검증을 실행하는 것입니다. Springframework에서는 ArgumentResolver에서 검증을 진행합니다. 자세한 사항은 글1글2를 읽어보시면 좋을 것 같습니다.

따라서, ArgumentResolver가 실행되는 로직에서만 사용할 수 있습니다. 즉, Json으로 API 통신하는 구조에서는 Controller에서 Json 값을 java 객체로 받을 때만 사용 가능합니다. 그렇지 않다면 검증 어노테이션을 가진 객체를 사용하는 곳에서 직접 Validator.validate 메서드를 호출해 줘야합니다. 하지만, 검증 어노테이션을 가진 객체들에 대해 모두 validate 메서드를 호출해주는 일은 동일한 코드가 너무 많이 반복되는 결과를 초래합니다.

저는 Json으로 변환하는 객체에서 사용한 것이 아닌, Service에서 Controller의 요청하는 값을 담는 객체에서 사용했기 때문에 위의 문제를 해결해야했습니다.

또한, 이를 단위테스트하기 위해서는 테스트 코드에서 Validator라는 객체를 선언해서 이를 통해 검증을 해줘야 하는데, 테스트 코드에 존재하는 javax.validation 관련 코드때문에 테스트의 핵심을 파악하기가 힘들다고 느꼈습니다.

@DisplayName("null, 빈문자열, 공백만으로 이루어진 문자열로 NicknameUpdateCommand를 생성하려 하면 예외를 발생시킨다.")
@ParameterizedTest
@NullSource
@ValueSource(strings = {"", " "})
void create_Null(String nickname) {
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
	  Validator validator = factory.getValidator();
    NicknameUpdateCommand nicknameUpdateCommand = new NicknameUpdateCommand(nickname);
    Set<ConstraintViolation<NicknameUpdateCommand>> constraintViolations = validator.validate(nicknameUpdateCommand);

    String message = constraintViolations.iterator().next().getMessage();
    assertThat(message).isEqualTo("닉네임이 입력되지 않았습니다.");
}

해결방안

만들면서 배우는 클린 아키텍처에서는 아래와 같은 방법을 제시합니다. 아래의 추상클래스를 선언합니다.

public abstract class SelfValidating<T> {

    private final Validator validator;

    protected SelfValidating() {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        this.validator = factory.getValidator();
    }

    protected void validateSelf() {
        Set<ConstraintViolation<T>> violations = validator.validate((T) this);
        if (!violations.isEmpty()) {
            throw new ConstraintViolationException(violations);
        }
    }
}

javax.validation의 어노테이션을 통해 검증하는 객체들이 이를 상속하고,
아래처럼 생성자에서 validateSelf 메서드를 호출하도록합니다.

public class NicknameUpdateCommand extends SelfValidating<NicknameUpdateCommand> {

    private static final String INVALID_NICKNAME_MESSAGE = "닉네임은 공백없이 (.), (-), 영어와 숫자로 이루어진 2 ~ 23자여야 합니다.";

    @NotNull(message = INVALID_NICKNAME_MESSAGE)
    @NotBlank(message = INVALID_NICKNAME_MESSAGE)
    @Pattern(regexp = "[a-zA-Z0-9-.]*", message = INVALID_NICKNAME_MESSAGE)
    @Length(min = 2, max = 23, message = INVALID_NICKNAME_MESSAGE)
    private final String nickname;

    public NicknameUpdateCommand(String nickname) {
        this.nickname = nickname;
        this.validateSelf(); // 호출!
    }

그렇다면 생성자에서 필드가 할당되고 validateSelf 메서드에 의해 필드값에 대한 validation이 이뤄집니다.

또한, 단위테스트도 아래와 같이 간결하게 진행할 수 있습니다.

package dandi.dandi.member.application.port.in;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
import static org.junit.Assert.assertEquals;

import java.util.Set;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;

class NicknameUpdateCommandTest {

    @DisplayName("공백 없이 (.), (-), 2 - 23자의 영어와 숫자로 이루어진 값으로 NicknameUpdateCommand를 생성하려 하면 예외를 발생시킨다.")
    @ParameterizedTest
    @ValueSource(strings = {"1.2", "a-b", "-12", "12.", "12", "ab", "a1", "abcd1234abcd1234abcd123"})
    void create(String nickname) {
        assertThatCode(() -> new NicknameUpdateCommand(nickname))
                .doesNotThrowAnyException();
    }
}

2. 동일한 예외메시지 중복 발생

아래의 경우에 예외의 상황이 NotNull과 NotBlank로 다르지만, 동일한 메시지를 반환해주고 있습니다.

public class LoginCommand extends SelfValidating<LoginCommand> {

    private static final String NULL_BLANK_LOGIN_COMMAND_EXCEPTION_MESSAGE = "idToken은 빈 값입니다.";

    @NotNull(message = NULL_BLANK_LOGIN_COMMAND_EXCEPTION_MESSAGE)
    @NotBlank(message = NULL_BLANK_LOGIN_COMMAND_EXCEPTION_MESSAGE)
    private final String idToken;

    public LoginCommand(String idToken) {
        this.idToken = idToken;
    }
}

@NotBlank는 @NotNull을 포함하고 있기에 하나로 처리할 수 있지 않냐는 생각을 하시는 분이 계실 수 있는데, @NotNul을 포함하는 @NotBlank는 javax가 아닌 hibernate 라이브러리의 어노테이션이고 Deprecated 되었습니다.

돌아와서,

아래처럼 validateSelf 메서드에 예외메시지를 전달해주었습니다.

public class LoginCommand extends SelfValidating<LoginCommand> {

    private static final String NULL_BLANK_LOGIN_COMMAND_EXCEPTION_MESSAGE = "idToken은 빈 문자열일 수 없습니다.";

    @NotNull
    @NotBlank
    private final String idToken;

    public LoginCommand(String idToken) {
        this.idToken = idToken;
        this.validateSelf(NULL_BLANK_LOGIN_COMMAND_EXCEPTION_MESSAGE);
    }

    public String getIdToken() {
        return idToken;
    }
}

또한, IllegalArgumentException으로 처리하고 싶어서 IllegalArgumentException를 throw 하도록 변경했습니다.

public abstract class SelfValidating<T> {

    private final Validator validator;

    protected SelfValidating() {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        this.validator = factory.getValidator();
    }

    protected void validateSelf() {
        Set<ConstraintViolation<T>> violations = validator.validate((T) this);
        if (!violations.isEmpty()) {
            ConstraintViolation<T> violation = new ArrayList<>(violations).get(0);
            throw new IllegalArgumentException(violation.getMessage());
        }
    }

    protected void validateSelf(String message) {
        Set<ConstraintViolation<T>> violations = validator.validate((T) this);
        if (!violations.isEmpty()) {
            throw new IllegalArgumentException(message);
        }
    }
}

동일한 예외메시지로 처리하려면 메시지를 넘기도록하고, 다른 메시지로 처리하려면 ConstraintViolation의 message를 받아서 IllegalArgumentException를 발생시키도록 했습니다.

참고자료

도서 : 만들면서 배우는 클린 아키텍처
https://kdhyo.kr/80
https://mangkyu.tistory.com/174

0개의 댓글