Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날

Phase 3-1·3-2가 만든 Repository와 Domain Service 위에 Application(953줄), Controller+DTO(763줄), Module(39줄)을 차례로 얹어 v2.0 번들 기반 학습 시스템을 닫는 단계. UC-06~10과 UC-14~17을 어떻게 코드로 옮겼는지, 인메모리 챌린지 스토어를 왜 일부러 남겼는지, 39줄짜리 모듈 한 장이 왜 끝의 끝인지를 기록한다.


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

  • Phase 3-3은 UC를 코드로 박는 단계다. BundleApplicationService가 UC-0610을, NewRecordChallengeApplicationService가 UC-1417을 맡아 합쳐 953줄이 들어갔다
  • Phase 3-4는 HTTP 표면 만들기. 컨트롤러 두 개에 v2 prefix를 단 엔드포인트 10개를 그렸고, 그 뒤를 받쳐주는 DTO 두 파일이 추가됐다
  • Phase 3-5는 39줄. ApplicationModule에 v2.0 providers / controllers / exports만 더하면 NestJS DI 컨테이너가 알아서 위 모든 객체를 묶어준다
  • 인메모리 챌린지 스토어는 의도된 미완성이다. “TODO: Move to Redis”를 일부러 남긴 이유는 v2.0의 본질이 “번들 학습”에 있고 챌린지는 부수 흐름이기 때문이다
  • 레벨링 모듈은 콘텐츠 3, 4 두 번만 돌린다. 5번에 한 번씩이 아니라 “사전 평가 → 재조정”이라는 두 번의 결정 지점만 잡아주는 게 운영자에게도 더 설명하기 쉽다

🗺️ 이 편의 자리 — Phase 3의 마지막 세 칸

지난 편에서 v2.0 Phase 3-1과 3-2를 닫았다. Repository 인터페이스 204줄과 Prisma 구현체 294줄이 깔렸고, 그 위에 BundleGenerationService 417줄과 LevelingModuleService 208줄이 올라갔다. 거기까지가 “DB가 새 모양인데 코드는 옛 시그니처를 들고 있는” 비대칭을 절반 봉합한 상태였다.

이번 편이 다루는 칸은 그 위 세 개다.

v2.0 번들 생성 콜체인 시퀀스 도식 — Controller부터 Repository까지 한 요청이 흐르는 길

도식에서 가장 왼쪽에 있는 HTTP 클라이언트가 POST /v2/assignments/42/bundles를 한 번 던지면, 그 요청이 컨트롤러 → 애플리케이션 서비스 → 도메인 서비스 → 리포지토리 → Prisma까지 다섯 칸을 지나간다. Phase 3-3·3-4·3-5는 이 길의 위 세 칸을 새로 까는 작업이다.

📌 핵심: 한 요청이 통과해야 할 다섯 칸 중에서 Phase 3-1·3-2에서 아래 두 칸을 깔았고, 이번 편에서 위 세 칸을 깐다. 다 깔리면 v2.0의 첫 엔드포인트가 비로소 “사용 가능한 상태”가 된다.

Phase작업추가 줄 수누구를 호출하나
3-3Application Services+953Phase 3-2의 Domain Services
3-4Controllers + DTOs+763Phase 3-3의 Application Services
3-5Module 등록+33 / -6NestJS DI 컨테이너 (위 네 레이어 전부)

세 커밋 합쳐 신규 1,755줄 + 수정 39줄. 줄 수만 보면 Phase 3-1·3-2(1,563줄)보다 큰데, 체감 난이도는 오히려 더 가볍다. 아래 두 칸을 인터페이스부터 그려놓은 덕에 위 칸을 짤 때 “이 메서드 시그니처에 맞춰 호출만 하면 된다”가 명확했기 때문이다.

다루는 세 커밋의 한 줄 요약

af26e07 — feat: Phase 3-3 Application Services 구현
- BundleApplicationService (616줄): UC-06 ~ UC-10
- NewRecordChallengeApplicationService (337줄): UC-14 ~ UC-17
총 +953줄

dff0c4c — feat: Phase 3-4 Controller & DTO Layer 구현
- BundleController (167줄) + NewRecordChallengeController (144줄)
- bundle.dto.ts (215줄) + new-record-challenge.dto.ts (152줄)
- BundleApplicationService 보완 (+80줄: getCurrentBundle 등)
- index.ts (5줄)
총 +763줄

