연속 집계(Continuous Aggregates) 이해하기

연속 집계(Continuous Aggregates) 이해하기

대시보드처럼 "항상 최신이어야 하는" 집계 결과가 필요할 때, 매번 원시 데이터 전체를 다시 계산하면 비용이 커요. TimescaleDB의 **연속 집계(Continuous Aggregates)**는 이런 부담을 줄여 주는 기능인데, 시간 버킷으로 묶은 롤업 결과를 미리 계산해 두고 백그라운드에서 자동으로 갱신해요. 새 데이터가 들어오거나 옛 데이터가 바뀌면 변경을 감지해 집계만 그때그때 다시 만들어 주니까, 일반 PostgreSQL materialized view처럼 전체를 매번 처음부터 다시 만들지 않아요.

출처: 공식문서 - Understand continuous aggregates

집계 방식의 종류

집계를 쉽게 만드는 방식은 크게 세 가지로 나뉘어요.

  • Materialized view: 표준 SQL 뷰로, 쿼리 결과를 물리적으로 저장하지만 데이터가 바뀌면 수동으로 새로고침해야 해요.
  • 연속 집계 (Continuous Aggregates): TimescaleDB 전용 기능이에요. materialized view와 비슷하지만, 새 데이터가 들어올 때마다 백그라운드에서 자동·증분 갱신돼요. 오래된 데이터가 수정되어도 효율적으로 재계산해요.
  • 실시간 집계 (Real-time aggregates): 연속 집계와 같은 구조이면서, 이미 집계된 데이터에 가장 최근 원시 데이터를 더해 쿼리할 때 항상 정확하고 최신인 결과를 돌려주는 방식이에요. TimescaleDB v2.13 이상에서는 실시간 집계가 기본적으로 비활성화(이전 버전은 활성)되어 있어요.

연속 집계는 하이퍼테이블 위에 만들어지기 때문에, 일반 테이블을 조회하듯 똑같이 SQL로 다룰 수 있어요. 행 저장소(rowstore)든 압축된 컬럼 저장소(columnstore)든 상관없이 조회 가능하고, 연속 집계 위에 다시 연속 집계를 만들어 더 세밀한 집계를 할 수도 있어요.

연속 집계 위에 연속 집계

연속 집계 위에 또 연속 집계를 만들면 다른 세밀도(granularity)로 데이터를 요약할 수 있어요. 예를 들어 초 단위 원시 하이퍼테이블에서 시간 단위 연속 집계를 만들고, 그 위에 일 단위 연속 집계를 쌓는 식이에요.

JOIN 절과 연속 집계

연속 집계는 여러 버전에 걸쳐 JOIN 지원이 확장되어 왔어요.

기능 TimescaleDB < 2.10.x TimescaleDB <= 2.15.x TimescaleDB >= 2.16.x
INNER JOIN
LEFT JOIN
LATERAL JOIN
하이퍼테이블 1개 + 표준 PostgreSQL 테이블 1개 조인
하이퍼테이블 1개 + 표준 PostgreSQL 테이블 여러 개 조인
조인 조건이 동등 조건 1개뿐
임의의 조인 조건

JOIN은 몇 가지 제약을 따라야 해요.

  • 변경 추적은 하이퍼테이블에만 적용되고, 표준 PostgreSQL 테이블의 변경은 추적되지 않아요.
  • INNER, LEFT, LATERAL 조인만 쓸 수 있고 그 외 조인 타입은 지원하지 않아요.
  • 연속 집계의 materialized 하이퍼테이블에 대한 조인은 지원하지 않아요.
  • JOIN 절이 있는 연속 집계 위에 계층적 연속 집계(hierarchical continuous aggregate)는 만들 수 있지만, 그 자체에 JOIN 절은 쓸 수 없어요.

다음 스키마를 기준으로 JOIN 예시를 볼게요.

CREATE TABLE locations (
  id TEXT PRIMARY KEY,
  name TEXT
);

CREATE TABLE devices (
  id SERIAL PRIMARY KEY,
  location_id TEXT,
  name TEXT
);

CREATE TABLE conditions (
  "time" TIMESTAMPTZ,
  device_id INTEGER,
  temperature FLOAT8
) WITH (
  tsdb.hypertable
);

ON 절로 동등 조건 하나만 쓰는 INNER JOIN 예시예요.

CREATE MATERIALIZED VIEW conditions_by_day WITH (timescaledb.continuous) AS
SELECT time_bucket('1 day', time) AS bucket, devices.name, MIN(temperature), MAX(temperature)
FROM conditions
JOIN devices ON devices.id = conditions.device_id
GROUP BY bucket, devices.name
WITH NO DATA;

WHERE 절에 조건을 추가하는 INNER JOIN입니다. (TimescaleDB v2.16.x 이상)

CREATE MATERIALIZED VIEW conditions_by_day WITH (timescaledb.continuous) AS
SELECT time_bucket('1 day', time) AS bucket, devices.name, MIN(temperature), MAX(temperature)
FROM conditions
JOIN devices ON devices.id = conditions.device_id
WHERE devices.location_id = 'location123'
GROUP BY bucket, devices.name
WITH NO DATA;

WHERE 절에 동등 조건을 명시하는 INNER JOIN이에요.

CREATE MATERIALIZED VIEW conditions_by_day WITH (timescaledb.continuous) AS
SELECT time_bucket('1 day', time) AS bucket, devices.name, MIN(temperature), MAX(temperature)
FROM conditions, devices
WHERE devices.id = conditions.device_id
GROUP BY bucket, devices.name
WITH NO DATA;

