DDD를 도입하기로 했다 — Repository/Domain/Application 3계층

📚 교육용 풀스택 SaaS 개발기 시리즈 (7편)

교육용 SaaS 백엔드에 DDD 3계층 구조를 도입한 과정. PrismaService 직접 호출에서 Repository 패턴, Domain Service, Application Service로 분리하기까지 4개 커밋에 걸친 아키텍처 전환 실전기.


💡 Tip. 바쁜 현대인들을 위한 본문 요약

  • v1 구조의 한계: 서비스에서 PrismaService를 직접 호출 → 비즈니스 로직과 DB 접근이 뒤섞여서 테스트·확장이 어려워졌다
  • 3계층 분리: Repository(영속성) → Domain Service(비즈니스 로직) → Application Service(오케스트레이션) 구조로 전환
  • BaseRepository 인터페이스 하나로 모든 Repository의 공통 계약을 정의하고, TransactionContext로 트랜잭션을 유연하게 처리
  • Domain Service는 순수 로직만 — HTTP도, DB도 직접 건드리지 않는다. Repository 인터페이스에만 의존
  • 4개 커밋, 8,810줄 추가로 전체 아키텍처를 전환. 코드량은 늘었지만, 이후 v2 리팩토링 때 이 구조가 생명줄이 됐다

🤔 왜 갑자기 DDD를 — 결정의 배경

지난 편까지 Prisma 스키마 27개 테이블, Seed 데이터, PK 리팩토링까지 마무리했다. 이제 본격적으로 비즈니스 로직을 작성할 시간이었다.

문제는 v1의 구조였다. 당시 src/ 아래에 있는 건 이게 전부였다.

src/
├── app.controller.ts
├── app.module.ts
├── app.service.ts
├── main.ts
├── prisma.service.ts
└── user/
    ├── user.controller.ts
    ├── user.module.ts
    └── user.service.ts

UserService의 코드를 보면 전형적인 NestJS 튜토리얼 패턴이었다.

// ❌ Before — v1의 UserService
@Injectable()
export class UserService {
  constructor(private readonly prisma: PrismaService) {}

  findAll() {
    return this.prisma.user.findMany();
  }

  createTestUser() {
    return this.prisma.user.create({
      data: {
        email: `test_${Date.now()}@example.com`,
        name: `Test User`,
      },
    });
  }
}

CRUD 하나 만드는 데는 이 구조로 충분하다. 그런데 우리 서비스의 요구사항은 단순 CRUD가 아니었다. 유저가 활동을 시작하면 콘텐츠 후보를 선정하고, 지표를 집계하고, 레벨 조정 여부를 판단하고, 커리큘럼 달성도를 평가해야 한다. 하나의 요청이 5~6개 테이블을 건드리는 트랜잭션이 필요한 구조.

이걸 UserService 패턴으로 밀어붙이면 어떻게 되는지 뻔했다. 서비스 하나에 2,000줄짜리 PrismaService 호출이 뒤섞이고, 비즈니스 로직과 쿼리가 한 메서드 안에서 스파게티를 이룬다.

📌 핵심: “지금은 괜찮은데”라는 말이 가장 위험하다. 스키마 27개 테이블, 유즈케이스 10개. 이 규모에서 레이어 분리 없이 가면 한 달 뒤에 돌이킬 수 없는 진흙탕이 된다.

결정의 계기는 도메인 문서에 적어놓은 유즈케이스 목록이었다. UC-06(태스크 롤링 발행)부터 UC-10(재도전)까지, 하나하나가 여러 도메인 개념을 교차하는 복잡한 흐름이었다. “이걸 Service 하나로 어떻게 짜지?”라는 질문에 답이 없었다. 그래서 DDD를 도입하기로 했다.


🏗️ 설계 — 3계층을 어떻게 나눌 것인가

DDD라고 해서 거창하게 시작한 건 아니다. 핵심은 단순했다. 책임을 3개 층으로 나누자.

계층 정의

계층책임의존 방향
RepositoryDB 접근, 영속성Prisma ← 이 계층만 Prisma를 안다
Domain Service순수 비즈니스 로직Repository 인터페이스에만 의존
Application Service오케스트레이션, DTO 변환Domain Service + Repository 조합

의존성 방향은 한 방향이다. Application → Domain → Repository. 역방향 의존은 금지.

Application Layer (Controller, AppService, DTO)


Domain Layer (Domain Service, Repository Interface, Entity)


