v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날

도메인 서비스를 박은 다음 그 위에 Application Service 4종 + Controller 4종 + DTO 4종을 한 번에 박은 결정 이야기. trackState, secondLevel, track1/2Completed, curriculumProgress 같은 v3.0 필드가 Application 경계에서 어떻게 흡수되는지, 그리고 그 결과로 터진 빌드 에러 51개를 다섯 카테고리로 잡아간 새벽의 디버깅 흐름까지. 도메인이 비즈니스 규칙을 가지고, Application은 그것을 호출하는 얇은 오케스트레이션 막이 된다는 결합 규약을 코드 위에서 다시 한 번 확인한 한 Phase의 기록.


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

  • 도메인 서비스를 박은 다음 날, Application Layer 4종을 한 Phase에 박았다. LevelAdjustmentApplicationService, BundleApplicationService, DiagnosticApplicationService, 그리고 사용자 클라이언트용 4종 — student-auth, student-home, student-assignment, student-content
  • Application은 도메인을 부르는 얇은 막이다. 비즈니스 규칙은 domain/services/가 가지고, Application은 트랜잭션을 열고 두세 도메인 서비스를 순서대로 호출하고 응답 DTO를 만드는 일만 한다. 이 규약이 무너지는 순간 v2.0 시절의 1,200줄짜리 서비스가 되돌아온다
  • v3.0 필드를 Application 경계에서 흡수했다. trackState(SINGLE / MIX), secondLevel, track1Completed/track2Completed, curriculumProgress, reviewLevel — 다섯 개의 새 필드가 도메인 코드에는 한 번씩만 등장하고, Application이 응답 DTO를 만들 때 모아서 직렬화한다
  • Phase 5 Controller 4종 + DTO 4종을 한 밤에 박았다. 사용자 학습 클라이언트의 인증·홈·과제·번들·콘텐츠 15개 엔드포인트가 한 커밋에 들어갔고, 그 직후 pnpm build에서 51~52개 타입 에러가 터졌다
  • 빌드 에러 51개를 다섯 카테고리로 잡았다. Prisma 필드명 불일치(15+), 관계 쿼리 변경(5+), 타입 정의(3), Import 경로(2), 변수 충돌(1) — 카테고리화해서 한 카테고리씩 정리하니 다섯 시간이 안 걸렸다
  • 앵글 한 줄. Application Layer를 얇게 유지하는 비결은 “여기서 if 한 줄 추가하지 말까?”라는 유혹을 매번 거절하는 것이다. 거기 들어간 if는 90% 확률로 도메인 서비스의 책임이다

🗺️ 이 편의 자리 — 도메인 박은 다음 날, 위에 올릴 게 없는 상태

지난 편에서 한 커밋에 1,682줄짜리 도메인 레이어를 박았다. types.ts(438줄), 도메인 서비스 5종, bundle.events.ts(218줄), BundleCompletedHandler(69줄)가 한꺼번에 들어갔다. 도메인 레이어 자체는 컴파일이 됐다 — 그러나 그 위에 올릴 게 없었다.

도메인 서비스는 공개 API가 없는 모듈이다. BundleGenerationService.generate(...)를 누가 어디서 부르냐는 질문에 v2.0 시절의 답은 *“애플리케이션 서비스가 부른다”*였고, v2.1 도메인을 박은 직후에는 그 애플리케이션 서비스가 아직 v2.0 모양이었다. 도메인은 v3.0 필드 — trackState, secondLevel, track1Completed, curriculumProgress — 를 알고 있는데, Application은 그 필드를 모르는 상태로 남아 있었다.

[Domain v2.1]   ← 박힘 (1,682줄, 어제)
[Application]   ← v2.0 모양 그대로 (오늘 다시 박아야 함)
[Controller]    ← v2.0 모양 그대로 (오늘 박아야 함)
[DTO]           ← v2.0 모양 그대로 (오늘 박아야 함)

이 격차를 메우는 작업이 Phase 4(Application Layer)와 Phase 5(API/Controller)다. 두 Phase는 새벽에 연달아 진행됐고, Phase 4 완료 보고에서 Phase 5 완료 보고까지 30분이 걸렸다. 빠르게 박을 수 있었던 이유는 단 하나, 도메인 서비스에 비즈니스 규칙을 다 박아 둔 덕분에 Application은 정말 “부르기만” 하면 됐기 때문이다.

Application Layer가 도메인 서비스를 호출하는 의존 흐름 도식 — 사용자 요청이 Controller, DTO, Application Service, Domain Service, Repository를 거쳐 다시 응답 DTO로 돌아가는 한 사이클의 구조

