Prisma 마이그레이션 이전 리허설: migrate diff로는 못 잡는 것들
운영 중인 서비스의 데이터베이스를 온프레미스에서 Supabase로 옮기는 중입니다. 지난번에 1단계로 앱을 무상태화했고, 이번이 2단계인 스키마 복제입니다. 프로젝트를 만들고 접속 문자열을 받아 prisma migrate deploy 한 줄 돌리면 끝나는 일이라고 생각했습니다. 그 전에 빈 데이터베이스에 한 번 리허설을 해봤는데, 거기서 인덱스 하나가 사라질 뻔한 걸 발견했습니다.
마이그레이션을 처음부터 돌려본 적이 없었습니다
생각해보니 이상한 일이었습니다. 이 프로젝트에는 마이그레이션 파일이 60개 쌓여 있는데, 그 60개를 빈 데이터베이스에 순서대로 전부 적용해본 적이 한 번도 없었습니다. 개발 DB는 첫 마이그레이션부터 조금씩 쌓아온 결과물이고, 새 마이그레이션이 추가될 때마다 마지막 한 개만 적용됩니다. 즉 "지금까지의 전체 체인이 0부터 통과하는가"는 아무도 확인한 적이 없는 상태였습니다.
이전 대상이 될 Supabase 프로젝트는 완전히 빈 데이터베이스입니다. 거기서 처음으로 60개가 한 번에 돌아갑니다. 중간에 하나라도 실패하면 실패 기록이 남아서 이후 배포가 전부 막히는 구조라, 실제 프로젝트에서 처음 시도하고 싶지는 않았습니다.
그래서 로컬에 빈 PostgreSQL 17.11 컨테이너를 띄우고 거기에 먼저 돌렸습니다. 옮겨갈 곳과 같은 메이저 버전으로 맞췄습니다.
60개 전부 통과했습니다. 결과는 테이블 50개, enum 27개, 인덱스 208개, 제약 181개, 시퀀스 39개였습니다. 참조 데이터 시드(국가 195개, 지역 17개와 시군구 229개, 비자 12종)도 정상적으로 들어갔습니다.
여기까지는 좋았습니다.
인덱스 하나가 데이터모델에 없었습니다
마이그레이션이 통과했다고 끝이 아닙니다. 그렇게 만들어진 데이터베이스가 데이터모델 정의와 일치하는지는 별개 문제입니다. Prisma에는 이걸 확인하는 migrate diff 명령이 있어서, 방금 만든 DB와 schema.prisma를 비교해봤습니다.
두 건이 나왔습니다. 그중 하나가 진짜였습니다.
[*] Changed the `users` table
[-] Removed index on columns (profile_image_id)프로필 사진 참조 컬럼의 인덱스입니다. 이 인덱스는 raw SQL 마이그레이션 안에서 CREATE INDEX로 직접 만들었습니다. 그런데 schema.prisma의 모델 정의에는 @@index 선언을 넣지 않았습니다. 데이터베이스에는 있고 데이터모델에는 없는 상태입니다.
이 상태로 다음에 prisma migrate dev를 돌리면 어떻게 될까요. Prisma는 데이터모델을 기준으로 삼기 때문에, "DB에는 있는데 모델에 없는 인덱스"를 지워야 할 대상으로 판단합니다. DROP INDEX가 들어간 마이그레이션이 자동으로 생성됩니다. 스키마 변경과 함께 커밋되고, 배포되고, 인덱스는 조용히 사라집니다.
이 인덱스는 데이터 보존 정책에 따라 오래된 이미지를 정리하는 배치가 매시간 쓰는 경로에 걸려 있습니다. 없어져도 에러는 나지 않습니다. 그냥 느려집니다. 아마 한참 뒤에 "이 배치가 원래 이렇게 오래 걸렸나" 하고 의심하게 됐을 겁니다.
raw SQL로 인덱스를 만들 때는 데이터모델에도 같은 선언을 넣어야 한다는 건 알고 있었는데, 그때는 빠뜨렸습니다. 이번에 빈 DB로 리허설을 안 했으면 계속 몰랐을 것 같습니다.
매번 뜨는 diff는 오탐이었습니다
나머지 한 건은 성격이 달랐습니다.
[*] Changed the `business_verifications` table
[-] Removed unique index on columns (user_id)
[+] Added unique index on columns (user_id)같은 컬럼의 유니크 인덱스를 지웠다가 다시 만든다고 나옵니다. 이건 사업자 인증 테이블의 부분 유니크 인덱스입니다. "인증이 살아 있는 상태의 행은 사용자당 하나만" 이라는 규칙을 DB가 강제하도록 걸어둔 것입니다. 조건절이 붙습니다.
CREATE UNIQUE INDEX uq_bv_user_active ON business_verifications(user_id)
WHERE (status IN ('VERIFIED', 'CERTIFICATE_SUBMITTED', 'OFFICIALLY_VERIFIED', 'CERTIFICATE_REJECTED'));처음에는 이것도 진짜 드리프트라고 생각했습니다. 그래서 실제로 DB에 저장된 조건식을 꺼내봤습니다.
(status = ANY (ARRAY['VERIFIED'::"BusinessVerificationStatus", ...]))제가 쓴 IN (...)이 아닙니다. Postgres는 IN 목록을 파싱하면서 = ANY (ARRAY[...]) 형태로 정규화해서 저장합니다. 원래 그렇게 동작합니다. 그리고 Prisma는 데이터모델에 적힌 IN 표현과 DB에서 읽어온 = ANY 표현을 같은 것으로 보지 못했습니다.
그러면 데이터모델 쪽을 = ANY 형태로 바꿔서 문자열을 맞춰주면 되지 않을까 싶었습니다. 바꿔서 다시 돌려봤습니다. 결과는 똑같았습니다. enum 타입 캐스팅이 붙은 배열 표현 자체를 비교하지 못하는 것으로 보입니다. 그래서 원래 표기로 되돌렸습니다. IN 쪽이 읽기 좋습니다.
같은 파일에 있는 다른 부분 인덱스는 조건이 role = 'ROOT' AND deleted_at IS NULL 처럼 단순해서 아무 문제 없이 통과합니다. 값 목록이 배열로 정규화되는 경우만 걸립니다.
여기서 원래 계획 하나를 접었습니다. migrate diff --exit-code를 CI에 게이트로 걸어서 "스키마와 마이그레이션이 어긋나면 빌드 실패" 를 만들려고 했는데, 항상 실패하는 검사는 게이트가 아닙니다. 몇 번 무시하고 나면 진짜 드리프트가 섞여 들어와도 아무도 안 봅니다.
그래서 카탈로그를 직접 대조했습니다
접은 김에 migrate diff의 다른 한계도 정리해두는 게 낫겠다고 생각했습니다.
migrate diff는 데이터모델과 DB를 비교합니다. 그런데 이 프로젝트의 마이그레이션에는 데이터모델에 표현되지 않는 것들이 꽤 있습니다. btrim(title) <> '' 같은 CHECK 제약, 복합 키를 참조하는 외래키, 전화번호 형식 검사. 전부 raw SQL로 넣었고 Prisma 데이터모델에는 대응하는 문법이 없거나 부정확합니다. 즉 이전 전후 두 데이터베이스가 정말 같은지를 migrate diff로는 증명할 수 없습니다.
그래서 시스템 카탈로그를 직접 읽어서 스냅샷을 만들고 두 스냅샷을 비교하는 스크립트를 하나 만들었습니다. 테이블, 컬럼 타입과 기본값, enum 라벨 순서, 인덱스 정의, 제약 정의, 시퀀스를 전부 뽑아서 정렬한 뒤 집합으로 비교합니다.
인덱스는 pg_indexes.indexdef를 그대로 씁니다. 부분 인덱스의 WHERE 절이 여기에 포함되기 때문에, 앞에서 문제가 됐던 조건절 차이도 이 방식으로는 정확히 잡힙니다. Postgres가 양쪽 다 같은 형태로 정규화해서 보여주니까요. 비교 대상을 사람이 쓴 문자열이 아니라 DB가 정규화한 결과로 바꾼 셈입니다.
서버 판본에 따라 공백이나 스키마 접두어가 달라질 수 있어서, 비교 전에 그 정도만 정규화합니다.
연결 문자열을 두 개로 나눠야 합니다
Supabase는 커넥션 풀러(Supavisor)를 앞에 둡니다. 서버리스 환경에서 함수 인스턴스가 늘어날 때마다 커넥션이 폭증하는 걸 막기 위해서입니다. 그런데 풀러의 트랜잭션 모드는 세션 상태를 유지하지 않기 때문에, 마이그레이션이 쓰는 어드바이저리 락이나 일부 DDL이 제대로 동작하지 않습니다.
그래서 런타임은 풀러(6543)로, 마이그레이션은 데이터베이스에 직접 붙는 연결(5432)로 나눠야 합니다. Prisma 설정 파일이 직결 주소가 있으면 그걸 먼저 쓰고, 없으면 기존 주소를 그대로 쓰도록 한 줄 고쳤습니다. 아직 옮기지 않은 환경은 동작이 바뀌지 않습니다.
아직 확인 못 한 것
리허설은 로컬 컨테이너에서 했습니다. 거기서는 제가 슈퍼유저입니다. 실제 Supabase에서는 접속 계정의 권한이 더 좁습니다.
초기 마이그레이션 맨 위에 CREATE SCHEMA IF NOT EXISTS "public" 이 한 줄 있습니다. Prisma가 자동으로 넣어준 것입니다. 스키마가 이미 있으면 넘어가는 구문이지만, 그 판단보다 권한 검사가 먼저 일어난다면 권한 부족으로 실패할 수 있습니다. 실제로 어느 쪽인지는 아직 확인하지 못했습니다.
여기서 곤란한 점은, 실패한다고 해서 이 줄을 지울 수 없다는 것입니다. Prisma는 적용된 마이그레이션 파일의 체크섬을 기록해두기 때문에, 파일을 고치면 이미 그 마이그레이션을 적용한 다른 데이터베이스들이 전부 어긋납니다. 문제가 생기면 마이그레이션이 아니라 대상 데이터베이스의 권한 쪽에서 풀어야 합니다.
프로젝트 생성 화면에서 하나 껐습니다
리허설과는 별개인데, Supabase 프로젝트 생성 화면에 Data API를 켜는 옵션이 기본으로 체크돼 있었습니다. public 스키마의 테이블을 PostgREST 기반 REST API로 노출해주는 기능이고, 클라이언트 라이브러리로 DB를 직접 다룰 때 쓰는 것입니다.
이 프로젝트는 ORM으로 직접 붙기 때문에 그 API를 쓸 일이 없습니다. 그리고 이 기능이 부여하는 권한은 anon 키로 접근할 수 있는데, anon 키는 클라이언트에 박히는 공개 값입니다. 저희 테이블에는 행 수준 보안 정책이 하나도 없습니다. 권한 검사는 전부 애플리케이션 계층에서 합니다. 켜둔 채로 마이그레이션을 돌렸으면 테이블 50개가 그대로 열렸을 겁니다.
바로 아래에 "새 테이블 자동 노출" 옵션도 같이 켜져 있었습니다. 화면에 Supabase 자신이 이걸 끄는 걸 권한다고 적어뒀는데, 기본값은 켜짐입니다. 마이그레이션으로 테이블을 대량 생성하기 직전이라 타이밍이 좋지 않았습니다. Data API 자체를 껐습니다. 나중에 필요해지면 설정에서 다시 켤 수 있습니다.
남은 순서
이제 실제 Supabase 프로젝트에 같은 절차를 돌리고, 로컬에서 만든 스냅샷과 대조하면 이 단계가 끝납니다. 카탈로그가 완전히 일치하면 그 다음부터는 애플리케이션을 붙여볼 수 있습니다.
돌아보면 리허설에 쓴 시간은 컨테이너 하나 띄우고 명령 몇 개 돌린 정도였습니다. 그걸로 인덱스 하나를 지켰고, CI에 걸려고 했던 검사가 쓸모없다는 것도 미리 알았습니다. 옮기는 작업 자체보다, 옮기기 전에 지금 상태를 정확히 아는 데 시간을 쓰는 편이 나은 것 같습니다.
함께 읽기
- Vercel·Supabase 이전 1단계: 계획을 코드로 다시 확인한 이유저는 이전 계획표에 번역과 이미지 변환을 기존 서버 유지로 적어뒀습니다. 둘 다 백그라운드 작업이니 상시 실행되는 서버가 필요할 것이라고 생각했습니다.
- 배포하면 CSS가 404: stale-while-revalidate 함정"모바일에서 사이트가 이상해요."
- 캐시 퍼지 순서: 오리진을 먼저 비워야 하는 이유엣지 캐시를 켜고 나니 대가가 하나 생겼습니다. 글을 고쳐 발행해도 최대 5분 동안 옛 내용이 나갔습니다. 캐시가 없던 때는 즉시 반영되던 일이라 후퇴처럼 느껴졌습니다.
- Cloudflare가 캐시하지 않는 3가지 이유: Vary, RSC, 언어 감지사이트를 Cloudflare 뒤에 두고 캐시 헤더를 제대로 붙였는데도 CF-Cache-Status가 계속 DYNAMIC으로 나오는 일을 겪었습니다. 한 번이 아니라 세 번이었고, 매번 다른 문제처럼 보였습니다.
- 한국에서 Cloudflare 엣지가 서울이 아닐 때: 무료 플랜의 POP 배정이미지 엣지 캐시를 손보고 나서 얼마나 빨라졌는지 재고 있었습니다. 캐시에서 나오면 156ms, 오리진까지 가면 622ms. 4배 차이니까 좋아하고 있었는데, 156ms라는 숫자가 자꾸 걸렸습니다.