✅ 도메인 주도 개발 기반 구현 코드
GitHub - seatbelt92/ddd-practice
Contribute to seatbelt92/ddd-practice development by creating an account on GitHub.
github.com
이번 글에서는 유저가 가입할 수 있는 서클 모델을 추가하여, 서클을 생성하고 서클에 가입하는 유즈케이스를 포함한 애플리케이션을 구성해보겠습니다.
앞서 살펴봤던 값객체, 엔티티, 도메인 서비스, 리포지토리, 애플리케이션 서비스, 의존 관계 역전 원칙과 함께 도메인 주도 설계에서 다뤄지는 개념인 팩토리, 트랜잭션, 에그리게이트, 명세에 대한 설명과 코드도 기술해보겠습니다.
그리고 저와 동일한 기술스택(Typescript, TypeORM, MySQL)을 사용하는 분들이 참고하실 수 있는 고민들도 적어보겠습니다.
- 엔티티의 속성을 값객체가 아닌 string, number와 같은 primitive type들로 사용하기
- 라이브러리(class-transformer, class-validator)를 활용하여 도메인 규칙을 대체하기
- 두 엔티티의 다대다 관계 설정하기 (1, 2번을 적용한 이유이기도 합니다.)
팩토리
아시다시피 팩토리는 객체를 만드는 지식에 특화된 객체입니다. 일반적으로 다형성의 장점을 활용하기 위해 사용하지만, 생성 절차가 복잡한 인스턴스를 만드는 코드를 모아두는 용도로도 활용할 수 있습니다. 만약 객체의 초기화를 담담하는 생성자 메서드가 복잡해졌다면 팩토리로 빼서 생성자 메서드를 단순하게 유지하는 게 좋습니다. 팩토리를 통해 객체 생성 절차를 캡슐화하면 코드의 의도를 더 분명히 드러내고, 중복을 막을 수 있습니다.
적용하기
circle 객체를 만드는 데 팩토리 패턴을 사용했습니다. 팩토리의 인터페이스를 구성하고, 구현 클래스는 싱클톤으로 등록하여 애플리케이션 서비스에서 주입하여 사용할 수 있게 했습니다.
// circle.factory.ts
export interface CircleFactory {
create(name: CircleName, owner: Owner): Circle;
}
// default.circle.factory.ts
@singleton()
export class DefaultCircleFactory implements CircleFactory {
create(name: CircleName, owner: Owner): Circle {
return new Circle(name, owner);
}
}
// circle.application.service.ts
@singleton()
export class CircleApplicationService {
constructor(
@inject(DefaultCircleFactory) private circleFactory: CircleFactory,
// .. 생략 ..
) {}
async createCircle(command: CircleCreateCommand): Promise<Circle> {
const { userId, name } = command;
const ownerId = userId;
const circleName = new CircleName(name);
const circle = this.circleFactory.create(circleName, new Owner(ownerId));
//.. 생략 ..
});
}
}
트랜잭션
데이터의 무결성, 즉 서로 모순이 없고 일관적인 데이터를 유지하기 위해서 데이터베이스에 유일키 제약(unique key)을 설정할 수 있습니다. 그러나 이는 코드로 표현되지 않을 뿐더러 특정 데이터베이스의 기술이기 때문에 특정 기술에 의존하게 됩니다. 그래서 일정 덩어리의 조작을 한꺼번에 완료하거나 취소하는 방법인 트랜잭션을 홀용하여 데이터의 무결성을 지키는 게 좋습니다.
트랜잭션을 사용할 때는 코드에서 ‘이 부분에서 데이터 무결성을 확보해야 한다’는 것이 명시적으로 표현되어야 합니다. 그리고 트랜잭션을 사용할 때는 데이터베이스에 Lock이 걸리므로, 성능 및 트랜잭션 처리의 실패 가능성 측면에서 범위를 최소한으로 해야 합니다.
적용하기
트랜잭션을 사용하기 위해서는 TypeORM의 EntityManager 객체의 transaction 메서드를 사용해야 합니다. 애플리케이션에 AppDataSource 를 사용할 수도 있지만 이미 AppDataSource를 주입받아 사용하는 리포지토리를 사용하는 게 좋겠죠. 그래서 get manager 메서드를 구성하여 EntityManager를 가져올 수 있게 했습니다.
그리고 트랜잭션안에서 find 메서드를 사용할 때, EntityManager를 변수로 입력받고, 트랜잭션임을 나타내는 transaction: true 옵션과 데이터를 읽는 순간 쓰기 작업까지 락을 거는 lock: { mode: "pessimistic_write" } 옵션을 설정했습니다.
// circle.repository.ts
export interface CircleRepository {
get manager(): EntityManager;
findByIdWithLock(manager: EntityManager, id: number): Promise<Circle | null>;
}
// circle.to.repository.ts
@singleton()
export class CircleTORepository {
//.. 생략 ..
get manager(): EntityManager {
return this.repository.manager;
}
async findByIdWithLock(manager: EntityManager, id: number): Promise<Circle | null> {
return manager.findOne(Circle, {
where: { id },
transaction: true,
lock: { mode: "pessimistic_write" },
relations: { members: true },
});
}
}
// circle.application.service.ts
@singleton()
export class CircleApplicationService {
//.. 생략 ..
async createCircle(command: CircleCreateCommand): Promise<Circle> {
// .. 생략 ..
return this.circleRepository.manager.transaction(async (manager) => {
const owner = await this.userRepository.findByIdWithLock(manager, ownerId);
if (!owner) throw new ResourceNotFoundError();
if (await this.circleService.exists(manager, circle)) throw new AlreadyExistError();
return manager.save(circle);
});
}
명세
명세는 객체를 평가하기 위한 객체입니다. 객체를 평가하는 절차가 단순하다면 객체의 메서드로 정의하면 되지만, 복잡할 경우 명세로 구현하는 것이 좋습니다. 서클의 도메인 규칙으로 “프리미엄 사용자가 10명 이상인 서클은 최대 인원이 30명에서 50명으로 늘어난다”라는 규칙이 추가되었다고 해보겠습니다.
서비스가 이와 같은 도메인 규칙에 근거한 로직을 포함할 경우 도메인 객체가 제 역할을 빼앗기고 서비스 코드에 도메인 주요 규칙이 중복될 수 있으므로, 해당 로직은 도메인 객체에 정의되어야 합니다.
다대다 관계 설정이 되지 않은 경우를 생각했을 때, 엔티티에 해당 규칙을 구현하려면 “프리미엄 사용자가 10명 이상인지 확인”하는 단계에서 리포지토리를 써야 하고, 결과적으로 엔티티가 리포지토리를 가지며 도메인 모델을 나타내는 데 집중하지 못하게 됩니다.
이때, 명세를 활용하여 객체를 평가하는 로직을 캡슐화하면 엔티티는 도메인 모델이라는 원래의 의도를 잘 나타낼 수 있습니다.
적용하기
유저와 서클을 다대다 관계로 설정했기 때문에 릴레이션을 설정하여 find 메서드를 실행하면 아래 코드처럼 circle.members 에 담겨온 유저정보로 객체의 평가를 할 수 있습니다.
// circle.specification.ts
export class CircleFullSpecification {
isSatisfiedBy(circle: Circle): boolean {
const premiumUserNumber = circle.members.filter((member) => member.isPremium).length;
const circleUpperLimit = premiumUserNumber < 10 ? 30 : 50;
return circle.countMember() >= circleUpperLimit;
}
}
// circle.application.service.ts
async joinCircle(command: CircleJoinCommand): Promise<Circle> {
const { userId, id } = command;
const memberId = userId;
const circleFullSpec = new CircleFullSpecification();
return this.circleRepository.manager.transaction(async (manager) => {
const member = await this.userRepository.findByIdWithLock(manager, memberId);
if (!member) throw new ResourceNotFoundError();
const circle = await this.circleRepository.findByIdWithLock(manager, id);
if (!circle) throw new ResourceNotFoundError();
if (circleFullSpec.isSatisfiedBy(circle)) throw new CircleCapacityExceededError();
circle.join(member);
return manager.save(circle);
});
}
애그리게이트
일반적으로 하나의 개념을 나타내는 데 여러 객체가 참여합니다. 이 개념들은 어떠한 처리가 되는 동안에 참을 유지해야 하는 어떤 불변 조건을 가지고 있을 것입니다. 애그리게이트는 이러한 불변 조건을 유지하는 단위로, 쉽게 말해 변경의 단위입니다.
애그리게이트는 경계와 루트를 갖습니다. 경계 안에 있는 것은 애그리게이트에 포함되는 객체이고, 루트는 애그리게이트를 다루는 모든 조작이 거쳐야 할 객체입니다. 애그리게이트를 활용함으로써 데메테르 법칙에서 말하는 메서드를 사용할 수 있는 객체의 범위를 제한할 수 있고, 변경에 대한 로직을 모아둠으로써 관리를 수월하게 할 수 있습니다.
코드로 구현할 때는 리포지토리는 애그리게이트 당 하나씩만 구현한다고 생각하면 좋습니다.
다대다 관계 설정하기
유저와 서클의 관계를 다대다로 설정하려고 하니, 엔티티의 필드가 값객체로 되어 있으니 설정이 불가했습니다. 그래서 원시타입으로 설정을 변경을 해야 했고, 동시에 값객체의 의미를 코드에서 유지해야 했습니다.
User 엔티티
userId는 User 객체를 생성할 때, 생성자에서 uuid를 무조건 할당하기 때문에 UserId 값객체가 아닌 string으로 변경해도 괜찮겠다고 판단했습니다. 문제는 userName 이었습니다.
엔티티에 규칙 표현하기
userId를 변경했던 것처럼 userName도 string으로 표현하고 class-validator 를 활용해서 userName의 최대, 최소 길이를 검사했습니다. 얼핏 보면 도메인 규칙을 반영한 것 같지만, userName을 다른 곳에서 사용할 경우 길이를 검사하는 데코레이터를 계속 써줘야 할 것입니다. 코드 중복이 발생하는 것이죠. 그리고 userName 이라는 도메인 모델도 사라졌습니다.
@Entity("tb_user")
export class User extends BaseEntity {
constructor(userName: string) {
super();
this.userId = uuidv4();
this.userName = userName;
}
// .. 생략 ..
@Column({ type: "varchar" })
@MinLength(3)
@MaxLength(10)
userName: string;
changeUserName(userName: string): void {
if (!userName) {
throw new Error("사용자명은 필수입니다.");
}
this.userName = userName;
}
}
그래서 TypeORM의 @Column 과 class-transformer를 활용했습니다. userName 도메인 모델을 표현했으며, 값객체 안에 도메인 규칙을 class-validator를 통해 구현했습니다. 그리고, @Column을 아래처럼 활용할 경우, 디폴트로 매칭되는 컬럼의 이름은 user_name_value 이기 때문에 prefix, name 옵션을 통해서 user_name이 되도록 했습니다.
// user.ts
@Column(() => UserName, { prefix: "user" })
@Type(() => UserName)
@ValidateNested()
userName: UserName;
// user.vo.ts
export class UserName {
@Column({ type: "varchar", name: "name" })
@MinLength(2)
@MaxLength(10)
value: string;
constructor(value: string) {
this.value = value;
}
}
Circle 엔티티
다대다 관계 설정하기
위처럼 변경한 뒤, User와 Circle 엔티티에 다대대 관계를 설정했습니다. 세부적인 설명은 해당 글을 참고해주세요.
@Entity("tb_circle")
export class Circle extends BaseEntity {
//.. 생략 ..
@ManyToMany(() => User, (user) => user.circles)
@JoinTable({
name: "tb_circle_member",
joinColumn: { name: "circle_id", referencedColumnName: "id" },
inverseJoinColumn: { name: "user_id", referencedColumnName: "userId" },
})
members: User[];
}
참고자료
도메인 주도 설계 철저 입문, 위키북스
'Dev > DDD' 카테고리의 다른 글
마이크로서비스 애플리케이션 아키텍처 (0) | 2025.06.03 |
---|---|
MSA의 이해 (0) | 2025.05.28 |
[의존 관계 역전 원칙] 의존 관계 방향성 제어를 통해 소프트웨어 유연성을 확보하자 (0) | 2025.01.25 |
객체의 관계는 설계 의도로 결정된다. (0) | 2025.01.25 |
유저 CRUD 코드 예시로 도메인 개념 살펴보기: 도메인 서비스, 리포지토리, 애플리케이션 서비스 (0) | 2025.01.22 |