Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)
이전 편의 배치고사 MVP 머지가 가능했던 것은 같은 날 오후에 Problem 모델의 콘텐츠 종속을 먼저 끊었기 때문이다. 본 작업은 Problem 테이블이 ContentItem에 종속돼 재사용 불가하던 구조를 끊고, 1,891개 문제를 신 스키마로 옮기고, Admin/Student 8 엔드포인트와 단위 테스트 38건을 한 머지에 묶은 마일스톤이다. 스키마·마이그레이션·API·테스트가 한 dev 머지 사이클에 들어간 BE 작업 기록을 정리한다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- Problem.contentId FK를 제거하고
levelId만 남겼다 — 콘텐츠 1건에 묶여 있던 문제를 레벨 단위로 재사용 가능하게 했다- 1,891개 문제 데이터 마이그레이션 + 48개 스킵(
Level 32미존재) —seed-problems.ts로 멱등 실행, 누락 분은 별도 빚으로 분리- Admin 6 엔드포인트 + Student 2 엔드포인트 합 8개를 신 컨트롤러 2개에 분리 — 응답에서
contentId필드 자체 제거- 단위 테스트 38건(
admin-problem18 +student-problem12 +problem-attempt8) — 같은 머지 안에서 회귀 차단 라인 확정ProblemAttemptFK는 유지 — downstream 모델은 손대지 않고 Problem 한 모델만 갈아끼움- 결과: 1,891 마이그 + 8 엔드포인트 + 38 테스트가 같은 dev 머지 사이클에 들어감 — 저녁의 배치고사 MVP(이전 편)가 이 머지 위에 올라탔다
🎯 배경 — 콘텐츠 1건에 묶인 문제를 레벨 단위로 재사용 가능하게
기존 Problem 테이블은 콘텐츠 1건에 강하게 묶여 있었다. 모델 정의에 contentId Int가 FK로 들어가 있어 ContentItem이 사라지면 그 콘텐츠에 속한 문제도 같이 끌려갔다. 같은 레벨의 같은 식(1+1=2)을 다른 콘텐츠에서 재사용하려면 같은 행을 한 번 더 INSERT 해야 했다. 1,939행 중 상당수가 사실상 중복이었고, 응답 DTO는 그 구조를 그대로 노출하고 있었다.
이 구조는 두 가지 비용을 같이 키우고 있었다. 첫째, 문제 풀(pool)을 콘텐츠별로 따로 들고 있어야 한다. 같은 식을 두 콘텐츠가 공유할 수 없으니, 풀 크기가 콘텐츠 수에 비례한다. 둘째, 배치고사 MVP가 다음 단계로 못 간다. 배치고사는 5개 지표를 측정하기 위해 레벨별 문제 풀을 인덱스로 뽑아 쓰는데, 풀이 콘텐츠 단위라면 인덱스 정의 자체가 흔들린다. 같은 날 저녁에 이전 편에서 다룬 배치고사 명세를 코드로 옮기려면, 그 앞에 Problem 구조를 먼저 끊어야 했다.
오후 13:15에 Backend에 시작 지시가 떨어졌다. 작업 흐름은 7단계로 결정됐다.
스키마 변경
↓
데이터 마이그레이션 (seed-problems.ts)
↓
Admin Problem API (병렬) Student Problem API (병렬)
↓
ProblemAttempt FK 유지 검증
↓
단위 테스트 38건 + 빌드 검증
이 7단계가 같은 머지에 들어갔다. 명세상으로 Frontend 관리자 페이지 UI 분리는 같이 가지 않았다 — Backend가 Admin API 완료 후 시작 가능 상태로만 안내했다. 본 글의 범위는 Backend 7단계 전부다.
📌 핵심: 모델 1개가 다른 모델 1개에 강하게 묶여 있을 때, 그 결합을 끊는 결정은 결합된 모델의 응답·테스트·시드를 모두 같은 머지에 묶을 수 있을 때만 싸다. 결합 해제 결정을 코드 변경 없이 며칠 묵히면 신구 응답 공존 비용이 곱으로 쌓인다.
⚖️ 설계 결정 5건 — 무엇을 한 머지에 묶고, 무엇을 분리했나
이번 마일스톤에서 명시한 결정 5건을 먼저 정리한다. 본문은 이 표의 결정 순서대로 코드·라인 수·테스트 케이스를 따라간다.
| # | 결정 | 한 머지에 묶을지 | 트레이드오프 |
|---|---|---|---|
| 1 | Problem.contentId FK 제거 — levelId만 남김 | 같은 머지 | ContentItem과의 1:N 관계가 사라지면서 콘텐츠 응답 DTO의 problemCount도 같은 머지에서 같이 제거. 콘텐츠 화면 한 칸이 사라지는 영향은 있지만 의도된 변경 |
| 2 | 데이터 마이그레이션은 멱등 실행 가능한 단일 스크립트로 | 같은 머지 | 48행 스킵(Level 32 미존재)을 에러로 보지 않고 경고 로그로 끝냄. 누락 분은 별도 빚으로 분리 — 시드에 Level 32가 들어오는 단계에서 재실행 |
| 3 | Admin과 Student 컨트롤러를 한 컨트롤러에 합치지 않고 분리 | 같은 머지 | 컨트롤러 2개 + 응용 서비스 2개로 LOC는 늘지만, 권한 가드(@AdminRoles() vs @StudentRoles())가 한 메서드에 두 개 붙는 패턴을 차단 |
| 4 | ProblemAttempt FK는 손대지 않음 | 같은 머지(검증만) | downstream 모델 회귀 폭이 0이 됨. ProblemAttempt.problemId 의 BigInt 타입이 그대로 유지되니 attempts 조회·집계 쿼리 영향 없음 |
| 5 | 단위 테스트 38건을 같은 머지 안에서 끝까지 | 같은 머지 | 회귀 차단 라인이 본 머지 시점에 확정된다. 다음 머지에서 테스트가 풀리는 함정이 사라지지만, 머지 단위가 커진다 |
이 5건 모두 “이번 한 머지에 끝낸다”로 묶었다. 분리하지 않은 것이 이번 마일스톤의 핵심 결정이다. 결정 4는 미묘하지만 가장 중요하다 — Problem 한 모델만 갈아끼우고 ProblemAttempt는 그대로 두는 결정이, 본 머지의 폭을 4시간 45분으로 잡아주는 단일 절제선이었다.
⚠️ 주의: 결정 1은 “응답에서
problemCount가 사라져도 Frontend가 잠시 깨지지 않는다”는 전제가 깔려 있다. 관리자 페이지의 콘텐츠 목록이 Backend의problemCount를 표시하고 있었다면, 같은 머지에 Frontend 픽스가 들어와야 한다. 본 머지 시점에는 그 화면이 콘텐츠 목록이 아직 Mock 상태였기 때문에 분리 가능했다.
🛠️ 구현 1 — Prisma 스키마 변경: contentId 제거와 식 표현 필드 신설
가장 먼저 schema.prisma를 갱신했다. 변경의 핵심은 두 가지다.
- 삭제 필드:
contentId,seq,stem,answer,metadataJson - 신설 필드:
expression,formulaNumbers,formulaSigns,isActive
expression은 사람이 읽는 표기 ("1+1=2")이고, formulaNumbers/formulaSigns는 클라이언트가 토큰화 없이 바로 렌더링 가능한 구조다. 배치고사에서 한 식을 한 화면에 그릴 때 자릿수별 입력 칸이 별도로 그려져야 하기 때문에, 표현식 한 줄을 다시 파싱하지 않고 배열을 그대로 쓰는 게 싸다.
// apps/api/prisma/schema.prisma (요지, AFTER)
model Problem {
id BigInt @id @default(autoincrement())
levelId Int // FK → Level (유지)
expression String // "1+1=2"
formulaNumbers Json // [1, 1, 2]
formulaSigns Json // ["+", "="]
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
level Level @relation(fields: [levelId], references: [id])
problemAttempts ProblemAttempt[]
@@index([levelId])
@@index([isActive])
@@map("problems")
}
// BEFORE — contentId가 FK로 박혀 있던 구조
model Problem {
id BigInt @id @default(autoincrement())
contentId Int // 콘텐츠에 종속
levelId Int
seq Int // 콘텐츠 안에서의 순번
stem String
answer String
metadataJson Json?
// ...
content ContentItem @relation(fields: [contentId], references: [id])
}
마이그레이션은 prisma migrate dev --name problem_decouple_from_content로 한 번에 실행했다. ContentItem → Problem 관계가 같이 사라지므로, ContentItem 모델에서 problems Problem[] 관계도 같은 마이그레이션에서 끊었다. Prisma 공식 가이드의 Breaking schema changes 흐름 그대로다 — prisma migrate dev가 PostgreSQL DDL을 생성하고, 본 머지에서는 dev 환경 전용이라 ALTER TABLE ... DROP COLUMN이 그대로 떨어진다.
🔍 단서: Prisma의 마이그레이션은 모델 변경을 SQL로 풀어 쓸 때 의외로 보수적이다.
contentId를 nullable로 바꾸지 않고 바로 제거하면 외래 키 제약을 먼저 끊고 컬럼을 삭제하는 두 단계 SQL을 자동으로 생성한다. 운영 DB라면 이 두 단계 사이에서 트래픽이 깨지므로, 운영에서는prisma migrate diff로 SQL을 먼저 출력해 두 단계 사이에 짧은 유지 보수 윈도가 필요한지 점검한다.
🛠️ 구현 2 — 데이터 마이그레이션 1,891개 + 48개 스킵
기존 데이터는 MariaDB problem 테이블의 1,939행이다. docs/problem-data/problem.sql 덤프를 그대로 들고 와 PostgreSQL 신 스키마에 맞춰 매핑했다. 변환 표는 다음과 같다.
| 기존 (MariaDB) | 신규 (PostgreSQL) | 비고 |
|---|---|---|
seq (PK) | 폐기 | 새 id는 autoincrement로 자동 부여 |
level | levelId | 그대로 매핑 (정수) |
expression | expression | 그대로 |
formulaNumber | formulaNumbers | 단수 → 복수, Json 타입으로 |
formulaSign | formulaSigns | 단수 → 복수, Json 타입으로 |
dbState = 'Y' | isActive = true | 활성 플래그 의미 일치 |
dbState = 'N' | isActive = false | 일부 비활성 행 그대로 보존 |
스크립트는 apps/api/prisma/seed-problems.ts에 신설했고, npx tsx seed-problems.ts로 실행했다. 멱등 실행을 보장하기 위해 시작 부분에서 await prisma.problem.deleteMany({})를 호출해 풀을 비운 뒤 한 번에 createMany로 INSERT 한다 — ProblemAttempt 테이블이 비어 있는 dev 환경에서만 가능한 절차다 (운영에서는 ProblemAttempt.problemId FK가 살아 있어 그대로 못 지운다).
// apps/api/prisma/seed-problems.ts (요지)
const rows = parseSqlDump(readFileSync('docs/problem-data/problem.sql', 'utf8'));
const levelIds = new Set(
(await prisma.level.findMany({ select: { id: true } })).map((l) => l.id),
);
const valid = rows.filter((r) => {
if (!levelIds.has(r.level)) {
skipped.push(r);
return false;
}
return true;
});
await prisma.problem.deleteMany({});
const result = await prisma.problem.createMany({
data: valid.map((r) => ({
levelId: r.level,
expression: r.expression,
formulaNumbers: r.formulaNumber, // JSON 배열
formulaSigns: r.formulaSign,
isActive: r.dbState === 'Y',
})),
});
console.log(`Inserted: ${result.count}, Skipped: ${skipped.length}`);
console.log(`Skipped levels: ${[...new Set(skipped.map((r) => r.level))].join(', ')}`);
결과는 1,891행 INSERT, 48행 스킵이었다. 스킵된 48행은 모두 마지막 레벨 노드(Level 32)에 속했는데, 현재 시드에는 Level 테이블에 31번까지만 들어가 있어 FK 제약을 만족시킬 수 없었다. 이 48행을 강제로 넣으려면 마지막 노드를 별도 시드로 신설해야 한다 — 본 머지에서는 이 결정을 하지 않고, 누락 분 48행 + 마지막 노드 시드는 다음 마일스톤의 빚으로 명시 분리했다(회고 표 1번 참조). 마지막 노드는 배치고사 5지표 풀에서 쓰이지 않는 레벨이라 배치고사 마일스톤에 영향이 없다는 게 분리의 근거였다.
레벨 분포는 다음과 같았다: 1, 3, 4, 6, 8, 11, 13, 15, 18, 25 10개 레벨. 레벨당 평균 약 189문제다. 배치고사가 각 지표에서 10문제씩 뽑아 쓰는 패턴을 고려하면 풀이 충분하다 — 본 머지에서는 풀 부족 시나리오 단위 테스트 1건만 짜고, 운영 단계의 부족 알람은 다음 마일스톤으로 미뤘다.
📊 데이터: 마이그레이션 통계 — 1,939행 입력 → 1,891행 INSERT(97.5%) + 48행 스킵(2.5%). 스킵 사유는 단일(
Level 32미존재). 평균 식 길이는 21자, 가장 긴 식은 47자.formulaNumbers배열 길이 평균 4, 가장 긴 배열은 13개 원소.
🛠️ 구현 3 — Admin Problem API 6 엔드포인트
관리자 페이지의 문제 관리 API는 기존에는 콘텐츠 하위에 묶여 있었다.
BEFORE: /api/v1/admin/contents/:contentId/problems
AFTER: /api/v1/admin/problems?levelId=X
라우팅 자체를 콘텐츠 하위에서 분리하고, 독립 컨트롤러 admin-problem.controller.ts를 신설했다. 이번 머지에서 신설한 6 엔드포인트는 다음과 같다.
| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | /admin/problems?levelId=X | 레벨별 문제 목록 (페이지네이션) |
| GET | /admin/problems/stats | 레벨별 통계(레벨, 활성/비활성 카운트) |
| GET | /admin/problems/:id | 문제 상세 |
| POST | /admin/problems | 문제 생성 |
| PATCH | /admin/problems/:id | 문제 수정 |
| DELETE | /admin/problems/:id | 문제 삭제 |
// apps/api/src/application/admin/admin-problem.application.service.ts (요지)
@Injectable()
export class AdminProblemApplicationService {
constructor(private readonly repo: ProblemRepository) {}
async list(query: ListProblemQuery): Promise<ListProblemResponse> {
const { levelId, isActive, page = 1, size = 50 } = query;
const where = { levelId, ...(isActive !== undefined ? { isActive } : {}) };
const [problems, total] = await Promise.all([
this.repo.findMany({ where, skip: (page - 1) * size, take: size }),
this.repo.count(where),
]);
return { problems: problems.map(toDto), total, page, size };
}
async stats(): Promise<LevelStatsResponse> {
const groups = await this.repo.groupByLevel();
return {
levels: groups.map((g) => ({
levelId: g.levelId,
activeCount: g.activeCount,
inactiveCount: g.inactiveCount,
total: g.activeCount + g.inactiveCount,
})),
};
}
// create / update / delete / detail — 도메인 검증 통과 후 repo 위임
}
/stats 엔드포인트는 신규 추가다. 관리자 페이지의 레벨 선택 드롭다운이 “레벨별 문제 수”를 같이 표시할 수 있도록 한 응답에 다 담는 패턴이다. Frontend가 별도 N+1 호출을 안 해도 되도록 본 머지에 같이 넣었다.
컨트롤러는 @AdminRoles(AdminRole.SUPER_ADMIN, AdminRole.ADMIN) 가드를 클래스 레벨에 한 번 붙이고, 메서드 단에는 권한 데코레이터를 추가로 안 단다. 같은 컨트롤러에 Student 라우팅을 섞지 않은 결정 3의 효과가 여기서 나타난다 — 메서드별로 다른 역할 가드를 붙이는 패턴이 차단된다.
📌 핵심: Admin과 Student를 한 컨트롤러에 합치면 LOC는 100~200줄 줄지만, 권한 가드의 위치가 메서드별로 흩어진다. 가드가 흩어지면 단위 테스트도 메서드별로 권한 케이스를 반복해야 한다. LOC 절감보다 테스트 케이스 압축이 더 싸다.
🛠️ 구현 4 — Student Problem API: 랜덤 조회와 권한 가드
회원용 API는 두 엔드포인트만 신설했다.
GET /api/v1/student/problems?levelId=X
GET /api/v1/student/problems/random?levelId=X&count=10
/random이 본 머지의 핵심 추가다. 배치고사가 각 지표에서 10문제를 뽑아 쓰는 패턴을 응답 한 번으로 끝내기 위해, 풀에서 무작위 추출을 BE 단에서 끝낸다.
// apps/api/src/application/student/student-problem.application.service.ts (요지)
@Injectable()
export class StudentProblemApplicationService {
constructor(private readonly repo: ProblemRepository) {}
async listByLevel(
levelId: number,
member: StudentJwtPayload,
): Promise<ListProblemResponse> {
const problems = await this.repo.findMany({
where: { levelId, isActive: true },
});
return { problems: problems.map(toDto), total: problems.length };
}
async random(
levelId: number,
count: number,
member: StudentJwtPayload,
): Promise<RandomProblemResponse> {
if (count < 1 || count > 50) {
throw new BadRequestException('count must be 1..50');
}
const pool = await this.repo.findMany({
where: { levelId, isActive: true },
});
if (pool.length < count) {
throw new ProblemPoolUnderfilledException(levelId, count, pool.length);
}
return { problems: shuffle(pool).slice(0, count).map(toDto) };
}
}
shuffle()은 Fisher–Yates 구현을 사용했다. 배치고사 단위 테스트(이전 편의 submitMetric 케이스 일부)가 정답 순서를 가정하지 않도록 짜여 있어서, 셔플 결과 자체를 단위 테스트로 강하게 묶지 않는 결정을 같이 했다. 대신 shuffle()이 입력 배열의 모든 원소를 보존하는지(분포가 아니라 보존성)만 단위 테스트로 검증했다 — student-problem.application.service.spec.ts 안의 한 케이스다.
ProblemPoolUnderfilledException은 풀이 부족할 때 던지는 새 예외다. 컨트롤러 단에서 422로 매핑해, Frontend가 “이 레벨은 풀이 부족합니다”를 명확히 받을 수 있게 했다. 동일한 422 응답을 배치고사 시작 단에서도 쓰는 게 일관된 처리다.
// apps/api/src/application/student/student-problem.controller.ts (요지)
@Controller('student/problems')
@StudentRoles(StudentRole.STUDENT)
export class StudentProblemController {
constructor(private readonly svc: StudentProblemApplicationService) {}
@Get()
list(
@Query('levelId', ParseIntPipe) levelId: number,
@CurrentUser() user: StudentJwtPayload,
) {
return this.svc.listByLevel(levelId, user);
}
@Get('random')
random(
@Query('levelId', ParseIntPipe) levelId: number,
@Query('count', new DefaultValuePipe(10), ParseIntPipe) count: number,
@CurrentUser() user: StudentJwtPayload,
) {
return this.svc.random(levelId, count, user);
}
}
ParseIntPipe로 쿼리 파라미터 타입을 강제했다. levelId가 누락되거나 정수가 아니면 NestJS가 400을 던진다 — 단위 테스트에서 이 경계 케이스를 별도로 검증하지 않고 parseIntPipe 표준 동작에 위임했다. NestJS 공식 문서의 Pipes 가이드 그대로다.
🛠️ 구현 5 — ProblemAttempt FK 유지: 손대지 않는 결정의 검증
결정 4의 미묘함은 본 단계에서 드러난다. ProblemAttempt.problemId FK는 그대로 두기로 했지만, 그 결정을 신뢰하기 위해 검증 단계가 필요했다. Problem.id가 BigInt로 유지되는 한 ProblemAttempt.problemId(BigInt)도 그대로 살아 있다는 게 명제다.
검증 항목 3가지:
ProblemAttempt의 기존 행이 INSERT 후에도 유효한가 —problemAttempt.problemId가 신Problem.id와 매칭되는가cascade동작이 의도치 않게 발생하지 않는가 —ContentItem삭제 시ProblemAttempt가 같이 끌려가지 않는가- attempts 조회 쿼리(
prisma.problemAttempt.findMany({ include: { problem: true } }))가 깨지지 않는가
검증은 dev DB에서 직접 수행했다. npx prisma studio로 ProblemAttempt 테이블을 열어 problemId 컬럼이 살아 있고, 조인된 Problem 행이 모두 표시되는지 확인한다. 본 단계에는 운영 데이터가 없는 시점이라 검증 폭은 좁았다 — 회고 표에서 명시했듯 운영 데이터가 쌓인 후에는 같은 패턴의 머지가 안 된다.
// 검증 쿼리 — attempts include 가 깨지지 않는지 확인
const attempts = await prisma.problemAttempt.findMany({
include: { problem: { include: { level: true } } },
take: 10,
});
// 기대: attempts[i].problem.id === attempts[i].problemId (BigInt 일치)
// 기대: attempts[i].problem.level.id === attempts[i].problem.levelId
// 기대: attempts[i].problem.contentId 필드 자체 없음(undefined)
세 항목 모두 통과했다. ProblemAttempt 테이블은 한 줄도 안 바꿨고, 응답 형태도 그대로다. downstream 모델을 손대지 않는 결정이 본 머지의 폭을 잡아준다는 게 이 단계의 결과다.
🛠️ 구현 6 — 단위 테스트 38건: 회귀 차단 라인 분포
본 머지의 마지막 단계는 단위 테스트 38건을 같은 머지 안에서 끝내는 것이다. 분포는 다음과 같다.
| 스펙 파일 | 케이스 수 | 핵심 명제 |
|---|---|---|
admin-problem.application.service.spec.ts | 18 | CRUD 7 + filter 4 + stats 3 + edge 4 |
student-problem.application.service.spec.ts | 12 | listByLevel 4 + random 5 + auth 3 |
problem-attempt.spec.ts(연동 검증) | 8 | FK 유지 4 + cascade 차단 2 + null 가드 2 |
| 합계 | 38 | 4개 응용 서비스 + 1개 도메인 회귀 차단 |
admin-problem.application.service.spec.ts 18건
CRUD 7건은 list, listByLevel, detail, create, update, delete, softDelete(isActive=false)를 각각 한 케이스로 검증한다. filter 4건은 페이지네이션(page/size), isActive 토글 두 방향, 빈 결과 응답이다. stats 3건은 /stats 응답에서 활성/비활성 카운트가 모두 들어가는지, 빈 레벨이 0/0으로 응답되는지, 미존재 레벨이 응답에서 빠지는지를 본다.
edge 4건이 가장 흥미롭다.
BigInt id직렬화 — JSON 응답에서 BigInt가 문자열로 변환되는지(String(problem.id))- 잘못된
levelId(미존재) — 404가 아닌 빈 배열 응답 정책 검증 formulaNumbers/formulaSignsJSON 스키마 — 배열이 아니면 400isActive토글이updatedAt을 갱신하는지 — Prisma@updatedAt이 모델 단에서 처리되므로 한 줄 명제
// admin-problem.application.service.spec.ts (요지 발췌)
describe('AdminProblemApplicationService', () => {
let svc: AdminProblemApplicationService;
let repo: jest.Mocked<ProblemRepository>;
beforeEach(() => {
repo = createMockRepository();
svc = new AdminProblemApplicationService(repo);
});
it('list — 페이지네이션이 page/size를 그대로 반영한다', async () => {
repo.findMany.mockResolvedValue([fixtureProblem({ id: 1n }), fixtureProblem({ id: 2n })]);
repo.count.mockResolvedValue(105);
const result = await svc.list({ levelId: 6, page: 2, size: 50 });
expect(repo.findMany).toHaveBeenCalledWith({
where: { levelId: 6 },
skip: 50,
take: 50,
});
expect(result.total).toBe(105);
expect(result.page).toBe(2);
});
it('stats — 빈 레벨도 활성/비활성 0/0으로 응답', async () => {
repo.groupByLevel.mockResolvedValue([
{ levelId: 1, activeCount: 189, inactiveCount: 2 },
{ levelId: 3, activeCount: 0, inactiveCount: 0 },
]);
const result = await svc.stats();
expect(result.levels).toHaveLength(2);
expect(result.levels[1]).toEqual({
levelId: 3,
activeCount: 0,
inactiveCount: 0,
total: 0,
});
});
it('detail — BigInt id가 문자열로 직렬화된다', async () => {
repo.findById.mockResolvedValue(fixtureProblem({ id: 1234567890123456789n }));
const result = await svc.detail('1234567890123456789');
expect(typeof result.id).toBe('string');
expect(result.id).toBe('1234567890123456789');
});
// ... 15 cases more
});
student-problem.application.service.spec.ts 12건
listByLevel 4건은 정상 응답, 비활성 문제 제외, 빈 풀 응답, 다른 회원의 결과 격리(memberId가 응답에 영향 없음)다. random 5건이 본 스펙의 중심이다.
count경계값(0, 51)에서 400을 던진다- 기본값 10으로 폴백한다(
@DefaultValuePipe단위 검증) - 풀 < count 일 때
ProblemPoolUnderfilledException422를 던진다 - 활성 문제만 풀에 들어간다 (
isActive: false제외) - 셔플 후에도 원소 보존(분포 검증이 아닌 보존성)
auth 3건은 STUDENT 역할 토큰 통과, 다른 역할(ADMIN 토큰) 거부, 토큰 누락 시 401이다. NestJS의 Guards 표준 동작에 위임하지 않고, 응용 서비스 단에서도 한 번 더 명시 검증을 한다 — 가드 설정이 빠진 채로 응용 서비스가 호출되는 회귀를 차단하기 위함이다.
// student-problem.application.service.spec.ts (요지 발췌)
it('random — 풀 < count 면 422 던진다', async () => {
repo.findMany.mockResolvedValue([fixtureProblem(), fixtureProblem(), fixtureProblem()]);
await expect(svc.random(6, 10, mockStudentUser)).rejects.toBeInstanceOf(
ProblemPoolUnderfilledException,
);
});
it('random — shuffle 후에도 원소가 보존된다', async () => {
const pool = Array.from({ length: 30 }, (_, i) =>
fixtureProblem({ id: BigInt(i + 1) }),
);
repo.findMany.mockResolvedValue(pool);
const result = await svc.random(6, 10, mockStudentUser);
expect(result.problems).toHaveLength(10);
const ids = new Set(result.problems.map((p) => p.id));
expect(ids.size).toBe(10); // 중복 없음
for (const p of result.problems) {
expect(pool.find((q) => q.id === BigInt(p.id))).toBeDefined();
}
});
it('listByLevel — 비활성 문제는 풀에 안 들어간다', async () => {
await svc.listByLevel(6, mockStudentUser);
expect(repo.findMany).toHaveBeenCalledWith({
where: { levelId: 6, isActive: true },
});
});
problem-attempt.spec.ts 8건
이 스펙은 응용 서비스가 아닌 도메인 회귀를 검증한다. 결정 4(“ProblemAttempt는 손대지 않음”)의 명제를 8건의 케이스로 굳혔다.
- FK 유지 4건:
problemAttempt.problemId가Problem.id(BigInt)와 매핑되는지, 조회 시include: { problem: true }가 깨지지 않는지, 응답 DTO에contentId가 없는지, BigInt 직렬화가 양쪽에서 일관된지 - cascade 차단 2건:
Problem.delete()시ProblemAttempt.problemId가null로 안 떨어지는지(RESTRICT동작),ContentItem.delete()시ProblemAttempt가 안 끌려가는지 - null 가드 2건: 미존재
Problem.id로ProblemAttempt생성 시 FK 위반,problemId누락 시 422
이 8건은 본 머지의 가장 깊은 회귀 차단 라인이다. 다음 마일스톤(배치고사 MVP)에서 ProblemAttempt를 DiagnosticAttempt와 결합할 때, 본 8건이 풀리지 않는 한 회귀 신호가 즉시 잡힌다.
⚠️ 주의: 도메인 회귀 차단 테스트는 응용 서비스 테스트와 같이 묶어 짜기 쉬운데, 본 머지에서는 의도적으로 분리했다. 응용 서비스 스펙 파일이 본 머지에서만 단방향으로 자라는 것을 막기 위함이다. 같은 패턴의 분리는 도메인 모델이 두 응용 서비스에 걸쳐 있을 때 유효하다 — 여기서는
Problem이 Admin과 Student 양쪽 응용 서비스에 다 쓰인다.

