배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다

이전 편에서 배치고사 명세를 자동 레벨 배치 폐기로 정리했다. 같은 날 저녁 그 명세를 실제 코드·테스트·시드 데이터로 한 번에 옮긴 마일스톤이다. 도메인·응용 갱신, Legacy 컨트롤러 3개 + 도메인 서비스 3개 + DTO 9건 571줄 삭제, 단위 테스트 22개, 회원 시드 보강, QA E2E 5 시나리오까지 5개 커밋이 한 머지 사이클에 들어간 운영 기록을 정리한다.


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

  • 명세 → 코드 → 테스트 → 시드 → E2E를 한 머지 사이클에 묶어, 갱신된 배치고사 명세가 문서가 아닌 실제 시스템으로 굳혔다
  • Legacy API 6개 메서드 + DTO 9건, 합계 571줄을 호환 윈도 없이 일괄 삭제 — 신구 공존 안 함, 단일 dev 브랜치 갈아끼우기
  • 신규 4개 엔드포인트(startDiagnosticV2, getProblems, submitMetric, getStatus)에 단위 테스트 22개(506줄) — 회귀 차단 라인 확정
  • 회원 시드를 BE가 책임지는 결정 — QA E2E 블로커였던 “로그인 가능한 풀 도메인 회원 1명”을 시드 보강(+90줄)으로 해결
  • 응답에서 placedLevelId 미반환을 명세 검증의 첫 줄로 — 단위 테스트와 E2E 양쪽에서 동일 명제로 점검
  • 결과: 5개 커밋 한 머지 사이클(약 4시간) — 88c8d45 → ba6e240 → 0ce9424 → e79aea4 → 2906124 → dev

🎯 배경 — 명세는 끝났고, 코드와 데이터가 그 다음 단계를 따라잡을 차례

배치고사 MVP의 명세 갱신은 같은 날 오전 흐름에서 마무리됐다. 자동 레벨 배치(placedLevelId 응답)는 폐기하고, 5지표(METRIC_A~METRIC_E) 정확도 측정만 남겼다. 제출 단위는 답안 1건이 아니라 지표 1건(10문제 묶음)으로 굳혔다. 신구 API를 한동안 공존시키지도 않고, Legacy 6개 메서드 + DTO 9건은 같은 머지에서 통째로 들어내기로 했다.

명세 결정은 끝났지만 그 상태는 위험했다. 명세 문서와 dev 브랜치 코드가 다른 세상에 있었기 때문이다. 컨트롤러에는 여전히 legacyStart·legacySubmit·legacyComplete 3개가 살아 있었고, 도메인 서비스에는 startDiagnostic(레벨 자동 배치 포함)·submitAnswer(답안 1건 단위)·completeDiagnostic(평균 정확도 → 레벨)이 그대로 동작했다. 응답 DTO는 placedLevelId 필드를 채워 돌려보냈다. 새로 약속한 4개 메서드(startDiagnosticV2·getProblems·submitMetric·getStatus)도 도메인 단까지 구현된 상태로, 응용·컨트롤러 정리가 남아 있었다.

명세→코드 사이의 빈틈을 길게 끌면 두 가지 비용이 같이 쌓인다. 첫째, 신구 메서드가 같은 컨트롤러에서 둘 다 노출되는 한, 클라이언트가 어느 쪽을 호출하든 200이 떨어진다. 명세는 한쪽을 폐기로 표기했어도 실제 트래픽은 양쪽에 흐른다. 둘째, 호환 윈도를 열어두는 순간 회귀 차단 단위 테스트 작성 비용이 곱으로 늘어난다. 신·구 양쪽을 모두 검증하든지, 한쪽만 검증한 채로 운영하든지 둘 다 나쁘다.

그래서 같은 날 저녁에 한 번에 정리하기로 했다. 도메인·응용 갱신 → Legacy 일괄 삭제 → 단위 테스트 22개 → 회원 시드 보강 → QA E2E 5 시나리오까지 5개 커밋을 같은 dev 머지 사이클 안에 넣었다.

📌 핵심: 명세를 자료 형태로만 갱신해두고 코드를 며칠 묵히면, 신구 공존 트래픽과 단위 테스트 비용이 곱으로 쌓인다. 명세→코드→테스트→시드→E2E는 같은 머지 사이클에 묶는 게 가장 싸다.