동등 조건이 여러 개인 INNER JOIN입니다. (TimescaleDB v2.16.x 이상)

CREATE MATERIALIZED VIEW conditions_by_day WITH (timescaledb.continuous) AS
SELECT time_bucket('1 day', time) AS bucket, devices.name, MIN(temperature), MAX(temperature)
FROM conditions
JOIN devices ON devices.id = conditions.device_id AND devices.location_id = 'location123'
GROUP BY bucket, devices.name
WITH NO DATA;

하이퍼테이블과 PostgreSQL 테이블 여러 개를 잇는 INNER JOIN이에요. (TimescaleDB v2.16.x 이상)

CREATE MATERIALIZED VIEW conditions_by_day WITH (timescaledb.continuous) AS
SELECT time_bucket('1 day', time) AS bucket, devices.name AS device, locations.name AS location, MIN(temperature), MAX(temperature)
FROM conditions
JOIN devices ON devices.id = conditions.device_id
JOIN locations ON locations.id = devices.location_id
GROUP BY bucket, devices.name, locations.name
WITH NO DATA;

하이퍼테이블과 PostgreSQL 테이블 간 LEFT JOIN입니다. (TimescaleDB v2.16.x 이상)

CREATE MATERIALIZED VIEW conditions_by_day WITH (timescaledb.continuous) AS
SELECT time_bucket('1 day', time) AS bucket, devices.name, MIN(temperature), MAX(temperature)
FROM conditions
LEFT JOIN devices ON devices.id = conditions.device_id
GROUP BY bucket, devices.name
WITH NO DATA;

하이퍼테이블과 서브쿼리 사이의 LATERAL JOIN이에요. (TimescaleDB v2.16.x 이상)

CREATE MATERIALIZED VIEW conditions_by_day WITH (timescaledb.continuous) AS
SELECT time_bucket('1 day', time) AS bucket, devices.name, MIN(temperature), MAX(temperature)
FROM conditions,
LATERAL (SELECT * FROM devices WHERE devices.id = conditions.device_id) AS devices
GROUP BY bucket, devices.name
WITH NO DATA;

함수 지원

TimescaleDB v2.7 이상에서는 연속 집계가 PostgreSQL 집계 함수를 모두 지원해요. SUM, AVG처럼 병렬화 가능한 집계부터 RANK처럼 병렬화할 수 없는 집계까지요. v2.10.0 이상에서는 FROM 절의 JOINS도 제약과 함께 지원돼요. 옛 버전에서처럼 오래된 동작을 원하면 연속 집계 생성 시 timescaledb.finalized 파라미터를 false로 설정하면 돼요.

연속 집계의 구성 요소

연속 집계는 크게 네 가지 요소로 이뤄져요.

  • Materialization 하이퍼테이블: 집계 결과를 저장하는 테이블
  • Materialization 엔진: 원시 테이블의 데이터를 집계해 materialization 하이퍼테이블로 옮기는 엔진
  • 무효화(Invalation) 엔진: 데이터 변경으로 재계산이 필요한 범위를 결정하는 엔진
  • 쿼리 엔진: 집계된 데이터에 접근하는 엔진

Materialization 하이퍼테이블

연속 집계는 원래 하이퍼테이블에서 원시 데이터를 가져와 집계한 뒤, 그 결과를 materialization 하이퍼테이블에 저장해요. 연속 집계 뷰를 조회하면 필요한 만큼 집계 데이터를 돌려주죠. 이 materialization 테이블도 하이퍼테이블로 저장되어서 하이퍼테이블의 확장성과 쿼리 최적화를 그대로 누릴 수 있어요. 테이블에는 쿼리의 각 group-by 절마다 컬럼이, 각 집계마다 aggregate 컬럼이 생겨요.

Materialization 엔진

Materialization 엔진은 트랜잭션 두 번으로 동작해요. 첫 번째 트랜잭션은 모든 INSERT·UPDATE·DELETE를 잠시 막고, materialize 할 시간 범위를 정한 뒤 무효화 임계값(threshold)을 갱신해요. 두 번째 트랜잭션은 다른 트랜잭션을 다시 열고 집계를 materialize 해요. 첫 트랜잭션은 매우 짧고 대부분의 작업은 두 번째에서 일어나서, 다른 작업을 방해하지 않게 설계되어 있어요.

무효화 엔진

하이퍼테이블의 데이터가 바뀌면 일부 materialized 행이 무효화될 수 있어요. 무효화 엔진은 시스템이 무효화로 가득 차지 않게 관리해요. 시계열 데이터는 대부분 최근 타임스탬프로 INSERT·UPDATE가 일어나기 때문에, 무효화 엔진은 모든 데이터를 다시 만들지 않고 materialization 임계값(materialization threshold)이라는 특정 시점까지만 materialize 해요.

임계값보다 오래된 데이터가 바뀌면, 각 트랜잭션이 수정한 행의 최소·최대 타임스탬프를 기록해요. 이 기록을 기반으로 어떤 시간 버킷이 영향받았는지 판단하죠. 예를 들어 10:00 버킷과 15:00 버킷의 행을 한 트랜잭션에서 수정하면, 새로고침 시 10:00부터 15:00까지(중간 11:00~14:00 포함) 모든 버킷이 재계산돼요. 반대로 두 버킷을 별도 트랜잭션으로 수정하면 10:00와 15:00 버킷만 재계산되고 중간 버킷은 영향받지 않아요. 임계값은 현재 변경이 활발한 영역보다 뒤처져 있도록 잡혀서, 로깅으로 인한 쓰기 부담은 작고 드물어요.

더 알아보기 (Learn more)