[DDD] 도메인 주도 설계 철저 입문 정리 (5) - 지식 표현을 위한 고급 패턴

calm0_0·2023년 10월 4일
0

DDD

목록 보기
4/6

이 글은 도메인 주도 설계에 대해 공부하기 위해 다음 책을 읽고 공부한 내용을 정리한 글입니다.

도메인 주도 설계 철저 입문 (나루세 마사노부 저/심효섭 역)

이번 시간에는 도메인 지식을 표현하기 위한 고급 패턴들에 대해 정리해보았다.

  • 애그리게이트 (Aggregate)
  • 명세 (Specification)

애그리게이트(Aggregate)


애그리게이트란

객체 지향 프로그래밍에서는 여러 개의 객체가 모여 하나의 의미를 갖는 객체가 된다. 이 연관된 객체들을 묶어서 표현하는 것을 '애그리게이트'라고 한다.

애그리게이트가 필요한 이유

도메인 모델이 복잡해졌을 때 개별 객체 수준에서 모델을 바라본다면 전반적인 구조나 도메인 간의 관계를 파악하기 어렵다. 그렇게 되면 코드를 변경하고 확장하는 것이 힘들어진다. 이러한 문제점을 해결하기 위해선 상위 수준에서 모델을 바라볼 수 있어야 한다. 애그리게이트를 활용하면 이러한 어려움을 해결할 수 있다.

애그리게이트의 이점

  • 애그리게이트를 사용하면 관련된 도메인을 묶어서 표현하고 이해하기 때문에 모델 간 관계를 파악하기가 쉬워진다.
  • 애그리게이트 단위로 일관성을 관리한다. 때문에 코드의 복잡도가 낮아지고, 기능 확장 및 변경에 들어가는 비용이 줄어든다.

에그리게이트 루트

애그리게이트의 중심이 되는 객체를 '애그리게이트 루트(Aggregate Root)'라고 한다.

애그리게이트에 속한 객체들이 일관성을 유지하려면 하나의 에그리게이트에 속한 객체들을 관리할 주체가 필요하다. 이 책임을 가지고 있는 것이 '애그리게이트 루트'이다.

class UserId {
	consturctor (public readonly value: number) {}
}

class UserName {
	constructor (public readonly value: string) {}
}

class User {
	public id: UserId;
	public name: UserName;
}

위 예시에서 User 클래스가 애그리게이트 루트가 된다.

애그리게이트에 포함된 객체를 조작할 때에는 애그리게이트 루트를 통해서 수정해야 한다.

const userName = new UserName("minsu");
const user = new User(userName);

user.Name = userName; // x
user.changeName(userName); // o

객체를 다루는 조작의 원칙들

  • 데메테르의 법칙 : 객체 간의 메서드 호출에 질서를 부여하기 위한 가이드라인으로 어떤 컨텍스트에서 다음 객체의 메서드만을 호출할 수 있게 제한한다.
    • 객체 자신
    • 인자로 전달받은 객체
    • 인스턴스 변수
    • 해당 컨텍스트에서 직접 생성한 객체
  • 객체 내부의 데이터를 외부에 함부로 공개하지 않는다.
    • 리포지토리 객체 외에는 애그리게이트의 내부 데이터에 접근하는 코드를 함부로 작성하지 않는다.

애그리게이트 경계

애그리게이트 경계는 하나의 애그리게이트에 포함되는 대상 객체를 결정하는 경계이다. 이 경계를 정하는 가장 흔하게 쓰이는 기준 중 하나는 '변경의 단위'이다.

위 그림에서 Circle과 User는 별개의 애그리게이트로 구성되어 있다. 만약, 서클에 속한 특정 사용자의 정보를 변경하는 작업을 서클 애그리게이트에 맡긴다면 어떻게 될까?

서클 리포지토리의 로직에 사용자의 정보를 수정하는 코드로 오염될 뿐만 아니라, 사용자 리포지토리의 코드와 중복된다. 어쩔 수 없는 경우가 아니면 코드 중복은 피하는 것이 좋다.

이는 변경의 단위를 넘어서는 변경을 시도했다가 발생한 문제이다. 애그리게이트에 대한 변경은 해당 애그리게이트 자신에게만 맡기고, 퍼시스턴시 요청도 애그리게이트 단위로 해야 한다. 그렇기 때문에 리포지토리는 애그리게이트마다 하나씩 만든다.

식별자를 활용한 컴포지션

class Circle {
	users: User[];	
}

const user1 = new User("minsu");
const user2 = new User("sumin");

userRepository.save(user1);
userRepository.save(user2);

const circle = new Circle();
circle.join(user1)
circle.join(user2);
circleRepository.save(circle);

user2.name = new UserName('suji');
userRepository.save(user2);