⚖️ 설계 결정 5건 — 무엇을 한 머지에 묶고, 무엇을 분리했나

이번 마일스톤에서 명시한 결정 5건을 먼저 정리한다. 본문은 이 표의 결정 순서대로 코드·라인 수·테스트 케이스를 따라간다.

#결정한 머지에 묶을지트레이드오프
1Legacy API 호환 윈도 없음, 일괄 삭제같은 머지외부 클라이언트 영향 0(미배포)을 전제로만 성립. 배포 후였다면 deprecate 라벨 + 2주 윈도가 필요했다
2단위 테스트 22개를 같은 머지 안에서 끝까지같은 머지회귀 차단 비용은 한 번에 청구되지만, 다음 머지에서 안 짠 테스트가 풀리는 함정이 사라진다
3회원 시드 보강은 BE 책임으로같은 머지QA가 별도 SQL을 들고 다니지 않게 된 대신, prisma/seed.ts가 도메인 결합도를 흡수한다(반·교육과정 폴백까지 같이)
4QA E2E에서 잡힌 FE/BE 버그 4건은 같은 머지에서 픽스같은 머지”분류 후 다음 작업”이 아니라 같은 머지 안 종결 — 머지 단위가 커지지만 추적 비용이 0
5응답에서 placedLevelId 미반환을 명세 검증의 첫 줄로단위 + E2E 동일 명제한 문장(expect(result.placedLevelId).toBeUndefined())으로 단위·E2E 양쪽 검증을 같은 의미로 묶음

표의 5건은 모두 “이번 한 머지에 끝낸다”로 묶었다. 분리하지 않은 것이 이번 마일스톤의 핵심 결정이다. 이 묶기 결정 덕에 다음 작업 항목 보드가 한 칸도 안 늘었다.

⚠️ 주의: 결정 1은 “아직 배포 안 했음” 전제가 깔려 있다. 외부 트래픽이 있는 API에는 그대로 쓸 수 없다. deprecation 헤더 + 윈도(보통 2주)를 같이 짜고, 응답 본문에 X-API-Deprecation: end-of-life=YYYY-MM-DD 같은 신호를 넣는 게 표준이다. NestJS 공식 가이드는 별도 deprecation 데코레이터를 권장하지 않으므로 인터셉터에서 직접 추가한다.

docs.nestjs.com

🛠️ 구현 1 — 도메인·응용 단계 갱신과 응답 모양 단정

명세 결정 중 가장 먼저 도메인·응용 서비스의 4개 메서드를 갱신했다. 가장 큰 변화는 응답에서 placedLevelId 필드를 빼는 것이다. 도메인 서비스 단까지 내려가지 않고 응용 서비스 응답 매핑에서 막아도 충분하지만, 도메인 모델 자체에 그 의미를 박지 않기로 했다. 응용 단의 매핑 한 곳이 단일 진입점이 된다.

// apps/api/src/application/services/student-diagnostic.application.service.ts (요지)
// 갱신된 응답 — 5지표 정확도만, 레벨 배정 없음
async submitMetric(
  sessionId: string,
  memberId: string,
  metric: MetricCode,
  answers: AnswerInput[],
): Promise<SubmitMetricResponse> {
  const session = await this.repo.findActiveSession(sessionId, memberId);
  if (!session) throw new SessionNotFoundException();
  if (session.completedMetrics.includes(metric))
    return { status: 'IDEMPOTENT', score: session.scores[metric] };

  const score = computeAccuracy(answers);
  const updated = await this.repo.saveMetricScore(sessionId, metric, score, answers);

  // 5개 지표 모두 완료되면 세션 종료 — placedLevelId 산출 안 함
  if (updated.completedMetrics.length === 5) {
    await this.repo.completeSession(sessionId);
    return { status: 'SESSION_COMPLETED', metricScores: updated.scores };
  }

  const nextMetric = nextMetricOf(updated.completedMetrics);
  return { status: 'METRIC_DONE', score, nextMetric };
}
// 갱신된 상태 응답 — placedLevelId 필드 자체가 없다
async getStatus(sessionId: string, memberId: string): Promise<DiagnosticStatus> {
  const session = await this.repo.findSession(sessionId, memberId);
  if (!session) return { status: 'NOT_STARTED' };

  return {
    status: session.endedAt
      ? 'COMPLETED'
      : session.abandonedAt
      ? 'ABANDONED'
      : 'IN_PROGRESS',
    metricScores: session.scores,
    completedMetrics: session.completedMetrics,
    // placedLevelId 필드 자체 없음
  };
}

