타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)
QA Round 2에서 EC-3 엣지 케이스가 잡은 타이머 NaN:NaN 버그. 같은 컴포넌트가 같은 코드로 한 화면에서 정상, 다른 화면에서 NaN:NaN으로 뜨던 패턴이다. 원인은 BE 응답 DTO에서 한 필드 누락 + FE 무방어 + seed 데이터 부족 세 가지의 합이었다. 응답 스키마 검증과 콘텐츠 후보 0건 방어를 같이 깐 라운드 회고.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 증상: 학습 클라이언트의 번들 진행 화면에서 타이머가
NaN:NaN으로 표시된다. 같은 코드의 같은 컴포넌트가 다른 화면에서는23:13처럼 정상이다.- 표면 원인 1 (FE):
formatTime(remainingTimeMs)가undefined를 받는다. 분·초 계산이Math.floor(undefined / 60000)→NaN→ 템플릿 보간으로"NaN:NaN"이 화면에 그대로 표시된다.- 표면 원인 2 (BE): Bundle 상세 API가 응답 DTO에서
remainingTimeMs필드를 반환하지 않는다. 같은 값을 가진 다른 API(과제 상세)는 반환한다. 같은 도메인을 두 API가 부분만 노출하던 패턴.- 진짜 원인: 응답 DTO를 매번 손으로 매핑하는 구조 + FE 타이머 컴포넌트가
Number.isFinite()가드 없이 바로 포매팅 + seed 데이터의 콘텐츠 후보가 빈 배열이라 화면이 빨리 비어 보였던 것까지 셋이 합쳐진 버그.- 해결: BE 응답 DTO에 필드 추가, FE 타이머 컴포넌트에
NaN가드 추가, seed에 콘텐츠 후보 50개 재생성. 커밋 한 번에 세 곳 같이.- 교훈: 같은 도메인을 노출하는 두 API가 응답을 부분만 공유하면, 한 화면에서는 정상이고 다른 화면에서는 빈 값이 떨어진다. FE는
undefined를 그대로 포매팅하지 말 것, BE는 응답 DTO를 한 곳에서 검증할 것.
🌱 왜 이 버그가 라운드 2에서 잡혔나
이전 편에서 라운드 1의 함정 세 가지를 정리하면서 엣지 케이스 5건을 docs/pm/qa-edge-case-spec.md로 분리했다. 그중 EC-3가 “타이머 0초에서 자동 제출, NaN·음수 표시 방어”였다.
라운드 1에서는 인증 흐름과 메인 페이지의 UI 스모크가 메인이었고, 번들 진행 화면은 5분 안에 첫 콘텐츠를 풀고 다음 화면으로 넘어가는 경로만 탔다. 타이머는 23:14처럼 정상으로 보였고, 0초까지 기다리는 시나리오는 한 번도 돌리지 않았다.
라운드 2의 첫 번째 케이스가 EC-3였다. 0초까지 기다리려고 화면을 열어 두는 대신, 일단 번들 진행 화면을 다시 띄워서 새 번들로 진입하는 흐름부터 점검했다. 새로 로드된 화면의 타이머가 처음 0.5초 동안 NaN:NaN으로 떴다가, 그 다음 다시 정상 값으로 깜박였다. 라운드 1에서는 한 번도 못 잡았던 깜빡임이다.
처음에는 화면 전환 직후 비동기 로딩 중에 잠깐 보일 수 있는 시각적 결함 정도로 봤다. 그런데 같은 화면을 다른 번들 ID로 다시 들어가니 NaN:NaN이 고정으로 깔린 채 사라지지 않았다. 이쯤 되니 단순한 깜빡임이 아니다.
📌 핵심: UI에서
NaN이 보이면 분기점은 둘 중 하나다. (1) 값이 늦게 와서undefined인 동안 포매팅한 경우 — 보통 곧 사라진다. (2) 값이 아예 안 오는 경우 — 안 사라진다. 두 케이스를 구분하지 않으면 똑같이 보이지만, 고쳐야 할 위치가 BE인지 FE인지가 갈린다.
🔥 증상 — 같은 코드가 한 화면에서 정상, 다른 화면에서 NaN:NaN
학습 클라이언트의 타이머 컴포넌트는 한 곳에서 정의했다. 두 화면에서 같은 컴포넌트를 마운트한다.
// components/AssignmentTimer.tsx — 한 정의, 두 화면에서 마운트
type Props = { remainingTimeMs: number };
export function AssignmentTimer({ remainingTimeMs }: Props) {
const minutes = Math.floor(remainingTimeMs / 60_000);
const seconds = Math.floor((remainingTimeMs % 60_000) / 1000);
return <span>{`${pad(minutes)}:${pad(seconds)}`}</span>;
}
function pad(n: number) {
return String(n).padStart(2, '0');
}
두 화면은 다음과 같다.
| 화면 | 데이터 출처 | 결과 |
|---|---|---|
| 메인의 “오늘의 숙제” 카드 | GET /api/v1/student/assignment/:id | 23:13 정상 |
| 번들 진행 화면 상단 바 | GET /api/v1/student/bundle/:bundleId | NaN:NaN |
같은 컴포넌트, 같은 prop, 같은 회원 계정. 차이는 단 하나, 데이터를 떠서 prop으로 내려주는 부모 페이지다. 컴포넌트 안쪽을 백 번 다시 봐도 버그가 아니라 prop이 다르게 들어오고 있다는 결론밖에 안 나왔다.
브라우저 DevTools의 Network 탭에서 두 API 응답을 비교했다.
// GET /api/v1/student/assignment/abc123 — 정상
{
"assignmentId": "abc123",
"bundleId": "bdl-9999",
"remainingTimeMs": 1393000,
"currentBundleOrder": 1,
...
}
// GET /api/v1/student/bundle/bdl-9999 — NaN:NaN
{
"bundleId": "bdl-9999",
"title": "Bundle 9999",
"contents": [...],
"currentContentOrder": 1
// remainingTimeMs 필드 자체가 없다
}
같은 도메인의 같은 시간 정보를 노출하는 두 API가 한 쪽만 remainingTimeMs를 반환했다. 번들 진행 화면은 두 번째 API의 응답에서 값을 꺼내 prop으로 내려주려고 했는데, 객체에 그 키 자체가 없으니 undefined가 컴포넌트로 들어갔고 그 결과가 NaN:NaN이었다.
그러면서 같은 화면의 콘텐츠 후보 영역도 빈 채로 깔려 있었다. “다음 콘텐츠 후보”가 두 개 떠야 하는데 0개로 떴다. 이쪽은 또 다른 함정이라 잠시 보류하고 타이머부터 잡기로 했다.

