LLM WikiAccess-protected knowledge portal

WIKI

TimescaleDB 2.28: 연속 집계를 작게 갱신하고 컬럼스토어 메타데이터로 답하는 방식

수동 refresh가 작은 운영 작업이 아니었던 이유 연속 집계 Continuous Aggregate 는 원본 시계열을 시간 버킷별로 미리 계산해 대시보드와 분석 쿼리의 반복 비용을 줄인다. 문제는 장기간 backfill이나 집계 정의 변경 뒤에 수개월치 구간을 수동으로 다시 계산할 때 생긴다. 큰 refresh window 하나가 긴 트랜잭션과 무거운 잠금, 높은 I/O를 한꺼번에 만들면 평소에는 잘 보이지 않던 운영 위험이

경로human/study/content/database-frontier/05-timescaledb-2-28-incremental-cagg-refresh.md
카테고리Study
태그#cagg #frontier #incremental #mysql #refresh #study #timescaledb

수동 refresh가 작은 운영 작업이 아니었던 이유

연속 집계(Continuous Aggregate)는 원본 시계열을 시간 버킷별로 미리 계산해 대시보드와 분석 쿼리의 반복 비용을 줄인다. 문제는 장기간 backfill이나 집계 정의 변경 뒤에 수개월치 구간을 수동으로 다시 계산할 때 생긴다. 큰 refresh window 하나가 긴 트랜잭션과 무거운 잠금, 높은 I/O를 한꺼번에 만들면 평소에는 잘 보이지 않던 운영 위험이 드러난다.

TimescaleDB 2.28.0은 2026년 6월 16일 공개됐다. 이 릴리스의 중요한 변화는 단순히 쿼리 몇 개가 빨라진 것이 아니다.

다만 2.28.0 직후 두 번의 patch release가 이어졌다. 2.28.1은 압축 테이블 DML crash와 first/last 오답 경로 등을 고쳤고, 2.28.2는 upgrade migration과 sparse index column ordering을 다시 수정했다. 따라서 이 장의 도입 기준은 기능 릴리스인 2.28.0이 아니라 2026년 6월 30일 공개된 2.28.2 이상이다.

이 장은 upstream release note, 2.28.2 tag의 source code, 관련 pull request를 기준으로 한다. 공식 release note는 특정 환경의 성능 향상률을 제시하지 않으므로 숫자를 일반화하지 않고 실행계획과 운영 지표로 검증하는 방법에 집중한다.


2.28의 핵심 경로를 한 장으로 보기

TimescaleDB 2.28 — 쓰기 변경 추적, batch refresh, 메타데이터 집계 원본 hypertable INSERT / UPDATE / DELETE 변경된 시간 범위 발생 invalidation log 다시 계산할 범위 기록 완전한 bucket 경계로 정렬 refresh_continuous_aggregate() 기본: batch당 10 buckets · newest first · batch 수 제한 없음 같은 CAGG catalog tuple을 잠가 invalidation 처리 직렬화 buckets_per_batch = 0이면 한 번의 atomic pass 긴 refresh window를 독립적인 작업 단위로 분할 Batch 4 — 최신 구간 먼저 refresh Batch 3 commit 경계 축소 Batch 2 I/O 폭주 완화 Batch 1 — 과거 구간 마지막에 refresh Materialized hypertable 변경된 bucket의 집계 결과 갱신 ADD COLUMN으로 새 aggregate 추가 가능 기존 row는 NULL, forced refresh로 backfill dashboard · alert · analytical query 압축 batch 메타데이터 first / last 순서상 경계값 batch 압축 해제 불필요 ColumnarIndexScan eligible plan일 때 메타데이터로 집계 운영 판단: 작은 batch는 blast radius를 줄이지만 전체 작업의 단일 원자성은 포기한다. 실행계획과 결과 정합성을 함께 검증한다.
TimescaleDB 2.28의 연속 집계 갱신과 컬럼스토어 조회 경로

변화 1: 수동 refresh도 기본적으로 batch 처리한다

2.28 이전에 운영자가 긴 window를 직접 refresh하면 그 범위를 큰 작업 하나로 다루기 쉬웠다. 2.28에서는 manual refresh도 refresh policy와 같은 batch 처리기를 사용한다. source code의 기본값은 다음과 같다.

옵션2.28 기본값의미
buckets_per_batch10한 batch가 처리할 time buckets 수. 0이면 batch 분할을 끄고 한 번에 처리
max_batches_per_execution0한 번의 호출에서 처리할 최대 batch 수. 0은 제한 없음
refresh_newest_firsttrue최신 구간부터 과거 방향으로 처리
forcefalseinvalidation이 있는 구간만 처리. true이면 대상 window를 다시 materialize

