논리적 복제(logical replication)를 사용한 무중단 Postgres 마이그레이션: 실제로 사용했던 체크리스트
요약
기존의 pg_dump 방식은 서비스 중단이 불가피하지만, 논리적 복제(logical replication)를 사용하면 쓰기 작업을 중단하지 않고 대규모 Postgres 데이터베이스 마이그레이션이 가능합니다. 본문은 74GB 규모 데이터를 무중단으로 이전한 경험을 바탕으로 필수 체크리스트와 SQL 코드를 제공하며, 성공적인 DB 이전을 위한 실질적인 가이드라인을 제시합니다.
핵심 포인트
- 논리적 복제는 스냅샷 방식의 한계를 극복하는 핵심 기술입니다.
- 마이그레이션 전 `wal_level = logical` 설정 확인 및 활성화가 필수입니다.
- 중복 키 오류 방지를 위해 대상 테이블을 제외하고 게시해야 합니다.
- 복제 스트림 유지를 위해 주기적인 서비스 핑(ping) 처리가 필요합니다.
프로덕션 데이터베이스를 이전하는 것에 대한 대부분의 조언은 “유지보수 기간을 잡고 pg_dump를 실행하라”는 내용으로 귀결됩니다. 이것은 고객들이 24시간 내내 제품을 사용하고 여섯 개의 서비스가 동일한 데이터베이스에 쓰기 작업을 수행할 때까지 작동합니다.
이것이 저희의 상황이었습니다. 자체 콘솔(모든 워크스페이스, 프로젝트 및 API 키) 뒤에 있는 데이터베이스는 Amazon RDS에 있었고, 저희는 사용자들을 위해 운영하는 관리형 Postgres인 Prisma Postgres로 이를 옮기고 싶었습니다. 쓰기 작업을 중단하지 않고 83개의 테이블과 74 GB를 이동했으며, 그 후에도 하루 동안 RDS로 다시 전환해도 행을 하나도 잃지 않았습니다.
이것은 저희가 같은 일을 앞둔 친구에게 건네줄 체크리스트이며, 중요했던 SQL 코드와 우리를 힘들게 했던 한 가지 사항입니다.
논리적 복제(logical replication)를 사용해야 하는 이유: 덤프/복원 방식 대신
덤프 및 복원은 스냅샷일 뿐입니다. 스냅샷 이후에 작성된 모든 것은 복원이 완료될 때까지 쓰기 작업을 중단하지 않는 한 손실됩니다. 74 GB 규모에서는 짧은 중단 시간이라고 할 수 없습니다.
하지만 규칙이 있으며, 우리가 부딪혔던 모든 문제점들은 그중 하나였습니다.
사전 점검 체크리스트(The pre-flight checklist)
저희는 무언가를 생성하기 전에 이 항목들을 모두 거쳤습니다. 하나라도 건너뛰면 마이그레이션이 중단됩니다.
1. 소스에서 논리적 디코딩(Logical decoding) 활성화. Postgres가 행 변경 사항을 게시하려면 wal_level = logical이 필요합니다. RDS에서는 rds.logical_replication 매개변수를 통해 이를 설정할 수 있습니다. 저희는 이미 이 설정이 되어 있었지만, 변경하는 것이 재부팅을 필요로 하므로 확인해야 합니다.
SELECT c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
...
3. 대상(Target)에 이미 스키마가 존재해야 함. 복제는 테이블이 아닌 행을 복사합니다. 저희는 일반 CI 파이프라인을 실행하여 204개의 마이그레이션을 새 데이터베이스에 모두 적용함으로써, 양쪽 모두 동일한 스키마를 갖도록 했습니다.
4. 대상 테이블은 비어 있지만, 대비해야 할 예외가 하나 있습니다. CI가 이미 새 데이터베이스를 마이그레이션했기 때문에, 마이그레이션 이력 테이블이 가득 차 있었습니다. 이전 데이터를 그대로 복사하면 중복 키(duplicate keys) 오류로 실패할 것입니다. 따라서 우리는 모든 테이블을 이름별로 게시하고 해당 테이블만 제외했습니다. 만약 히스토리를 테이블에 추적하는 어떤 마이그레이션 도구를 사용한다면, 동일한 문제가 발생할 것입니다.
6. 대상(target)은 깨어 있습니다. 저희가 사용했던 방식은 클라이언트 연결이 없는 상태에서 일시 정지되는 것이고, 복제 스트림(replication stream)은 카운트되지 않기 때문에, 복사하는 동안 몇 초 간격으로 작은 서비스 핑(ping)을 보내 대상에 신호를 보냈습니다.
7. 바이트가 어디 있는지 알고 있습니다. 두 개의 테이블이 전체 데이터의 약 4분의 3를 차지했습니다. 가장 큰 것은 보존 정책이 없는 웹훅 전송 로그였습니다. 복사 전에 30일 보존 작업(retention job)을 수행했다면, 기다리는 시간을 많이 절약할 수 있었을 것입니다. 물론 저희는 나중에 이 작업을 추가했습니다.
작동한 SQL
여기에 요약된 버전이 있습니다. 호스트 이름과 비밀번호는 자리 표시자입니다.
소스(source)에서 전용 복제 로그인 및 테이블 목록을 명시적으로 지정하는 게시물(publication)을 생성합니다:
CREATE ROLE migration_repl WITH LOGIN PASSWORD '<password>';
GRANT rds_replication TO migration_repl;
GRANT USAGE ON SCHEMA public TO migration_repl;
...
CREATE SUBSCRIPTION app_migration
CONNECTION 'host=<source> dbname=app user=migration_repl
password=<password> sslmode=require'
...
그런 다음 세 가지를 관찰하십시오: 대상에서의 테이블별 상태, 대상의 오류 카운터, 그리고 소스에서 슬롯(slot)이 보유하고 있는 WAL 양입니다.
-- 대상(target): r = ready
SELECT srsubstate, count(*) FROM pg_subscription_rel GROUP BY 1;
SELECT apply_error_count, sync_error_count FROM pg_stat_subscription_stats;
...
모든 82개 테이블이 같은 날 'ready' 상태에 도달했으며, 두 오류 카운터 모두 0이었습니다.
스스로를 속이지 않고 검증하기
대상(target)에서 먼저 ANALYZE를 실행하십시오. 초기 복사 과정은 플래너 통계(planner statistics)를 남기지 않으며, 이 통계가 없으면 첫 번째 실제 쿼리들이 제대로 계획되지 않을 것입니다.
다음으로 두 가지 검사를 사용했습니다. 첫째, 한 쿼리(dblink를 통해)로 양쪽의 모든 테이블에 있는 행 수를 계산하고 차이가 나는 테이블만 반환하도록 했습니다. 결과가 비어 있다는 것은 원하는 신호이며, 82개의 일치하는 숫자를 스크롤하며 확인하는 것보다 훨씬 신뢰하기 쉽습니다.
둘째는 몇몇 테이블의 전체 행에 대한 md5 해시를 이용한 검사입니다:
SELECT md5(string_agg(x::text, '|' ORDER BY id)) FROM "Organization" x;
이러한 해시는 변화가 적은 테이블에서는 일치했지만, 로그인할 때마다 접근하는 두 개의 테이블에서는 차이가 났습니다. 이는 단지 두 쿼리가 몇 초 간격으로 실행되었기 때문입니다. 이런 현상은 예상하고 당황하지 마십시오.
전환(The cutover): 하나의 병합, 모든 연결 경로
우리의 규칙은 모든 서비스와 각 서비스 내부의 모든 연결 경로는 같은 배포(deploy)에서 전환되어야 한다는 것이었습니다. 만약 한 경로만 이동하고 다른 경로는 그렇지 않다면, 해당 서비스는 한 데이터베이스에 행을 기록한 후 다른 데이터베이스에서 읽는 데 실패할 수 있습니다.
우리는 의도적으로 이 과정을 지루하게 만들었습니다:
- 며칠 전부터 모든 서비스가 이전 비밀 이름(secret name)에 폴백(fallback)하면서 새로운 비밀 이름에서 연결 문자열을 읽도록 학습했습니다. 동작 변화는 없었습니다.
- 몇 분 전에 새로운 비밀 이름을 생성했습니다. 실행 중인 서비스들은 재배포되지 않으면 이를 인식하지 못하므로, 여전히 아무런 변화가 없습니다.
- 하나의 풀 리퀘스트(pull request)를 병합했고, 배포 과정에서 모든 서비스가 새 데이터베이스로 전환되었습니다.
배포되는 몇 분 동안 일부 서비스는 이전 데이터베이스에 쓰기 작업을 하고 일부는 새로운 데이터베이스에 쓰기 작업을 했습니다. 순방향 복제(forward replication)는 계속해서 구측의 쓰기 작업을 전달했습니다. 키 충돌(key collision)은 적용 오류(apply error)로 나타났고 (0으로 유지되었습니다). 두 측면에서 같은 행을 업데이트하는 것은 어디에서도 표시되지 않았는데, 이것이 우리가 그 시간 창을 짧게 유지하는 데 크게 신경 쓴 이유입니다. RDS에 대한 마지막 쓰기 작업은 병합 후 7분 뒤에 발생했습니다.
일주일 동안 보존한 롤백(The rollback we kept for a week)
이것이 가장 추천하고 싶은 부분입니다. 전환 이후, 우리는 복제의 방향을 되돌려 이전 데이터베이스가 새로운 데이터베이스를 따라가도록 했습니다:
- 병합 몇 분 전에 새 데이터베이스에 논리적 복제 슬롯(logical replication slot)을 생성합니다. 이 슬롯은 그 순간부터의 모든 변경 사항을 유지하므로 롤백이 전환 자체를 포함할 수 있습니다. 며칠 전에 미리 만들지 마세요. 읽지 않은 슬롯 하나가 저희에게 시간당 약 1기가바이트의 WAL(Write-Ahead Log)을 잡아먹고 있었습니다.
- 이전 쪽이 조용해졌을 때 (쓰기 카운터(write counters)를 60초 동안 샘플링한 후), 포워드 구독(forward subscription)을 삭제합니다.
되돌리는 것은 하나의 커밋을 되돌리는 것을 의미했을 것입니다. 매일 행 개수(row count) 확인을 일주일간 진행한 끝에, 저희는 역방향 구독(reverse subscription)을 삭제했습니다 (슬롯이 연결되어 있었기 때문에 트랜잭션 블록 외부에서 처리). RDS를 스냅샷하고 종료했습니다.
우리가 어려움을 겪었던 부분
논리적 복제는 스키마 변경 사항을 복제하지 않습니다. ALTER TABLE도, DDL(Data Definition Language)도 전혀 아닙니다. 복제가 양방향으로 실행되더라도, 모든 마이그레이션은 두 데이터베이스 모두에서 실행되어야 합니다.
저희는 그것을 알고 있었습니다. 각 데이터베이스마다 하나의 CI(Continuous Integration) 마이그레이션 작업이 있었습니다. 하지만 저희의 전환 변경 사항 중 하나가 실수로 오래된 데이터베이스의 작업을 새 데이터베이스를 향하도록 지정했습니다. 전환 후 52분 만에, 마이그레이션이 새 쪽에만 세 개의 컬럼을 추가했고, 역방향 스트림은 그 컬럼들을 사용한 첫 번째 행에서 멈췄습니다. 저희는 약 15분 후에 RDS에 동일한 SQL을 수동으로 적용했고, 스트림은 손실된 데이터 없이 따라잡았습니다.
교훈은 지루하지만 현실적입니다: 연결 설정을 변경하기 전에 CI 작업 등을 포함하여 그것을 사용하는 모든 것을 목록화해야 합니다.
만약 다시 한다면
저희는 다음 사항들을 유지할 것입니다: 이름으로 게시된 테이블(tables published by name), 구독 전에 dblink 연결성 테스트, 불일치만 확인하는 쿼리(mismatch-only verification queries), 모든 연결 경로마다의 배포(deploy), 그리고 역방향 복제 롤백.
다음 사항들을 변경할 것입니다: 먼저 각 연결 문자열을 누가 사용하는지 감사하고(audit), 복사하기 전에 노이즈가 많은 테이블에 보존 기간(retention)을 추가할 것입니다.
또한, 저희는 AI 코딩 에이전트와 페어링했습니다. 이 에이전트는 모든 쿼리를 초안 작성하고 결과를 읽었으며, 인간은 프로덕션에 영향을 미치는 모든 구문을 실행했습니다. 그 분할 방식이 잘 작동했습니다.
저희는 모든 SQL 파일과 단계별 타임라인을 포함하여 전체 과정을 [블로그에서] 작성했습니다. 실제 데이터베이스에서 이를 연습하고 싶다면, [Prisma의 관리형 Postgres 호스팅]은 카드 없이 이용할 수 있는 무료 플랜을 제공합니다. 이 플랜은 월 200,000회 작업 및 2 GB의 스토리지를 갖춘 Prisma Postgres와, 1M 요청, 360 GB-시간 메모리, 4개의 활성 vCPU-시간, 그리고 월 10 GB의 아웃바운드 대역폭을 제공하는 Prisma Compute를 포함합니다. 어떤 단계에 대해 질문이 있으신가요? 댓글로 문의해 주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기