3.5 도메인 객체의 유효성 검증과 ContractValidator

3.5 도메인 객체의 유효성 검증과 ContractValidator

3.5.1 도메인 객체의 유효성 검증 전략

도메인 객체를 생성할 때 해당 객체가 도메인 규칙을 준수하는지 검증하는 것은 매우 중요합니다. 이러한 검증은 크게 두 가지 접근 방식으로 나눌 수 있습니다.

사후 검증 전략 (Validate After Creation)

첫 번째 방식은 객체를 먼저 생성한 후에 유효성을 검증하는 것입니다. 이 방식에서는 일반적인 생성자를 통해 객체를 만든 후, 별도의 검증 메서드를 호출하여 유효성을 확인합니다.

public class Book {
    private String isbn;
    private String title;
    private Author author;
    
    public Book(String isbn, String title, Author author) {
        this.isbn = isbn;
        this.title = title;
        this.author = author;
    }
    
    // 검증 로직을 별도 메서드로 분리
    public void validate() {
        if (isbn == null || isbn.trim().isEmpty()) {
            throw new IllegalStateException("ISBN is required");
        }
        if (title == null || title.trim().isEmpty()) {
            throw new IllegalStateException("Title is required");
        }
        if (author == null) {
            throw new IllegalStateException("Author is required");
        }
    }
}

// 사용 예시
Book book = new Book("123-456-789", "Clean Code", author);
book.validate(); // 검증 실패  예외 발생

이 방식의 문제점은 다음과 같습니다:

  1. 객체가 일시적으로 유효하지 않은 상태로 존재할 수 있습니다.
  2. 검증 메서드 호출을 잊어버릴 수 있습니다.
  3. 여러 곳에서 검증이 필요한 경우 중복 코드가 발생할 수 있습니다.
  4. 예외를 통한 오류 처리는 예측하기 어려운 제어 흐름을 만듭니다.

사전 검증 전략 (Validate Before Creation)

두 번째 방식은 객체를 생성하기 전에 모든 유효성을 검증하는 것입니다. 이 방식에서는 정적 팩토리 메서드를 통해 객체 생성과 검증을 함께 처리합니다.

public class Book {
    private final String isbn;
    private final String title;
    private final Author author;
    
    private Book(String isbn, String title, Author author) {
        this.isbn = isbn;
        this.title = title;
        this.author = author;
    }
    
    public static Book create(String isbn, String title, Author author) {
        if (isbn == null || isbn.trim().isEmpty()) {
            throw new IllegalArgumentException("ISBN is required");
        }
        if (title == null || title.trim().isEmpty()) {
            throw new IllegalArgumentException("Title is required");
        }
        if (author == null) {
            throw new IllegalArgumentException("Author is required");
        }
        
        return new Book(isbn, title, author);
    }
}

// 사용 예시
Book book = Book.create("123-456-789", "Clean Code", author);

이 방식의 장점은 다음과 같습니다:

  1. 생성된 객체는 항상 유효한 상태임을 보장합니다.
  2. 불변성을 자연스럽게 달성할 수 있습니다.
  3. 검증 로직이 생성 로직과 함께 있어 잊어버릴 수 없습니다.

하지만 이 방식도 몇 가지 개선이 필요한 부분이 있습니다:

  1. 검증 로직이 단순 if문으로 구성되어 가독성이 떨어집니다.
  2. 예외를 통한 오류 처리는 여전히 문제가 됩니다.
  3. 검증 조건이 많아지면 코드가 복잡해집니다.

3.5.2 ContractValidator의 도입 배경

앞서 살펴본 검증 방식들의 문제점을 해결하기 위해 ContractValidator가 도입되었습니다. ContractValidator는 다음과 같은 목표를 가지고 설계되었습니다:

  1. 검증 로직의 가독성 향상
  2. Result 타입을 통한 예측 가능한 오류 처리
  3. 재사용 가능한 검증 패턴 제공
  4. 도메인 규칙의 명확한 표현

ContractValidator를 사용하면 앞선 예제를 다음과 같이 개선할 수 있습니다:

public class Book {
    private final String isbn;
    private final String title;
    private final Author author;
    
    private Book(String isbn, String title, Author author) {
        this.isbn = isbn;
        this.title = title;
        this.author = author;
    }
    
    public static Result<Book> create(String isbn, String title, Author author) {
        return ContractValidator.start()
            .requireNotBlank(isbn, "ISBN")
            .requireNotBlank(title, "Title")
            .requireNotNull(author, "Author")
            .validate()
            .map(() -> new Book(isbn, title, author));
    }
}

// 사용 예시
Result<Book> bookResult = Book.create("123-456-789", "Clean Code", author);

이러한 접근 방식의 장점은 다음과 같습니다:

  1. 체이닝을 통한 명확한 검증 흐름
  2. Result 타입을 통한 안전한 오류 처리
  3. 재사용 가능한 검증 메서드
  4. 의도가 명확히 드러나는 코드

