Skip to content

ci: 마이그레이션 리플레이 검증 — 빈 DB 에서 스키마 재현 + 드리프트 감지 - #138

Merged
seizeh merged 32 commits into
mainfrom
ci/migration-replay
Jul 29, 2026
Merged

ci: 마이그레이션 리플레이 검증 — 빈 DB 에서 스키마 재현 + 드리프트 감지#138
seizeh merged 32 commits into
mainfrom
ci/migration-replay

Conversation

@seizeh

@seizeh seizeh commented Jul 29, 2026

Copy link
Copy Markdown
Owner

무엇을

지금까지 CI 는 스키마 스냅샷(schema.sql 12,730줄) 복원으로 테스트 DB 를 만들었다.
2026-06-08 이전 기반 스키마가 저장소 밖에 있어 마이그레이션만으로는 빈 DB 를 채울 수
없었기 때문이다. 그 결과 이 저장소만 받아서 올려보는 게 불가능했고, 마이그레이션
없이 운영 DB 에 직접 친 DDL 도 아무도 몰랐다.

빠진 조각을 스냅샷에서 역산해 baseline.sql 로 복원하고, 등식이 성립하는지 CI 가
매번 검증하게 한다.

빈 DB ─ prelude → replay-stubs → baseline → 기본권한 → migrations(175건)
                                                        ↓ pg_dump
빈 DB ─ prelude → schema.sql ────────────────────────── ↓ pg_dump  → diff

양쪽 다 복원→덤프를 거치게 한 건 pg_dump 출력이 되먹였을 때 글자 그대로 재현되지
않기 때문이다(파서가 배열 캐스트를 접는 등). 남는 왕복 아티팩트는
scripts/normalize_schema.py 가 정규화한다.

이걸로 잡힌 것 — 실제 드리프트

20260729070000_capture_prod_drift.sql 로 포착했다. 정의는 전부 schema.sql
(= 운영 덤프)에서 그대로 가져왔으므로 운영 적용은 no-op 이다.

  1. app.dispatch_engagement_notifications 의 얼굴 필터 유실
    and w.context = p.authored_as(같은 얼굴 팔로워만)를 20260718160000 이 넣었는데
    20260720090000 의 create or replace 가 조용히 지웠다. 운영에는 살아 있지만
    저장소만 보고 재생하면 업체 소식이 개인 팔로워에게도 나간다.

  2. 간이 회원(status='lite') 관련이 통째로 저장소 밖
    users_status_check'lite', add_facility_reviewapp.uid_lite()
    재인증 게이트, 후기 조회의 전화번호 마스킹 등. 함수 36개 + 권한만 다른 함수 8개.

검증

  • replay 잡: 마이그레이션 175건 적용 → 덤프 9,668줄이 스냅샷과 완전 일치
  • 재생된 DB 에서 pgTAP 16종도 통과(형태만 맞고 의미가 틀린 경우 방지)
  • 기존 pgtap 잡은 그대로(빠른 회귀)

로컬 실행

docker run -d --name pm -e POSTGRES_PASSWORD=postgres -p 54323:5432 \
  supabase/postgres:17.6.1.121 postgres -c cron.database_name=pmdb_replay
./scripts/replay_check.sh "postgresql://postgres:postgres@localhost:54323/postgres"

앞으로 스냅샷을 갱신하면(./scripts/dump_schema.sh) 베이스라인도 함께 재생성된다.

🤖 Generated with Claude Code

seizeh and others added 30 commits July 29, 2026 14:50
지금까지 CI 는 스키마 스냅샷(schema.sql 12,730줄) 복원으로 테스트 DB 를 만들었다.
2026-06-08 이전 기반 스키마가 저장소 밖에 있어 마이그레이션만으로는 빈 DB 를
채울 수 없었기 때문인데, 그 결과 "이 저장소만 받아서 올려보기"가 불가능했고
마이그레이션 없이 운영 DB 에 직접 친 DDL 도 잡히지 않았다.

빠진 조각을 스냅샷에서 역산해 baseline.sql 로 복원한다:

    baseline = schema.sql − (마이그레이션이 만드는 객체)

역산이 성립하는 근거(마이그레이션 175개 전수 확인) — 기반 테이블 add column 은
전부 if not exists, 제약 변경은 전부 drop→add 쌍, 함수 239개가 create or replace.
따라서 "이미 있으면 실패하는" 객체 133개만 빼면 나머지는 최종 정의 그대로 두어도
리플레이가 같은 결과에 수렴한다. 자동 명명 제약(<테이블>_<컬럼>_check)과
drop … cascade 파급(public_profiles → 피드 뷰 5개)까지 시뮬레이션해서 판정한다.