📊 결과 — 4시간 45분의 마일스톤 6 측정 지표
오후 4시간 45분 동안 들어간 결과는 다음과 같다.
| 지표 | 값 |
|---|---|
| 스키마 변경 | 5필드 삭제 + 4필드 추가 + 1 FK 제거 |
| 데이터 이관 | 1,939행 입력 → 1,891행 INSERT(97.5%) + 48행 스킵 |
| 신규 엔드포인트 | 8개 (Admin 6 + Student 2) |
| 단위 테스트 | 38건, 100% pass — admin-problem 18 + student-problem 12 + problem-attempt 8 |
| 추가/삭제 라인 | +1,180 / -290(연쇄 변경 포함, admin-content/student-content DTO 정리 동반) |
| 빌드 | pnpm build 통과 — Prisma Client 재생성 후 ProblemAttempt 타입 안정 |
흥미로운 점은 추가 라인(+1,180)이 삭제 라인(-290)보다 4배 많다는 것이다. 명세를 단순화한 마일스톤이지만 단위 테스트 38건이 거의 800줄을 차지하기 때문이다. 회귀 차단 라인이 같은 머지에 쌓인 결과로 봐야 한다.
운영 분기 수도 같이 줄었다. Problem 모델이 가지던 분기 — contentId 유무, seq 정렬, metadataJson 파싱 — 3가지가 사라지고, 단일 명제(levelId + expression + formulaArrays)로 줄었다. 응답 DTO 한 줄(contentId: number | undefined)이 사라진 것이 가장 큰 변화다.
📌 핵심: 마일스톤 평가에서 LOC는 표면 지표일 뿐이다. 운영 분기 수(branch count)와 검증 라인(test LOC)을 따로 추적하면, “코드는 안 줄었는데 명세는 단순해졌다”는 결과가 정확히 표현된다. 본 머지는 LOC 기준으로는 +890 늘었지만, 응답 분기 수는 3 → 0으로 줄었다.
🔄 회고 — 후속에서 갚을 빚 4건
본 작업은 한 머지 묶기 면에서 만족스럽지만, 후속에서 갚아야 할 빚이 4건 명시적으로 남았다. 마일스톤 회고는 “잘 됐다”보다 “다음에 갚을 게 무엇인가”가 더 중요하다.
| # | 빚 | 갚을 단계 |
|---|---|---|
| 1 | Level 32 미존재로 스킵된 48행 — 시드에 Level 32 들어오는 단계에서 재실행 필요 | 다음 시드 보강 마일스톤에 명시. seed-problems.ts는 이미 멱등 실행 가능하니 재실행만으로 처리 |
| 2 | dev 환경 전제 마이그레이션 패턴 — ProblemAttempt를 비우고 Problem.deleteMany 하는 절차는 운영에서 못 씀 | 첫 운영 배포 전에 prisma migrate diff + 짧은 유지 보수 윈도 매뉴얼 작성 |
| 3 | shuffle() 분포 미검증 — 보존성만 단위 테스트, 분포 균일성은 검증 안 함 | Fisher–Yates 분포 단위 테스트 추가, 또는 crypto.randomInt 기반으로 교체 검토 |
| 4 | problemCount 응답 칸 제거의 Frontend 영향 미분석 — 본 머지 시점 Mock 상태라 분리 가능했음 | 관리자 페이지 콘텐츠 목록 API 연결 단계에서 같이 검증 |
이 중 2번이 가장 무겁다. 다음 마일스톤(Problem 풀에 직접 INSERT/UPDATE 가 일어나는 운영 단계)에서는 같은 패턴(deleteMany 후 createMany)을 못 쓴다. ProblemAttempt.problemId FK가 살아 있어 Problem 행을 통째로 못 지운다. 대안은 (a) prisma migrate diff로 SQL을 직접 생성·검토, (b) INSERT ... ON CONFLICT ... DO UPDATE 패턴으로 멱등 보장, (c) Problem.isActive 토글로만 신구 분기, 이 세 가지를 운영 첫 시드 단계에서 결정해야 한다.
3번도 흥미롭다. 배치고사 단위 테스트는 정답 순서를 가정하지 않도록 짜여 있어 셔플 분포 검증이 당장 회귀로 잡히지 않지만, “셔플이 균일하지 않다”는 잠재 버그는 그대로 남아 있다. Fisher–Yates 구현은 표준이지만 한 단계 실수(j = Math.floor(Math.random() * i) vs (i + 1))로 마지막 원소가 항상 같은 인덱스에 남는 케이스를 본 적이 있다. 회고에 명시해 두는 게 다음 머지 검토 시 단서가 된다.
💡 인사이트: “이번에 잘 됐다”는 회고의 위험 신호다. 본 머지는 5개 결정이 한 머지에 안전하게 들어간 이유가 단일 조건(dev 환경, 운영 데이터 부재)이 우연히 맞았기 때문이다. 운영 트래픽이 생긴 이후 같은 패턴을 답습하면 한 줄 SQL이 운영 사고가 된다.
📋 정리 — 모델 종속 끊기 + 회귀 차단 라인 한 머지 묶기
| 측면 | 안티패턴 | 권장 패턴 |
|---|---|---|
| FK 결합 해제 | 며칠 묵힌 채 신구 컬럼 공존 → DTO에서만 가림 | 같은 머지에 스키마 + 응답 + 단위 테스트 모두 옮김 |
| 데이터 마이그레이션 | 응용 서비스 단의 ad-hoc 변환 | 멱등 실행 가능한 단일 스크립트 + 누락 분 명시 분리 |
| API 분리 | Admin/Student를 한 컨트롤러에 — 메서드별 가드 | 컨트롤러 2개 — 클래스 레벨 가드 한 번 |
| downstream FK | 같이 갈아끼우려고 시도하다 머지 폭 폭발 | ProblemAttempt처럼 손대지 않음 — 검증 8건으로 명제 굳히기 |
| 단위 테스트 | 다음 머지로 미루기 | 같은 머지 안 38건 — 회귀 차단 라인이 본 dev 시점에 박힘 |
본 마일스톤은 모델 종속 해제와 단위 테스트 38건을 같은 dev 머지 사이클에 묶은 운영 기록이다. 결정 5건, 신규 엔드포인트 8개, 단위 테스트 38건, +1,180 / -290 라인. 같은 날 저녁의 배치고사 MVP 머지는 본 머지가 먼저 들어갔기 때문에 가능했다. 다음 편(devlog-50)에서는 같은 머지 흐름에서 잡혔던 운영자 권한 버그 — “내 반만 보여줘”가 안 되던 날 — 한 라운드의 트러블슈팅 기록을 정리할 예정이다.
📚 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 권한 가드 — 목록은 막고 상세는 뚫린 날