PostgreSQL 대형 테이블에 순번 컬럼 추가, 재작성 없이 기존 행을 생성 순으로 채번하기
BIGSERIAL 한 줄은 테이블 전체를 다시 쓰고 서비스를 멈춥니다. 짧은 잠금으로 경계를 정하고 배치로 기존 행을 채번해 무중단으로 순번 컬럼을 붙인 과정과 함정을 정리했습니다.
PK 가 nanoid 같은 무작위 문자열인 테이블에 순번 컬럼을 추가할 일이 생겼습니다. 외부 배치가 WHERE id BETWEEN :start AND :end 처럼 숫자 범위로 나눠 읽게 하려는 거였죠.
조건은 세 가지였습니다. 기존 행은 생성 순으로 1..N 을 받고, 새 행은 N+1 부터 이어 받고, 서비스는 멈추지 않는다.
BIGSERIAL 한 줄이면 안 될까요?
가장 먼저 떠오르는 건 ALTER TABLE t ADD COLUMN seq_no BIGSERIAL 입니다. GENERATED … AS IDENTITY 나 NOT NULL DEFAULT nextval(...) 도 마찬가지고요.
그런데 이건 테이블 전체를 다시 씁니다. 휘발성 DEFAULT 는 행마다 값을 계산해야 해서, 그동안 ACCESS EXCLUSIVE 잠금이 걸리고 읽기와 쓰기가 모두 멈춥니다.
게다가 번호가 물리 순서로 매겨집니다. UPDATE 를 거친 행은 힙의 다른 위치로 옮겨 가 있으니 결과가 생성 순이 아닙니다.
또 하나, 새 행과 번호가 겹치지 않으려면 기존 행이 몇 개인지 정확히 알아야 합니다. 그런데 세는 동안에도 INSERT 가 들어옵니다.
경계는 짧은 잠금 한 번으로 확정합니다
잠금은 딱 한 번, 짧게만 잡습니다. DO 블록 하나 안에서 LOCK TABLE … IN ACCESS EXCLUSIVE MODE 를 잡고, count(*) 로 N 을 세고, setval(seq, N+1, false) 를 호출한 뒤, nullable 컬럼을 추가하고 SET DEFAULT nextval(seq) 를 겁니다.
nullable 컬럼 추가와 SET DEFAULT 는 카탈로그만 바꾸므로 재작성이 없습니다. 이 뒤로 들어오는 새 행은 N+1 부터 번호를 받고, 기존 N 행만 NULL 로 남습니다.
기존 행 번호는 채번표로 매깁니다. row_number() OVER (ORDER BY created_at, id) 로 1..N 을 미리 적어 두고, 동률 시각은 PK 로 가릅니다.
그다음 채번표 앞쪽부터 대상 행을 FOR UPDATE SKIP LOCKED 로 잡아 번호를 기록하고, 기록한 것만 채번표에서 지웁니다. 한 문장 CTE 로 묶으면 실패했을 때 함께 롤백됩니다. 사용자가 잡고 있는 행은 기다리지 않고 건너뛰니, 다건 삭제 같은 작업과 교착하지 않습니다.
마지막으로 제약은 뒤에서 가볍게 겁니다. CREATE UNIQUE INDEX CONCURRENTLY 를 만들고, CHECK (col IS NOT NULL) NOT VALID 를 VALIDATE 한 다음 SET NOT NULL 을 합니다. PG12 이상은 검증된 CHECK 가 있으면 SET NOT NULL 이 테이블을 다시 훑지 않습니다.
DO $$
DECLARE n bigint;
BEGIN
PERFORM set_config('lock_timeout', '3s', true);
LOCK TABLE items IN ACCESS EXCLUSIVE MODE;
SELECT count(*) INTO n FROM items;
CREATE SEQUENCE items_seq_no_seq;
PERFORM setval('items_seq_no_seq', n + 1, false);
ALTER TABLE items ADD COLUMN seq_no BIGINT; -- 재작성 없음
ALTER TABLE items ALTER COLUMN seq_no SET DEFAULT nextval('items_seq_no_seq');
ALTER SEQUENCE items_seq_no_seq OWNED BY items.seq_no;
END $$;
CREATE TABLE items_seq_map AS
SELECT id, row_number() OVER (ORDER BY created_at, id) AS seq_no
FROM items WHERE seq_no IS NULL;
-- 0행이 될 때까지 반복
WITH picked AS (
SELECT m.id, m.seq_no FROM items_seq_map m JOIN items i ON i.id = m.id
WHERE i.seq_no IS NULL ORDER BY m.seq_no LIMIT 5000
FOR UPDATE OF i SKIP LOCKED
), done AS (
UPDATE items i SET seq_no = p.seq_no FROM picked p WHERE i.id = p.id RETURNING i.id
)
DELETE FROM items_seq_map m USING done WHERE m.id = done.id;겪을 만한 함정들
약한 잠금에서 시작해 올리면 안 됩니다. SHARE ROW EXCLUSIVE 로 쓰기만 막고 세다가 ADD COLUMN 에서 ACCESS EXCLUSIVE 로 올리면, "SELECT 는 끝냈고 INSERT 는 아직"인 트랜잭션과 교착합니다. 처음부터 강한 잠금을 잡으면 PG 가 대기 큐를 재정렬해 교착을 풀어 줍니다.
스냅샷이 잠금보다 먼저 고정되면 count 가 모자랍니다. 세션이 REPEATABLE READ 면 잠금을 기다리는 동안 커밋된 행이 count 에서 빠지고 번호가 겹칩니다. 그래서 DO 안에서 current_setting('transaction_isolation') 을 확인합니다.
시퀀스 권한도 챙겨야 합니다. 앱 롤이 테이블 소유자가 아니면 DEFAULT 의 nextval 이 permission denied for sequence 로 모든 INSERT 를 깨뜨립니다. 시퀀스 소유자를 테이블 소유자로 맞추고, 테이블 INSERT 권한이 있는 롤(aclexplode(relacl))에 USAGE, SELECT 를 같은 트랜잭션에서 줍니다.
스키마 파일의 가드는 한 문장이어야 합니다. "행이 있으면 RAISE" 하는 DO 뒤에 ADD COLUMN … NOT NULL DEFAULT nextval 을 별도 문장으로 두면, psql -f(기본 ON_ERROR_STOP off)는 예외 뒤에도 다음 문장을 실행합니다. 가드가 막으려던 재작성이 그대로 일어나는 거죠. 판정과 생성을 같은 DO 안에 넣어야 합니다.
마지막으로 순번은 커밋 순서가 아닙니다. 번호는 INSERT 시점에 할당되므로, max(seq_no) 를 증분 워터마크로 쓰면 늦게 커밋된 작은 번호를 영구히 놓칩니다. 증분 소비자는 겹침 창을 둬야 합니다.
사실 BIGSERIAL 한 줄이면 끝날 것 같았던 작업이 이렇게 길어질 줄은 몰랐습니다. 대형 테이블이라면 한 번쯤 짚고 갈 만한 내용이라 메모 겸 남겨 둡니다.
참고
- PostgreSQL ALTER TABLE (Notes 절, 휘발성 DEFAULT 는 재작성, SET NOT NULL 과 검증된 CHECK): https://www.postgresql.org/docs/current/sql-altertable.html
- PostgreSQL Explicit Locking: https://www.postgresql.org/docs/current/explicit-locking.html
- PostgreSQL CREATE INDEX CONCURRENTLY: https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY
- PostgreSQL SELECT, SKIP LOCKED: https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE
- psql ON_ERROR_STOP: https://www.postgresql.org/docs/current/app-psql.html