배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)
초기 배치고사는 정확도로 회원 레벨을 자동 배치하려 했지만, 출제 문제 셋이 바뀌면 레벨이 출렁였다. 측정과 결정을 분리해 레벨 배치 로직을 걷어내고 5지표 정확도 측정만 남긴 명세 갱신과 트레이드오프 4건을 정리한다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 자동 레벨 배치는 표본/문제 분포에 흔들리는 구조 — 정확도만으로 회원 레벨을 결정하면 출제 셋이 바뀔 때마다 레벨이 출렁인다
- 명세 갱신:
placedLevelId폐기, 5지표 정확도만 응답 — 레벨 배정은 운영자 판단으로 분리- 신규 4 API:
startDiagnosticV2,getProblems(metric),submitMetric,getStatus- Legacy 6개 메서드 + 9개 DTO 일괄 삭제(571줄) + 신규 단위 테스트 22개로 회귀 차단
- 제출 단위는 답안 1건이 아닌 지표 1개(10문제 묶음) — 통신 횟수 1/10 + 멱등성 단순화
- 5지표 × 10문제 = 50문제 풀이 후 평균 정확도 산출, 레벨 배정은 운영자가 별도 결정
🎯 배경 — 왜 자동 레벨 배치를 걷어냈나
배치고사는 회원이 처음 가입할 때 한 번 보는 진단형 평가다. 5지표(METRIC_A~METRIC_E)로 50문제 정도를 풀고, 그 결과로 어느 레벨부터 시작할지 결정한다. 초기 명세는 정확도 → 자동 레벨 배치까지 한 번에 처리했다.
// ❌ 초기 명세 — 정확도 → 레벨 자동 배치
async completeDiagnostic(memberId: string) {
const score = computeAverageAccuracy(answers);
const placedLevel = await calculateInitialLevelByAccuracy(score);
await prisma.member.update({
where: { id: memberId },
data: {
curriculumCurrentTargetId: placedLevel.id,
diagnosticStatus: 'COMPLETED',
},
});
return { placedLevelId: placedLevel.id, score };
}
문제는 두 가지였다.
첫째, 출제 셋이 바뀔 때마다 레벨이 출렁였다. 같은 회원이 다른 문제 셋으로 보면 정확도가 10~15%포인트씩 흔들렸고, 그게 그대로 레벨 배정을 바꿨다. 운영자에게 “왜 같은 회원인데 어제는 중간 레벨, 오늘은 상위 레벨인가요?” 같은 질문이 들어오기 시작하면 답이 없다.
둘째, 레벨은 비즈니스 정책 영역인데 코드에 박혀 있었다. calculateInitialLevelByAccuracy()는 정확도 80% 이상이면 상위 레벨, 60~80%면 중간 레벨 같은 식으로 임계값이 하드코딩되어 있었다. 정책이 바뀔 때마다 BE 배포가 필요했고, 운영자는 정책 표를 코드 안에서 찾아야 했다.
정리하면 자동 레벨 배치는 측정과 결정을 한 메서드에 묶은 안티패턴이었다. 측정은 BE의 책임이지만, 레벨 배정은 비즈니스 정책이고 운영자가 다뤄야 한다.
📌 핵심: “측정”과 “결정”을 한 메서드에 묶으면, 측정 정확도와 정책 변경이 같은 배포 시점에 묶인다. 분리하면 측정은 안정적으로, 정책은 운영자 손에서 바뀐다.
⚖️ 설계 결정 4건 — 무엇을 빼고 무엇을 남겼나
이번 갱신에서는 4가지 결정을 명세에 명시했다.
| # | 결정 | 빼고/남기고 | 트레이드오프 |
|---|---|---|---|
| 1 | 레벨 자동 배치 폐기 | placedLevelId 응답에서 제거 | FE 결과 페이지에서 레벨 배지 사라짐 (운영자가 별도 판단) |
| 2 | 제출 단위 = 지표(10문제 묶음) | submitAnswer 폐기 → submitMetric | 풀이 중 이탈 시 부분 점수 보존 X (지표 단위 멱등성으로 단순화) |
| 3 | 신구 API 공존 X — 즉시 삭제 | Legacy 6개 메서드 전부 폐기 | 호환성 부담 0, 다만 명세 변경 직전 dev 브랜치 정리 필수 |
| 4 | 세션 1개 = 5지표 × 10문제 | 지표별 getProblems(metric) 분리 | 한 번에 50문제 받지 않음 — 지표 단위 부분 완료 가능 |
결정 1: 레벨 자동 배치 폐기
placedLevelId를 응답에서 빼는 게 핵심이다. FE 결과 페이지는 5지표별 정확도와 평균만 보여준다.
// ❌ 초기 응답
{
placedLevelId: 5,
score: 0.78,
metricScores: { ... }
}
// ✅ 갱신 후 응답
{
metricScores: {
METRIC_A: { correct: 8, total: 10, accuracy: 0.8 },
METRIC_B: { correct: 7, total: 10, accuracy: 0.7 },
METRIC_C: { correct: 9, total: 10, accuracy: 0.9 },
METRIC_D: { correct: 6, total: 10, accuracy: 0.6 },
METRIC_E: { correct: 8, total: 10, accuracy: 0.8 },
},
averageAccuracy: 0.76
}
레벨 배정은 운영자가 클래스 페이지에서 직접 한다. “이 회원은 어느 레벨부터 시작” 같은 결정이 코드가 아닌 운영 판단으로 옮겨갔다. 정책이 바뀌어도 BE 배포가 필요 없고, 같은 회원이 며칠 새 다른 레벨로 잡히는 일도 없어졌다.
결정 2: 제출 단위는 지표 1개
처음에는 submitAnswer로 답안 1개씩 보내는 설계였다. 50문제면 50번 통신.
// ❌ 초기 명세 — 답안 1건 단위 제출
async submitAnswer(sessionId, problemId, choice) { ... }
// ✅ 갱신 — 지표 1건(10문제 묶음) 단위 제출
async submitMetric(sessionId, metric, answers: AnswerInput[]) {
// answers.length === 10 검증
// 멱등성: 같은 (sessionId, metric)이면 결과 덮어쓰기 X
}
지표 단위로 묶으면 통신 횟수가 1/10로 줄고, 멱등성 키가 (sessionId, metric) 하나로 단순해진다. 풀이 중 새로고침해도 같은 지표 안에서 다시 시작하면 된다.
트레이드오프는 명확하다. 지표 도중 이탈하면 그 지표는 처음부터 다시 풀어야 한다. 부분 점수 보존이 안 된다. 다만 한 지표 10문제는 5분 내외라 재시작 부담이 크지 않다고 판단했다.
⚠️ 주의: 멱등성 키를 (sessionId, metric)로 잡으면 같은 지표 두 번 제출 시 첫 결과만 살아남는다. 새로고침/네트워크 재시도로 인한 중복 제출이 무해하다는 게 핵심 이득이다.
결정 3: Legacy 즉시 삭제
배포 전이라 호환성 부담이 없었다. dev 브랜치에서 6개 메서드 + 9개 DTO를 한 번에 삭제했다.
- StudentDiagnosticController.legacyStart / legacySubmit / legacyComplete
- StudentDiagnosticApplicationService.startDiagnostic / submitAnswer / completeDiagnostic
- DiagnosticEvaluationService.calculateInitialLevelByAccuracy → @deprecated 유지(참조용)
- Legacy DTO 9개 인터페이스 전체
571줄 삭제, 단일 커밋. 이런 정리는 배포 전이 아니면 절대 못 한다. “혹시 어디서 쓰고 있을지 모르니” 패턴 한 줄만 남겨도 1년 뒤에 그 한 줄이 발목을 잡는다. 코드베이스가 커지면 grep 결과로 살아남은 Legacy가 여전히 신규 작성에 영향을 준다.
⚠️ 주의: Legacy 즉시 삭제는 “배포 전 + 단일 dev 브랜치” 조건에서만 가능하다. 외부 클라이언트가 한 명이라도 붙어 있으면 deprecated → sunset 일정 → 삭제 3단계로 가야 한다.
결정 4: 세션 1개 = 5지표 × 10문제
지표별로 문제를 받는 구조. getProblems(metric)를 호출하면 해당 지표 10문제만 반환한다.
// 회원이 METRIC_A를 시작할 때
GET /api/v1/student/diagnostic/problems?metric=METRIC_A
→ Problem[10] (METRIC_A 10문제, 난이도 분포 균등)
한 번에 50문제를 받지 않는 이유는 두 가지다.
- 지표별 진행률 추적: METRIC_A 10문제 풀고 METRIC_B 10문제 풀고… 처럼 지표 단위로 끊어진 진행률을 회원이 인지할 수 있다.
- 이탈 시 재개 단위 분리: 한 지표 안에서만 재시작이 일어나므로 데이터 정합성이 단순하다.
🛠️ 구현 — 4 API + Domain + DTO
신규 4 API
| 메서드 | 엔드포인트 | 책임 |
|---|---|---|
startDiagnosticV2(memberId) | POST /diagnostic/start | 세션 생성, 활성 명세(activeVersion) 검증 |
getProblems(sessionId, metric) | GET /diagnostic/problems?metric= | 지표 1개(10문제) 반환 |
submitMetric(sessionId, metric, answers) | POST /diagnostic/submit-metric | 지표 답안 검증/저장, 정확도 계산, 다음 지표 안내 |
getStatus(sessionId) | GET /diagnostic/status | 세션 상태(NOT_STARTED, IN_PROGRESS, COMPLETED, ABANDONED) |
Domain 메서드 — 세션 시작
// MemberDiagnosticDomainService
async startSession(memberId: string): Promise<DiagnosticSession> {
const active = await this.specRepo.findActiveSpec();
if (!active) throw new NoActiveDiagnosticSpec();
// 기존 미완료 세션은 ABANDONED 처리
await this.sessionRepo.abandonOpenSessions(memberId);
return this.sessionRepo.create({
memberId,
specVersion: active.version,
status: 'IN_PROGRESS',
metricProgress: {
METRIC_A: { status: 'NOT_STARTED' },
METRIC_B: { status: 'NOT_STARTED' },
METRIC_C: { status: 'NOT_STARTED' },
METRIC_D: { status: 'NOT_STARTED' },
METRIC_E: { status: 'NOT_STARTED' },
},
});
}
핵심은 활성 명세가 없으면 시작 자체를 막는다는 점이다. 운영자가 명세를 비활성화하면 신규 세션이 자동으로 차단된다. 명세 교체 작업 중 데이터가 섞이는 사고를 방지한다.
submitMetric — 멱등성 + 전체 완료 분기
async submitMetric(sessionId, metric, answers: AnswerInput[]) {
if (answers.length !== 10) throw new InvalidAnswerCount();
const session = await this.sessionRepo.findById(sessionId);
this.guardSessionActive(session);
// 멱등성: 이미 완료된 지표는 무시
if (session.metricProgress[metric].status === 'COMPLETED') {
return this.buildStatusResponse(session);
}
const correct = await this.scoreAnswers(metric, answers);
await this.sessionRepo.recordMetric(sessionId, metric, {
answers,
correct,
accuracy: correct / 10,
completedAt: new Date(),
});
// 5지표 모두 완료면 세션 종료
const refreshed = await this.sessionRepo.findById(sessionId);
const allDone = Object.values(refreshed.metricProgress).every(
(m) => m.status === 'COMPLETED',
);
if (allDone) {
await this.sessionRepo.complete(sessionId);
return { status: 'COMPLETED', metricScores: this.aggregateScores(refreshed) };
}
return { status: 'IN_PROGRESS', nextMetric: this.pickNextMetric(refreshed) };
}
여기서 두 가지를 명시적으로 처리했다.
- 멱등성: 같은 지표를 두 번 제출해도 첫 결과만 살아남는다. 새로고침으로 인한 중복 제출이 무해하다.
- 전체 완료 분기: 마지막 지표 제출 시 세션을
COMPLETED로 전이. FE는 응답의status만 보고 결과 페이지로 이동한다.
DTO — class-validator 전면 적용
export class SubmitMetricDto {
@IsUUID() sessionId: string;
@IsEnum(MetricType) metric: MetricType;
@ValidateNested({ each: true })
@Type(() => AnswerInput)
@ArrayMinSize(10)
@ArrayMaxSize(10)
answers: AnswerInput[];
}
export class AnswerInput {
@IsString() problemId: string;
@IsInt() @Min(0) @Max(3) selectedChoice: number;
@IsInt() @Min(0) elapsedMs: number;
}
이전 편 DTO interface→class 전환 삽질기에서 정리한 패턴을 그대로 적용했다. interface가 아닌 class라서 Swagger 문서가 자동 생성되고, ValidationPipe가 런타임 검증을 한다. NestJS 공식 문서가 강조하듯, ValidationPipe + class-validator는 “DTO를 단일 진입점에서 검증”하는 표준 패턴이다.
단위 테스트 — 22개 신규 spec
startDiagnosticV2: 4개 (세션 생성, 이미 완료 시 에러, 기존 세션 abandon, 활성 명세 없음)
getProblems : 7개 (조회, 세션 검증, 다른 회원 차단, 완료 세션 차단, 이미 완료 지표, 레벨 없음, 문제 부족)
submitMetric : 7개 (저장, 정확도 계산, 세션 검증, 멱등성, 5지표 완료 시 종료, nextMetric 반환, 답안 수 검증)
getStatus : 4개 (NOT_STARTED, COMPLETED, IN_PROGRESS, ABANDONED)
스펙 파일 506줄. 회귀 차단의 한 축이 됐다. 특히 멱등성 케이스와 “5지표 완료 시 자동 종료”는 FE 흐름과 직결되어 있어 단위 테스트로 가장 먼저 묶었다.

