갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고

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편을 한 줄씩 나열하면 이렇게 된다.

#한 줄 요약
1343줄 기획 피드백이 1,800줄 문서가 되며 “Block → Bundle” 결심
14Phase 1 — Use Case 4개를 Bundle 기반으로 재작성, 숨은 공백 3개 발견
15Phase 2 — 스키마 대수술, “data will be lost” 경고 4개 무력화, 빌드 에러 36개 수용
16Phase 3-1·3-2 — Repository 인터페이스 + Domain Service 1,500줄
17Phase 3-3·3-4·3-5 — Application(953줄) + Controller+DTO(763줄) + Module(39줄)로 v2.0 닫기
18v2.0 코드 닫은 그날 밤 24분 사이에 4,658줄 DDD 문서를 v2.1로 끌어올림
19도메인 서비스 1,682줄을 한 커밋에 박은 v2.1 Domain Layer
20v3.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 — 을 만들어 가는 과정이었다는 것이다.

v1.1과 v2.1의 코드 책임 분포 비교 도식 — 단일 서비스 덩어리에서 Domain·Application·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.1docs/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. 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
  2. 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
  3. 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
  4. 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
  5. 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
  6. 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
  7. 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
  8. 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
  9. 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
  10. 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
  11. 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
  12. 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
  13. 13. 번들 구조를 통째로 바꿔야 했던 이유
  14. 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
  15. 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
  16. 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
  17. 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
  18. 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
  19. 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
  20. 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
  21. 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
  22. 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
  23. 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루