본문으로 건너뛰기

4.3 Schema Migration 리허설

큰 테이블에 컬럼을 추가하거나 index를 만드는 migration은 staging에서 원활히 실행되어도 production에서는 다르게 동작합니다. 데이터의 분포와 크기가 다르기 때문입니다. production과 같은 데이터를 가진 branch에서 먼저 실행합니다. 그러면 실제 데이터 규모를 기준으로 실행 시간, lock 대기, 디스크 증가량을 측정합니다. 이 장에서는 그 절차를 단계별로 정리합니다.

절차

  1. production 부모에서 branch를 만듭니다. compute 크기는 production과 같게 설정합니다.
  2. branch에서 migration 도구를 그대로 실행합니다.
  3. 실행 시간과 lock, 디스크 변화를 측정합니다.
  4. schema-diff로 결과 schema를 확인합니다.
  5. 문제가 없으면 branch를 삭제하거나 reset해 다음 리허설에 재사용합니다.

1단계: branch 생성

neon branches create --name rehearsal/2026-09-orders-index \
--parent production --cu 4 --expires-at 2026-09-08T00:00:00Z

--cu는 production compute와 같은 값으로 고정합니다. autoscaling 범위를 지정하면 시작 시점의 크기가 달라져 시간 비교가 흐려집니다. 리허설이 끝나면 삭제할 branch이므로 만료 시점을 하루 뒤로 설정합니다.

branch는 부모의 현재 LSN에서 분기하므로 production과 같은 데이터를 가집니다. 부모에서 긴 트랜잭션이 진행 중이어도 branch에는 영향을 주지 않습니다. branch는 분기 시점에 commit된 상태를 기준으로 합니다. 이후 부모의 변경 사항은 branch에 반영되지 않습니다.

2단계: migration 실행

팀에서 사용하는 도구를 그대로 사용합니다. 연결 문자열만 branch의 것으로 바꿉니다. Flyway를 예로 들면 다음과 같습니다.

export DB_URL=$(neon connection-string rehearsal/2026-09-orders-index --database-name app)

# Neon 이 주는 URI 는 postgresql://user:password@host/db?sslmode=require 형태입니다.
# JDBC URL 에는 자격 증명이 들어가지 않으므로 네 조각으로 나눕니다.
eval "$(python3 - "$DB_URL" <<'PY'
import sys, urllib.parse as u
p = u.urlparse(sys.argv[1])
print(f"PGHOST={p.hostname}")
print(f"PGDATABASE={p.path.lstrip('/')}")
print(f"PGUSER={p.username}")
print(f"PGPASSWORD={p.password}")
PY
)"

flyway -url="jdbc:postgresql://${PGHOST}/${PGDATABASE}?sslmode=require" -user="$PGUSER" -password="$PGPASSWORD" migrate

pgJDBC URL은 jdbc:postgresql://host[:port]/database 형식입니다. 인증 정보는 URL이 아닌 -user, -password 또는 별도 속성으로 전달합니다. Neon URI에서 postgresql://만 제거해 그대로 붙이면 user:password@host 부분이 host 자리에 들어가 연결에 실패합니다.

도구 없이 SQL 파일을 직접 실행한다면 psql에서 \timing을 켭니다.

psql "$DB_URL" -v ON_ERROR_STOP=1 <<'SQL'
\timing on
ALTER TABLE orders ADD COLUMN fulfillment_status text;
CREATE INDEX CONCURRENTLY orders_customer_created_idx ON orders (customer_id, created_at DESC);
SQL

CREATE INDEX CONCURRENTLY는 트랜잭션 블록 안에서 실행할 수 없습니다. 따라서 migration 도구가 각 문장을 트랜잭션으로 감싸는지 확인합니다. 리허설에서 이런 도구 특성이 드러나면 production 적용 전에 고칩니다.

3단계: 측정

실행 시간은 \timing 출력이나 도구 로그에서 확인합니다. lock 대기는 실행 중에 다른 세션에서 확인합니다.

SELECT pid, state, wait_event_type, wait_event,
now() - xact_start AS xact_age,
left(query, 80) AS query
FROM pg_stat_activity
WHERE datname = current_database()
AND state <> 'idle'
ORDER BY xact_start;

ALTER TABLE ... ADD COLUMN에 기본값과 NOT NULL을 함께 지정하면 PostgreSQL 11 이후에는 대부분 카탈로그만 변경합니다. 그러나 타입 변경이나 일부 제약 추가는 테이블 전체를 다시 씁니다. 리허설에서 발생한 table rewrite는 실행 시간과 relation 크기 변화로 드러납니다.

SELECT pg_size_pretty(pg_total_relation_size('orders')) AS total,
pg_size_pretty(pg_relation_size('orders')) AS heap,
pg_size_pretty(pg_indexes_size('orders')) AS indexes;

migration 전후에 이 쿼리를 실행해 차이를 기록합니다. 이 값은 PostgreSQL이 보는 relation의 논리적 크기입니다. table rewrite나 index 추가 여부를 판단하는 데 사용합니다.