📊 결과 — 명세 갱신 + 회귀 차단
| 항목 | 수치 |
|---|---|
| 신규 4 API 단위 테스트 | 22개 통과 |
| Legacy 코드 정리 | 6 메서드 + 9 DTO + 571줄 삭제 |
| 신규 spec 작성 | 506줄 |
| QA E2E 통과 | 5개 시나리오 모두 PASS |
| FE 응답 파싱 버그 수정 | data.data 중첩 처리 + axios 인터셉터로 Authorization 헤더 |
| 작업 시간 | 19:00 → 23:45 (BE 도메인 → FE 페이지 → QA 통합까지) |
E2E에서 FE 측 4건 버그를 같이 잡았다.
| 버그 | 위치 | 수정 |
|---|---|---|
| BE: JWT memberId 추출 오류 | member-diagnostic.controller.ts | sub → memberId |
| FE: Authorization 헤더 누락 | App.tsx | axios 인터셉터 추가 |
FE: 응답 파싱 (data.data) | 결과/시작/문제 페이지 3곳 | 중첩 구조 처리 |
| FE: 쿼리 파라미터 누락 | DiagnosticPlayPage.tsx | axios 직접 사용 |
특히 data.data는 응답 표준화 인터셉터와 직결된 이슈였다. 인터셉터가 모든 응답을 { success, data, meta }로 감싸기 때문에, FE에서 axios.get(...).then(r => r.data.data)로 한 번 더 풀어줘야 한다. 이걸 잊으면 컴포넌트가 undefined를 다룬다.
🔍 단서: “왜 axios 응답이 비어 보이지?”는 응답 표준화 인터셉터가 도입된 직후 자주 보이는 증상이다. 인터셉터를 도입하면 FE에 BE 응답 풀어주는 어댑터를 한 곳에서 통일하는 게 안전하다.
🔄 회고 — 다시 한다면
다시 같은 결정을 해도 4건 중 3건은 그대로 갈 것이다. 하나만 바꾼다면 submitMetric 응답 구조다.
지금은 진행 중일 때 nextMetric을 응답에 박아서 FE가 그 값을 보고 다음 화면으로 이동한다. 그러나 이게 BE에 회원의 화면 흐름을 결정하게 만드는 결합이 됐다. FE가 “다음 지표는 뭘 보여줄지” 결정해도 충분한데, BE가 그 권한을 들고 있다.
다음에 같은 설계를 한다면 nextMetric을 빼고 metricProgress만 응답하는 쪽으로 갈 것이다. FE가 진행률 객체를 보고 다음 지표를 직접 고르는 구조. BE는 측정만, FE는 흐름만. 측정과 결정을 분리한 결정 1번의 정신을 한 단계 더 밀고 가는 셈이다.
또 하나는 활성 명세 검증을 컨트롤러 단계의 가드로 옮기는 것이다. 지금은 도메인 서비스에서 NoActiveDiagnosticSpec을 던지는데, 이걸 가드로 빼면 컨트롤러 진입 자체를 차단할 수 있다. NestJS 공식 문서가 권장하는 가드 활용 패턴이고, 응답 표준화 + 에러 코드 체계가 더 깔끔해진다.
📋 정리 — 결정 요약
| 영역 | 안티패턴 | 채택 패턴 |
|---|---|---|
| 측정 vs 결정 | ❌ completeDiagnostic에서 정확도 → 레벨 동시 결정 | ✅ 측정만 — 레벨 배정은 운영자 분리 |
| 제출 단위 | ❌ 답안 1건 단위 (50번 통신) | ✅ 지표 1건 = 10문제 묶음 |
| Legacy 정리 | ❌ “혹시 모르니” 한 줄 남기기 | ✅ 배포 전 즉시 삭제 (571줄) |
| 멱등성 키 | ❌ (sessionId, problemId, choice) 다중 | ✅ (sessionId, metric) 단일 |
| FE/BE 흐름 결정 | ❌ BE가 nextMetric 결정 | ⚠️ 다음 회차에 FE 책임으로 이전 |
| 활성 명세 검증 | ❌ 도메인 서비스에서 throw | ⚠️ 컨트롤러 가드로 이전 검토 |
한 줄 정리
배치고사 MVP의 핵심 설계 결정은 **“측정과 결정의 분리”**다. 정확도는 BE가 안정적으로 측정하고, 레벨 배정은 운영자가 비즈니스 정책으로 다룬다. 이 분리가 향후 명세 갱신·정책 변경의 비용을 크게 낮췄다.
다음 편에서는 JWT Guard 구현기를 다룬다. request.user가 항상 undefined였던 이유와 @Public() / @Roles() 데코레이터 설계까지.
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)
- 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에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
- 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
- 25. 멀티테넌트 누수 — tenantId 3계층 강제
- 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
- 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
- 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
- 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
- 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
- 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
- 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
- 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
- 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
- 35. Unity ↔ 웹 PostMessage 브릿지 설계기
- 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
- 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
- 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
- 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
- 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
- 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
- 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
- 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
- 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
- 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
- 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날