4.3 애그리거트 구현 가이드
애그리거트 구현 가이드
개요
도메인 애그리거트는 데이터 변경의 단위로 취급되는 도메인 객체들의 클러스터입니다. 이는 도메인 모델의 일관성을 유지하고 비즈니스 불변성을 보호하는 데 도움을 줍니다. 이 가이드에서는 도서관 관리 시스템의 예시를 통해 도메인 애그리거트를 효과적으로 구현하는 방법을 살펴보겠습니다.
도메인 애그리거트의 핵심 원칙
1. 단일 진실 공급원(Single Source of Truth)
모든 애그리거트는 정확히 하나의 애그리거트 루트를 가져야 합니다. 애그리거트 루트는 전체 애그리거트의 일관성을 유지할 책임이 있습니다. 우리의 도서관 시스템에서 Member와 Librarian은 추상 User 클래스를 상속받는 애그리거트 루트입니다:
public abstract sealed class User extends AbstractAggregateRoot<UserId>
permits Member, Librarian {
private final UserId id;
private final Username username;
private final HashedPassword password;
private final Email email;
private final FullName fullName;
private final UserStatus status;
}User 클래스가 sealed로 표시되고 permits를 통해 어떤 클래스가 상속할 수 있는지 명시적으로 선언하는 것에 주목하세요. 이를 통해 사용자 계층 구조가 명확하게 정의되고 닫혀 있음을 보장합니다.
2. 캡슐화
애그리거트는 내부 상태를 캡슐화하고 신중하게 설계된 동작만을 외부에 노출해야 합니다. Member 클래스가 회원 등급을 관리하는 방식을 살펴보겠습니다:
public final class Member extends User {
private MembershipTier membershipTier;
public void upgradeTier(MembershipTier newTier) {
Objects.requireNonNull(newTier, "새로운 회원 등급은 null일 수 없습니다");
if (!newTier.isHigherThan(this.membershipTier)) {
throw new IllegalArgumentException("새로운 등급은 현재 등급보다 높아야 합니다");
}
this.membershipTier = newTier;
}
}회원 등급은 private으로 선언되어 있고, 모든 수정은 비즈니스 규칙을 강제하는 메서드를 통해서만 이루어져야 합니다.
구현 가이드라인
1. 생성을 위한 팩토리 메서드
애그리거트 생성 시에는 생성자 대신 팩토리 메서드를 사용하겠습니다. 이는 다음과 같은 이점을 제공합니다:
- 생성 로직의 캡슐화
- 비즈니스 규칙의 검증
- 실패를 처리하기 위한 Result 타입 반환
Member 클래스의 예시:
public static Result<Member> create(Username username,
HashedPassword password,
Email email,
Supplier<Boolean> isEmailUnique,
FullName fullName) {
return ContractValidator.start()
.require(isEmailUnique.get(), ErrorCodes.DUPLICATE_EMAIL, email)
.validate()
.map(id -> new Member(
UserId.newId(),
username,
password,
email,
fullName,
MembershipTier.getDefaultTier()))
.onSuccess(e -> e.registerDomainEvent(new MemberRegisteredEvent(e.getId())));
}2. 오류 처리를 위한 Result 타입
성공과 실패 케이스를 명시적으로 처리하기 위해 Result 타입을 사용하겠습니다. 이는 다음을 제공합니다:
- 타입 안전한 오류 처리
- 실패 케이스의 명확한 전달
- 연산의 쉬운 조합
Result의 구조:
public sealed interface Result<T> {
T getValue();
ErrorCode getErrorCode();
Object[] getErrorArgs();
default Result<T> onSuccess(Consumer<? super T> consumer) {
if (this instanceof Success<T> success) {
consumer.accept(success.value());
}
return this;
}
}3. 속성을 위한 값 객체
자체 검증 규칙이나 비즈니스 의미를 가진 속성에는 값 객체를 사용하세요. 이는 다음을 보장합니다:
- 도메인 규칙의 일관된 적용
- 복잡한 검증 로직의 캡슐화
- 비즈니스 개념의 명확한 표현
Email 값 객체의 예시:
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 final String address;
public static Result<Email> of(String address) {
return validate(address)
.map(Email::normalize)
.map(Email::new);
}
}4. 부수 효과를 위한 도메인 이벤트
시스템의 다른 부분에 변경 사항을 알리기 위해 도메인 이벤트를 사용하세요. 이는 다음과 같은 이점을 제공합니다:
- 느슨한 결합 유지
- 감사 추적 가능
- 이벤트 기반 아키텍처 지원
도메인 이벤트 등록의 예:
.onSuccess(e -> e.registerDomainEvent(new MemberRegisteredEvent(e.getId())));5. 계약 검증
계약 검증자 패턴을 사용하여 체계적인 검증을 구현하세요. 이는 다음을 제공합니다:
- 일관된 검증 접근 방식
- 연결 가능한 검증 규칙
- 명확한 오류 메시지
사용 예시:
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()모범 사례
1. 방어적 프로그래밍
항상 입력을 검증하고 불변성을 보호하세요:
Objects.requireNonNull(newTier, "새로운 회원 등급은 null일 수 없습니다");
if (!newTier.isHigherThan(this.membershipTier)) {
throw new IllegalArgumentException("새로운 등급은 현재 등급보다 높아야 합니다");
}2. 명확한 오류 코드
각 검증 실패에 대한 구체적인 오류 코드를 정의하세요:
public enum ErrorCodes {
USERNAME_EMPTY,
USERNAME_LENGTH_INVALID,
USERNAME_INVALID_PATTERN,
// ...
}4. 저장소 인터페이스
애그리거트 영속성을 위한 명확한 저장소 인터페이스를 정의하세요:
public interface UserRepository extends Repository<UserId, User> {
Result<Member> create(Member member);
Result<Librarian> create(Librarian librarian);
boolean existsByEmail(Email email);
}피해야 할 일반적인 함정
-
내부 상태를 노출하지 마세요: 가변 객체를 반환하는 게터를 피하세요.
-
애그리거트 경계를 깨지 마세요: 다른 애그리거트를 직접 참조하는 대신 ID를 사용하세요.
-
검증을 무시하지 마세요: 항상 입력을 검증하고 불변성을 유지하세요.
-
영속성 로직을 섞지 마세요: 영속성 관련 사항은 인프라스트럭처 계층에 유지하세요.
-
오류 처리를 건너뛰지 마세요: 실패할 수 있는 작업에는 항상 Result 타입을 사용하세요.
애그리거트 테스트
다음 사항을 검증하는 포괄적인 테스트를 작성하세요:
- 생성 규칙과 검증
- 비즈니스 연산과 상태 변경
- 도메인 이벤트 발행
- 불변성 보호
테스트 구조의 예:
@Test
@DisplayName("새 회원 생성 시 기본 상태와 등급 설정 확인")
void shouldInitializeWithDefaultStatusAndTier() {
Result<Member> result = builder.build();
assertThat(result.isSuccess()).isTrue();
Member member = result.getValue();
assertSoftly(softly -> {
softly.assertThat(member.getStatus()).isEqualTo(UserStatus.PENDING);
softly.assertThat(member.getMembershipTier()).isEqualTo(MembershipTier.BASIC);
});
}결론
효과적인 애그리거트 구현을 위해서는 캡슐화, 검증, 비즈니스 규칙에 대한 세심한 주의가 필요합니다. 이러한 가이드라인과 패턴을 따름으로써 비즈니스 불변성을 효과적으로 보호하고 도메인 개념을 명확하게 표현하는 강력한 도메인 모델을 만들 수 있습니다.
애그리거트는 단순한 데이터 컨테이너가 아니라 도메인 모델에서 비즈니스 규칙과 일관성을 지키는 수호자라는 점을 기억하세요. 적절한 애그리거트 경계를 식별하고 신중하게 구현하는 데 시간을 투자하세요.