5.02 값 객체 구현

사용자 도메인에서 값 객체는 도메인의 중요한 개념을 표현하고 유효성을 검증하는 핵심 구성요소입니다. 각 값 객체는 자체적인 유효성 규칙을 가지며, 불변성을 보장합니다. 이 섹션에서는 사용자 도메인에서 사용하는 각 값 객체의 구현 방법을 살펴보겠습니다.

사용자 식별자(UserId) 구현

UserId는 사용자를 고유하게 식별하는 값 객체입니다. UUID를 기반으로 하여 전역적 유일성을 보장합니다.

public class UserId extends AbstractValueObject {
    private static final int MIN_LENGTH = 3;
    private static final int MAX_LENGTH = 36;

    private final String value;

    private UserId(String value) {
        this.value = value;
    }

    public static UserId newId() {
        return of(UUID.randomUUID().toString())
                .orElseThrow(InternalServerException::new);
    }

    public static Result<UserId> of(String value) {
        return validate(value).map(UserId::new);
    }

    private static Result<String> validate(String value) {
        return ContractValidator.start()
                .requireNotBlank(value, ErrorCodes.USER_ID_EMPTY)
                .requireLength(value, MIN_LENGTH, MAX_LENGTH, 
                    ErrorCodes.USER_ID_LENGTH_INVALID)
                .validate()
                .map(__ -> value);
    }

    @Override
    protected Object[] getAtomicValues() {
        return new Object[]{value};
    }
}

Username 값 객체

Username은 시스템 내에서 사용자를 식별하는 고유한 이름을 표현합니다. 보안과 가독성을 위해 특정 패턴을 강제합니다.

public class Username extends AbstractValueObject {
    private static final int MIN_LENGTH = 3;
    private static final int MAX_LENGTH = 20;
    private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z0-9._@-]+$");

    private final String value;

    private Username(String value) {
        this.value = value;
    }

    public static Result<Username> of(String value) {
        return validate(value).map(Username::new);
    }

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

    @Override
    protected Object[] getAtomicValues() {
        return new Object[]{value};
    }

    @Override
    public String toString() {
        return value;
    }
}

Email 값 객체

Email은 사용자의 이메일 주소를 표현합니다. RFC 5322 표준을 준수하는 이메일 형식을 검증하며, 도메인 부분의 정규화를 수행합니다.

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;

    private Email(String address) {
        this.address = address;
    }

    public static Result<Email> of(String address) {
        return validate(address)
                .map(Email::normalize)
                .map(Email::new);
    }

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

    private static Result<String> validate(String value) {
        return ContractValidator.start()
                .requireNotBlank(value, ErrorCodes.EMAIL_BLANK)
                .require(isValidEmail(value), ErrorCodes.EMAIL_INVALID_PATTERN)
                .validate()
                .map(__ -> value);
    }

    private static boolean isValidEmail(String email) {
        return email != null && email.matches(EMAIL_REGEX);
    }

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

    public String getLocalPart() {
        return address.substring(0, address.indexOf(AT_SIGN));
    }

    public String getDomain() {
        return address.substring(address.indexOf(AT_SIGN) + 1);
    }

    @Override
    protected Object[] getAtomicValues() {
        return new Object[]{address};
    }
}

Password와 HashedPassword 값 객체

비밀번호는 두 가지 형태로 표현됩니다. Password는 사용자가 입력한 원본 비밀번호를, HashedPassword는 해시화된 비밀번호를 표현합니다.

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 final String value;

    private Password(String value) {
        this.value = value;
    }

    public static Result<Password> of(String value) {
        return validate(value).map(Password::new);
    }

    private static Result<String> validate(String value) {
        return ContractValidator.start()
                .requireNotBlank(value, ErrorCodes.USER_PASSWORD_BLANK)
                .require(meetsLengthRequirement(value), 
                    ErrorCodes.USER_PASSWORD_LENGTH_INVALID, MIN_LENGTH, MAX_LENGTH)
                .require(meetsComplexityRequirement(value), 
                    ErrorCodes.USER_PASSWORD_INVALID_STRENGTH)
                .validate()
                .map(__ -> value);
    }

    private static boolean meetsComplexityRequirement(String password) {
        return LETTER_PATTERN.matcher(password).find() &&
                DIGIT_PATTERN.matcher(password).find() &&
                SPECIAL_CHAR_PATTERN.matcher(password).find();
    }

    public String value() {
        return value;
    }

    @Override
    public String toString() {
        return "********";  // 보안을 위해 실제 값을 노출하지 않습니다
    }

    @Override
    protected Object[] getAtomicValues() {
        return new Object[]{value};
    }
}

public class HashedPassword extends AbstractValueObject {
    private final String value;

    private HashedPassword(String value) {
        this.value = value;
    }

    public static Result<HashedPassword> of(String value) {
        return ContractValidator.start()
                .requireNotBlank(value, ErrorCodes.HASHED_PASSWORD_BLANK)
                .validate()
                .map(__ -> new HashedPassword(value));
    }

    @Override
    protected Object[] getAtomicValues() {
        return new Object[]{value};
    }

    @Override
    public String toString() {
        return "********";  // 보안을 위해 실제 값을 노출하지 않습니다
    }
}

FullName 값 객체

FullName은 사용자의 실명을 표현하며, 문화적 다양성을 고려한 유효성 검사를 수행합니다.

public final class FullName extends AbstractValueObject {
    private static final int MAX_LENGTH = 50;
    private static final Pattern NAME_PATTERN = Pattern.compile("^[\\p{L}\\s'-]+$");

    private final String value;

    private FullName(String value) {
        this.value = value.trim();
    }

    public static Result<FullName> of(String value) {
        return validate(value).map(FullName::new);
    }

    private static Result<String> validate(String value) {
        return ContractValidator.start()
                .requireNotBlank(value, ErrorCodes.FULL_NAME_BLANK)
                .requireMaxLength(value, MAX_LENGTH, ErrorCodes.FULL_NAME_TOO_LONG)
                .requirePattern(value, NAME_PATTERN, ErrorCodes.FULL_NAME_INVALID_PATTERN)
                .validate()
                .map(__ -> value);
    }

    @Override
    protected Object[] getAtomicValues() {
        return new Object[]{value};
    }

    @Override
    public String toString() {
        return value;
    }
}

값 객체 구현의 핵심 원칙

모든 값 객체는 다음과 같은 공통 원칙을 따릅니다:

  1. 불변성

    • 모든 필드는 final로 선언합니다
    • 생성자는 private으로 선언하고 팩토리 메서드를 사용합니다
    • 수정이 필요한 경우 새로운 인스턴스를 생성합니다
  2. 자가 유효성 검증

    • 생성 시점에 모든 유효성 검사를 수행합니다
    • ContractValidator를 사용한 체계적인 검증을 구현합니다
    • 명확한 오류 코드와 메시지를 제공합니다
  3. 값 기반 동등성

    • AbstractValueObject를 상속하여 값 기반 equals와 hashCode를 구현합니다
    • 모든 속성을 비교에 포함합니다
    • 일관된 문자열 표현을 제공합니다
  4. 보안 고려사항

    • 민감한 정보는 toString() 출력 시 마스킹 처리합니다
    • 방어적 복사본을 사용합니다
    • 유효하지 않은 입력에 대한 보호를 구현합니다

이러한 값 객체들은 도메인의 중요한 개념을 명확하게 표현하며, 비즈니스 규칙의 일관성을 보장합니다. 또한 재사용 가능하고 테스트하기 쉬운 구조를 제공하여, 전체 시스템의 견고성을 향상시킵니다.