응답 객체에 필드를 두지 않는 쪽과 placedLevelId: null로 명시하는 쪽 두 가지가 있다. 후자가 외부 호환 신호로는 명확하지만, 이번 마일스톤은 호환 윈도 자체를 두지 않기로 했으므로 필드 자체를 없애는 쪽이 더 단정적이다. 클라이언트가 어쩌다 그 필드를 다시 읽으려 하면 TypeScript 단에서 컴파일 에러로 잡힌다.

이 변경은 커밋 88c8d45로 dev에 들어갔다. 도메인 서비스(diagnostic-session.service.ts)에서 calculateInitialLevelByAccuracy() 호출 경로가 사라졌고, 응용 서비스에서 응답 DTO의 placedLevelId 필드를 더 이상 채우지 않는다.

🔍 단서: 응답 모양 변경은 NestJS 응답 인터셉터 단에서 일괄 적용하면 컨트롤러 코드를 안 건드려도 된다. 다만 본 마일스톤은 도메인 서비스 자체가 레벨 결정 책임을 더 이상 안 가진다는 의미를 함께 굳히기로 했다. 단순 응답 가리기가 아니다.


🛠️ 구현 2 — Legacy 6개 메서드 + DTO 9건, 571줄 일괄 삭제

명세 갱신 결정에서 가장 큰 비중이 Legacy 삭제다. 호환 윈도 없이 한 머지에 들어낼 수 있었던 건 클라이언트 배포 전이라는 단일 조건 덕분이었다. 삭제 범위를 미리 정리하면 다음과 같다.

계층제거 대상줄 수
ControllerlegacyStart() POST /legacy/start, legacySubmit() POST /legacy/submit, legacyComplete() POST /legacy/complete~90
Application ServicestartDiagnostic()(레벨 자동 배치 포함), submitAnswer()(답안 1건 단위), completeDiagnostic()(평균 정확도 → 레벨)~340
DTOLegacy 응답·요청 인터페이스 9건(LegacyStartRequest, LegacyAnswerInput, LegacyMetricSummary 등)~140
합계6개 메서드 + 9개 DTO~571
// ❌ 삭제된 컨트롤러 메서드 — POST /legacy/* 3건
// @Post('legacy/start')
// async legacyStart(@CurrentUser() user: StudentJwtPayload) {
//   return this.svc.startDiagnostic(user.studentId);
// }
// @Post('legacy/submit')
// async legacySubmit(@Body() body: LegacyAnswerInput) { ... }
// @Post('legacy/complete')
// async legacyComplete(@Body() body: LegacyCompleteRequest) { ... }

// ✅ 남은 컨트롤러 — v3 명세 4개만
@Post('start')
async start(@CurrentUser() user: StudentJwtPayload): Promise<StartDiagnosticResponse> {
  return this.svc.startDiagnosticV2(user.studentId);
}

@Get(':id/problems')
async getProblems(
  @Param('id') sessionId: string,
  @Query('metric') metric: MetricCode,
  @CurrentUser() user: StudentJwtPayload,
): Promise<ProblemsResponse> {
  return this.svc.getProblems(sessionId, user.studentId, metric);
}

@Post(':id/submit')
async submit(
  @Param('id') sessionId: string,
  @Body() body: SubmitMetricRequest,
  @CurrentUser() user: StudentJwtPayload,
): Promise<SubmitMetricResponse> {
  return this.svc.submitMetric(sessionId, user.studentId, body.metric, body.answers);
}

@Get(':id/status')
async getStatus(
  @Param('id') sessionId: string,
  @CurrentUser() user: StudentJwtPayload,
): Promise<DiagnosticStatus> {
  return this.svc.getStatus(sessionId, user.studentId);
}

