9.x 파티션 테이블을 10.x. 이상 버전으로 업그레이드 하기

파티션 테이블 업그레이드 하기


들어가며

9.x 대 버전까지는  다른 관계형 데이터베이스와 달리 파티션 테이블에 대한 명시적인 설명과 방법이 없었습니다. 
왜냐하면 PostgreSQL은 초창기부터 테이블 상속이라는 개념이 있어, 파티션 테이블은 이 상속 기법으로 처리할 수 있었으니까요.

create table 엄마 ();
create table 아빠 ();
create table 첫째 () inherits (엄마,  아빠);
create table 둘째 () inherits (엄마,  아빠);

이런 아주 가족적인 테이블 구성을 할 수 있습니다.
(윗 네줄을 복사해서 psql 안에서 실행하면 정상적으로 sql 구문이 실행됩니다. - 심심하면 한 번 해보세요 . 다른 관계형 데이터베이스만 알고 있는 사람들이 본다면, 아이들 교육에 도움이 되겠네, 라고 말할 기능이죠.)

이 방식으로 파티션 테이블 기능을 구현했습니다. 

하지만, 이 기법이 주류가 아닐뿐더러 편하지도 않고, 성능 문제도 있고, 그밖에 다른 문제들도 있고 해서 결국 10버전부터 주류 관계형 데이터베이스에서 사용하는 파티션 테이블이라는 아주 구체적인 개념을 도입했고, 구현했습니다. 
(테이블을 탁자라는 우리말로 옮기지 못했고, 파티션 테이블도 결국 나눠진 탁자로 옮기지 못해 그냥 파티션 테이블이라고 합니다.
하지만, 영어에서는 partitioned table : 상위 테이블, partition tables : 하위 테이블로 명확하게 구분해서 사용합니다. 영문 사용 설명서를 읽을 때 참고하세요. - 뱀발)

세월은 흘러 이제 9.x 버전은 더 이상 엔진 코드 변경을 안하겠다고 개발 그룹에서 발표하고, 그저 이 데이터베이스를 쓰는 사람 처지에서는 이런 저런 이유로 10 버전 이상으로 데이터베이스 업그레이드를 생각하는데, 이 업그레이드 작업에서 이 파티션 테이블 업그레이드가 아주 껄꺼러운 문제가 되어 버립니다. 

왜냐하면 PostgreSQL 메이져버전 업그레이드는 pg_upgrade라는 명령을 이용해서 하는데, 이 명령의 내부작업에서 이 문제(하위 버전의 inherits로 만든 테이블을 새로운 버전에서 제공하는 파티션 테이블로 쉽게 바꾸는 것)를 깔끔하게 처리하지 못하기 때문입니다. 

PostgreSQL 데이터베이스 관리자 용어로 이야기하면, 

pg_upgrade .....

이 명령이 잘 작동하는 것처럼 보이는데, 
단 기존 데이터베이스 안에 테이블 상속 기법을 이용한 파티션 테이블이 있다면, 
이것을 알아서 잘 10 이상에서 제공하는 새로운 파티션 테이블로 바꾸지 않습니다. 
(10버전에서는 이 새로운 파티션 기법을 declarative table partitioning 이라고 합니다.)

이 글은 이 파티션 테이블 업그레이드에 대한 이야기입니다. 


10버전에서 달라지는 파티션 테이블 특성

  • 상위 테이블은 반드시 하나의 테이블이여야합니다.
  • 하위 테이블의 구조는 상위 테이블과 똑 같아야합니다. (상속에서는 달라도 되었죠)
  • 다른 관계형 데이터베이스와 달리 상위 테이블과 하위 테이블을 각각의 DDL 구문으로 만들어야합니다.
  • 기본키가 있는 경우 그 기본키가 참조하는 칼럼 가운데 하나는 반드시 파티션 키여야합니다.
  • 상속 기반으로 작성되었던 파티션 테이블 흉내냈던 기능들은 모두 내장 기능으로 바뀌었기 때문에 변환을 한다면, 후속 작업이 필요합니다.
    • 상위 테이블에 지정했던 자료 처리용 트리거는 사용하지 않습니다. 이제 자동으로 됨
    • 하위 테이블에 지정했던 check 제약 조건은 파티션 범위나 항목으로 바뀌기 때문에 이것도 사용하지 않습니다.
    • 하위 테이블 추가, 삭제 구문이 바뀝니다. (inherit -> attach, detach)


상속 기능을 이용하는 상위 테이블과 하위 테이블을 파티션 테이블로 바꾸기

