오픈소스 히스토리안: 시계열 저장소 선택과 운영
📍 현재 위치: Part III · 저장과 연결 — 13장. 이제 수집 계층(capture layer)이 우리에게 센서 판독값의 강물을 쏟아내고 있다. 이 장에서는 그 판독값이 살아갈 장소, 즉 오픈소스 *히스토리안(historian)*을 구축하고, 오픈소스가 어디에서 멈추고 상용 PI 서버가 어디에서 시작되는지를 정직하게 짚는다.
공정 히스토리안(process historian)은 지치지 않는 서기와 같다. 그의 유일한 임무는 플랜트가 내보내는 모든 숫자를, 그것이 발생한 시각과 함께, 영원히 적어 두는 것이다. 그리고 그중 어떤 조각이든 밀리초 단위로 다시 건네주는 것이다. 바이오리액터(bioreactor)는 몇 초마다 한 번씩 판독값을 내쉰다. 온도, pH, 용존산소(dissolved oxygen), 역가(titer). 14일 배치(batch) 동안이면 그것은 수백만 장의 종이쪽지가 된다. 평범한 데이터베이스는 그 앞에서 숨이 막히지만, 히스토리안은 바로 그것을 위해 만들어졌다. 상용의 표준(gold standard)은 AVEVA PI(예전의 OSIsoft PI)다. 이 책에서는 그에 상응하는 오픈소스를 구축한다. 그리고 그것이 상응하지 않는 두 지점을 솔직하게 알려 준다. PI가 유명한 특허 압축(patented compression), 그리고 모든 값에 붙는 PI 고유의 품질 플래그(quality flag)다.
이 장에서 다루는 내용
수집 장(5~12장)에서 우리는 센서, 엣지 게이트웨이(edge gateway), 컬렉터(collector)를 연결했고, 이들은 모두 판독값을 하나의 테이블로 흘려보냈다. 이 장은 그 테이블이 실제로 살아가는 곳이다. 우리는 다음을 할 것이다.
- PostgreSQL 안에서 **TimescaleDB 하이퍼테이블(hypertable)**로 히스토리안을 구축하여, 고속 센서 데이터와 관계형 배치 모델이 하나의 엔진을 공유하게 한다.
- **연속 집계(continuous aggregate)**로 1분·1시간 요약을 미리 굴려 두고, **보존 정책(retention policy)**으로 저장 용량을 제한한다. 다만 이 편의 기능들이 TimescaleDB 커뮤니티(Community, TSL) 기능이라는 점, 즉 무료로 실행할 수 있으나 OSI 오픈소스가 아니라 소스 공개(source-available)라는 점을 정직하게 밝힌다.
- **라이선스 함정(license trap)**을 큰 소리로 명명한다(진정한 Apache-2.0 코어는 하이퍼테이블,
create_hypertable,time_bucket,drop_chunks뿐이고, 연속 집계, 보존·CAGG 정책, Hypercore 압축은 모두 소스 공개 TSL 아래에 있다). 그리고 엄격하게 Apache-2.0인 대안들, 즉 Apache IoTDB, InfluxDB 3 Core, QuestDB를 살펴본다. - 모든 상용 히스토리안이 사용하는 알고리즘인 스윙잉도어(swinging-door) 압축을 설명하고, 부주의한 데드밴드(deadband)가 어떻게 조용히 기록을 손상시킬 수 있는지 설명한다.
- 그리고 어떤 오픈소스 히스토리안도 기본 제공하지 않는 한 가지, 즉 PI의 값별 **데이터 품질 플래그(data-quality flag)**와, 이 저장소가 그것을 어떻게 품고 가는지를 마주한다.
이 장의 스키마는 examples/platform/db/20-historian.sql에 있으며 make seed로 적용된다. 거기로 흘러드는 데이터는 examples/sim/bioproc_sim/fed_batch.py의 결정론적 시뮬레이터(deterministic simulator)가 생성한다. 둘 다 실재하며 테스트되어 있다.
모든 것을 담는 하나의 테이블
히스토리안의 심장은 거의 모욕적일 만큼 단순하다. examples/platform/db/20-historian.sql에서 가져온 테이블 전체는 다음과 같다.
CREATE TABLE ts.sensor_reading (
ts timestamptz NOT NULL,
tag text NOT NULL,
value double precision,
unit text,
quality smallint NOT NULL DEFAULT 192, -- OPC UA: 192 Good, 64 Uncertain, 0 Bad
batch_id text
);
여섯 개의 열. 타임스탬프, 태그 이름, 숫자, 단위, 품질 코드, 그리고 그것이 속한 배치다. 이 길고 좁은(long, narrow) 형태, 즉 센서당 한 열이 아니라 판독값당 한 행을 두는 방식이 히스토리안을 규정하는 선택이다. 새 센서는 새로운 tag 값일 뿐 스키마 마이그레이션(schema migration)이 아니다. 태그가 천 개든 하나든 모델링 비용은 같다. 이것은 7장에서 읽었던 OPC UA 주소 공간(address space)을 관계형으로 비춘 거울이다.
다음 줄이 평범한 Postgres 테이블을 시계열 엔진으로 바꾸는 부분이다.
SELECT create_hypertable('ts.sensor_reading', 'ts', chunk_time_interval => INTERVAL '1 day');
CREATE INDEX ON ts.sensor_reading (tag, ts DESC);
CREATE INDEX ON ts.sensor_reading (batch_id, ts DESC);
하이퍼테이블은 겉보기에도 동작에도 정확히 하나의 테이블처럼 보인다. ts.sensor_reading에 평소처럼 INSERT하고 SELECT한다. 하지만 그 아래에서 TimescaleDB는 자동으로 그것을 시간으로 분할된 *청크(chunk)*로 잘라낸다. 여기서는 하루에 하나의 청크다 [1]. 그 분할 덕분에 "어제의 역가"를 묻는 쿼리는 결코 지난달을 훑지 않는다. 플래너(planner)는 쿼리의 시간 범위와 겹치는 청크만 건드린다. 두 인덱스는 이 책의 나머지가 던지는 두 질문, 즉 한 태그를 시간에 걸쳐 달라와 한 배치를 시간에 걸쳐 달라를 비춘다. 둘 다 내림차순 시간 순으로 유지되는데, 가장 많이 묻는 질문이 "최근에 무슨 일이 있었나?"이기 때문이다.
왜 목적 특화 시계열 서버가 아니라 Postgres 확장(extension)인가? 바이오공정 세계가 근본적으로 조인(join) 문제이기 때문이다. 온도 판독값은 그것의 배치, 단계(phase), 장비, 레시피(recipe)에 묶이기 전까지는 무의미하다. 그리고 그 모든 것은 3장에서 구축한 관계형 ISA-88/95 모델 안에 살고 있다. 히스토리안을 같은 PostgreSQL 인스턴스 안에 두면 그 조인은 그저 평범한 SQL 조인이 되고, 시스템 간 접착제(glue)가 필요 없다. 이것이 정확히 14장이 활용하는 부분이다. 우리는 약간의 순수 적재 처리량(ingest throughput)을 내주고, 공정 질문을 하나의 쿼리로 던질 수 있는 능력을 얻는다. 단일 mAb 라인에는 그것이 옳은 교환이다.
여기에 내려앉는 데이터
이 테이블의 숫자들은 손으로 입력한 것이 아니다. 그것들은 examples/sim/bioproc_sim/fed_batch.py의 결정론적 시뮬레이터에서 온다. 이 시뮬레이터는 14일 유가식(fed-batch) CHO 배양을 모델링한다. 글루코스(glucose)와 글루타민(glutamine)으로 제한된 로지스틱 성장(logistic growth), 영양분이 고갈되면서 나타나는 사멸기(death phase), 생성되었다가 소비되는 락테이트(lactate), 그리고 생존 세포의 적분에 따라 축적되는 항체 역가까지, 모두 한정된 센서 노이즈를 지닌 PID 방식 컨트롤러 아래에서다. 그것은 자신의 열여섯 개 태그를 명시적으로 선언한다.
def _tag_specs() -> dict[str, str]:
return {
"BR101.Temp.PV": "degC",
"BR101.Temp.SP": "degC",
"BR101.pH.PV": "pH",
...
"BR101.OnlineGlucose.PV": "g/L",
"BR101.Titer.PV": "g/L",
}
곱씹어 볼 만한 점은 *결정론(determinism)*이다. 시뮬레이터는 자신의 무작위성을 하나의 마스터 값(SIM_SEED=2026)에 스트림별 라벨을 해시한 것으로 시드(seed)하므로, 같은 실행은 어떤 머신에서도 바이트 단위로 동일한 숫자를 만들어 낸다. 바로 이 점이 책이 정확한 값을 인용하고 CI가 그것을 검증하게 해 준다. 그것을 스모크 테스트(smoke test)로 실행하면 다음이 나온다.
$ python -m bioproc_sim.fed_batch
BATCH-2026-001: rows=322560 tags=16
final VCD=18.2e6 viab=64% titer=5.77 g/L
저 rows=322560은 열여섯 개 태그 곱하기 20,160분, 즉 분당 한 행으로 저장된 2주다. 네이티브 수집은 더 빠르다(실제 스키드(skid)는 몇 초마다 내보낸다). 분당 1회는 히스토리안이 영속화하는 주기로, 우리가 다시 다룰 첫 번째이자 정직한 형태의 다운샘플링(downsampling)이다. examples/datasets/fedbatch_timeseries_10min.csv에서 가져온 긴 형식 스트림의 한 조각이 바로 테이블에 INSERT되는 것이다. 다만 이 커밋된 골든(golden) CSV는 저장소를 작게 유지하기 위해 의도적으로 10분당 한 행으로 솎아 둔(32,256행, 처음 두 시간의 짧은 발췌도 fedbatch_timeseries_10min.sample.csv로 함께 커밋되어 있다) 점에 유의하라. 따라서 그 행들은 시뮬레이터의 네이티브 분당 1회, 322,560행 스트림을 다운샘플링한 뷰이지, 별개의 데이터셋이 아니다.
ts,tag,value,unit,quality,batch_id
2026-01-05 00:00:00+00:00,BR101.Temp.PV,37.0145,degC,192,BATCH-2026-001
2026-01-05 00:00:00+00:00,BR101.DO.PV,40.8224,%sat,192,BATCH-2026-001
2026-01-05 00:00:00+00:00,BR101.pH.PV,7.0511,pH,192,BATCH-2026-001
2026-01-05 00:00:00+00:00,BR101.Titer.PV,-0.0045,g/L,192,BATCH-2026-001
(그렇다, 접종 시점에 역가가 약간 음수로 읽힌다. 그것은 참값 0 주변의 측정 노이즈이며, 데이터가 교과서가 아니라 실제 프로브(probe)처럼 거동하도록 일부러 남겨 둔 것이다.)
품질 플래그: 상용 히스토리안에는 있고 대부분의 OSS가 잊는 열
저 quality 열을, 그리고 시뮬레이터가 그것을 어떻게 채우는지를 다시 보라. 유가식 모델은 7일째에 의도적인 결함을 주입한다. 온도 설정값(setpoint)이 0.5도 떨어지고 용존산소 프로브가 세 시간 동안 신뢰할 수 없게 되는 냉각 이탈(cooling excursion)이다.
GOOD, UNCERTAIN, BAD = 192, 64, 0 # OPC UA quality codes
...
if excursion:
# day-7 cooling excursion: setpoint dips 0.5 degC for ~3 h, DO reads uncertain
e0 = int(7 * 24 * 60)
e1 = e0 + 180
temp_sp[e0:e1] = 36.5
temp[e0:e1] = 36.5 + rng.normal(0, 0.05, e1 - e0)
do_uncertain[e0:e1] = True
저 192 / 64 / 0 숫자는 OPC UA 데이터 접근(Data Access) 상태 심각도, 즉 Good, Uncertain, Bad로, 소스에서부터 저장소까지 줄곧 운반된다. 이탈 동안 온도와 DO 판독값은 quality = 64로 기록되며, 저장된 데이터에서 그것들을 볼 수 있다.
ts,tag,value,unit,quality,batch_id
2026-01-12 00:00:00+00:00,BR101.Temp.PV,36.593,degC,64,BATCH-2026-001
2026-01-12 00:10:00+00:00,BR101.Temp.PV,36.4887,degC,64,BATCH-2026-001
2026-01-12 00:20:00+00:00,BR101.Temp.PV,36.468,degC,64,BATCH-2026-001
이것은 보이는 것보다 더 중요하다. Good인 36.47 °C 값과 Uncertain인 36.47 °C 값은 세계에 관한 서로 다른 사실이며, 둘을 뒤섞는 것은 데이터 무결성(data integrity) 실패다. ALCOA+에서 *Accurate(정확성)*의 "A"는 판독값이 자신의 신뢰성을 스스로 지니는 데 달려 있다. 상용 PI는 수십 년간 모든 포인트에 품질/대체 데이터 플래그를 운반해 왔다. 정직한 오픈소스 현실은 이렇다. 오픈 히스토리안 중 어느 것도 PI의 네이티브 품질 모델을 기본 제공하지 않는다. TimescaleDB도, IoTDB도, InfluxDB도, QuestDB도. 그래서 이 저장소는 PI가 대신 해 주는 명백한 일을 한다. quality를 일급(first-class) NOT NULL 열로 만들어 192(Good)를 명시적 기본값으로 두고, 컬렉터가 그것을 낮출 수 있게 한다. 그것은 다운로드받는 기능이 아니라 작은 설계 규율의 조각이며, 바로 이 책이 명명하기 위해 존재하는 종류의 간극이다.
오픈소스 히스토리안: 일 단위 청크로 자동 분할되고 열여섯 개 태그로 채워지는 하나의 길고 좁은 하이퍼테이블이, 1분·1시간 연속 집계로 미리 굴려지고, 보존 정책으로 제한되며, OPC UA 품질 플래그를 모든 행에 운반한다. Original diagram by the authors, created with AI assistance.
데이터를 미리 굴리기: 연속 집계
대시보드는 1,200픽셀 화면에 14일 추세를 그리기 위해 20,160개의 원시 포인트를 원하지 않는다. 요약을 원한다. 답이 거의 바뀌지 않는데도 새로고침할 때마다 원시 테이블 위에서 avg/min/max를 계산하는 것은 낭비다. TimescaleDB의 연속 집계가 이를 해결한다. 그것은 하이퍼테이블 위의 구체화된 뷰(materialized view)로, 새 데이터가 내려앉을 때 증분적으로(incrementally) 새로고침되므로 결코 과거를 다시 계산하지 않는다. examples/platform/db/20-historian.sql에서다.
-- 1-minute rollup (avg/min/max/last) as a continuous aggregate
CREATE MATERIALIZED VIEW ts.sensor_1m
WITH (timescaledb.continuous) AS
SELECT time_bucket('1 minute', ts) AS bucket,
tag,
avg(value) AS avg_value,
min(value) AS min_value,
max(value) AS max_value,
last(value, ts) AS last_value
FROM ts.sensor_reading
GROUP BY bucket, tag
WITH NO DATA;
time_bucket은 GROUP BY의 시계열 대응물이다. 각 타임스탬프를 자신의 1분 슬롯으로 내림하여 모든 판독값이 하나의 버킷에 떨어지게 한다. last(value, ts) 집계, 즉 평균이 아니라 각 버킷에서 가장 최근의 값을 고르는 것은 공정 엔지니어가 끊임없이 손을 뻗는 것이자 평범한 SQL로는 어색한 것이다. 두 번째 뷰인 ts.sensor_1h는 같은 데이터를 장기 추세용으로 시간 단위로 굴린다. 결정적으로 우리는 min과 max를 avg 옆에 유지한다. 1분 평균은 짧은 스파이크(spike)를 매끄럽게 지워 버렸을 것이지만, max 열은 그것을 보존한다. 그것이 이탈 조사(deviation investigation)에 신뢰할 수 있는 요약과 증거를 조용히 숨기는 요약의 차이다.
집계는 마법으로 스스로 새로고침되지 않는다. 정책이 그것을 스케줄링한다.
SELECT add_continuous_aggregate_policy('ts.sensor_1m',
start_offset => INTERVAL '3 days', end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 hour');
이렇게 읽으라. 매시간, 3일 전부터 1분 전까지의 데이터에 대해 1분 롤업을 새로고침한다. 유용할 만큼 최근이고, 늦게 도착하는 판독값이 안정되었을 만큼 충분히 과거다.
아래 라이선스 절에서 충분히 전개하지만 놀라지 않도록 여기서 미리 짚어 둘 한 가지 정직한 단서. 연속 집계(CREATE MATERIALIZED VIEW … WITH (timescaledb.continuous))와 add_continuous_aggregate_policy를 구동하는 백그라운드 작업 스케줄러는 Apache-2.0가 아니라 TimescaleDB 커뮤니티(TSL) 기능이다. 무료로 실행할 수 있지만, OSI 오픈소스가 아니라 소스 공개다. Hypercore 압축에 적용되는 것과 똑같은 단서다. 엄격하게 Apache-2.0를 고수해야 한다면, 그에 상응하는 것은 외부 크론(cron) 스케줄로 새로고침되는 평범한 CREATE MATERIALIZED VIEW다. 증분적이고 과거를 다시 계산하지 않는 거동은 잃지만 깨끗한 라이선스는 지킨다.
보존: 알맞은 양을, 알맞은 기간 동안
결코 잊지 않는 히스토리안은 결국 디스크를 가득 채운다. 그 반대의 실수, 즉 법이 보관을 요구하는 기록을 잊어버리는 것은 더 나쁘다. TimescaleDB는 행을 하나씩 삭제하는 대신 노후한 청크 전체를 떨어뜨리며 보존을 선언적으로(declaratively) 표현하게 해 준다.
-- keep raw readings for 400 days (multi-jurisdiction retention is set per region
-- in Chapter 23; this is a safe default longer than any single chapter needs).
SELECT add_retention_policy('ts.sensor_reading', INTERVAL '400 days');
여기서 무엇이 오픈소스이고 무엇이 아닌지에 대해 한마디. 이 책이 결코 얼버무리지 않는 바로 그런 종류의 세부 사항이기 때문이다. 수동 프리미티브, 즉 drop_chunks('ts.sensor_reading', older_than => INTERVAL '400 days')를 당신의 스케줄에 따라 직접 호출하는 것은 Apache-2.0다. 잊지 않도록 백그라운드 작업을 등록하는 위의 선언적 add_retention_policy는 TimescaleDB 커뮤니티(TSL) 기능으로 [2], 연속 집계 정책과 같은 작업 스케줄러를 탄다. 무료로 실행할 수 있지만 소스 공개이지 OSI 오픈소스가 아니다. 엄격한 Apache-2.0 스택이라면 이 한 줄을 drop_chunks를 직접 호출하는 크론 작업으로 대체할 것이다. 어느 쪽이든 메커니즘은 같다. 청크를 떨어뜨리는 것은 파티션 수준 연산이므로 저렴하다. WHERE 절로 322,560개의 행을 삭제하는 것은 그렇지 않다.
하지만 400 days라는 숫자는 임의적이지 않으며, 다이얼을 맞추는 것은 공학이 아니라 규제다. 미국 cGMP는 배치 기록을 그 배치의 유효기간 만료 후 최소 1년 보관할 것을, 그리고 보관된 전자 기록이나 진본 사본(true copies)을 즉시 검색 가능하게 유지할 것을 요구한다 [3]. EU의 Annex 11은 *매체(medium)*에 대해 더 나아간다. 저장·보관된 데이터는 전체 보존 기간에 걸쳐 접근성, 가독성, 무결성을 보장받고 주기적으로 점검되어야 한다. 단지 바이트를 보관하는 것이 아니라 그것을 읽을 수 있게 유지해야 한다 [4]. 그러므로 400일은 의도적으로 보수적인 단일 인스턴스 기본값이다. 실제 관할권별 보존 매트릭스는 플랫폼이 23장에서 적재하는 데이터인데, 글로벌 제조사는 제조된 장소에 따라 같은 데이터를 서로 다른 기간 동안 보관하기 때문이다.
라이선스 함정, 솔직하게 말하면
여기 이 책이 약속한 정직함이 있으며, 그것은 편리한 이야기보다 더 날카롭다. TimescaleDB는 **이중 라이선스(dual-licensed)**이지만, Apache-2.0 선은 대부분의 글이 인정하는 것보다 덜 관대한 곳에 떨어진다. 진정한 Apache-2.0 코어는 작다. 하이퍼테이블과 create_hypertable, time_bucket 함수, first/last 집계, 그리고 drop_chunks를 통한 수동 청크 관리다 [5]. 편리한 기능의 큰 집합은 소스 공개 **타임스케일 라이선스(Timescale License, TSL)**의 지배를 받는 tsl/ 디렉터리에 살고 있는데, 이것은 OSI 정의상 오픈소스 라이선스가 아니다. 결정적으로, 이 장이 기대는 세 가지가 Apache-2.0가 아니라 TSL이다. 연속 집계(WITH (timescaledb.continuous) 구체화 뷰), 선언적 보존(add_retention_policy), 그리고 add_continuous_aggregate_policy 뒤의 백그라운드 작업 스케줄러다. 대표적인 TSL 기능은 Hypercore 컬럼스토어(columnstore)와 네이티브 압축, 즉 다년치 히스토리안에 가장 원할 바로 그것이자 PI가 훌륭히 해내는 것이지만, 위에서 사용한 자동화도 같은 쪽 선상에 있다. 이 중 어느 것도 돈이 들지 않는다. TSL은 무료 사용에 소스 공개다. 그저 OSI 오픈소스가 아닐 뿐이며, 아닌 척하는 것이 바로 이 책이 피하고자 존재하는 라이선싱 과장(overstatement)이다.
그래서 이 장은 실용적인 정직한 하이브리드(honest-hybrid) 경로를 택한다. TSL 커뮤니티 자동화(연속 집계와 add_retention_policy)는 무료이고 훌륭하므로 사용하되, 그 부재가 디스크만큼만 비용을 치르게 하는 하나의 TSL 기능인 Hypercore 압축에서는 비켜선다. examples/platform/db/20-historian.sql 상단의 주석 블록이 그 경계를 정확히 명명한다.
-- Apache-2.0 core (hypertables, create_hypertable, time_bucket, drop_chunks) plus
-- free TimescaleDB Community (TSL) automation: continuous aggregates and
-- add_retention_policy. TSL is free-to-use and source-available, but NOT OSI
-- open source. We deliberately do NOT use the TSL Hypercore columnstore/compression,
-- so a strictly Apache-2.0 build is one cron-driven drop_chunks away — see Chapter 13.
한 문단으로 정리한 교환이 그것이다. 동반 스택은 표준 timescale/timescaledb:2.17.2-pg17 이미지를 고정하는데, 이 이미지는 무료 TSL 커뮤니티 기능을 묶고 있으므로 이 파일의 연속 집계와 add_retention_policy는 쓰인 그대로 실행된다. 만약 대신 엄격하게 Apache-2.0이어야 한다면, 예컨대 어떤 소스 공개 구성 요소도 없이 스택을 재배포하려면, TSL 함수를 노출조차 하지 않는 Apache 전용 -oss 빌드로 전환하고, 연속 집계를 크론으로 새로고침되는 평범한 구체화 뷰로, add_retention_policy를 스케줄된 drop_chunks로 대체하라. 그러면 히스토리안은 진정한 오픈소스 라이선스 아래에 놓인다. 그 편의를 내준 대가로 말이다. 반대로 조직이 TSL 조건을 받아들일 수 있다면, Hypercore를 켜는 것은 한 줄짜리 변경이자 큰 저장 용량 절감이다. 이 각각은 라이선싱 결정이며, 우리는 그것을 몰래 들여오는 대신 드러내 보인다.
엄격하게 Apache-2.0인 대안들은 정확히 소스 공개 조건을 전혀 받아들이지 않을 팀들을 위해 존재한다. Apache IoTDB는 깔끔하게 Apache-2.0이며 *장치 네이티브(device-native)*다. 각 시계열을 (device, measurement, timestamp, value) 경로로 모델링하고 자체 컬럼형 TsFile 포맷을 기본 제공하는데, SQL 테이블이 아니라 장비 트리로 사고할 때 자연스럽게 맞는다 [6]. InfluxDB 3 Core, 즉 오픈소스 계층(MIT/Apache-2.0, Apache Arrow·DataFusion·Parquet 위에 재구축됨)은 허용적이다. 다만 Enterprise와 Cloud 계층은 그렇지 않으며, 그래서 부주의한 influxdb:latest 풀(pull)이 알려진 함정이다 [7]. QuestDB는 Apache-2.0이며, 시간 정렬 쿼리를 간결하게 만드는 SAMPLE BY, LATEST ON, ASOF JOIN 같은 목적 특화 SQL 시계열 연산자를 갖췄다 [8]. 우리가 TimescaleDB를 기본값으로 제공하는 까닭은 배치 모델로의 조인 이야기가 하나의 Postgres 안에서 훨씬 더 깔끔하기 때문이며, 기본값이 무료 TSL 커뮤니티 자동화를 사용한다는 점, 그리고 어디서나 엄격한 Apache-2.0이 필요한 독자에게는 실재하고 명명된 경로가 있다는 점을 명시한다.
스윙잉도어 압축: 힘과 위험
TSL이 게이트하는 기능이자 모든 상용 히스토리안이 기대는 기법은 자체 설명을 받을 자격이 있다. "공간 절약"이 조용히 "기록 변경"이 될 수 있는 지점이기 때문이다. 고전적 알고리즘은 1987년 Bristol이 특허를 낸 **스윙잉도어 추세화(swinging-door trending)**다 [9]. 직관은 이렇다. 천천히 변하는 신호의 모든 포인트를 저장하는 대신, 허용 대역(tolerance band) 안에서 더는 판독값을 가로지르는 선을 그을 수 없을 때만 포인트를 저장한다. 그 허용 대역이 "편차(deviation)" 또는 데드밴드다. 한 시간 동안 37.0 °C로 유지되는 평평한 온도 자취는 3,600개 포인트에서 몇 개로 붕괴하고, 선형 보간(linear interpolation)으로 복원된다.
그 허용 매개변수는 양쪽을 베는 칼이다. 그것은 저장 용량 절감과 복원 오차(reconstruction error) 사이의 교환을 지배하는 단 하나의 다이얼이다 [10]. 느슨하게 맞추면 눈부신 압축을 얻는다. 그리고 당신이 탐지할 의무가 있는 바로 그 이탈을 매끄럽게 지워 버릴 수 있다. 고속 센서 스트림에 대한 스윙잉도어 연구는 정확히 이것을 정량화한다. 지나치게 공격적인 허용 한계는 기록된 신호를 왜곡하는 실제 복원 오차를 도입한다 [11]. GMP 기록에 그것은 단지 품질 우려가 아니다. ALCOA+에서 Accurate가 걸린 문제다. 우리의 7일째 이탈은 설정값보다 겨우 약 0.5 °C 아래에서 정점을 찍는데, 허술하게 설정된 데드밴드 안에 충분히 들어간다. 이 장이 당신에게 남기고 싶은 교훈은 이렇다. 손실 압축(lossy compression)은 정당하고 어디에나 있지만, 데드밴드는 저장 용량을 아끼려는 뒤늦은 생각이 아니라 *검증된 매개변수(validated parameter)*이며, 가장 안전한 오픈소스 자세는 원시 기록을 저장하고 손실 원본에서 복원하는 대신 설명 가능한 롤업(우리의 연속 집계)으로 다운샘플링하는 것이다.
왜 중요한가
히스토리안은 플랫폼 전체가 딛고 선 바닥이다. 그 형태를 틀리면 그 위의 모든 장이 그 실수를 물려받는다. 이 짧은 DDL 파일 안의 세 가지 결정이 가장 무거운 무게를 진다. 길고 좁은 스키마는 센서 추가가 결코 마이그레이션을 치르지 않게 한다. 히스토리안을 PostgreSQL 안에 두는 것은 컨텍스트화(contextualization, 14장)를 통합 프로젝트가 아니라 조인으로 만든다. 그리고 OPC UA 품질 플래그를 일급 열로 운반하는 것은 판독값의 신뢰성이 영구 기록 안으로 그것과 함께 이동하게 한다. 이것이 ALCOA+ 데이터 무결성과 그 뒤를 따르는 모든 감사 추적(audit-trail) 검토의 기술적 전제 조건이다. 우리가 의도적으로 빼 둔 조각들, 즉 TSL 압축, 스윙잉도어 손실 축소도 그만큼 중요하다. 그것들을 쓰지 않기로 택하는 것이야말로 스택을 깨끗하게 열린 상태로, 기록된 신호를 충실하게 유지하기 때문이다.
실제 현장에서는
지난 30년 동안 "플랜트 신호는 어디에 사는가?"에 대한 답은 OSIsoft PI, 이제는 AVEVA PI였다. 네이티브 압축, 포인트별 품질 모델, 자산 프레임워크(asset framework)를 갖춘 성숙하고 검증된, 벤더 지원을 받는 히스토리안으로, 거의 모든 대형 바이오제조사에 배포되어 있다. 오픈소스 히스토리안은 진정으로 멀리까지 데려다준다. TimescaleDB나 IoTDB는 라이선스 비용 없이 당신의 태그를 적재하고, 굴리고, 보존하고, 제공하며, 그것도 잘해낸다. 정직한 간극은 구체적이며 움츠리지 않고 말할 가치가 있다. PI의 특허 압축은 공짜로 얻지 못한다(TSL 게이트이거나 부재한다). 내장 품질 플래그는 얻지 못한다(우리가 했듯 열을 직접 구축한다). 그리고 GAMP-5 평가가 기대는 벤더의 검증된 시스템 패키지, 지원 계약, 공급자 책임도 얻지 못한다. 그 부담은 당신의 것이 되며, 17장과 22장이 그것을 진지하게 다룬다. PI가 기록 시스템(system of record)으로 남고 OSS 스택이 그 옆의 분석 계층이 되는 매우 흔한 경우를 위한 실제 양방향 PI 브리지(bridge)도 포함해서다. 그 하이브리드는 오픈소스의 실패가 아니다. 규제받는 플랜트의 현실적 모습이다.
NIIMBL, 즉 바이오의약품 제조 혁신을 위한 미국의 민관 협력 기관(Institute)은 바로 이 질문이 살을 무는 다중 벤더, 센서 밀집 라인을 구축하고 있다. 그 SABRE 시설(NIIMBL/델라웨어 대학교의 파일럿 규모 cGMP, 즉 현행 우수 제조 관리 기준(current Good Manufacturing Practice) 플랜트로 2024년 4월 착공)은 많은 스키드로부터 스트림을 생성할 것이고, 그 모두가 어딘가 쿼리 가능하고 보존 가능한 곳에 내려앉아야 한다. SABRE는 데이터 표준이나 히스토리안 제품이 아니라 건설 중인 시설이지만, 이 장의 "하나의 신뢰할 수 있는 시계열 저장소" 문제를 학술적이 아니라 구체적으로 만드는 바로 그 설정이다.
핵심 용어
- 히스토리안(historian) — 고속의 타임스탬프 찍힌 공정 신호를 저장하고 제공하는 데 특화된 데이터베이스. AVEVA/OSIsoft PI의 오픈소스 대응물.
- 하이퍼테이블(hypertable) — 하나의 테이블처럼 거동하지만 시간 범위 청크로 자동 분할되는 TimescaleDB 테이블. 쿼리가 관련된 시간 창만 훑게 한다.
- 청크(chunk) — 하이퍼테이블의 한 시간 범위 파티션(여기서는 하루). 보존이 떨어뜨리고 플래너가 가지치기하는 단위.
- 연속 집계(continuous aggregate) — 데이터가 도착할 때 증분적으로 새로고침되는 하이퍼테이블 위의 구체화된 뷰.
avg/min/max/last요약을 미리 굴리는 데 쓴다. - 보존 정책(retention policy) — 설정된 간격보다 오래된 청크를 떨어뜨리는 스케줄된 규칙. 행 삭제가 아니라 선언적으로 표현된다.
- 품질 플래그(quality flag) — 값이 얼마나 신뢰할 만한지를 기록하는 판독값별 코드(OPC UA: 192 Good, 64 Uncertain, 0 Bad). 여기서는 일급이지만 OSS 히스토리안에서는 기본적으로 부재한다.
- 길고 좁은 스키마(long/narrow schema) — 센서당 한 열이 아니라 판독값당 한 행(
ts, tag, value, …). 새 태그를 마이그레이션이 아니라 데이터가 되게 한다. - TSL(타임스케일 라이선스, Timescale License) — TimescaleDB의
tsl/기능(Hypercore 컬럼스토어, 네이티브 압축)을 지배하는 소스 공개 라이선스. OSI 오픈소스가 아니며 여기서는 의도적으로 미사용. - 스윙잉도어 추세화(swinging-door trending) — 고전적 손실 히스토리안 압축 알고리즘. 그 편차/데드밴드 허용 한계는 저장 용량과 복원 오차를 교환하며 검증된 매개변수로 다뤄져야 한다.
다음 이야기
이제 히스토리안은 충실하고, 보존 가능하며, 품질이 태깅된 판독값의 강물을 담고 있다. 하지만 그 판독값 하나하나는 여전히 어느 배치의 어느 단계에 속하는지에 대해 말이 없다. **14장 — 컨텍스트화: 시계열을 배치에 조인하기(Contextualization: Joining Time-Series to the Batch)**에서 우리는 이 ts.sensor_reading 하이퍼테이블을 3장의 ISA-88/95 배치 모델과 단 하나의 시간적 조인(temporal-join) 뷰로 결혼시킨다. 그러면 판독값은 "어느 순간의 37.04 °C"이기를 멈추고 "BR101의 BATCH-2026-001 생산 단계 동안의 37.04 °C"가 된다. 원시 데이터가 공정 지식으로 바뀌는 순간이다.