본문으로 건너뛰기

배치·장비 데이터 모델: PostgreSQL로 구현하는 ISA-88/95

📍 현재 위치: Part I, 청사진. 스택은 이미 돌아가고 있습니다(2장). 이제 그 스택에 척추를 세워줄 차례입니다. 이후 등장하는 모든 숫자가 매달리는, 관계형(relational) 배치·장비 모델 말입니다.

2장에서 여러분은 make up을 실행했고, CHO 바이오리액터(bioreactor) 시뮬레이터가 히스토리안(historian)으로 숫자를 밀어 넣기 시작하는 모습을 지켜봤습니다. 그 숫자들은 실재하는 값이지만, 지금은 고아 신세입니다. BR101.Temp.PV라는 태그(tag)와 타임스탬프(timestamp)가 붙은 37.05라는 값. 그런데 그것은 어느 배치(batch)였을까요? 어느 장비였을까요? 바로 그 순간 레시피(recipe)의 어느 단계가 돌아가고 있었을까요? 이 질문들에 답할 수 없다면, 여러분이 가진 것은 기록이 아니라 텔레메트리(telemetry)에 불과합니다.

이 장은 그 답을 만들어 나갑니다. 우리는 자동화 업계가 이미 합의해 둔 방식 그대로 공정을 모델링합니다. 레시피에는 ISA-88을, 장비에는 ISA-95를 씁니다. 그리고 이를 평범한 PostgreSQL로, 한자리에서 읽어 내려갈 수 있는 약 100줄의 SQL로 구현합니다.

쉽게 말하면

극장을 떠올려 보세요. ISA-95는 건물입니다. 회사는 여러 극장(사이트, site)을 소유하고, 극장마다 방(에어리어, area)이 있으며, 방마다 무대(바이오리액터 BR101 같은 유닛, unit)가 있습니다. ISA-88은 대본입니다. 연극(레시피)에는 막(오퍼레이션, operation)과 장면(페이즈, phase)이 있고, 정해진 순서대로 공연됩니다. 배치는 어느 하룻밤의 공연입니다. 특정 무대 위에서 특정 출연진이 특정 대본을 따라 공연하는 것이죠. 우리 데이터베이스는 건물과 대본, 그리고 어느 밤 어느 무대에서 정확히 무슨 일이 벌어졌는지에 대한 기록을 저장합니다. 이 세 가지를 제대로 잡으면, 모든 센서 측정값이 갑자기 자기가 어디에 속하는지를 알게 됩니다.

이 장에서 다루는 내용

  • 왜 표준이 둘인가 — ISA-88과 ISA-95 — 그리고 둘이 어떻게 세상을 나눠 갖는가.
  • 장비 계층(equipment hierarchy)(enterprise → site → area → unit)과 절차 모델(procedural model)(recipe → operation → phase)을 위한 PostgreSQL 스키마. 동반 저장소(companion repo)에서 그대로 가져왔습니다.
  • 배치 그 자체, 그 계보(genealogy)(시드 → 바이오리액터 → 캡처 풀 → 원료의약품(drug substance)), 그리고 레시피 파라미터에 대한 정규화(normalized) 대 JSONB의 트레이드오프.
  • 이 책 나머지가 재사용하는, 진짜 유가식(fed-batch) CHO + 프로테인 A(Protein A) 라인 하나를 시드(seed)하기.
  • 센서 측정값이 마침내 자기 배치를 만나는 과정 — 그리고 그것을 증명하는 테스트.
  • 이 모델이 정직한 오픈 소스인 지점, 그리고 GMP 기록이 어려운 선택을 강요하는 지점.

두 표준, 하나의 척추

배치 제조에는 맞물려 돌아가는 두 개의 ANSI/ISA 표준이 있습니다. 둘 다 IEC 표준으로도 발간되었는데, 이 둘을 머릿속에서 서로 다른 상자에 넣어 두면 이해가 훨씬 수월해집니다.

ISA-88(ANSI/ISA-88.00.01, IEC 61512-1이기도 함)은 배치가 어떻게 만들어지는가, 즉 절차적 측면을 설명합니다. 깔끔한 중첩 구조를 제공하죠. 레시피는 **프로시저(procedure)**로 분해되고, 프로시저는 **유닛 프로시저(unit procedure)**로, 그다음 오퍼레이션으로, 그다음 가장 작은 의미 있는 단계인 페이즈로 분해됩니다 [1]. "피드 A를 50 mL/min으로 30분간 투입하라"가 하나의 페이즈입니다. ISA-88은 이 절차적 로직을 물리적 장비로부터 의도적으로 분리합니다. 그래서 같은 레시피를 다른 리액터에서 돌릴 수 있습니다.