예를 들어 1시간 bucket을 24개씩, 최신 구간부터 최대 4 batch만 처리하려면 다음과 같이 호출한다.

CALL refresh_continuous_aggregate(
  'metrics_hourly',
  now() - INTERVAL '180 days',
  now(),
  options => '{
    "buckets_per_batch": 24,
    "max_batches_per_execution": 4,
    "refresh_newest_first": true
  }'::jsonb
);

이 호출은 한 번에 최대 96시간 bucket을 처리한다. 아직 invalidation이 남아 있다면 같은 호출을 다시 실행해야 한다. max_batches_per_execution은 작업 시간을 제한하는 안전장치이지, 남은 구간을 자동으로 예약하는 scheduler가 아니다.

normal refresh와 forced refresh의 batch 기준

두 모드는 같은 크기로 단순히 window를 자르는 것이 아니다.

CALL refresh_continuous_aggregate(
  'metrics_hourly',
  '2026-01-01 00:00:00+00',
  '2026-04-01 00:00:00+00',
  force => true,
  options => '{
    "buckets_per_batch": 24,
    "max_batches_per_execution": 2,
    "refresh_newest_first": false
  }'::jsonb
);

과거 데이터를 순서대로 재처리해야 하거나 downstream export가 시간 순서를 가정한다면 refresh_newest_first: false가 낫다. 대시보드의 최신 구간을 먼저 복구하는 것이 중요하면 기본값을 유지한다.

batch가 줄이는 것과 늘리는 것

작은 batch는 한 번에 유지하는 transaction과 lock의 범위를 줄이고, 장애가 나도 재작업 범위를 좁힌다. 반면 batch마다 계획·commit·metadata 처리가 반복되므로 총 실행 시간은 늘 수 있다. 너무 작은 값은 I/O를 부드럽게 만드는 대신 overhead를 크게 만든다.

따라서 10을 정답으로 보지 않는다. 실제 bucket당 row 수, materialization 쿼리의 join·aggregate 비용, replica WAL replay lag, storage latency를 함께 보고 조정해야 한다.


변화 2: invalidation 처리의 잠금 범위를 줄였다

연속 집계 refresh는 원본 hypertable의 변경 범위를 invalidation log에서 읽고, 처리한 범위를 잘라내야 한다. 같은 연속 집계를 두 세션이 동시에 처리하면 log를 중복 소비하거나 경계가 꼬일 수 있으므로 직렬화 지점은 필요하다.

이전 구현은 materialization hypertable에 ShareUpdateExclusiveLock을 잡아 log 처리를 보호했다. 하지만 이 잠금은 같은 연속 집계의 invalidation 처리보다 넓은 작업을 막을 수 있었다.

2.28은 materialization hypertable 전체를 잠그는 대신 continuous aggregate catalog의 해당 tuple을 잠근다. 보호하려는 invariant는 그대로다.

여기서 "lock이 없어졌다"고 이해하면 안 된다. refresh 자체가 데이터를 쓰고 catalog를 갱신하므로 다른 잠금과 I/O 경합은 남는다. 개선된 것은 동시성을 막는 경계가 materialization relation 전체에서 같은 CAGG의 catalog row로 좁아진 것이다.

운영 중에는 pg_stat_activity, pg_locks, WAL 생성량, replica lag를 함께 본다. refresh가 빨라졌는지만 보면 primary에서는 성공하고 replica에서 뒤늦게 지연이 누적되는 문제를 놓칠 수 있다.


변화 3: 연속 집계에 aggregate column을 추가할 수 있다

기존 연속 집계에 새 지표 하나를 넣기 위해 view를 drop하고 다시 만들면 dependent view, 권한, dashboard 계약과 전체 backfill을 함께 건드리게 된다. 2.28은 제한된 형태의 ADD COLUMN을 지원한다.

ALTER MATERIALIZED VIEW metrics_hourly
ADD COLUMN max_temperature double precision
GENERATED ALWAYS AS (max(temperature)) STORED;

허용되는 형태는 GENERATED ALWAYS AS (<aggregate>) STORED다. 일반 column, NOT NULL, COLLATE, STORAGE 같은 추가 column constraint를 붙인 형태는 거부된다.

내부에서는 새 column을 materialization hypertable에 추가하고 partial view, direct view, user view의 select list를 함께 다시 정의한다. 이 때문에 다음 경계가 생긴다.

