로그인은 되는데 피드만 안 떴어요
사이드 프로젝트를 하나 하고 있는데, 만드는 것도 배포하는 것도 저 혼자 하고 있어요.
어느 날 로그인하고 홈에 들어갔더니, 피드가 나와야 할 자리에 이 문장이 떠 있었습니다.
피드를 불러오지 못했어요. 잠시 후 다시 시도해주세요.
콘솔을 열어보니 데이터를 가져오는 API 두 개만 500이 나고 있었고, 로그인 자체는 멀쩡했어요.
GET /api/auth/get-session => 200
GET /api/v1/feed => 500
GET /api/v1/stories => 500바로 전날 어드민 기능 PR을 하나 머지했어요.
컬럼을 추가하는 마이그레이션과, 그 컬럼을 읽는 코드가 같이 들어간 PR이었습니다.
// src/lib/server/serialize.ts
tag: {
select: {
id: true,
name: true,
assetKey: true,
imageUrl: true,
inkColor: true,
},
},코드는 배포됐는데 DB에는 그 컬럼이 없어서 500이 난 거였습니다.
여기까지는 이해가 됐어요.
그런데 마이그레이션이 5일째 막혀 있었어요
그러면 컬럼을 추가하는 마이그레이션은 왜 적용이 안 된 걸까요?
마이그레이션은 배포 파이프라인이 아니라 제가 손으로 돌리고 있어서, 프로덕션에 직접 migrate deploy를 걸어봤어요.
🤔 migrate deploy란?
아직 적용되지 않은 마이그레이션이 있으면 순서대로 실행해주는 Prisma 명령입니다.
Error: P3009
migrate found failed migrations in the target database,
new migrations will not be applied.
The `20260710235031_presets_with_category` migration
started at 2026-07-10 15:19:53 UTC failed🤔 P3009란?
대상 DB에 실패한 마이그레이션이 남아 있다는 Prisma 에러입니다. 실패 기록이 하나라도 있으면 그 뒤 마이그레이션을 전부 막습니다.
실패했다는 마이그레이션의 날짜를 보니 7월 10일, 어제가 아니라 5일 전이었어요.
그날 실패한 기록이 그대로 남아서 그 뒤의 마이그레이션을 전부 막고 있었던 거고요.
그러니까 어제 한 배포로 인해 고장이 난 게 아니라, 5일 전부터 나 있던 고장을 어제서야 알게 된 거였습니다.
flowchart LR
A["7월 10일<br/>마이그레이션 실패"] -->|"5일 동안 아무 반응 없음"| B["7월 14일<br/>PR 머지, 코드만 배포"]
B --> C["7월 15일<br/>피드 500, 그제야 발견"]
마이그레이션은 왜 실패했을까요?
다행히 실패한 이유는 바로 확인할 수 있었어요.
Prisma는 마이그레이션을 언제 어떻게 실행했는지를 DB 안의 _prisma_migrations 테이블에 스스로 기록해두는데, 여기에 실패 로그가 통째로 남아 있었거든요.
여기서부터 테이블이 두 개 나오는데, 관계는 이렇습니다.
erDiagram
direction LR
tags ||--o{ records : "tag_id"
tags {
uuid id PK
uuid space_id "NULL이면 앱 기본 제공 태그"
text asset_key "태그 식별자 (yoga, barbell)"
text name
text category
}
records {
uuid id PK
uuid tag_id FK "tags.id를 가리킴"
}
Database error code: 23503
ERROR: update or delete on table "tags" violates foreign key
constraint "records_tag_id_fkey" on table "records"
DETAIL: Key (id)=(25de5ebc-...) is still referenced from table "records".23503은 외래키 제약 위반이라는 뜻이라, 문제가 된 마이그레이션 파일을 열어봤습니다.
-- 기존 전역 프리셋 전부 제거 후 category 포함 재삽입(더미 단계라 UPDATE 대신 재시드).
ALTER TABLE "tags" ADD COLUMN IF NOT EXISTS "category" VARCHAR(20);
DELETE FROM "tags" WHERE "space_id" IS NULL;
INSERT INTO "tags" (...) VALUES
(gen_random_uuid(), NULL, '역기', 'barbell', 'strength', NULL),
... 26종 ...주석에 적어둔 것처럼 “아직 더미 단계니까 전부 지우고 다시 넣어도 된다”는 전제로 짠 SQL이었어요.
여기서 space_id가 비어 있는(IS NULL) 태그는 특정 유저가 만든 게 아니라 앱이 기본으로 제공하는 태그를 뜻합니다.
아래에서는 이걸 전역 프리셋이라고 부를게요.
그런데 그 시점의 프로덕션은 이미 더미가 아니었습니다.
전역 프리셋 중 두 개를 유저가 실제로 써서, 그 태그를 가리키는 기록이 남아 있었거든요.
asset_key는 태그를 구분하는 식별자예요.
asset_key | name | 이 태그를 가리키는 기록 수
-----------+----------+---------------------------
yoga | 요가 | 5
badminton | 배드민턴 | 2그리고 records.tag_id가 tags를 가리키는 이 외래키는 RESTRICT로 걸려 있었어요.
🤔 외래키의 RESTRICT란?
다른 테이블이 참조하고 있는 행은 지우지 못하게 막는 설정입니다.records의 어떤 행이tags의 한 행을 가리키고 있으면, 그 태그를 지우려는 순간 DB가 거부합니다.
그러니까 기록이 가리키고 있는 태그는 애초에 지울 수 없는 상태였어요.
그래서 DELETE가 거부됐고, 마이그레이션 전체가 실패한 겁니다.
그런데 바로 앞 마이그레이션 파일을 열어보고 좀 당황했어요.
15분 전에 제가 쓴 그 파일에, 지금 이 문제를 그대로 경고하는 주석이 이미 적혀 있었거든요.
-- FK(records.tag_id) RESTRICT: 기록이 붙은 태그는 삭제 실패 → 더미 아님을 알림.
DELETE FROM "tags"
WHERE "space_id" IS NULL
AND "asset_key" NOT IN ('yoga', 'badminton');요가랑 배드민턴을 조건에서 빼서 일부러 안 지운 거였는데, 15분 뒤에 쓴 마이그레이션에서는 그 둘까지 통째로 지우려고 했습니다.
제가 직접 써둔 경고인데, 정작 다음 파일을 쓸 때는 그걸 잊고 있었어요.
그럼 5일 동안 왜 아예 몰랐을까요?
여기서부터가 진짜 궁금했던 부분인데, 고장은 7월 10일에 났는데 제가 안 건 7월 15일이었거든요.
문제를 알려줄 수 있었던 곳을 하나씩 짚어보니 네 군데가 나왔습니다.
| 알려줄 수 있었던 곳 | 5일 동안 어땠나 |
|---|---|
| 배포 파이프라인 | 마이그레이션을 보는 단계가 없어서 계속 성공 |
_prisma_migrations 테이블 | 에러 전문이 남아 있었지만 조회한 적이 없음 |
| 앞 마이그레이션의 주석 | 그 파일을 열어야만 보이는 글자 |
| Prisma가 남긴 적용 기록 | applied_steps_count: 0으로 틀린 값을 알려줌 |
배포가 성공했다는 걸 저는 문제가 없다는 뜻으로 읽고 있었어요.
그런데 제 파이프라인은 코드를 빌드하고 올리기만 할 뿐, 마이그레이션이 잘 적용됐는지는 아예 보지 않았습니다.
확인하는 항목에 없으니 실패로 잡힐 일도 없었던 거예요.
네 개 중에 제일 의외였던 건 마지막에 있는 Prisma의 적용 기록이었어요.
Postgres는 ALTER TABLE 같은 스키마 변경도 트랜잭션으로 묶어주기 때문에, 저는 중간에 실패했으면 앞부분까지 통째로 되돌아갔을 거라고 생각했어요.
그런데 실제 테이블을 조회해보니 category 컬럼은 들어가 있었습니다.
column_name | data_type
------------+-------------------
category | character varyingALTER까지는 통과하고 DELETE에서 실패한, 절반만 적용된 상태였던 거예요.
이걸 모르고 기록만 믿었다면 컬럼이 없다고 가정하고 새로 추가하는 SQL을 짰을 텐데, 이미 있는 컬럼이라 그대로 에러가 났을 거예요.
지울 게 없어서 DELETE를 뺐어요
우선 서비스부터 정상으로 돌려놔야 했는데, 떠올린 방법이 세 가지였어요.
| 방법 | 왜 안 골랐나 |
|---|---|
| 어제 머지한 PR 되돌리기 | 500은 바로 없어지지만, 막힌 마이그레이션이 그대로 남아 다음에 또 막힘 |
| 실패한 마이그레이션 그냥 재시도 | 조건이 그대로여서 같은 외래키 위반이 다시 남 |
migrate resolve --applied로 넘기기 | 실제 컬럼은 여전히 없어서 다음 쿼리에서 또 에러 |
🤔 migrate resolve —applied란?
실패한 마이그레이션을 “적용된 걸로 치자”고 표시해서 넘어가는 명령입니다. 실제 DB는 그대로 두고 기록만 고칩니다.
유저가 아직 적어서 몇 시간은 버텨도 된다고 판단했고, 그래서 증상을 덮는 대신 마이그레이션 자체를 다시 쓰기로 했어요.
그런데 DB의 현재 상태를 확인하고 나니까 좀 허무했습니다.
남아 있던 전역 프리셋 두 개를 둘 다 기록이 가리키고 있어서, 애초에 지울 수 있는 행이 하나도 없었거든요.
장애를 낸 저 DELETE는 실제로는 아무것도 지우지 못하는 구문이었던 거예요.
그래서 DELETE를 통째로 빼고, 없는 것만 넣고 있는 건 값만 채우는 방식으로 바꿨어요.
-- before: 전부 지우고 다시 넣기
DELETE FROM "tags" WHERE "space_id" IS NULL;
INSERT INTO "tags" (...) VALUES (...);
-- after: 없는 것만 넣고, 있는 건 category만 채우기
INSERT INTO "tags" (...)
SELECT ... FROM preset p
WHERE NOT EXISTS (SELECT 1 FROM "tags" a WHERE a."asset_key" = p.slug);
UPDATE "tags" SET "category" = p.category
FROM preset p WHERE "asset_key" = p.slug;기존 태그의 id를 안 건드리는 게 핵심이었는데, 그래야 records.tag_id가 가리키는 대상이 안 사라지니까요.
프로덕션에 바로 걸지는 않고 같은 데이터로 복사본을 하나 만들어서, records의 기록 7개가 그대로 남는지 먼저 확인했어요.
그다음에 막혀 있던 실패 기록을 정리하고 다시 배포했습니다.
🤔 migrate resolve —rolled-back이란?
--applied와 반대로 “이건 적용 안 됐다”고 표시하는 명령입니다. 실패 기록이 풀리면서 같은 마이그레이션을 다시 돌릴 수 있게 됩니다.
npx prisma migrate resolve --rolled-back 20260710235031_presets_with_category
npx prisma migrate deploy그러고 나니 피드가 다시 떴고, 요가 태그가 원래 id를 유지해서 그 태그를 가리키던 기록 5개도 살아 있었어요.
그리고 뒤늦게 안 건데, 피해 범위가 feed랑 stories만이 아니었습니다.
serialize.ts의 저 select는 여러 API가 같이 쓰는 거라서 moments랑 calendar도 같이 500이 나고 있었거든요.
어디에 알람을 걸어야 할까요?
복구를 어떻게 했는지보다 더 신경 쓰였던 건, 5일 동안 이 문제를 모르고 있었다는 점이에요.
고장 자체는 났다는 걸 알기만 하면 고칠 수 있는데, 문제를 인식하는 데 시간이 오래 걸렸거든요.
그래서 지금은 두 가지를 생각하고 있어요.
- CI에
migrate status한 줄 넣기. 막혀 있는 마이그레이션이 있으면 배포 전에 실패로 잡힙니다. 비용이 거의 안 들고, 손으로 돌리는 지금 방식을 바꾸지 않아도 돼요. - 서버 헬스 체크에 넣기. 배포가 끝난 뒤에도 주기적으로 확인되지만, 지금은 헬스 체크 자체가 없어서 그것부터 만들어야 해요.
비용이 적은 쪽부터 해볼 생각인데, 이걸로 충분할지는 아직 잘 모르겠어요.
이번에도 알아챌 수 있었던 곳이 네 군데나 있었는데 전부 놓쳤으니까요.
그래도 이번 일로 하나는 바뀌었습니다.
배포가 성공했다는 것만 보고 괜찮다고 넘기지는 않으려고요.
아무것도 확인하지 않는 파이프라인도 배포는 성공하니까요.
이 글의 테이블·컬럼·마이그레이션 이름은 익명화했어요. 에러 코드와 날짜, 상황은 실제 그대로입니다.