ISA-95(ANSI/ISA-95.00.01, IEC 62264-1이기도 함)는 어디서 만들어지는가, 즉 현장(plant floor)을 비즈니스와 통합하는 물리적·조직적 계층을 설명합니다. enterprise → site → area → work center / unit 순이죠 [2]. IEC 62264-1:2013에 대한 ISO/IEC 카탈로그 항목이 이 객체 모델들의 권위 있는 출처이며, 우리가 테이블로 정규화하는 대상이 바로 그것입니다 [3].

두 표준은 유닛에서 만납니다. ISA-88은 페이즈가 어느 유닛 위에서 돌아간다고 말하고, ISA-95는 그 유닛이 무엇인지를 말합니다. 학계에서는 이 두 표준 사이의 용어가 겹치고 때로는 충돌한다는 점, 그리고 둘을 깔끔하게 연결하려면 화해되고 형식화된 엔티티 모델이 필요하다는 점을 오래전부터 지적해 왔습니다. 우리가 지금 막 내리려는 모델링 결정이 바로 그것입니다 [4].

우리에게 B2MML 전체 객체 그래프가 필요한 것은 아닙니다. 그것은 방대하고, 대부분은 저장이 아니라 기업 간 메시징을 위한 것이니까요. B2MML/BatchML은 MESA International이 유지보수하는, ISA-95와 ISA-88의 로열티 프리(royalty-free) XML 스키마 구현이며, 다른 회사 시스템과 레시피나 배치 기록을 교환해야 할 때 참조하기에 알맞은 기준입니다 [5]. 우리 내부 저장소를 위해서는 그것의 엔티티 — Equipment, Recipe, Process Segment — 만 빌려 오되, 깊게 재귀적인 절차 트리는 훨씬 단순한 부모 FK + seq_no 패턴으로 평탄화합니다. 스키마를 읽기 쉽게 유지해 주는 것이 바로 이 단 하나의 결정입니다.

장비 계층을 SQL로

모든 것이 하나의 PostgreSQL 데이터베이스 안에 삽니다. 2장의 이미지가 timescale/timescaledb(TimescaleDB 확장이 달린 PostgreSQL)이기 때문에, 히스토리안 하이퍼테이블(hypertable)과 이 관계형 모델은 하나의 데이터베이스, 하나의 트랜잭션 경계를 공유합니다. 데이터베이스 간 조인도 없고, 두 번째 연결도 없습니다.

스키마는 관심사별로 하나씩, examples/platform/db/00-init.sql에서 가장 먼저 생성됩니다:

-- examples/platform/db/00-init.sql
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- digest() for the ALCOA+ hash chain

-- One schema per concern, mirroring the book's chapters.
CREATE SCHEMA IF NOT EXISTS s88; -- ISA-88/95 batch + equipment model (Ch 3)
CREATE SCHEMA IF NOT EXISTS ts; -- time-series historian (hypertable) (Ch 13)
CREATE SCHEMA IF NOT EXISTS lab; -- samples, tests, results (Ch 8/11)
CREATE SCHEMA IF NOT EXISTS events; -- operation events / equipment states (Ch 7/10/12)
CREATE SCHEMA IF NOT EXISTS audit; -- system-versioned history + hash chain(Ch 20/21)
CREATE SCHEMA IF NOT EXISTS gov; -- tag dictionary, jurisdictions, suppliers (Ch 4/22/23)

s88라는 스키마 이름은, 배치 업계가 이 표준들을 흔히 "S88", "S95"라고 부르는 습관에 대한 작은 경의입니다. 이 장은 s88을 소유하고, 이후 장들은 각자 자기 것을 채웁니다. 물리적 계층 자체는 네 개의 테이블로, 각각이 자기 부모를 가리키며, examples/platform/db/10-isa88-95.sql에 들어 있습니다:

-- examples/platform/db/10-isa88-95.sql (ISA-95 equipment hierarchy)
CREATE TABLE s88.enterprise (
enterprise_id text PRIMARY KEY,
name text NOT NULL
);

CREATE TABLE s88.site (
site_id text PRIMARY KEY,
enterprise_id text NOT NULL REFERENCES s88.enterprise,
name text NOT NULL,
country text NOT NULL DEFAULT 'US'
);

