v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
📚 교육용 풀스택 SaaS 개발기 시리즈 (23편)
도메인 서비스를 박은 다음 그 위에 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가 얇아지는 건 그 자체가 목적이 아니라, 도메인 서비스에 비즈니스 규칙이 빠짐없이 박혀 있다는 신호다. 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 자리 |
|---|---|---|---|
trackState | SINGLE / MIX | 없음 | Member.trackState |
secondLevel | 트랙 2의 등급 | 없음 | Member.secondLevelId |
track1Completed / track2Completed | 트랙 완료 여부 | 없음 | 두 boolean |
curriculumProgress | 복수 목표 등급 진행 상태 | 단일 목표 1개 | 배열 + 진행률 |
reviewLevel | 복습 모듈에서 풀 등급 | 없음 | 도메인 서비스가 결정 |
도메인 레이어는 이 다섯 필드를 알고 있다. BundleGenerationService는 trackState를 받아 trackNumber를 정하고, LevelAdjustmentDecisionService는 trackState === '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/home | trackState · 진행률 · 다음 번들 요약 |
| 출석 | POST /member/attendance | 활동 기록 1건 |
| 과제 | GET /member/assignment/current | 현재 진행중 작업 묶음 |
| 번들 | POST /member/bundle/:id/start | 번들 시작 |
| 번들 | GET /member/bundle/:id/content-candidates | 5단계 폴백 호출 |
| 번들 | 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개)를 반환한다.
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 관계 신설 |
| 타입 정의 수정 | 3 | TransactionContext, ReviewModuleInput, AttendanceSource 인터페이스 보강 |
| Import 경로 | 2 | DatabaseService → PrismaService 모듈 분리 후속 |
| 변수 충돌 | 1 | diagnosticVersion 중복 선언 |
가장 많은 건 Prisma 필드명 불일치였다. v3.0에서 모델명을 바꿨는데, 한 모델이 다른 모델의 관계 키로 등장하는 자리에서 옛 이름이 살아 있는 경우가 15곳쯤 됐다. 패턴이 같으니 정규식 한 번으로 쓸어내고, 남은 건 한 줄씩 잡았다.
카테고리화의 효과
51개를 차례대로 잡으면 5시간이 든다. 카테고리로 묶으면 카테고리당 30분이라 다섯 카테고리에 2시간 반 안 걸린다. 같은 패턴의 에러는 한 번에 같이 본다는 게 핵심이다.
$ pnpm build
> @alp/api@0.0.1 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_at → scheduledAt 일괄 교체로 한 번에 끝난다. 같은 카테고리 안에서는 고민할 필요가 거의 없다. 카테고리를 가르는 데에 첫 30분을 썼고, 카테고리 간 작업 순서는 영향 범위가 가장 큰 것부터로 정했다.
🔍 단서: 빌드 에러 50개 이상이 한꺼번에 떨어질 때 가장 빠른 길은 에러 메시지로 그룹핑해 보는 것이다. 같은 메시지가 N건이면 그건 한 패턴의 N개 인스턴스다. 한 패턴씩 잡으면 N개가 같이 사라진다.
다 잡고 나서
$ pnpm build
> @alp/api@0.0.1 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를 마치고 적은 결합 규약 한 표가 다음 편의 시작점이 된다.
| 위치 | 책임 | 의존 방향 |
|---|---|---|
| Controller | HTTP 표면, 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.$transaction | Application Service가 트랜잭션 열고 도메인 호출 |
| DTO 변환 | Domain 타입을 그대로 응답 | Application이 *.fromDomain(...)으로 변환 |
| 한 Phase 묶음 단위 | Controller만 / DTO만 / Service만 따로 | 한 클라이언트의 엔드포인트를 한 Phase에 |
| 빌드 에러 50+ 개 | 위에서부터 한 줄씩 | 에러 메시지로 그룹핑 후 한 패턴씩 |
📚 교육용 풀스택 SaaS 개발기 시리즈 (23편)
- 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
- 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
- 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
- 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
- 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
- 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
- 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
- 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
- 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
- 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
- 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
- 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
- 13. 번들 구조를 통째로 바꿔야 했던 이유
- 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
- 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
- 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
- 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
- 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
- 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
- 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
- 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
- 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
- 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루