Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기

v2.0 Phase 2는 self-reference를 배열로 바꾸고, enum 두 개를 추가하고, NOT NULL을 nullable로 푸는 스키마 대수술이었다. Prisma migrate가 자동 생성한 SQL의 'data will be lost' 경고 4개를 어떻게 무력화했는지, 그리고 마이그레이션 직후 36개 빌드 에러가 났는데도 왜 멘탈이 멀쩡했는지의 기록.


💡 Tip. 바쁜 현대인들을 위한 본문 요약

  • Prisma migrate dev가 뱉은 SQL은 그대로 쓰면 안 된다. 자동 생성된 마이그레이션의 머리에 data will be lost 경고가 4건 박혀 있었다
  • self-reference를 배열로 바꾸려면 두 단계가 필요하다. 컬럼만 갈아끼우면 기존 부모/자식 관계가 통째로 증발한다
  • 새 컬럼은 무조건 default + nullable로 시작한다. 그래야 기존 row를 깨지 않고 스키마만 먼저 도착시킬 수 있다
  • NOT NULL을 nullable로 푸는 건 후퇴가 아니다. “정책이 바뀌었으니 일부 row는 비어 있어도 된다”는 명시적 선언이고, 기존 데이터의 안식처가 된다
  • 마이그레이션 직후 36개 빌드 에러는 정상이다. Phase 2는 DB만 옮긴 단계고, 코드가 깨지는 건 다음 Phase의 일이다

🗺️ Phase 2의 자리 — “동결된 규칙을 DB로 새긴다”

지난 편에서 v2.0 5-Phase 중 Phase 1을 닫았다. Use Case 4개를 Bundle 기반으로 다시 쓰고, 비즈니스 규칙 표를 마무리하면서 “이게 우리 도메인의 SSoT”라는 합의를 만들었다. 그 다음 칸이 Phase 2다.

직접 정리한 Phase 2 스키마 마이그레이션 작업 흐름도
직접 정리한 Phase 2 스키마 마이그레이션 작업 흐름도

Phase 2의 한 줄 정의는 이렇다.

📌 핵심: Phase 1에서 동결된 비즈니스 규칙을 Prisma Schema에 그대로 새기는 단계. 코드(Repository/Domain/Application)는 건드리지 않는다. DB와 schema 파일만.

코드가 멀쩡한데 DB만 바꾸면 빌드는 당연히 깨진다. 깨지는 게 정상이다. 뒤에서 자세히 풀겠지만 마이그레이션 직후 빌드 에러가 36개 떴고, 그게 의도된 신호였다.

이번 편이 다루는 커밋 한 줄

e0d379c — feat: v3.0 Phase 1 스키마 마이그레이션 완료
- Level: upperLevelIds[], lowerLevelIds[], levelType, semester 추가
- Member: trackState, secondLevelId, track2 필드들 추가
- Curriculum: targetLevelIds[], currentTargetIdx 추가
- Class: grade, semester 추가
- DiagnosticSession: placedLevelId nullable, resultMetrics 추가
- 시드 데이터 31개 등급 (MIX: 2, SINGLE_NODE: 6)
Phase 2-5 Application Layer 업데이트 필요 (36 빌드 에러)

⚠️ 주의: 코드명이 두 개다. PRD 문서에서는 “v2.0”, 스키마 마이그레이션 폴더명에는 “v3.0”으로 찍혀 있다. 같은 작업의 다른 라벨이라 그러려니 한다. 글에서는 PRD 라벨인 v2.0을 쓴다.


🔍 v1.x 스키마의 무엇을 바꿔야 했나

작업 시작 전에 schema.prisma 933줄을 한 번 통독했다. 통독하면서 “이건 못 버틴다” 싶었던 자리가 세 군데였다.

1. self-reference로는 분기가 안 그려진다

v1.x의 Level 모델은 트리 구조를 다음과 같이 표현했다.

