번들 구조를 통째로 바꿔야 했던 이유
📚 교육용 풀스택 SaaS 개발기 시리즈 (23편)
기획자의 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 전면 재작성 이야기를 다룬다.
📚 교육용 풀스택 SaaS 개발기 시리즈 (23편)
- 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에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루