📌 핵심: Application Layer가 얇아지는 건 그 자체가 목적이 아니라, 도메인 서비스에 비즈니스 규칙이 빠짐없이 박혀 있다는 신호다. Application이 두꺼워지기 시작하면 — if (student.trackState === 'MIX' && ...) 같은 코드가 Application Service에 등장하면 — 그건 도메인 서비스가 빈자리를 남겼다는 뜻이다.


🧱 결정 1 — Application Service는 도메인을 부르는 얇은 막

Phase 4에서 박은 Application Service는 세 개다.

서비스줄 수(추정)책임
LevelAdjustmentApplicationService~180트랙 상태 분기 + 연속일 관리 + 트랙 완료/합류
BundleApplicationService~140트랙 기반 번들 생성 + trackNumber 전달
DiagnosticApplicationService~110레벨 배치 제거 + 커리큘럼 기반 초기 등급 결정

이름의 패턴부터 v2.0과 다르다. v2.0 시절 비슷한 책임은 LevelService 한 덩어리에 다 들어가 있었고, 그 한 클래스가 도메인 규칙·트랜잭션 관리·DTO 매핑·로깅을 다 가지고 있었다. v2.1에서 그 한 덩어리를 셋으로 쪼개고, 각 조각의 이름에 Application을 박았다.

LevelAdjustmentApplicationService의 한 메서드 모양

// apps/api/src/application/services/level-adjustment.application.service.ts (발췌)

@Injectable()
export class LevelAdjustmentApplicationService {
  constructor(
    private readonly tx: PrismaService,
    private readonly decision: LevelAdjustmentDecisionService, // 도메인 서비스
    private readonly tracks: TrackManagementService,           // 도메인 서비스
    private readonly events: EventBus,
  ) {}

  async adjustOnBundleComplete(input: AdjustInput) {
    return this.tx.$transaction(async (tx) => {
      const member = await this.tracks.loadMemberWithTracks(tx, input.memberId);
      const decision = this.decision.evaluate({
        accuracy: input.accuracy,
        consecutiveDays: member.consecutiveDays,
        trackState: member.trackState,
      });

      const next = this.tracks.applyDecision(member, decision);
      await this.tracks.persist(tx, next);

      this.events.publish(new LevelAdjustedEvent(member.id, decision));
      return next;
    });
  }
}

이 메서드가 하는 일은 다섯 줄로 적는다 — (1) 트랜잭션을 열고, (2) 도메인 모델을 불러오고, (3) 도메인 서비스에 결정을 묻고, (4) 결과를 영속화하고, (5) 이벤트를 발행한다. 비즈니스 규칙 “정확도 90% 이상이면 레벨업 후보, 70% 미만이면 레벨다운, 연속 5일이면 레벨업” 같은 문장은 이 코드 어디에도 없다. 그건 LevelAdjustmentDecisionService.evaluate(...) 안에 박혀 있다.

⚠️ 주의: Application Service에 if (accuracy > 0.9) 같은 줄을 한 번 허용하면 다음에 또 한 줄 들어오고, 한 달 뒤에는 도메인 서비스가 비어 있고 Application이 1,200줄이 돼 있다. if 한 줄을 거절하기가 Application Layer 유지 전략의 거의 전부다.

의존 방향 한 줄 규약

Application Service는 도메인 서비스에 의존하고, Repository에 의존하고, 인프라(Prisma, EventBus)에 의존한다. 그 반대 방향 — 도메인 서비스가 Application Service를 부르는 일 — 은 일어나지 않는다. 한 줄 규약은 이렇게 적힌다.

[Controller] → [Application Service] → [Domain Service]
                       ↓                       ↓
                 [Repository] → [Prisma] ← [Domain Type]

이 화살표가 한 방향으로만 흐르도록 박아 두면, *“이 책임은 어느 레이어인가”*라는 질문이 거의 자동으로 풀린다. 재사용 가능한 규칙은 도메인, 한 유즈케이스 전용 오케스트레이션은 Application이라는 메모리 한 줄이 새 클래스를 박을 때마다 결정을 빠르게 만들어 줬다.


🎯 결정 2 — v3.0 필드를 Application 경계에서 흡수하기

v3.0 마이그레이션의 본체는 다섯 개의 새 필드다.