3.5.3 ContractValidator의 상세 구현 분석

ContractValidator는 도메인 객체의 유효성 검증을 위한 명확하고 표현력 있는 API를 제공합니다. 도메인의 핵심 규칙들을 일관되고 가독성 높은 방식으로 표현할 수 있게 해줍니다.

3.5.3.1 핵심 구조와 설계 철학

ContractValidator의 기본 구조를 살펴보겠습니다:

public class ContractValidator {
    private Result<Void> result;

    private ContractValidator() {
        this.result = Result.success();
    }

    public static ContractValidator start() {
        return new ContractValidator();
    }

    public Result<Void> validate() {
        return result;
    }
}

이 구조는 검증 과정의 본질을 잘 반영합니다. 검증은 항상 시작점이 있고(start()), 순차적으로 진행되며, 최종적인 결론(validate())에 도달합니다. 마치 계약서를 검토하듯이, 각 조항을 하나씩 확인해나가는 과정이라고 볼 수 있습니다.

3.5.3.2 도메인 규칙의 표현

도서관 시스템에서 대출(Checkout) 처리를 예로 들어, ContractValidator가 어떻게 도메인 규칙을 명확하게 표현하는지 살펴보겠습니다:

public class Checkout {
    public static Result<Checkout> create(
            CheckoutId id,
            BookCopy bookCopy, 
            Member member, 
            LocalDate checkoutDate,
            LocalDate dueDate) {

        return ContractValidator.start()
            // 필수 값 검증
            .requireNotNull(id, ErrorCodes.CHECKOUT_ID_REQUIRED)
            .requireNotNull(bookCopy, ErrorCodes.BOOK_COPY_REQUIRED)
            .requireNotNull(member, ErrorCodes.MEMBER_REQUIRED)
            // 도메인 불변식: 시간의 흐름에 관한 기본 법칙
            .invariantThat(dueDate.isAfter(checkoutDate), 
                          ErrorCodes.INVALID_DUE_DATE,
                          "Due date must be after checkout date")
            // 비즈니스 규칙: 도서관 정책에 따른 검증
            .ensureThat(
                ChronoUnit.DAYS.between(checkoutDate, dueDate) <= 
                    libraryPolicy.getMaxCheckoutDays(),
                ErrorCodes.EXCEEDS_MAX_CHECKOUT_PERIOD,
                "Checkout period exceeds maximum allowed days")
            // 비즈니스 규칙: 회원의 현재 상태 검증
            .ensureThat(member.canCheckout(),
                       ErrorCodes.MEMBER_CANNOT_CHECKOUT,
                       "Member is not eligible for checkout")
            .validate()
            .map(() -> new Checkout(id, bookCopy, member, checkoutDate, dueDate));
    }
}

이 코드에서 우리는 검증의 세 가지 주요 유형을 명확히 구분하여 표현하고 있습니다:

  1. 도메인 불변식(invariantThat) 이는 시간이나 상황에 관계없이 항상 참이어야 하는 근본적인 규칙들입니다. 예를 들어, 반납일이 대출일보다 이후여야 한다는 것은 도서 대출이라는 개념 자체에 내재된 불변의 법칙입니다.

  2. 비즈니스 규칙(ensureThat) 도서관의 운영 정책이나 상황에 따라 변경될 수 있는 규칙들입니다. 최대 대출 기간이나 회원의 대출 자격 요건 등이 여기에 해당합니다.

  3. 필수 값 검증(requireNotNull, requireNotBlank) 기술적인 관점에서 객체의 무결성을 보장하기 위한 기본적인 검증입니다.

3.5.3.3 검증 로직의 구현

각 검증 메서드는 다음과 같이 구현됩니다:

public ContractValidator require(boolean condition,
                               ErrorCode errorCode, 
                               Object... errorMessageArguments) {
    if (result.isSuccess() && !condition) {
        result = Result.failure(errorCode, errorMessageArguments);
    }
    return this;
}

public ContractValidator invariantThat(boolean condition,
                                     ErrorCode errorCode, 
                                     Object... errorMessageArguments) {
    return require(condition, errorCode, errorMessageArguments);
}

public ContractValidator ensureThat(boolean condition,
                                  ErrorCode errorCode, 
                                  Object... errorMessageArguments) {
    return require(condition, errorCode, errorMessageArguments);
}

여기서 주목할 점은 invariantThat과 ensureThat이 기술적으로는 동일한 검증 메커니즘을 사용한다는 것입니다. 그러나 이 구분은 단순한 기술적 차이가 아닌, 도메인 개념의 명확한 표현을 위한 것입니다. 이는 코드를 읽는 사람에게 해당 규칙의 성격과 중요도를 즉각적으로 전달합니다.

Last updated on