여기서 다루는 예제 테이블은 지난 "9.6 이하 버전에서의 파티션 테이블 이야기" 글에서 사용했던 테이블을 대상으로 합니다.
postgres=# \dt
              List of relations
 Schema |      Name       | Type  |  Owner
--------+-----------------+-------+----------
 public | wwwlog          | table | postgres
 public | wwwlog_20180111 | table | postgres
 public | wwwlog_20180112 | table | postgres
 public | wwwlog_20180113 | table | postgres
 public | wwwlog_20180114 | table | postgres
(5 rows)

postgres=# \d wwwlog
                                       Table "public.wwwlog"
 Column |            Type             | Collation | Nullable |               Default
--------+-----------------------------+-----------+----------+-------------------------------------
 seq    | integer                     |           | not null | nextval('wwwlog_seq_seq'::regclass)
 ctime  | timestamp without time zone |           | not null | CURRENT_TIMESTAMP
 node   | bigint                      |           | not null |
 data   | jsonb                       |           | not null |
Indexes:
    "wwwlog_pkey" PRIMARY KEY, btree (seq)
    "wwwlog_ctime_i" brin (ctime)
    "wwwlog_data_i" gin (data)
Triggers:
    tr_wwwlog_insert BEFORE INSERT ON wwwlog FOR EACH ROW EXECUTE FUNCTION wwwlog_insert_dynamic()
Number of child tables: 4 (Use \d+ to list them.)

postgres=# \d wwwlog_20180111
                                  Table "public.wwwlog_20180111"
 Column |            Type             | Collation | Nullable |               Default
--------+-----------------------------+-----------+----------+-------------------------------------
 seq    | integer                     |           | not null | nextval('wwwlog_seq_seq'::regclass)
 ctime  | timestamp without time zone |           | not null | CURRENT_TIMESTAMP
 node   | bigint                      |           | not null |
 data   | jsonb                       |           | not null |
Indexes:
    "wwwlog_20180111_pkey" PRIMARY KEY, btree (seq)
    "wwwlog_20180111_ctime_idx" brin (ctime)
    "wwwlog_20180111_data_idx" gin (data)
Check constraints:
    "wwwlog_20180111_ctime_check" CHECK (ctime >= '2018-01-11 00:00:00'::timestamp without time zone AND ctime < '2018-01-12 00:00:00'::timestamp without time zone)
Inherits: wwwlog
위와 같이 전형적인 상속 기능을 이용한 파티션 테이블 있을 때, 이것은 새 파티션 테이블로 바꾸는 작업을 합니다.

전체 과정은 다음과 같습니다. 
  1. 필요하다면, 기존 테이블의 모든 기본키에 파티션 키를 추가해서 기본키를 바꾸고
  2. 새 파티션 상위 테이블을 만들고,
  3. 옛 파티션 하위 테이블을 떼어내어 새 파티션 테이블에 붙이고,
  4. 그 하위 테이블에 있는 check 제약 조건을 지웁니다.
1.
운영 환경 중에 기본키를 바꾸는 전략은 일반적으로
  1. 새 유니크 인덱스를 concurrently 옵션으로 하나 만들고, (파티션 키를 포함하는 인덱스가 되겠죠)
  2. 기존 기본키를 지우고,
  3. 새로 만든 유니크 인덱스를 기본키로 지정합니다.
이론상으로 별로 어렵지 않은 작업 같아보이지만, PostgreSQL 유니크 인덱스는 null 자료에 대해서는 중복을 허용합니다. 하지만, 기본키로 사용한다면, 그 소속 칼럼 값은 null을 허용하지 않습니다. 이런 문제 때문에, 필요하다면, 이 null 문제부터 풀어야합니다. 아무 생각 없이 파티션 키로 사용하는 칼럼에 null 허용 속성이 부여되어있다면, 먼저 not null 속성으로 바꾸는 일부터 해야합니다.
슬프게도, 이 작업은 alter 작업이고, 테이블의 그 칼럼 값을 전부 조회하는 일을 하게 됩니다. 자료가 많다면, 꽤 오랜 시간 테이블 전체가 잠기게 됩니다.
윗 테이블 구조 상황이라면, 다음과 같은 방식으로 진행합니다.
postgres=# create unique index concurrently if not exists wwwlog_new_pk on wwwlog (seq, ctime);
CREATE INDEX
postgres=# begin;
           alter table wwwlog drop constraint wwwlog_pkey;
           alter index wwwlog_new_pk rename to wwwlog_pkey;
           alter table wwwlog add primary key using index wwwlog_pkey;
           end;
COMMIT
이 기본키 바꾸기 작업을 하위테이블도 모두 해야합니다.
 