CREATE TABLE s88.area (
area_id text PRIMARY KEY,
site_id text NOT NULL REFERENCES s88.site,
name text NOT NULL
);

CREATE TABLE s88.unit ( -- the equipment a phase runs on
unit_id text PRIMARY KEY, -- e.g. BR101
area_id text NOT NULL REFERENCES s88.area,
name text NOT NULL,
unit_type text NOT NULL, -- bioreactor | chromatography | tff | fill_line ...
vendor text,
model text
);

이것이 얼마나 평범한지 주목하세요. 영리한 상속도, 엔티티-속성-값(entity-attribute-value) 테이블도, XML 컬럼도 없습니다. 각 계층은 안정적인 텍스트 기본 키(불투명한 정수가 아니라 BR101)를 가집니다. 이 식별자들이 바로 운영자, SCADA, 배치 기록이 이미 쓰고 있는 그 식별자이기 때문입니다. 이것들은 비즈니스 키(business key)이고, 이를 기본 키로 쓴다는 것은 사람이 조회 없이도 한 행을 읽을 수 있다는 뜻입니다. unit_type 컬럼은 ISA-88으로 향하는 경첩입니다. 페이즈는 자신이 필요로 하는 유닛의 타입(bioreactor)을 선언하고, 배치는 그것을 특정 유닛(BR101)에 바인딩합니다.

절차 모델: 레시피, 오퍼레이션, 페이즈

이제 대본입니다. ISA-88의 완전한 중첩은 recipe → procedure → unit procedure → operation → phase이지만, 단일 제품 mAb 라인에서는 그 깊이의 대부분이 그저 격식일 뿐입니다. 우리는 이를 recipe → operation → phase로 압축하고, 각 자식이 seq_no를 지니게 하여 순서가 테이블 위치가 아니라 데이터가 되도록 합니다. 역시 examples/platform/db/10-isa88-95.sql에 있습니다:

-- examples/platform/db/10-isa88-95.sql (ISA-88 recipe / procedure)
CREATE TABLE s88.recipe (
recipe_id text PRIMARY KEY,
product_id text NOT NULL,
name text NOT NULL,
version int NOT NULL DEFAULT 1
);

CREATE TABLE s88.operation ( -- an ordered step of the recipe
operation_id text PRIMARY KEY,
recipe_id text NOT NULL REFERENCES s88.recipe,
seq_no int NOT NULL,
name text NOT NULL, -- Inoculation | Fed-batch | Harvest | ProteinA ...
unit_type text NOT NULL
);

CREATE TABLE s88.phase ( -- the smallest procedural element
phase_id text PRIMARY KEY,
operation_id text NOT NULL REFERENCES s88.operation,
seq_no int NOT NULL,
name text NOT NULL
);

이 부모 FK 더하기 seq_no 형태가 바로 이 장이 약속한 단순화의 전부입니다. 레시피의 완전한 절차 그래프가 두 개의 평범한 일대다(one-to-many) 조인이 됩니다. 단계 순서를 바꾸는 것은 스키마 마이그레이션이 아니라 seq_no에 대한 UPDATE입니다. 그리고 operation.unit_typeunit.unit_type과 일치하기 때문에, 모델은 이미 ProteinA 오퍼레이션이 바이오리액터가 아니라 chromatography 유닛에 속한다는 사실을 알고 있습니다. 나중에 강제하거나 검증할 수 있는 제약이죠.

정규화 대 JSONB — 그리고 각각이 이기는 지점

레시피는 파라미터를 지닙니다. 설정값(setpoint), 지속 시간, 허용 오차 같은 것들이죠. 여기서 이 책은 의도적이고 다소 주관이 들어간 선택을 합니다. 여러 배치에 걸쳐 조회되거나, 추세 분석(trend)되거나, 비교되는 소수의 파라미터 — 온도 설정값, pH 설정값, 용존산소(dissolved-oxygen) 설정값 — 는 자기만의 타입이 지정된 정규화 테이블을 갖습니다. 그리고 그 테이블은 유효일자(effective-dated) 방식이라, 24장에서 이력을 파괴하지 않고도 레시피를 제자리에서 버전 관리할 수 있습니다:

-- examples/platform/db/10-isa88-95.sql (effective-dated recipe parameters)
CREATE TABLE s88.recipe_parameter (
recipe_id text NOT NULL REFERENCES s88.recipe,
name text NOT NULL,
value numeric NOT NULL,
unit text NOT NULL,
valid_from timestamptz NOT NULL DEFAULT now(),
valid_to timestamptz NOT NULL DEFAULT 'infinity',
PRIMARY KEY (recipe_id, name, valid_from)
);