SET lock_timeout = '5s';

ALTER MATERIALIZED VIEW metrics_hourly
ADD COLUMN max_temperature double precision
GENERATED ALWAYS AS (max(temperature)) STORED;

CALL refresh_continuous_aggregate(
  'metrics_hourly',
  now() - INTERVAL '90 days',
  now(),
  force => true,
  options => '{
    "buckets_per_batch": 24,
    "max_batches_per_execution": 4,
    "refresh_newest_first": true
  }'::jsonb
);

DDL이 성공했다고 migration이 끝난 것은 아니다. NULL인 역사 구간과 값이 채워진 최신 구간이 공존할 수 있으므로 consumer가 이를 구분할 수 있어야 한다. backfill 완료 조건을 row count가 아니라 원본 직접 집계와의 표본 대조로 정의하는 편이 안전하다.


변화 4: first()last()를 압축 해제 없이 계산한다

"장비별 최신 측정값"처럼 시계열에서 자주 나오는 쿼리는 다음 형태다.

SELECT
  device_id,
  last(temperature, time) AS latest_temperature
FROM metrics
WHERE time >= now() - INTERVAL '30 days'
GROUP BY device_id;

2.28은 새로 압축되는 batch에 정렬 순서상의 실제 첫 값과 마지막 값을 sparse metadata로 보관한다. 기존 minmax가 통계적 최솟값·최댓값을 추적하는 것과 달리 firstlast순서 경계의 값을 추적한다. eligible plan에서는 ColumnarIndexScan이 compressed row를 풀지 않고 이 metadata를 aggregate 입력으로 사용한다.

이 최적화는 모든 first()·last() 쿼리에 자동 적용되는 만능 경로가 아니다. 2.28.2 source 기준으로 중요한 조건은 다음과 같다.

EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT
  device_id,
  last(temperature, time)
FROM metrics
WHERE time >= now() - INTERVAL '30 days'
GROUP BY device_id;

Custom Scan (ColumnarIndexScan)과 compressed chunk의 metadata index 사용 여부를 확인한다. 쿼리 latency만 비교하면 cache warming과 데이터 분포 차이를 최적화 효과로 오해하기 쉽다. 같은 snapshot, 같은 filter, 비슷한 cache 조건에서 실행계획과 buffer read를 함께 비교해야 한다.

왜 2.28.2부터 도입해야 하는가

새 실행 경로는 기능 릴리스 직후 patch에서 여러 번 수정됐다.

성능 기능이 결과 정확성과 맞닿아 있을 때는 "최신 patch를 쓰자"가 보수적인 조언이 아니라 correctness 요구사항이다. 2.28.0이나 2.28.1에서 실행계획이 빨라 보였다는 이유만으로 production에 고정하지 않는다.


호환성과 migration에서 놓치기 쉬운 변화

PostgreSQL 15 지원은 2.28 계열이 마지막이다

upstream은 2.29.0부터 PostgreSQL 15를 지원하지 않고 PostgreSQL 16, 17, 18만 지원한다고 공지했다. PostgreSQL 15에서 TimescaleDB 2.28로 올리는 것은 막다른 길에 가까운 중간 단계다. extension upgrade와 PostgreSQL major upgrade의 순서, logical/physical migration 방식, rollback 경로를 함께 설계해야 한다.

adaptive chunking이 제거됐다

약 9년 동안 experimental 상태였던 adaptive chunking이 2.28에서 제거됐다. upgrade script는 chunk_target_size > 0인 hypertable이 있으면 update를 중단한다.

SELECT id, schema_name, table_name, chunk_target_size
FROM _timescaledb_catalog.hypertable
WHERE chunk_target_size > 0;

결과가 있다면 현재 version에서 adaptive chunking을 비활성화하고 명시적인 chunk_time_interval 운영으로 전환한 뒤 upgrade한다. 내부 catalog를 직접 update해서 검사를 우회하면 안 된다.

_timescaledb_catalog.chunk_constraint는 table이 아니다

2.28은 내부 chunk_constraint catalog table을 제거하고 임시 compatibility view로 대체했다. 현재 query 결과가 같더라도 object type과 내부 구조가 달라졌다. 이 view도 향후 제거될 예정이다.

내부 catalog를 직접 읽는 monitoring, inventory, migration script가 있다면 timescaledb_information의 public informational view로 옮긴다. 내부 catalog query가 돌아간다는 이유로 호환성이 보장된다고 판단하지 않는다.

in-place downgrade를 rollback으로 간주하지 않는다

