PostgreSQL에서 ALTER TABLE 변경이 전체 테이블 재작성을 유발하는지 안전하게 확인하는 방법을 소개합니다. 테스트용 테이블을 만들고, 변경 전후의 filenode를 비교해 재작성 여부를 판별합니다.
Postgres 테이블이 수년 동안 정확히 같은 구조를 유지하는 일은 드뭅니다. 새 컬럼이 추가되기도 하고, 오래된 컬럼이 제거되기도 하며, 컬럼 데이터 타입을 바꿔야 하기도 합니다. 이런 작업은 모두 ALTER TABLE 명령으로 합니다. 그런데 이런 변경의 큰 단점은 테이블 전체 재작성(table rewrite) 을 강제할 수 있다는 점입니다. 재작성은 테이블의 완전히 새로운 복사본을 만든 다음, 기존 테이블을 삭제하는 것을 의미합니다. 큰 테이블에서는 시간이 매우 오래 걸릴 수 있습니다. 더 나쁜 점은, 그 동안 테이블을 사용하는 다른 모든 작업이 차단/잠금되기 때문에 애플리케이션에 다운타임이 필요할 수 있습니다.
그렇다면 어떤 명령은 전체 테이블 재작성이 필요하고, 어떤 명령은 찰나의 잠금만 필요할까요? alter table 문서에 몇 가지 가이드라인이 있긴 하지만, 결국 여러분이 알고 싶은 건 운영 애플리케이션에서 큰 다운타임이 생길지 말지를 확실히 아는 것입니다. 여기서는 재작성이 발생하는지 안전하게 판별하는 빠른 레시피를 소개합니다. 요약하면 테이블의 복사본을 만들고, 그 복사본을 변경한 뒤, 디스크의 해당 테이블 기반 파일이 바뀌었는지 확인하는 것입니다.
테이블이 수정될 때 어떤 일이 벌어지는지 살펴봅시다. 이를 위해 pgbench -i를 실행하면 만들어지는 믿음직한 pgbench_accounts 테이블을 사용하겠습니다. 첫 번째 예제에서는 구문을 트랜잭션 안에 넣어서, 오래 걸리는 테이블 재작성 효과를 시뮬레이션할 수 있습니다. 우선 새 컬럼을 추가하고 어떤 잠금이 잡히는지 확인해 봅시다.
greg=> begin;
BEGIN
greg=*> alter table pgbench_accounts add chocolates int;
ALTER TABLE
greg=*> select locktype, mode from pg_locks
where relation::regclass::text = 'pgbench_accounts';
locktype | mode
----------+---------------------
relation | AccessExclusiveLock
AccessExclusiveLock 는 테이블 전체에 걸리는 매우, 매우 강한 잠금으로, 다른 프로세스가 테이블에 접근하는 거의 모든 동작을 차단합니다. 이제 다른 프로세스를 하나 띄워서 어떤 일이 생기는지 봅시다.
-- lock_timeout이 없으면, 이 update는 영원히 대기하거나
-- 다른 트랜잭션이 커밋할 때까지 기다리게 됩니다:
greg=> set lock_timeout TO '5s';
SET
greg=> update pgbench_accounts set bid = bid;
ERROR: canceling statement due to lock timeout
LINE 1: update pgbench_accounts set bid = bid;
-- 이 잠금은 SELECT 문도 막습니다!
-- (영원히 기다리지 않는 다른 방법은 statement_timeout을 쓰는 것입니다)
greg=> set statement_timeout = '500ms';
SET
greg=> select * from pgbench_accounts limit 10;
ERROR: canceling statement due to statement timeout
즉, ALTER TABLE이 애플리케이션이 테이블에 접근하는 것을 막을 수 있다는 점이 확인됩니다. 하지만 희망이 없는 건 아닙니다. 커밋 없이 실행하면 어떤 일이 벌어지는지 보세요.
greg=> \timing on
Timing is on.
greg=> alter table pgbench_accounts add chocolates bigint;
ALTER TABLE
Time: 2.149 ms
2밀리초면 매우 빠릅니다. 대부분의 애플리케이션은 충분히 감당할 수 있습니다. 단, 잠금 자체는 여전히 획득해야 한다는 점이 주의사항입니다. 잠금을 얻으려면 아주 짧은 순간이라도 테이블을 완전히 단독으로 제어할 수 있어야 합니다. 매우 바쁜 테이블이라면 애플리케이션과의 조율이 필요할 수 있습니다. 반면 테이블 재작성은 애플리케이션이 몇 시간, 심지어 며칠 동안 차단될 수도 있습니다!
시작할 때는 언제나 운영에서 중요한 테이블의 모사체(simulacrum) 로 작업하고 싶습니다. 즉, 테이블 데이터는 없이 구조만 복사한 테이블입니다. Postgres는 CREATE TABLE 명령의 LIKE 절로 이를 손쉽게 할 수 있습니다. 복사할 테이블의 선택적 요소를 지정할 수 있는데, 여기서는 컬럼 기본값(default)만 필요하므로 이렇게 작성할 수 있습니다.
CREATE UNLOGGED TABLE gregtest (LIKE pgbench_accounts INCLUDING defaults)
어차피 매우 임시로 쓰는 객체이니 unlogged 테이블로 만들어도 좋습니다. 두 테이블을 비교하면 구조가 동일하다는 것을 알 수 있습니다.
greg=> \d pgbench_accounts
Table "public.pgbench_accounts"
Column | Type | Collation | Nullable | Default
----------+---------------+-----------+----------+---------
aid | integer | | not null |
bid | integer | | |
abalance | integer | | |
filler | character(84) | | |
greg=> \d gregtest
Unlogged table "public.gregtest"
Column | Type | Collation | Nullable | Default
----------+---------------+-----------+----------+---------
aid | integer | | not null |
bid | integer | | |
abalance | integer | | |
filler | character(84) | | |
이제 새 테이블을 ALTER할 수 있습니다. 그런데 테이블이 재작성되었는지는 어떻게 알까요? 이 테이블에는 행이 없으니, 걸린 시간으로 감지할 수 없습니다. Postgres에서 모든 테이블은 Postgres 데이터 디렉터리 안의 하나 이상의 물리 파일에 매핑됩니다. pg_relation_filenode 함수를 사용하면 테이블의 파일을 확인할 수 있습니다. 출력이 바뀌었다면 재작성되었다는 뜻입니다.
먼저 기본값이 없는 단순 BIGINT 컬럼을 추가해 봅시다. 이는 재작성을 유발하지 않아야 합니다.
greg=> select pg_relation_filenode('gregtest');
pg_relation_filenode
----------------------
1495135
greg=> alter table gregtest add monkeys bigint;
ALTER TABLE
greg=> select pg_relation_filenode('gregtest');
pg_relation_filenode
----------------------
1495135
filenode가 바뀌지 않았습니다. 즉, Access Exclusive 잠금은 필요하지만 재작성은 발생하지 않았습니다.
이번에는 테이블 재작성을 일으키는 예제를 실행해 봅시다.
greg=> select pg_relation_filenode('gregtest');
pg_relation_filenode
----------------------
1495135
greg=> alter table gregtest add foobar4 bigint default random(1,10);
ALTER TABLE
greg=> select pg_relation_filenode('gregtest');
pg_relation_filenode
----------------------
1495140
문서에 나온 대로 정적(static)이지 않은 기본값은 재작성을 강제합니다. 이 경우 기존 모든 행에 1~10 사이의 난수가 삽입됩니다. 이 테이블은 행이 0개지만, 재작성은 어쨌든 일어났습니다.
또 다른 흔한 사례는 기존 컬럼의 데이터 타입을 변경하는 것입니다. 쓰라린 경험상, int에서 bigint로 바꾸는 것은 항상 재작성이 필요합니다.
greg=> select pg_relation_filenode('gregtest');
pg_relation_filenode
----------------------
1495140
greg=> alter table gregtest alter column bid type bigint;
ALTER TABLE
greg=> select pg_relation_filenode('gregtest');
pg_relation_filenode
----------------------
1495144
재작성이 필요 없는 데이터 타입 변경도 일부 있으니, 항상 TIAS(“try it and see”, 직접 해보고 확인)하는 것이 좋습니다. 8바이트 bigint에서 4바이트로 바꾸는 것도 재작성일까요?
greg=> select pg_relation_filenode('gregtest');
pg_relation_filenode
----------------------
1495144
greg=> alter table gregtest alter bid type integer;
ALTER TABLE
greg=> select pg_relation_filenode('gregtest');
pg_relation_filenode
----------------------
1495147
네! bigint에 들어갈 수 있는 값이 integer에도 “맞아 들어갈” 수 있더라도, 디스크에 표현되는 방식이 다르기 때문에 테이블 재작성이 필요합니다.
이 방법이 운영 테이블을 실제로 건드리지 않고도 재작성이 필요한지 여부를 배우는 데 도움이 되길 바랍니다! 작업이 끝나면 테스트 테이블을 삭제하는 것도 잊지 마세요. 테이블 재작성이 필요한 변경을 해야 하지만 다운타임을 감당할 수 없다면, 더 복잡한 대안 — 예를 들어 논리 복제(logical replication) 활용 — 이 도움이 될 수 있습니다.
전체 테이블 재작성을 유발할 수 있는 다른 작업들도 몇 가지 더 있지만, 대부분 자주 마주칠 일은 없을 것입니다.