1f9f184 — feat: Phase 3-5 Module 등록 완료
- ApplicationModule providers/controllers/exports 갱신
+33 / -6

🔧 Phase 3-3: Application Services — UC를 코드로 박는 단계

BundleApplicationService는 UC-06부터 UC-10까지 5개의 유즈케이스를 담는 클래스다. 5개 메서드의 책임을 한 줄씩 뽑으면 이렇다.

메서드UC한 줄 책임
generateBundleUC-065콘텐츠 번들을 새로 만들어 DB에 저장한다
startContentUC-07콘텐츠 1개의 상태를 IN_PROGRESS로 옮긴다
completeContentUC-08결과를 저장하고 레벨링 모듈을 한 번 돌린다
completeBundleUC-095콘텐츠 모두 끝났을 때 평균 정확도를 계산해 닫는다
completeAssignmentUC-10과제 전체를 닫고 달성도(EXCELLENT/NORMAL/POOR)를 매긴다

다섯 메서드를 합쳐 616줄. 클래스 하나에 묶기엔 작지 않은 분량이지만, 다섯 개가 같은 Aggregate(과제 → 번들 → 콘텐츠)에 붙어 있기 때문에 분리하면 오히려 가독성이 떨어진다. v1.x에서 이 다섯 가지 흐름이 다섯 군데 흩어져 있었던 게 v2.0의 통증 중 하나였고, 그 통증의 해소가 이 클래스의 존재 이유다.

generateBundle — 사전 조회 5개, 호출 1개, 저장 1개

async generateBundle(dto: GenerateBundleDto): Promise<BundleGeneratedResultDto> {
  // 1. 과제 조회 (학생 + 기존 번들 같이)
  const assignment = await this.prisma.assignment.findUnique({
    where: { id: BigInt(dto.assignmentId) },
    include: { bundles: true, student: true },
  });
  if (!assignment) throw new NotFoundException('과제를 찾을 수 없습니다.');
  if (!assignment.student.currentLevelId) {
    throw new BadRequestException('학생의 레벨이 배치되지 않았습니다.');
  }

  // 2. 번들 순서 계산
  const bundleOrder = assignment.bundles.length + 1;

  // 3. 학생 지표 스냅샷 조회 (가장 최근 것만)
  const metricSnapshots = await this.prisma.studentMetricSnapshot.findMany({
    where: { studentId: dto.studentId },
    orderBy: { snapshotDate: 'desc' },
    distinct: ['metricCode'],
  });

  // 4. 오늘 출제된 콘텐츠 조회 (킥오프 06:00 기준)
  const todayStart = this.getTodayKickoffTime();
  const todayAttempts = await this.prisma.contentAttempt.findMany({
    where: { studentId: dto.studentId, startedAt: { gte: todayStart } },
    select: { contentId: true },
    distinct: ['contentId'],
  });
  const todayUsedContentIds = todayAttempts.map((a) => a.contentId);

  // 5. Domain Service 호출 — 핵심 결정은 여기서
  const generationResult = await this.bundleGenerationService.generateBundle({
    studentId: dto.studentId,
    assignmentId: BigInt(dto.assignmentId),
    currentLevelId: assignment.student.currentLevelId,
    metricSnapshots: metricSnapshots.map((s) => ({
      metricCode: s.metricCode,
      score: s.score,
    })),
    todayUsedContentIds,
    selectedContentIdsInBundle: [],
  }, bundleOrder);

  // 6. Repository에 저장
  const bundle = await this.bundleRepository.create({
    assignmentId: BigInt(dto.assignmentId),
    bundleOrder,
    contents: generationResult.contents.map((c) => ({ ... })),
  });

  return { bundleId: Number(bundle.id), ... };
}

이 한 메서드를 보면 Application Layer의 책임이 또렷이 보인다. **“바깥 세계(DB)에서 데이터를 그러모아 Domain Service에 던져주고, 결과를 다시 DB에 저장한다”**가 전부다. 어떤 콘텐츠를 고를지, 어떤 폴백을 적용할지 같은 결정은 단 한 줄도 여기서 하지 않는다. 그건 지난 편에서 만든 BundleGenerationService의 일이다.

