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;
}
}값 객체 구현의 핵심 원칙
모든 값 객체는 다음과 같은 공통 원칙을 따릅니다:
-
불변성
- 모든 필드는 final로 선언합니다
- 생성자는 private으로 선언하고 팩토리 메서드를 사용합니다
- 수정이 필요한 경우 새로운 인스턴스를 생성합니다
-
자가 유효성 검증
- 생성 시점에 모든 유효성 검사를 수행합니다
- ContractValidator를 사용한 체계적인 검증을 구현합니다
- 명확한 오류 코드와 메시지를 제공합니다
-
값 기반 동등성
- AbstractValueObject를 상속하여 값 기반 equals와 hashCode를 구현합니다
- 모든 속성을 비교에 포함합니다
- 일관된 문자열 표현을 제공합니다
-
보안 고려사항
- 민감한 정보는 toString() 출력 시 마스킹 처리합니다
- 방어적 복사본을 사용합니다
- 유효하지 않은 입력에 대한 보호를 구현합니다
이러한 값 객체들은 도메인의 중요한 개념을 명확하게 표현하며, 비즈니스 규칙의 일관성을 보장합니다. 또한 재사용 가능하고 테스트하기 쉬운 구조를 제공하여, 전체 시스템의 견고성을 향상시킵니다.