Posts [Java] 객체지향적으로 입력 유효성 검증하기
Post
Cancel

[Java] 객체지향적으로 입력 유효성 검증하기

스프링에 의존하지 않고, 즉 bean validation을 사용하지 않고 입력 유효성을 검증할 수 있는 방법은 없을까?
물론 있다.
조건문을 나열해서 확인할 수도 있고, 유효성 검증을 위한 객체를 생성해서 확인할 수도 있다.

이번 글에서는 유효성 검증을 위한 객체를 생성해서 확인하는 방법을 위주로 살펴보려고 한다.
단순한 로직이라면 조건문을 나열하는 방법이 빠르고 쉬울 수 있다.
하지만 유지보수확장성의 관점에서, 아무리 단순한 로직이더라도 유효성 검증을 위한 객체를 생성하는 것이 더 좋다고 생각한다.


절차지향적인 코드

절차지향적으로 코드를 작성하면 어떤 문제가 있을까?

유효성 검증 로직이 간단한 경우에는 딱히 문제가 없을 수 있다.
하지만 유효성 검증 로직이 조금이라도 복잡해지면, 새로운 로직을 추가하거나 기존 로직을 수정할 때 어려움을 겪을 수 있다.

이 방법은 코드의 전체적인 가독성을 낮추고, 개발자가 실수할 가능성을 높인다.
결국 유지보수하기 어려운 코드로 만든다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Validator {

    public void validate(Request request) {
        if (request == null) {
            // Do something
        }
        if (request.getA()) {
            // Do something
        }
        if (request.getB()) {
            // DO something
        }
        if (request.getA() > 0) {
            // Do something
        }

        ...
    }
}


객체지향적인 코드

한편, 객체지향적으로 코드를 작성하면 어떨까?

설명을 위해 간단한 회원가입 프로젝트를 생성했다.
자세한 코드는 여기를 확인하자.

회원가입 정책은 다음과 같다.
▪️ 개인유저기업유저가 가입할 수 있다.
▪️ 개인유저는 아이디, 비밀번호, 이름이 필수값이다.
▪️ 기업유저는 아이디, 비밀번호가 필수값이다.

image


객체 의존관계는 다음과 같다.

image


각 필드마다 validator 클래스를 만들고, 모든 validator를 하나로 묶는 인터페이스로 validator를 한 단계 추상화한다.
인터페이스는 validate()와 isSatisfiedBy()를 가지고, 구현체는 유효성 검증 로직과 유효성 검증 조건을 구현한다.

isSatisfiedBy()를 예로 들면, 아이디는 모든 유저에서 유효성을 검증하므로 무조건 true를 반환한다.
한편 이름은 개인유저에서만 유효성을 검증하므로 개인유저면 true, 기업유저면 false를 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// SignUpValidator.java
public interface SignUpValidator {

    List<InvalidResponse> validate(ValidationRequest request);

    boolean isSatisfiedBy(boolean isCompany);
}

// IdValidator.java
@Component
public class IdValidator implements SignUpValidator {

    private static final InvalidResponse INVALID_RESPONSE = new InvalidResponse("id", "유효하지 않은 id");

    @Override
    public List<InvalidResponse> validate(ValidationRequest request) {
        String id = request.getId();

        if (id == null || isBlank(id)) {
            return Collections.singletonList(INVALID_RESPONSE);
        }

        if (isLowercaseOrNumber(id)) {
            return Collections.emptyList();
        }
        return Collections.singletonList(INVALID_RESPONSE);
    }

    private boolean isBlank(String id) {
        return id.trim().isEmpty();
    }

    private boolean isLowercaseOrNumber(String id) {
        return id.matches("[a-z0-9]+");
    }

    @Override
    public boolean isSatisfiedBy(boolean isCompany) {
        return true;
    }
}

// PasswordValidator.java
...

// NameValidator.java
@Component
public class NameValidator implements SignUpValidator {

    private static final InvalidResponse INVALID_RESPONSE = new InvalidResponse("name", "유효하지 않은 name");

    @Override
    public List<InvalidResponse> validate(ValidationRequest request) {
        String name = request.getName();

        if (name == null || isBlank(name)) {
            return Collections.singletonList(INVALID_RESPONSE);
        }

        if (isKorean(name)) {
            return Collections.emptyList();
        }
        return Collections.singletonList(INVALID_RESPONSE);
    }

    private boolean isBlank(String name) {
        return name.trim().isEmpty();
    }

    private boolean isKorean(String id) {
        return id.matches("[가-힣]+");
    }

    @Override
    public boolean isSatisfiedBy(boolean isCompany) {
        return !isCompany;
    }
}


모든 validator를 완성했다면, 필드 validator를 사용하는 클라이언트 클래스를 생성한다.
이 클래스는 List로 validator를 조합한다.
validate()에서 stream을 흘려 어떤 validator에게 유효성 검증을 위임할지 결정한다.(filter)
유효성 검증을 하고(map), 전체 유효성 검증 결과를 모아서 반환한다.(collect)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// SignUpValidation.java
@Component
@RequiredArgsConstructor
public class SignUpValidation {

    private final List<SignUpValidator> validators;

    public List<InvalidResponse> validate(ValidationRequest request) {
        if (request == null) {
            return Collections.singletonList(new InvalidResponse("request", "유효하지 않은 request"));
        }

        return validators.stream()
            .filter(validator -> validator.isSatisfiedBy(request.isCompany()))
            .map(validator -> validator.validate(request))
            .flatMap(Collection::stream)
            .collect(Collectors.toList());
    }
}


만약 중복되는 메서드가 있다면?

추가적으로, 모든 validator에 중복되는 메서드가 있다면 어떻게 해야 할까?
이때는 인터페이스와 구현체 사이에 추상 메서드를 둬서 해결할 수 있다.

단, 클래스는 단 1번만 상속할 수 있기 때문에 최대한 인터페이스를 활용하는 걸 추천한다.
구현은 n번 할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface Validator {

    void duplicateValidate();
}

public abstract class AAAValidator implements Validator {

    @Override
    protected void duplicateValidate() {
        ...
    }
}

public class BBBValidator extends AAAValidator {
    ...
}

public class CCCValidator extends AAAValidator {
    ...
}


결론

이번 예제를 만들면서 좀 더 확장에 유연하고 응집도가 높은 코드를 작성하기 위해 노력했다.

필드 validator는 현재 3개가 있지만, 기획이 변경되면 새로운 validator가 추가될 수 있다.
이때 상위 인터페이스를 구현하면 변경에 쉽게 대처할 수 있다.

각 validator는 특정 필드 입력값에 대한 유효성 검증이라는 책임만 다하면 되기 때문에 응집도가 높다.
클라이언트 클래스는 모든 validator를 사용하지만, 인터페이스에 의존하기 때문에 직접적인 결합도는 낮다.

결과적으로 절차지향적인 코드보다 유지보수하기 좋고 가독성이 뛰어난 코드가 됐다.

물론 이 방식이 최선은 아닐 수 있다.
그렇지만, 한 번쯤 활용해봐도 좋을 것 같다.

This post is licensed under CC BY 4.0 by the author.