📌 핵심: Application Service는 “오케스트레이터”다. 결정은 안 하고, 데이터를 모아서 Domain Service에 던지고, 응답을 받아서 Repository에 저장한다. 비즈니스 규칙이 여기 들어오면 그 순간 레이어가 무너진다.

completeContent — 7단계 흐름의 본보기

completeContent는 한 메서드에 단계가 7개 들어가서 가장 길지만, 흐름이 깨끗해서 새로 합류한 사람이 읽어도 따라간다.

async completeContent(dto: CompleteContentDto): Promise<CompleteContentResultDto> {
  // 1. 번들 콘텐츠 조회
  const bundleContent = await this.bundleRepository.findContentById(dto.bundleContentId);
  if (!bundleContent) throw new NotFoundException('번들 콘텐츠를 찾을 수 없습니다.');

  // 2. 상태 확인 — IN_PROGRESS만 완료 가능
  if (bundleContent.status !== BundleContentStatus.IN_PROGRESS) {
    throw new BadRequestException(`완료 가능한 상태가 아닙니다: ${bundleContent.status}`);
  }

  // 3. 콘텐츠 결과 저장
  await this.bundleRepository.updateContent(Number(bundleContent.id), {
    status: BundleContentStatus.COMPLETED,
    accuracyPct: dto.accuracyPct,
    timeSpentMs: dto.timeSpentMs,
    completedAt: new Date(),
  });

  // 4. ContentAttempt 생성 (지표 반영용)
  await this.prisma.contentAttempt.create({
    data: {
      studentId: dto.studentId,
      bundleContentId: bundleContent.id,
      contentId: bundleContent.contentId,
      levelId: bundleContent.adjustedLevelId,
      origin: AttemptOrigin.BUNDLE,
      countsForAdjustment: true,
      accuracyPct: dto.accuracyPct,
      timeSpentMs: dto.timeSpentMs,
      ...
    },
  });

  // 5. Assignment.lastContentCompletedAt 갱신
  const bundle = await this.bundleRepository.findById(Number(bundleContent.bundleId));
  if (bundle) {
    await this.prisma.assignment.update({
      where: { id: bundle.assignmentId },
      data: { lastContentCompletedAt: new Date() },
    });
  }

  // 6. 레벨링 모듈 적용 (콘텐츠 3, 4 완료 시에만)
  let levelingApplied = false;
  let nextContentAdjustedLevelId: number | undefined;
  if (bundle) {
    const levelingResult = await this.applyLevelingModule(
      Number(bundle.id),
      bundleContent.contentOrder,
      dto.accuracyPct,
      bundleContent.originalLevelId,
    );
    levelingApplied = levelingResult.applied;
    nextContentAdjustedLevelId = levelingResult.adjustedLevelId;
  }

  // 7. 번들 완료 확인 (콘텐츠 5 완료 시 자동 호출)
  if (bundleContent.contentOrder === 5 && bundle) {
    await this.completeBundle(Number(bundle.id));
  }

  return { bundleContentId, contentOrder, accuracyPct, status, levelingApplied, nextContentAdjustedLevelId };
}

🔍 단서: 7단계 중 4번(ContentAttempt 생성)이 핵심이다. 번들 안 콘텐츠가 끝났다는 사실을 BundleContent 한 곳에만 기록하지 않고, 별도의 ContentAttempt 테이블에 한 번 더 적는다. 이유: 지표 누계와 레벨링 결정은 “이 학생이 이 콘텐츠를 어떻게 풀었는가”의 시계열 이력에서 나오는데, BundleContent만 보면 ‘왜 이 결정이 났는지’를 거꾸로 추적할 수 없다.


⏰ 킥오프 시간 — “오늘”의 정의를 코드에 박기

generateBundle 4단계의 getTodayKickoffTime()은 한 줄짜리 헬퍼지만 v2.0 운영 정책의 핵심 중 하나다.

private getTodayKickoffTime(): Date {
  const now = new Date();
  const kickoff = new Date(now);
  kickoff.setHours(6, 0, 0, 0); // KST 06:00
  if (now < kickoff) {
    kickoff.setDate(kickoff.getDate() - 1); // 새벽 학습은 어제로 친다
  }
  return kickoff;
}