2.28의 firstlast sparse index가 만들어진 뒤 extension을 downgrade하고 chunk가 다시 압축되면 metadata 일관성 문제가 생길 수 있다. 이후 2.28로 재upgrade할 때 update script가 이를 감지해 중단한다.

안전한 rollback은 package만 낮추는 것이 아니라, 사전에 검증한 database snapshot이나 replica promotion, logical restore 경로여야 한다. in-place downgrade가 꼭 필요하다면 upstream 경고와 재압축 절차를 별도 runbook으로 검증한다.


운영자 도입 절차

1. version과 차단 조건을 먼저 확인한다

SELECT extversion
FROM pg_extension
WHERE extname = 'timescaledb';

SHOW server_version;

SELECT id, schema_name, table_name, chunk_target_size
FROM _timescaledb_catalog.hypertable
WHERE chunk_target_size > 0;

PostgreSQL 15이면 2.29 이후 경로까지 포함한 major upgrade 계획을 먼저 만든다. 내부 catalog를 직접 참조하는 사내 query도 source search로 찾는다.

2. 2.28.2 이상으로 upgrade한다

TimescaleDB changelog는 .psqlrc가 이전 extension version을 예상치 않게 load하지 않도록 psql -X 사용을 경고한다.

psql -X -d appdb -v ON_ERROR_STOP=1 \
  -c "ALTER EXTENSION timescaledb UPDATE TO '2.28.2';"

database별로 extension version이 다를 수 있으므로 같은 PostgreSQL instance의 모든 database를 확인한다. package 설치와 각 database의 ALTER EXTENSION은 별도 단계다.

3. 먼저 작은 CAGG로 batch 경계를 검증한다

4. ADD COLUMN은 DDL과 backfill을 분리한다

5. 실행계획으로 columnstore 최적화를 확인한다

6. rollback 조건을 사전에 숫자로 정한다

다음 중 하나라도 발생하면 rollout을 중단한다.


어떤 값을 선택할 것인가

운영 상황시작값이유
최신 dashboard를 먼저 복구refresh_newest_first: true가장 최근 bucket의 freshness를 먼저 회복
과거 순서대로 downstream exportrefresh_newest_first: false시간 순서 의존 consumer의 혼란 방지
큰 backfill의 blast radius 제한buckets_per_batch를 작게, max_batches_per_execution 지정한 번의 호출 시간과 WAL burst 제한
아주 좁고 반드시 한 덩어리여야 하는 refreshbuckets_per_batch: 0single-pass atomic refresh. 긴 window에는 부적합
CAGG schema에 지표 추가ADD COLUMN 후 제한된 forced refreshdrop/recreate를 피하되 DDL lock과 역사 구간 NULL 관리
최신값 집계 최적화2.28.2 이상 + eligible orderby + EXPLAIN 검증metadata-only path의 correctness patch 포함

기본값을 그대로 쓰는 것보다 중요한 것은 작업 단위와 완료 조건을 명시하는 것이다. batch refresh는 큰 작업을 작게 나눠주지만, 남은 작업을 자동으로 끝냈다고 보장하지 않는다. ADD COLUMN은 view 재생성을 피하지만, 역사 구간 backfill을 대신해 주지 않는다. first/last metadata는 압축 해제를 피할 수 있지만, planner 조건을 충족하지 않으면 기존 경로로 돌아간다.


정리

TimescaleDB 2.28의 변화는 시계열 운영에서 자주 충돌하던 세 경계를 다시 그린다.

첫째, 긴 수동 refresh를 기본 10-bucket batch로 나눠 transaction과 lock의 blast radius를 줄인다. 둘째, invalidation 처리의 직렬화 지점을 materialization relation 전체가 아니라 같은 CAGG의 catalog tuple로 좁힌다. 셋째, compressed batch의 first/last metadata를 이용해 eligible 집계를 압축 해제 없이 처리한다.

여기에 연속 집계 ADD COLUMN이 더해져 schema evolution의 선택지가 넓어졌지만, 무잠금·무backfill 기능은 아니다. 기존 row는 NULL로 시작하고 관련 view에는 강한 DDL lock이 필요하다.

도입 판단의 기준은 기능 목록이 아니라 운영 절차다. 2.28.2 이상을 사용하고, adaptive chunking과 내부 catalog 의존성을 먼저 제거하며, batch 완료 여부·CAGG 정합성·replica lag·실행계획을 함께 검증해야 한다. 특히 2.28.1과 2.28.2에서 correctness와 migration 문제가 연속으로 수정됐다는 사실은 latest patch 적용이 선택이 아니라 전제임을 보여준다.

References