이 차이를 branch의 추가 storage 사용량으로 해석해서는 안 됩니다. 둘은 서로 다른 값입니다. UPDATE로 기존 page를 모두 다시 써도 relation 크기는 거의 변하지 않습니다. 하지만 pageserver에는 새 page version이 쌓입니다. 반대로 Neon은 자식 branch의 storage를 누적 변경량과 논리 크기 중 작은 값으로 산정합니다. branch의 실제 사용량은 Console의 사용량 지표나 API의 usage endpoint에서 따로 확인합니다.

pg_stat_statements가 켜져 있으면 migration이 실행한 문장별 시간을 한꺼번에 확인합니다.

SELECT calls, round(total_exec_time::numeric, 1) AS total_ms,
round(mean_exec_time::numeric, 1) AS mean_ms,
left(query, 100) AS query
FROM pg_stat_statements
WHERE query ILIKE 'ALTER%' OR query ILIKE 'CREATE INDEX%'
ORDER BY total_exec_time DESC;

4단계: schema 확인

neon branches schema-diff production rehearsal/2026-09-orders-index --database app

출력은 부모를 기준으로 branch에서 변경된 내용을 보여주는 SQL diff입니다. migration 파일이 의도한 변경과 diff가 일치하는지 확인합니다.

이 도구는 두 branch의 schema를 덤프해 비교하므로 데이터 변경은 나타나지 않습니다. migration 도구가 자체 이력 테이블(flyway_schema_history 등)에 추가한 행도 diff에는 나타나지 않습니다. 이력이 제대로 기록됐는지는 두 branch에서 직접 조회해 비교합니다.

for BR in production rehearsal/2026-09-orders-index; do
echo "== $BR"
psql "$(neon connection-string "$BR" --database-name app)" -Atc \
"SELECT version, description, success FROM flyway_schema_history ORDER BY installed_rank DESC LIMIT 3"
done

5단계: 정리 또는 재사용

리허설을 마치면 branch를 삭제합니다.

neon branches delete rehearsal/2026-09-orders-index

같은 migration을 수정해 다시 실행하려면 branch를 삭제하지 않고 부모 상태로 되돌립니다.

neon branches reset rehearsal/2026-09-orders-index --parent

reset은 branch의 데이터와 schema를 부모의 현재 상태로 덮어씁니다. 연결 문자열은 바뀌지 않으므로 도구 설정을 변경할 필요가 없습니다. reset 후에는 부모가 리허설 시작 이후에 받은 변경 사항도 함께 반영됩니다. 따라서 데이터 기준 시점이 앞으로 이동한다는 점을 기록해 둡니다.

리허설 결과 읽는 법

리허설 시간과 production 적용 시간은 다를 수 있습니다. 몇 가지 차이가 있습니다.

첫째, branch의 compute는 새로 시작하므로 shared buffers와 local file cache가 비어 있습니다. production은 이미 워밍된 상태입니다. 따라서 읽기 위주 작업은 리허설에서 더 느리게 나옵니다. 리허설 전에 대상 테이블을 한 번 스캔해 캐시를 채우면 차이가 줄어듭니다.

둘째, branch에는 production의 동시 부하가 없습니다. 따라서 lock 경합은 리허설에서 거의 드러나지 않습니다. ALTER TABLE에 필요한 lock 종류를 확인합니다. production에서는 lock_timeout을 설정해 대기가 길어지면 실패하게 합니다. 이 방어책은 별도로 준비합니다.

셋째, Neon의 쓰기 경로에서는 WAL이 safekeeper 정족수에 도달해야 commit이 이루어집니다. 따라서 대량 쓰기 migration은 로컬 디스크 PostgreSQL과 다른 시간 특성을 보입니다. production도 Neon이라면 같은 조건이므로 비교가 유효합니다.

리허설에서 드러나는 문제

  • 트랜잭션 안에서 CREATE INDEX CONCURRENTLY를 실행하려는 도구 설정
  • 기본값이 있는 컬럼 추가로 위장한 table rewrite
  • 외래 키 추가 시 참조 무결성을 위반하는 데이터
  • NOT VALID 없이 추가한 제약의 전체 스캔
  • migration 이력 테이블과 실제 schema의 불일치

이 중 마지막 문제는 branch가 없으면 production에서만 발견됩니다. 리허설 branch는 production 데이터를 그대로 가지므로 이력 테이블의 상태도 같습니다. 다만 이 불일치는 schema-diff가 아니라 위의 이력 조회에서 드러납니다.

연습 문제

  1. 100만 행 테이블을 만든 branch에서 ALTER TABLE ... ALTER COLUMN ... TYPE bigint를 실행하고 크기 변화와 시간을 기록합니다.
  2. 같은 작업을 --cu 1 branch와 --cu 4 branch에서 각각 실행해 시간 차이를 비교합니다.
  3. migration을 실패시킨 뒤 reset --parent로 되돌리고 schema-diff가 빈 결과를 내는지 확인합니다.

참고