Postgres 19를 기대하며: 드디어 '시간'을 다룰 때
5 hours ago
2
- 데이터가 특정일자(지난 화요일) 시점에 어떤 모습이었는지 묻는 질문에 대응하기 위해, Postgres 19가 네이티브 시점 이력 테이블(temporal table) 지원을 도입, 별도 감사 트리거 시스템 없이 변경 전후 데이터 추적 가능
- SQL:2011 표준이 10여 년 전 정의한 Temporal 테이블을 Postgres가 뒤늦게 코어에 반영, 다른 DB 엔진보다 늦게 합류
- 기존 valid_from/valid_to 두 컬럼과 btree_gist 확장 기반 배제 제약(exclusion constraint) 대신, 단일 범위 타입(range type) 컬럼과 WITHOUT OVERLAPS 제약으로 더 직관적인 표현 제공
- FOR PORTION OF 구문으로 UPDATE·DELETE 시 행 자동 분할, 시간축의 공백·중첩 방지를 엔진이 처리
- 이번 도입은 양시점(bi-temporal) 시스템 중 유효 시간(application time) 절반에 해당, 시스템 시간(system time)은 미지원이지만 향후 릴리스의 기반을 마련
기존 방식 - The Old-Fashioned Way
- 제품 가격을 시간순으로 추적하려는 첫 시도는 valid_from, valid_to 두 날짜 컬럼과 valid_from < valid_to CHECK 제약으로 구성
- 그러나 동일 제품에 대해 날짜 범위가 겹치는 두 행 삽입을 막지 못함 (예: 제품 42번이 같은 화요일에 $9.99이자 $14.99)
- 전통적 해법은 btree_gist 확장과 배제 제약(exclusion constraint) 사용
- EXCLUDE USING gist (product_id WITH =, daterange(valid_from, valid_to) WITH &&) 형태로 겹치는 행 삽입 시 오류 발생
- 이 방식의 문제점
- GiST는 Postgres 고유 색인으로 경험이 필요하며, 선택적 확장이라는 점에서 진입 장벽 존재
- 배제 제약 구문이 직관적이지 않아 표준 접근으로 떠올리기 어려움
- 테이블 자체에 시점 인식이 내장되지 않음, 시간 범위 변경 시 행을 수동으로 분할·결합해야 하며 시점 정합성 부담이 응용 프로그램에 전가됨
시간의 짧은 역사 - A Brief History of Time
- SQL:2011 표준이 유효 시간 기간(APPLICATION TIME), WITHOUT OVERLAPS 제약, 시점 데이터 조작용 FOR PORTION OF 구문 도입
- Henrietta Dombrovskaya(Hetti)가 Chad Slaughter와 함께 pg_bitemporal 확장 개발, PL/pgSQL로 Postgres 내에서 양시점 테이블을 관리하는 프레임워크
- 2015년부터 여러 컨퍼런스에서 개념 발표, 유효 시간(valid time)(현실 세계에서 사실이 참인 시점)과 거래 시간(transaction time)(DB가 사실을 기록한 시점)을 동시에 추적하는 방법 시연
- 두 시간 차원의 구분
- 유효 시간은 "이 가격은 1월부터 6월까지 유효"라는 의미
- 거래 시간은 "이 행은 3월 12일 오후 3시 47분에 삽입, 4월 3일 오전 9시 1분에 대체됨"이라는 DB 관점
- 둘을 결합하면 "당시 알고 있던 정보 기준으로 지난 화요일 가격을 무엇이라 생각했는가"에 답하는 양시점 테이블 구성
- pg_bitemporal은 EXCLUDE USING gist를 이중으로 사용, effective 범위(유효 시간)와 asserted 범위(거래 시간)에 각각 하나씩 적용
- 양시점 삽입·갱신·정정·비활성·삭제용 함수와 시점 추론을 위한 앨런 구간 관계(Allen's interval relationships) 구현 제공
- 확장의 한계
- 쿼리 플래너가 시점 조건을 인식하도록 바꾸거나, 엔진 레벨 제약 시스템과 통합하거나, 네이티브 조작 구문을 제공할 수 없음 → 코어 진입 필요
- Postgres 19가 양시점 시스템의 유효 시간 절반 수용, 전체는 아니지만 큰 진전
범위로 해결 - Ranges to the Rescue
- Postgres 19 방식은 별도 valid_from/valid_to 대신 단일 범위 타입 컬럼 valid_at DATERANGE 사용
- PRIMARY KEY (product_id, valid_at WITHOUT OVERLAPS)로 btree_gist 확장과 배제 제약 불필요
- WITHOUT OVERLAPS는 product_id가 임의의 시점에서 유일하되 범위가 겹치지 않으면 동일 제품의 여러 행 허용
- 내부적으로는 여전히 GiST 색인을 쓰고 키의 비-시점 컬럼에 btree_gist가 필요하나, 제약 초기화 시 의존성을 Postgres가 자동 처리
- 범위 표기법: [는 포함, )는 제외 → [2025-01-01, 2025-07-01)은 1월 1일 포함, 7월 1일 미포함
- 마지막 Gadget 행 [2026-01-01,)는 종료일이 열린(open-ended) 범위로 현재 가격에 정의된 종료일 없음
- 겹침 보호 동작
- 잘못된 범위 [2025-03-01, 2025-01-01) 삽입 시 "범위 하한이 상한보다 작거나 같아야 한다"는 오류
- 겹치는 범위 [2025-03-01, 2025-09-01) 삽입 시 products_pkey 배제 제약 위반 오류
- 범위 사용 하나로 두 가지 검증 동시 확보
잘라내고 나누기 - Slicing and Dicing
- 상품의 가격을 2025년 3월~9월에만 $10.99로 변경하는 경우, 기존 방식은 행을 수동 분할·삽입해야 했고 실수 시 공백·중첩 발생
- 시점 이력 테이블에서는 의도한 대로 직접 표현
- UPDATE products FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-09-01' SET price = 10.99 WHERE product_id = 1
- 결과적으로 Widget 행이 3개에서 5개로 증가
- 기존 $9.99 행을 [2025-01-01, 2025-03-01)로 축소
- $10.99 신규 행을 남은 범위에 추가
- 기존 $12.99 행을 [2025-09-01, 2026-01-01)로 축소
- $10.99 신규 행을 남은 [2025-07-01, 2025-09-01) 범위에 추가
- $10.99가 두 행으로 나뉜 이유는 FOR PORTION OF가 매칭되는 각 행을 독립적으로 처리, 인접 범위를 사후 병합(coalesce)하지 않기 때문
- 최종 결과는 공백·중첩 없음, 순수 배제 논리(exclusion logic)로는 없던 이점
- 예외 상황(edge case)
- FOR PORTION OF 범위가 단일 기존 행 내부에 완전히 들어가면 최대 2개의 잔여 행(앞·뒤) 생성
- 기존 경계와 정확히 일치하면 잔여 행 불필요
- 새로 생기는 시점 잔여 행은 INSERT 권한이 필요 없으나, 기존 INSERT 트리거는 발동 → 감사 로깅이나 SECURITY DEFINER 트리거 함수에서 유의 필요
이력 지우기 - Erasing History
- FOR PORTION OF는 DELETE에서도 동작, 특정 상품을 2025년 6월~10월 카탈로그에서 일시 제거하는 걸 예로 들어보면
- DELETE FROM products FOR PORTION OF valid_at FROM '2025-06-01' TO '2025-10-01' WHERE product_id = 2
- 결과
- 6월~10월 구간이 잘려 나가고, [2025-04-01, 2026-01-01)을 덮던 $22.99 행이 6월에 끝나는 행과 10월에 시작하는 두 잔여 행으로 분할
- 공백 전후 가격 데이터는 원래 값으로 보존, DELETE가 행 수를 증가시키는 결과
- 시점 이력 테이블 관리 메커니즘이 모두 자동 처리, 응용 프로그램 레벨에서 과도 삭제나 고아 조각(orphaned fragment) 위험 제거
광고의 진실 - Truth in Advertising
- 시점 이력 테이블은 시점 외래 키(temporal foreign key) 없이는 완성되지 않으며, Postgres 19는 PERIOD 키워드로 이를 지원
- FOREIGN KEY (product_id, PERIOD valid_at) REFERENCES products (product_id, PERIOD valid_at) 형태
- PERIOD 키워드는 외래 키 자체가 시점 기반임을 알림
- 참조 대상 product가 variant의 valid_at 범위 전체 기간 동안 존재해야 함
- 참조 테이블의 모든 매칭 행의 조합이 참조하는 행의 기간을 완전히 덮어야 함
- product 시간 범위를 넘는 variant 생성 시도([2025-01-01, 2027-01-01))는 거부
- Widget 가격이 2026년 중반까지만 정의되어 2027년까지 유효 주장하는 variant는 외래 키 제약 위반으로 거부
- 한 가지 중요한 제한
- 시점 외래 키는 참조 동작(referential action)으로 NO ACTION만 지원, CASCADE·SET NULL·SET DEFAULT 제외
- variant가 의존하는 product 행 삭제 시 항상 오류, 연쇄 시점 연산의 복잡성 때문이며 응용 프로그램이 명시적으로 처리해야 함
작은 걸음들 - Baby Steps
- 현재 확보된 기능: 겹침 방지가 적용된 유효 시간 기반 시점 이력 테이블, 시점 데이터 조작, 시점 외래 키
- 가장 큰 누락은 거래 시간이라고도 불리는 시스템 시간(system time)
- 유효 시간은 사실이 현실에서 참인 시점을 추적, 시스템 시간은 DB가 그 사실을 인지하는 시점을 추적, 둘 다 활용하는 시스템 다수
- 이 영역은 2015년부터 pg_bitemporal 확장이 채워온 부분
- 트리거로 시스템 시간 모사(emulation)는 가능하나, 다른 신규 시점 기능처럼 엔진이 투명하게 관리하는 것과는 다름
- 시점 이력 테이블 문서도 시스템 시간이 네이티브 미지원이며 모사 가능함을 명시, Postgres 20 이후 도입 여부는 미정이나 기반은 이미 마련됨
맺음말 - Final Thoughts
- EXCLUDE USING gist 방식은 동작하지만 비교적 조잡한 우회책, pg_bitemporal 같은 확장이 개념을 증명하고 논의를 이어옴
- GiST 배제 제약보다 훨씬 직관적인 접근
- 기본 키의 WITHOUT OVERLAPS는 평이한 영어처럼 읽히고, FOR PORTION OF는 동작을 그대로 표현
- 시점 갱신·삭제 시 자동 행 분할이 한 부류의 잠재적 버그 제거
- SQL:2011에서 Postgres 19까지의 여정은 길었으며, Hetti와 커뮤니티가 수년간 패턴의 필요성과 실현 가능성 증명, 이제 코어에 반영됨
- 향후 릴리스의 시스템 시간 지원 주목 필요, Postgres가 양시점의 양쪽 절반을 모두 갖추면 가능성이 크게 확장됨
-
Homepage
-
Tech blog
- Postgres 19를 기대하며: 드디어 '시간'을 다룰 때