// ❌ Before (v1.x)
model Level {
  id Int @id @default(autoincrement())

  upperLevelId Int? // 상위 등급 (NULL = 최상위)
  lowerLevelId Int? // 하위 등급 (NULL = 최하위)

  upperLevel Level? @relation("UpperLevel", fields: [upperLevelId], references: [id], onDelete: SetNull)
  lowerLevel Level? @relation("LowerLevel", fields: [lowerLevelId], references: [id], onDelete: SetNull)

  lowerOfLevel Level[] @relation("UpperLevel")
  upperOfLevel Level[] @relation("LowerLevel")
}

각 등급이 위/아래로 정확히 하나씩만 연결된다. 1자형 사슬이다. 그런데 Phase 1에서 동결된 새 규칙은 분기점을 요구했다.

        L25 (MIX_LEVEL)
        /     \
      L26     L27   ← 두 갈래
        \     /
        L28 (재합류)

upperLevelId Int?로는 한 부모만 가리킬 수 있다. 두 갈래에서 합류하는 L28을 표현할 방법이 자연스럽지 않다. 정수 컬럼을 살리고 별도 join 테이블을 추가하면 가능하지만, 그러면 Level.upperLevels를 한 번에 읽으려고 매번 join을 두 번 해야 한다.

2. 단일 진행 경로 가정이 박혀 있었다

Member 모델(코드상 Student로 두는 그 도메인 모델)에는 “현재 등급” 컬럼이 하나뿐이었다.

// ❌ Before (v1.x)
model Member {
  currentLevelId           Int?
  poorConsecutiveDays      Int  @default(0)
  excellentConsecutiveDays Int  @default(0)
  // ... 단일 트랙용 필드들
}

새 규칙에서는 한 사용자가 동시에 두 트랙을 진행할 수 있다(MIX 상태). 그러면 트랙 1, 트랙 2 각각의 등급, 각각의 누적 카운터가 필요하다. 컬럼을 새로 더하는 거야 어렵지 않은데, 문제는 기존에 단일 트랙으로 잘 돌아가던 row들을 어떻게 안 깨고 옮기느냐였다.

3. 진단 결과는 등급 배치를 강제했다

DiagnosticSessionplacedLevelId Int가 NOT NULL로 박혀 있었다.

// ❌ Before (v1.x)
model DiagnosticSession {
  score         Float
  placedLevelId Int   // 배치된 등급 — NOT NULL
}

v2.0에서는 진단(코드상 진단평가, 도메인 라벨로는 “배치고사”)이 등급을 직접 배치하지 않는다. 지표 점수만 측정하고, 등급은 운영자가 만든 학습 경로(Curriculum)가 결정한다. 그러면 placedLevelId는 사라져야 마땅한데, 이미 쌓인 진단 row 수만 별 다섯 자리였다. 컬럼을 drop하면 그 row의 “어떤 등급으로 배치됐었는지”라는 역사가 통째로 증발한다.

🔍 단서: 새 컬럼 추가는 쉽다. 어려운 건 기존 컬럼이 사라지는 자리다. 데이터 손실 0건을 목표로 잡는 순간, 마이그레이션은 “drop 한 번을 두 번에 나눠 친다”는 식의 단계 분할이 필요해진다.


🛠️ Prisma migrate가 자동 생성한 SQL의 함정

스키마 파일을 일단 새 모양으로 다 고쳤다. self-reference는 배열로 바꾸고, enum 두 개를 새로 정의하고, placedLevelId?를 붙였다. 그리고 pnpm prisma migrate dev --name v3_schema_update.

생성된 마이그레이션 SQL의 첫 부분을 읽고 잠깐 멈췄다.

-- 20260114091938_v3_schema_update/migration.sql
/*
  Warnings:

  - You are about to drop the column `playableLevelIds` on the `content_items` table. All the data in the column will be lost.
  - You are about to drop the column `targetLevelId` on the `curricula` table. All the data in the column will be lost.
  - You are about to drop the column `lowerLevelId` on the `levels` table. All the data in the column will be lost.
  - You are about to drop the column `upperLevelId` on the `levels` table. All the data in the column will be lost.
*/