Infrastructure Layer (Repository 구현체, Prisma, PostgreSQL)

⚠️ 주의: 여기서 핵심은 Domain Layer가 Prisma를 직접 import하지 않는다는 것이다. Domain Service는 IStudentRepository 같은 인터페이스에만 의존하고, 실제 Prisma 호출은 Repository 구현체가 담당한다.

왜 이렇게 나눠야 하냐면, Domain Service를 테스트할 때 DB 없이 Mock Repository만 주입하면 되기 때문이다. 비즈니스 로직 검증에 PostgreSQL 연결이 필요 없어진다.

DDD 3계층 아키텍처 구조도


🔧 1단계 — Repository 계층 구축 (커밋 #15)

가장 먼저 한 일은 기반 타입과 인터페이스를 정의하는 것이었다. domain/common/ 폴더를 만들고 세 파일을 작성했다.

types.ts — 도메인 공통 타입

// domain/common/types.ts
export type EntityId = string | number;

export type TransactionContext = Omit<
  PrismaClient,
  '$connect' | '$disconnect' | '$on' | '$transaction' | '$use'
>;

EntityIdstring | number로 정의한 이유가 있다. 5편에서 PK를 CUID(string) → Mixed Strategy(string + number)로 바꿨는데, Repository 인터페이스 레벨에서 PK 타입에 종속되지 않으려면 유니온 타입이 필요했다.

TransactionContext는 Prisma의 트랜잭션 클라이언트 타입이다. $transaction 콜백 안에서 받는 tx 파라미터의 타입을 정의한 것. 모든 Repository 메서드에 선택적으로 tx를 받을 수 있게 해서, 트랜잭션 경계를 Application Layer에서 결정할 수 있도록 했다.

base.repository.ts — 베이스 인터페이스

// domain/common/base.repository.ts
export interface BaseRepository<T> {
  findById(id: EntityId, tx?: TransactionContext): Promise<T | null>;
  save(entity: T, tx?: TransactionContext): Promise<void>;
  update(entity: T, tx?: TransactionContext): Promise<void>;
}

모든 Repository가 상속하는 최소 계약. findById, save, update 세 개뿐이다. 화려한 제네릭을 넣고 싶은 유혹을 참았다. 필요한 건 나중에 추가하면 되고, 지금 중요한 건 “모든 Repository는 이 세 가지는 반드시 구현한다”라는 규약을 세우는 것.

errors.ts — 도메인 에러

// domain/common/errors.ts
export class EntityNotFoundError extends Error {
  constructor(entityName: string, id: string | number) {
    super(`${entityName} with id ${id} not found`);
    this.name = 'EntityNotFoundError';
  }
}

export class BusinessRuleViolationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'BusinessRuleViolationError';
  }
}

HTTP 상태 코드에 의존하지 않는 순수 도메인 에러다. NotFoundException은 NestJS 종속이라 Domain Layer에서 쓰면 안 된다. 대신 EntityNotFoundError를 던지고, Application Layer의 ExceptionFilter에서 HTTP 404로 변환한다.

🔍 단서: 이 분리가 나중에 진가를 발휘했다. v2 리팩토링 때 Domain Layer를 통째로 옮기면서도 에러 핸들링 코드를 한 줄도 안 바꿔도 됐다.

Repository 인터페이스 + 구현체

기반 타입이 준비됐으니, 엔티티별 Repository를 만들 차례. 첫 타자는 StudentRepository였다.

// domain/student/student.repository.interface.ts
export interface IStudentRepository extends BaseRepository<Student> {
  findByEmail(email: string, tx?: TransactionContext): Promise<Student | null>;
  findByLoginId(loginId: string, tx?: TransactionContext): Promise<Student | null>;
  findByClassId(classId: EntityId, tx?: TransactionContext): Promise<Student[]>;
  findActiveStudents(tx?: TransactionContext): Promise<Student[]>;
  findByConsecutiveDays(
    type: 'poor' | 'excellent' | 'normal',
    days: number,
    tx?: TransactionContext,
  ): Promise<Student[]>;
}

BaseRepository<Student>를 상속하면서, 도메인에 필요한 쿼리 메서드를 추가했다. findByConsecutiveDays 같은 건 레벨 조정 배치에서 쓰는 메서드인데, 이런 도메인 특화 쿼리를 인터페이스에 명시하는 게 Repository 패턴의 핵심이다.

구현체는 Prisma를 감싸는 형태다.