⚠️ 주의: 자정 기준으로 “오늘”을 잡으면 새벽 학습이 통계에서 분리된다. 새벽 5시에 푼 콘텐츠가 이미 “오늘 출제된 콘텐츠”로 분류돼 다시 출제 후보에서 빠지는 사고가 v1.x 운영 중에 한 번 났다. 06:00을 경계로 잡으면 새벽까지의 학습이 어제 묶음으로 들어가서 “오늘 처음 받는 묶음”의 콘텐츠 풀이 회복된다.

이 한 줄을 메서드로 뽑은 이유는 다른 두 곳에서도 같은 정의를 쓰기 때문이다. “오늘”의 정의가 한 군데에만 살아 있어야 정책이 바뀔 때 한 번만 바꿔도 안전하다.


🎯 레벨링 모듈 트리거 — 왜 콘텐츠 3과 4 두 번만인가

completeContent 6단계에서 호출되는 applyLevelingModule콘텐츠 3과 4 완료 시에만 실행된다.

private async applyLevelingModule(
  bundleId: number,
  completedContentOrder: number,
  completedAccuracy: number,
  originalLevelId: number,
): Promise<{ applied: boolean; adjustedLevelId?: number }> {
  // 콘텐츠 1, 2, 5에서는 그냥 빠진다
  if (completedContentOrder !== 3 && completedContentOrder !== 4) {
    return { applied: false };
  }

  // ... (생략) ...

  let levelingResult;
  if (completedContentOrder === 3) {
    // 콘텐츠 1-3 평균 정확도로 콘텐츠 4, 5 레벨 결정
    const content1 = bundle.contents.find((c) => c.contentOrder === 1);
    const content2 = bundle.contents.find((c) => c.contentOrder === 2);
    levelingResult = await this.levelingModuleService.applyAfterContent3({
      content1Accuracy: content1?.accuracyPct || 0,
      content2Accuracy: content2?.accuracyPct || 0,
      content3Accuracy: completedAccuracy,
      currentLevel,
    });
    await this.bundleRepository.updateContentsLevel(bundleId, [4, 5], levelingResult.adjustedLevelId);
  } else {
    // 콘텐츠 4 단독 정확도로 콘텐츠 5 레벨 재조정
    levelingResult = await this.levelingModuleService.applyAfterContent4({
      content4Accuracy: completedAccuracy,
      currentLevel,
    });
    await this.bundleRepository.updateContentsLevel(bundleId, [5], levelingResult.adjustedLevelId);
  }

  this.eventEmitter.emit('leveling.applied', {
    bundleId, originalLevelId,
    adjustedLevelId: levelingResult.adjustedLevelId,
    reason: levelingResult.reason,
    contentOrder: completedContentOrder,
  });

  return { applied: true, adjustedLevelId: levelingResult.adjustedLevelId };
}

다섯 콘텐츠를 푸는 동안 다섯 번 매번 레벨을 만지는 게 아니라, 3번에서 한 번 사전 평가, 4번에서 한 번 재조정. 두 번의 결정 지점만 잡는 이유는 두 가지다.

첫째, 운영자에게 설명 가능한 모델이라야 한다. “왜 콘텐츠 5의 레벨이 콘텐츠 1과 다른가요?”라는 질문에 “콘텐츠 1~3 평균이 95%여서 한 단계 위로 갔어요”가 답이 된다. 매 콘텐츠마다 미세 조정하면 답이 “여러 요인이 작용해서요”로 흐려진다.

둘째, 사용자 체감 안정성. 1, 2 풀고 3 풀자마자 “아 이거 어려워졌네”가 한 번. 4 풀고 5에서 한 번. 두 번이면 충분하고, 세 번 이상은 불안정하게 느껴진다. 운영 데이터로도 두 번이 균형이 잘 맞았다.

📌 핵심: 트리거 시점은 코드 한 줄(completedContentOrder !== 3 && !== 4)이지만, 이 한 줄에 “운영자에게 설명 가능 + 사용자 체감 안정”이라는 두 정책이 박혀 있다. 이런 결정은 코드에 박기 전에 문서로 남겨야 6개월 뒤의 자신이 “왜 3과 4지?”를 다시 안 묻는다.


🔔 EventEmitter2 — 부수효과를 본 흐름에서 떼어내기

applyLevelingModule 마지막 줄, completeBundle 끝, bundle.completed 등에서 일관되게 eventEmitter.emit이 호출된다.

// leveling.applied
this.eventEmitter.emit('leveling.applied', {
  bundleId, originalLevelId, adjustedLevelId, reason, contentOrder,
});