valid_from/valid_to 쌍은 고전적인 이중 시간(bitemporal) 기법입니다. "BATCH-2026-004가 시작된 날 기준으로 온도 설정값은 무엇이었나?"는 WHERE 'date' BETWEEN valid_from AND valid_to 질의이고, 옛 값은 절대 덮어쓰이지 않습니다. 이것이 중요한 이유는, GMP 현장에서 레시피 변경이 통제되고 감사(audit)되는 사건이기 때문입니다. 옛 설정값이 무엇이었는지 조용히 잊어버리는 것은 허용되지 않습니다.

그렇다면 JSONB는 어디서 등장할까요? 느슨하게 구조화되고 거의 조회되지 않는 속성들의 긴 꼬리(long tail) — 벤더별 페이즈 옵션, 자유 형식 메모, 볼러스 피드(bolus-feed) 시각의 중첩 테이블 — 에는, 50개의 희소(sparse) 컬럼을 만들거나 엔티티-속성-값의 늪에 빠지는 대신 jsonb 컬럼이 정직한 답입니다. PostgreSQL의 jsonb 타입은 파싱된 이진 JSON을 저장하고 (jsonb_opsjsonb_path_ops를 통한) GIN 인덱싱을 지원하므로, 필요할 때면 그 문서들조차 조회 가능한 상태로 남습니다 [6]. 이 책이 따르는 경험칙은 이렇습니다. 그것으로 필터링·조인·추세 분석을 할 것이라면 정규화하라. 통째로 다시 읽기만 할 것이라면 JSONB로 두라. 중요한 설정값을 "마이그레이션을 아끼려고" JSONB에 넣는 것은, 배치 기록을 검토 불가능하게 만드는 바로 그런 부류의 지름길입니다.

왼쪽에 ISA-95 물리적 장비 계층(enterprise, site, area, unit), 오른쪽에 ISA-88 절차 모델(recipe, operation, phase)을 보여주고, 가운데에서 레시피를 유닛에 바인딩하는 배치 행이 둘을 연결하며, 시드 바이오리액터에서 프로테인 A 풀을 거쳐 원료의약품으로 흐르는 로트 계보 간선을 함께 보여주는 계층 다이어그램.

두 ISA 표준은 배치에서 만난다: ISA-95는 어디(뉴어크 업스트림의 BR101)를, ISA-88은 어떻게(유가식 CHO mAb 레시피)를 말하며, 배치 행이 둘을 하나의 제조 실행으로 묶고, 계보 간선이 시드 트레인부터 원료의약품까지 물질을 추적한다.

Original diagram by the authors, created with AI assistance.

배치 — 그리고 그 가계도

배치는 하나의 제조 실행입니다. 특정 레시피를, 특정 유닛에서, 로트 번호와 상태와 함께 돌린 것이죠. 두 개의 테이블이 더 추가되어 각 페이즈가 실제로 언제 돌았는지물질 계보를 담아냅니다. 모두 examples/platform/db/10-isa88-95.sql에 있습니다:

-- examples/platform/db/10-isa88-95.sql (the batch and its genealogy)
CREATE TABLE s88.batch (
batch_id text PRIMARY KEY,
product_id text NOT NULL,
recipe_id text NOT NULL REFERENCES s88.recipe,
unit_id text NOT NULL REFERENCES s88.unit,
lot text,
status text NOT NULL DEFAULT 'in_progress', -- in_progress | complete | released | rejected
start_ts timestamptz NOT NULL,
end_ts timestamptz
);

CREATE TABLE s88.batch_phase ( -- when each phase actually ran for a batch
batch_id text NOT NULL REFERENCES s88.batch,
phase_id text NOT NULL REFERENCES s88.phase,
unit_id text NOT NULL REFERENCES s88.unit,
start_ts timestamptz NOT NULL,
end_ts timestamptz,
PRIMARY KEY (batch_id, phase_id)
);

-- lot genealogy: directed edges child -> parent (seed -> bioreactor -> pool -> DS -> DP)
CREATE TABLE s88.genealogy (
batch_id text REFERENCES s88.batch,
child text NOT NULL,
child_type text NOT NULL,
parent text NOT NULL,
parent_type text NOT NULL,
PRIMARY KEY (child, parent)
);

