롤백 절차 — 실서비스에 문제가 생겼을 때
저장소 분리 적용 범위 (#1463): 이 문서의 실행 코드·DB·앱 release 절차는
dekerd/Barbelic에 적용한다. 문서·관리자·랜딩은 각각의 독립 저장소 절차를 따른다. 과거 docs/admin 결합·앱 main 문서 직행 설명은 분리 전 이력이며 저장소 경계와 독립 운영·실제 전환 상태가 그 부분을 대체한다.
2026-09-09 개정 (이슈 #1456·#1457·#1458, 오너 결정) — 명령·워크플로 이름이 바뀌었다. 작업 PR 통합 요청
npm run merge:request(구 landing:request), PR 전 사전 검증npm run ci:precheck-local(최신 release 합치기 → verify → 서버 변경 시 DB 단계, 브라우저 없음), 전체 검증npm run ci:full-local(릴리스 큐 runner 안에서만; 세션은--only <단계>재현만). 워크플로 표시 이름:3-Release Merge Request(release-merge-request.yml) ·GitHub Full CI(policy-contract.yml) ·1-Production Deploy/2-Staging Deploy(production-deploy.yml·staging-deploy.yml, 단계 정의는 deploy-steps.yml) ·Production smoke (manual)·Social login daily check·Daily data backup.landing:lock·db:preflight스크립트는 폐기됐다(GitHub concurrency 가 잠금, DB 검사는 사전 검증과 큐가 한다). 아래 본문의 옛 이름은 그 개정 전 기록이다.
배포 절차는 deployment-pipeline.md에 있다. 이 문서는 이미 나간 것에 문제가 생겼을 때 무엇을 어떤 순서로 하는지만 다룬다. 실행 주체는 오너다(세션이 대신할 수도 있으나 판단은 오너가 한다).
0. 대원칙 셋
- 화면은 되돌린다. DB는 되돌리지 않는다. DB를 되돌리면 그 사이 유저가 쓴 데이터가 사라진다. DB는 언제나 "새 마이그레이션으로 앞으로 고친다"(forward-fix).
production브랜치도 되감지 않는다. 강제 푸시는 ruleset이 막고 있고, 되감으면 다음 릴리스의 변경 목록 계산이 깨진다. 브랜치는 항상 앞으로만 간다.- 롤백은 임시 상태다. 되돌린 뒤에는 수정 릴리스가 나갈 때까지 다른 릴리스를 하지 않는다. 롤백 중에 무관한 릴리스를 내보내면 문제 코드가 그대로 다시 나간다.
1. 먼저 판단 — 어느 층의 문제인가
| 증상 | 층 | 가는 곳 |
|---|---|---|
| 화면이 깨진다·버튼이 안 먹는다·표시가 틀렸다 (저장은 됨) | 화면 | 2. 화면 되돌리기 |
| 저장·조회가 실패한다, 권한 오류가 난다 | DB 또는 서버 함수 | 4. DB 문제 |
| 특정 백그라운드 작업(통계 갱신·인입)만 실패한다 | 서버 함수 | 3. 서버 함수 되돌리기 |
| 무엇인지 모르겠다 | — | 일단 화면부터 되돌린다. 화면 롤백은 데이터를 건드리지 않아 되돌리기 쉽고, 대부분의 사고가 여기서 멎는다 |
배포 순서가 DB → 서버 함수 → 화면이라, DB와 서버 함수는 새 화면과 옛 화면을 둘 다 감당하도록 만들어져 있다. 그래서 화면만 옛 것으로 돌려도 정상 동작하는 것이 정상이다.
2. 화면 되돌리기 (즉시)
Vercel의 Instant Rollback을 쓴다. 실서비스 도메인이 즉시 이전 배포본을 가리키게 된다.
대시보드 (권장)
- Vercel → barbelic 프로젝트 → Overview
- Production Deployment 타일의 Instant Rollback 클릭
- 되돌아갈 배포본 선택(Pro 요금제라 이전 것 아무거나 고를 수 있다) → Continue
- 되돌아갈 도메인 목록 확인 → Confirm Rollback
명령줄 (대안)
vercel rollback <배포 URL 또는 ID>상태 확인은 vercel rollback status.
되돌린 뒤 반드시 확인
curl -s https://www.barbelic.com/ | grep -o '/assets/app-[A-Za-z0-9._-]*\.js'파일 이름이 되돌아간 배포본의 것과 같아야 한다. 브라우저로 확인할 때는 강력 새로고침(캐시 무시)으로 본다.
⚠ 화면 롤백의 부작용 셋 (Vercel 공식 문서 기준)
- 실서비스 도메인 자동 배정이 꺼진다. 이게 가장 중요하다 — 롤백 이후에는 새 배포를 해도 도메인이 자동으로 새 배포를 가리키지 않는다. 즉 수정 릴리스가 GitHub Actions에서 초록으로 끝나도 실제로는 안 나간 상태일 수 있다. 6절 참조.
- 환경변수는 되돌아가지 않는다. 되돌아간 배포본은 자기가 빌드될 때 구워진 값을 그대로 쓴다. 실서비스 환경변수는 비워 두는 정책(값이 없으면 코드의 기본값=실서비스)이라 현재는 영향이 없다.
- Vercel cron은 되돌아간 배포본 기준으로 복원된다. 현재
vercel.json에 cron 설정이 없어 해당 없음.
3. 서버 함수 되돌리기
되돌리기 기능이 따로 없으므로 직전 정상 버전을 다시 배포한다. 함수는 3개(wodup-start-import, wodup-process-import-jobs, stats-process-refresh-jobs).
가장 확실한 방법은 수정 릴리스로 앞으로 고치는 것이다(4절과 같은 흐름). 급하면 세션이 직전 커밋의 함수 코드로 수동 배포할 수 있으나, 이는 D5(사람이 Production에 직접 배포하지 않는다)의 예외이므로 오너가 명시로 지시한 경우에만 한다.
배포 확인:
curl -i https://kobxeylancdimqhfkbnl.supabase.co/functions/v1/stats-process-refresh-jobs응답 헤더의 x-barbelic-deployment-version이 기대한 버전인지 본다.
4. DB 문제 — 되돌리지 않고 앞으로 고친다
이미 적용된 마이그레이션 파일은 절대 수정하지 않는다. 잘못된 부분을 바로잡는 새 마이그레이션을 만들어 릴리스한다.
순서:
- 세션이 수정 마이그레이션을 만들어
main에 머지 → staging에 자동 적용되어 staging에서 먼저 확인 - 릴리스 PR(
main → production)을 열고 오너가 머지 → 실서비스 반영 - 화면을 2절에서 되돌려 둔 상태라면 6절로
저장 계약(writer RPC)이 깨진 경우는 옛 RPC를 되살리는 우회를 하지 않는다. 현재 계약을 고치는 마이그레이션을 먼저 적용한 뒤 화면을 다시 배포한다.
5. 되돌린 뒤 정리 — 브랜치와 버전
화면을 되돌려도 저장소와 버전 표시는 그대로다. 이때 상태가 이렇게 어긋난다.
| 상태 | |
|---|---|
| 실서비스 화면 | 되돌아간 옛 배포본 |
production 브랜치 | 문제 있는 코드 그대로 |
버전 태그(예: v0.9.1) | 문제 있는 커밋을 가리킨 채 |
| DB | 그대로(앞으로만 간다) |
정리 규칙:
- 브랜치는 되감지 않는다. 문제를 고친 코드를
main에 넣고 다음 릴리스 PR로 내보낸다. - 태그는 지우지 않는다.
v0.9.1은 실제로 나갔던 역사다. 대신 그 GitHub Release 설명에 한 줄 남긴다 — 예: "이 릴리스는 <증상> 때문에 롤백됨.v0.9.2에서 수정." 수정본은 다음 번호로 나간다(patch 자리 +1). - 롤백 중에는 다른 릴리스를 하지 않는다(0절 3번). 수정 릴리스가 우선이다.
6. 수정 릴리스를 낸 뒤 — 도메인 되살리기 (빠뜨리기 쉬움)
2절에서 화면을 되돌렸다면 실서비스 도메인 자동 배정이 꺼져 있다. 수정 릴리스의 배포가 초록으로 끝나도 도메인은 아직 옛 배포본을 가리킬 수 있다.
반드시 확인한다:
curl -s https://www.barbelic.com/ | grep -o '/assets/app-[A-Za-z0-9._-]*\.js'새 배포본의 파일 이름과 다르면 아직 안 나간 것이다. 되살리는 방법:
- 대시보드: Production Deployment 타일의 Undo Rollback → 새 배포본 선택 → Confirm
- 명령줄:
vercel promote <새 배포 URL 또는 ID>
둘 다 도메인 자동 배정을 다시 켠다. 이 확인은 deploy-steps.yml(1-Production Deploy·2-Staging Deploy 가 호출)의 frontend 잡에도 자동 검사로 들어 있어(배포본과 실서비스 도메인의 번들 파일 이름 대조) 어긋나면 빨간불이 난다 — 그래도 사람 눈으로 한 번 더 보는 것을 권한다.
7. 사고 뒤 남길 것
bug-report/bug-NNN-YYYYMMDD.md작성(형식은bug-report/README.md). 증상·원인·조치·재발 방지.- 롤백한 릴리스의 GitHub Release 설명에 한 줄(5절).
- 게이트로 막을 수 있었던 사고면 게이트를 추가한다 — 같은 사고를 두 번 사람의 주의력으로 막지 않는다.
8. 유저 기록 값 복원 — 특정 유저의 특정 행만 되살린다 (이슈 #1236)
0절의 대원칙 "DB는 되돌리지 않는다"는 그대로다. 유저가 입력한 값(원본)이 잘못 바뀌거나 지워졌을 때는 DB 전체가 아니라 그 행만 되살린다. 원칙 정본은 docs/contracts/user-fact-immutability.md.
복원 원천은 둘이고, 가까운 것부터 쓴다.
| 원천 | 무엇이 있나 | 언제 쓰나 |
|---|---|---|
user_fact_history (Phase 1 이후) | 원본이 바뀌거나 지워질 때마다 자동으로 남는 이전 행 전체 — 누가(유저/수리 티켓)·언제 | 값이 바뀐 지 얼마 안 됐고 이력 테이블이 있는 테이블 |
매일 데이터 사본 (Daily data backup 워크플로, artifact daily-data-backup-<날짜>, 90일) | 등급 목록의 유저 테이블 데이터 전체(pg_dump --data-only --column-inserts) — 매일 03:40 KST 시점 | 이력이 없는 테이블, 이력 자체가 손상된 경우, 여러 행을 한 시점으로 맞춰야 할 때 |
8-1. 이력에서 되살리기
v0.18.0의 선택 복구는 user_fact_restore_plan_v1 → 계획의 owner·행·hash·허용 필드 대조 → user_fact_restore_apply_v1(request_id, plan) 순서다. 같은 요청의 재시도는 영수증으로 판별하고, 오래된 계획은 다시 만든다. 복구 영수증 계약을 따른다.
- 이슈를 만들고(무엇이 언제 어떻게 바뀌었는지, 되살릴 행 id) 수리 티켓으로 쓴다.
user_fact_history에서 그 행의 마지막 정상 버전을 찾는다:select * from public.user_fact_history where table_name = 'exercise_set_part' and row_id = '<id>' order by archived_at desc;- 티켓을 선언한 격리 연결에서 계획을 만든다. 아래 입력의 owner·대상 행·정상 이력 id를 확정한다. 가족 전체 복원은 session을 대상으로 family를 full로 지정한다.
begin;
select set_config('lift_guild.repair_ticket', 'issue#NNNN', true);
select public.user_fact_restore_plan_v1(jsonb_build_object(
'version', 1, 'source', 'history', 'owner_user_id', '<owner UUID>',
'entity', jsonb_build_object('table', 'exercise_set_part', 'row_id', '<row UUID>'),
'target', jsonb_build_object('history_id', '<history id>'), 'family', 'row'
));
commit;- 반환 계획의 status·reasons·steps·사전 조건과 허용 범위를 대조한다. 계획 원문 JSON과 새 request UUID를 보존하고 같은 티켓 트랜잭션에서
user_fact_restore_apply_v1(request_uuid, plan_json)에 전달한다. 실패하면 rollback하고 성공 영수증·현재 원본과 사전 조건을 확인한다. 계획을 손으로 고쳐 적용하지 않는다. - 되살린 facts/관계를 대조하고 기존 full 재생이 끝난 뒤
user_stats_refresh_state의 requested_version=applied_version과 read-model을 확인한다. 8-2의 독립 반복 명령이 변경·부분 삭제·가족 삭제·실패 재개를 이 경로로 실행한다.
8-2. 데이터 사본에서 되살리기
- 권한 있는
Daily data backup사본과 manifest를 확보하고 원본을 불변으로 보관한다.validate.mjs의 원문 bytes/hash·gzip·표별 completeness·부모 관계·schema 판정을 사용한다. 구형valid_reconstructed는 원래 manifest.txt와 연결된 근거이며 현재 DB metadata 확보와 구분한다. - 기존
scripts/data-copy/restore-isolated.mjs로 로컬 격리 스키마에 적재한다.--report와--drill-out을 사본 밖 경로로 지정해 원본에 결과를 쓰지 않는다. 기존 스키마를 덮어쓰지 않는다. scripts/data-copy/recovery-plan.mjs에--schema,--owner,--table,--row-id,--family,--out,--sql,--ticket issue#NNNN을 지정한다. owner가 대상 Auth에 없으면owner_deleted·0단계다. SQL도 첫 fact 접근 전에 owner를 잠그고 재확인한다. 범위·ID·FK·source·원본 등급 필드와 계획 hash가 허용된 것인지 대조한 뒤 격리 대상에서 적용한다.- update·삭제 복구는 대상에 이력이 있으면 S10 계획/영수증 경로를 우선한다. 적용 후 원본/비대상 digest를 대조하고 기존 full 통계 재생과 요청=적용·stale0를 확인한다. 실패한 apply는 전부 rollback되어야 하고 같은 요청으로 재개할 때 성공 영수증 여부를 먼저 확인한다.
- 이 절의 리허설 성공은 Production 수리 권한이 아니다. 운영 원본 수리는 별도 이슈·손실 감사·오너 승인·수리 티켓을 갖춘다. 인입 출처 행은 허용된 원문 재인입 경로를 따르며 없는 파일을 추정해 만들지 않는다.
R03 독립 반복 명령
앱의 검증된 R03 커밋을 checkout하고 npm ci --ignore-scripts를 실행한다. Node와 Supabase CLI/PostgreSQL은 앱 package.json의 고정 toolchain을 사용하고 Docker가 실행 중이어야 한다. 입력은 기존 manifest 형식의 실제 승인 사본이다. 사본에 catalog가 맞는 native 완료 세션, 같은 owner의 다른 가족과 다른 owner의 가족, 별도 누락 부모 행, 실제 복합키 즐겨찾기, Motra normalized 자료가 있어야 한다. 필요한 실제 입력이 없으면 실패/미검증으로 기록하며 fixture로 채우지 않는다.
PowerShell에서 다음 네 입력만 정해 실행한다. sandbox와 out은 서로 다른 존재하지 않는 절대 경로로, 앱과 원래 사본 디렉터리 밖에 둔다. 포트는 연속 세 개가 비어 있어야 한다.
$r03Copy = 'D:\authorized-copy\daily-data-backup-2026-09-09'
$r03Sandbox = 'D:\rehearsal\r03-independent-db'
$r03Output = 'D:\rehearsal\r03-private-evidence'
node scripts/data-copy/rehearse.mjs --copy $r03Copy --sandbox $r03Sandbox --out $r03Output --base-port 61431명령은 매번 고유한 새 로컬 프로젝트와 현재 migration을 만들고, 빈 Auth/session을 확인한 뒤 실제 사본을 적재한다. Auth는 사본 ID를 잇는 로컬 준비 행이며 자격증명 복원이 아니다. 대조 가족을 최신 상태로 만든 다음 대상 준비를 완료해 PR 최근 활동 순서를 동일하게 유지한다. 기존 S11 계획/SQL과 S10 복구, D01 비교 장부·D11 full writer를 호출한다. source 변경, 원본/비대상/hash/통계 불일치, 실패 후 부분 저장, 정리 실패는 종료 코드1이다. 성공·실패 모두 자기 스택을 stop --no-backup으로 정리한다. 실패 뒤에는 private-error.log를 비공개로 읽고 새 경로로 다시 실행한다.
rehearsal.json은 단계별 시간·사본 나이·원본 불변·정리 결과, phase2.json은 실제 행/ID/FK 대조, phase3.json은 원본·비대상·통계 지문과 세 복구 시나리오, dry-run.json/copy-faults.json은 거절, import-inventory.json은 인입 가용성을 기록한다. 원래 파일 취득 시간은 인계된 로컬 사본의 경우 미측정이다. 적재 시간과 선택 복구 시간, 샌드박스 준비 시간은 따로 보고하고 사본 생성→측정의 나이는24시간 예산에 대조한다. 단일 인계 사본의 나이를 운영 전체의 최신 RPO로 단정하지 않는다.
출력 전체는 비공개다. private-*에는 실제 행·SQL·시험 사본이 있으며, validation/drill에는 경로나 내부 근거가 있을 수 있다. 공개 보고에는 검토한 집계·불변 여부·정제한 hash만 옮긴다. R03 실제 결과와 미검증 범위를 대조하며 현재 Production schema 사본·원래 Motra/InBody 파일이 없다는 한계를 감추지 않는다. Wodup·관리자 자동검사는 #1478 제외다.
8-3. 하지 않는 것
- 전체 DB 시점 복원 — 그 시각 이후 다른 유저가 쓴 기록이 함께 사라진다. 시점 복원(PITR)은 재정 문제로 켜지 않았다(오너 결정 D2).
- 티켓 없는 손 UPDATE — 보호 트리거가 거부한다(Phase 1 이후). 거부 메시지가 나오면 우회하지 말고 이 절차로 돌아온다.
- 탈퇴한 회원의 기록 복원 — 사본에는 계정(auth) 정보가 없어 owner가 대상 Auth에 없으면
owner_deleted로 0단계가 되고, 이를 우회해 되살리지 않는다. 개인정보처리방침 v5 제6항이 이 규칙을 약속한다(앱 #1625): 계정 삭제 후 사본에 남은 정보는 복구 대상에서 제외하고 Supabase 백업 7일·GitHub 사본 90일 만료로 소멸한다.