// bundle.completed
this.eventEmitter.emit('bundle.completed', {
  bundleId, avgAccuracyPct,
});

이 한 줄들이 있는 이유는 단 하나, Application은 “이게 일어났다”만 알리고, “그래서 뭘 할지”는 모르는 척하기 위해서다. 레벨이 조정되면 모니터링 대시보드에 알림을 쏘고 싶고, 번들이 완료되면 학생 지표를 갱신하고 싶다. 이걸 같은 메서드 안에서 호출하면 “번들 완료 메서드가 알림 모듈 코드를 포함하는” 의존이 생긴다.

💡 인사이트: v1.x에서는 이 부분이 직접 호출이었다. completeBundle 안에서 metricAggregationService.updateStudentMetrics(...)를 직접 부르고, 알림도 같은 자리에서 쐈다. 그러다 보니 번들 완료 한 번 호출이 8개 모듈을 깨우는 큰 바위가 됐고, 메트릭 갱신이 실패하면 번들 완료 자체가 롤백되는 사고로 이어졌다. 부수효과는 이벤트로 빼내고, 본 흐름은 본 흐름의 책임만 진다.

이벤트 핸들러는 별도 파일에 모여 있고, 이 시리즈에서는 별도 편으로 다룰 예정이다. 지금 이 자리에서는 “Application은 한 발 앞에서 멈춘다”만 짚어두면 충분하다.


🌐 Phase 3-4: Controller & DTO — HTTP 표면 10개 그리기

Phase 3-4는 위에서 만든 두 Application Service를 HTTP에 노출시키는 단계다. 컨트롤러 2개, DTO 파일 2개. 합쳐 763줄.

BundleController — v2 prefix 6개 엔드포인트

@Controller()
export class BundleController {
  constructor(private readonly bundleService: BundleApplicationService) {}

  @Post('v2/assignments/:assignmentId/bundles')
  @HttpCode(HttpStatus.CREATED)
  async generateBundle(
    @Param('assignmentId', ParseIntPipe) assignmentId: number,
    @Body() dto: Omit<GenerateBundleRequestDto, 'assignmentId'>,
  ): Promise<BundleGeneratedResultDto> {
    return this.bundleService.generateBundle({ ...dto, assignmentId });
  }

  @Post('v2/bundles/:bundleId/contents/:contentOrder/start')
  @HttpCode(HttpStatus.OK)
  async startContent(
    @Param('bundleId', ParseIntPipe) bundleId: number,
    @Param('contentOrder', ParseIntPipe) contentOrder: number,
    @Body() dto: { studentId: string },
  ): Promise<StartContentResultDto> {
    return this.bundleService.startContent({ bundleId, contentOrder, studentId: dto.studentId });
  }

  // ... completeContent, completeAssignment, getCurrentBundle, getBundlesByAssignment ...
}

여기서 눈에 띄는 패턴이 두 개 있다.

패턴 1. URL Param과 Body의 분리 — Omit<DTO, 'param'>

@Body() dto: Omit<GenerateBundleRequestDto, 'assignmentId'>,

assignmentId는 URL의 path parameter로 받고, body에서는 같은 필드를 타입에서 빼버린다. 그렇지 않으면 클라이언트가 path와 body 양쪽에 assignmentId를 보낼 수 있고, 두 값이 다르면 어느 쪽이 진짜인지 명확하지 않다. 소스가 하나뿐이라야 디버깅이 안 어그러진다.

⚠️ 주의: Omit을 빼먹고 @Body() dto: GenerateBundleRequestDto로 그냥 받으면, dto.assignmentIdassignmentId 두 변수가 살아남는다. 코드 리뷰에서 “둘 중 어느 게 먼저인가요?”가 나오면 이미 진 게임이다.

패턴 2. @HttpCode 명시

@Post('v2/assignments/:assignmentId/bundles')
@HttpCode(HttpStatus.CREATED) // 201, 기본은 201이지만 명시한다

@Post('v2/bundles/:bundleId/contents/:contentOrder/start')
@HttpCode(HttpStatus.OK) // 200 — 사실 POST의 기본은 201, 명시 필요

NestJS는 @Post 기본 응답이 201이다. start처럼 “리소스가 새로 생기지 않는 POST”는 200을 명시해줘야 클라이언트가 “이건 생성 아니구나”를 정확히 받는다. 암묵적 디폴트에 의존하지 않는다.