등식은 추측이 아니라 CI 가 매번 검증한다 — replay 잡이 빈 DB 에
prelude → replay-stubs → baseline → migrations 를 쌓고 pg_dump 해서 schema.sql 과
diff 한다. 어긋나면 실패하므로 스냅샷/마이그레이션 드리프트도 같이 잡힌다.
재생된 DB 에서 pgTAP 도 한 번 더 돌려 형태만 맞고 의미가 틀린 경우를 막는다.

- scripts/build_baseline.py  : 베이스라인 역산(--report 로 제외 목록만 확인)
- scripts/replay_check.sh    : 리플레이 + 스냅샷 대조(로컬에서도 실행 가능)
- scripts/_schema_dump.sh    : 덤프 필터 공통부(두 경로가 어긋나면 없는 diff 가 난다)
- supabase/schema/replay-stubs.sql : 컨테이너에 없는 storage·pg_cron·realtime 흉내

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
supabase/postgres 기본 DB 에는 storage 스키마가 이미 있고 소유자가 postgres 가
아니라 스텁을 얹을 수 없다(permission denied for schema storage). 같은 서버에
pmdb_replay 를 새로 만들어 거기서 리플레이한다 — '빈 DB' 전제에도 더 맞다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
app.has_license 는 create or replace 라 그 자체로는 충돌하지 않지만 인자 타입
app.biz_license_type 이 마이그레이션 소관이라 베이스라인 시점에 아직 없다
(ERROR: type app.biz_license_type does not exist). 제외 대상을 참조하는 블록도
마이그레이션이 다시 만들어 주면 같이 빼도록 고정점까지 전파한다.

마이그레이션이 안 만드는데 참조만 하는 블록은 목록으로 보고한다(현재 0개).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
identity 시퀀스(ALTER TABLE … ADD GENERATED)는 딸린 테이블을 owner 로 잡지
못해 제외 테이블의 시퀀스가 남았다(relation app.business_doc_purge_queue does
not exist). SEQUENCE 의 owner 를 본문에서 찾고, 제약·FK·기본값·인덱스·GRANT 등
부속 블록이 제외 대상을 참조하면 같이 빼도록 일반화한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CREATE TRIGGER 는 트리거 함수를 참조하는데, 그 함수가 제외 대상이면
function app.tg_posts_check_write() does not exist 로 깨진다. 부속 블록 참조
검사 대상에 함수 이름도 넣는다(해당 트리거는 마이그레이션이 다시 만든다).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
베이스라인에 최종 뷰 정의를 두면, 컬럼이 더 적던 옛 정의로 replace 하는
마이그레이션에서 ERROR: cannot drop columns from view 로 깨진다(함수와 달리
뷰의 or replace 는 컬럼 추가만 허용). 마이그레이션이 만드는 뷰 8개를 모두 빼고
리플레이가 처음부터 쌓게 한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
public_profiles 는 기반 스키마에 있었는데 20260610120125 가 drop cascade 후
다시 만든다. 그래서 이전 마이그레이션 5개가 이 뷰를 참조하는데(relation
public.public_profiles does not exist), 스냅샷의 최종 정의는 한참 뒤에 생기는
business_profiles 에 의존해 베이스라인에 둘 수도 없다.

역산 불가능한 조각을 baseline-manual.sql 로 분리하고 생성물 끝에 붙인다.
재생성 이전 형태이므로 앞 마이그레이션이 쓰는 컬럼(id·nickname·user_type)만
맞추면 된다 — 어차피 20260610120125 가 통째로 갈아치운다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
reset_password_user 처럼 인자 이름이 바뀐 함수는 최종 정의 위에 옛 정의를
replace 할 수 없다(cannot change name of input parameter). 뷰와 같은 이유이므로
같은 규칙을 적용한다 — 베이스라인에 남는 건 마이그레이션이 한 번도 건드리지
않은 객체뿐이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
'만들어지기 전에 쓰이는가' 를 재는데 두 가지 오탐이 있었다.
  · comment on … '…(app.has_license)…' 처럼 문자열 리터럴 안의 이름을 사용으로 셈
  · 참조에는 시그니처가 없어 4인자 오버로드의 생성을 7인자 버전의 조기 사용으로 읽음
문자열·COMMENT 를 걸러내고, 같은 이름의 가장 이른 생성 시점과 비교한다.
남는 조기 사용은 app.uid()·facility_sibling_ids 둘뿐(16 → 2).

baseline-manual.sql 이 복원한 객체는 자동 파트에서 빼 중복 정의를 막는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
create extension pg_cron 은 cron.database_name 이 가리키는 DB 에서만 된다
(기본 postgres). 서비스 컨테이너로는 postgres 실행 인자를 줄 수 없어 docker run
으로 직접 띄우고 cron.database_name=pmdb_replay 를 넘긴다.