위 예제에서 서클 안에 있는 user2의 이름이 변경되었지만 서클 안에는 user2가 변경된 상태로 저장되어 있지 않다. 그래서 해당 서클을 복원할 경우 이전 user2의 데이터로 불러와진다.

이런 변경의 여파를 줄이고 경계를 더 확실히 하기 위해서 객체 인스턴스 대신 식별자(id)를 가지고 있어야 한다.

class Circle {
	userIds: UserId[];	
	
  	...

이렇게 하면 User의 인스턴스를 수정해도 이와 관련된 다른 엔티티들을 신경쓰지 않아도 된다. 또한 메모리를 절약하는 부수적인 효과도 있다.

애그리게이트의 크기와 조작의 단위

  • 애그리게이트의 크기가 크면 클수록 트랜잭션 락의 적용 범위도 커진다.
  • 그래서 애그리게이트의 크기는 가능한 한 작게 유지하는 것이 좋다.
  • 또한 한 트랜잭션에서 여러 애그리게이트를 다루는 것도 가능한 한 피해야 한다.

명세 (Specification)


객체를 평가하는 절차가 단순하다면 해당 객체의 메서드로 정의하면 되지만, 복잡한 평가 절차가 필요할 수도 있다. 또한 평가 절차를 해당 객체의 메서드로 두는 것이 부자연스러운 경우도 있다.

이러한 객체 평가 절차는 애플리케이션 서비스에 구현되어서는 안 된다. 객체에 대한 평가는 도메인 규칙 중에서도 중요도가 높은 것으로 서비스에 구현하기에 알맞지 않다.

이를 위한 대책으로 사용하는 것이 명세이다. 명세는 어떤 객체가 그 객체의 평가 기준을 만족하는지 판정하기 위한 객체이다.

명세의 예시

다음과 같은 도메인 규칙이 있다.

  • 사용자 중에는 프리미엄 사용자라는 유형이 존재한다.
  • 서클의 최대 인원은 서클장과 소속 사용자를 포함해 30명이다.
  • 프리미엄 사용자가 10명 이상 소속된 서클은 최대 인원이 50명으로 늘어난다.

이 규칙을 아래와 같이 애플리케이션 서비스에서 기술하는 것이 아닌 명세를 이용했다.

class CircleFullSepcification {
	constructor(private readonly userRepository: UserRepository) {}

	isSatisfiedBy(circle: Circle): boolean {
		const users = this.userRepository.find(circle.members);
      	const premiumUserNumber = users.count(user => user.IsPremium);
        const circleUpperLimit = premiumUserNumber < 10 ? 30 : 50;
		return circle.contMembers() >= circleUpperLimit;
	}
}

이 명세를 서비스에 적용한다.

class CircleApplicationService {
 	...
    
    join(command: CircleJoinCommand) {
    	const circleId = new CircleId(command.circleId);
      	const circle = circleRepository.find(circleId);
      
      	const circleFullSpecification = new CircleFullSpecification(userRepository);
      
      	if (circleFullSpecification.ifSatisfiedBy(circle)) {
        	throw new Error('~~~');
        }
      
      	...
    }
}

이제 복잡한 객체 평가 코드를 캡슐화해 원래 객체의 의도를 잘 드러낼 수 있게 되었다.

명세와 리포지토리의 조합

  • 명세는 단독으로 사용되기도 하지만 리포지토리와 조합해 사용할 수도 있다.

    • 리포지토리가 명세를 전달받아 명세에 정의된 조건과 합치하는 객체를 검색하는 방법이다.
    • 리포지토리에도 검색을 수행하는 메서드가 있지만, 검색에 중요한 규칙이 포함되는 경우, 이를 리포지토리의 메서드로 정의하면 중요한 도메인 규칙이 리포지토리 구현체로 빠져나가기 때문
  • 다만, 리포지토리에서 명세를 필터로 사용할 경우 항상 성능을 염두해 두어야 한다.

  • 복잡한 읽기 작업에서 성능 문제가 우려된다면 이러한 부분에 한해 도메인 객체의 제약을 벗어나도 된다. (페이지네이션, 복잡한 JOIN문 등)



Reference
도메인 주도 설계 철저 입문 (나루세 마사노부 저/심효섭 역)
https://private-space.tistory.com/94
https://rudaks.tistory.com/entry/%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%A3%BC%EB%8F%84-%EC%84%A4%EA%B3%84-%EC%B2%A0%EC%A0%80-%EC%9E%85%EB%AC%B8-%EC%95%A0%EA%B7%B8%EB%A6%AC%EA%B2%8C%EC%9D%B4%ED%8A%B8?category=1025984


profile
공부한 내용들을 정리하는 블로그

0개의 댓글