2.
10버전 이상용 새 파티션 테이블 만드는 작업은 비교적 단순합니다.
postgres=# create table if not exists p_wwwlog (like wwwlog including all) partition by range (ctime);
CREATE TABLE
postgres=# \d p_wwwlog
                                  "public.p_wwwlog" 파티션 테이블
 필드명 |            종류             | Collation | NULL허용 |               초기값
--------+-----------------------------+-----------+----------+-------------------------------------
 seq    | integer                     |           | not null | nextval('wwwlog_seq_seq'::regclass)
 ctime  | timestamp without time zone |           | not null | CURRENT_TIMESTAMP
 node   | bigint                      |           | not null |
 data   | jsonb                       |           | not null |
파티션 키: RANGE (ctime)
인덱스들:
    "p_wwwlog_pkey" PRIMARY KEY, btree (seq, ctime)
    "p_wwwlog_ctime_idx" brin (ctime)
    "p_wwwlog_data_idx" gin (data)
파티션 테이블 수: 0
including all 옵션을 사용해서 만들었습니다. 편하죠.

3.
옛날 파티션 테이블에서 하위 테이블을 떼어내어, 새 파티션 테이블에 끼워넣는 작업은 하나의 트랜잭션으로 처리하는 것이 바람직합니다.
또한 운영 환경 상태라면, 응용 프로그램이 사용하는 쿼리들이 어떤 결과를 보일 것인지도 예측하고 작업을 진행해야겠죠.
postgres=# begin;
     alter table wwwlog_20180111 no inherit wwwlog;
     alter table p_wwwlog attach partition wwwlog_20180111 for values from ('2018-01-11 00:00:00') to ('2018-01-12 00:00:00');
     end;
COMMIT
이 작업도 모든 하위 테이블을 대상으로 진행합니다. 이때 attach partition 옵션 뒤에 사용하는 파티션이 이름이 바로 떼어낸 하위 테이블 이름입니다.

파티션 attach 작업 때 기억해야하는 것은 그 테이블에 있는 자료가 for values 로 지정한 허용 범위안의 자료인지 테이블 전체 자료를 검사한다는 것입니다. 단 예외가 있는데, 그 끼워넣을 하위 테이블에 check 제약조건이 for values 조건과 같다면, 이 자료 전체 검사를 생략합니다. 그래서, 끼워넣는 작업 전에 하위 테이블에 있는 check 제약조건을 먼저 지우면 작업 시간이 길어지게됩니다.

4.
체크 제약조건 없애는 것은 늘 하던 대로
postgres=# alter table wwwlog_20180111 drop constraint wwwlog_20180111_ctime_check;
ALTER TABLE
이 작업도 당연히 모든 하위테이블을 대상으로 해야겠죠.


운영 환경 데이터베이스 업그레이드 때 한 번 더 생각해야 하는 것들

먼저 고려해야할 것은
  • 바뀐 파티션 테이블 특성을 잘 파악에서 기존 테이블의 형상 변경이 필요한지를 파악해야합니다. - 대표적으로 상위 테이블의 기본키 정의에 반드시 파티션 키가 포함되어야 하는지 살펴보는거겠죠. 만들어야 한다면, 먼저 기본키 변경 작업부터 해야합니다.  이 작업을 운영 환경에서 한다는 것은 꽤 큰 부담을 안고 진행하는 일입니다. 특히나 자료가 많은 경우라면 더더욱. 작업 영향도를 잘 파악해야합니다.
  • alter table ... no inherits 구문을 사용해서 상속 관계를 끊는 작업, 새 상위 파티션 테이블에 떼어낸 하위 테이블을 alter table ... attach 구문으로 포함 시키는 작업, 그 하위 테이블에 있는 check 제약 조건을 없애는 작업까지 모두 하나의 트랜잭션으로 처리하는 것이 안전하고, 이 작업들이 DDL 작업으로 테이블 잠금이 일어난다는 것입니다. 즉, 작업 영향도를 반드시 파악해야합니다.
  • 운영 환경에서는 이 글 예제처럼 파티션 테이블이 몇 개로 구성되는 경우는 거의 없습니다. 적어도 월단위 1년치니까, 한 파티션 테이블의 하위 테이블이 적어도 12개 이상은 됩니다. 그런데, 이 여러개 하위 테이블의 반복 작업이 꽤 성가신 작업입니다. 즉, 반복 작업을 위한 SQL 구문 자동 생성을 하든, SQL 구문을 자동으로 생성해서 실행까지 자동화 하든 어떤 방법으로든 어느 정도의 자동화가 필요합니다. 완전 자동화 방법은 그 작업 과정 중에 발생하는 테이블 잠김 상황 때 대처가 쉽지 않기 때문에 여기서는 SQL 구문 자동 생성 하고, 그것을 관리자가 차례로 실행하는 방식을 소개합니다.