phasebatch_phase의 분리는 계획과 *실적(actuals)*의 차이입니다. phase는 레시피가 Growth 페이즈를 가지고 있다고 말하고, batch_phase는 BATCH-2026-001의 경우 Growth가 1월 5일 정오부터 1월 12일 자정까지 돌았다고 기록합니다. 바로 그 실적 테이블이, 이후 원시 타임스탬프를 "이 측정값은 Growth 동안 일어났다"로 바꿔 주는 것입니다.

genealogy 테이블은 작아 보이지만 규제 무게는 막대한 물건입니다. 방향이 있는 자식 → 부모 간선을 저장하므로, 한 완제의약품(drug product) 로트를 원료의약품, 프로테인 A 캡처 풀, 생산 바이오리액터, 시드 트레인까지 거슬러 추적할 수 있습니다. 이는 선택적인 장부 기록이 아닙니다. 미국 cGMP — 현행 우수 제조 관리 기준(current Good Manufacturing Practice) — 는 모든 배치마다 마스터 기록을 재현하는 배치 생산·관리 기록이 존재할 것을 요구하며 [7], 그 기록의 구조가 바로, 말 그대로, 우리 batchbatch_phase 테이블이 인코딩하는 골격입니다 [8]. 그리고 21 CFR 211.184는 완성된 각 배치를, 그 안에 들어간 물질의 로트까지 추적하기에 충분한 성분(component) 및 정산(reconciliation) 기록을 요구합니다. 이것이 바로 genealogy 간선이 여러분에게 주는 바로 그것입니다 [9]. 이 간선들에 대한 셀프 조인(self-join)이나 재귀 CTE는 필요할 때 전체 계보를 재구성해 냅니다.

진짜 라인 하나를 시드하기

데이터 없는 스키마는 텅 빈 극장입니다. examples/platform/db/seed/seed_cho_line.sql의 시드는, 이 책 전체가 재사용하는 바로 그 유가식 CHO + 프로테인 A 라인을 위한 장비 계층, 레시피, 배치, 페이즈 윈도(phase window)를 세웁니다. 이 공정은 정규(canonical) A-Mab 사례 연구를 본떴는데, A-Mab은 프로테인 A 캡처 단계로 만든 CHO 유래 단일클론항체(monoclonal antibody, mAb)에 대한 업계 공통의 참조 기준입니다 [10]. (genealogy 간선은 이 시드에 없습니다. 이들은 나중에 make load가 히스토리안·랩 데이터와 함께 lot_genealogy.csv에서 로드합니다 — 14장 참조.) 장비, 레시피, 배치는 이렇게 로드됩니다:

-- examples/platform/db/seed/seed_cho_line.sql (equipment + recipe)
INSERT INTO s88.unit VALUES
('BR101', 'UPSTREAM', 'Production Bioreactor 101', 'bioreactor', 'Sartorius', 'Biostat STR 50'),
('N1SEED', 'UPSTREAM', 'N-1 Seed Bioreactor', 'bioreactor', 'Sartorius', 'Biostat STR 10'),
('PA01', 'DOWNSTREAM', 'Protein A Capture Skid', 'chromatography', 'Cytiva', 'AKTA process'),
('TFF01', 'DOWNSTREAM', 'UF/DF Skid', 'tff', 'Cytiva', 'AKTA flux'),
('FILL-LINE-01', 'FILL', 'Aseptic Fill Line', 'fill_line', 'Bausch+Stroebel', 'KSF')
ON CONFLICT DO NOTHING;

INSERT INTO s88.operation VALUES
('OP1', 'CHO-MAB-001', 1, 'Inoculation', 'bioreactor'),
('OP2', 'CHO-MAB-001', 2, 'Fed-batch', 'bioreactor'),
('OP3', 'CHO-MAB-001', 3, 'Harvest', 'bioreactor'),
('OP4', 'CHO-MAB-001', 4, 'ProteinA', 'chromatography') ON CONFLICT DO NOTHING;

INSERT INTO s88.phase VALUES
('PH1', 'OP1', 1, 'Inoculate'),
('PH2', 'OP2', 1, 'Growth'),
('PH3', 'OP2', 2, 'Production'),
('PH4', 'OP3', 1, 'Harvest'),
('PH5', 'OP4', 1, 'Capture') ON CONFLICT DO NOTHING;

모든 INSERTON CONFLICT DO NOTHING으로 끝나므로 make seed는 **멱등(idempotent)**합니다. 두 번 실행해도 라인이 중복되지 않으며, 이는 장과 장 사이에 다시 시드할 때 중요합니다. 그다음 시드는 여섯 개의 캠페인 배치를 로드하는데, 그중 하나는 의도적으로 경고의 사례로 만들어졌습니다:

