4.1 값 객체(Value Object) 구현 가이드

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());
}

값 객체 사용 시 주의사항

  1. 가변 객체 참조 금지 값 객체가 다른 객체를 참조할 때는 불변성이 깨지지 않도록 주의해야 합니다.

  2. 방어적 복사 컬렉션이나 배열을 반환할 때는 방어적 복사를 사용해야 합니다:

public Object[] getErrorArgs() {
    return args.clone();  // 배열의 방어적 복사
}
  1. toString 구현 디버깅과 로깅을 위해 적절한 toString 메서드를 구현해야 합니다:
@Override
public String toString() {
    if (this instanceof HashedPassword) {
        return "********";  // 민감 정보 보호
    }
    return value;  // 일반적인 경우
}

결론

값 객체는 도메인 모델에서 중요한 개념을 표현하는 강력한 도구입니다. 불변성, 값 기반 동등성, 개념적 완전성이라는 세 가지 핵심 특성을 중심으로 구현되어야 하며, 이를 통해 도메인 규칙을 명확하게 표현하고 안전하게 강제할 수 있습니다.

특히 팩토리 메서드, Result 타입, ContractValidator와 같은 패턴들을 활용하면 견고하고 유지보수하기 쉬운 값 객체를 구현할 수 있습니다. 또한 체계적인 테스트를 통해 값 객체의 무결성을 보장할 수 있습니다.