이 글은 도메인 주도 설계에 대해 공부하기 위해 다음 책을 읽고 공부한 내용을 정리한 글입니다.
지난 시간에 살펴본 값 객체, 엔티티, 도메인 서비스는 도메인 지식을 표현하기 위한 것들이었다. 이번 시간에는 애플리케이션을 구성하기 위한 패턴들에 대해서 정리하려고 한다.
리포지토리의 일반적인 의미는 보관창고이다. 소프트웨어 개발에서 말하는 리포지토리 역시 데이터 보관 창고를 의미한다.
리포지토리의 책임은 도메인 객체를 저장하고 복원하는 퍼시스턴시다.
프로그램을 실행할 때 메모리에 로드된 데이터는 프로그램 종료와 함께 사라진다. 그런데 엔티티는 생애주기를 갖는 객체이기 때문에 프로그램 종료와 함께 사라져서는 안 된다.
객체를 다시 이용하려면 데이터 스토어에 있는 객체 데이터를 저장 및 복원할 수 있어야 한다. 리포지토리는 데이터를 저장하고 복원하는 처리를 추상화하는 객체이다.
객체 인스턴스를 저장할 때에는 데이터 스토어에 기록하는 처리를 직접하는 대신에 리포지토리에 객체의 저장을 맡긴다. 또한 저장해 둔 데이터에서 다시 객체를 읽어 들일 때도 리포지토리에 객체의 복원을 맡긴다.
만약 데이터 스토어를 조작하는 절차를 도메인 코드에 직접 노출한다면, 코드의 많은 부분이 데이터베이스를 다루는 코드로 이루어져 있어 코드의 의도를 파악하기 어려워진다. 반면에, 데이터 스토어를 직접 다루는 퍼시스턴시 관련 처리를 리포지토리에 맡기면 비지니스 로직을 더욱 순수하게 유지할 수 있다.
리포지토리를 거쳐 간접적으로 데이터를 저장 및 복원하는 방식을 취하면 소프트웨어의 유연성이 놀랄만큼 향상된다.
리포지토리는 인터페이스로 정의된다.
export interface UsersRepository {
findOneById(id: number): Promise<User | null>;
save(user: User): Promise<User>;
}
주의할 점은 '사용자명 중복 확인' 같은 도메인 규칙에 가까운 것들은 리포지토리에 기술해서는 안 된다. 리포지토리의 책임은 객체의 퍼시스턴시까지이다. '사용자명 중복 확인'은 도메인 서비스가 주체가 되어야 한다.
인터페이스로 정의한 리포지토리를 구현하는 방법 중에 인메모리 방식과 ORM을 이용하는 방식을 소개해보려고 한다.
테스트를 위한 인프라를 구축하는 것은 번거로운 일이다. 때문에 아래와 같이 테스트용 리포지토리를 인메모리 형태로 만들어 사용하기도 한다.
export class TestQuestionsRepository implements QuestionsRepository {
private nextId = 1;
private questions: Question[] = [];
async findOneById(id: number) {
const question = this.questions.find(question => question.id === id);
if(!question) {
return null;
}
return question;
}
async save(question: Question) {
question.id = this.nextId++;
this.questions.push(question);
return question;
}
reset() {
this.nextId = 1;
this.questions = [];
}
}
SQL문을 직접 코드에 작성해 실행하는 대신 객체-관계 매핑(ORM)을 많이 사용한다. 먼저, 데이터베이스 테이블과 매핑되는 객체(엔티티)를 만든다. 주의할 점은 도메인 객체의 엔티티와 ORM에서 말하는 엔티티는 서로 다르다는 것이다.
@Entity('users')
export class User {
...
@PrimaryGeneratedColumn()
id: number;
@Column()
email: string;
...
}
그 다음, 리포지토리 인터페이스를 구현하고, ORM 객체를 다루는 리포지토리를 만들어준다.
@Injectable()
export class TypeormUsersRepository implements UsersRepository {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async findOneById(id: number): Promise<User | null> {
const user = await this.userRepository.findOne({ where: { id }});
return user;
}
async save(user: User): Promise<User> {
const savedUser = await this.userRepository.save(user);
return savedUser;
}
}
이렇게 리포지토리를 인터페이스로 정의하고 각 상황에 맞게 구현한다면 특정 DB나 환경(테스트 등)에 종속되지 않으며, 서비스와 리포지토리 간 결합도를 낮출 수 있다.
애플리케이션 서비스는 유스케이스를 구현하는 객체이다. 애플리케이션의 목표는 이용자의 필요를 만족시키고 목적을 달성하게 하는 것이다. 그러나 도메인 객체만으로는 이를 해결할 수 없다. 애플리케이션 서비스는 이용자가 원하는 기능을 구현하며, 이를 위해 도메인 서비스, 리포지토리, 도메인 객체 등을 활용한다.
이 행동들이 애플리케이션 서비스의 메서드가 된다.
export class GetUserDto {
public readonly id: UserId,
public readonly email: string
constructor(id: UserId, email: string) {
this.id = id;
this.email = email;
}
}
class UserApplicationService {
constructor(
private readonly userRepository: UserRepository,
private readonly userService: UserService,
) {}
register(email: string) {
const user = new User(email);
if (this.userService.exists(user)) {
throw new Error('이미 등록된 사용자입니다.');
}
this.userRepository.save(user);
}
get(email: string): User | null {
const user = this.userRepository.findOneByEmail(email);
if (!user) {
throw new Error('사용자를 찾을 수 없습니다.');
}
return GetUserDto(user.id, user.email);
}
}
위 예제에서 UserApplicationService 에서는 리포지토리, 도메인 서비스 등을 사용하여 유스케이스를 구현하고 있다.
UserApplicationService의 get 메서드를 살펴보면, 도메인 객체가 아닌 GetUserDto 객체를 리턴하고 있다. 도메인 객체를 클라이언트에 공개하는 경우 외부에서 데이터를 조작할 수 있기 때문에 비공개로 남겨 두고, 데이터 전송을 위한 객체(DTO, Data Transfer Object)를 만들어 전달하는 것이 좋다.
class UpdateUserCommand {
constructor(
public readonly email: string
)
}
class UserApplicationService {
update(command: UpdateUserCommand): void {
...
}
}
또한, 데이터를 변경하는 경우 파마리터를 하나하나 직접 다 받는 것보다, 다음 예제와 같이 커멘드 객체를 만들어 받는 것이 좋다.
애플리케이션 서비스는 도메인 객체가 수행하는 작업들을 조율하기만 해야 하며, 도메인 규칙이 직접적으로 노출되어서는 안 된다.
예를 들어, '같은 닉네임을 가진 사용자는 없어야 한다' 라는 도메인 규칙이 있다. 이를 도메인 객체가 아닌 애플리케이션 서비스에 기술한다면?
class UserApplicationService {
...
register(nickname: string) {
...
if (this.userRepository.findByEmail(email)) {
throw new Error('이미 등록된 사용자입니다.');
}
const user = new User(email);
this.userRepository.save(user);
}
update(...) { ... }
문제점은 도메인 규칙이 바뀌었을 때 수정해야 할 부분이 도메인 객체가 아닌 애플리케이션 객체가 된다는 점이다. 이 규칙은 사용자 등록 뿐만 아니라 사용자 정보 수정에도 반영되어야 하는 규칙이다. 만약 위 규칙이 '같은 이메일을 가진 사용자는 없어야 한다'로 변경되었다면, register 메서드와 update 메서드 모두 수정해야 한다.
따라서 애플리케이션 서비스에는 유스케이스를 처리하는 로직만 담당하고, 도메인 규칙과 관련된 내용은 도메인 객체에 위임해야 하는 것이 좋다.
서비스는 자신의 행동을 변화시키는 것을 목적으로 하는 상태를 갖지 않는다. 예제의 UserApplicationService의 경우 userService와 userRepository를 속성으로 가지고 있지만, 서비스의 행동을 변화시키기 위한 목적이 아니다.
상태가 가져오는 복잡성은 개발자를 혼란스럽게 한다. 상태를 만들지 않을 방법을 먼저 생각해 보는 것이 좋다.
객체 생성을 책임지는 객체를 마치 도구를 만드는 공장과도 같다고 해서 '팩토리'라고 부른다. 즉, 팩토리는 객체의 생성 과정과 관련된 지식이 정리된 객체이다.
팩토리는 리포지토리와 마찬가지로 도메인에서 유래한 객체는 아니다. 그렇지만, 도메인을 표현하기 위해 필요한 요소이다. 도메인을 표현하는데 도움을 주는 팩토리와 리포지토리 등의 요소는 도메인 설계를 구성하는 요소가 된다.
class User {
private readonly id: UserId;
private name: UserName;
// 사용자 객체를 복원할 때
constructor(id: UserId, name: UserName) {
...
}
}
class UserFactory {
// 사용자를 최초로 생성할 때
create(name: UserName): User {
const id = new UserId( ... );
return new User(id, name);
}
}
const user = userFactory.create(...);
위와 같이 팩토리를 통해 User 객체를 생성할 수 있다.
class Circle {
constructor(
public userId: UserId,
public name: CircleName
);
}
class User {
private id: UserId;
createCircle(circleName: CircleName): Circle {
return new Circle(this.id, circleName);
}
}
클래스 자체가 아닌 메서드가 팩토리의 역할을 수행하는 경우도 있다.
모든 인스턴스를 팩토리에서 만들어야 하는 것은 아니다. 생성 절차가 간단하다면 그냥 생성자 메서드를 호출하는 편이 낫다. 팩토리가 필요한지 검토하는 습관을 들이자.
Reference
도메인 주도 설계 철저 입문 (나루세 마사노부 저/심효섭 역)