필드의미v2.0 자리v3.0 자리
trackStateSINGLE / MIX없음Member.trackState
secondLevel트랙 2의 등급없음Member.secondLevelId
track1Completed / track2Completed트랙 완료 여부없음두 boolean
curriculumProgress복수 목표 등급 진행 상태단일 목표 1개배열 + 진행률
reviewLevel복습 모듈에서 풀 등급없음도메인 서비스가 결정

도메인 레이어는 이 다섯 필드를 알고 있다. BundleGenerationServicetrackState를 받아 trackNumber를 정하고, LevelAdjustmentDecisionServicetrackState === 'MIX'일 때 트랙별로 독립적으로 결정한다. 그러나 이 필드가 사용자에게 보이는 형태는 도메인이 책임지지 않는다. 그 자리는 Application + DTO다.

BundleApplicationService가 v3.0 필드를 받아내는 자리

// apps/api/src/application/services/bundle.application.service.ts (발췌)

async generateNext(memberId: string): Promise<BundleResponseDto> {
  const member = await this.members.findOne(memberId);

  // 도메인 서비스가 v3.0 필드를 다 안다
  const bundle = await this.generation.generate({
    memberId,
    trackState: member.trackState,
    track1: { levelId: member.firstLevelId, completed: member.track1Completed },
    track2: member.trackState === 'MIX'
      ? { levelId: member.secondLevelId!, completed: member.track2Completed }
      : null,
    curriculumProgress: member.curriculumProgress,
  });

  // Application은 도메인 응답을 사용자 응답으로 변환만 한다
  return BundleResponseDto.fromDomain(bundle, {
    trackNumber: bundle.trackNumber,
    reviewLevel: bundle.reviewLevel?.displayName,
  });
}

이 메서드가 v3.0 필드 다섯 개를 모두 다룬다. 그러나 비즈니스 규칙은 단 한 줄도 없다. trackState === 'MIX'라는 조건이 보이긴 하지만 그건 *“Mix면 트랙 2 정보를 도메인에 같이 넘긴다”*라는 단순한 데이터 라우팅이다. 트랙 1과 트랙 2 중 어느 쪽 번들을 먼저 만들지, 둘 다 완료됐을 때 어떻게 합류시킬지 같은 결정은 모두 BundleGenerationService 안에 있다.

💡 인사이트: 데이터 라우팅비즈니스 결정을 구분하는 한 가지 기준은 “이 if를 다른 유즈케이스에서도 쓰나?”다. trackState === 'MIX' 분기가 사용자 홈/번들 생성/리포트에서 모두 등장하면 그건 도메인 서비스로 올라가야 한다. 한 군데에서만 등장하면 Application의 데이터 라우팅으로 남겨도 된다.

Member.curriculumProgress라는 새 풍경

curriculumProgress는 v3.0의 가장 큰 모양 변화다. v2.0은 사용자 한 명에 목표 등급이 한 개였고, v3.0은 그게 배열이다.

// v2.0
type Member = {
  curriculumTargetLevelId: string;  // 한 개
};

// v3.0
type Member = {
  curriculumTargetLevelIds: string[];  // 복수
  curriculumProgress: Array<{
    levelId: string;
    completedBundles: number;
    totalBundles: number;
  }>;
};

복수 목표는 도메인 모델 자체의 변화다. *“사용자가 동시에 두 개의 등급을 목표로 진행할 수 있다”*는 비즈니스 규칙이 들어간 결과다. 도메인 서비스가 이 배열을 가지고 다음 번들의 targetLevelId를 결정하고, Application은 그 결과만 받아 응답 DTO에 직렬화한다.

curriculumProgress 변환 자리는 Member 도메인 모델 → MemberHomeResponseDto의 단 한 곳에 박혀 있다. 이 한 곳을 잘 박아 두면, 학생 홈/관리자 대시보드/리포트의 진행률 표시가 모두 같은 한 변환을 거친다.


🛣️ 결정 3 — Controller 4종을 한 Phase에 박는 미친 짓에 가까운 결정

Phase 5는 사용자 학습 클라이언트용 Controller 4종을 한 번에 박는 작업이었다. 끝나고 나서 적은 표를 그대로 옮긴다.

그룹엔드포인트비고
인증POST /member/auth/login토큰 발급
인증POST /member/auth/refresh갱신
인증POST /member/auth/logout블랙리스트
인증PATCH /member/auth/password비번 변경
GET /member/hometrackState · 진행률 · 다음 번들 요약
출석POST /member/attendance활동 기록 1건
과제GET /member/assignment/current현재 진행중 작업 묶음
번들POST /member/bundle/:id/start번들 시작
번들GET /member/bundle/:id/content-candidates5단계 폴백 호출
번들POST /member/bundle/:id/select-content사용자 택1 결과
번들GET /member/bundle/:id/review-result복습 발동 결과
번들POST /member/bundle/:id/complete완료 + 레벨 결정
과제POST /member/assignment/:id/complete작업 완료
콘텐츠POST /member/content-attempt/submit정답 제출
콘텐츠POST /member/content-attempt/:id/complete콘텐츠 완료

