메이저 버전 업그레이드는 미루기 쉬운 일이다. RDS의 in-place 업그레이드는 버튼 한 번으로 끝나는 대신 그 시간만큼 DB가 멈춰 있고, 데이터가 크면 중단이 수십 분으로 늘어난다. 서비스를 세워 둘 수 없는 상황에서 14가 곧 지원 종료를 앞두고 있다면 결국 다른 방법을 찾게 된다. 이 글은 그럴 때 쓰는 논리적 복제 기반 cutover를 다룬다.
결론부터 말하자면, PostgreSQL 16 인스턴스를 따로 띄워 논리적 복제로 14의 데이터를 실시간 복제해 두고, 변경이 거의 따라잡힌 시점에 애플리케이션 접속 대상만 16으로 바꾸는 방식이다. 전체 복제가 백그라운드에서 끝나 있으므로 실제 중단은 쓰기를 멈추고 접속을 전환하는 cutover 구간, 환경에 따라 다르지만 대략 수십 초 안팎으로 줄어든다. 완전 무중단은 아니다.
1. 논리적 복제가 무엇인가
PostgreSQL의 논리적 복제(logical replication)는 WAL(write-ahead log, 변경을 먼저 기록하는 로그)을 디코딩해서 테이블 단위의 INSERT, UPDATE, DELETE 같은 논리적 변경으로 바꿔 구독자에게 보내는 방식이다. 블록 단위를 그대로 복사하는 물리적 복제(스트리밍 복제)와 달리 서로 다른 메이저 버전 사이에서도 동작한다. 14에서 16으로 데이터를 흘려보낼 수 있는 이유가 여기 있다.
구성 요소는 두 가지다. 원본에서 어떤 테이블의 변경을 내보낼지 정의하는 publication, 대상에서 그 publication을 받아 적용하는 subscription이다. subscription을 만들면 원본에 논리적 복제 슬롯이 생긴다. 슬롯은 구독자가 어디까지 받아 갔는지를 원본이 기억하게 해 주는 장치로, restart_lsn(슬롯이 보존해야 하는 가장 오래된 WAL 위치)부터 WAL을 붙잡아 둔다. 구독자가 느리면 이 슬롯 때문에 원본의 WAL이 계속 쌓여 디스크를 채울 수 있으니 복제 지연은 반드시 지켜봐야 한다.
2. 사전 준비
원본(publisher)에서 논리적 복제를 켜야 한다. EC2에 직접 올린 PostgreSQL이면 postgresql.conf에서 wal_level = logical로 바꾸고 재시작한다.
-- EC2 자체 설치본 ALTER SYSTEM SET wal_level = 'logical'; -- 재시작 후 확인 SHOW wal_level; wal_level ----------- logical
RDS for PostgreSQL이면 postgresql.conf를 직접 못 건드리므로 파라미터 그룹에서 rds.logical_replication을 1로 설정한다. 이 값은 정적(static) 파라미터라 적용하려면 인스턴스 재부팅이 필요하다. AWS 문서에 따르면 이 파라미터를 켜면 wal_level, max_wal_senders, max_replication_slots, max_connections가 함께 조정된다. WAL 생성량이 늘 수 있으니 실제로 논리적 슬롯을 쓸 때만 켜는 것이 좋다.
aws rds modify-db-parameter-group \ --db-parameter-group-name pg14-logical \ --parameters "ParameterName=rds.logical_replication,ParameterValue=1,ApplyMethod=pending-reboot" aws rds reboot-db-instance --db-instance-identifier sarc-pg14
재부팅 뒤 원본에서 SHOW wal_level;이 logical로 나오면 준비가 됐다. 작업 계정은 복제 슬롯을 다룰 권한이 있어야 한다. RDS에서는 rds_replication 역할을 부여한다.
대상(subscriber)은 PostgreSQL 16 인스턴스를 새로 만든다. RDS면 16 엔진으로 신규 인스턴스를, EC2면 16을 설치한다. 네트워크는 대상에서 원본으로 5432 접속이 열려 있어야 한다. 보안 그룹과 pg_hba.conf(또는 RDS 보안 그룹)를 먼저 확인한다.
3. 스키마를 먼저 옮긴다
논리적 복제는 데이터만 복제한다. 테이블 정의, 인덱스, 시퀀스, 함수 같은 스키마 객체는 복제되지 않으므로 대상에 미리 만들어 두어야 한다. 데이터 없이 구조만 덤프해서 적용한다.
# 원본에서 스키마만 덤프 (데이터 제외) pg_dump -h sarc-pg14.xxxx.ap-northeast-2.rds.amazonaws.com \ -U admin -d sarc --schema-only --no-owner --no-acl -f schema.sql # 대상 16 인스턴스에 적용 psql -h sarc-pg16.xxxx.ap-northeast-2.rds.amazonaws.com \ -U admin -d sarc -f schema.sql
여기서 한 번 걸리기 쉽다. UPDATE와 DELETE를 복제하려면 각 테이블에 기본키가 있거나 REPLICA IDENTITY가 설정돼 있어야 한다. 기본키 없는 테이블이 있으면 그 테이블의 UPDATE/DELETE는 복제 중 에러가 난다. 다음으로 기본키 없는 테이블을 먼저 찾아 둔다.
SELECT n.nspname, c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r' AND n.nspname = 'public'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.oid AND i.indisprimary);
나오는 테이블에는 적절한 유니크 인덱스를 걸고 ALTER TABLE ... REPLICA IDENTITY USING INDEX로 지정하거나, 임시로 REPLICA IDENTITY FULL을 준다. FULL은 모든 컬럼을 키로 비교해서 느리니 큰 테이블에는 권장하지 않는다.
4. publication과 subscription 생성
원본에서 복제할 테이블을 묶는 publication을 만든다. 데이터베이스 전체를 옮긴다면 모든 테이블을 대상으로 잡는다.
-- 원본(14)에서 CREATE PUBLICATION pub_upgrade FOR ALL TABLES; SELECT pubname, puballtables FROM pg_publication; pubname | puballtables -------------+-------------- pub_upgrade | t
대상에서 subscription을 만들면 초기 적재가 시작된다. 원본의 현재 테이블 데이터를 전부 복사한 뒤, 그 시점부터의 변경을 이어서 받는다.
-- 대상(16)에서 CREATE SUBSCRIPTION sub_upgrade CONNECTION 'host=sarc-pg14.xxxx.ap-northeast-2.rds.amazonaws.com port=5432 dbname=sarc user=admin password=***' PUBLICATION pub_upgrade; NOTICE: created replication slot "sub_upgrade" on publisher CREATE SUBSCRIPTION
초기 적재 진행은 대상의 pg_stat_subscription에서 본다. worker_type이 table synchronization인 행이 테이블별 초기 복사 작업이다. 이 행들이 사라지고 apply 워커만 남으면 초기 복사가 끝나 변경 적용 단계로 넘어간 것이다.
-- 대상(16)에서 SELECT subname, worker_type, relid, received_lsn, latest_end_lsn FROM pg_stat_subscription;
5. 따라잡음 확인
cutover 판정에서 가장 조심할 부분이다. 지연은 원본(publisher)에서 pg_stat_replication으로 본다. 여기서 구독자에 해당하는 행은 application_name이 subscription 이름(sub_upgrade)으로 잡힌다.
컬럼 의미를 정확히 짚어야 한다. sent_lsn은 이 연결로 마지막으로 '보낸' WAL 위치, replay_lsn은 구독자에 마지막으로 '적용된' WAL 위치다. 따라서 sent_lsn - replay_lsn은 '보냈지만 아직 적용 안 된 양'이다. 이 값만 0으로 보고 전환하면 위험하다. 원본이 아직 전송하지 않은 커밋(pg_current_wal_lsn() > sent_lsn)이 남아 있어도 저 차이는 0으로 보일 수 있기 때문이다. 전체 따라잡음은 원본의 현재 WAL 위치(pg_current_wal_lsn())와 replay_lsn을 비교해야 한다.
-- 원본(14)에서
SELECT application_name,
pg_current_wal_lsn() AS current_lsn,
replay_lsn,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes
FROM pg_stat_replication
WHERE application_name = 'sub_upgrade';
application_name | current_lsn | replay_lsn | lag_bytes
------------------+-------------+-------------+-----------
sub_upgrade | 58/B4120A90 | 58/B4120A90 | 0
lag_bytes가 꾸준히 작은 값(수 KB 이하)에 머물면 복제가 실시간으로 따라붙은 상태다. 쓰기를 멈추기 전까지는 0으로 완전히 떨어지지 않는 게 정상이다. 원본에 계속 커밋이 들어오기 때문이다. 0은 cutover 때 쓰기를 끊은 뒤 확인한다.
6. cutover
여기서만 서비스가 잠깐 멈춘다. 순서가 중요하다.
- 애플리케이션의 원본 쓰기를 멈춘다. 배포로 읽기 전용 모드로 돌리거나, 원본에서 접속을 막는다. RDS면 보안 그룹에서 앱 접근을 잠깐 닫는 방법도 있다.
- 원본에서
lag_bytes가 0이 될 때까지 기다린다. 더 이상 커밋이 없으니 곧 0으로 떨어진다. - 시퀀스를 맞춘다. 논리적 복제는 시퀀스 현재값을 복제하지 않으므로 대상의 시퀀스가 1 같은 초기값에 머물러 있다. 전환 직전에 원본값으로 끌어올려야 한다.
- 애플리케이션 접속 대상을 16 엔드포인트로 바꾸고 쓰기를 연다.
시퀀스 보정은 원본에서 현재값을 뽑아 setval 문을 생성한 뒤 대상에서 실행하면 된다.
-- 원본(14)에서 setval 스크립트 생성
SELECT 'SELECT setval(' || quote_literal(quote_ident(schemaname)||'.'||quote_ident(sequencename))
|| ', ' || last_value || ', true);'
FROM pg_sequences
WHERE last_value IS NOT NULL;
-- 출력된 문장들을 대상(16)에서 실행
접속 전환은 애플리케이션 설정에서 DB 호스트만 16으로 바꾸는 일이다. Spring 앱이면 application.properties의 spring.datasource.url, Next.js 앱이면 DATABASE_URL 한 줄이다. 전환 후 16에서 신규 쓰기가 정상 반영되는지 확인하면 cutover가 끝난다.
전환이 안정되면 대상에서 subscription을 제거한다. 그래야 원본의 복제 슬롯도 정리돼 WAL이 더 쌓이지 않는다.
-- 대상(16)에서 DROP SUBSCRIPTION sub_upgrade;
7. 자주 걸리는 지점
apply 워커가 멈추고 복제가 안 따라온다
대상의 로그에 logical replication apply worker 에러가 반복되면 변경 적용이 막힌 것이다. 원인은 하나로 단정하기 어렵다. 제약 위반(중복 키 등), 타입 불일치, 복제 중 생긴 스키마 변경으로 인한 컬럼 불일치, 네트워크나 슬롯 문제까지 여러 경로가 있다. 자주 보이는 원인 중 하나가 복제 중 원본에서 실행된 DDL이다. DDL은 복제되지 않으므로 복제 기간에는 원본 스키마를 바꾸지 않는 것이 안전하다. 대상 로그에서 실제 에러 메시지를 먼저 확인하고 그에 맞게 대응한다.
원본 디스크가 차오른다
구독자가 느리거나 멈추면 슬롯이 WAL을 붙잡아 원본 스토리지가 찬다. pg_replication_slots의 wal_status와 restart_lsn을 보고, 지연이 비정상적으로 벌어지면 원인을 먼저 잡는다. 쓰지도 않을 슬롯을 그대로 두는 것이 가장 위험하다.
-- 원본(14)에서
SELECT slot_name, active, wal_status, restart_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;
큰 테이블 초기 복사가 느리다
초기 적재는 테이블별로 COPY가 돈다. 수십 GB 테이블이 섞여 있으면 이 단계가 길어진다. 전환 일정을 잡을 때 초기 복사 시간을 미리 측정해 두고, 복사가 끝나 table synchronization 워커가 모두 사라진 뒤에 cutover를 계획한다.
8. 정리
논리적 복제 cutover는 메이저 업그레이드를 서비스 중단 최소화로 넘기는 현실적인 방법이다. 핵심은 세 가지다. 스키마와 시퀀스는 복제되지 않으니 사람이 챙긴다. 따라잡음은 sent_lsn 차이가 아니라 pg_current_wal_lsn()과 replay_lsn 비교로 판정한다. cutover 구간 수십 초의 쓰기 중단은 감수하되, 쓰기 차단, 지연 0 확인, 시퀀스 보정, 접속 전환의 순서를 지킨다.
버전 번호나 출력은 인스턴스 상태에 따라 다를 수 있다. 실제 전환 전에 복제본으로 리허설을 한 번 돌려 보는 것을 권한다.