DTO 파일 — Swagger 문서까지 한 번에

// bundle.dto.ts (215줄)
export class GenerateBundleRequestDto {
  @ApiProperty({ description: '과제 ID' })
  @IsInt()
  assignmentId!: number;

  @ApiProperty({ description: '학생 ID' })
  @IsString()
  studentId!: string;
}

export class CompleteContentRequestDto {
  @ApiProperty()
  @IsInt()
  bundleContentId!: number;

  @ApiProperty()
  @IsString()
  studentId!: string;

  @ApiProperty({ description: '정확도 (0~100)' })
  @IsNumber()
  @Min(0)
  @Max(100)
  accuracyPct!: number;

  // ...
}

DTO를 class로 만들고 class-validator + @nestjs/swagger를 같이 거는 건 devlog-25 SC-A 시즌에 한 번 호되게 당한 뒤로 시리즈의 표준이 됐다. interface는 런타임에 사라져서 검증도 Swagger 문서도 못 만든다. v2.0의 DTO는 처음부터 class로 만들었다.


🧩 Phase 3-5: Module 등록 — 39줄로 닫는 v2.0

Phase 3-5는 진짜 작다. ApplicationModule 한 파일에 33줄 추가, 6줄 수정. 끝.

// Domain Services (v2.0)
import { BundleGenerationService } from '../domain/services/bundle-generation.service';
import { LevelingModuleService } from '../domain/services/leveling-module.service';

// Application Services (v2.0)
import { BundleApplicationService } from './services/bundle.application.service';
import { NewRecordChallengeApplicationService } from './services/new-record-challenge.application.service';

// Controllers (v2.0)
import { BundleController } from './controllers/bundle.controller';
import { NewRecordChallengeController } from './controllers/new-record-challenge.controller';

@Module({
  imports: [
    DomainModule,
    ScheduleModule.forRoot(),
  ],
  controllers: [
    // v1.x Controllers
    StudentOnboardingController,
    AssignmentController,
    LevelAdjustmentController,
    AttendanceController,
    BatchProcessController,
    FreeLearningController,
    // v2.0 Controllers
    BundleController,
    NewRecordChallengeController,
  ],
  providers: [
    // Domain Services (v1.x) ...
    // Domain Services (v2.0)
    BundleGenerationService,
    LevelingModuleService,
    // Application Services (v1.x) ...
    // Application Services (v2.0)
    BundleApplicationService,
    NewRecordChallengeApplicationService,
  ],
  exports: [
    // v1.x Application Services ...
    // v2.0 Application Services
    BundleApplicationService,
    NewRecordChallengeApplicationService,
  ],
})
export class ApplicationModule {}

📌 핵심: 39줄짜리 모듈 하나가 위 1,716줄을 묶어서 NestJS의 DI 컨테이너에 넘긴다. BundleControllerBundleApplicationService를, BundleApplicationServiceBundleGenerationServiceBundleRepository를 받는 의존성 그래프가 이 한 파일의 providers/controllers/exports 배열에 적힌 순서대로 만들어진다.

// v1.x / // v2.0 주석은 시각적 자산이다

위 코드의 import 블록과 배열 항목에 // v1.x / // v2.0 구분 주석을 일관되게 박아둔 건 의도가 있다. 마이그레이션이 끝난 뒤에도 한참 동안 v1과 v2 컨트롤러가 공존하기 때문에, 새 합류자가 ApplicationModule을 열었을 때 “어디까지가 옛날 거고 어디부터가 신규인지”를 한 줄에 알 수 있어야 한다. 한참 뒤 v1을 들어낼 때도 이 주석이 가위질 가이드가 된다.

exports에 v2.0 서비스를 넣은 이유

exports: [
  // v2.0 Application Services
  BundleApplicationService,
  NewRecordChallengeApplicationService,
],

exports다른 모듈에서 이 모듈의 service를 주입 받을 수 있게 공개하는 배열이다. 당장 v2.0 안에서만 쓰는데도 export에 넣은 이유는, 배치 모듈이 BundleApplicationService.completeAssignment를 호출할 일이 곧 생기기 때문이다. “타이머 만료된 과제 자동 종료” 같은 cron 작업이 다음 Phase에서 들어오는데, 그 때 export 누락으로 한 번 더 깨지는 걸 미리 막는다.