15개 엔드포인트가 4개 Controller에 나뉘어 들어갔다. 이걸 한 Phase에 박은 결정의 근거는 셋이다.

(1) Controller는 정말 얇다. Application Service에 책임이 가 있으니, Controller 한 메서드는 평균 4~6줄이다. @Body()로 받은 DTO를 Application Service에 넘기고, 응답을 그대로 반환한다. 그 외에 Swagger 데코레이터와 가드뿐이다.

(2) DTO를 같이 박아야 의미가 산다. Controller 따로 / DTO 따로 / Application Service 따로 박으면, 한 엔드포인트의 변경이 세 PR을 거친다. 한 엔드포인트가 정말 끝났다는 건 DTO·서비스·컨트롤러가 모두 같은 v3.0 필드 모양으로 정렬됐을 때다. 한 Phase로 묶는 게 그래서 더 빠르다.

(3) 한 클라이언트 단위로 묶었다. 이 15개는 모두 사용자 학습 클라이언트 한 클라이언트가 부른다. 관리자 포털이나 운영자 포털과는 의존이 끊겨 있다. 한 클라이언트가 쓰는 엔드포인트를 한 Phase로 묶으면, 그 클라이언트의 v3.0 마이그레이션이 끝났다는 표시가 깔끔하게 떨어진다.

Controller 메서드 한 개의 모양

// apps/api/src/application/controllers/member-bundle.controller.ts (발췌)

@Controller('member/bundle')
@UseGuards(MemberJwtGuard)
export class MemberBundleController {
  constructor(private readonly bundle: BundleApplicationService) {}

  @Get(':id/content-candidates')
  @ApiOperation({ summary: '콘텐츠 후보 2개 조회 (5단계 폴백)' })
  @ApiResponse({ type: ContentCandidatesResponseDto })
  async candidates(
    @CurrentMember() member: MemberContext,
    @Param('id') bundleId: string,
    @Query() query: ContentCandidatesQueryDto,
  ): Promise<ContentCandidatesResponseDto> {
    return this.bundle.getContentCandidates({
      memberId: member.id,
      bundleId,
      progressIndex: query.progressIndex,
      lastResult: query.lastResult,
    });
  }
}

Controller 메서드 한 개는 5줄이다. 가드·스웨거·핸들러뿐이고, 비즈니스 로직은 한 줄도 없다. BundleApplicationService.getContentCandidates(...)가 도메인 서비스를 부르고, 5단계 폴백을 거쳐 ContentCandidate[](정확히 2개)를 반환한다.

docs.nestjs.com

NestJS 공식 문서가 권장하는 Controller 사용 방식이 이 한 줄 규약과 정확히 일치한다 — “Controllers are responsible for handling incoming requests and returning responses to the client. Their purpose is to receive specific requests for the application.” 비즈니스 로직은 컨트롤러 자리가 아니라는 문장이 docs/controllers 첫 단락에 박혀 있다.

📌 핵심: Controller가 얇아지는 건 Application Service가 그 무게를 다 받아 주기 때문이다. 그리고 Application Service가 얇아지는 건 도메인 서비스가 그 무게를 다 받아 주기 때문이다. 얇은 Controller는 도메인 모델이 건강하다는 외부 신호다.


🔥 빌드 에러 51개 — 한 Phase의 이자

Phase 5가 끝난 직후 pnpm build를 돌렸다. 결과는 51~52개 타입 에러였다. 한꺼번에 박아 둔 코드의 이자가 한 번에 청구됐다.

처음 30분은 좀 멍했다. 51개를 하나하나 잡으면 끝이 안 날 것 같았다. 그래서 카테고리화를 먼저 했다. 다섯 카테고리가 떨어졌다.

카테고리건수주요 사례
Prisma 필드명 불일치15+scheduledAt vs scheduled_at, targetMetricRank vs target_metric_rank, title/seq 필드 신설 분
관계 쿼리 변경5+classMembers 관계 이름 변경, playableLevels 관계 신설
타입 정의 수정3TransactionContext, ReviewModuleInput, AttendanceSource 인터페이스 보강
Import 경로2DatabaseServicePrismaService 모듈 분리 후속
변수 충돌1diagnosticVersion 중복 선언