🔍 탐색 — 잘못된 가설들
원인 후보가 셋이었다.
가설 1: FE 마운트 타이밍 문제?
처음에는 React의 마운트 직후 렌더 순서를 의심했다. useEffect로 데이터를 패치하는 동안 첫 렌더에서 remainingTimeMs가 undefined로 잠시 들어갔다가 다음 틱에 채워지는 흐름이 있을 수 있다. 그런데 NaN:NaN이 사라지지 않고 그대로였다. 다음 틱에도 채워지지 않는다는 뜻이고, 즉 BE가 끝까지 그 값을 안 준다는 뜻이었다.
// pages/bundle/[bundleId].tsx — 첫 가설
const { data, isLoading } = useBundleDetail(bundleId);
if (isLoading) return <Spinner />;
return <AssignmentTimer remainingTimeMs={data.remainingTimeMs} />;
// ^^^^^^^^^^^^^^^^^^ undefined가 끝까지 그대로
isLoading이 끝난 다음에도 data.remainingTimeMs가 undefined라면, FE 마운트 타이밍이 문제가 아니라 응답 객체 자체에 그 키가 없다는 결론밖에 안 남는다.
가설 2: 타이머 폴링 주기?
타이머가 1초마다 갱신되는 폴링이 어디선가 깨졌나 싶었다. 콘솔에 Date.now()를 찍어보니 폴링 자체는 돌고 있었다. 그런데 매 폴링마다 data.remainingTimeMs가 여전히 undefined였다. 폴링은 아니고, 한 번 받은 응답에 키가 처음부터 없는 상태였다.
가설 3: 응답 매핑 인터셉터?
NestJS 응답 표준화 인터셉터가 어딘가에서 필드를 누락시켰을 가능성을 봤다. 다른 응답에서는 같은 필드를 잘 반환하니까 인터셉터 전역 문제는 아니다. 컨트롤러별로 응답을 따로 매핑하는 구조의 결과로, 한 컨트롤러가 그 필드를 빼먹은 게 정답에 가까웠다.
🎯 진짜 원인 — 응답 DTO를 손으로 매핑하다 한 필드가 누락됐다

