갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
📚 교육용 풀스택 SaaS 개발기 시리즈 (23편)
devlog-13부터 devlog-20까지, v2.0 갈아엎기 5편과 v2.1로 끌어올리기 2편, v3.0 한 단계 더 1편을 한 발 떨어져서 다시 읽는다. Block에서 Bundle로의 전환, 5-Phase로 쪼갠 마이그레이션, 같은 날 v2.1로 SSoT를 끌어올린 결정, 1,682줄을 한 커밋에 박은 도메인 레이어, 그리고 그 위에 얇은 막을 입힌 Application Service까지. Before/After가 정말로 달라진 다섯 축(코드 책임, 문서 트랙, 멘탈, 도메인 명확성, 테스트 가능성)과 80일이 지나도 안 달라진 한 축(혼자 결정하고 혼자 박는 운영 모드)을 코드 위에서 정리한다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 8편 메타 회고다. devlog-13 “번들 구조 결심”부터 devlog-20 “Application Layer 재작성”까지를 한 발 떨어져서 다시 읽고, 80일 동안 진짜로 달라진 것과 안 달라진 것을 정리한다
- 달라진 축 ① 코드 책임의 분포. v1.1 시절
LearningService한 덩어리에 박혀 있던 비즈니스 규칙·트랜잭션·DTO 매핑이 v2.1에서는 Domain Service / Application Service / Repository / DTO로 네 개의 자리에 나눠 박혔다- 달라진 축 ② 문서 트랙. devlog-18에서 v2.0 코드 닫은 같은 날 밤 24분 사이에 4,658줄짜리 DDD 문서를 v2.1로 박고 v2.0 docs는
_archived/v2.0/로 묻었다. 코드와 문서가 다른 트랙으로 흐르지 않게 하는 일이 이 시리즈의 가장 큰 외적 변화다- 달라진 축 ③ 멘탈 보호막. devlog-15 “Phase 2 직후 빌드 에러 36개”와 devlog-20 “Phase 5 직후 빌드 에러 51개”를 같은 마음 상태로 통과한 비결은 인터페이스로 경계를 박아 둔 것 — DB는 미래에 있고 코드는 과거에 있을 때 그 사이를 메우는 인터페이스 한 줄
- 달라진 축 ④ 도메인 명확성. Block(단일 콘텐츠) 시절에는 “이 규칙이 어디에 박혀야 하는가”가 매번 새 결정이었지만, Bundle(5콘텐츠 묶음)로 바꾼 뒤에는 도메인 타입 —
MetricRank TOP1~5,V21_THRESHOLDS,trackState SINGLE/MIX— 가 자리를 강제했다- 달라진 축 ⑤ 테스트 가능성. 인터페이스 분리와 도메인 서비스의 등장으로 Prisma 없이 단위 테스트가 가능한 영역이 처음으로 생겼다. v1.1 시절에는 단위 테스트가 사실상 통합 테스트였다
- 안 달라진 축 한 가지. 1인 개발 운영 모드. 결정 회의 0회, 코드 리뷰 0회, 모든 PR 셀프 머지. 80일 동안 이 부분만큼은 그대로다. 그래서 SSoT 문서·세션 아카이브·LESSONS.md 세 종류를 동시에 굴리는 비용이 그대로 남았다
- 앵글 한 줄. 갈아엎기는 코드를 바꾸는 일이 아니라 책임의 분포를 바꾸는 일이다. 80일이 지나서야 그 문장의 무게를 눈으로 본다
🗺️ 이 편의 자리 — 7편을 닫고 한 발 떨어져 보기
이 시리즈를 처음 펼친 사람을 위해 한 줄로 적으면, devlog-12에서 “v1을 통째로 갈아엎기로 결심”했고, 그 결심을 devlog-13부터 코드와 문서로 풀어 박은 8편이 바로 직전 시리즈 구간이다. 이 편(#21)은 그 8편을 위에서 내려다보는 편이다.
8편을 한 줄씩 나열하면 이렇게 된다.
| # | 한 줄 요약 |
|---|---|
| 13 | 43줄 기획 피드백이 1,800줄 문서가 되며 “Block → Bundle” 결심 |
| 14 | Phase 1 — Use Case 4개를 Bundle 기반으로 재작성, 숨은 공백 3개 발견 |
| 15 | Phase 2 — 스키마 대수술, “data will be lost” 경고 4개 무력화, 빌드 에러 36개 수용 |
| 16 | Phase 3-1·3-2 — Repository 인터페이스 + Domain Service 1,500줄 |
| 17 | Phase 3-3·3-4·3-5 — Application(953줄) + Controller+DTO(763줄) + Module(39줄)로 v2.0 닫기 |
| 18 | v2.0 코드 닫은 그날 밤 24분 사이에 4,658줄 DDD 문서를 v2.1로 끌어올림 |
| 19 | 도메인 서비스 1,682줄을 한 커밋에 박은 v2.1 Domain Layer |
| 20 | v3.0 Application Layer 4종 + Controller 4종을 한 Phase에 박고 빌드 에러 51개 정리 |
여기서 한 발 떨어지면 보이는 게 두 가지 있다. 하나는 이 8편이 세 개의 마이그레이션을 동시에 다뤘다는 것 — v1.1→v2.0(코드), v2.0→v2.1(문서·도메인), v2.1→v3.0(Application). 다른 하나는 그 세 마이그레이션이 모두 같은 결합 규약 한 줄 — Controller → Application → Domain → Repository — 을 만들어 가는 과정이었다는 것이다.

📌 핵심: 갈아엎기는 코드를 다시 쓰는 일보다 책임의 분포를 다시 그리는 일에 가깝다. 8편 동안 줄 수는 수천 줄이 들어왔다 나갔지만, 정작 시리즈가 추적한 변화는 “어느 결정이 어느 자리에 박히는가”에 대한 합의가 굳어 가는 과정이었다.
🧱 달라진 축 1 — 코드 책임의 분포
v1.1 시절 비슷한 책임은 한 덩어리에 들어 있었다. devlog-12에서 결심한 갈아엎기의 직접적 동기 중 하나도 바로 이 하나의 덩어리였다. v2.1에 도착한 지금, 같은 책임은 다섯 자리에 나눠 박힌다.
[Controller] ← HTTP 입출력만
[DTO] ← 직렬화 모양만
[Application Service] ← 트랜잭션 + 도메인 호출 순서 + 응답 조립
[Domain Service] ← 비즈니스 규칙 (재사용 가능한 결정 로직)
[Repository (Interface)] ← 도메인 모델 조회·저장 규약
└─ Prisma 구현체 ← DB 쿼리 실제 구현
이 다섯 자리 중에서 값을 모르는 곳에서 값을 만드는 일은 한 자리에서만 일어난다 — Domain Service. v2.0 Phase 3에서 박은 BundleGenerationService 417줄, v2.1로 끌어올리며 박은 도메인 타입 enum과 V21_THRESHOLDS, 그리고 v3.0에서 다시 박은 LevelAdjustmentDecisionService.evaluate(...)가 모두 같은 자리다.
이 합의를 코드로 보면 v3.0 Application Service의 한 메서드가 가장 압축적이다 — devlog-20에 발췌했던 형태를 다시 한 번 적으면.
// apps/api/src/application/services/level-adjustment.application.service.ts (발췌)
@Injectable()
export class LevelAdjustmentApplicationService {
constructor(
private readonly tx: PrismaService,
private readonly decision: LevelAdjustmentDecisionService, // 도메인
private readonly tracks: TrackManagementService, // 도메인
private readonly events: EventBus,
) {}
async adjustOnBundleComplete(input: AdjustInput) {
return this.tx.$transaction(async (tx) => {
const member = await this.tracks.loadMemberWithTracks(tx, input.memberId);
const decision = this.decision.evaluate({
accuracy: input.accuracy,
consecutiveDays: member.consecutiveDays,
trackState: member.trackState,
});
const next = this.tracks.applyDecision(member, decision);
await this.tracks.persist(tx, next);
this.events.publish(new LevelAdjustedEvent(member.id, decision));
return next;
});
}
}
여기서 “정확도 90%면 레벨업” 같은 규칙은 단 한 줄도 보이지 않는다. 그 규칙은 decision.evaluate(...) 안에 박혀 있고, Application Service는 그저 트랜잭션을 열고 도메인을 부르고 이벤트를 발행한다. v1.1 시절의 같은 책임이 한 덩어리 1,200줄짜리 클래스 안에 순서 없이 있던 것과 비교하면, 이 5~6줄짜리 메서드 모양 자체가 80일의 결과다.
⚠️ 주의: Application Service에
if한 줄을 허용하기 시작하면 한 달 뒤에 다시 1,200줄짜리 덩어리가 돌아온다. devlog-20에 이미 적었지만 이 시리즈에서 가장 자주 무너지는 규약이 이거다.
📚 달라진 축 2 — 문서 트랙이 코드 트랙과 분리되지 않는다
v2.0 직전까지 문서와 코드는 서로 다른 속도로 흘렀다. 코드가 먼저 가고 문서는 며칠 늦거나, 문서가 먼저 가고 코드는 그 문서를 부분적으로만 따라잡거나. v1 시절의 docs 디렉토리에는 합의된 적은 있지만 코드와 더는 일치하지 않는 문장이 누적돼 있었고, 그 누적이 devlog-12의 결심에 한 몫을 했다.
devlog-18이 박은 결정 한 줄로 이 흐름이 끊긴다. v2.0 코드를 닫은 같은 날 밤 24분 사이에 두 개의 docs 커밋을 박고, v2.0 시절의 DDD 문서들을 _archived/v2.0/으로 묻고, 그 자리에 4,658줄짜리 v2.1 DDD 문서를 새로 적었다. 이 결정은 단순히 문서 작업이 아니라 SSoT(Single Source of Truth)를 코드와 같은 속도로 끌고 가기로 한 운영 결정이다.
| 시점 | 문서 위치 | 코드와의 관계 |
|---|---|---|
| v1.1 | docs/use-cases/ 등 | 부분 동기화, 합의 시점에서 멈춤 |
| v2.0 코드 닫는 순간 | docs/ (v2.0 모양) | 코드와 일치, 그러나 도메인 모델 v2.0에 묶임 |
| v2.0 코드 닫고 24분 뒤 | docs/_archived/v2.0/로 이동 | 의도적으로 묻음 — “여기가 과거다”라는 신호 |
| v2.1 적용 후 | docs/ (v2.1 모양 4,658줄) | 코드와 같은 속도로 진화하기로 한 새 SSoT |
🔍 단서:
docs/_archived/v2.0/에 들어간 문서는 *“틀려서 묻은 문서”*가 아니라 *“역할이 끝나서 묻은 문서”*다. 이 차이가 새 사람(또는 미래의 나)이 docs를 읽을 때의 신호 강도를 정한다._archived안의 문장을 현재 진실로 오해하지 않게 하는 비용이 ✅ 이 디렉토리 분리 한 줄로 끝난다.
위 표는 devlog-18의 본문에서 추적한 두 커밋의 결과를 한 줄씩 옮겨 적은 것이다. 80일 회고에서 가장 외적인 변화 한 가지를 꼽으라면 문서가 코드보다 늦지 않게 된 것이다.
🛡️ 달라진 축 3 — 빌드 에러 수십 개를 같은 마음으로 통과하는 법
이 시리즈에서 빌드 에러가 대량으로 터진 시점이 두 번 있다.
| 시점 | 에러 개수 | 직전 작업 |
|---|---|---|
| devlog-15 Phase 2 직후 | 36개 | Prisma 스키마 대수술 직후 |
| devlog-20 Phase 5 직후 | 51개 | Application Service + Controller + DTO 한 Phase에 박은 직후 |
흥미로운 건 두 시점에서 마음 상태가 거의 같았다는 것이다. devlog-15 본문에서 적었듯 “DB는 미래에 있고 코드는 과거에 있을 때” 그 사이를 메우는 두 가지 도구가 있다.
❌ Before — 인터페이스 없이 직접 의존
// v1.1 시절의 흔한 패턴 (재구성)
class LearningService {
constructor(private readonly prisma: PrismaService) {}
async assignNext(memberId: number) {
const member = await this.prisma.member.findUnique({
where: { id: memberId },
include: { currentBundle: { include: { contents: true } } },
});
if (member.metricA > 0.9) { /* ... */ }
if (member.consecutiveDays >= 5) { /* ... */ }
if (member.currentBundle.contents.length < 5) { /* ... */ }
await this.prisma.bundle.update({ /* ... */ });
return { /* DTO 모양 */ };
}
}
✅ After — 인터페이스로 경계를 박는다
// v2.1 / v3.0 모양
interface BundleRepository {
loadWithContents(memberId: number, tx?: PrismaTx): Promise<BundleAggregate>;
persist(bundle: BundleAggregate, tx?: PrismaTx): Promise<void>;
}
class BundleApplicationService {
constructor(
private readonly bundles: BundleRepository, // 인터페이스
private readonly generation: BundleGenerationService, // 도메인
private readonly tx: PrismaService,
) {}
// ... (도메인 호출 + 트랜잭션만)
}
devlog-16이 박은 BundleRepository 인터페이스 204줄과 그 Prisma 구현체 294줄이 정확히 이 역할을 한다. Prisma 구현체가 빌드되지 않아도 도메인과 Application 코드는 컴파일이 통과한다는 사실 한 줄이 빌드 에러 수십 개 앞에서 멘탈을 지킨다.
💡 인사이트: 멘탈 보호막은 *“에러가 없는 상태”*가 아니라 *“에러의 위치가 예측되는 상태”*다. 36개와 51개라는 에러 수 자체보다 “이건 Prisma 구현체 빌드 에러다” / *“이건 DTO 매핑 에러다”*라는 카테고리화가 더 큰 차이를 만들었다. devlog-20에서 51개를 다섯 카테고리로 나눠 다섯 시간 안에 닫은 흐름이 그대로 같은 패턴이다.
🧬 달라진 축 4 — 도메인 타입이 자리를 강제한다
v1.1 시절에는 *“이 enum 값이 어디에 살아야 하는가”*가 매번 새 결정이었다. 누가 STATUS = 'IN_PROGRESS'를 넣고 누가 LEVEL_UP_THRESHOLD = 0.9를 박는가 — 그 답이 코드 베이스 곳곳에 흩어져 있었다.
devlog-19에서 한 커밋에 1,682줄을 박을 때, 그 커밋의 첫 437줄이 domain/types.ts였다. 핵심은 줄 수가 아니라 그 자리가 도메인 타입의 정식 거주지로 박혔다는 것이다.
// apps/api/src/domain/types.ts (발췌)
export type MetricRank = 'TOP1' | 'TOP2' | 'TOP3' | 'TOP4' | 'TOP5';
export const V21_THRESHOLDS = {
LEVEL_UP_ACCURACY: 0.9,
LEVEL_DOWN_ACCURACY: 0.7,
CONSECUTIVE_DAYS_FOR_BUMP: 5,
REVIEW_COVERAGE_REQUIRED: 1.0, // 90% 이상도 100% 복습 필수로 바뀐 결정
} as const;
export type TrackState = 'SINGLE' | 'MIX';
이 파일이 박힌 뒤로 *“임계값을 어디에 두는가”*는 더 이상 결정이 아니다. 항상 domain/types.ts다. v3.0에서 새로 도입한 trackState SINGLE/MIX도 같은 자리에 추가됐다 — devlog-20의 Application Service가 그 enum을 읽기만 한다는 사실이 이 합의의 결과다.
📊 데이터: v2.1에서 박은 5개 도메인 서비스 —
LevelAdjustmentDecisionService,TrackManagementService,BundleGenerationService,ContentCandidateService,ReviewModuleService— 가 모두domain/types.ts의 같은 enum을 import한다. 같은 결정을 다섯 곳에서 다르게 적지 않는 보장이 enum 한 자리에서 나온다.
🧪 달라진 축 5 — 도메인은 Prisma 없이 테스트 가능하다
devlog-09에서 박은 Jest 인프라가 처음 진짜로 빛을 본 건 v2.1 도메인 레이어다. v1.1 시절의 단위 테스트는 Prisma 모킹이 필수였다 — LearningService.assignNext(...)를 테스트하려면 prisma.member.findUnique의 반환을 모킹해야 했고, 그 모킹이 깨지면 테스트도 깨졌다.
v2.1에 박힌 도메인 서비스는 Prisma를 모르는 함수다. LevelAdjustmentDecisionService.evaluate({ accuracy, consecutiveDays, trackState })는 입력 객체 하나만 받고 결정 객체 하나를 돌려준다. 이런 함수 모양이 단위 테스트의 정의에 가깝다.
// 단위 테스트 — Prisma 모킹 없음
describe('LevelAdjustmentDecisionService.evaluate', () => {
it('정확도 90% 이상이면 레벨업 후보', () => {
const result = sut.evaluate({
accuracy: 0.91,
consecutiveDays: 1,
trackState: 'SINGLE',
});
expect(result.kind).toBe('LEVEL_UP_CANDIDATE');
});
it('정확도 70% 미만이면 레벨다운', () => {
// ...
});
});
📌 핵심: 테스트가 빨라진다는 건 부수 효과다. 진짜 변화는 테스트할 가치가 있는 단위가 명시적으로 등장했다는 것이다. v1.1 시절에는 “단위”라는 단어가 사실상 통합 테스트와 같은 뜻이었다.
🪞 안 달라진 축 — 1인 결정 모드
여기서부터 솔직해져야 한다. 80일 동안 안 달라진 한 축이 있다 — 결정 회의 0회, 코드 리뷰 0회, 모든 PR 셀프 머지. 이 운영 모드 자체는 v1 시절과 똑같이 굴러갔다.
이 사실이 만든 비용이 두 가지 있다.
- 합의 비용을 자기 자신과 한다. devlog-13에서 박은 1,800줄짜리 마스터 문서, devlog-18에서 박은 4,658줄짜리 DDD 문서, 그리고 LESSONS.md 7개 — 이 문서들은 외부 합의를 위해 적힌 게 아니라 미래의 자기 자신과 합의하기 위해 적힌 것이다. 외부 코드 리뷰의 빈자리를 SSoT 문서가 메운다
- 세션 아카이브가 자기 코드 리뷰의 대체재가 된다. 매 세션의 결정과 그 결정의 이유가 적히고, 그 다음 세션이 그 기록을 읽고 시작한다. 이 흐름은 코드 리뷰의 형식을 흉내 내지만 외부 시각의 내용까지는 못 채운다
⚠️ 주의: 1인 운영 모드는 나쁜 모드가 아니지만, 이 시리즈가 만든 다섯 축의 변화가 어느 정도는 그 모드의 부작용을 보완하기 위한 보호막이라는 점을 80일이 지나서야 본다. 인터페이스 분리도, 도메인 타입의 단일 거주지도, _archived 디렉토리도, 모두 외부 시각이 없는 환경에서 자기 자신을 속이지 않게 만드는 장치다.
📋 정리 — 80일 동안 변한 것과 변하지 않은 것
| 축 | Before (v1.1) | After (v2.1 / v3.0) | 결정이 박힌 편 |
|---|---|---|---|
| 코드 책임 분포 | 단일 서비스 덩어리 | Domain·Application·Repository·DTO 4분할 | 16, 17, 19, 20 |
| 문서 트랙 | 코드보다 늦거나 부분 동기화 | 같은 날 같은 속도, _archived/로 과거 분리 | 18 |
| 빌드 에러 대응 | 위치 예측 불가, 멘탈 직격 | 카테고리화로 다섯 시간 안에 정리 | 15, 20 |
| 도메인 명확성 | enum·임계값 곳곳에 흩어짐 | domain/types.ts 단일 거주지 | 19 |
| 테스트 가능성 | Prisma 모킹 없이는 단위 테스트 불가 | 도메인 서비스가 순수 함수 모양 | 19, 20 |
| 운영 모드 (안 변함) | 1인 결정·셀프 머지 | 1인 결정·셀프 머지 | — |
이 표를 읽고 가장 무거운 줄은 마지막 줄이다. 갈아엎기는 코드의 모양을 바꿨지만, 결정의 사회적 구조는 바꾸지 못했다. 그래서 다섯 축의 변화가 진짜로 의미를 가지려면 문서·아카이브·인터페이스 같은 장치들이 외부 시각의 빈자리를 얼마나 잘 메우는가가 다음 시리즈의 질문이 된다.
💡 인사이트: 갈아엎고 80일이 지나서야 한 가지 문장을 정리할 수 있다 — 코드를 다시 쓰는 일은 책임의 분포를 다시 그리는 일이고, 그 분포가 의미를 가지려면 결정의 흐름이 그 분포 위에서 굴러가야 한다. 결정의 흐름까지 같이 바꾸는 일은 이 시리즈의 다음 챕터다.
📚 참고
이 글은 새 코드보다 기존 8편의 회고가 중심이지만, 인용한 결정들은 모두 다음 공식 문서들의 권장 패턴 위에 서 있다.
📚 교육용 풀스택 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에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루