이미지 기본 DB 를 안 쓰는 이유도 진단으로 확정했다 — postgres 롤은 수퍼유저가
아니고 storage 스키마는 supabase_admin 소유이며 테이블이 하나도 없다. 그대로
쓸 수도, 스텁을 얹을 수도 없다. 새 DB 에서는 우리가 소유자라 둘 다 해결된다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docker exec pg_isready 는 엔트리포인트의 초기화용 임시 서버를 잡아 곧바로
통과한다(그 뒤 서버가 재기동되어 호스트 연결이 끊긴다). 실제 포트로 재고,
실패 시 컨테이너 로그를 남긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
postgres -c … 로 넘기면 이미지 기본 커맨드를 덮어써 설정 경로가 날아간다
(pg_stat_statements must be loaded via shared_preload_libraries). ALTER SYSTEM 은
수퍼유저 전용이라 쓸 수 없으므로, 기동 후 config_file 에 덧붙이고 재기동한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cron.database_name 을 작업용 DB 로 맞춘 뒤로는 20260701150000 의
create extension pg_cron 이 실제로 성공한다. 스텁이 만든 schema cron 이
그 확장 설치를 막는다(schema "cron" already exists). 스케줄을 쓰는
마이그레이션은 전부 그 뒤라 스텁 없이도 순서가 맞는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
server version: 17.6 / pg_dump version: 16.14 로 덤프가 거부됐다.
postgresql-client-17 을 깔아도 러너에 먼저 있는 16 이 PATH 앞자리라
/usr/lib/postgresql/17/bin 을 명시적으로 앞세운다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
콘솔 출력은 잘려서 어떤 함수가 어긋났는지 다 볼 수 없다. 전체 diff 를
replay-diff.txt 로 남기고 실패 시 아티팩트로 올린다(콘솔은 앞 150줄).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
텍스트 대조로는 왕복 아티팩트가 계속 나온다. pg_dump 가 뽑은
  check (status in ('a','b')) → = ANY ((ARRAY['a','b'])::text[])
를 되먹이면 파서가 원소별 캐스트로 접어 글자가 달라진다(의미는 동일).
스냅샷도 작업용 DB 에 한 번 복원했다 덤프해서 같은 처리를 거치게 하면 상쇄된다.

ACL 차이도 드리프트가 아니라 환경 차이였다 — Supabase 프로젝트의 public 스키마
기본 권한(ALTER DEFAULT PRIVILEGES) 때문에 새 테이블이 GRANT 없이도
anon/authenticated/service_role 권한을 갖는다. 운영 DB 의 pg_default_acl 을 그대로
읽어 replay-stubs 에 재현한다(함수는 PUBLIC 을 걷어내야 운영과 같아진다).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
리플레이 결과에 트리거 6개와 FK 2개가 통째로 빠져 있었다. 원인 둘:

  · 마이그레이션의 `alter table … add column if not exists` 는 베이스라인에 이미
    그 컬럼이 있으면 통째로 no-op 이 된다. 같은 문장에 달린 REFERENCES·CHECK 도
    그때 함께 사라진다(posts.photo_verification_id → …_fkey). 베이스라인은
    '마이그레이션 이전 상태' 여야 하므로 그 컬럼들을 빼는 게 원래 맞다.
  · 트리거 함수를 참조한다는 이유로 CREATE TRIGGER 블록까지 제외했는데, 그 트리거를
    다시 만드는 마이그레이션이 없어 유실됐다. 트리거는 남기고, 대신 그것이 참조하는
    함수를 베이스라인에 되살린다(인자 없는 트리거 함수라 뒤 or replace 도 안전).

비교 방식도 두 가지 손봤다.
  · A(스냅샷 복원)에는 기본 권한을 걸지 않는다 — 스냅샷은 이미 결과(GRANT 문)를
    담고 있어 그 위에 또 얹으면 운영보다 넓어진다. 기본 권한은 B 에만 필요하다.
  · IN 목록의 두 가지 표기(배열 통째 캐스트 / 원소별 캐스트)를 정규화한다.
    같은 제약이 어떤 텍스트에서 만들어졌느냐에 따라 갈려 가짜 차이를 만든다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pg_dump 헤더의 이름은 스키마 없이 온다(`FUNCTION needs_photo_gate(…)`).