도메인 서비스 쪽은 더 까다로웠다. startDiagnostic()은 단순 메서드 삭제가 아니라, 그 안에서 호출되던 calculateInitialLevelByAccuracy()까지 같이 정리하는 흐름이었다. 다만 calculateInitialLevelByAccuracy() 자체는 @deprecated 주석을 단 채 잔존시켰다. 이번 작업의 단일 진입점에서는 호출되지 않지만, 운영자 도구(별도 관리자 페이지)에서 참조용으로 남겨두는 쪽이 안전하다. 운영자가 “이 회원에게 어느 레벨이 적합한지” 추정할 때 정확도 기반 추정값이 한 줄로 뜨면 판단 보조가 된다.

// 잔존시킨 deprecated 메서드 — 운영 추정값으로만, 자동 배치 경로에서는 호출되지 않음
/**
 * @deprecated 자동 레벨 배치 로직(폐기). 운영자 추정 보조용으로만 잔존.
 * 응답에 자동 배치 신호로 넣지 말 것.
 */
async calculateInitialLevelByAccuracy(accuracy: number): Promise<Level> { ... }

이 결정은 같은 머지에 들어간 별표 항목이다. @deprecated 잔존 4건(calculateInitialLevelByAccuracy, curriculumTargetLevelId 필드, findCandidatesWithCooldown, BaseRepository.save/update)은 모두 “현 트래픽 경로에서 호출되지 않음 + 차후 운영 도구에서 참조 가능” 두 조건을 같이 만족한다.

$ grep -rn "@deprecated" apps/api/src/ | wc -l
4
$ grep -rn "legacy" apps/api/src/ | grep -v node_modules | wc -l
0

삭제 커밋은 ba6e240. 한 커밋에서 -571줄. 이 커밋만 따로 보면 “이렇게 큰 삭제 커밋을 한 번에 푸시해도 되나” 싶지만, 같은 머지 사이클의 단위 테스트 22개와 E2E 5 시나리오 통과가 같은 dev 시점에 묶여 있다. 회귀 차단 라인이 같은 머지 안에 같이 있다는 게 큰 삭제 커밋의 안전 조건이다.

📌 핵심: “큰 삭제 커밋”의 안전 조건은 커밋 크기가 아니라, 같은 머지 사이클 안에 회귀 차단 라인이 같이 있는지다. 단위 테스트 22개와 E2E 5 시나리오를 같은 dev 시점에 묶어 두면, -571줄 커밋의 위험은 작은 리팩터링 커밋과 다르지 않다.


🛠️ 구현 3 — 단위 테스트 22개로 신규 4 메서드 회귀 차단

Legacy 삭제와 같은 머지 안에서 단위 테스트 22개를 새로 짰다. 대상은 v3 명세의 4개 메서드. 한 메서드당 평균 5~6 케이스, 총 506줄. 핵심은 “정상 케이스 + 멱등성 + 오너십(다른 회원의 세션 차단) + 상태 전이(NOT_STARTED → IN_PROGRESS → COMPLETED) + 명세 검증” 다섯 줄기를 한 메서드 안에 같이 묶은 것이다.

메서드케이스 수검증 내용
startDiagnosticV24세션 생성 / 이미 완료 시 에러 / 기존 세션 abandon 처리 / 활성 명세 버전 없음
getProblems7문제 조회 / 세션 검증 / 다른 회원 차단 / 완료된 세션 차단 / 이미 완료된 지표 차단 / 레벨 매핑 없음 / 문제 풀 부족
submitMetric7답안 저장 / 정확도 계산 / 세션 오너십 / 멱등성 / 5번째 지표 완료 시 세션 종료 / nextMetric 반환 / placedLevelId 미반환
getStatus4NOT_STARTED / IN_PROGRESS / COMPLETED / ABANDONED 4상태
합계22

가장 신경 쓴 케이스는 submitMetric멱등성이다. 같은 지표(예: METRIC_A)를 두 번 제출하면, 두 번째 호출은 첫 번째 응답을 그대로 돌려줘야 한다. 답안이 다르게 들어오더라도 첫 번째 점수를 유지한다. 이건 클라이언트의 네트워크 재시도(타임아웃 후 재요청)에 대비한 것이다. 도메인 단의 completedMetrics 배열을 보고 분기하는 한 줄로 처리했다.

// submitMetric — 멱등성 단위 테스트
it('returns first score on duplicate submission (idempotent)', async () => {
  const sessionId = 'sess-1';
  const memberId = 'mem-1';
  const firstAnswers = makeAnswers({ correct: 8 }); // 80%
  const secondAnswers = makeAnswers({ correct: 10 }); // 100%

  // 1차 제출 — 80%로 저장
  await service.submitMetric(sessionId, memberId, 'METRIC_A', firstAnswers);

  // 2차 제출 — 같은 지표, 다른 답안
  const second = await service.submitMetric(sessionId, memberId, 'METRIC_A', secondAnswers);

  expect(second.status).toBe('IDEMPOTENT');
  expect(second.score).toBe(0.8); // 첫 점수 유지
  expect(repo.saveMetricScore).toHaveBeenCalledTimes(1); // 두 번째는 저장 호출 X
});

명세 검증을 단위 테스트의 한 줄로 굳힌 케이스도 따로 있다. 응답에서 placedLevelId가 미반환임을 확인하는 한 줄이다. E2E에서도 같은 명제를 검증하지만, 단위 테스트가 응답 모양의 첫 방어선이 된다.

// submitMetric — 5번째 지표 완료 시 placedLevelId 미반환
it('does not return placedLevelId on session completion', async () => {
  const sessionId = 'sess-1';
  const memberId = 'mem-1';
  setSessionWithCompletedMetrics(['METRIC_A', 'METRIC_B', 'METRIC_C', 'METRIC_D']);

  const result = await service.submitMetric(
    sessionId,
    memberId,
    'METRIC_E',
    makeAnswers({ correct: 7 }),
  );

  expect(result.status).toBe('SESSION_COMPLETED');
  expect(result.metricScores).toHaveProperty('METRIC_E');
  // 명세 검증의 첫 줄 — placedLevelId 자체가 응답에 없다
  expect((result as Record<string, unknown>).placedLevelId).toBeUndefined();
});

테스트 작성 과정에서 기존 테스트 한 곳(admin-problem.application.service.spec.ts)에서 mock 누락 2건이 별도로 발견됐다. 이번 마일스톤과 무관한 이슈라 같은 머지에 묶지 않고 별도 보드로 분리했다. 같은 머지 묶기의 원칙은 “이번 결정과 관계된 것만 한 머지에”다.

테스트 커밋은 0ce9424. 506줄, 22 케이스 전부 통과.

📊 데이터: 단위 테스트 22개의 코드 라인 평균은 케이스당 23줄. mocking이 무겁지 않은 도메인이라 한 케이스가 510줄 setup + 35줄 act + 1~3줄 assert로 떨어졌다. 회귀 차단 시리즈를 정상 케이스 위주로 가볍게 늘리는 패턴은 명세 갱신 단계에서 유효하다.

docs.nestjs.com

🛠️ 구현 4 — 회원 시드 보강과 QA E2E 블로커 해소

같은 머지에 단위 테스트만 들어가서는 명세 갱신이 살아있다는 보장을 못 받는다. 실제 풀 도메인(인증 → 세션 시작 → 지표별 문제 조회 → 5지표 제출 → 결과 페이지)을 한 번 통과해야 한다. 이때 QA 단에서 첫 번째 블로커가 떴다. 시드 데이터에 로그인 가능한 회원이 1명도 없었던 것이다.

기존 prisma/seed.ts는 운영자 계정(슈퍼 어드민, 고객사 원장, 운영자)까지만 만들어 두고 회원은 비워뒀다. 명세 단의 책임 구분으로는 “회원은 운영자가 생성한다”는 정책이라 의도된 상태였다. 하지만 E2E 자동화 단계에서는 그 정책이 블로커가 된다. 운영자 계정으로 회원을 만들고 → 그 회원에 반·교육과정 폴백을 묶는 시퀀스를 매번 돌리면 시간이 길어진다.

이번 결정은 시드에서 회원 1명을 미리 만들어 둔다였다. 다만 같이 만들어야 할 게 4종이다. 회원 한 명만 만들어 두면 반·교육과정 폴백·로그인 자격이 매번 따로 묶여야 한다.