가장 많은 건 Prisma 필드명 불일치였다. v3.0에서 모델명을 바꿨는데, 한 모델이 다른 모델의 관계 키로 등장하는 자리에서 옛 이름이 살아 있는 경우가 15곳쯤 됐다. 패턴이 같으니 정규식 한 번으로 쓸어내고, 남은 건 한 줄씩 잡았다.

카테고리화의 효과

51개를 차례대로 잡으면 5시간이 든다. 카테고리로 묶으면 카테고리당 30분이라 다섯 카테고리에 2시간 반 안 걸린다. 같은 패턴의 에러는 한 번에 같이 본다는 게 핵심이다.

$ pnpm build
> @alp/[email protected] build
> nest build

src/application/services/student-content.application.service.ts:42:18
  error TS2339: Property 'scheduled_at' does not exist on type 'Bundle'.

src/application/services/student-content.application.service.ts:78:22
  error TS2339: Property 'scheduled_at' does not exist on type 'Bundle'.

src/application/services/student-home.application.service.ts:103:14
  error TS2339: Property 'scheduled_at' does not exist on type 'Bundle'.
... (15 more)

이 패턴이 보이면 IDE의 Find in Path에서 scheduled_atscheduledAt 일괄 교체로 한 번에 끝난다. 같은 카테고리 안에서는 고민할 필요가 거의 없다. 카테고리를 가르는 데에 첫 30분을 썼고, 카테고리 간 작업 순서는 영향 범위가 가장 큰 것부터로 정했다.

🔍 단서: 빌드 에러 50개 이상이 한꺼번에 떨어질 때 가장 빠른 길은 에러 메시지로 그룹핑해 보는 것이다. 같은 메시지가 N건이면 그건 한 패턴의 N개 인스턴스다. 한 패턴씩 잡으면 N개가 같이 사라진다.

다 잡고 나서

$ pnpm build
> @alp/[email protected] build
> nest build
# 0 errors ✅

새벽 3시쯤 0 errors가 떴다. Phase 4 완료가 새벽 0시 30분, Phase 5 완료가 새벽 1시, 빌드 에러 51개 클로즈가 새벽 8시 50분이었다. 한 밤에 Application Layer + Controller + DTO + 빌드 정합성까지 모두 v3.0 모양으로 정렬했다.


📋 정리 — Application이 Domain을 호출하는 결합 규약

Phase 4와 Phase 5를 마치고 적은 결합 규약 한 표가 다음 편의 시작점이 된다.

위치책임의존 방향
ControllerHTTP 표면, DTO 검증, 가드→ Application Service
Application Service트랜잭션, 오케스트레이션, DTO 변환→ Domain Service, Repository
Domain Service비즈니스 규칙, 도메인 결정→ Domain Type, Repository 인터페이스
Repository (구현체)Prisma → 도메인 타입 변환→ Prisma
Domain Type비즈니스 어휘 (MetricRank, BundleStatus, V21_THRESHOLDS)(정점, 의존 없음)

이 표를 코드 한 줄 박을 때마다 다시 꺼낸다. *“이 if를 어디에 둘까”*라는 질문이 들면 위에서부터 내려가며 *“여기서는 호출만, 여기서는 변환만, 여기서는 결정만”*이라는 책임 한 줄로 자리를 정한다. 다음 편에서는 이 결합 규약이 v2.0 → v2.1 회고와 만나며 어떤 코드 품질 변화를 만들어 냈는지를 다룬다.

상황❌ 안티패턴✅ 권장 패턴
비즈니스 규칙 위치Application Service에 if 한 줄Domain Service의 메서드 한 개
트랜잭션 경계Domain Service가 tx.$transactionApplication Service가 트랜잭션 열고 도메인 호출
DTO 변환Domain 타입을 그대로 응답Application이 *.fromDomain(...)으로 변환
한 Phase 묶음 단위Controller만 / DTO만 / Service만 따로한 클라이언트의 엔드포인트를 한 Phase에
빌드 에러 50+ 개위에서부터 한 줄씩에러 메시지로 그룹핑 후 한 패턴씩

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

  1. 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
  2. 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
  3. 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
  4. 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
  5. 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
  6. 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
  7. 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
  8. 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
  9. 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
  10. 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
  11. 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
  12. 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
  13. 13. 번들 구조를 통째로 바꿔야 했던 이유
  14. 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
  15. 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
  16. 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
  17. 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
  18. 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
  19. 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
  20. 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
  21. 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
  22. 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
  23. 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루