4건의 데이터 손실 경고. 자동 생성된 SQL을 그대로 prod에 쓰면 컬럼 4개가 통째로 사라진다. Prisma migrate는 친절하게 경고를 적어주지만, 실행을 막아주는 건 아니다. --accept-data-loss 비슷한 flag가 자동 붙은 셈이다.

자동 SQL이 잘하는 것 vs 못하는 것

항목자동 SQL이 잘함자동 SQL이 못함
ALTER ENUM (값 추가)ADD VALUE 깔끔하게 추가
새 컬럼 + default + nullable✅ 그대로 사용 가능
새 테이블/인덱스/FK✅ 그대로 사용 가능
컬럼 drop 직전 백필자동으로 UPDATE 안 끼워줌
NOT NULL → nullable 전환✅ 자동의미는 사람이 결정해야 함
신규 컬럼 default 값 의미도메인 문맥은 사람만 안다

⚠️ 주의: 자동 생성 SQL을 그대로 적용해도 dev DB에서는 통과한다. row가 거의 비어 있으니 잃을 게 없는 것뿐이다. prod DB에 들어가는 순간 의미가 달라진다는 사실을 dev에서 시뮬레이션하기는 의외로 어렵다.

그래서 실제로 적용한 마이그레이션 SQL의 모양

자동 생성된 SQL을 베이스로 두고, 데이터 손실이 일어나는 자리마다 단계 분할을 적용했다. 이 작업의 결과물을 한 페이지로 보면 이렇다.

-- 1단계: 새 enum 정의 (비파괴)
CREATE TYPE "LevelType" AS ENUM ('SINGLE_NODE', 'SINGLE_TRACK', 'MIX_LEVEL');
CREATE TYPE "TrackState" AS ENUM ('SINGLE', 'MIX');

-- 2단계: 새 컬럼/테이블 추가 (모두 default + nullable)
ALTER TABLE "levels"
  ADD COLUMN "levelType" "LevelType" NOT NULL DEFAULT 'SINGLE_TRACK',
  ADD COLUMN "lowerLevelIds" INTEGER[],
  ADD COLUMN "upperLevelIds" INTEGER[],
  ADD COLUMN "semester" TEXT;

ALTER TABLE "students"
  ADD COLUMN "trackState" "TrackState" NOT NULL DEFAULT 'SINGLE',
  ADD COLUMN "track1Completed" BOOLEAN NOT NULL DEFAULT false,
  ADD COLUMN "secondLevelId" INTEGER,
  ADD COLUMN "track2Completed" BOOLEAN,
  ...
ALTER TABLE "diagnostic_sessions"
  ADD COLUMN "resultMetrics" JSONB,
  ALTER COLUMN "placedLevelId" DROP NOT NULL;

-- 3단계: 시드 백필 (seed.ts에서 실행)
-- 자동 SQL 안에는 없고, prisma db seed로 수동 실행

-- 4단계: 구 컬럼 drop (3단계 검증 후 수동 적용)
ALTER TABLE "levels" DROP CONSTRAINT "levels_upperLevelId_fkey";
ALTER TABLE "levels" DROP CONSTRAINT "levels_lowerLevelId_fkey";
ALTER TABLE "levels" DROP COLUMN "upperLevelId", DROP COLUMN "lowerLevelId";
ALTER TABLE "curricula" DROP COLUMN "targetLevelId",
  ADD COLUMN "targetLevelIds" INTEGER[],
  ADD COLUMN "currentTargetIdx" INTEGER NOT NULL DEFAULT 0;

