4.1 값 객체(Value Object) 구현 가이드
값 객체(Value Object) 구현 가이드
개요
값 객체는 도메인 주도 설계의 핵심 구성 요소 중 하나입니다. 식별자가 아닌 속성값에 의해 정의되는 도메인 객체로, 불변성과 개념적 완전성을 가집니다. 이 가이드에서는 실제 프로젝트에서 값 객체를 어떻게 효과적으로 구현할 수 있는지 살펴보겠습니다.
값 객체의 특징
1. 불변성(Immutability)
값 객체는 생성 후 그 상태가 변경되지 않아야 합니다. 이는 다음과 같은 방식으로 구현됩니다:
public final class Email extends AbstractValueObject {
private final String address; // final로 선언된 필드
private Email(String address) { // private 생성자
this.address = address;
}
// 값을 변경할 때는 새로운 인스턴스를 생성
public Email withDomain(String newDomain) {
String localPart = getLocalPart();
return Email.ofPart(localPart, newDomain);
}
}불변성이 중요한 이유:
- 객체의 일관성 보장
- 동시성 문제 예방
- 부수 효과 방지
- 코드의 예측 가능성 향상
2. 개념적 완전성
값 객체는 도메인에서 하나의 완전한 개념을 표현해야 합니다. 예를 들어, 이메일 주소는 단순한 문자열이 아닌 로컬 파트와 도메인으로 구성된 복합적인 개념입니다:
public final class Email extends AbstractValueObject {
private static final String EMAIL_REGEX =
"^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$";
private static final String AT_SIGN = "@";
private final String address;
public String getLocalPart() {
return address.substring(0, address.indexOf(AT_SIGN));
}
public String getDomain() {
return address.substring(address.indexOf(AT_SIGN) + 1);
}
private static String normalize(String email) {
int atIndex = email.indexOf(AT_SIGN);
String localPart = email.substring(0, atIndex);
String domain = email.substring(atIndex + 1).toLowerCase();
return localPart + AT_SIGN + domain;
}
}3. 값 기반 동등성
값 객체는 식별자가 아닌 속성값을 기준으로 동등성을 판단합니다:
public abstract class AbstractValueObject {
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
return Arrays.deepEquals(getAtomicValues(),
((AbstractValueObject) o).getAtomicValues());
}
@Override
public int hashCode() {
return Arrays.deepHashCode(getAtomicValues());
}
protected abstract Object[] getAtomicValues();
}하위 클래스에서는 다음과 같이 구현합니다:
public final class FullName extends AbstractValueObject {
private final String value;
@Override
protected Object[] getAtomicValues() {
return new Object[]{value};
}
}값 객체 구현 패턴
1. 팩토리 메서드 패턴
값 객체의 생성은 팩토리 메서드를 통해 이루어져야 합니다:
public final class Password extends AbstractValueObject {
// 생성자는 private으로 선언
private Password(String value) {
this.value = value;
}
// 검증과 함께 새 인스턴스를 생성하는 팩토리 메서드
public static Result<Password> of(String value) {
return validate(value).map(Password::new);
}
// 이미 검증된 값으로 인스턴스를 생성하는 팩토리 메서드
public static Password from(String value) {
return of(value).orElseThrow();
}
}팩토리 메서드의 이점:
- 생성 의도가 명확히 드러남
- 유효성 검증을 강제할 수 있음
- Result 타입을 통한 실패 처리 가능
2. 체계적인 유효성 검증
ContractValidator를 사용한 체계적인 유효성 검증:
public final class Username extends AbstractValueObject {
private static Result<String> validate(String value) {
return ContractValidator.start()
.requireNotBlank(value, ErrorCodes.USERNAME_EMPTY)
.requireLength(value, MIN_LENGTH, MAX_LENGTH,
ErrorCodes.USERNAME_LENGTH_INVALID)
.requirePattern(value, USERNAME_PATTERN,
ErrorCodes.USERNAME_INVALID_PATTERN)
.validate()
.map(__ -> value);
}
}유효성 검증의 핵심 원칙:
- 모든 제약조건을 명시적으로 검사
- 도메인 특화 오류 코드 사용
- 검증 로직의 재사용성 확보
3. Result 타입을 활용한 오류 처리
값 객체의 생성이나 변환 과정에서 발생할 수 있는 실패를 명시적으로 처리:
public static Result<Email> ofPart(String localPart, String domain) {
if (localPart == null || domain == null) {
return Result.failure(ErrorCodes.EMAIL_PARTS_NULL, localPart, domain);
}
return of(localPart + AT_SIGN + domain);
}Result 타입 활용의 장점:
- 예외 처리보다 명시적인 오류 처리
- 체이닝을 통한 연속적인 처리 가능
- 실패의 맥락 정보 보존
4. 도메인 규칙의 캡슐화
값 객체 내부에 관련된 모든 도메인 규칙을 캡슐화:
public final class Password extends AbstractValueObject {
private static final int MIN_LENGTH = 8;
private static final int MAX_LENGTH = 50;
private static final Pattern LETTER_PATTERN = Pattern.compile("[a-zA-Z]");
private static final Pattern DIGIT_PATTERN = Pattern.compile("\\d");
private static final Pattern SPECIAL_CHAR_PATTERN =
Pattern.compile("[!@#$%^&*(),.?\":{}|<>]");
private static boolean meetsComplexityRequirement(String password) {
return LETTER_PATTERN.matcher(password).find() &&
DIGIT_PATTERN.matcher(password).find() &&
SPECIAL_CHAR_PATTERN.matcher(password).find();
}
}값 객체 테스트 전략
1. 생성 테스트
@Test
@DisplayName("유효한 이메일 주소로 Email 객체 생성")
void shouldCreateValidEmail() {
Result<Email> result = Email.of("user@example.com");
assertThat(result.isSuccess()).isTrue();
assertThat(result.getValue().getAddress()).isEqualTo("user@example.com");
}2. 유효성 검증 테스트
@ParameterizedTest
@DisplayName("비밀번호 복잡도 규칙 검증")
@ValueSource(strings = {
"short1!", // 길이 부족
"NoDigits!", // 숫자 없음
"no-upper1", // 대문자 없음
"NO-LOWER1!" // 소문자 없음
})
void shouldFailWithInvalidComplexity(String invalidPassword) {
Result<Password> result = Password.of(invalidPassword);
assertThat(result.isFailure()).isTrue();
assertThat(result.getErrorCode())
.isEqualTo(ErrorCodes.USER_PASSWORD_INVALID_STRENGTH);
}3. 동등성 테스트
@Test
@DisplayName("같은 값을 가진 두 이메일 객체는 동등해야 함")
void shouldBeEqualWhenValuesAreEqual() {
Email email1 = Email.of("user@example.com").orElseThrow();
Email email2 = Email.of("user@example.com").orElseThrow();
assertThat(email1).isEqualTo(email2);
assertThat(email1.hashCode()).isEqualTo(email2.hashCode());
}값 객체 사용 시 주의사항
-
가변 객체 참조 금지 값 객체가 다른 객체를 참조할 때는 불변성이 깨지지 않도록 주의해야 합니다.
-
방어적 복사 컬렉션이나 배열을 반환할 때는 방어적 복사를 사용해야 합니다:
public Object[] getErrorArgs() {
return args.clone(); // 배열의 방어적 복사
}- toString 구현 디버깅과 로깅을 위해 적절한 toString 메서드를 구현해야 합니다:
@Override
public String toString() {
if (this instanceof HashedPassword) {
return "********"; // 민감 정보 보호
}
return value; // 일반적인 경우
}결론
값 객체는 도메인 모델에서 중요한 개념을 표현하는 강력한 도구입니다. 불변성, 값 기반 동등성, 개념적 완전성이라는 세 가지 핵심 특성을 중심으로 구현되어야 하며, 이를 통해 도메인 규칙을 명확하게 표현하고 안전하게 강제할 수 있습니다.
특히 팩토리 메서드, Result 타입, ContractValidator와 같은 패턴들을 활용하면 견고하고 유지보수하기 쉬운 값 객체를 구현할 수 있습니다. 또한 체계적인 테스트를 통해 값 객체의 무결성을 보장할 수 있습니다.