그대로 파싱하면 public 으로 읽혀 app 스키마 함수의 COMMENT 가 제외되지 않고
남는다(function app.needs_photo_gate(integer) does not exist). 블록 헤더의
Schema 를 기본값으로 쓰고, TYPE 코멘트도 키를 잡도록 보강한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
chat_rooms.context 를 빼면서 chat_rooms_context_check 를 남겨 두면
ERROR: column "context" does not exist 로 깨진다. 그 제약은 컬럼과 같은
ALTER 문에서 마이그레이션이 다시 붙인다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
컬럼을 빼면 그 컬럼의 COMMENT ON COLUMN 과 ALTER COLUMN … SET DEFAULT 도
갈 곳이 없다(column ai_ref_image_path of relation pets does not exist).
컬럼과 함께 마이그레이션이 붙이므로 베이스라인에서 뺀다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pawings_uq 처럼 뺀 컬럼(context)을 키에 포함하는 제약이 남으면
ERROR: column "context" named in key does not exist. 컬럼에 딸린 블록 제거를
COMMENT/DEFAULT 에서 '그 컬럼을 참조하는 모든 부속 블록' 으로 넓힌다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
20260717010000 은 `drop constraint pawings_uq`(IF EXISTS 없음) 로 시작한다.
그 제약은 기반 스키마에 2컬럼(follower_id, following_id)으로 있었고 마이그레이션이
context 를 넣어 3컬럼으로 바꾼다. context 컬럼을 베이스라인에서 빼면서 제약도
함께 빠져 drop 이 실패했다 — 2컬럼 형태를 baseline-manual.sql 에 복원한다.

앞으로 같은 상황을 손으로 찾지 않도록, 컬럼 때문에 빠진 제약·인덱스를
IF EXISTS 없이 drop 하는 마이그레이션이 있으면 --report 가 짚어 준다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
마이그레이션에 방어적으로 써 둔 `add column if not exists` 때문에, 이미 기반
스키마에 있던 컬럼까지 '마이그레이션이 붙인 것' 으로 보고 빼고 있었다. 그러면
리플레이에서 컬럼 순서가 어긋난다(pawings.notified — 운영은 context 앞인데
마이그레이션 순서로는 뒤).

ADD COLUMN 은 항상 맨 뒤에 붙으므로, 진짜 마이그레이션 소관 컬럼은 운영 스키마에서
'마이그레이션 순서대로 늘어선 꼬리' 를 이룬다. 뒤에서부터 그 조건이 깨지는 지점까지만
빼도록 바꿨다 — 운영 스키마 자체를 판별 기준으로 쓴다.

GRANT/REVOKE 나열 순서는 권한을 준 차례에 따라 갈리는데 의미와 무관하므로
비교 전에 정렬한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
베이스라인은 운영의 최종 ACL 을 GRANT 문으로 담고 있어 빈 ACL 에서 출발해야
정확히 재현된다. 기본 권한을 먼저 걸면 그 위에 GRANT 가 얹혀 운영보다 넓어졌다
(admin_logs 가 SELECT,MAINTAIN 이 아니라 ALL). 반대로 마이그레이션이 만드는
객체는 운영에서 기본 권한을 받은 상태여야 한다 — 그래서 그 사이에 끼운다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
리플레이 CI 가 찾아낸, 마이그레이션 없이 운영에 직접 적용돼 있던 정의들을
저장소로 되돌린다. 전부 schema.sql(운영 덤프)에서 그대로 가져왔으므로 운영
적용은 no-op 이다.

특히 두 가지:

  1) app.dispatch_engagement_notifications 의 pawings 조인에서
     `and w.context = p.authored_as` 가 빠져 있었다. 20260718160000 이 넣은
     조건을 20260720090000 의 create or replace 가 조용히 지웠다(운영에는 살아
     있다). 저장소만 보고 재생하면 업체 소식이 개인 팔로워에게도 나가는 상태가
     된다 — 공유 함수 재정의로 남의 변경이 유실되는 그 패턴이다.

  2) 간이 회원(status='lite') 관련이 통째로 저장소 밖이었다 —
     users_status_check 의 'lite', add_facility_review 의 app.uid_lite() 와
     재인증 게이트, 후기 조회의 전화번호 마스킹.

함수 36개 + 권한만 다른 함수 8개 + facilities 코멘트.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dump_schema.sh 는 ALTER DEFAULT PRIVILEGES 문만 걸러내고 pg_dump 가 붙인
`-- Name: … Type: DEFAULT ACL` 머리말은 남긴다. 기본 권한이 걸린 쪽(리플레이)에만
그 껍데기가 생겨 마지막 가짜 차이로 남았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DEFAULT ACL 껍데기를 걷어내면 앞뒤 빈 줄 개수가 한쪽만 달라진다. 빈 줄 수는
의미가 없으므로 연속 빈 줄을 한 줄로 모아 비교한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
빈 줄 압축으로는 블록을 걷어낸 자리의 구분 빈 줄 하나가 계속 어긋난다.
빈 줄은 의미가 없으므로 아예 지우고 비교한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@seizeh
seizeh merged commit 6565eb3 into main Jul 29, 2026
2 checks passed
@seizeh
seizeh deleted the ci/migration-replay branch July 29, 2026 10:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant