본문 바로가기
Dev/DDD

유저 CRUD 코드 예시로 도메인 개념 살펴보기: 도메인 서비스, 리포지토리, 애플리케이션 서비스

by 싯벨트 2025. 1. 22.
728x90

도메인 주도 개발 리포지터리

이번 글에서는 도메인 서비스, 리포지토리, 애플리케이션 서비스에 대한 개념을 살펴보고, 유저에 대한 CRUD를 구현한 코드까지 살펴보겠다.


도메인 서비스

도메인 서비스란 무엇인가

소프트웨어 개발에서 클라이언트를 위해 무언가를 해주는 객체를 서비스라고 한다. 도메인 서비스란 도메인을 위해 무언가를 해주는 객체로, 값 객체나 엔티티로 구현했을 때 부자연스러운 행동을 담당한다.

도메인에 대한 규칙 등 어떠한 처리를 도메인 객체 안에 정의했을 때 잘 들어맞지 않는다는 느낌이 든다면 이를 도메인 서비스로 옮기면 자연스럽게 나타낼 수 있다. 그치만 남용을 할 경우, 데이터와 행위가 단절되며 로직이 흩어질 수 있는데 이는 데이터와 행위를 모아놓는다는 객체 지향 설계의 기본 원칙을 거스를 뿐만 아니라 엔티티 같은 도메인 객체가 정작 도메인에 대한 정보를 제공하지 못하는 빈혈 도메인 모델을 초래할 수 있으니 주의해야 한다.

도메인 서비스가 다루는 것

도메인 서비스에서 어떤 처리를 할 때, 불가피하게 데이터스토어를 다루는 경우가 생길 수 있다. 그런데 데이터스토어와 관련된 코드로 인해 정작 도메인에서 다루어야 할 본질적인 것, 예를 들면 ‘사용자를 생성한다’, ‘사용자명 중복을 확인하다’와 같은 내용을 파악하기 어려워진다. 그래서 이는 데이터 퍼시스턴시를 추상화한 객체인 리포지토리를 통해 데이터스토어 코드를 담당하게 하여 처리한다.

도메인 서비스가 데이터스토어를 다루는 것에 대해 일각에서는 도메인에 없는 존재이기 때문에 다루면 안 된다고 하지만, 어떤 처리가 도메인에 기초했다면 도메인 서비스에서 데이터스토어를 다루는 것도 도메인 주도 설계에서 크게 벗어나지 않으니 고려해볼만 하다. 단, 애플리케이션을 만들며 필요하게 된 것은 도메인 서비스에 들어가면 안 된다.

도메인 서비스 이름 규칙

도메인 파일의 이름은 다음 3가지 중 하나를 주로 사용한다.

  • 도메인 개념
  • 도메인 개념 + Service
  • 도메인 개념 + DomainService

리포지토리

리포지토리란 무엇인가

리포지토리는 데이터를 저장하고 복원하는 처리를 추상화하는 객체다. 객체를 저장하고 복원하는 데이터 퍼시스턴시에 대한 책임을 담당한다. 데이터스토어 관련 코드가 리포지토리에 정리됨으로써 도메인 객체는 도메인에 대한 정보를 담는 데 충실할 수 있다.

리포지토리의 책임

리포지토리의 책임은 객체의 퍼시스턴시까지다. 계속 예시를 들었던 유저 이름에 대한 중복 확인은 도메인 규칙에 가까우므로 이것은 리포지토리의 책임에 벗어난다. 도메인 서비스에서 인프라를 다루는 것이 꺼려져서 리포지토리에 관련 로직을 정의하고 싶다면 구체적인 중복확인 키를 전달하여 중복확인의 기준이 사용자명이라는 도메인 지식이 누락되지 않게 한다.

리포지토리 구현

비즈니스 로직에서 특정 기술에 의존하는 구현은 바람직하지 않지만, 리포지토리의 구현 클래스는 특정 기술에 의존해도 문제없다. 그리고 저장을 할 때는 저장 대상 객체를 인자로 받아야 한다. 특정 항목만 받게 하면 안된다.

애플리케이션 서비스

애플리케이션 서비스는 유즈케이스를 구현하는 객체다. 도메인 객체들을 조합하여 이용자의 목적에 부응하는 기능을 구현한다. 애플리케이션 서비스는 도메인 객체가 수행하는 태스크를 조율하는 데만 전념해야 한다. 도메인 규칙을 기술할 경우, 같은 코드가 여러 곳에 중복되는 현상이 나타난다. 앞서 계속 예시로 다루었던 사용자 도메인에 대한 CRUD 유즈케이스를 구현한 코드를 함께 살펴보자. 

API는 사용자 등록, 단건 조회, 리스트 조회, 정보 수정, 탈퇴만 구현했다.

기술 스택

  • typescript
  • typeorm
  • mysql
  • express

코드

1. 도메인 규칙은 도메인 모델에 위치시킨다.
2. DTO를 통해 도메인 객체를 클라이언트에 노출시키지 않는다.
3. 애플리케이션 서비스 분리를 통해 응집도를 높인다.
4. 커맨드 객체를 통해 애플리케이션 서비스의 메서드 시그니처를 유지한다.
5. 리포지터리의 패턴은 인터페이스를 구성하고, 구현체는 특정 기술에 의존해도 괜찮다.

디렉토리 구조

값 객체 - user.vo.ts

UserId와 UserName은 값 객체로 각각 도메인 규칙을 가지고 있다. 생성자에 이를 반영하여 string을 값 객체 인스턴스로 만들 때, 해당 규칙을 체크하도록 했다. 이렇게 구성할 경우 User 클래스, 즉 엔티티에서 typeorm과 연결을 하는 과정에서 컬럼에 추가적인 조치를 해줘야 한다. 바로 이어지는 user.ts 코드에서 살펴보자.

export class UserId {
    constructor(public value: string) {
        if (!value) {
            throw new Error("유저아이디는 필수입니다.");
        }
    }
}

export class UserName {
    constructor(public value: string) {
        if (!value) {
            throw new Error("사용자명은 필수입니다.");
        }
        if (value.length < 2 || value.length > 10) {
            throw new Error("사용자명은 2글자 이상, 10글자 이하여야 합니다.");
        }
    }
}

엔티티 - user.ts

식별자는 유일해야 하므로, uuid 라이브러리를 통해 userId를 구성했다. 도메인 규칙을 갖는 값 객체인 UserId, UserName은 클래스이므로, DB에 저장하기 위해서는 trasformer 옵션을 통해서 string으로 변경해줘야 한다. createdAt, updated

At, deletedAt과 같은 시간에 대한 컬럼은 다른 엔티티에도 기본적으로 사용되기 때문에 BaseEntity에 구성하고 이를 상속받았다. 

import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { UserId, UserName } from "./user.vo";
import { v4 as uuidv4 } from "uuid";
import { BaseEntity } from "../common/base.entity";

@Entity("tb_user")
export class User extends BaseEntity {
    constructor(userName: UserName) {
        super();
        this.userId = new UserId(uuidv4());
        this.userName = userName;
    }

    @PrimaryGeneratedColumn()
    idx: number;

    @Column({
        type: "varchar",
        transformer: {
            to: (value: UserId) => value.value,
            from: (value: string) => new UserId(value),
        },
    })
    userId: UserId;

    @Column({
        type: "varchar",
        transformer: {
            to: (value: UserName) => value.value,
            from: (value: string) => new UserName(value),
        },
    })
    userName: UserName;

    changeUserName(userName: UserName): void {
        if (!userName) {
            throw new Error("사용자명은 필수입니다.");
        }
        this.userName = userName;
    }
}

도메인 서비스 - user.service.ts

도메인 서비스는 중복을 확인하는 로직을 구현하고 있으며, 리포지토리에 userName을 인자로 전달하여 어떤 요소를 중복 체크하는지에 대한 도메인 지식을 표현했다.

import { User } from "./user";
import { UserRepository } from "./user.repository";

export class UserService {
    constructor(private userRepository: UserRepository) {}

    async exists(user: User): Promise<boolean> {
        const { userName } = user;
        const users = await this.userRepository.findAll(userName);
        return users.length > 0 ? true : false;
    }
}

애플리케이션 서비스 - user.application.service.ts

중복을 체크하는 도메인 서비스와 데이터스토어를 다루는 리포지토리의 조합으로 관련 기능을 구현했다.

export class UserApplicationService {
    constructor(
        private userService: UserService,
        private userRepository: UserRepository,
    ) {}

    async registerUser(command: UserRegisterCommand): Promise<UserDataModel> {
        const { name } = command;
        const user = new User(new UserName(name));
        const userNameExists = await this.userService.exists(user);
        if (userNameExists) {
            throw new AlreadyExistError();
        }
        return new UserDataModel(await this.userRepository.save(user));
    }
    //.. 다른 메서드들..
}

애플리케이션 서비스 - user.persistence.service.ts

애플리케이션 서비스의 메서드들 중에는 도메인 서비스를 사용하지 않고, 리포지터리만 사용하는 메서드들이 있다. 응집도를 높이기 위해 해당 메서드들로만 구성된 애플리케이션 서비스를 분리했다.

export class UserPersistenceService {
    constructor(private userRepository: UserRepository) {}

    async getUser(command: UserGetCommand): Promise<UserDataModel> {
        const { id } = command;
        const user = await this.userRepository.findById(new UserId(id));
        if (!user) throw new ResourceNotFoundError();

        return new UserDataModel(user);
    }
    //.. 다른 메서드들 ..
}

커맨드 객체 - user.command.ts

생성, 수정 기능 등에 파라미터가 추가될 경우, 애플리케이션 서비스의 수정이 불가피하다. 메서드 시그니처를 유지하기 위해 각 메서드에 커멘드 객체들을 구성했다. 변경이 발생해도, 커맨드 객체만 수정하면 대응이 가능하다. 

export class UserUpdateCommand {
    constructor(
        public id: string,
        public name: string,
    ) {}
}

export class UserRegisterCommand {
    constructor(public name: string) {}
}

export class UserGetCommand {
    constructor(public id: string) {}
}

export class UserDeleteCommand {
    constructor(public id: string) {}
}

DTO - user.dto.ts

애플리케이션 서비스에서 메서드의 결과로 도메인 객체를 그대로 노출시킬 경우, 클라이언트에서 객체의 메서드를 사용할 수 있는 위험이 발생한다. 도메인 객체의 행동에 대한 책임은 오직 애플리케이션 서비스에만 있어야 한다. 이를 구현하기 위해 DTO를 리턴값에 적용하여 클라이언트가 도메인 객체의 메서드를 사용하지 못하게 했다.

import { User } from "./user";
import { UserId, UserName } from "./user.vo";

export class UserDataModel {
    constructor(user: User) {
        this.userId = user.userId;
        this.userName = user.userName;
        this.createdAt = user.createdAt;
    }

    userId: UserId;
    userName: UserName;
    createdAt: Date;
}