-- examples/platform/db/seed/seed_cho_line.sql (the six campaign batches; -004 is OOS)
INSERT INTO s88.batch (batch_id, product_id, recipe_id, unit_id, lot, status, start_ts, end_ts) VALUES
('BATCH-2026-001', 'MAB-001', 'CHO-MAB-001', 'BR101', 'L26001', 'released', '2026-01-05T00:00:00Z', '2026-01-19T00:00:00Z'),
('BATCH-2026-004', 'MAB-001', 'CHO-MAB-001', 'BR101', 'L26004', 'rejected', '2026-01-05T00:00:00Z', '2026-01-19T00:00:00Z'),
('BATCH-2026-006', 'MAB-001', 'CHO-MAB-001', 'BR101', 'L26006', 'complete', '2026-01-05T00:00:00Z', '2026-01-19T00:00:00Z')
ON CONFLICT DO NOTHING;

BATCH-2026-001은 이 책이 모든 것의 추세 기준으로 삼는 *골든 배치(golden batch)*입니다. BATCH-2026-004는 의도적인 규격 이탈(out-of-specification, OOS) 일탈과 rejected 상태를 지녀, 이후 장들이 탐지하고 조사하고 설명할 진짜 실패 사례를 갖게 합니다. 나머지 배치들은 통계적 공정 관리(statistical-process-control) 차트가 씹어 먹을 거리를 제공합니다.

마지막으로 시드는 골든 배치의 페이즈 윈도 — 타임스탬프가 자기 페이즈를 찾게 해 주는 실적 — 를 기록합니다:

-- examples/platform/db/seed/seed_cho_line.sql (phase windows for the golden batch)
INSERT INTO s88.batch_phase (batch_id, phase_id, unit_id, start_ts, end_ts) VALUES
('BATCH-2026-001', 'PH1', 'BR101', '2026-01-05T00:00:00Z', '2026-01-05T12:00:00Z'),
('BATCH-2026-001', 'PH2', 'BR101', '2026-01-05T12:00:00Z', '2026-01-12T00:00:00Z'),
('BATCH-2026-001', 'PH3', 'BR101', '2026-01-12T00:00:00Z', '2026-01-18T00:00:00Z'),
('BATCH-2026-001', 'PH4', 'BR101', '2026-01-18T00:00:00Z', '2026-01-19T00:00:00Z')
ON CONFLICT DO NOTHING;

센서 측정값이 자기 배치를 만날 때

여기 보상이 있습니다. 모델과 시드가 자리를 잡으면, 2장의 고아 측정값이 *맥락화(contextualized)*될 수 있습니다. 그 일을 해내는 뷰(view)는 examples/platform/db/60-views.sql에 있습니다(14장에서 온전히 구축되지만, 이 모델이 존재하는 이유 그 자체이기에 여기서 보여 줍니다):

-- examples/platform/db/60-views.sql (a reading with its full batch + phase context)
CREATE OR REPLACE VIEW s88.v_batch_sensor AS
SELECT r.ts, r.tag, r.value, r.unit, r.quality, r.batch_id,
b.product_id, b.recipe_id, b.unit_id,
bp.phase_id, ph.name AS phase_name
FROM ts.sensor_reading r
JOIN s88.batch b ON b.batch_id = r.batch_id
LEFT JOIN s88.batch_phase bp ON bp.batch_id = r.batch_id
AND r.ts >= bp.start_ts AND (bp.end_ts IS NULL OR r.ts < bp.end_ts)
LEFT JOIN s88.phase ph ON ph.phase_id = bp.phase_id;

LEFT JOIN ... AND r.ts >= bp.start_ts AND r.ts < bp.end_ts는 각 순간을 그때 활성화되어 있던 페이즈로 매핑하는 시간 윈도 조인(time-window join)입니다. 그 고아였던 BR101.Temp.PV = 37.05는 이제, 자신이 제품 MAB-001, 레시피 CHO-MAB-001, 유닛 BR101에서, Growth 페이즈 동안 일어났음을 아는 행으로 읽힙니다. 헐벗은 태그가 지식이 된 것입니다.

우리는 단지 이것이 작동한다고 주장만 하지 않습니다. 동반 저장소가 그것을 증명합니다. examples/tests/test_db.py에서, 살아 있는 스택을 상대로 한 pytest 실행이 계층이 시드되었는지, 그리고 골든 배치의 모든 측정값이 이름 붙은 페이즈로 해소(resolve)되는지를 검사합니다:

