Claude Code를 활용하여 1억 8천만 행 테이블의 무중단 PostgreSQL 마이그레이션 수행기
요약
1억 8천만 행의 PostgreSQL 테이블을 다운타임 없이 `int4`에서 `bigint`으로 마이그레이션하는 과정을 다룬 기술 기사입니다. Claude Code를 활용하여 전체 의존성을 분석하고, '확장/축소(expand/contract)' 패턴을 적용해 쓰기 잠금 없이 성공적으로 9일 만에 완료했습니다.
핵심 포인트
- PostgreSQL의 `ALTER TABLE`은 대용량 테이블에서 서비스 중단(outage) 위험이 높다.
- Claude Code를 활용하여 테이블 의존성 전체 그래프를 분석하는 것이 핵심이다.
- 쓰기 잠금 없이 마이그레이션을 수행하기 위해 '확장/축소' 패턴을 적용했다.
- 대규모 데이터베이스 변경 시, 단계적 접근과 에이전트의 도움을 받는 것이 중요하다.
요약 (TL;DR)
우리 events 테이블의 int4 기본 키가 2,147,483,647이라는 한계에 다다를 지경이었고, 이 테이블은 1억 8천만 행을 가지고 있었으며 다운타임에 대한 허용치가 전혀 없었다. 나는 Claude Code를 사용해 bigint으로의 확장/축소(expand/contract) 마이그레이션을 계획하고 작성했으며, 이는 9일 동안 진행되었고 쓰기 잠금(locked writes)은 발생하지 않았다. 이 에이전트는 지루한 80%의 과정에서 환상적인 부조종사 역할을 했으며, 프로덕션을 다운시킬 뻔했던 두 가지 부분에서는 자신감 있게 틀렸다.
문제점 (The Problem)
다음 경고가 시작이었다:
events_id_seq: 1,931,204,118 / 2,147,483,647 (89.9%)
projected exhaustion: ~41 days
events 테이블은 애플리케이션에서 가장 바쁜 테이블이다. 피크 시간대에는 초당 약 1,400개의 삽입(insert)이 발생하며, 외래 키를 통해 다른 세 개의 테이블에서 참조되고, 거의 모든 요청에서 API가 이 테이블을 읽는다. 이 테이블은 몇 년 전에 serial로 생성되었는데, 이는 int4를 의미한다. 시퀀스가 최대치에 도달하면 모든 삽입이 실패한다. '느려지는' 것이 아니다. 실패한다.
교과서적인 해결책은 한 줄이다:
ALTER TABLE events ALTER COLUMN id TYPE bigint;
그리고 그 한 줄은 함정이다. 이는 ACCESS EXCLUSIVE 잠금을 가져오고 전체 테이블과 그 위의 모든 인덱스를 다시 작성한다. 1억 8천만 행(인덱스 포함 약 210 GB)에 대해, 이 명령을 스테이징 환경에서 리허설했을 때 3시간 12분이 걸렸다. 핵심 테이블에 대한 3시간 동안의 읽기 및 쓰기 중단은 마이그레이션이 아니라 추가 단계가 붙은 서비스 중단(outage)이다.
내가 스스로에게 설정한 제약 조건:
- 어떤 구문도 2초 이상 무거운 잠금을 유지해서는 안 된다.
- 읽기 복제본(read replicas)의 복제 지연 시간은 5초 미만으로 유지되어야 한다.
- 최종 스왑 전까지 모든 단계는 되돌릴 수 있어야 한다.
- 예상치 못한 상황에 대비하여 41일 마감일보다 훨씬 전에 완료해야 한다.
설정 환경: 관리형 서비스의 PostgreSQL 16.4, Node.js 22.x API, 그리고 스키마 및 스테이징 데이터베이스에 읽기 권한을 가진 터미널에서 실행되는 Claude Code (당시 v2.x).
해결 방법 (How I Solved It)
단계 0: 에이전트에게 먼저 폭발 반경(blast radius)을 이해시키기
SQL을 작성하기 전에, 저는 Claude Code에게 events.id에 접근하는 모든 것을 매핑하도록 요청했습니다. 테이블뿐만 아니라 전체 그래프를요:
> events.id에 의존하는 모든 외래 키(foreign key), 인덱스(index), 뷰(view), 트리거(trigger), 그리고 애플리케이션 쿼리를 찾아라. 표로 출력해라. 아직 해결책을 제안하지 마라.
여기서 "아직 해결책을 제안하지 마라"라는 문구가 중요합니다. 이 문구가 없으면 에이전트가 바로 계획 수립으로 넘어가고 인벤토리 목록이 지저분해집니다. 이 문구 덕분에 저는 깔끔한 목록을 얻었습니다: 다른 테이블에 있는 3개의 FK 컬럼(역시 int4이며 마이그레이션이 필요함), 4개의 인덱스, 1개의 Materialized View, 그리고 애플리케이션 코드에서 id를 캐스팅하는 11개의 ORM 쿼리입니다. 이 중 두 개의 캐스팅은 제가 존재한다는 사실조차 몰랐던 것이었습니다.
단계 1: 확장 — 새 컬럼 추가 및 동기화 유지
우리가 결정한 계획은 전형적인 확장/축소(expand/contract) 패턴입니다:
flowchart LR
A[id_new bigint 추가] --> B[트리거가 새로운 쓰기를 동기화함]
B --> C[오래된 행들의 배치 백필(Batched backfill)]
...
기본값 없이 Nullable 컬럼을 추가하는 것은 최신 Postgres에서는 메타데이터만 변경하는 것이므로 즉시 완료됩니다:
SET lock_timeout = '2s';
ALTER TABLE events ADD COLUMN id_new bigint;
그다음, 모든 새 또는 업데이트된 행에 id_new가 자동으로 채워지도록 트리거를 만듭니다:
CREATE OR REPLACE FUNCTION events_sync_id_new() RETURNS trigger AS $$
BEGIN
NEW.id_new := NEW.id;
...
SET lock_timeout = '2s' 라인은 이 프로젝트의 모든 DDL(Data Definition Language) 문에 대한 규칙이 되었습니다. 만약 ALTER가 2초 내에 잠금(lock)을 얻지 못한다면 (장시간 실행되는 쿼리가 무언가를 붙잡고 있는 경우), 대기열에 쌓이는 대신 포기합니다. 이것이 중요한 이유는, 대기열에 쌓인 ACCESS EXCLUSIVE 요청은 단순 읽기(plain reads)를 포함하여 그 뒤의 모든 쿼리를 차단하기 때문입니다. 실패한 DDL은 재시도할 수 있습니다. 하지만 잠금 대기열이 쌓이는 것은 되돌릴 수 없습니다.
단계 2: 작고 제한된 배치로 백필(Backfill) 수행
여기서 Claude Code가 저의 타이핑을 가장 많이 절약해 주었습니다. 저는 명시적인 요구사항을 담아 백필 스크립트를 요청했습니다: 기본 키 범위별 배치 처리, 배치 간 일시 정지 시간, 그리고 자동으로 속도를 늦추는 복제본 지연(replica lag) 확인 기능이 그것입니다.
우리가 최종적으로 구현한 핵심 로직 부분입니다 (Node.js, pg 클라이언트 사용):
const BATCH = 20_000;
const MAX_LAG_SECONDS = 5;
...
실질적인 차이를 만든 몇 가지 세부 사항:
LIMIT/OFFSET대신 범위 기반 배치(Range-based batches) 사용. 오프셋은 진행할수록 속도가 느려집니다. 기본 키(primary key)에 대한 범위는 상수 시간(constant-time)을 유지합니다.AND id_new IS NULL추가: 각 배치를 독립적으로 실행 가능하게 만듭니다(idempotent). 범위를 재실행해도 무해합니다.- 배치마다 체크포인트 기록. 스크립트가 두 번 중단되었는데 (한 번은 배포로, 한 번은 저에 의해), 그때마다 마지막으로 저장된 id부터 재개했습니다.
백필(backfill) 작업은 약 5.5일 동안 진행되었으며, 피크 시간대에는 평균 ~380행/초의 속도를 보였고, 레이그 확인(lag check)이 거의 작동하지 않는 밤에는 훨씬 빨랐습니다. 전체 실행 기간 중 최대 복제본 지연(Peak replica lag): 4.1초.
3단계: 쓰기 작업 차단 없이 인덱스 구축하기
id_new를 기본 키로 만들려면 고유 인덱스(unique index)가 필요하며, 일반적인 CREATE INDEX는 전체 빌드 기간 동안 쓰기를 차단합니다. 그래서 다음과 같이 했습니다:
CREATE UNIQUE INDEX CONCURRENTLY events_id_new_idx ON events (id_new);
이 작업은 47분이 걸렸지만, 그 시간 내내 쓰기 작업은 정상적으로 흐르도록 유지되었습니다.
4단계: 전체 테이블 잠금 없이 NOT NULL 증명하기
기본 키는 NOT NULL을 요구합니다. ALTER COLUMN ... SET NOT NULL은 일반적으로 무거운 잠금(heavy lock) 하에 전체 테이블을 스캔합니다. 이 트릭 (Postgres 12 이상) 은 CHECK 제약 조건(constraint)을 NOT VALID로 추가하고, 더 약한 잠금으로 별도로 유효성을 검사한 다음, SET NOT NULL이 그 증명을 재사용하도록 하는 것입니다:
SET lock_timeout = '2s';
ALTER TABLE events
ADD CONSTRAINT events_id_new_not_null CHECK (id_new IS NOT NULL) NOT VALID;
...
5단계: 스왑(Swap) — 짧고 숙련된 트랜잭션 하나로 끝내기
위의 모든 과정은 약 1.4초 동안의 실제 잠금 시간만을 위한 준비였습니다:
BEGIN;
SET LOCAL lock_timeout = '2s';
...
우리는 스테이징 스냅샷에서 이것을 6번 연습했습니다. 프로덕션 환경에서는 1.4초 만에 실행되었습니다. 첫 번째 시도는 긴 분석 쿼리 때문에 실제로 lock_timeout에 걸렸고, 깔끔하게 롤백되었으며, 30초 후의 두 번째 시도가 성공적으로 완료되었습니다. 이것이 바로 원하는 동작입니다.
세 개의 외래 키(foreign-key) 테이블도 그 후에 같은 과정을 거쳤습니다 (테이블 크기가 작아 더 빨랐음). 그리고 일주일 후에 아무것도 이 id_old을 읽지 않는다는 확신이 들어서 삭제했습니다.
에이전트가 자신 있게 틀렸던 부분
여기서는 솔직하게 말하고 싶습니다. 왜냐하면 이것은 대부분의 'AI를 사용해 X를 했다'라는 글들이 건너뛰는 부분이기 때문입니다.
❌ 실수 1: 마이그레이션 트랜잭션 내부의 CONCURRENTLY
Claude Code가 생성한 첫 번째 마이그레이션 파일은 우리의 마이그레이션 도구 기본 트랜잭션 안에 CREATE UNIQUE INDEX CONCURRENTLY를 감싸고 있었습니다. Postgres는 이것을 전면적으로 거부합니다 (CREATE INDEX CONCURRENTLY cannot run inside a transaction block). 따라서 조용히 실패하는 것이 아니라, 명확하게 오류를 발생시켰습니다. 짜증나지만 안전했습니다. 해결책은 해당 마이그레이션을 비(非)트랜잭션으로 표시하는 것이었습니다.
⚠️ 실수 2: 장애를 유발했을 '더 간단한' 스왑
이것 때문에 제가 겁을 먹었습니다. 중간에 저는 에이전트에게
-
에이전트가 계획하기 전에 인벤토리(inventory)를 만들게 하세요. '어떻게 이 컬럼을 마이그레이션할까요?'라는 질문보다 '모든 의존성을 찾고, 아직 해결책을 제안하지 말라'는 지시가 훨씬 더 나은 맵을 생성했습니다. 앱 코드에 숨겨져 있던 두 개의 캐스트(casts)는 실제 운영 환경에서 버그가 될 수 있었습니다.
-
안전 규칙을 희망이 아닌 제약 조건으로 인코딩하세요. 모든 DDL에
lock_timeout = '2s'를 적용하고 백필(backfill) 과정에 레플리카 지연 방지 장치(replica-lag guard)를 추가한 것이 '조심하라'는 말을 데이터베이스가 강제하는 무언가로 바꿨습니다. 에이전트는 이 규칙들이 작업에 명시적으로 작성된 후 완벽하게 따랐습니다. -
잠금(lock)에 민감한 단계에서는 스테이징 리허설 없이 '간단하게' 처리하라는 요청을 절대 받아들이지 마세요. 에이전트에게 각 구문이 어떤 잠금을 가져가는지 질문해 보세요. 제가 '이것은 어떤 잠금을 획득하고, 얼마나 오래 유지하는가?'라고 물어보기 시작했을 때, 답변들이 훨씬 더 신중해졌습니다.
-
지루할 때까지 스왑(swap)을 리허설하세요. 여섯 번의 리허설은 과하다고 느껴졌지만, 첫 번째 실제 운영 시도에서 잠금 시간 초과가 발생했을 때 아무도 당황하지 않았는데, 이미 그 정확한 롤백(rollback) 과정을 경험했기 때문입니다.
-
에이전트에게 지루한 코드를 작성하게 하되, 타임라인은 본인이 소유하세요. Claude Code가 스크립트와 SQL의 약 90%를 작성했습니다. 저는 작업 순서, 진행/중단(go/no-go) 호출, 그리고 롤백 계획을 담당했습니다. 그 분할이 적절하다고 느꼈습니다.
다음 단계 (What's Next)
저에게는 '2년 이내' 목록에 있는 int4 테이블이 4개 더 있습니다. 제 계획은 전체 흐름을 에이전트와 함께 재사용 가능한 런북(runbook)으로 만드는 것입니다. 여기에는 체크리스트 템플릿, 매개변수화된 백필 스크립트, 그리고 잠금 지속 시간을 자동으로 측정하고 어떤 구문이라도 2초를 초과하면 실패하는 스테이징 리허설 단계가 포함됩니다. 목표는 다음 마이그레이션이 9일짜리 프로젝트가 아니라 대부분 검토만 하면 되는 작업이 되도록 하는 것입니다.
마무리 (Wrap-up)
만약 바쁜 테이블의 serial 컬럼을 가지고 있다면, 오늘 여러분의 시퀀스(sequence)를 확인해 보세요:
SELECT last_value FROM events_id_seq;
그리고 이것을 2,147,483,647과 비교하세요. 미래의 당신이 감사할 것입니다.
👉 AI 코딩 에이전트를 활용하여 실제 시스템을 배포하는 더 많은 빌드 로그를 보려면 Dev.to에서 저를 팔로우해 주세요. 그리고 여러분만의 마이그레이션 공포 이야기를 댓글로 남겨주세요. 특히 외래 키(foreign-key) 테이블을 처리하는 더 깔끔한 방법이 있는지 궁금합니다. 🚀
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기