번들 구조를 통째로 바꿔야 했던 이유
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (63편)
기획자의 43줄짜리 검토 내용이 마스터 문서 1,013줄, 마이그레이션 로드맵 441줄, CLAUDE.md 307줄로 풀어졌다. Block에서 Bundle로, 레벨링 모듈 설계, 그리고 AI 지식 전승 문서를 처음 만든 이야기.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 기획자의 43줄 피드백이 1,800줄+ 문서로 전개됐다. 마스터 문서 1,013줄 + 로드맵 441줄 + CLAUDE.md 307줄 + PRD 재작성
- “블록을 번들로 바꿔”가 아니라, 도메인 모델 자체가 재설계됐다. 가변 개수 → 5개 고정, 레벨링 모듈이라는 새 메커니즘 추가
- 기획 피드백에서 빠진 디테일을 찾아내는 게 핵심이었다. 첫 번들은? L1이면? 타이머 만료 시? 0개 완료면?
- CLAUDE.md를 처음 만들었다. v2.0 전환을 계기로 AI 세션 간 지식 전승 문서가 필요해졌다
- 3개 커밋, 20분 — 문서가 코드보다 먼저였다. 코드 한 줄 안 바꾸고 1,800줄 문서를 먼저 완성한 하루
📄 기획자의 43줄 — 생각보다 짧았다
이전 편에서 v1.0을 갈아엎기로 결심하는 데까지 이야기했다. 이번 편은 그 결심 이후, 실제로 무엇을 바꿔야 하는지를 정의하는 과정이다.
기획자에게 받은 원본 피드백은 딱 43줄이었다.
변경 내역
- 블록이라는 명칭이 번들로 변경됨.
- 번들은 5개의 콘텐츠로 구성됨
- 콘텐츠1 (가장 수치가 높은 지표)
- 콘텐츠2 (평균 지표)
- 콘텐츠3 (가장 수치가 낮은 지표)
- 콘텐츠4 (가장 수치가 낮은 지표 + *레벨링 모듈 결과값)
- 콘텐츠5 (가장 수치가 낮은 지표 + *레벨링 모듈 결과값)
- 레벨링 모듈
- 번들 풀이 과정에서 1-3번 문제의 정확도를 바탕으로
일시적으로 학생레벨(=문제 난이도)를 낮춤
문서 하단에 이런 문장이 있었다.
“기존문서에 반영하는 경우 전파사항이 많아서 디테일을 놓치는 우려사항이 있음”
기획자도 알고 있었다. 이건 패치가 아니라 재작성이 필요한 수준이라는 걸.
📌 핵심: 기획 피드백이 짧다고 변경 범위가 작은 게 아니다. 43줄 안에 도메인 모델 재설계, 새 알고리즘 도입, 타이머 로직 변경이 전부 들어있었다. 문서의 길이와 영향 범위는 비례하지 않는다.
🔬 빠진 디테일을 찾아라 — 4가지 엣지케이스
43줄을 받고 가장 먼저 한 일은 “써있지 않은 것”을 찾는 거였다. 기획 피드백에는 정상 흐름만 있었다. 엣지케이스는 내가 찾아야 했다.
1. 첫 번들은 어떻게 구성하나?
5개 콘텐츠는 강점/약점/평균 지표 기반으로 선택된다. 그런데 처음 접속한 유저에게는 지표 데이터가 없다. 진단평가(온보딩) 직후에는 5개 지표가 전부 동일한 초기값이다.
// 첫 번들: 지표 구분 불가
강점 = 약점 = 평균 = 초기값
→ 해결: 5개 지표에 균등 배분 후 랜덤 선택
2. L1(최하위 레벨)에서 레벨링 모듈이 작동하면?
레벨링 모듈은 정답률이 낮으면 레벨을 내리는 로직이다. 그런데 이미 최하위 레벨(L1)인 유저는 더 내릴 곳이 없다.
// L1에서 정답률 30% → 연관 하위 레벨(lowerLevelId)?
// L1.lowerLevelId = NULL
→ 해결: L1이면 레벨 조정 스킵 (L1 유지)
3. 타이머 만료 시 진행 중인 콘텐츠는?
30분 타이머가 끝났을 때 유저가 콘텐츠를 플레이하고 있다면?
// 타이머 만료 + 콘텐츠 진행 중
// 선택지 A: 즉시 강제 종료
// 선택지 B: 현재 콘텐츠까지는 허용
→ 해결: 팝업으로 종료/계속 선택.
현재 콘텐츠만 완료 허용, 다음 콘텐츠는 시작 불가
4. 번들 0개 완료 시 어떻게 처리하나?
30분 안에 번들 하나도 완성 못 한 경우. 미완료 번들은 지표에 미반영이라고 했는데, 그러면 이 유저의 하루 학습은 아무 흔적도 없는 건가?
// 완료 번들 = 0, 미완료 번들만 존재
// 지표 미반영이니 이 날은 SKIP?
→ 해결: POOR 처리. 지표는 미반영이지만
레벨 조정에는 반영 (POOR 3일 연속 시 레벨 하향)
+ 연속일수 카운터에도 반영
이 4가지를 기획자에게 역질문하고, 확인된 내용을 v2.0 마스터 문서에 반영했다.
⚠️ 주의: 엣지케이스는 기획자가 알려주는 게 아니다. 개발자가 찾아내는 거다. 기획은 “정상적으로 쓸 때”를 설계하고, 개발은 “비정상적으로 쓸 때”를 대비한다. 이 4개만 찾아낸 게 아니라, 마스터 문서 작성 중에 수십 개의 소소한 결정사항들이 쏟아졌다.
📊 v1.0 Block vs v2.0 Bundle — 기술적 비교
이전 편에서 Block → Bundle 변경을 개괄적으로 다뤘다. 여기서는 왜 그 구조여야 했는지 기술적 이유를 파본다.
v1.0의 Block: 범용적이었지만 의미가 없었다
// v1.0 — Block은 "그냥 학습 단위"
model Block {
id BigInt @id @default(autoincrement())
assignmentId BigInt
seq Int // 순번만 있을 뿐
blockType BlockType // CUSTOMIZED | REMIND
targetMetricCode MetricCode?
selectedContentId Int
status BlockStatus
...
}
Block 모델의 문제는 의미론적 빈약함이었다. blockType이 CUSTOMIZED 아니면 REMIND, 두 가지뿐이다. “이 블록이 강점을 강화하는 건지, 약점을 보강하는 건지”는 targetMetricCode를 보고 추론해야 했다. 코드를 읽는 사람이 머릿속으로 조합해야 하는 정보가 많았다.
게다가 Block의 개수가 가변이었다. 맞춤 3개마다 리마인드 1개가 끼어드는 패턴이라, 과제당 Block이 몇 개 생길지 미리 알 수 없었다.
// v1.0 — 블록 생성 패턴
if (completedCustomizedCount % 3 === 0) {
blockType = REMIND // 맞춤 3개 → 리마인드 1개
} else {
blockType = CUSTOMIZED
}
// 4개? 7개? 10개? 러닝타임 끝날 때까지 계속 생성
v2.0의 Bundle: 역할이 고정된 5콘텐츠
// v2.0 — Bundle의 각 콘텐츠는 역할이 명확하다
model BundleContent {
id BigInt @id @default(autoincrement())
bundleId BigInt
seq Int // 1~5 고정
metricType BundleMetricType // STRENGTH | WEAKNESS | AVERAGE
contentId Int
levelId Int // 레벨링 모듈로 조정 가능
...
}
seq가 1이면 강점, 23이면 약점, 45면 평균 + 레벨 조정. 코드를 읽는 순간 “이 콘텐츠가 왜 여기 있는지” 바로 알 수 있다.
개수도 항상 5개다. 번들 하나의 크기가 예측 가능하니까, 타이머 로직도 단순해진다. “30분 안에 번들 몇 개를 완성할 수 있느냐”로 계산하면 된다.
| 비교 | v1.0 Block | v2.0 Bundle |
|---|---|---|
| 단위 크기 | 가변 (러닝타임까지 계속 생성) | 5개 고정 |
| 콘텐츠 역할 | 암묵적 (MetricCode로 추론) | 명시적 (STRENGTH/WEAKNESS/AVERAGE) |
| 타입 | CUSTOMIZED / REMIND | 타입 없음 (역할이 순번에 바인딩) |
| 번들 내 난이도 조정 | 없음 | 레벨링 모듈 (4~5번 동적 조정) |
| 미완료 처리 | 없음 | 지표 미반영 + POOR 판정 |
📌 핵심: v1.0 Block은 “학습 단위”라는 범용 컨테이너였다. v2.0 Bundle은 “강점 1 + 약점 2 + 평균 2”라는 교육학적 의도가 구조에 녹아든 모델이다. 코드가 도메인 언어를 반영하면 “왜 이렇게 됐지?”를 묻는 일이 줄어든다.
🧮 레벨링 모듈 — 번들 안의 미니 피드백 루프
v2.0에서 가장 흥미로운 신규 메커니즘이다. 기존에는 번들(블록) 전체를 끝낸 뒤에 지표를 갱신했는데, v2.0은 번들 진행 중에 난이도가 바뀐다.