BE 코드를 열었다. Bundle 상세 컨트롤러는 응답 DTO를 손으로 매핑하고 있었다.
// student-bundle.application.service.ts — 수정 전
async findBundleDetail(bundleId: string): Promise<BundleDetailResponse> {
const bundle = await this.prisma.bundle.findUniqueOrThrow({
where: { id: BigInt(bundleId) },
include: {
assignment: true,
contents: { include: { content: true } },
},
});
return {
bundleId: bundle.id.toString(),
title: bundle.title,
contents: bundle.contents.map(toContentDto),
currentContentOrder: bundle.currentContentOrder,
// remainingTimeMs 매핑이 없다
};
}
같은 도메인을 노출하는 과제 상세 컨트롤러는 동일한 값을 반환하고 있었다.
// student-assignment.application.service.ts — 같은 값을 반환
async findAssignmentDetail(assignmentId: string): Promise<AssignmentDetailResponse> {
...
const remainingTimeMs = await this.calculateRemainingTimeMs(assignment.id);
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// 이 메서드는 콘텐츠 플레이 시간 합으로 계산
return {
assignmentId: assignment.id.toString(),
bundleId: ...,
remainingTimeMs,
...
};
}
원인은 단순했다. 같은 도메인의 시간 정보를 노출하는 두 API가 응답을 각자 손으로 매핑했고, 번들 상세 컨트롤러가 그 한 필드를 매핑하지 않은 채로 머물렀던 것이다. 응답 DTO 클래스도 따로 정의되어 있어서, 한 쪽에 필드를 추가하면 다른 쪽도 자동으로 따라가는 구조가 아니었다.
그러면서 같이 잡힌 두 번째 함정 — seed 부족
같은 라운드에서 “콘텐츠 후보 빈 배열” 문제도 같이 떴다. 회원이 진입한 번들에 매핑된 콘텐츠 후보가 0개로 떨어졌다. seed 스크립트를 들여다보니 BundleContent 매핑이 라운드 1을 돌면서 일부 회원에 대해 5건 정도만 들어갔고, 라운드 2의 새 회원·새 레벨 케이스에서는 매핑이 비어 있었다.
// prisma/seed.ts — 수정 전
const memberCount = 8;
const contentsPerMember = 5;
for (const member of members) {
for (let i = 0; i < contentsPerMember; i++) {
await prisma.bundleContent.create({ ... });
}
}
// 8 × 5 = 40건. 그런데 새 레벨 회원이 들어오면 매핑이 비어 있다
이 두 함정은 별개 버그처럼 보이지만, 타이머가 NaN으로 떠서 화면이 비어 보이는 동안 콘텐츠 후보도 빈 배열로 떨어지면 회원 입장에서는 같은 한 가지 증상으로 묶여 보인다. 라운드 2의 EC-3가 두 결함을 동시에 잡은 셈이다.
🛠️ 해결 — 한 커밋으로 세 곳을 같이
수정은 세 군데를 한 커밋(324d060)에 묶어서 진행했다.
1) BE 응답 DTO에 필드 추가 + 한 곳에서 계산
응답 DTO 클래스에 필드를 추가하고, 계산 메서드를 도메인 서비스로 끌어올려서 두 컨트롤러가 같은 함수를 호출하게 했다.
// domain/services/timer.service.ts — 새로 분리
@Injectable()
export class TimerService {
constructor(private prisma: PrismaService) {}
/**
* 과제의 남은 시간 (ms) — 콘텐츠 플레이 시간 합 기준
* 실시간 경과 시간이 아니라, 회원이 콘텐츠를 실제로 푼 누계 시간을 뺀 값.
*/
async calculateRemainingTimeMs(assignmentId: bigint): Promise<number> {
const assignment = await this.prisma.assignment.findUniqueOrThrow({
where: { id: assignmentId },
include: { classMember: { include: { class: true } } },
});
const timeLimitMs = (assignment.classMember?.class?.bundleTimeLimitMinutes ?? 30) * 60_000;
const attempts = await this.prisma.contentAttempt.findMany({
where: {
bundleContent: { bundle: { assignmentId } },
status: 'COMPLETED',
endedAt: { not: null },
},
select: { startedAt: true, endedAt: true },
});
const playedMs = attempts.reduce((acc, a) => {
const dur = a.endedAt!.getTime() - a.startedAt.getTime();
return acc + Math.max(0, dur);
}, 0);
return Math.max(0, timeLimitMs - playedMs);
}
}
// student-bundle.application.service.ts — 수정 후
async findBundleDetail(bundleId: string): Promise<BundleDetailResponse> {
const bundle = await this.prisma.bundle.findUniqueOrThrow({
where: { id: BigInt(bundleId) },
include: { assignment: true, contents: { include: { content: true } } },
});
const remainingTimeMs = await this.timerService.calculateRemainingTimeMs(bundle.assignmentId);
return {
bundleId: bundle.id.toString(),
title: bundle.title,
contents: bundle.contents.map(toContentDto),
currentContentOrder: bundle.currentContentOrder,
remainingTimeMs, // 추가
};
}
응답 DTO 클래스에도 필드를 명시해서, 클래스 검증(class-validator + Swagger 데코레이터)이 누락을 잡아낼 수 있게 했다.
// dto/bundle-detail.response.ts
export class BundleDetailResponse {
@ApiProperty()
bundleId!: string;
@ApiProperty()
title!: string;
@ApiProperty({ type: () => BundleContentDto, isArray: true })
contents!: BundleContentDto[];
@ApiProperty()
currentContentOrder!: number;
@ApiProperty({ description: '남은 시간 (ms). 콘텐츠 플레이 시간 합 기준' })
remainingTimeMs!: number; // ← 필수 필드, 누락 시 컴파일 에러
}
remainingTimeMs!: number로 선언했기 때문에, 매핑에서 누락하면 TS 컴파일러가 잡는다. class-validator로 런타임 검증까지 깔면 다음 라운드에 한 번 더 안전망이 생긴다.
2) FE 타이머에 NaN 가드
타이머 컴포넌트가 undefined나 NaN을 받아도 화면에 그대로 박지 않도록 가드를 깔았다.
// components/AssignmentTimer.tsx — 수정 후
type Props = { remainingTimeMs: number | undefined };
export function AssignmentTimer({ remainingTimeMs }: Props) {
if (!Number.isFinite(remainingTimeMs) || (remainingTimeMs ?? -1) < 0) {
return <span aria-label="타이머 데이터 누락">--:--</span>;
}
const total = remainingTimeMs as number;
const minutes = Math.floor(total / 60_000);
const seconds = Math.floor((total % 60_000) / 1000);
return <span>{`${pad(minutes)}:${pad(seconds)}`}</span>;
}
Number.isFinite() 한 줄이 핵심이다. undefined, null, NaN, Infinity 모두 한 번에 걸러진다. 화면에 NaN:NaN이 박히는 대신 --:--이 뜨고, BE 데이터 누락이 화면 한쪽에 시각적으로 남는다.
3) seed 데이터 콘텐츠 후보 보강
seed 스크립트가 회원별로 5건만 매핑하던 구조를 바꿔, 레벨별로 10건씩 풀(pool)을 만든 다음 회원의 레벨에 맞춰 채우는 방식으로 정리했다.
// prisma/seed.ts — 수정 후
const levelsToSeed = [1, 3, 6, 9, 20, 25]; // 라운드 2의 회원 레벨 분포
for (const level of levelsToSeed) {
// 레벨당 콘텐츠 후보 10건
const contentsForLevel = await prisma.content.findMany({
where: { level },
take: 10,
});
for (const member of members.filter(m => m.level === level)) {
for (const content of contentsForLevel) {
await prisma.bundleContent.create({
data: {
bundleId: member.activeBundleId,
contentId: content.id,
order: contentsForLevel.indexOf(content) + 1,
},
});
}
}
}
// 8 회원 × 10 콘텐츠 = 80건. 라운드 2의 모든 레벨에서 후보 0건이 안 나옴
✅ 검증 — Round 4 결과
수정 후 라운드 4(같은 날 22:00)에서 같은 시나리오를 다시 돌렸다.
| 이슈 | 이전 | 현재 | 결과 |
|---|---|---|---|
| 타이머 표시 | NaN:NaN (고정) | 23:13 | ✅ FIXED |
| 콘텐츠 후보 | 빈 배열 | 2개 후보 | ✅ FIXED |
| BE 응답 객체 | remainingTimeMs 키 없음 | 1393000 정상 | ✅ |
| FE 가드 | 없음 — NaN 그대로 표시 | --:-- 폴백 | ✅ |
세 곳을 한 커밋으로 같이 고친 덕분에, 라운드 4의 재테스트는 케이스 하나에 5분이 안 걸렸다. EC-3가 명세에 있어서 0초 자동 제출 시나리오까지 같은 라운드에서 한 번에 점검하고 넘어갈 수 있었다.
라운드 4 직후의 진행 상태는 이랬다.
✅ 타이머 표시 정상 (23:13)
✅ 콘텐츠 후보 2개 (레벨 매칭됨)
⚠️ 다음 번들 자동 생성 미구현 — Round 5에서 잡힘
❌ TEST 회원 일부 과제 미배정 — Round 5 seed에서 잡힘
타이머와 콘텐츠 후보 두 함정을 라운드 2에서 한 번에 잡고, 그 다음 라운드는 자동 생성과 seed 보강으로 바로 넘어갔다. 라운드 1처럼 같은 이슈를 두 라운드에 걸쳐 다시 보지 않았다.
🛡️ 예방 — 응답 스키마 검증 + FE 가드 + seed 최소치
같은 패턴의 버그를 다음 라운드에 안 만들기 위한 체크리스트다.
BE 응답 DTO 한 곳에서 정의 + 도메인 서비스로 계산 분리
같은 도메인을 노출하는 컨트롤러가 둘 이상이면, 응답 DTO에 들어가는 도메인 값(remainingTimeMs 같은 것)은 도메인 서비스의 한 메서드로 끌어올린다. 컨트롤러는 그 메서드를 호출만 한다. 한 컨트롤러에서 필드를 빼먹는 일을 컴파일 시점에 잡으려면 응답 DTO를 클래스로 정의하고 !: number처럼 필수로 표기한다.
// 컨트롤러에서 매핑 누락 시 TS 컴파일러가 잡도록
export class BundleDetailResponse {
@ApiProperty()
remainingTimeMs!: number; // ← optional이 아니라 필수
}
// 매핑에서 빼먹으면
return {
bundleId: ...,
// remainingTimeMs 누락 → TS2322: Property 'remainingTimeMs' is missing
};
FE 타이머 컴포넌트는 Number.isFinite() 가드를 디폴트로
타이머·진행률·점수처럼 숫자 포매팅이 들어가는 컴포넌트는 prop이 undefined나 NaN일 수 있다고 가정한다. 가드가 한 줄이라도 사용자 화면에 NaN:NaN이 뜨는 일은 막아야 한다.
// 모든 숫자 포매팅 컴포넌트의 첫 줄
if (!Number.isFinite(value)) return <span>--:--</span>;
seed 스크립트에 회원·레벨별 최소치 검증을 묶는다
seed가 끝난 다음 verify-seed.ts에서 회원별 콘텐츠 후보 수, 레벨별 콘텐츠 풀 수를 검증해서 0건이면 에러로 떨어뜨린다. 다음 라운드 진입 전에 콘솔에서 확인할 수 있다.
// prisma/verify-seed.ts
const orphanMembers = await prisma.member.findMany({
where: { bundleContents: { none: {} } },
});
if (orphanMembers.length > 0) {
throw new Error(`콘텐츠 후보 0건 회원 ${orphanMembers.length}명: ${orphanMembers.map(m => m.loginId).join(', ')}`);
}
응답 누락이 시각적으로 드러나는 폴백을 둔다
--:--처럼 명시적인 폴백을 두면, 다음 라운드에서 같은 버그가 다시 터졌을 때 한눈에 보인다. 빈 문자열이나 0:00으로 폴백하면 정상처럼 보여서 다시 못 잡는다.
| 폴백 선택 | 시각 진단 | 권장 |
|---|---|---|
NaN:NaN 그대로 | 사용자에게 노출, 진단 어려움 | ❌ |
빈 문자열 / 0:00 | 정상처럼 보임, 다음 라운드에서 못 잡음 | ❌ |
--:-- 또는 명시적 메시지 | 한눈에 데이터 누락임을 안다 | ✅ |
📌 정리
| 항목 | 내용 |
|---|---|
| 표면 증상 | 학습 클라이언트 번들 진행 화면 타이머에 NaN:NaN 고정 표시 |
| 표면 원인 1 | FE formatTime(undefined) → Math.floor(NaN) → "NaN:NaN" |
| 표면 원인 2 | BE 응답 DTO에서 remainingTimeMs 필드 누락 |
| 같이 잡힌 함정 | seed 데이터의 콘텐츠 후보 매핑 빈 배열 |
| 진짜 원인 | 응답 DTO를 컨트롤러별로 손으로 매핑 + FE 가드 부재 + seed 최소치 검증 부재 |
| 해결 커밋 | 324d060 (BE DTO 필드 + 도메인 서비스 분리 + FE Number.isFinite 가드 + seed 50건 재생성) |
| 라운드 시간 | 발견 21:00 → 수정 22:00 → 검증 22:30 (90분) |
라운드 1의 함정은 “혼자 여러 역할을 맡는 환경에서 자신의 코드가 잘 안 깨진다”는 구조적 문제였다. 라운드 2의 함정은 그보다 한 층 더 구체적이었다. 같은 도메인 값을 두 API가 부분만 노출하면, 한 화면에서는 정상이고 다른 화면에서는 빈 값이 떨어진다. 컨트롤러별로 응답을 손으로 매핑하면 이런 부분 노출이 쉽게 생긴다. DTO 클래스 + 도메인 서비스 + FE 가드 셋이 같이 깔려야 안 터진다.
다음 편에서는 라운드 5에서 잡힌 “다음 번들 자동 생성 미구현” 함정을 다룬다. 라운드 4에 같이 안 잡힌 이유와, 자동 생성 트리거를 ContentAttempt 완료 이벤트 한 곳으로 모은 패턴이다.
📚 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 권한 가드 — 목록은 막고 상세는 뚫린 날