핵심은 2단계와 4단계 사이에 시드 백필이 끼어 있다는 점이다. 자동 생성된 단일 마이그레이션 파일은 이 사이를 비워두지 않는다. 그래서 dev 환경에서는 빠르게 한 번에 돌리되, prod 환경 적용 전에 같은 마이그레이션을 시드 백필 단계와 함께 두 번 나눠 검증한다는 결정을 내렸다.


🧷 데이터를 살리는 5가지 작전

이번 마이그레이션을 돌면서 실제로 사용한 보존 기법을 정리하면 다섯 개다. 각각 자동 SQL이 만들어주는 것과 사람 손이 더해야 하는 것의 경계가 다르다.

1) 새 컬럼은 default + NOT NULL 또는 nullable

-- ✅ After
ALTER TABLE "students"
  ADD COLUMN "trackState" "TrackState" NOT NULL DEFAULT 'SINGLE',
  ADD COLUMN "track1Completed" BOOLEAN NOT NULL DEFAULT false;

기존 row 입장에서 trackState가 갑자기 NOT NULL로 추가되면, default가 없으면 ALTER 자체가 막힌다(PostgreSQL은 새 NOT NULL 컬럼에 default 없으면 거부). default가 있으면 PostgreSQL이 모든 기존 row에 그 값을 넣어준다.

여기서 default 선택이 도메인 결정이다. v1.x의 모든 기존 사용자는 단일 트랙 상태였으니 'SINGLE'이 정확한 정답이다. 'MIX'로 default를 잡았다면 기존 사용자가 모두 동시에 두 트랙을 가진 것처럼 보일 거고, 그 다음 화면에서 NPE 폭탄이 터졌을 거다.

📌 핵심: default 값은 SQL 문법이 아니라 도메인 진실의 선언이다. “기존 row는 어떤 상태였느냐”에 대한 답을 마이그레이션이 강제로 묻는다.

2) NOT NULL → nullable 전환은 데이터의 안식처

-- ✅ After
ALTER TABLE "diagnostic_sessions"
  ALTER COLUMN "placedLevelId" DROP NOT NULL,
  ADD COLUMN "resultMetrics" JSONB;

placedLevelId를 drop하지 않고 nullable로 두는 결정은 v2.0에서 이 컬럼을 더는 안 쓴다는 사실과 별개다. 이미 쌓인 진단 row의 “옛날에는 이 등급으로 배치됐었다”는 역사를 남기기 위해서다. 새로 들어오는 row는 NULL이고, 옛 row는 그대로다.

model DiagnosticSession {
  // ...
  placedLevelId Int?  // @deprecated - v2.0에서는 사용하지 않음
  resultMetrics Json? // 새 결과 (지표만)
}

@deprecated JSDoc 주석을 schema에 박는 건 작은 버릇 하나지만, 같은 모델을 6개월 뒤에 다시 보는 미래의 자신에게는 커다란 표지판이 된다.

3) self-reference → 배열은 두 단계로 쪼갠다

-- 2단계: 새 배열 컬럼 추가 (default = 빈 배열)
ALTER TABLE "levels"
  ADD COLUMN "upperLevelIds" INTEGER[],
  ADD COLUMN "lowerLevelIds" INTEGER[];

-- (사이에 백필 SQL 또는 seed.ts UPDATE 실행)
UPDATE "levels"
  SET "upperLevelIds" = ARRAY["upperLevelId"]
  WHERE "upperLevelId" IS NOT NULL;

-- 4단계: 구 컬럼/FK drop
ALTER TABLE "levels"
  DROP CONSTRAINT "levels_upperLevelId_fkey",
  DROP CONSTRAINT "levels_lowerLevelId_fkey",
  DROP COLUMN "upperLevelId",
  DROP COLUMN "lowerLevelId";

배열의 default는 PostgreSQL이 빈 배열({})로 잡는다. 이렇게 두면 새 컬럼은 비어 있는 상태로 시작하지만, 2단계와 4단계 사이에 UPDATE를 한 번 끼워서 옛날 정수 컬럼의 값을 배열로 옮겨놓을 수 있다. 시드 코드 안에서 처리하든 raw SQL로 처리하든 둘 다 가능하다.