작동 순서
1. 콘텐츠 1~3 완료 → 정답률 평균 계산
2. 평균 정답률로 레벨 조정 판정
├─ 90%+ → 레벨 유지 (잘하고 있음)
├─ 40~89% → 1 ~ (현재레벨-1) 중 랜덤 하향
└─ 0~39% → 연관 하위 레벨(lowerLevelId)로 직행
3. 조정된 레벨로 콘텐츠 4 플레이
4. 콘텐츠 4 단독 정답률로 콘텐츠 5 레벨 재조정
└─ 같은 구간 로직 적용
5. 조정된 레벨로 콘텐츠 5 플레이
왜 1~3번 평균이고, 4번은 단독인가?
처음에 “왜 통일하지 않지?”라고 생각했다. 하지만 이유가 있었다.
1~3번은 진단 구간이다. 오늘 이 유저가 어떤 컨디션인지 샘플링하는 3문제. 3개의 평균이니까 한 문제 실수해도 보정된다.
4번은 적응 구간이다. 1~3번 결과로 난이도를 한 번 조정한 뒤, 그 조정이 적절했는지 4번 하나로 확인한다. 여전히 못 풀면 5번은 더 내린다. 적절하면 유지한다.
1~3번: "오늘 이 유저의 실력을 측정" (3개 평균 = 노이즈 감소)
4번: "조정한 난이도가 맞는지 검증" (1개 단독 = 즉시 피드백)
5번: "최종 조정된 난이도로 학습" (적응 완료)
🔍 단서: 이 구조는 교육학에서 말하는 Zone of Proximal Development(근접 발달 영역) 개념과 맞닿아 있다. 너무 쉬운 것도, 너무 어려운 것도 아닌, “조금만 도와주면 풀 수 있는” 난이도를 실시간으로 찾아가는 메커니즘이다.
40~89% 구간의 “랜덤 하향”
이 부분이 처음에 직관적이지 않았다. 왜 특정 레벨로 고정하지 않고 랜덤인가?
현재 레벨: L15, 정답률: 60%
→ L1~L14 중 랜덤
Q: 왜 L14(한 단계 아래)로 고정하지 않는가?
A: 40~89%는 "확실히 모르는 건 아닌데 불안정한" 상태.
고정 하향은 너무 예측 가능 — 유저가 패턴을 학습함.
랜덤 하향은 다양한 레벨의 문제를 노출시켜
"어디까지는 확실히 아는지" 추가 데이터를 수집하는 효과.
이 설계 의도까지 마스터 문서에 명시해뒀다. 나중에 다른 개발자(또는 미래의 나)가 “이거 버그 아닌가?”라고 오해하지 않도록.
📝 1,013줄의 마스터 문서 — 43줄에서 여기까지
기획 피드백 43줄을 가지고, v2.0 마스터 문서를 처음부터 새로 작성했다. 기존 v1.1 문서에 패치하지 않은 이유는 기획자가 이미 말해줬다.
“기존문서에 반영하는 경우 전파사항이 많아서 디테일을 놓치는 우려사항이 있음”
v1.1 마스터 문서에서 “Block”을 “Bundle”로 찾아바꾸기한다고 끝나는 게 아니다. 번들의 5콘텐츠 구조, 레벨링 모듈, 타이머 로직, 미완료 처리 — 이것들이 기존 비즈니스 규칙 곳곳에 영향을 미친다. 한두 군데 빠뜨리면 문서 내에서 모순이 생기고, 그 모순 위에 코드를 짜면 버그가 된다.
그래서 v2.0은 빈 문서에서 시작했다.
마스터 문서 구조
docs/00_프로젝트_마스터_v2.0.md (1,013줄)
├── 버전 변경 이력 (8개 변경사항 상세)
├── 프로젝트 개요 (목적, 핵심 가치, MVP 범위)
├── 핵심 개념 정리 (레벨, 콘텐츠, 번들, 과제...)
├── 주요 액터와 권한
├── 핵심 프로세스 플로우
├── 데이터 모델 핵심 구조
├── 주요 비즈니스 규칙
├── 의사결정 사항
├── 설계 개선 사항 (확정)
└── 참조 문서
v1.1이 ~800줄이었으니 200줄 이상 늘었다. 대부분 레벨링 모듈과 번들 구조의 엣지케이스 처리 명세였다.
같은 날, 3개 커밋
이 모든 문서 작업이 같은 날 20분 안에 커밋됐다.
| 시각 | 커밋 | 내용 | 줄 수 |
|---|---|---|---|
| 20:21 | #53 | 마스터 문서 v2.0 + 기획 검토 원본 | +1,053 |
| 20:29 | #54 | v2.0 마이그레이션 로드맵 | +441 |
| 20:41 | #55 | CLAUDE.md + PRD v2.0 재작성 | +1,090 |
20분 만에 2,584줄. Claude Code와 페어로 작업했기 때문에 가능한 속도였다. 내가 엣지케이스를 질문하면, Claude가 문서 구조를 잡고, 내가 검토하고 확정하는 사이클.
📌 핵심: 코드 한 줄 안 바꿨다. 스키마도 안 건드렸다. 문서만 1,800줄+ 작성한 하루다. Phase 1(문서 정비)을 코드 변경 전에 완료하는 게 마이그레이션 로드맵의 첫 단계였다.
🤖 CLAUDE.md의 탄생 — AI 지식 전승
커밋 #55에서 이 프로젝트의 CLAUDE.md가 처음 등장했다. 307줄짜리 AI 지식 전승 문서.
왜 이 시점에 만들었느냐면, v2.0 전환이 컨텍스트 단절을 만들었기 때문이다.
v1.0까지는 마스터 문서 하나만 읽으면 프로젝트를 이해할 수 있었다. 그런데 v2.0으로 전환하면서 문서가 여러 개로 쪼개졌다.
읽어야 하는 문서 (v1.0):
└── docs/00_프로젝트_마스터_v1.1.md ← 이것만 읽으면 됨
읽어야 하는 문서 (v2.0):
├── docs/00_프로젝트_마스터_v2.0.md ← SSoT
├── docs/V2_MIGRATION_ROADMAP.md ← 마이그레이션 순서
├── docs/domain/*.md ← DDD 설계 (v2.0 업데이트 필요)
├── prisma/schema.prisma ← 스키마 (v2.0 마이그레이션 필요)
└── docs/_archived/* ← v1.1 참조용
새 세션을 시작할 때마다 “어떤 문서를 어떤 순서로 읽어야 하는지”부터 설명해야 했다. CLAUDE.md는 그 진입점 역할을 했다.
# CLAUDE.md 핵심 구조
## 1. 프로젝트 개요 (목적, 기술 스택, 핵심 가치)
## 2. 필수 참조 문서 (우선순위 + 현재 상태)
## 3. v2.0 핵심 개념 (번들, 레벨링 모듈, 신기록 도전)
## 4. 프로젝트 구조 (디렉토리 맵)
## 5. 작업 가이드 (DDD 규칙, 테스트 규칙)
“마스터 문서를 먼저 읽으세요”라는 한 줄이 CLAUDE.md에 있는 것만으로도, 새 세션에서의 삽질이 크게 줄었다.
🔍 단서: CLAUDE.md는 “코드를 읽기 전에 읽는 문서”다. README.md가 외부 사용자를 위한 문서라면, CLAUDE.md는 프로젝트 내부에서 작업하는 AI(혹은 새 팀원)를 위한 온보딩 문서다. v2.0 전환처럼 프로젝트 구조가 크게 바뀌는 시점에서 특히 가치가 높다.
📋 정리 — 핵심 요약
43줄의 기획 피드백을 받고, 코드는 한 줄도 안 바꾸고, 문서 1,800줄+을 작성한 하루였다. 문서가 코드보다 먼저라는 원칙을 실천한 날.
| 산출물 | 줄 수 | 역할 |
|---|---|---|
| 마스터 문서 v2.0 | 1,013 | SSoT — 모든 비즈니스 규칙의 기준 |
| 마이그레이션 로드맵 | 441 | 5-Phase 실행 계획 |
| CLAUDE.md | 307 | AI 세션 간 지식 전승 |
| PRD v2.0 | ~360 | Phase별 작업 정의 + 수용 기준 |
| 상황 | 안티패턴 | 권장 패턴 |
|---|---|---|
| 기획 피드백 수신 | ❌ 바로 코드 수정 시작 | ✅ 엣지케이스부터 찾아서 기획 확정 |
| 문서 전환 | ❌ 기존 문서에 패치 (전파 누락 위험) | ✅ 빈 문서에서 재작성 (일관성 보장) |
| 엣지케이스 | ❌ “기획에 없으니까 나중에” | ✅ 개발자가 선제 발굴 → 기획 확정 → 문서 반영 |
| 프로젝트 구조 변경 | ❌ 구두 설명에 의존 | ✅ CLAUDE.md로 진입점 문서화 |
| AI 협업 | ❌ 코드만 짜달라고 요청 | ✅ 문서 구조화를 먼저 협업 → 코드는 그다음 |
다음 편에서는 5-Phase 마이그레이션의 첫 삽 — Phase 1 문서 정비와 Use Case 전면 재작성 이야기를 다룬다.
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (63편)
- 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 권한 가드 — 목록은 막고 상세는 뚫린 날
- 47. 콘텐츠 후보 선택 3차 최적화 — 단일 쿼리로 옮기기
- 48. 재화 시스템 첫 머지 — 코인 지갑과 거래 원장(Wallet API)
- 49. 회원 레포트 5탭 API 설계 — 인사이트 3파트 구조
- 50. 보호자 외부 뷰어 대시보드 — 모바일 앱·초대 토큰 회원가입
- 51. 외부 뷰어 리포트 v1→v2 토큰 전환 — 가장 길었던 하루
- 52. 외부 뷰어 리포트 인사이트 — 활동 데이터를 자연어로 바꾸기
- 53. Framer Motion whileInView — 일부 카드만 안 뜨던 날
- 54. 외부 뷰어 리포트 4탭 N+1 — 14초 응답을 2초로
- 55. Cloud SQL 리전 트랩 — US→Taiwan 71% 트러블슈팅
- 56. QR 배치고사 + Firebase Hosting 멀티 사이트 배포
- 57. 1,974줄 풀 백업 — 1인 개발에서 상태 관리하는 법
- 58. 주간 출석 KST 타임존 — 월요일이 사라진 트러블슈팅
- 59. 연락처 포맷 통일 — 저장은 숫자만, 표시는 하이픈
- 60. react-hook-form + Zod 폼 표준 정착기
- 61. Soft Delete 구현 — deletedAt 한 컬럼이 닿은 27곳의 설계
- 62. 교육과정 자동 승급의 늪 — 도메인 버그 3 건 트러블슈팅
- 63. 교육과정 도메인 BE 완성과 같은 날 핫픽스 7 건 — NestJS @Cron 2 중 실행 묶음