🧪 인메모리 챌린지 스토어 — 의도된 미완성을 남기는 법

NewRecordChallengeApplicationService의 머리에는 이런 코드가 있다.

// In-memory Challenge Store (TODO: Move to Redis or DB)
interface ActiveChallenge {
  id: number;
  studentId: string;
  contentId: number;
  levelId: number;
  currentBestAccuracyPct: number;
  startedAt: Date;
}

@Injectable()
export class NewRecordChallengeApplicationService {
  // In-memory challenge store (TODO: Move to Redis or DB)
  private readonly activeChallenges = new Map<number, ActiveChallenge>();
  private challengeIdCounter = 1;
  // ...
}

startChallenge 호출 시 challengeIdCounter++로 ID를 발급하고 메모리 Map에 넣는다. completeChallenge에서 그 ID로 꺼내 쓴다. 서버 재시작하면 진행 중 챌린지는 다 날아간다.

이 결정은 일부러 남긴 미완성이다.

선택지장점단점
인메모리 Map (선택)1시간이면 끝 / Redis 의존성 없음재시작 시 진행 중 챌린지 소실
Redis영속성 + 만료 자동인프라 추가 / 키 설계 / 직렬화
DB 테이블영속성 + 조회 가능스키마 추가 + 마이그레이션 1회

v2.0의 본질은 번들 학습 시스템이다. 신기록 도전은 부수 흐름이고, 사용자가 “도전 시작 → 한 콘텐츠 풀고 → 완료”를 보통 5분 안에 끝낸다. 5분 안에 서버가 죽을 확률을 받아들이고, 남는 시간을 v2.0 본체 검증에 썼다. TODO 주석으로 남은 부채는 다음 Phase에서 Redis로 옮기면서 갚는다.

💡 인사이트: 부채를 안 만드는 게 최선이지만, “이건 일부러 남긴 부채다”라고 코드에 박아두는 것이 두 번째다. 다른 사람이 “이거 왜 인메모리예요?”라고 물을 때 답이 한 줄로 끝난다. TODO 없이 남은 인메모리는 그냥 잊혀진 사고이고, TODO가 박힌 인메모리는 다음 작업의 약속이다.


📋 정리 — 핵심 요약

항목Phase 3-3Phase 3-4Phase 3-5
줄 수+953+763+33 / -6
핵심 산출물2개 Application Service (UC-0610, UC-1417)2 Controller + 2 DTO 파일 (10 endpoints)ApplicationModule providers/controllers/exports 갱신
책임 한 줄”데이터를 모아 Domain Service에 던지고 결과를 저장""HTTP를 Application Service 호출로 번역""위 모든 것을 NestJS DI 컨테이너에 묶음”
의존 방향Domain ← ApplicationApplication ← Controller모두 ← Module

v2.0 Phase 3 전체를 한 문장으로 압축하면 이렇다. 새 DB 구조에 맞춰 Repository → Domain Service → Application Service → Controller → Module 다섯 칸을 위에서 아래로(또는 아래에서 위로) 한 칸씩 채웠다. 각 칸은 자기 위/아래 칸과만 대화하고, 결정과 오케스트레이션이 분리됐다.

안티패턴권장 패턴
❌ Application Service에 비즈니스 규칙 박기✅ Application은 오케스트레이션만, 결정은 Domain Service
❌ Controller가 Repository를 직접 호출✅ Controller → Application → Domain → Repository 단방향
❌ DTO를 interface로 작성✅ class + class-validator + @ApiProperty
❌ POST 응답 코드 암묵 의존@HttpCode(HttpStatus.OK / CREATED) 명시
❌ URL Param + Body 같은 필드 중복@Body() dto: Omit<DTO, 'paramKey'>
❌ “오늘”의 정의를 호출자마다 작성getTodayKickoffTime() 한 군데에만
❌ TODO 없이 인메모리로 살짝 끼워두기✅ “TODO: Move to Redis”를 주석으로 박아 부채를 약속으로

다음 편에서는 Phase 3 전체를 닫은 직후 pnpm build0 errors를 띄우는 순간과, 그 직후 v2.1로 넘어가며 DDD 문서를 전면 재작성한 이야기를 다룬다. 코드보다 문서가 더 길어진 이유가 핵심이다.

📚 교육용 풀스택 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에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루