항목보강 내용
반(Class)“테스트반”(1분기 1차, targetLevelIds: [1,2,3,4,5]) — 회원-반 연결의 폴백 단위
회원(Member)loginId TEST001, password student123(bcrypt.hash), diagnosticStatus: 'PENDING'
Class-Member 연결academyClassId 필드로 회원 ↔ 반 묶기
교육과정 폴백curriculumCurrentTargetId: 1 초기값 — 교육과정 단계 미설정 시의 기본
// apps/api/prisma/seed.ts — 회원 시드 보강 (+90줄)
const academyClass = await prisma.academyClass.create({
  data: {
    academyId: academy.id,
    name: '테스트반',
    grade: 1,
    semesterId: '1-1',
    targetLevelIds: [1, 2, 3, 4, 5],
  },
});

const studentPassword = await bcrypt.hash('student123', 10);
const studentUser = await prisma.user.create({
  data: {
    email: '[email protected]',
    passwordHash: studentPassword,
    role: 'STUDENT',
    academyId: academy.id,
    student: {
      create: {
        loginId: 'TEST001',
        name: '테스트회원',
        academyId: academy.id,
        academyClassId: academyClass.id, // 반 연결 필수
        diagnosticStatus: 'PENDING',
        curriculumCurrentTargetId: 1, // 교육과정 폴백 초기값
      },
    },
  },
});

여기서 트레이드오프 하나가 추가됐다. 시드가 도메인 결합도를 흡수한다는 점이다. 회원 시드 한 건을 만들기 위해 반·연결·교육과정 폴백 초기값 4종이 같은 파일에 다 들어왔다. 시드 파일이 점점 도메인의 작은 사본이 되어 가는데, 그 비용을 받아들이는 쪽이 더 싸다고 결정했다. QA E2E 자동화가 한 줄 스크립트로 끝나는 게 더 큰 이득이었다.

시드 보강 후 알려진 이슈가 하나 잡혔다. 시드 재실행 시 Level 삭제 단계에서 FK 오류(Problem 참조)가 났다. 기존 데이터가 있을 때만 발생하는 경합이라 회원 시드 자체에는 영향이 없었지만, 운영자에게 “시드 재실행 전 DB 리셋 권장” 한 줄 추가 권장사항을 남겼다. 이것도 같은 머지 사이클의 후속 작업 보드에 올랐다.

시드 커밋은 e79aea4, +90줄.

⚠️ 주의: 시드 파일은 도메인 결합도가 점점 올라간다. 회원 1명을 만들기 위해 반·연결·교육과정·자격 4종이 같이 들어가고, 그게 다음 명세 변경 때 4 줄이 같이 흔들린다. 시드를 “최소 풀 도메인”으로 운영하려면 정기적인 시드 리팩터링(예: 도메인별 시드 모듈 분리)이 필요하다.

prisma.io

🛠️ 구현 5 — QA E2E 5 시나리오와 같은 머지에서 잡은 버그 4건

회원 시드 보강 후 QA E2E 5 시나리오를 같은 머지 안에서 돌렸다. 결과는 5개 시나리오 모두 PASS였지만, 그 중 3개는 PASS 직전에 발견된 버그를 같은 머지에서 픽스했다.

#시나리오1차 결과발견된 버그픽스
1로그인FIXED 후 PASSFE 응답 파싱 — 봉투 { data: { ... } } 중첩 미처리FE: response.data.data 분기 추가
2배치고사 시작FIXED 후 PASSBE: JWT payload에서 studentId 추출 오류(sub로 잘못 읽음) + FE: Authorization 헤더 누락BE: payload.studentId로 교정 / FE: axios.interceptors 추가
3문제 로딩FIXED 후 PASSFE: 쿼리 파라미터(?metric=) 누락 — fetch 대신 axios 직접 사용으로 가야 함FE: 해당 페이지만 axios 직접 호출로 우회
45지표 풀이PASS
5결과 페이지PASSplacedLevelId 미반환 확인

가장 중요한 픽스는 시나리오 2번의 BE 버그다. JWT payload에서 studentId를 꺼낼 때 sub 필드를 읽고 있었는데, 본 명세에서는 studentId를 별도 필드로 박아두고 있었다. sub에는 다른 식별자(예: user.id)가 들어가는 약속이라, payload.sub를 회원 ID로 쓰면 매번 다른 사람의 세션이 잡힌다.