1. 상속 기반 상위 테이블과 하위 테이블 찾기
postgres=# select inhrelid::regclass, inhparent::regclass 
from pg_inherits where inhparent::regclass = 'wwwlog'::regclass;
    inhrelid     | inhparent
-----------------+-----------
 wwwlog_20180111 | wwwlog
 wwwlog_20180112 | wwwlog
 wwwlog_20180113 | wwwlog
 wwwlog_20180114 | wwwlog
2. 해당 테이블의 기본키 찾기
postgres=# select conname,pg_get_constraintdef(oid) 
from pg_constraint where conrelid::regclass = 'wwwlog'::regclass and
 contype = 'p';
   conname   |   pg_get_constraintdef
-------------+--------------------------
 wwwlog_pkey | PRIMARY KEY (seq, ctime)
3. 상속된 하위 테이블의 check 제약조건 찾기
postgres=# select conrelid::regclass, conname,pg_get_constraintdef(oid) 
from pg_constraint where conrelid 
  in (select inhrelid from pg_inherits 
      where inhparent::regclass = 'wwwlog'::regclass)
and contype = 'c' ;
    conrelid     |           conname           |                                                           pg_get_constr
aintdef
-----------------+-----------------------------+------------------------------------------------------------------------
------------------------------------------------------------------
 wwwlog_20180112 | wwwlog_20180112_ctime_check | CHECK (((ctime >= '2018-01-12 00:00:00'::timestamp without time zone) A
ND (ctime < '2018-01-13 00:00:00'::timestamp without time zone)))
 wwwlog_20180113 | wwwlog_20180113_ctime_check | CHECK (((ctime >= '2018-01-13 00:00:00'::timestamp without time zone) A
ND (ctime < '2018-01-14 00:00:00'::timestamp without time zone)))
 wwwlog_20180114 | wwwlog_20180114_ctime_check | CHECK (((ctime >= '2018-01-14 00:00:00'::timestamp without time zone) A
ND (ctime < '2018-01-15 00:00:00'::timestamp without time zone)))
4. 파티션 테이블 관련 쿼리
postgres=# select parentrelid,relid,pg_get_partition_constraintdef(relid) 
from (
  select (pg_partition_tree(oid)).* 
  from pg_class where relkind = 'p'
) as t;
 parentrelid |      relid      |                                                              pg_get_partition_constrain
tdef
-------------+-----------------+----------------------------------------------------------------------------------------
------------------------------------------------------------------
             | p_wwwlog        |
 p_wwwlog    | wwwlog_20180111 | ((ctime IS NOT NULL) AND (ctime >= '2018-01-11 00:00:00'::timestamp without time zone)
AND (ctime < '2018-01-12 00:00:00'::timestamp without time zone))
아쉽게도 check 제약조건 내용이나, 파티션 for values 값 정의 부분이 그냥 문자열로 뽑을 수 밖에 없습니다. 그래서, 필요한 부분만 딱 뽑아서 SQL을 쉽게 만들 수가 없습니다. 물론 이런 문제는 regexp_replace() 함수로 충분히 정규식을 이용한 문자열 조작으로 풀 수 있기는 합니다. 이 부분은 그때 그때 상황에 맞게 만들어야할 것 같습니다.

마지막으로 고려해야할 것은 운영 환경에서 이 형상 변경 작업으로 발생하는 서비스 중지 시간 최소화 방안입니다.
지난 일반 테이블을 파티션 테이블로 만들기 문서에서도 언급했듯이
이런 형상 변경 작업을 사용자들이 눈치 채지 못하게 아주 유연하게 바꾸는 것은 쉽지 않은 작업입니다. 이 부분은 결국 관리자와 응용 프로그램 개발자 사이 긴밀한 도움으로 최적 방안을 그 환경에 맞게 만드는 것 뿐입니다.


나가며

다 쓰고 나니 그다지 중요하지도 어렵지도 않는 간단한 작업을 뭘 이리 주절주절 썼나싶은데, 그냥 단순하게 '나는 상속 기반 파티션 테이블을 이런 쿼리로 바꿨다' 이렇게 쿼리만 달랑 기록해 두면, 분명 운영환경에서 고생할 것이 예상이 되어 노파심에서 주절주절했습니다.
아무튼 테이블 형상 변경 작업은 생각보다 예민한 작업입니다. 잘 풀어가시길 빕니다!