오픈소스 히스토리안: 시계열 저장소 선택과 운영
📍 현재 위치: Part III · 저장과 연결 — 16장. 이제 수집 계층(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)입니다.
이 장에서 다루는 내용
수집 장(7–15장)에서 우리는 센서, 엣지 게이트웨이(edge gateway), 컬렉터(collector)를 연결했고, 이들은 모두 판독값을 하나의 테이블로 흘려보냈습니다. 이 장은 그 테이블이 실제로 살아가는 곳입니다. 우리는 다음을 할 것입니다.
- PostgreSQL 안에서 TimescaleDB 하이퍼테이블(hypertable)로 히스토리안을 구축하여, 고속 센서 데이터와 관계형 배치 모델 — 판독값이 어느 배치, 단계(phase), 장비에 속하는지를 기술하는 평범한 SQL 테이블(4장) — 이 하나의 엔진을 공유하게 합니다.
- 연속 집계(continuous aggregate)로 1분·1시간 요약을 미리 굴려 두고, 보존 정책(retention policy)으로 저장 용량을 제한합니다. 다만 이 편의 기능들이 TimescaleDB 커뮤니티(Community, TSL) 기능이라는 점, 즉 무료로 실행할 수 있으나 소스 공개(source-available)(코드는 읽을 수 있지만 자유롭게 재사용·재배포할 수는 없음)일 뿐 OSI(Open Source Initiative, 오픈소스 이니셔티브) 정의상 오픈소스는 아니라는 점을 정직하게 밝힙니다.
- 라이선스 함정(license trap)을 큰 소리로 명명합니다(진정한 Apache-2.0 코어 — Apache-2.0은 누구나 코드를 사용·수정·재배포할 수 있게 하는 관대한 OSI 승인 오픈소스 라이선스입니다 — 는 하이퍼테이블,
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 up으로 컨테이너가 처음 기동될 때 자동으로 적용됩니다(db/ 디렉터리가 Postgres의 /docker-entrypoint-initdb.d에 마운트되어, 데이터베이스가 처음 시작할 때 00–60 스키마 파일이 실행됩니다). 거기로 흘러드는 데이터는 examples/sim/bioproc_sim/fed_batch.py의 결정론적 시뮬레이터(deterministic simulator)가 생성하며, make data와 make load로 생성·적재됩니다. 둘 다 실재하며 테스트되어 있습니다.
모든 것을 담는 하나의 테이블
히스토리안의 심장은 거의 모욕적일 만큼 단순합니다. 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, -- legacy OPC DA: 192 Good, 64 Uncertain, 0 Bad
batch_id text
);
여섯 개의 열. 타임스탬프, 태그 이름, 숫자, 단위, 품질 코드, 그리고 그것이 속한 배치입니다. 이 길고 좁은(long, narrow) 형태, 즉 센서당 한 열이 아니라 판독값당 한 행을 두는 방식이 히스토리안을 규정하는 선택입니다. 새 센서는 새로운 tag 값일 뿐 스키마 마이그레이션(schema migration)이 아닙니다. 태그가 천 개든 하나든 모델링 비용은 같습니다. 이것은 7장에서 읽었던 OPC UA 주소 공간(address space)을 관계형으로 비춘 거울입니다. OPC UA는 모든 센서를 이름 붙은, 탐색 가능한 노드로 노출하는 산업 통신 표준입니다.
다음 줄이 평범한 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) 문제이기 때문입니다. 조인이란 공유 키(여기서는 batch_id)로 두 테이블의 행을 한데 엮는 데이터베이스 연산입니다. 온도 판독값은 그것의 배치, 단계(phase), 장비, 레시피(recipe)에 묶이기 전까지는 무의미합니다. 그리고 그 모든 것은 4장에서 구축한 관계형 ISA-88/95 모델 안에 살고 있습니다(ISA-88과 ISA-95는 배치, 레시피, 장비를 구조화된 데이터로 기술하는 제조 표준입니다). 히스토리안을 같은 PostgreSQL 인스턴스 안에 두면 그 조인은 그저 평범한 SQL 조인이 되고, 시스템 간 접착제(glue)가 필요 없습니다. 이것이 정확히 17장이 활용하는 부분입니다. 우리는 약간의 순수 적재 처리량(ingest throughput, 행이 얼마나 빨리 기록되는가)을 내주고, 공정 질문을 하나의 쿼리로 던질 수 있는 능력을 얻습니다. 단일 단클론항체(mAb) 라인에는 그것이 옳은 교환입니다.
히스토리안 판독값의 해부: 길고 좁은 원자
플랫폼 전체가 이 여섯 개 열짜리 행 위에 서 있으므로, 한 번에 한 필드씩 해부해 볼 가치가 있습니다. 각 열이 의도된 결정이며, 그중 하나라도 틀리면 이 테이블에 조인하는 모든 장으로 위로 전파되기 때문입니다. 커밋된 골든 데이터셋(examples/datasets/fedbatch_timeseries_10min.csv)에서 실제 행 하나를 가져옵니다. 2026-01-05 00:00:00+00, BR101.DO.PV, 40.8224, %sat, 192, BATCH-2026-001. 필드별로 읽으면 설계 의도가 드러납니다.
ts(timestamptz NOT NULL) — UTC 소스 타임스탬프이자 TimescaleDB가 분할하는 열입니다. 이것은 쓰기 시각이 아니라 프로브가 읽힌 동시대(contemporaneous)의 순간으로, ALCOA+ 의미의 동시성을 만드는 것입니다(ALCOA+는 기록이 Attributable·Legible·Contemporaneous·Original·Accurate, 그리고 Complete·Consistent·Enduring·Available이어야 한다는 FDA/EMA 데이터 무결성 기대입니다). 또한 청크 키이기도 합니다.2026-01-05 00:00:00+00이라는 값이 이 행이 어느 하루 단위 청크에 내려앉을지를 결정합니다.tag(text NOT NULL) — 길고 좁은 차원(dimension)입니다. 천 개의 센서가 하나의 테이블을 공유하게 하는 열로, 새 센서는 새 열이 아니라tag의 새 값입니다. 그 점으로 구분된 구조(BR101.DO.PV= 자산 · 측정 · 역할 — 여기서는 바이오리액터 BR101, 그 용존산소 프로브DO, 설정값SP가 아니라 현재 공정값PV를 보고)는 아래 카드에서 해독됩니다.value(double precision) — 정확히 하나의 부동소수점이며, 이 장이 거듭 돌아오는 이음매(seam)입니다. 행당 하나의 스칼라는 온도, pH, 역가 숫자의 알맞은 거처입니다. 701포인트 스펙트럼이나 크로마토그램에는 맞지 않는 거처이며(둘 다 다중 포인트 곡선, 즉 함께 취해야만 의미가 있는 수백 개의 숫자이지 단일 판독값이 아닙니다), 그것이 다음 절의 제목이 그렇게 붙은 이유입니다. (이것이NOT NULL로 표시되지 않은 유일한 열임에 유의하세요. 프로브가 온라인이면서도 잠시 값을 내지 못할 수 있으며, 품질이 Bad인 NULL 값이 조작된 0보다 더 정직한 기록입니다.)unit(text) — 비정규화된(denormalized) 단위 문자열(%sat는 산소 포화도 퍼센트,degC,g/L)로, 비정규화란 별도 테이블에서 조회하지 않고 일부러 모든 행에 중복 저장한다는 뜻입니다.40.8224하나는 무의미하지만40.8224 %sat는 사실입니다. 단위를 각 판독값에 비정규화하는 것은 작은 저장 비용으로 모든 쿼리에서 조인 하나를, 모든 대시보드에서 단위 혼동 오류 한 부류를 제거합니다.quality(smallint NOT NULL DEFAULT 192) — 레거시 OPC DA 신뢰 플래그(192 Good, 64 Uncertain, 0 Bad)로, 기본값 Good와 함께NOT NULL이므로 판독값은 신뢰성 판정 없이는 결코 저장되지 않습니다. 상용 PI에는 있고 대부분의 OSS가 잊는 열로, 아래 자체 절에서 전개합니다.batch_id(text) — 관계형 조인 키입니다. 17장이 조인하는 ISA-88 배치 기록(s88.batch)을 가리킴으로써 "어느 순간의 37.04 °C"를 "BATCH-2026-001 동안의 37.04 °C"로 바꾸는 것입니다. NULLbatch_id는 정당합니다. 배치 사이에 취해진 판독값도 여전히 히스토리안에 속합니다.
히스토리안 판독값 하나의 해부: 여섯 개의 열, 그중 단일 double precision value를 스칼라 전용 이음매로 강조했다. 행당 하나의 부동소수점은 온도나 역가에는 맞지만 스펙트럼에는 맞지 않는다.
저자가 AI의 도움을 받아 직접 제작한 그림입니다.
이 여섯 개 열짜리 행은 두 권 앞에서 태어난 데이터 포인트가 내려앉는 오픈소스의 거처입니다. 1권에서 물리적 단계는 생산 바이오리액터입니다. 그 프로브가 몇 초마다 온도, pH, 용존산소, 역가를 내쉬는 숨 쉬는 CHO(Chinese Hamster Ovary, 차이니즈 햄스터 난소) 세포 배양입니다. 2권에서 공정 데이터는 어디에서 태어나는가는 그 판독값을 출처를 지닌 값으로 틀 짓고, 자동화와 제어 데이터는 이 장의 SQL이 답하는 열린 과제, 즉 히스토리안의 데드밴드와 압축을 제기합니다. 위에서 보는 tag, value, unit, quality, ts, batch_id는 그 추상적 판독값이 하나의 행으로 구체화된 것입니다.
여기에 내려앉는 데이터
이 테이블의 숫자들은 손으로 입력한 것이 아닙니다. 그것들은 examples/sim/bioproc_sim/fed_batch.py의 결정론적 시뮬레이터에서 옵니다. 이 시뮬레이터는 14일 유가식(fed-batch) CHO 배양을 모델링합니다. 글루코스(glucose)와 글루타민(glutamine)으로 제한된 모노드(Monod) 동역학 성장, 영양분이 고갈되면서 나타나는 사멸기(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
마지막 줄은 배양의 종료 상태를 보고합니다. VCD는 생존 세포 밀도(viable cell density, 여기서는 밀리리터당 18.2 × 10⁶ 세포, 18.2e6은 과학적 표기법입니다), viab는 생존율(viability, 아직 살아 있는 세포의 비율), titer는 항체 농도입니다. 저 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 # legacy OPC DA 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 DA(Classic) 상태 심각도, 즉 Good, Uncertain, Bad로, 소스에서부터 저장소까지 줄곧 운반됩니다. (7장에서 짚었듯 이것들은 OPC DA의 패킹된 품질 바이트입니다. OPC UA 네이티브 품질은 그 대신 Good이 0인 32비트 StatusCode이므로, 우리는 시뮬레이터와 대부분의 히스토리안이 여전히 쓰는 잘 알려진 192/64/0 코드를 유지합니다.) 이탈 동안 온도와 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 DA 품질 플래그를 모든 행에 운반한다. 저자가 AI의 도움을 받아 직접 제작한 그림입니다.
왜 스칼라 전용인가: 스펙트럼은 태그가 아니다
이 스키마가 맞지 않는다고 정직하게 인정하는 한 가지 형태가 있으며, 다음 장이 이 테이블에 기대기 전에 이를 명명해 둘 가치가 있습니다. ts.sensor_reading의 모든 행은 정확히 하나의 double precision, 즉 타임스탬프당·태그당 하나의 부동소수점(float)을 담습니다. 그것은 스칼라(scalar) 신호의 알맞은 거처입니다. 온도 하나, pH 하나, 단일 역가 숫자 하나. 하지만 모든 측정이 스칼라인 것은 아닙니다. 라만(Raman)이나 NIR 스펙트럼은 벡터(vector)이며, 이 책 자체의 분석 장에서는 그것이 파수(wavenumber, 빛 에너지의 척도로, 각 포인트는 한 색깔의 빛에서의 신호 세기입니다)에 걸친 701개의 강도 포인트입니다. 그리고 크로마토그램(chromatogram)은 곡선(curve), 즉 용리 시간(elution time)에 걸친 검출기 응답의 자취 전체입니다. N-GLYcanyzer 테스트베드(인라인(in-line) 분석기 — 바이오리액터 루프에 직접 배관되어 사람이 샘플을 뽑지 않고도 배양액을 자동으로 측정하는 것으로, 그 분석 장에서 소개됩니다)가 이를 구체적으로 보여 줍니다. 그것의 HILIC-HPLC 실행 — 항체의 글리칸(glycan, 항체에 붙은 당 사슬)을 분리하는 액체 크로마토그래피로, 기기가 혼합물을 그 성분들로 분류하고 검출기가 각 성분이 시간에 걸쳐 얼마나 나오는지를 추적합니다 — 은 하나의 글리칸 숫자를 내보내지 않습니다. 단일 Protein A 역가(항체가 얼마나 있는지를 빠르게 재는 친화성 측정)와 더불어 글리칸 크로마토그램(그 검출기 자취 전체, 수백 개의 포인트)을 내보냅니다. 역가는 스칼라이며 설계된 그대로 이 하이퍼테이블에 한 행으로 내려앉습니다. 크로마토그램은 그렇지 않으며, 그것을 억지로 밀어넣는 것은 범주 오류(category error)일 것입니다.
그렇다면 그냥 파수를 tag에 몰래 넣어(Raman.wn_400, Raman.wn_402, …) 701개의 행을, 각각 부동소수점 하나씩 쓰면 안 될까요? 그렇게 하면 배열을 배열이게 하는 단 하나의 것, 즉 그 701개의 숫자가 물리적 축으로 인덱싱되어 함께 취해진 하나의 관측이라는 점을 조용히 내버리게 되고, 길고 좁은 테이블을 그 스키마가 바로 피하려 했던 태그 폭증(tag explosion)으로 부풀리기 때문입니다. 행당 한 스칼라 태그는 벡터 페이로드(payload)에 맞지 않는 그릇입니다. 알맞은 그릇은 당신이 울타리의 어느 쪽에 서 있느냐에 대한 의도적인 선택입니다. 저장 쪽에서는, 스펙트럼을 그 관계형 맥락 옆에 두고 싶을 때의 Postgres 배열이나 JSONB 열, 또는 분석 규모에서 훨씬 더 나은 열 지향 배열 저장소입니다. 이것이 바로 분석 장이 각 스펙트럼을 하나의 701열 Parquet 파일의 한 행으로 주차할 때 하는 일입니다(Parquet은 데이터를 열 단위로 저장하는 간결한 파일 포맷으로, 분석을 위해 훑기에 빠릅니다). 표준 쪽에서는, 배열이 설계상 일급 시민입니다. 두 가지 벤더 중립 분석 데이터 포맷, 즉 Allotrope ADF(과학용 HDF5 파일 포맷 위에 만들어진 바이너리 컨테이너)와 AnIML(XML 형식)이 그것을 운반합니다. ADF는 스펙트럼, 크로마토그램, 곡선을 위해 만들어진 n차원 데이터 큐브(Data Cube)로, AnIML은 배열의 SeriesSet으로 운반합니다. 분석 실험실 장이 Allotrope ASM와 더불어 짚어 가는 그 포맷들입니다(그 JSON과 XML 결과 형식은 단일 스칼라 결과를 담습니다. 빽빽한 배열은 바이너리 ADF 컨테이너에 속합니다). 교훈은 방금 품질 플래그 절이 짚은 것과 같되 다른 음조입니다. 데이터의 참된 형태에 맞는 그릇을 고르고, 정돈된 스칼라 테이블이 곡선을 잡음으로 평탄화하도록 유혹하게 두지 마십시오.
같은 행을 트리플로: 태그는 암묵적 어휘다
점으로 구분된 태그 BR101.DO.PV와 quality 바이트는 납작한 문자열이 시사하는 것보다 더 많은 일을 합니다. 그것들은 명시화되기를 기다리는 암묵적 온톨로지(implicit ontology), 즉 공장 전체가 이미 어느 정도 합의하고 있는 공유 어휘입니다. 그 분해(자산 · 측정 · 역할)는 형식 모델이 클래스와 관계로 적어 두는 바로 그 구조이며, 이 다리를 보아 둘 가치가 있습니다. 19장이 바로 이 테이블을 RDF 지식그래프로 들어올리기 때문입니다. 자원 기술 프레임워크(RDF — 모든 사실을 트리플, 즉 주어·술어·목적어로 표현하는 W3C 모델)는 저장된 판독값 하나를 행이 아니라 작은 트리플 묶음으로 그려 냅니다. 값은 QUDT 단위 온톨로지에 대해 타입이 부여되고 자산과 배치로 다시 묶입니다.
# Illustrative — the same reading as RDF triples (bridges to Chapter 19's graph).
bp:reading-0001 a sosa:Observation ;
sosa:observedProperty bp:DissolvedOxygen ; # the 'DO' measurement role
sosa:madeBySensor bp:BR101-DO-probe ; # the 'BR101' asset
sosa:hasResult [ qudt:numericValue 40.8224 ; qudt:unit unit:PERCENT ] ;
sosa:resultTime "2026-01-05T00:00:00Z"^^xsd:dateTime ;
bp:qualityCode 192 ; # 192 Good, the OPC DA flag
bp:fromBatch bp:BATCH-2026-001 .
관계형 테이블이 암묵적으로 지니는 세 가지가 여기서 일급 사실이 됩니다. unit 문자열은 4권이 사용하는 단위·차원 온톨로지 QUDT를 통해 타입이 부여된 양이 되므로, 40.8224 %sat는 어떤 대시보드든 잘못 읽을 수 있는 컬럼이 아니라 기계가 읽을 수 있는 데이터로서 자신의 차원을 운반합니다. batch_id 외래 키는 derivedFrom으로 걸어갈 수 있는 엣지, 즉 한 쿼리가 로트의 전체 혈통을 추적하게 하는 계보 관계가 됩니다. 그리고 점으로 구분된 태그의 세 부분은 발신 DCS만 이해하는 관례가 아니라 공유된 클래스 분류 체계(4권의 classes-and-taxonomy)에서 가져온 자산, 측정 속성, 역할이 됩니다.
특히 quality 플래그는 자연스러운 역량 질문(competency question), 즉 명세 단계가 모델이 답할 수 있어야 한다고 말하는 종류의 질문입니다. "생산 단계 동안 Good이 아닌 quality 판독값을 운반한 모든 출하 배치를 반환하라"는, 태그가 타입이 부여된 속성이 되면 한 줄짜리 SPARQL ASK/SELECT이지만, 납작한 테이블에 대해서는 깨지기 쉬운 문자열 파싱입니다. 그리고 히스토리안이 기대는 폐쇄세계(closed-world) 보장 — 판독값은 신뢰성 판정 없이는 결코 저장되지 않는다는 것(quality NOT NULL) — 이 바로 4권의 출하 관문에서 SHACL 형상(Shapes Constraint Language, RDF의 폐쇄세계 검증기)이 강제하는 것입니다. 품질 속성에 대한 sh:minCount 1이 NOT NULL 제약의 그래프 네이티브 형태입니다. 히스토리안은 같은 규율의 홈 도메인식 실용 버전이고, 온톨로지 책들은 값, 그 단위, 그 품질, 그 혈통이 LIMS에서도 MES에서도 그래프에서도 같은 것을 의미하도록 어휘를 명시화합니다.
데이터를 미리 굴리기: 연속 집계
대시보드는 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)에 신뢰할 수 있는 요약과 증거를 조용히 숨기는 요약의 차이입니다.
롤업의 해부: 하나의 연속 집계 버킷
원시 판독값이 히스토리안의 원자라면, 연속 집계 버킷은 그 분자입니다. 같은 방식으로 하나를 해부할 가치가 있습니다. ts.sensor_1h의 한 행도 여섯 개의 필드를 갖지만 의미가 다릅니다. 하나의 판독값 대신 버킷은 여럿을 요약합니다. 골든 데이터셋에서 BR101.DO.PV의 첫 1시간 창(2026-01-05 00:00부터 00:50까지의 10분 간격 여섯 판독값으로, 모두 00:00 1시간 버킷에 떨어집니다)을 집계하면 그 필드가 다음처럼 읽히는 하나의 요약 행이 나옵니다.
bucket(time_bucket) — 창의 바닥(floor)입니다.time_bucket은 시간을 위한GROUP BY입니다. 창 안의 모든 판독값이 하나의 슬롯으로 붕괴하며, 여기서는2026-01-05 00:00:00+00입니다.tag(text) —GROUP BY를 그대로 통과해 운반되는 차원으로, 변하지 않습니다. 버킷은(bucket, tag)쌍마다 하나씩 존재하므로BR101.DO.PV는BR101.Temp.PV와 독립적으로 굴려집니다.avg_value(avg(value)) —39.6358. 대시보드가 그리는 매끄러운 선이자, 그 자체로는 극값에 대해 거짓말을 하는 값입니다.min_value(min(value)) —38.0121. 창의 낮은 가장자리입니다.max_value(max(value)) —40.8224. 카드에서 강조한 까닭은 이것이 안전 열이기 때문입니다.avg가 보이지 않게 매끄럽게 지웠을 순간적인 고점이 여기서 살아남습니다.avg만 남긴 요약은 "조용히 증거를 숨기는" 종류이고,max(와min)를 유지하는 것이 이탈 조사에 롤업을 신뢰할 수 있게 만듭니다.last_value(last(value, ts)) —40.086. 창 안의 가장 최근 판독값으로, "현재 상태" 패널이 원하는 값이자 평범한 SQL로는 어색한 집계입니다. 그래서 TimescaleDB가 그것을 기본 제공합니다.
롤업 버킷 하나의 해부: 여러 원시 판독값이 들어가고 하나의 ts.sensor_1h 요약 행이 나온다. 평균이 매끄럽게 지웠을 스파이크를 보존하는 열로 max_value를 강조했다.
저자가 AI의 도움을 받아 직접 제작한 그림입니다.
집계는 마법으로 스스로 새로고침되지 않습니다. 정책이 그것을 스케줄링합니다.
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 26; 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(current Good Manufacturing Practice, 의약품 제조에 대한 FDA의 규제 프레임워크)는 배치 기록을 그 배치의 유효기간 만료 후 최소 1년 보관할 것을, 그리고 보관된 전자 기록이나 진본 사본(true copies)을 즉시 검색 가능하게 유지할 것을 요구합니다 [8]. EU의 Annex 11은 매체(medium)에 대해 더 나아갑니다. 저장·보관된 데이터는 전체 보존 기간에 걸쳐 접근성, 가독성, 무결성을 보장받고 주기적으로 점검되어야 합니다. 단지 바이트를 보관하는 것이 아니라 그것을 읽을 수 있게 유지해야 합니다 [9]. 그러므로 400일은 의도적으로 보수적인 단일 인스턴스 기본값입니다. 실제 관할권별 보존 매트릭스는 플랫폼이 26장에서 적재하는 데이터인데, 글로벌 제조사는 제조된 장소에 따라 같은 데이터를 서로 다른 기간 동안 보관하기 때문입니다.
라이선스 함정, 솔직하게 말하면
Apache-2.0 대 TSL 라이선스 이음매
여기 이 책이 약속한 정직함이 있으며, 그것은 편리한 이야기보다 더 날카롭습니다. TimescaleDB는 이중 라이선스(dual-licensed)이지만, Apache-2.0 선은 대부분의 글이 인정하는 것보다 덜 관대한 곳에 떨어집니다. 진정한 Apache-2.0 코어는 작습니다. 하이퍼테이블과 create_hypertable, time_bucket 함수, first/last 집계, 그리고 drop_chunks를 통한 수동 청크 관리입니다 [1]. 편리한 기능의 큰 집합은 소스 공개 타임스케일 라이선스(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 16.
한 문단으로 정리한 교환이 그것입니다. 동반 스택은 표준 timescale/timescaledb:2.17.2-pg17 이미지를 고정하는데, 이 이미지는 무료 TSL 커뮤니티 기능을 묶고 있으므로 이 파일의 연속 집계와 add_retention_policy는 쓰인 그대로 실행됩니다. 만약 대신 엄격하게 Apache-2.0이어야 한다면, 예컨대 어떤 소스 공개 구성 요소도 없이 스택을 재배포하려면, TSL 함수를 노출조차 하지 않는 Apache 전용 -oss(open-source-software) 빌드 — 별도로 패키징된 Apache-2.0 전용 배포본 — 로 전환하고, 연속 집계를 크론으로 새로고침되는 평범한 구체화 뷰로, add_retention_policy를 스케줄된 drop_chunks로 대체하세요. 그러면 히스토리안은 진정한 오픈소스 라이선스 아래에 놓입니다. 그 편의를 내준 대가로 말입니다. 반대로 조직이 TSL 조건을 받아들일 수 있다면, Hypercore를 켜는 것은 한 줄짜리 변경이자 큰 저장 용량 절감입니다. 이 각각은 라이선싱 결정이며, 우리는 그것을 몰래 들여오는 대신 드러내 보입니다.
엄격하게 Apache-2.0인 대안들은 정확히 소스 공개 조건을 전혀 받아들이지 않을 팀들을 위해 존재합니다. Apache IoTDB는 깔끔하게 Apache-2.0이며 장치 네이티브(device-native)입니다. 각 시계열을 (device, measurement, timestamp, value) 경로로 모델링하고 자체 컬럼형 TsFile 포맷을 기본 제공하는데, SQL 테이블이 아니라 장비 트리로 사고할 때 자연스럽게 맞습니다 [3]. InfluxDB 3 Core, 즉 오픈소스 계층(MIT/Apache-2.0, Apache Arrow·DataFusion·Parquet 위에 재구축됨)은 허용적입니다. 다만 Enterprise와 Cloud 계층은 그렇지 않으며, 그래서 부주의한 influxdb:latest 풀(pull)이 알려진 함정입니다 [4]. QuestDB는 Apache-2.0이며, 시간 정렬 쿼리를 간결하게 만드는 SAMPLE BY, LATEST ON, ASOF JOIN 같은 목적 특화 SQL 시계열 연산자를 갖췄습니다 [5]. 우리가 TimescaleDB를 기본값으로 제공하는 까닭은 배치 모델로의 조인 이야기가 하나의 Postgres 안에서 훨씬 더 깔끔하기 때문이며, 기본값이 무료 TSL 커뮤니티 자동화를 사용한다는 점, 그리고 어디서나 엄격한 Apache-2.0이 필요한 독자에게는 실재하고 명명된 경로가 있다는 점을 명시합니다.
스윙잉도어 압축: 힘과 위험
TSL이 게이트하는 기능이자 모든 상용 히스토리안이 기대는 기법은 자체 설명을 받을 자격이 있습니다. "공간 절약"이 조용히 "기록 변경"이 될 수 있는 지점이기 때문입니다. 고전적 알고리즘은 1987년 산업 제어 기업 Bristol이 특허를 낸 스윙잉도어 추세화(swinging-door trending)입니다 [6]. 직관은 이렇습니다. 천천히 변하는 신호의 모든 포인트를 저장하는 대신, 허용 대역(tolerance band) 안에서 더는 판독값을 가로지르는 선을 그을 수 없을 때만 포인트를 저장합니다. 그 허용 대역이 "편차(deviation)" 또는 데드밴드, 즉 새 포인트가 기록되기 전까지 판독값이 얼마나 벗어나도 되는지를 정하는 폭입니다. 한 시간 동안 37.0 °C로 유지되는 평평한 온도 자취는 3,600개 포인트에서 몇 개로 붕괴하고, 선형 보간(linear interpolation)으로 복원됩니다.
그 허용 매개변수는 양쪽을 베는 칼입니다. 그것은 저장 용량 절감과 복원 오차(reconstruction error) 사이의 교환을 지배하는 단 하나의 다이얼입니다 [7]. 느슨하게 맞추면 눈부신 압축을 얻습니다. 그리고 당신이 탐지할 의무가 있는 바로 그 이탈을 매끄럽게 지워 버릴 수 있습니다.
데드밴드가 이탈을 지워 버릴 때
여기서의 위험은 이론적인 것이 아니며, 증거와 이 책 자신의 데이터로 그것을 기반에 두는 것이 좋습니다. 고속 센서 스트림에 대한 스윙잉도어 연구는 그 실패를 정확히 정량화합니다. 지나치게 공격적인 허용 한계는 기록된 신호를 왜곡하는 실제 복원 오차를 도입하며, 그 오차는 데드밴드와 함께 커집니다 [10]. 이 알고리즘은 1987년 Bristol이 특허를 낸 이래로 정확히 이것을 해 왔습니다. 그것은 선을 그을 수 있는 포인트를 떨어뜨리도록 설계되었고, 충분히 넓은 대역은 실제 사건을 가로질러 곧장 선을 긋습니다 [6]. 이제 그것을 우리 자신의 기록에 적용해 봅니다. 시뮬레이터의 7일째 냉각 이탈은 BR101.Temp.PV를 37.0 °C 설정값에서 약 36.5 °C로 끌어내립니다. 정점 편차는 약 0.5 °C에 불과하고, 창 동안 저장된 행은 36.593, 36.4887, 36.468 degC 판독값을 운반하며 각각 quality = 64(Uncertain)로 플래그됩니다. "센서 노이즈를 제거"하려고 엔지니어가 고를 법한 무해해 보이는 ±0.5 °C로 설정된 스윙잉도어 데드밴드는 그 세 시간짜리 함몰을 가로질러 깨끗하게 직선을 긋고 아무것도 저장하지 않을 것입니다. 탐지할 의무가 있는 이탈도, 프로브가 고전했음을 증명하는 Uncertain 품질 플래그도, 둘 다 하나의 보간된 선분으로 사라집니다. GMP 기록에 그것은 단지 품질 우려가 아닙니다. ALCOA+에서 Accurate가 걸린 문제입니다.
이 장이 당신에게 남기고 싶은 교훈은 이렇습니다. 손실 압축(lossy compression)은 정당하고 어디에나 있지만, 데드밴드는 저장 용량을 아끼려는 뒤늦은 생각이 아니라 검증된 매개변수(validated parameter)이며, 가장 안전한 오픈소스 자세는 원시 기록을 저장하고 손실 원본에서 복원하는 대신 설명 가능한 롤업(우리의 연속 집계)으로 다운샘플링하는 것입니다. 롤업은 0.5 °C 함몰이 요약에서 살아남도록 정확히 min과 max를 유지합니다. 데드밴드는 부주의하게 설정되면 그것을 소스에서 파괴합니다.
모델의 학습 기질로서의 히스토리안
이 테이블 하류에서 학습하는 모든 것 — 라만 소프트 센서, 출하 예측기, 5권의 드리프트 탐지기 — 은 바로 이 열들에서 연료를 끌어옵니다. 그래서 히스토리안의 설계 선택 세 가지는 조용히 모델 품질 결정이기도 합니다. 첫째, batch_id는 행이 아니라 모델이 나누어야 할 단위입니다. 한 배치의 분들을 양쪽 절반에 흩뿌리는 순진한 무작위 학습/검증 분할은 이음매를 가로질러 정보를 누설하고 점수를 부풀립니다. 같은 유가식 배치의 행들은 자기상관(autocorrelated)되어 있어, 모델은 이웃을 외워 보류된 분을 "예측"할 수 있습니다. 정직한 분할은 batch_id로 그룹화한 것, 즉 한 캠페인 전체를 한 번에 보류하는 leave-one-batch-out 교차 검증이며, 이는 정확히 5권이 고집하는 누설 없는 배치 단위 분할입니다. 히스토리안은 그것을 사소하게 만듭니다. 그룹 키가 사후에 재구성할 것이 아니라 일급 열이기 때문입니다.
둘째, quality 플래그는 적용 범위(applicability domain) 신호입니다. 모델은 자신이 보정된 입력과 닮은 입력에서만 신뢰할 수 있습니다. 7일째의 quality = 64 창은, 모델의 관점에서, 학습 집합이 배제하거나 플래그하도록 학습해야 할 범위 밖(out-of-domain) 영역입니다. Uncertain 판독값을 Good인 것처럼 소프트 센서에 먹이는 것은 모델이 확신에 차서 틀리는 방식이며 — 적용 범위와 누설 논의가 경고하는 바로 그 실패 — 그것을 막는 열이 바로 대부분의 OSS 히스토리안이 유지하기를 잊는 열입니다.
셋째, 데드밴드는 MLOps 루프가 꿰뚫어 볼 수 없는 드리프트 함정입니다. 5권의 드리프트 탐지기는 공정 변화를 공변량 이동(covariate shift)(입력 P(X)가 움직임 — 오염되는 프로브, 새 원료 로트)과 개념 드리프트(concept drift)(입력-대-출력 매핑 P(Y|X)가 움직임)로 나누고, 전자를 일찍 잡기 위해 라벨이 필요 없는 입력 모니터(Population Stability Index)를 실행합니다(MLOps 장). 그러나 이미 이탈을 매끄럽게 지워 버린 스윙잉도어 데드밴드는 그 모니터가 읽는 바로 그 분포 증거를 파괴했습니다. 드리프트가 보이지 않는 까닭은 그것을 증명하는 신호가 애초에 저장소에 도달하지 못했기 때문입니다. 그러므로 충실한 원시 기록을 저장하는 것은 ALCOA+ 의무일 뿐 아니라, 하류의 모든 정직한 드리프트 탐지 또는 모델 혈통(model lineage) 감사(어느 모델 버전이 어느 보존된 청크를 보았는지)의 전제 조건입니다. 거버넌스되고 충실하며 배치를 키로 삼는 데이터는 신뢰할 수 있는 모델이 딛고 선 바닥이며, 이 테이블이 그 바닥이 부어지는 곳입니다.
왜 중요한가
히스토리안은 플랫폼 전체가 딛고 선 바닥입니다. 그 형태를 틀리면 그 위의 모든 장이 그 실수를 물려받습니다. 이 짧은 DDL 파일 안의 세 가지 결정이 가장 무거운 무게를 집니다. 길고 좁은 스키마는 센서 추가가 결코 마이그레이션을 치르지 않게 합니다. 히스토리안을 PostgreSQL 안에 두는 것은 컨텍스트화(contextualization, 17장)를 통합 프로젝트가 아니라 조인으로 만듭니다. 그리고 OPC DA 품질 플래그를 일급 열로 운반하는 것은 판독값의 신뢰성이 영구 기록 안으로 그것과 함께 이동하게 합니다. 이것이 ALCOA+ 데이터 무결성과 그 뒤를 따르는 모든 감사 추적(audit-trail) 검토의 기술적 전제 조건입니다. 우리가 의도적으로 빼 둔 조각들, 즉 TSL 압축, 스윙잉도어 손실 축소도 그만큼 중요합니다. 그것들을 쓰지 않기로 택하는 것이야말로 스택을 깨끗하게 열린 상태로, 기록된 신호를 충실하게 유지하기 때문입니다.
실제 현장에서는
지난 30년 동안 "플랜트 신호는 어디에 사는가?"에 대한 답은 OSIsoft PI, 이제는 AVEVA PI였습니다. 네이티브 압축, 포인트별 품질 모델, 자산 프레임워크(asset framework)를 갖춘 성숙하고 검증된, 벤더 지원을 받는 히스토리안으로, 거의 모든 대형 바이오제조사에 배포되어 있습니다. 오픈소스 히스토리안은 진정으로 멀리까지 데려다줍니다. TimescaleDB나 IoTDB는 라이선스 비용 없이 당신의 태그를 적재하고, 굴리고, 보존하고, 제공하며, 그것도 잘해냅니다. 정직한 간극은 구체적이며 움츠리지 않고 말할 가치가 있습니다. PI의 특허 압축은 공짜로 얻지 못합니다(TSL 게이트이거나 부재합니다). 내장 품질 플래그는 얻지 못합니다(우리가 했듯 열을 직접 구축합니다). 그리고 GAMP-5(Good Automated Manufacturing Practice, 가이드 5 — 전산화 시스템 검증 표준) 평가가 기대는 벤더의 검증된 시스템 패키지, 지원 계약, 공급자 책임도 얻지 못합니다. 그 부담은 당신의 것이 되며, 20장과 25장이 그것을 진지하게 다룹니다. PI가 기록 시스템(system of record, 어떤 데이터의 권위 있고 공식적인 출처)으로 남고 OSS 스택이 그 옆의 분석 계층이 되는 매우 흔한 경우를 위한 실제 양방향 PI 브리지(bridge)도 포함해서입니다. 그 하이브리드는 오픈소스의 실패가 아닙니다. 규제받는 플랜트의 현실적 모습입니다.
핵심 용어
- 히스토리안(historian) — 고속의 타임스탬프 찍힌 공정 신호를 저장하고 제공하는 데 특화된 데이터베이스. AVEVA/OSIsoft PI의 오픈소스 대응물.
- 역가(titer) — 배양액 속 항체 산물의 농도(그램/리터, g/L). 대표적 수율 숫자로, 1권의 생산 바이오리액터 장에서 다룬다.
- ALCOA+ — 기록이 Attributable·Legible·Contemporaneous·Original·Accurate, 그리고 Complete·Consistent·Enduring·Available이어야 한다는 FDA/EMA 데이터 무결성 기대.
- 하이퍼테이블(hypertable) — 하나의 테이블처럼 거동하지만 시간 범위 청크로 자동 분할되는 TimescaleDB 테이블. 쿼리가 관련된 시간 창만 훑게 한다.
- 청크(chunk) — 하이퍼테이블의 한 시간 범위 파티션(여기서는 하루). 보존이 떨어뜨리고 플래너가 가지치기하는 단위.
- 연속 집계(continuous aggregate) — 데이터가 도착할 때 증분적으로 새로고침되는 하이퍼테이블 위의 구체화된 뷰.
avg/min/max/last요약을 미리 굴리는 데 쓴다. - 보존 정책(retention policy) — 설정된 간격보다 오래된 청크를 떨어뜨리는 스케줄된 규칙. 행 삭제가 아니라 선언적으로 표현된다.
- 품질 플래그(quality flag) — 값이 얼마나 신뢰할 만한지를 기록하는 판독값별 코드(레거시 OPC DA: 192 Good, 64 Uncertain, 0 Bad). 여기서는 일급이지만 OSS 히스토리안에서는 기본적으로 부재한다.
- 길고 좁은 스키마(long/narrow schema) — 센서당 한 열이 아니라 판독값당 한 행(
ts, tag, value, …). 새 태그를 마이그레이션이 아니라 데이터가 되게 한다. - TSL(타임스케일 라이선스, Timescale License) — TimescaleDB의
tsl/기능(Hypercore 컬럼스토어, 네이티브 압축)을 지배하는 소스 공개 라이선스. OSI 오픈소스가 아니며 여기서는 의도적으로 미사용. - 스윙잉도어 추세화(swinging-door trending) — 고전적 손실 히스토리안 압축 알고리즘. 그 편차/데드밴드 허용 한계는 저장 용량과 복원 오차를 교환하며 검증된 매개변수로 다뤄져야 한다.
time_bucket— 타임스탬프를 고정된 창(1분, 1시간)으로 내림하는 Apache-2.0 함수.GROUP BY의 시계열 대응물이자 모든 연속 집계 행의 그룹화 키.- 롤업 버킷(rollup bucket) — 여러 원시 판독값을 대표하는 연속 집계의 요약 행 하나(
bucket, tag, avg/min/max/last).avg옆에min/max를 유지하는 것이 짧은 이탈을 요약에서 살아남게 한다. - RDF 트리플(triple) — 지식그래프의 주어·술어·목적어 원자. 히스토리안 판독값 하나가 그 태그·단위·품질·배치를 명시적 사실로 만들었을 때 취하는 형태(19장, 4권).
- 역량 질문(competency question) — 형식 온톨로지가 답할 수 있어야 하는 질문(예: "생산 단계에서 Good이 아닌 판독값을 가진 모든 배치"). 태그가 타입이 부여된 속성이 되면 한 줄짜리 SPARQL 쿼리이지만, 납작한 테이블에 대해서는 깨지기 쉬운 문자열 파싱.
- SHACL
minCount— 속성이 반드시 존재해야 한다는 폐쇄세계 제약. 이 히스토리안이 강제하는quality NOT NULL규율의 그래프 네이티브 대응물. - leave-one-batch-out 교차 검증 —
batch_id를 키로 삼아 한 캠페인 전체를 한 번에 보류하는 그룹 분할. 한 배치의 자기상관된 행들이 학습/검증 이음매를 가로질러 결코 누설되지 않게 한다. - 적용 범위(applicability domain) — 모델이 보정되었고 그 안에서 신뢰할 수 있는 입력 영역.
quality = 64이탈 창은 품질 플래그가 모델로 하여금 배제하거나 플래그하게 하는 범위 밖 영역이다. - 공변량 이동 대 개념 드리프트(covariate vs concept drift) — 입력 분포 이동(
P(X)) 대 입력-대-출력 매핑의 변화(P(Y|X)). 부주의한 데드밴드는 전자를 잡기 위해 선행 드리프트 모니터가 필요로 하는 원시 증거를 지운다.
다음 이야기
이제 히스토리안은 충실하고, 보존 가능하며, 품질이 태깅된 판독값의 강물을 담고 있습니다. 하지만 그 판독값 하나하나는 여전히 어느 배치의 어느 단계에 속하는지에 대해 말이 없습니다. 17장 — 컨텍스트화: 시계열을 배치에 조인하기(Contextualization: Joining Time-Series to the Batch)에서 우리는 이 ts.sensor_reading 하이퍼테이블을 4장의 ISA-88/95 배치 모델과 단 하나의 시간적 조인(temporal-join) 뷰로 결혼시킵니다. 그러면 판독값은 "어느 순간의 37.04 °C"이기를 멈추고 "BR101의 BATCH-2026-001 생산 단계 동안의 37.04 °C"가 됩니다. 원시 데이터가 공정 지식으로 바뀌는 순간입니다.