# examples/tests/test_db.py
def test_schema_and_hypertable(conn):
assert _scalar(conn, "select count(*) from timescaledb_information.hypertables "
"where hypertable_name='sensor_reading'") == 1
assert _scalar(conn, "select count(*) from s88.batch") >= 6

def test_contextualization_joins_phase(conn):
# every reading in the golden batch should resolve to a named phase
rows = _scalar(conn, "select count(distinct phase_name) from s88.v_batch_sensor "
"where batch_id='BATCH-2026-001' and phase_name is not null")
assert rows >= 4 # Inoculate, Growth, Production, Harvest

make up && make seed && make load를 실행한 다음 make test를 돌리면, 이것들은 노트북에서도 통과하고 깨끗한 CI 러너에서도 다시 통과합니다. 이 모델이 다이어그램이 아니라 실재한다는, 이 책의 변치 않는 약속이죠. 같은 make test는 20장이 이 스키마 위에 얹는 감사 체인(audit chain)도 함께 검증하지만, 그것은 나중 이야기입니다.

왜 중요한가

배치 기록은, 어느 의약품 로트를 출하해도 되는지를 규제 당국이 판단하기 위해 검토하는 법적 산물입니다. 이 책의 다른 모든 것 — 히스토리안, 대시보드, 지식 그래프, 분석 — 은 어떤 의미에서, 여러분이 이 장에서 세운 척추에 매달린 장식입니다. 척추가 틀리면, 하류의 모든 숫자가 그 오류를 물려받습니다.

ISA-88과 ISA-95 위에서 모델링하는 것은 구체적인 두 가지를 사 줍니다. 첫째, **의미의 이식성(portability of meaning)**입니다. 이 표준들을 아는 엔지니어라면 안내 없이도 여러분의 operationunit 테이블을 읽을 수 있고, 미래의 MES나 상용 히스토리안도 거기에 매핑할 수 있습니다. 둘째, **구성에 의한 추적성(traceability by construction)**입니다. 계보와 페이즈 실적은 감사 지적 사항이 나온 뒤에 덧붙이는 것이 아니라, 일급(first-class) 테이블로서 스키마의 첫 마이그레이션(10-isa88-95.sql, 어떤 데이터가 들어오기 전)부터 존재합니다. 바로 cGMP가 기대하는 자세입니다.

실제 현장에서는

실제 공장에서 이 관계형 모델이 홀로 사는 경우는 드뭅니다. 배치의 절차적 실행은 보통 상용 제조 실행 시스템(Manufacturing Execution System, MES)이나 전자 배치 기록(electronic batch record) — Werum PAS-X, Körber, Tulip, 또는 DeltaV/Syncade 구성 — 이 소유하며, 이 시스템들은 검증되고(validated), 벤더 지원을 받으며, 단연코 오픈 소스가 아닙니다. 이 책이 취하는 정직한 입장은, PostgreSQL이 훌륭한 맥락·분석 시스템(system of context and analysis) — 시계열을 배치에, 배치를 페이즈에 조인하고 캠페인 전반에 걸쳐 질문을 던지는 곳 — 이지만, 박스에서 꺼내자마자 Part 11 준수(Part-11-compliant) 전자 배치 기록인 것은 아니라는 점입니다. 어떤 오픈 소스 데이터베이스도 그렇지 않습니다. 준수(compliance)는 *검증된 시스템 더하기 절차(procedures)*의 속성이지(GAMP 5는 오픈 소스 소프트웨어 부록을 포함해 이를 명시적으로 짚습니다), CREATE TABLE을 친다고 손에 넣을 수 있는 속성이 아닙니다. 우리는 데이터 무결성(data-integrity) 골조 — 시스템 버전 이력(system-versioned history), 감사 로그, 변조 증거(tamper-evident) 해시 체인 — 를 20장과 21장에서 구축하며, 거기서 슈퍼유저(superuser)가 여전히 우회할 수 있는 것이 무엇인지에 대해 솔직하게 다룹니다.