이번 작업에서는 등급 시드를 통째로 다시 깔았기 때문에 UPDATE 백필 대신 prisma db seed로 31개 등급을 신규 트리 구조로 다시 심는 방식을 택했다. 사용자 데이터(students, curricula)는 살리되, 메타 트리 데이터(levels)는 도메인 시드로 다시 쌓는 비대칭 전략이다.

4) DB 손실 방어와 별개로 schema.prisma.backup을 남긴다

# 마이그레이션 작업 시작 전
cp apps/api/prisma/schema.prisma apps/api/prisma/schema.prisma.backup

“마이그레이션 실패 시 stash로 되돌리면 되잖아”라고 생각했었다. 그게 통하는 건 코드만 깨졌을 때다. 마이그레이션 SQL이 한 번 실행되면 DB 상태가 git의 통제를 벗어나는 순간이 있다. 그때 코드 schema.prisma만 git stash pop으로 살려도, DB는 이미 한쪽 다리가 미래로 가버린 상태가 된다.

.backup 파일은 git이 추적하지만, 정작 쓸모는 git을 못 쓰는 순간에 진가가 나온다. dev DB를 한쪽 분기로 망쳐놓고 다른 ticket 작업을 끼워넣어야 할 때, “지금 DB와 일치하는 schema가 어디였더라”의 답을 찾는 닻이 된다.

5) 인덱스는 신규 컬럼이 정착한 다음에 추가한다

-- 마지막 단계
CREATE INDEX "students_trackState_idx" ON "students"("trackState");
CREATE INDEX "classes_grade_semester_idx" ON "classes"("grade", "semester");
CREATE INDEX "levels_levelType_idx" ON "levels"("levelType");
CREATE INDEX "levels_semester_idx" ON "levels"("semester");

새 컬럼이 비어있는 상태에서 인덱스를 만드는 것은 어차피 효과가 없다. 시드/백필 후에 만들면 한 번에 정렬되니 이게 더 효율적이다. PostgreSQL의 CREATE INDEX CONCURRENTLY까지 쓸 정도로 prod row 수가 크진 않아서 그냥 순차로 만들었지만, 만약 row가 수백만 단위였다면 CONCURRENTLY를 끼웠을 자리다.

💡 인사이트: 마이그레이션은 “한 번의 ALTER”가 아니라 “여러 ALTER의 순서”다. 새 컬럼 → 백필 → 구 컬럼 drop → 인덱스의 4박자가 무너지면 그 자리에서 prod row 한 줄이 사라진다.


🔬 마이그레이션 직후 36개 빌드 에러 — 의도된 신호

pnpm prisma migrate deploy 후에 곧바로 했던 일은 pnpm build였다. 의도와 무관하게 결과는 빨간 줄 36개.

apps/api/src/level/level.service.ts:42:26 - error TS2551:
  Property 'upperLevelId' does not exist on type 'Level'.
  Did you mean 'upperLevelIds'?

apps/api/src/student/student.service.ts:118:30 - error TS2741:
  Property 'trackState' is missing in type ...

apps/api/src/curriculum/curriculum.service.ts:67:14 - error TS2551:
  Property 'targetLevelId' does not exist on type 'Curriculum'.
  Did you mean 'targetLevelIds'?

... (총 36건)

여기서 멘탈이 안 깨진 이유는 단 한 가지였다. 이 에러들은 Phase 3에서 처리할 일이라고 5-Phase 로드맵에 적어두었기 때문이다.

Phase작업빌드 상태
Phase 2Prisma Schema 마이그레이션❌ 36 에러 (의도됨)
Phase 3-1Repository 레이어 재작성🟡 일부 에러 잔존
Phase 3-2Domain 서비스 재작성🟡 일부 에러 잔존
Phase 3-3Application 서비스 재작성🟡 일부 에러 잔존
Phase 3-4Controller / Module 재작성✅ 빌드 성공

