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(); // 검증 실패 시 예외 발생이 방식의 문제점은 다음과 같습니다:
- 객체가 일시적으로 유효하지 않은 상태로 존재할 수 있습니다.
- 검증 메서드 호출을 잊어버릴 수 있습니다.
- 여러 곳에서 검증이 필요한 경우 중복 코드가 발생할 수 있습니다.
- 예외를 통한 오류 처리는 예측하기 어려운 제어 흐름을 만듭니다.
사전 검증 전략 (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);이 방식의 장점은 다음과 같습니다:
- 생성된 객체는 항상 유효한 상태임을 보장합니다.
- 불변성을 자연스럽게 달성할 수 있습니다.
- 검증 로직이 생성 로직과 함께 있어 잊어버릴 수 없습니다.
하지만 이 방식도 몇 가지 개선이 필요한 부분이 있습니다:
- 검증 로직이 단순 if문으로 구성되어 가독성이 떨어집니다.
- 예외를 통한 오류 처리는 여전히 문제가 됩니다.
- 검증 조건이 많아지면 코드가 복잡해집니다.
3.5.2 ContractValidator의 도입 배경
앞서 살펴본 검증 방식들의 문제점을 해결하기 위해 ContractValidator가 도입되었습니다. ContractValidator는 다음과 같은 목표를 가지고 설계되었습니다:
- 검증 로직의 가독성 향상
- Result 타입을 통한 예측 가능한 오류 처리
- 재사용 가능한 검증 패턴 제공
- 도메인 규칙의 명확한 표현
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);이러한 접근 방식의 장점은 다음과 같습니다:
- 체이닝을 통한 명확한 검증 흐름
- Result 타입을 통한 안전한 오류 처리
- 재사용 가능한 검증 메서드
- 의도가 명확히 드러나는 코드
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));
}
}이 코드에서 우리는 검증의 세 가지 주요 유형을 명확히 구분하여 표현하고 있습니다:
-
도메인 불변식(invariantThat) 이는 시간이나 상황에 관계없이 항상 참이어야 하는 근본적인 규칙들입니다. 예를 들어, 반납일이 대출일보다 이후여야 한다는 것은 도서 대출이라는 개념 자체에 내재된 불변의 법칙입니다.
-
비즈니스 규칙(ensureThat) 도서관의 운영 정책이나 상황에 따라 변경될 수 있는 규칙들입니다. 최대 대출 기간이나 회원의 대출 자격 요건 등이 여기에 해당합니다.
-
필수 값 검증(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이 기술적으로는 동일한 검증 메커니즘을 사용한다는 것입니다. 그러나 이 구분은 단순한 기술적 차이가 아닌, 도메인 개념의 명확한 표현을 위한 것입니다. 이는 코드를 읽는 사람에게 해당 규칙의 성격과 중요도를 즉각적으로 전달합니다.