표준 자체는 열망이 아니라 진정한 업계 기준선(baseline)입니다. ISA-88과 ISA-95는 제약 업계의 사실상 모든 MES 및 배치 히스토리안 통합을 떠받치고, B2MML은 두 회사의 시스템이 레시피나 배치 기록을 교환해야 할 때의 공통어(lingua franca)입니다. NIIMBL — 미국의 민관 협력 바이오의약품 제조 혁신 연구소(Institute for Innovation in Manufacturing Biopharmaceuticals) — 와, 2024년 4월에 착공한 NIIMBL/델라웨어 대학교 시설 SABRE 같은 파일럿 규모 cGMP 시설은 바로 이런 종류의 표준 기반 장비·배치 모델링 위에서 돌아갑니다. 상호운용 가능한 데이터 플랫폼은 그것 없이는 가망이 없습니다. 우리 라인의 강화/연속(intensified / continuous) 변종 — 관류(perfusion) 업스트림에 다중 컬럼 연속 캡처(multi-column continuous capture) — 의 경우, 장비 계층은 거의 바뀌지 않습니다(관류 유닛 하나와 크로마토그래피 컬럼 몇 개를 추가할 뿐이죠). 그러나 절차 모델은 무리가 갑니다. 공정이 결코 멈추지 않으면 "페이즈"의 경계를 긋기가 더 어려워지니까요. 그 긴장은 이 책 뒷부분에 반복해서 등장하는 주제입니다. 부모 FK + seq_no 모델은 그 긴장을, 빳빳하게 중첩된 모델보다 한결 우아하게 흡수합니다. 우리가 평탄한 형태를 택한 또 하나의 이유입니다.

핵심 용어

  • ISA-88(S88, IEC 61512) — 배치 절차 표준: recipe → procedure → unit procedure → operation → phase. 배치를 어떻게 만드는지를, 그것이 돌아가는 장비로부터 분리한다.
  • ISA-95(S95, IEC 62264)물리적/조직적 표준: enterprise → site → area → work center / unit. 현장을 비즈니스와 통합한다.
  • B2MML / BatchML — MESA International의 로열티 프리 XML 스키마 구현으로, ISA-95/ISA-88을 따르며, 시스템 간에 장비·레시피·배치 기록을 교환하는 데 쓴다.
  • 유닛(Unit) — 페이즈가 돌아가는 장비(예: BR101); ISA-88과 ISA-95가 만나는 조인 지점.
  • 페이즈(Phase) — 가장 작은 절차 단계(예: Growth, Capture).
  • 배치(Batch) — 하나의 제조 실행: 유닛 위의 레시피, 로트 번호·상태·시작/종료 시각과 함께.
  • 계보/로트 추적성(Genealogy / lot traceability) — 한 완제의약품 로트를 원료의약품·캡처 풀·바이오리액터·시드 트레인까지 거슬러 잇는, 방향 있는 자식 → 부모 간선.
  • 유효일자(이중 시간) 파라미터(Effective-dated, bitemporal parameter)valid_from/valid_to를 지닌 값으로, 이력을 덮어쓰지 않고도 레시피를 버전 관리할 수 있게 한다.
  • JSONB — GIN 인덱싱을 갖춘 PostgreSQL의 이진 JSON 타입으로, 느슨하게 구조화된 속성의 긴 꼬리에 쓰되, 중요하고 조회 가능한 설정값에는 절대 쓰지 않는다.
  • 맥락화(Contextualization) — 원시 센서 측정값을 그것의 배치·장비·활성 페이즈에 조인하는 것; 뷰 s88.v_batch_sensor가 이를 한다.
  • 골든 배치(Golden batch)BATCH-2026-001, 이 책이 모든 것의 추세 기준으로 삼는 참조 실행; BATCH-2026-004는 의도적인 OOS(규격 이탈) 반례.
  • cGMP — 현행 우수 제조 관리 기준(current Good Manufacturing Practice), 모든 배치마다 배치 기록이 존재하고 그에 따라 추적 가능해야 한다는 규제 기대치.

다음 이야기

모델은 이제 BR101과 레시피를 압니다. 하지만 2장의 측정값은 여전히 헐벗은 문자열 BR101.Temp.PV로 태깅되어 도착했고, 그 문자열이 히스토리안·대시보드·MQTT 토픽에서 똑같이 표기된다는 보장은 아직 아무것도 없습니다. 다음 장 **이름 짓기: 태그, 계층, 그리고 통합 네임스페이스(Naming Things: Tags, Hierarchies, and the Unified Namespace)**에서 우리는 통제된 태그 사전(tag dictionary)과 ISA-95에 정렬된 통합 네임스페이스(Unified Namespace)를 구축합니다. 즉흥적인 태그 문자열을, 통치되고(governed) 기계 검증 가능한(machine-checkable) 주소 공간으로 바꾸는 것이죠. 그리고 누군가 규칙에 맞지 않는 이름을 지어내면 빌드를 실패시키는 린터(linter)도 함께 작성합니다.