📌 핵심: Phase 2는 “DB는 미래에 도착했고, 코드는 아직 과거에 있다”는 의도된 비대칭 상태로 끝난다. 이 상태에서 빌드가 성공하면 그게 더 이상한 거다.

만약 Phase 2와 Phase 3을 한 커밋에 묶었다면

세 가지 시나리오를 머릿속으로 그려본 적이 있다.

  1. 한 커밋에 다 몰아넣기: 파일 70~80개가 한 번에 변경된다. 코드 리뷰 불가, 롤백은 통째로만 가능. 한 자리에서 실수하면 디버깅 진입점을 잡을 수 없다.
  2. Phase별 커밋: 3040개 파일짜리 커밋이 45개. 각 단계에서 “여기까지는 정상”이라는 체크포인트가 있고, 어디서 깨졌는지 즉답이 가능하다.
  3. Phase별 커밋 + branch: 매 Phase마다 별도 브랜치. 1인 개발 환경에서 3까지 가면 오버. 2가 sweet spot이었다.

이번 v2.0 마이그레이션은 2번을 골랐다. 결과적으로 Phase 3에서 끔찍한 에러가 두 번 정도 났는데, 그때마다 “Phase 2 직후로 reset”이라는 안전한 후퇴 지점이 있어서 멘탈이 안 깨졌다.


✅ 검증 — 시드 31개와 트랙 상태 무결성

마이그레이션 SQL을 적용한 다음에 했던 검증은 두 종류다.

1) 등급 시드의 형상 검증

$ pnpm prisma db seed
 31개 등급 시드 완료
   - SINGLE_NODE: 6 (L1, L2, L6, L7, L22, L29)
   - SINGLE_TRACK: 23
   - MIX_LEVEL: 2 (L25, L31)
-- 분기점 한 곳을 직접 검증
SELECT id, name, "levelType", "lowerLevelIds"
FROM "levels"
WHERE "levelType" = 'MIX_LEVEL';

 id | name |  levelType  | lowerLevelIds
----+------+-------------+----------------
 25 | L25  | MIX_LEVEL   | {26, 27}
 31 | L31  | MIX_LEVEL   | {32, 33}

{26, 27} 형태로 PostgreSQL의 INTEGER[] 컬럼이 잘 동작한다는 것을 직접 눈으로 확인했다. self-reference 단일 정수 시절에는 표현 불가능하던 모양이다.

2) 기존 사용자 row의 trackState 일관성

SELECT "trackState", COUNT(*)
FROM "students"
GROUP BY "trackState";

 trackState | count
------------+--------
 SINGLE     |   1247
 MIX        |      0

좋은 신호. 새 컬럼 default 'SINGLE'이 모든 기존 row에 그대로 박혔고, 아직 누구도 MIX 상태로 들어가지 않았다(트랙 분기 로직은 Phase 3에서 추가). 만약 여기서 MIX가 1건이라도 잡혔다면 default 적용에 사고가 있었다는 신호일 거다.

-- placedLevelId nullable 검증
SELECT
  COUNT(*) FILTER (WHERE "placedLevelId" IS NOT NULL) as 옛날_데이터,
  COUNT(*) FILTER (WHERE "placedLevelId" IS NULL) as 신규_데이터
FROM "diagnostic_sessions";

 옛날_데이터 | 신규_데이터
------------+--------------
        842 |           0

옛 진단 row 842건의 placedLevelId가 그대로 살아 있다. drop 안 하기로 한 결정이 보상받는 자리.

🔍 단서: 마이그레이션 검증의 본질은 “row 수가 같느냐”가 아니라 “row의 의미가 같느냐”다. count(*)만 같고 분포가 어긋나면, prod에 올라간 후 화면에서 터진다.


🛡️ 예방 — 다음 마이그레이션을 위한 셀프 룰 5개