// domain/student/student.repository.ts
@Injectable()
export class StudentRepository implements IStudentRepository {
  constructor(private readonly prisma: PrismaService) {}

  private getClient(tx?: TransactionContext) {
    return tx || this.prisma;
  }

  async findById(id: EntityId, tx?: TransactionContext): Promise<Student | null> {
    const client = this.getClient(tx);
    return await client.student.findUnique({
      where: { id: String(id) },
      include: { user: true },
    });
  }

  async save(student: Student, tx?: TransactionContext): Promise<void> {
    const client = this.getClient(tx);
    await client.student.create({ data: student });
  }

  // ...
}

getClient(tx) 패턴이 포인트다. 트랜잭션이 주어지면 트랜잭션 클라이언트를 쓰고, 아니면 기본 Prisma를 쓴다. 이걸로 트랜잭션 참여 여부를 호출자가 결정할 수 있게 됐다.

첫 번째 커밋에서 5개 Repository를 만들었다. Student, Level, Assignment, ContentItem, ContentAttempt. 1,825줄 추가.


⚙️ 2단계 — Domain Service 구축 (커밋 #17)

Repository가 준비됐으니, 이제 비즈니스 로직을 담을 Domain Service를 작성할 차례다.

가장 핵심적인 서비스부터 만들었다. 태스크 생성 알고리즘을 담는 BlockGenerationService.

// domain/services/block-generation.service.ts
@Injectable()
export class BlockGenerationService {
  private readonly METRIC_SIMILARITY_MAP: Record<MetricCode, MetricCode[]> = {
    METRIC_A: [MetricCode.METRIC_B],
    METRIC_B: [MetricCode.METRIC_C, MetricCode.METRIC_A],
    METRIC_C: [MetricCode.METRIC_B],
    METRIC_D: [MetricCode.METRIC_E],
    METRIC_E: [MetricCode.METRIC_D],
  };

  constructor(
    private readonly contentItemRepository: IContentItemRepository,
    private readonly levelRepository: ILevelRepository,
  ) {}

  async generateBlock(input: BlockGenerationInput): Promise<BlockGenerationResult> {
    const blockType =
      input.completedCustomizedCount % 3 === 0
        ? BlockType.REMIND
        : BlockType.CUSTOMIZED;

    if (blockType === BlockType.CUSTOMIZED) {
      return await this.generateCustomizedBlock(input);
    } else {
      return await this.generateRemindBlock(input);
    }
  }
}

이 코드에서 주목할 부분이 두 가지 있다.

첫째, constructor에 구현체가 아닌 인터페이스 타입이 들어간다. IContentItemRepository, ILevelRepository. 이 서비스는 “콘텐츠를 어떻게 쿼리하는지”는 모른다. “콘텐츠 후보를 달라”고 요청만 한다. 실제 Prisma 쿼리가 어떻게 생겼는지는 Repository 구현체의 관심사다.

둘째, METRIC_SIMILARITY_MAP 같은 비즈니스 규칙이 서비스 안에 있다. DB에 저장할 수도 있지만, 이 매핑은 도메인 전문가가 정한 고정 규칙이다. 코드로 표현하는 게 맞다. 변경 빈도가 낮고, 변경 시 코드 리뷰를 거쳐야 하는 종류의 규칙이니까.

이 커밋에서 만든 Domain Service는 5개다.

서비스역할
BlockGenerationService태스크 그룹 생성 알고리즘 (맞춤/리마인드)
ContentCooldownService콘텐츠 쿨다운 관리
CurriculumEvaluationService진행 트랙 달성도 평가
LevelAdjustmentDecisionService스킬 레벨 조정 판단
MetricAggregationService퍼포먼스 지표 집계 (TOP 5)

총 2,591줄. 순수 비즈니스 로직만 들어있다. HTTP도 없고, res.json()도 없다.

📌 핵심: Domain Service의 단위 테스트를 작성할 때 PrismaService를 mock할 필요가 없다. Repository 인터페이스만 mock하면 된다. 테스트 셋업이 5줄에서 2줄로 줄어든다.


📦 3단계 — Application Service 구축 (커밋 #18)

Application Service의 역할은 오케스트레이션이다. Domain Service와 Repository를 조합해서 유즈케이스 흐름을 완성한다.

// application/services/assignment.application.service.ts
@Injectable()
export class AssignmentApplicationService {
  private readonly EXCELLENT_THRESHOLD = 90;
  private readonly POOR_THRESHOLD = 70;
  private readonly MIN_CUSTOMIZED_BLOCKS = 3;

  constructor(
    private readonly prisma: PrismaService,
    private readonly blockGenerationService: BlockGenerationService,
    private readonly eventEmitter: EventEmitter2,
  ) {}

  async generateRollingAssignment(dto: GenerateRollingAssignmentDto) {
    // 1. 유저 조회
    const student = await this.prisma.student.findUnique({
      where: { id: dto.studentId },
    });
    if (!student || !student.currentLevelId) {
      throw new NotFoundException('유저를 찾을 수 없거나 스킬 레벨이 배치되지 않았습니다.');
    }

    // 2. 태스크 생성
    const assignment = await this.prisma.assignment.create({
      data: {
        studentId: dto.studentId,
        status: AssignmentStatus.ACTIVE,
        source: dto.source,
        runtimeMinutes: dto.runtimeMinutes || 30,
        scheduledAt: new Date(),
        attemptCount: 1,
      },
    });

    // 3. 첫 태스크 그룹 생성 (Domain Service 위임)
    const blockResult = await this.generateNextBlock({
      assignmentId: assignment.id,
      studentId: dto.studentId,
    });

    // 4. 이벤트 발행
    this.eventEmitter.emit('assignment.created', { ... });

    return { assignment, firstBlock: blockResult };
  }
}

Application Service에서 눈여겨볼 패턴 세 가지.

1. DTO를 받고, 도메인 객체를 반환한다. Controller에서 넘어온 DTO를 Domain Service가 이해할 수 있는 형태로 변환하고, 결과를 다시 Response DTO로 포장한다. 이 변환이 Application Layer의 핵심 책임이다.

2. 트랜잭션 경계를 결정한다. 여러 Repository를 호출해야 하는 작업이면, $transaction으로 감싸는 건 이 레이어에서 한다. Domain Service는 트랜잭션을 모른다.

// 트랜잭션 패턴 — Application Layer에서 경계 설정
await this.prisma.$transaction(async (tx) => {
  await this.bundleRepo.save(bundle, tx);
  await this.contentRepo.save(content, tx);
  await this.metricService.aggregate(studentId, tx);
});

3. 도메인 이벤트를 발행한다. EventEmitter2를 통해 이벤트를 발행하는 건 Application Service의 몫이다. Domain Service가 이벤트를 직접 발행하면 @nestjs/event-emitter에 종속되니까.

이 커밋에서 만든 Application Service는 6개, DTO는 7개. 총 2,657줄.


🧩 4단계 — 모듈 구성과 의존성 연결 (커밋 #19)

코드를 다 짜도, NestJS에게 “이 인터페이스에 저 구현체를 넣어줘”라고 알려주지 않으면 런타임에 터진다. 이 단계에서 11개 Bounded Context 모듈을 만들고, DI 연결을 완성했다.

// modules/assignment/assignment.module.ts
@Module({
  providers: [
    AssignmentRepository,
    BlockGenerationService,
    ContentItemRepository,
    LevelRepository,
  ],
  exports: [AssignmentRepository, BlockGenerationService],
})
export class AssignmentModule {}

그리고 AppModule이 이 구조를 조립한다.

// ✅ After — v2 AppModule
@Module({
  imports: [
    // 핵심 인프라 (Global)
    DatabaseModule,
    CommonModule,
    EventModule,

    // Bounded Context 모듈 (11개)
    StudentModule,
    ContentModule,
    AssignmentModule,
    LevelAdjustmentModule,
    LevelModule,
    MetricModule,
    CurriculumModule,
    DiagnosticModule,
    LearningActivityModule,
    OrganizationModule,
    AttendanceModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

v1의 AppModule과 비교해보면 차이가 극명하다.

// ❌ Before — v1 AppModule
@Module({
  imports: [UserModule, DomainModule],
  controllers: [AppController],
  providers: [AppService, PrismaService],
})
export class AppModule {}

모듈 2개에서 14개로 늘었다. 코드량은 폭증했지만, 각 모듈의 책임이 명확해졌다. AssignmentModule은 태스크 관련만, StudentModule은 유저 관련만.

⚠️ 주의: 모듈 11개를 만들 때 가장 신경 쓴 건 순환 의존 방지였다. AssignmentModuleStudentModule을 import하고, StudentModule이 다시 AssignmentModule을 import하면 NestJS가 부팅 시 터진다. 의존 그래프를 한 방향으로 유지하는 게 중요하다.

이 커밋에서 1,737줄이 추가됐다. 11개 모듈 정의, DatabaseModule(PrismaService 글로벌화), CommonModule(에러 필터, 인터셉터), EventModule(도메인 이벤트), 그리고 main.ts 재구성.


📊 Before vs After — 숫자로 보는 변화

4개 커밋으로 총 8,810줄이 추가됐다. 파일 수도 크게 늘었다. 하지만 단순히 코드가 늘어난 게 아니라, 구조가 생긴 것이다.

디렉토리 구조 비교

❌ Before (v1)                    ✅ After (v2)
src/                              src/
├── app.module.ts                 ├── application/
├── prisma.service.ts             │   ├── controllers/
└── user/                         │   ├── services/      ← 오케스트레이션
    ├── user.controller.ts        │   └── dtos/
    ├── user.module.ts            ├── domain/
    └── user.service.ts           │   ├── common/        ← 타입, 에러
                                  │   │   ├── types.ts
                                  │   │   ├── base.repository.ts
                                  │   │   └── errors.ts
                                  │   ├── services/      ← 비즈니스 로직
                                  │   ├── student/       ← Repository
                                  │   ├── content/
                                  │   ├── assignment/
                                  │   └── ...
                                  ├── modules/           ← 11개 Bounded Context
                                  │   ├── student/
                                  │   ├── content/
                                  │   ├── assignment/
                                  │   └── ...
                                  ├── common/            ← Guards, Filters
                                  └── events/            ← 도메인 이벤트

핵심 차이 요약

항목Before (v1)After (v2)
파일 수6개50개+
계층1계층 (Service = 모든 것)3계층 (Repo/Domain/App)
DB 접근어디서나 PrismaServiceRepository 구현체만
비즈니스 로직Service에 뒤섞임Domain Service에 격리
테스트DB 연결 필수Mock Repository로 단위 테스트
트랜잭션각 서비스에서 직접Application Layer에서 결정
모듈2개14개 (Bounded Context)

🛡️ 돌아보며 — 이 결정이 옳았는지

솔직히 말하면, 이 시점에서 DDD를 도입한 건 과잉 설계에 가까웠을 수도 있다. 아직 API 엔드포인트 하나 없는 상태에서 3계층을 나누고 11개 모듈을 만든 거니까.

그런데 이 투자가 회수되는 순간이 바로 왔다. 다음 편에서 다룰 인터페이스 구현체 전환(커밋 #20), 그리고 v2.0 대전환에서 스키마를 통째로 바꿀 때, Repository 인터페이스 덕분에 Domain Service는 한 줄도 안 고쳐도 됐다. 구현체만 교체하면 끝.

1인 개발에서 DDD가 필요한가? 보통은 아니다. 그런데 도메인이 복잡하고, 스키마 변경이 예정되어 있고, 장기 프로젝트라면 이야기가 다르다. 초기 투자 3일이 이후 3개월의 리팩토링 비용을 절약해줬다.

📌 핵심: DDD는 “지금” 필요해서 도입하는 게 아니라, “곧 필요해질 것”을 알기 때문에 도입하는 것이다. 27개 테이블, 10개 유즈케이스를 가진 도메인에서 레이어 분리 없이 간다면, v2 리팩토링 때 전부 갈아엎어야 했을 것이다.


📋 정리 — 핵심 요약

계층파일 위치역할의존 대상
Repositorydomain/{entity}/DB 영속성PrismaService
Domain Servicedomain/services/순수 비즈니스 로직Repository 인터페이스만
Application Serviceapplication/services/오케스트레이션, DTO 변환Domain Service + Repository
Modulemodules/{context}/DI 연결, Bounded Context위 세 계층 조립
안티패턴권장 패턴
❌ Service에서 PrismaService 직접 호출✅ Repository를 통해 간접 접근
❌ 비즈니스 로직에 HTTP 에러 던지기✅ 도메인 에러 → ExceptionFilter에서 변환
❌ 하나의 Service에 모든 로직✅ Domain(로직) + Application(오케스트레이션) 분리
❌ 트랜잭션을 Domain Layer에서 결정✅ Application Layer에서 $transaction 경계 설정
❌ 구현체에 직접 의존✅ 인터페이스에 의존, DI로 구현체 주입

다음 편에서는 이 3계층 위에 인터페이스 구현체 전환을 올리는 과정을 다룬다. StudentRepositoryIStudentRepository로 바꾸고, NestJS DI 컨테이너에서 느슨한 결합을 완성하는 이야기.

docs.nestjs.com
prisma.io