// ❌ Before — JWT payload에서 sub를 회원 ID로 잘못 읽음
@Get(':id/status')
async getStatus(
  @Param('id') sessionId: string,
  @Req() req: Request,
) {
  const memberId = req.user.sub; // 잘못된 필드
  return this.svc.getStatus(sessionId, memberId);
}

// ✅ After — payload에 박아둔 studentId 직접 사용
@Get(':id/status')
async getStatus(
  @Param('id') sessionId: string,
  @CurrentUser() user: StudentJwtPayload,
) {
  return this.svc.getStatus(sessionId, user.studentId);
}

같이 들어간 FE 픽스는 axios 인터셉터에서 Authorization 헤더를 자동으로 채우는 한 줄이었다. 페이지별로 fetch 호출이 헤더를 빠뜨리는 일이 반복돼서, 인터셉터 한 곳에 집중시키는 게 답이었다.

// FE — axios 인터셉터에서 Authorization 자동 첨부
axios.interceptors.request.use((config) => {
  const token = localStorage.getItem('access_token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

결과 페이지 검증(시나리오 5)에서는 명세의 핵심 명제를 한 번 더 확인했다. 응답에서 placedLevelId 필드가 없고, 5지표 정확도만 표시된다. 단위 테스트 22개 중 한 케이스와 같은 명제를 E2E에서도 동일하게 확인하는 구조다. 명세 검증의 단일 명제가 단위·E2E 양쪽에 같이 있는 게 명세→코드 한 머지 묶기의 마지막 마무리였다.

QA 픽스 커밋은 2906124. feature/qa-work 브랜치에서 dev로 머지됐다.

🔍 단서: JWT payload 필드명은 약속이지 약속만으로 안전해지지 않는다. 토큰 발급 단(auth.service.ts)에서 substudentId를 함께 채우는 패턴이 자주 보이지만, 사용 쪽에서 어느 필드를 읽을지가 한 곳에 정해져 있어야 한다. 이번 마일스톤 이후 @CurrentUser() user: StudentJwtPayload 데코레이터 사용을 강제로 하고, req.user.sub 직접 접근을 막는 lint 규칙을 따로 보드에 올렸다.

placement exam MVP — spec to live rollout (commit chain · legacy deletion impact · verification matrix)


📊 결과 — 한 머지 사이클에 들어간 5개 커밋과 6 측정 지표

저녁 4시간 15분 동안 들어간 결과는 다음과 같다.

지표
커밋 수5개 (88c8d45, ba6e240, 0ce9424, e79aea4, 2906124)
삭제 라인-571줄 (Legacy 6 메서드 + DTO 9건)
추가 라인+702줄 (단위 테스트 506 + 시드 90 + JWT 픽스 + 인터셉터 등)
단위 테스트22개, 100% pass
QA E2E 시나리오5 시나리오, 100% pass(3 시나리오는 발견된 버그 픽스 후)
응답 명세 검증단위·E2E 양쪽에서 동일 명제(placedLevelId 미반환)

흥미로운 점은 추가 라인이 삭제 라인보다 약간 더 많다는 것이다. 명세를 단순화한 마일스톤이지만 검증 라인(단위 + 시드 + 픽스)이 같은 머지에 들어왔기 때문에 LOC만 보면 코드는 줄지 않았다. 다만 운영 시 마주칠 분기 수는 확실히 줄었다. Legacy 6 메서드가 사라지면서 클라이언트가 호출할 경로가 7 → 4로 줄었고, 응답 분기(placedLevelId 유무 두 가지)가 사라지면서 FE 상태 분기도 한 칸이 사라졌다.

📌 핵심: “한 머지에 다 묶었더니 LOC는 안 줄었다”는 결과는 잘못이 아니다. 회귀 차단 라인이 같은 머지에 들어왔기 때문이지, 명세 단순화 자체가 무효라는 의미가 아니다. 운영 분기 수와 LOC를 따로 추적하는 게 마일스톤 평가의 표준이다.


🔄 회고 — 후속에서 갚을 빚 4건

이번 작업은 단위 결정과 한 머지 묶기 면에서 만족스럽지만, 후속에서 갚아야 할 빚이 4건 명시적으로 남았다. 마일스톤 회고는 “잘 됐다”보다 “다음에 갚을 게 무엇인가”가 더 중요하다.

#갚을 단계
1외부 클라이언트 호환 윈도가 없는 삭제 패턴의 한계 — 미배포 전제만 성립첫 배포 후에는 deprecate 윈도 + 헤더 + 응답 본문 신호 표준화가 필요
2시드 파일의 도메인 결합도 — 회원 1명에 반·연결·교육과정 4종이 같이 들어옴시드 모듈 분리(seed/member.ts, seed/class.ts) 후 의존성 명시
3JWT payload의 sub/studentId 혼용 — 인터페이스 약속은 있지만 lint 강제 없음@CurrentUser() 강제 + req.user.sub 직접 접근 lint 규칙 추가
4시드 재실행 시 Level 삭제 FK 오류Problem 참조 경합시드 리셋 절차에 DB drop+migrate 단계 명시, 또는 truncate 순서 재정렬

이 중 1번이 가장 무겁다. 다음 마일스톤(첫 운영 배포)에서는 같은 패턴(호환 윈도 없이 -571 일괄 삭제)을 못 쓴다. 명세 갱신을 코드로 옮기는 머지 사이클 자체의 폭이 좁아지므로, 다른 결정 묶기 패턴이 필요하다. 후보로는 (a) 신구 응답 두 가지를 모두 채우되 신구 분기는 Accept-Version 헤더로, (b) Legacy 메서드는 @deprecated 데코레이터 + Sunset 헤더로 6주 윈도, (c) 단위 테스트는 신·구 양쪽 모두 케이스를 짜고 비교 검증, 이 세 가지를 다음 명세 변경에 한 묶음으로 가져가야 한다.

💡 인사이트: “이번에 잘 됐다”가 마일스톤 회고의 위험 신호다. 다음 머지에 같은 패턴이 안 되는 조건(첫 배포 후 트래픽 존재)이 분명한 경우, 회고는 “다음에 무엇을 바꿀까”로 깊게 들어가야 한다. 이번 마일스톤이 가장 잘 됐던 이유는 같은 머지에 묶는 결정이 옳았기 때문이 아니라, 그게 가능한 단일 조건(미배포)이 우연히 맞았기 때문이다.


📋 정리 — 명세→코드 한 머지 묶기

측면안티패턴권장 패턴
명세→코드 시간 차며칠 묵힌 채 신구 공존 → 트래픽 양쪽으로 흐름같은 머지 사이클에 다 옮김 → 신구 분기 자체가 없음
Legacy 삭제호환 윈도 + 데코레이터 + 윈도 만료까지 추적외부 트래픽 없을 때만 한 머지 일괄 삭제 — 윈도 비용 0
단위 테스트다음 머지로 미루기같은 머지 안 22개 — 회귀 차단 라인이 같은 dev 시점에 박힘
시드 보강QA 별도 SQL · 매번 운영자 계정으로 생성BE 시드에 풀 도메인 1건(반+연결+교육과정 폴백) — E2E 한 줄 스크립트
응답 모양 검증컨트롤러 응답을 통째 비교명세 핵심 명제 한 줄로 단위·E2E 양쪽에서 동일 검증

이번 마일스톤은 명세 갱신을 같은 날 저녁의 한 머지 사이클로 옮긴 운영 기록이다. 결정 5건, 커밋 5개, 단위 22 + E2E 5, -571 + 702 라인. 다음 편(devlog-49)에서는 같은 머지에서 따로 비워둔 단위 테스트 38개(Problem 도메인 회귀 차단)의 작성 마일스톤을 정리할 예정이다. 단위 테스트 22개를 4 메서드에 짠 본 머지와 다르게, 38개는 4개 도메인 서비스에 걸친 더 넓은 회귀 차단 라인이다.


📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)

  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에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
  24. 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
  25. 25. 멀티테넌트 누수 — tenantId 3계층 강제
  26. 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
  27. 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
  28. 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
  29. 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
  30. 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
  31. 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
  32. 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
  33. 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
  34. 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
  35. 35. Unity ↔ 웹 PostMessage 브릿지 설계기
  36. 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
  37. 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
  38. 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
  39. 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
  40. 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
  41. 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
  42. 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
  43. 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
  44. 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
  45. 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
  46. 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날