이번 작업이 끝난 직후에 메모해뒀던 룰 다섯 개. 다음 마이그레이션에서 같은 함정에 다시 빠지지 않으려는 보루다.

#한 줄 이유
1migrate dev 후 SQL 무조건 사람 눈으로 한 번 읽는다data will be lost 경고는 친절한 경고지 차단이 아니다
2새 NOT NULL 컬럼에는 항상 default를 단다default 없으면 ALTER가 거부되거나 prod에서 무한 락
3컬럼 drop은 별도 마이그레이션으로 분리백필 → 검증 → drop의 3박자를 분리할 수 있는 구조
4nullable 전환은 deprecated 주석을 schema에 박는다6개월 뒤 자신에게 보내는 메모
5마이그레이션 직후 빌드 깨짐은 Phase 로드맵에 미리 적어둔다”정상적으로 깨진 것”임을 5분 안에 확신할 수 있어야 멘탈이 안 깨진다

Prisma migrate에 대한 신뢰의 경계선

이번 작업은 결과적으로 Prisma migrate의 자동 SQL 생성 기능에 대한 내 신뢰의 경계를 명확하게 만들었다.

  • 신뢰: enum 추가, 새 테이블, 새 인덱스, FK 추가, 단순 컬럼 추가
  • 🟡 재검토: NOT NULL → nullable 전환, default 변경, 컬럼 타입 변경
  • 무조건 사람 검토: 컬럼 drop, 테이블 drop, FK drop, ENUM 값 삭제

💡 인사이트: “Prisma migrate를 신뢰하느냐”가 아니다. **“어느 작업을 신뢰하느냐”**가 정답이다. 도구는 도메인 의도를 모르고, 모르는 자리에서 친절하게 데이터를 날려준다.

prisma.io

이 문서가 자동 생성된 SQL을 어떻게 사람이 다듬는지에 대한 공식 가이드다. 처음 마이그레이션을 손대는 사람은 한 번 읽고 시작하면 사고를 80%는 피한다.


📋 정리 — 핵심 요약

이번 Phase 2 작업을 한 페이지 표로 정리하면 이렇다.

상황안티패턴권장 패턴
컬럼 drop이 필요할 때migrate dev 자동 SQL 그대로 prod 적용✅ 새 컬럼 추가 → 백필 → drop의 3단계 분리 마이그레이션
새 NOT NULL 컬럼 추가❌ default 없이 추가 (ALTER 거부 또는 락)✅ 도메인 진실 기반 default 명시 ('SINGLE' 등)
더 안 쓰는 컬럼 처리❌ 무조건 drop✅ nullable + @deprecated 주석으로 역사 보존
self-reference → 배열❌ 한 마이그레이션에 drop과 add 동시에✅ 2단계: 배열 add → UPDATE 백필 → 정수 drop
마이그레이션 직후 빌드 에러❌ “왜 깨졌지?” 즉시 코드 수정 시작✅ Phase 로드맵에 “이 시점에 N개 에러 정상”을 사전 명시
마이그레이션 SQL 검토❌ “Prisma가 알아서 했겠지”data will be lost 경고는 무조건 사람이 한 줄씩 검토
schema.prisma 변경❌ git만 믿기.backup 사본 + git의 이중 안전장치

다음 편(devlog-16)에서는 이 마이그레이션 직후의 36개 빌드 에러를 어떻게 잡았는지 — Phase 3 Repository 레이어 재작성 이야기로 이어진다. self-reference가 사라진 자리에 어떤 메서드 시그니처가 새로 들어갔고, 단일 트랙 가정이 박혀 있던 코드를 어디까지 뜯어내야 했는지의 기록이다.

📌 한 줄 결론: Prisma migrate는 좋은 출발점이지만 종착역이 아니다. 자동 생성 SQL의 경고 4건을 무력화하는 사람의 손이 데이터 손실 0건 마이그레이션의 진짜 가치다.


📚 교육용 풀스택 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에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루