배치·장비 데이터 모델: PostgreSQL로 구현하는 ISA-88/95
📍 현재 위치: Part I · 청사진, 4장. 스택은 이미 돌아가고 있습니다(2장). 이제 그 스택에 척추를 세워줄 차례입니다. 이후 등장하는 모든 숫자가 매달리는, 관계형(relational) 배치·장비 모델(모든 기계와 레시피 단계를 기술하는 테이블들) 말입니다.
2장에서 여러분은 make up을 실행했고, CHO(차이니즈 햄스터 난소 세포, Chinese Hamster Ovary cell) 바이오리액터(bioreactor) 시뮬레이터가 히스토리안(historian, 모든 센서 측정값을 저장하는 시계열 데이터베이스 — 사람이 아닙니다)으로 숫자를 밀어 넣기 시작하는 모습을 지켜봤습니다. 그 숫자들은 실재하는 값이지만, 지금은 고아 신세입니다. BR101.Temp.PV라는 태그(tag, BR101은 장비, Temp는 측정 대상, PV는 프로세스 값(process value) — 설정값이 아니라 실제 측정값 — 을 뜻합니다)와 타임스탬프(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)은 벌크 상태의 정제된 항체, 완제의약품(drug product)은 충전·완료된 바이알입니다), 그리고 레시피 파라미터에 대한 정규화(normalized) 대 JSONB의 트레이드오프.
- 진짜 유가식(fed-batch) CHO + 프로테인 A(Protein A) 라인 하나를 시드(seed)하기 — 차이니즈 햄스터 난소 세포로 단일클론항체 하나를 만드는 생산 공정 하나(물리적 단계들은 1권의 주제이고, 여기서는 이 책 나머지가 재사용하는 워크드 예제입니다).
- 센서 측정값이 마침내 자기 배치를 만나는 과정 — 그리고 그것을 증명하는 테스트.
- 이 모델이 정직한 오픈 소스인 지점, 그리고 GMP 기록이 어려운 선택을 강요하는 지점.
두 표준, 하나의 척추
배치 제조에는 맞물려 돌아가는 두 개의 ANSI/ISA 표준 — 국제자동화학회(International Society of Automation, ISA)가 합의해 발간하고 ANSI가 미국 국가 표준으로 비준한 문서들 — 이 있습니다. 둘 다 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 → unit 순이죠 [2]. (ISA-95는 에어리어와 유닛 사이에 work center / process cell 계층도 정의하지만, 여기서는 이를 압축합니다. 즉 그 중간 계층을 그냥 빼는 것인데, 단일 제품 라인에는 프로세스 셀이 하나뿐이라 그 계층이 추가 정보를 담지 못하므로 안전합니다. 아래에서 ISA-88의 더 깊은 절차 중첩을 압축하는 것과 마찬가지죠.) IEC 62264-1:2013에 대한 ISO/IEC 카탈로그 항목이 이 객체 모델들의 권위 있는 출처이며, 우리가 테이블로 정규화하는 대상이 바로 그것입니다 [3].
두 표준은 유닛에서 만납니다. ISA-88은 페이즈가 어느 유닛 위에서 돌아간다고 말하고, ISA-95는 그 유닛이 무엇인지를 말합니다. 학계에서는 이 두 표준 사이의 용어가 겹치고 때로는 충돌한다는 점, 그리고 둘을 깔끔하게 연결하려면 정합되고 형식화된 엔티티 모델이 필요하다는 점을 오래전부터 지적해 왔습니다. 우리가 지금 막 내리려는 모델링 결정이 바로 그것입니다 [4].
우리에게 B2MML 전체 객체 그래프 — 다음에 소개할 XML 데이터 형식인 B2MML이 정의하는, 서로 연결된 객체들(장비, 레시피, 세그먼트와 그 관계들)의 완전한 그물 — 가 필요한 것은 아닙니다. 그것은 방대하고, 대부분은 저장이 아니라 기업 간 메시징을 위한 것이니까요. B2MML/BatchML은 MESA International이 유지보수하는, ISA-95와 ISA-88의 로열티 프리(royalty-free) XML 스키마 구현이며, 다른 회사 시스템과 레시피나 배치 기록을 교환해야 할 때 참조하기에 알맞은 기준입니다 [5]. 우리 내부 저장소를 위해서는 그것의 엔티티 — Equipment, Recipe, Process Segment — 만 빌려 오되, 깊게 재귀적인 절차 트리(트리란 각 항목이 그 아래에 또 다른 항목들을 임의의 깊이까지 중첩할 수 있는 구조입니다)는 훨씬 단순한 부모 FK + seq_no 패턴으로 평탄화합니다. 즉 모든 행이 자기 부모 행의 이름을 담은 외래 키(FK) — 그 값이 다른 테이블의 기존 행을 반드시 가리켜야 하는 컬럼 — 와, 형제들 사이에서 순서를 고정하는 seq_no(시퀀스 번호)를 함께 저장합니다. 스키마를 읽기 쉽게 유지해 주는 것이 바로 이 단 하나의 결정입니다.
장비 계층을 SQL로
모든 것이 하나의 PostgreSQL 데이터베이스 안에 삽니다. 2장의 이미지가 timescale/timescaledb(TimescaleDB 확장이 달린 PostgreSQL)이기 때문에, 히스토리안 하이퍼테이블(하이퍼테이블은 TimescaleDB가 시간 청크로 자동 분할하면서도 하나의 평범한 테이블처럼 질의하게 해 주는 대형 시계열 테이블을 가리키는 이름입니다)과 이 관계형 모델(외래 키로 서로 연결된 장비·레시피 테이블들)은 하나의 데이터베이스, 하나의 트랜잭션 경계를 공유합니다. 즉 한 번의 쓰기가 둘 모두를 원자적으로(atomically), 전부-아니면-전무로 갱신할 수 있다는 뜻이죠. 센서 쓰기는 성공했는데 배치 갱신이 실패하면 둘 다 보존되지 않으므로, 히스토리안과 배치 모델이 절반만 쓰인 채 끝나 서로 어긋날 일이 결코 없습니다 — 이것이 바로 둘이 손으로 보조를 맞춰야 하는 별개의 두 데이터베이스에 사는 순간 떠안게 되는 위험입니다 — 그래서 데이터베이스 간 조인도 없고, 동기화를 유지해야 할 두 번째 연결도 없습니다.
스키마는 관심사별로 하나씩, examples/platform/db/00-init.sql에서 가장 먼저 생성됩니다(여기서 PostgreSQL의 스키마는 네임스페이스 — s88.unit처럼 관련 테이블을 묶어 주는 이름 붙은 폴더 — 이지, 테이블 설계를 뜻하는 것이 아닙니다):
-- 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 4)
CREATE SCHEMA IF NOT EXISTS ts; -- time-series historian (hypertable) (Ch 16)
CREATE SCHEMA IF NOT EXISTS lab; -- samples, tests, results (Ch 10/14)
CREATE SCHEMA IF NOT EXISTS events; -- operation events / equipment states (Ch 9/13/15)
CREATE SCHEMA IF NOT EXISTS audit; -- system-versioned history + hash chain(Ch 23/24)
CREATE SCHEMA IF NOT EXISTS gov; -- tag dictionary, jurisdictions, suppliers (Ch 5/25/26)
(이 주석들에 이름이 오른 pgcrypto 확장과 audit 스키마의 해시 체인은 ALCOA+ — 규제 당국이 GMP 기록에 기대하는 데이터 무결성 속성으로, 아래 핵심 용어에서 온전히 정의합니다 — 를 위한 것이며, 그 감사 로그는 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 컬럼도 없습니다. REFERENCES s88.enterprise(그리고 REFERENCES s88.site, REFERENCES s88.area) 절은 외래 키입니다. 각 자식 행은 기존의 부모 행을 가리켜야 하므로, 속할 enterprise 없이는 site가 존재할 수 없습니다. 바로 그것이 네 테이블이 계층으로 맞물리는 방식이죠. 각 계층은 안정적인 텍스트 기본 키 — 그 행을 가리키는 고유 식별자로, 여기서는 불투명하고 의미 없는 정수가 아니라 BR101 같은 읽을 수 있는 코드 — 를 가집니다. 이 식별자들이 바로 운영자, SCADA(현장을 운영하는 감시 제어 및 데이터 수집 시스템, Supervisory Control And Data Acquisition), 배치 기록이 이미 쓰고 있는 그 식별자이기 때문입니다. 이것들은 비즈니스 키(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_type이 unit.unit_type과 일치하기 때문에, 모델은 이미 ProteinA 오퍼레이션이 바이오리액터가 아니라 chromatography 유닛에 속한다는 사실을 알고 있습니다. 나중에 강제하거나 검증할 수 있는 제약이죠.
정규화 대 JSONB — 그리고 각각이 이기는 지점
레시피는 파라미터를 지닙니다. 설정값(setpoint), 지속 시간, 허용 오차 같은 것들이죠. 여기서 이 책은 의도적이고 다소 주관이 들어간 선택을 합니다. 여러 배치에 걸쳐 조회되거나, 추세 분석(trend)되거나, 비교되는 소수의 파라미터 — 온도 설정값, pH 설정값, 용존산소(dissolved-oxygen) 설정값 — 는 자기만의 타입이 지정된 정규화(normalized) 테이블을 갖습니다. 여기서 "정규화"란 각 사실이 더 큰 덩어리(blob) 안에 묻히는 대신 자기만의 타입 지정 컬럼으로서 정확히 한 곳에만 산다는 뜻입니다. 그리고 그 테이블은 유효일자(effective-dated) 방식이라, 27장에서 이력을 파괴하지 않고도 레시피를 제자리에서 버전 관리할 수 있습니다(옛 값을 덮어쓰는 대신 새 일자 행을 추가함으로써, 이전의 모든 값을 보존하면서 레시피를 바꾸는 것입니다):
-- 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 쌍은 고전적인 유효일자(유효 시간, valid-time) 기법입니다. "BATCH-2026-004가 시작된 날 기준으로 온도 설정값은 무엇이었나?"는 WHERE 'date' BETWEEN valid_from AND valid_to 질의이고, 옛 값은 절대 덮어쓰이지 않습니다. (이것은 데이터베이스가 기록할 수 있는 두 시간 축 가운데 하나 — 유효 시간, 즉 어떤 값이 현실 세계에서 참이었던 기간 — 를 추적합니다. 두 번째 축인 트랜잭션 시간 — 변경이 실제로 시스템에 입력된 시점 — 은 23장의 ALCOA+ 감사 로그가 별도로 공급합니다. 첫 번째 축만 지니면 이 쌍은 유효일자 방식이 되고, 둘을 한꺼번에 지니면 온전한 이중 시간(bitemporal) 방식이 됩니다.) 이것이 중요한 이유는, GMP 현장에서 레시피 변경이 통제되고 감사(audit)되는 사건이기 때문입니다. 옛 설정값이 무엇이었는지 조용히 잊어버리는 것은 허용되지 않습니다.
그렇다면 JSONB는 어디서 등장할까요? 느슨하게 구조화되고 거의 조회되지 않는 속성들의 긴 꼬리(long tail) — 벤더별 페이즈 옵션, 자유 형식 메모, 볼러스 피드(bolus-feed) 시각의 중첩 테이블 — 에는, 50개의 희소(sparse) 컬럼(대부분의 행에서 대부분의 컬럼이 비어 있는 테이블)을 강요하거나 엔티티-속성-값의 늪(EAV 안티패턴, (name, value) 쌍으로 "행마다 사실 하나"를 저장하다가 모든 타입 검사를 잃는 방식)에 빠지는 대신 jsonb 컬럼이 정직한 답입니다. PostgreSQL의 jsonb 타입은 파싱된 이진 JSON을 저장하고 GIN 인덱싱을 지원하므로(인덱스란 데이터베이스가 모든 행을 훑지 않고도 일치하는 행을 찾을 수 있도록 곁에 두는 보조 구조이고, GIN은 JSON 문서 내부를 검색하는 데 알맞은 인덱스 종류입니다), 필요할 때면 그 문서들조차 조회 가능한 상태로 남습니다 [6]. 주어진 속성이 둘 중 어느 집에 속하는지는 또렷한 답이 있는 질문이며, 아래 별도의 절에서 경험칙으로 승격합니다.
두 ISA 표준은 배치에서 만난다: ISA-95는 어디(뉴어크 업스트림의 BR101)를, ISA-88은 어떻게(유가식 CHO mAb 레시피)를 말하며, 배치 행이 둘을 하나의 제조 실행으로 묶고, 계보 간선이 시드 트레인부터 원료의약품과 완제의약품까지 물질을 추적한다.
저자가 AI의 도움을 받아 직접 제작한 그림입니다.
배치 — 그리고 그 가계도
배치는 하나의 제조 실행입니다. 특정 레시피를, 특정 유닛에서, 로트 번호와 상태와 함께 돌린 것이죠. 두 개의 테이블이 더 추가되어 각 페이즈가 실제로 언제 돌았는지와 물질 계보를 담아냅니다. 모두 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)
);
phase와 batch_phase의 분리는 계획과 실적(actuals)의 차이입니다. phase는 레시피가 Growth 페이즈를 가지고 있다고 말하고, batch_phase는 BATCH-2026-001의 경우 Growth가 1월 5일 정오부터 1월 12일 자정까지 돌았다고 기록합니다. 바로 그 실적 테이블이, 이후 원시 타임스탬프를 "이 측정값은 Growth 동안 일어났다"로 바꿔 주는 것입니다.
배치 기록의 해부: s88.batch 한 행
batch 테이블은 이 장 전체의 축소판이라, CREATE TABLE 블록으로 읽기보다 한 행을 필드 하나씩 뜯어 읽을 때 더 많은 것을 줍니다. 아래는 골든 배치의 실제 시드 행 — seed_cho_line.sql의 BATCH-2026-001 — 을 컬럼별로 해부한 것입니다. 통상적 의미의 데이터가 얼마나 적은지 주목하세요. 대부분의 컬럼은 읽을 수 있는 비즈니스 키이거나, 다른 곳에 정의된 사물에 이 실행을 묶어 주는 외래 키(foreign key)입니다. 배치 행은 레시피나 장비를 서술하지 않습니다. 그것들을 가리킬 뿐이며, 바로 그 가리킴이 기록을 동시에 간결하고 감사 가능하게 만듭니다.
s88.batch 한 행: 읽을 수 있는 비즈니스 키 BATCH-2026-001, 프로시저를 버전에 고정하는 레시피 외래 키, 장비로 향하는 unit_id 경첩, 그리고 그 로트의 출하 여부를 결정하는 상태(status).
저자가 AI의 도움을 받아 직접 제작한 그림입니다.
이 s88.batch 한 행이 바로 삼부작의 고리를 닫는 코드입니다. 이 행이 기록하는 제조 실행은, 1권이 처음부터 끝까지 따라 걷는 물리적 여정 — 시드 트레인, 생산 바이오리액터, 프로테인 A 캡처, 충전 — 그 자체이며, 바이오프로세싱 개관에 담겨 있습니다. 이것을 복제하는 대신 가리키는, 감사 가능한 한 행으로 모델링하기로 한 결정은 2권이 데이터 그림자로 제기하는 열린 문제이고, 아래의 계보 간선은 2권이 디지털 스레드라 부르는 바로 그 계보입니다. 그 장들이 데이터 포인트를 던지고, 이 장이 그것을 실재하게 만드는 SQL입니다.
컬럼을 순서대로 읽어 봅니다. batch_id는 PRIMARY KEY이고, unit_id가 BR101인 것과 같은 이유로 사람이 읽을 수 있는 비즈니스 키(BATCH-2026-001)입니다. 조인 없이도 한 행을 읽을 수 있게 하기 위함이죠. product_id(MAB-001)는 이 실행이 어느 제품을 만드는지를 기록합니다. recipe_id(CHO-MAB-001)는 s88.recipe로 들어가는 외래 키이고, 그 레시피 행이 version 컬럼을 지니므로 배치는 암묵적으로 레시피 버전에 고정됩니다. 즉 오늘 레시피가 어떤 모습이든 상관없이, 실행이 시작될 때 효력을 가졌던 프로시저에 묶입니다. lot(L26001)은 로트 번호 — 로트란 함께 출하·출고되는 특정 생산 수량의 제품을 말합니다 — 이고, 분석 성적서(certificate of analysis, 그 로트의 시험 결과를 나열한 품질 문서)에 찍히는 바로 그 번호입니다. status는 작은 통제된 어휘 — in_progress | complete | released | rejected — 를 따르며, 검토자가 그 로트의 출하 여부를 판단하려 읽는 바로 그 컬럼입니다. 골든 배치는 released, 형제 배치 BATCH-2026-004는 rejected죠. start_ts와 end_ts는 timestamptz 양 끝점입니다. 배치가 아직 in_progress인 동안 end_ts는 NULL이며, 이는 지금 진행 중인 실행을 질의가 찾아내는 방법이기도 합니다.
ISA-88 / ISA-95 경첩: unit_type
배치 행에서 가장 하중을 많이 받는 단일 컬럼은 unit_id, 카드에서 초록으로 강조된 필드입니다. 두 표준이 하나의 외래 키 안에서 물리적으로 만나는 지점이기 때문이죠. unit_id = BR101은 s88.unit을 거쳐 그 장비가 무엇인지로 해소됩니다 — ISA-95의 답(뉴어크 업스트림 에어리어의 바이오리액터). 그것이 돌리는 레시피는 recipe_id를 통해 도달하며 어떻게를 제공합니다 — ISA-88의 답. 따라서 같은 조인 키가 제조 사실의 양쪽 절반을 모두 운반하는데, 이것이 바로 표준 문헌이 연결 모델이 명시해야 한다고 주장하는 그 정합입니다 [4].
이 경첩은 정체성뿐 아니라 타입으로 강제됩니다. s88.operation이 unit_type을 지니고 s88.unit도 그렇다는 점을 떠올리세요. ProteinA 오퍼레이션은 unit_type = 'chromatography'를 선언하고, BR101은 unit_type = 'bioreactor', PA01은 chromatography입니다. 그래서 모델은 — 어떤 배치와도 무관하게 — 프로테인 A 캡처 단계가 바이오리액터가 아니라 PA01에 속한다는 것을 이미 압니다. 배치는 추상적 요구("이 페이즈는 크로마토그래피 유닛이 필요하다")를 구체적 자산("PA01")에 바인딩하고, 서로 일치하는 unit_type 문자열이 그 바인딩을 정직하게 유지해 주는 값싸고 선언적인 제약입니다. 이것이 ISA-88이 레시피(장비 독립적)와 실제 장비 위에서의 실행 사이에 긋는 차이를, 우연히 같은 값으로 비교되는 두 텍스트 컬럼으로 표현한 것입니다.
genealogy 테이블은 작아 보이지만 규제 무게는 막대한 물건입니다. 방향이 있는 자식 → 부모 간선을 저장하므로, 한 완제의약품(drug product) 로트를 원료의약품, 프로테인 A 캡처 풀, 생산 바이오리액터, 시드 트레인까지 거슬러 추적할 수 있습니다. 이는 선택적인 장부 기록이 아닙니다. 미국 cGMP — 현행 우수 제조 관리 기준(current Good Manufacturing Practice) — 는 모든 배치마다 마스터 기록을 재현하는 배치 생산·관리 기록이 존재할 것을 요구하며 [7], 그 기록의 구조가 바로, 말 그대로, 우리 batch와 batch_phase 테이블이 인코딩하는 골격입니다 [8]. 그리고 21 CFR 211.184 — 미국 연방 규정집(Code of Federal Regulations) 제21편의 한 조항으로, FDA의 구속력 있는 규칙입니다 — 는 완성된 각 배치를, 그 안에 들어간 물질의 로트까지 추적하기에 충분한 성분(component) 및 정산(reconciliation) 기록을 요구합니다. 이것이 바로 genealogy 간선이 여러분에게 주는 바로 그것입니다 [9]. 이 간선들에 대한 셀프 조인(self-join)이나 재귀 CTE는 필요할 때 전체 계보를 재구성해 냅니다. 재귀 CTE(공통 테이블 표현식, Common Table Expression)란 간선을 거듭 따라가는 질의로, 각 물질에서 그 부모로, 다시 그 부모의 부모로, 꼭대기에 닿을 때까지 한 칸씩 건너뛰는 것입니다.
계보: 자식에서 부모로 가는 간선
계보 행도 배치 행만큼이나 꼼꼼히 해부할 가치가 있습니다. 그 형태가 특이하기 때문이죠. 이것은 엔티티 테이블이 아니라 간선 테이블(edge table)입니다. s88.genealogy의 각 행은 방향이 있는 간선 하나 — (batch_id, child, child_type, parent, parent_type) — 이고, 기본 키는 (child, parent) 쌍이므로 어떤 물질이든 어느 한 직계 부모에 대해 기록된 간선을 많아야 하나만 가질 수 있습니다 — 그보다 먼 조상은 또 다른 행이 아니라 이 직계 간선들을 한 칸씩 따라가며 도달하는데, 이것이 바로 아래 리콜 추적(recall walk)이 하는 일입니다. make load가 적재하는 lot_genealogy.csv 데이터셋은 골든 배치에 대해 다섯 개의 간선을 기록하며, 이를 자식 → 부모로 읽으면 바이알에서 셀 뱅크까지 실행을 거꾸로 추적하게 됩니다. (유의: make seed는 s88.genealogy 테이블을 만들지만 비워 둡니다. 간선은 17장의 make load와 함께 들어오므로, 시드 직후 select * from s88.genealogy는 아무것도 반환하지 않습니다.)
# examples/datasets/lot_genealogy.csv (the five edges for BATCH-2026-001)
batch_id child child_type parent parent_type
BATCH-2026-001 SEED-001 seed_train WCB-CHO-001 wcb
BATCH-2026-001 BATCH-2026-001 bioreactor SEED-001 seed_train
BATCH-2026-001 PApool-001 capture_pool BATCH-2026-001 bioreactor
BATCH-2026-001 DS-001 drug_substance PApool-001 capture_pool
BATCH-2026-001 DP-001 drug_product DS-001 drug_substance
물질은 앞으로 흐릅니다 — 워킹 셀 뱅크(working cell bank, 모든 캠페인이 출발점으로 삼는, 동결되고 적격성 평가를 거친 세포 비축분) → 시드 트레인(seed train, 생산 바이오리액터를 채울 만큼 충분한 세포를 키워 내는, 점점 커지는 단계적 배양 연쇄) → 바이오리액터 → 프로테인 A 풀(프로테인 A 캡처 컬럼에서 받아 낸 정제된 항체) → 원료의약품(벌크 상태의 정제된 항체) → 완제의약품(충전·완료된 바이알); 이 이름들 뒤의 물리적 단계들은 1권의 주제입니다 — 그러나 테이블은 자식에서 부모로 향하는 역방향 포인터를 저장합니다. 그것이 바로 리콜(recall)이 걸어가는 방향이기 때문입니다. 규제 당국이 "로트 DP-001이 의심된다, 그 계보를 공유하는 다른 것은 무엇인가?"라고 물으면, DP-001에서 시작해 parent 간선을 따라 올라갑니다. "이 셀 뱅크 바이알이 오염되었다, 하류에 어떤 제품들이 있는가?"라고 물으면, child 간선을 따라 내려갑니다. 하나의 간선 테이블이 동일한 재귀 CTE로 두 질문 모두에 답하며, 이것이 바로 21 CFR 211.184가 요구하는 성분·정산 추적성입니다 [9]. 데이터가 구체적으로 보여 주는 미묘한 점 하나: BATCH-2026-001 / bioreactor → SEED-001 / seed_train 행은 배치 id를 물질 노드로 사용합니다 — 바이오리액터 하베스트(harvest)가 곧 배치이기에 — 그래서 같은 식별자가 실행과 그것이 만든 벌크(bulk) 둘 다를 가리킵니다. 또한 여기서는 다단계 시드 트레인(바이알 해동 → 진탕 플라스크 → 웨이브 백 → N-3 → N-2 → N-1 — 생산 바이오리액터 N에서부터 거꾸로 번호를 매긴 연속 스케일업 배양으로, N-1이 생산 바이오리액터 바로 앞의 마지막 시드 단계입니다)을 단일 SEED-001 노드로 압축합니다. 생산 시스템이라면 각 확장 단계를 자기만의 계보 간선으로 기록할 것입니다. 하류 쪽도 같은 방식으로 압축됩니다. 단일 PApool-001 → DS-001 간선은, 실제 공정이 프로테인 A 캡처와 원료의약품 사이에서 돌리는 폴리싱(polishing) 크로마토그래피, 바이러스 불활화·바이러스 여과, 그리고 UF/DF(TFF01에서의 최종 농축과 버퍼 교환) 단계들을 대신합니다 — 사실 원료의약품을 산출하는 것은 캡처가 아니라 UF/DF입니다 — 그리고 생산 시스템이라면 이들 각각도 자기만의 간선으로 기록할 것입니다.
BATCH-2026-001의 계보: 물질은 워킹 셀 뱅크에서 완제의약품으로 앞으로 흐르지만, 테이블은 리콜이 걸어가는 역방향 자식→부모 간선을 저장한다.
저자가 AI의 도움을 받아 직접 제작한 그림입니다.
같은 간선을 트리플로: 이 모델이 그래프와 만나는 지점
관계형 genealogy 행은 4권이 두 번째 방식으로 모델링하는 사실의 한 형태이며, 둘을 한꺼번에 보아 둘 가치가 있습니다. 이 페이지의 핵심 산출물 — 방향이 있는 자식 → 부모 간선 — 이야말로 외래 키 옷을 걸친 그래프 간선이기 때문입니다. DS-001 / drug_substance → PApool-001 / capture_pool이라는 행은 지식 그래프 트리플(knowledge-graph triple, 하나의 사실을 주어 — 술어 — 목적어로 적은 것)로 읽으면, RDF(Resource Description Framework — 모든 사실을 그런 트리플 하나로 적는 W3C 데이터 모델)에서 그저 bp:DS-001 bp:derivedFrom bp:PApool-001일 뿐입니다. 이 책은 시맨틱스와 디지털 스레드에서 바로 그 그래프를 실제로 구축합니다. 같은 간선들을 RDFLib에 로드하고 SPARQL로 걸어가죠. 그리고 4권의 개념화 장은 derivedFrom이 정의되는 곳입니다. rdfs:domain bp:Material와 rdfs:range bp:Material로 양 끝을 고정하고, owl:TransitiveProperty로 선언해 우리가 저장하는 직속 간선들만으로 추론기(reasoner)가 전체 계보를 추론하게 합니다.
관계형 모델이 암묵적으로 남겨 두는 세 가지를 온톨로지는 명시적으로 만들며, 그것들을 이름 붙이면 우리 genealogy 테이블이 실제로 약속하는 바가 더 또렷해집니다:
- 이행성이 질의가 아니라 공리가 된다. 우리 재귀 CTE는 실행될 때마다 다중 홉 조상을 계산하지만,
derivedFrom을 이행적으로 선언하면 추론기는 똑같은 다섯 개의 저장된 간선으로부터DS-001 derivedFrom WCB-CHO-001을 단번에 함의합니다. 같은 간선들, 그러나 장거리 연결은 매번 다시 걷는 대신 공리로 얻습니다. - 도메인/레인지 고정이 SHACL 셰이프가 된다. 우리
child → parent컬럼은 둘 다 평범한text입니다. SQL에는 부주의한 로드가 로트의 부모를 그 배치를 운전한 작업자로 가리키는 것을 막는 장치가 전혀 없죠. 4권은 SHACL(Shapes Constraint Language — RDF의 검증 계층)로 그 구멍을 막습니다. 그 셰이프는derivedFrom간선이 반드시 물질-대-물질로 이어져야 한다고 말하며, 그 조용한 오염을 표시된 위반으로 바꿔 놓습니다 — 우리text컬럼이 표현하지 못하는 외래 키 제약의 그래프 쪽 등가물입니다. - 리콜 걸어가기가 컴피턴시 질문이 된다. "의심스러운 완제의약품 로트가 주어졌을 때, 그것은 깊이에 상관없이 무엇으로부터 유래하는가?"는 4권 ORSD에서 번호 붙은 컴피턴시 질문(CQ — 온톨로지가 답해야 하는 평이한 영어 질문으로, 통과/실패 인수 테스트로 쓰임)이며, 하나의 SPARQL 프로퍼티 패스 질의 —
bp:DP-001 bp:derivedFrom+ ?ancestor, 여기서+는 "이 간선을 한 홉 이상 따라가라"를 뜻함 — 으로 답합니다. 계보 테이블과 그래프는 같은 규제 질문에 답하며, 다른 점은 재귀가 손으로 쓴 SQL인가 패스 연산자인가뿐입니다.
이 가운데 무엇도 관계형 저장소를 대체하지 않습니다. 히스토리안 조인은 여전히 SQL에 살고, 물질 생성 시점에 로드된 text 컬럼 간선은 값싸고 견고한 기록입니다. 온톨로지는 바로 그 같은 간선들의 두 번째 투영이며, 그 다리는 의도된 것입니다. 여러분이 여기서 작성하는 계보가 곧 4권이 추론하는 계보입니다. (child_type, parent_type) 문자열을 공유 IOF/BFO 클래스에 한 번 매핑해 두면, 규제 당국의 계보 질문에 어느 언어로든 답할 수 있습니다.
정규화 컬럼 대 JSONB: 경험칙
앞에서 우리는 레시피 파라미터가 어디에 사는지에 대해 의도적인 선택을 했습니다 — 타입이 지정되고 정규화되고 유효일자가 적용된 컬럼 대 jsonb 문서. 그 선택은 규칙으로 승격할 가치가 있습니다. 배치 기록에서 나중에 가장 발목을 잡기 쉬운 결정이기 때문이죠. 이 책이 따르는 경험칙: 그것으로 필터링·조인·추세 분석을 할 것이라면 정규화하라. 통째로 다시 읽기만 할 것이라면 JSONB로 두라. 27장이 버전 관리하고 통계적 공정 관리 차트가 여섯 캠페인에 걸쳐 추세 분석하는 온도 설정값은 정규화된 recipe_parameter 행입니다. 어떤 질의도 내부에 닿지 않는 볼러스 피드 시각의 중첩 테이블은 정직한 jsonb 컬럼입니다. PostgreSQL의 jsonb는 쓰레기장이 아닙니다 — 파싱된 이진 JSON을 저장하고 GIN 인덱싱을 지원하므로 정말 필요할 때 그 문서들이 조회 가능한 상태로 남습니다 [6] — 그러나 중요하고 조회 가능한 설정값을 "마이그레이션을 아끼려고" JSONB에 묻는 것은, 검토자가 더 이상 중요한 값을 보거나 추세 분석하거나 제약할 수 없게 되어 배치 기록이 검토 불가능해지는 바로 그 방식입니다.
계보 간선이 누락되면: 현장 실패 노트
계보가 — 사후에 재구성하는 것이 아니라 — 스키마의 첫 마이그레이션부터 일급 테이블인 이유는, 깨진 추적성이 GMP 제조에서 가장 중대한 데이터 무결성 실패 중 하나이기 때문입니다. 정규 A-Mab 사례 연구 — 업계가 발간한, 가상의 단일클론항체(mAb) 공정에 대한 워크드 예제로, 공통 참조로 널리 쓰입니다 — 는 종단 간 로트 추적성을 CHO 유래 단일클론항체 공정의 선택적 기능이 아니라 기준 기대치로 다룹니다 [10]. 간선이 없거나 틀리면 실패 양상은 가혹합니다. 오염된 워킹 셀 뱅크 바이알을 그것을 소비한 하류 로트들에 묶을 수 없으니, 마땅히 하나의 계보에만 닿았어야 할 리콜이 그 워킹 셀 뱅크에서 끌어다 쓴 모든 캠페인을 끌어들이거나 — 더 나쁘게는 — 어느 완제품이 그것에서 유래했는지 아무도 증명할 수 없어 의심 로트가 출하됩니다. 21 CFR 211.184가 존재하는 이유가 바로, 일탈(excursion — 측정값이 허용 범위를 벗어나는 것) 이후 이 계보를 종이에서 재구성하는 일이 느리고 오류투성이이기 때문입니다 [9]. 물질이 만들어지는 순간에 간선을 저장하면 — make load가 히스토리안·랩 데이터가 들어오는 것과 똑같은 방식으로 lot_genealogy.csv 행을 기록합니다 — 그 답은 조사가 아니라 하나의 질의가 됩니다.
진짜 라인 하나를 시드하기
데이터 없는 스키마는 텅 빈 극장입니다. 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에서 로드합니다 — 17장 참조.) 장비, 레시피, 배치는 이렇게 로드됩니다:
-- 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;
각 s88.unit 행의 마지막 두 컬럼은 장비의 실제 vendor(제조사)와 model(예: Cytiva / AKTA process)이고, unit_type은 그 장비가 무엇을 하는지를 이름 짓습니다. chromatography 유닛은 스키드(skid, 프레임 위에 올린 자족적 장비 모듈)로, 패킹된 컬럼을 통과시켜 항체를 정제합니다 — Protein A Capture Skid PA01이 바로 "프로테인 A 라인" 전체가 이름을 따온 캡처 단계입니다 — 그리고 tff 유닛은 접선 흐름 여과(tangential-flow filtration)를 수행하는데, 여기서는 벌크를 농축하고 버퍼 교환(한외여과 / 정용여과, ultrafiltration / diafiltration)하여 원료의약품으로 만드는 UF/DF Skid TFF01입니다. 이 기계들이 물리적으로 무엇을 하는지는 1권의 주제이고, 여기서는 그저 행일 뿐입니다.
모든 INSERT는 ON CONFLICT DO NOTHING으로 끝나므로 make seed는 멱등(idempotent)합니다. 두 번 실행해도 라인이 중복되지 않으며, 이는 장과 장 사이에 다시 시드할 때 중요합니다. (make seed는, 아래에서 쓰는 make up·make load·make test와 마찬가지로, 셸에서 입력하는 명령이며, 각각 저장소 Makefile의 이름 붙은 타깃을 실행합니다.) 그다음 시드는 여섯 개의 캠페인 배치를 로드하는데, 그중 하나는 의도적으로 경고의 사례로 만들어졌습니다:
-- 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에 있습니다(17장에서 온전히 구축되지만, 이 모델이 존재하는 이유 그 자체이기에 여기서 보여 줍니다):
-- 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)입니다 — 조인은 ON 조건이 일치하는 곳마다 두 테이블의 행을 꿰어 붙이는 것이므로, 여기서는 각 센서 측정값이 그 타임스탬프를 시작/종료 윈도 안에 담는 단 하나의 배치 페이즈에 매칭됩니다(LEFT 조인은 일치하는 페이즈가 없어도 측정값을 그대로 남겨 두며, 그 페이즈 컬럼만 비어서 돌아옵니다) — 그래서 각 순간이 그때 활성화되어 있던 페이즈로 매핑됩니다. 그 고아였던 BR101.Temp.PV = 37.05 degC는 이제, 자신이 제품 MAB-001, 레시피 CHO-MAB-001, 유닛 BR101에서, Growth 페이즈 동안 일어났음을 아는 행으로 읽힙니다. 헐벗은 태그가 지식이 된 것입니다.
우리는 단지 이것이 작동한다고 주장만 하지 않습니다. 동반 저장소가 그것을 증명합니다. examples/tests/test_db.py에서, 살아 있는 스택 — make up이 Docker에서 띄우는 서비스 집합(PostgreSQL/TimescaleDB 데이터베이스, MQTT 브로커, Grafana, 바이오리액터 시뮬레이터)으로, 목(mock)이 아니라 실제로 돌고 있는 것 — 을 상대로 한 pytest 실행(pytest는 표준 파이썬 테스트 도구이고, 아래의 각 def test_…은 하나의 자동화된 검사입니다)이 계층이 시드되었는지, 그리고 골든 배치의 모든 측정값이 이름 붙은 페이즈로 해소(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 러너 — 변경이 있을 때마다 스택 전체를 처음부터 다시 빌드하는 지속적 통합(continuous-integration) 서비스의 새 기계 — 에서도 다시 통과합니다. 이 모델이 다이어그램이 아니라 실재한다는, 이 책의 변치 않는 약속이죠. 같은 make test는 23장이 이 스키마 위에 얹는 감사 체인(audit chain)도 함께 검증하지만, 그것은 나중 이야기입니다.
batch_id 컬럼은 머신러닝 안전장치이기도 하다
v_batch_sensor가 모든 측정값에 찍는 batch_id는 여기서는 보이지 않지만 나중에 결정적인 두 번째 역할을 합니다. 이 데이터 위의 정직한 모델이라면 반드시 그것을 기준으로 분할해야 하는 그룹화 키라는 역할이죠. 5권의 모델과 검증 장은, 바이오공정 데이터셋이 독립적인 행들의 자루가 아니라는 점을 짚습니다. 한 배치 안의 측정값들은 강하게 상관되어 있어서, 순진한 무작위 학습/검증 분할은 답을 누설합니다 — 같은 런의 행들을 양쪽에 흩뿌려, 모델이 사실상 이미 본 값을 "예측"하게 만들죠. 해결책은 그룹화 교차검증(grouped cross-validation, 한 번에 배치 전체를 떼어 두는 검증 — 행이 아니라 batch_id로 분할)과 그 엄격한 형태인 leave-one-batch-out입니다. 떼어 둔 배치는 모델이 한 번도 건드리지 않은 것이므로, 그 점수가 곧 다음 캠페인에서 얻을 점수입니다. 측정값이 어느 배치에 속하는지 이름 붙이지 못하는 맥락화 모델은 이렇게 분할할 수 없습니다 — 이 장에서 batch_id를 행에 올려 두는 일이 두 권 뒤의 어떤 신뢰할 만한 모델에도 전제 조건인 이유가 바로 이것입니다.
같은 행은 ML의 세 가지 규율의 씨앗을 더 품고 있으며, 그 각각은 여기서 구축한 모델 없이는 무너집니다:
- 적용 범위(applicability domain). 소프트 센서는 학습 데이터를 닮은 입력에서만 신뢰할 수 있습니다. 골든 배치의 페이즈 윈도는 5권이 그 범위를 페이즈별로 울타리 치게 해 줍니다 — Growth에서 보정된 라만 모델에는 Harvest로 외삽할 권리가 없기 때문이죠.
- 공정 드리프트 대 모델 드리프트. 예측이 흔들리기 시작하면, MLOps 장은 공정이 실제로 이동하는 것(새 원자재 로트, 스케일 이동)과 모델이 낡아 가는 것을 구분해야 합니다 — 그리고 둘 다 이 테이블이 공급하는 배치·페이즈 맥락에 대비해 진단됩니다. 맥락화된 기록이 없으면 드리프트를 잴 기준선도 없습니다.
- 모델 계보(model lineage). 위의 계보와 효력 기간(effective-dated)
recipe_parameter패턴은 모델 계보를 위한 데이터 쪽 예행연습입니다 — 배포된 모델은 자신이 어느 레시피 버전과 어느 배치들로 학습되었는지를 기록해야 하며, 이는 "복제가 아니라 가리키기"라는 동일한 감사 가능 규율이라, 재학습된 모델이 조용한 제자리 수정이 아니라 버전이 매겨진 추적 가능한 산출물이 됩니다.BATCH-2026-004OOS 런을 데이터에 일부러 남겨 둔 것은 SPC 반례로서만이 아니라, 미래의 분류기가 학습할 수 있는 레이블된 실패로서이기도 합니다 — 거버넌스된 데이터셋이야말로 추적 가능한 모델을 가능하게 합니다.
요컨대 이 장은 머신러닝을 하지는 않지만, GMP 플랜트에서 ML이 신뢰받기 위해 없어서는 안 될 단 하나를 구축합니다. 모든 숫자가 자기 배치와 페이즈와 버전을 아는 기록 말입니다.
왜 중요한가
배치 기록은, 어느 의약품 로트를 출하해도 되는지를 규제 당국이 판단하기 위해 검토하는 법적 산물입니다. 이 책의 다른 모든 것 — 히스토리안, 대시보드, 지식 그래프(4권 바이오의약품 제조를 위한 온톨로지가 바로 이 간선·테이블 모델을 질의 가능한 그래프로 끌어올립니다), 분석 — 은 어떤 의미에서, 여러분이 이 장에서 세운 척추에 매달린 장식입니다. 척추가 틀리면, 하류의 모든 숫자가 그 오류를 물려받습니다.
ISA-88과 ISA-95 위에서 모델링하는 것은 구체적인 두 가지를 사 줍니다. 첫째, 의미의 이식성(portability of meaning)입니다. 이 표준들을 아는 엔지니어라면 안내 없이도 여러분의 operation과 unit 테이블을 읽을 수 있고, 미래의 MES(제조 실행 시스템, Manufacturing Execution System — 현장에서 각 레시피 단계를 구동하고 기록하는 소프트웨어)나 상용 히스토리안도 거기에 매핑할 수 있습니다. 둘째, 구성에 의한 추적성(traceability by construction)입니다. 계보와 페이즈 실적은 감사 지적 사항이 나온 뒤에 덧붙이는 것이 아니라, 일급(first-class) 테이블로서 스키마의 첫 마이그레이션(10-isa88-95.sql, 어떤 데이터가 들어오기도 전)부터 존재합니다. 바로 cGMP가 기대하는 자세입니다.
실제 현장에서는
실제 공장에서 이 관계형 모델이 홀로 사는 경우는 드뭅니다. 배치의 절차적 실행은 보통 상용 제조 실행 시스템(MES, 앞에서 소개함)이나 전자 배치 기록(electronic batch record, EBR — 종이 배치 기록의 디지털 등가물)이 소유합니다. 여기 이름을 든 제품들 — Werum PAS-X, Körber, Tulip, 또는 DeltaV/Syncade 구성 — 이 바로 그런 상용 MES/EBR 시스템의 예이며, 이 시스템들은 검증되고(validated), 벤더 지원을 받으며, 단연코 오픈 소스가 아닙니다. 이 책이 취하는 정직한 입장은, PostgreSQL이 훌륭한 맥락·분석 시스템(system of context and analysis) — 시계열을 배치에, 배치를 페이즈에 조인하고 캠페인 전반에 걸쳐 질문을 던지는 곳 — 이지만, 있는 그대로는 Part 11 준수(Part-11-compliant) 전자 배치 기록은 아니라는 점입니다. Part 11이란 21 CFR Part 11, 즉 전자 기록이 종이를 대신해 신뢰받으려면 충족해야 하는 요건(감사 추적, 서명, 접근 통제)을 정한 FDA 규칙입니다. 어떤 오픈 소스 데이터베이스도 그렇지 않습니다. 준수(compliance)는 검증된 시스템 더하기 절차(procedures)의 속성이지(GAMP 5 — 제약 업계의 우수 자동화 제조 관리(Good Automated Manufacturing Practice) 지침으로, 오픈 소스 소프트웨어 전용 부록을 두고 있습니다 — 가 이를 명시적으로 짚습니다), CREATE TABLE을 친다고 손에 넣을 수 있는 속성이 아닙니다. 우리는 데이터 무결성(data-integrity) 골조 — 시스템 버전 이력(system-versioned history), 감사 로그, 변조 증거(tamper-evident) 해시 체인 — 를 23장과 24장에서 구축하며, 거기서 슈퍼유저(superuser)가 여전히 우회할 수 있는 것이 무엇인지에 대해 솔직하게 다룹니다.
표준 자체는 열망이 아니라 진정한 업계 기준선(baseline)입니다. ISA-88과 ISA-95는 제약 업계의 사실상 모든 MES 및 배치 히스토리안 통합을 떠받치고, B2MML은 두 회사의 시스템이 레시피나 배치 기록을 교환해야 할 때의 공통어(lingua franca)입니다. 이 책 전체가 거듭 가리키는 단일 사용(single-use)·모듈러 스키드를 위해 짚어 둘 만한 더 새로운 표준이 하나 있습니다. 모듈 타입 패키지(Module Type Package, MTP) — VDI/VDE/NAMUR 2658, 독일의 공학 및 공정 자동화 기관들이 만든 표준 — 로, 스키드의 서비스, 운영자 인터페이스, 통신을 기술하여 모듈러 유닛이 상위 시스템에 의해 플러그 앤 프로듀스(plug-and-produce)(맞춤 통합이 거의 없이 연결만 하면 작동에 들어가는 것, USB 기기를 꽂으면 그냥 작동하는 방식) 방식으로 오케스트레이션될 수 있게 합니다. 여기서 우리는 장비를 정적으로 모델링합니다(벤더와 모델을 담은 unit 행 하나). 모듈러 시설이라면 각 스키드를 MTP로 추가 기술하여, 오케스트레이션 계층이 맞춤 통합 없이도 그 유닛이 무엇을 제공하는지 발견할 수 있게 할 것입니다. 7장은 MTP를 더 넓은 맥락에 놓았습니다 — 런타임이 OPC UA 정보 모델 위에서 동작하는 모듈형 자동화 표준으로, 본격적인 OPC UA 컴패니언 규격(PA-DIM, PackML, LADS)과 나란히 있는 것이죠. 우리 라인의 강화/연속(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 → unit (단일 제품 라인을 위해 work center / process cell 계층은 여기서 압축한다). 현장을 비즈니스와 통합한다.
- B2MML / BatchML — MESA International의 로열티 프리 XML 스키마 구현으로, ISA-95/ISA-88을 따르며, 시스템 간에 장비·레시피·배치 기록을 교환하는 데 쓴다.
- 유닛(Unit) — 페이즈가 돌아가는 장비(예:
BR101); ISA-88과 ISA-95가 만나는 조인 지점. - 페이즈(Phase) — 가장 작은 절차 단계(예: Growth, Capture).
- 배치(Batch) — 하나의 제조 실행: 유닛 위의 레시피, 로트 번호·상태·시작/종료 시각과 함께.
- 배치 기록(
s88.batch행) — 하나의 제조 실행을 한 행으로: 비즈니스 키batch_id,product_id, 외래 키recipe_id(버전 고정)와unit_id, 그리고lot·status·시작/종료 타임스탬프. 대부분 외래 키다 — 복제하지 않고 바인딩한다. - 계보/로트 추적성(Genealogy / lot traceability) — 한 완제의약품 로트를 원료의약품·캡처 풀·바이오리액터·시드 트레인까지 거슬러 잇는, 방향 있는 자식 → 부모 간선.
- 간선 테이블(Edge table) — 행이 엔티티가 아니라 관계인 테이블;
s88.genealogy는 행마다(child, parent)간선 하나를 저장하고, 재귀 CTE가 필요할 때 위로(리콜) 또는 아래로(영향 분석) 걸어간다. - 유효일자(유효 시간) 파라미터(Effective-dated, valid-time parameter) —
valid_from/valid_to를 지닌 값으로, 이력을 덮어쓰지 않고도 레시피를 버전 관리할 수 있게 한다. 별도의 트랜잭션 시간 축은 23장의 ALCOA+ 감사 로그가 공급한다. - JSONB — GIN 인덱싱을 갖춘 PostgreSQL의 이진 JSON 타입으로, 느슨하게 구조화된 속성의 긴 꼬리에 쓰되, 중요하고 조회 가능한 설정값에는 절대 쓰지 않는다.
- 맥락화(Contextualization) — 원시 센서 측정값을 그것의 배치·장비·활성 페이즈에 조인하는 것; 뷰
s88.v_batch_sensor가 이를 한다. - RDF 트리플 /
derivedFrom— 같은 계보 간선을 그래프 사실로 적은 것, 주어 — 술어 — 목적어(bp:DS-001 bp:derivedFrom bp:PApool-001); 4권은derivedFrom을owl:TransitiveProperty로 선언하고 물질-대-물질 SHACL 셰이프로 검증하는데, 이는 이 장의 외래 키의 그래프 쪽 쌍둥이다. - 그룹화 / leave-one-batch-out 교차검증 — 배치 전체를 떼어 두는(행이 아니라
batch_id로 분할) 모델 검증으로, 한 런의 상관된 측정값이 학습과 검증에 걸치지 않게 한다; 맥락화된 모든 행의batch_id가 가능하게 하는 누설 없는 분할. - 골든 배치(Golden batch) —
BATCH-2026-001, 이 책이 모든 것의 추세 기준으로 삼는 참조 실행;BATCH-2026-004는 의도적인 OOS(규격 이탈) 반례. - cGMP — 현행 우수 제조 관리 기준(current Good Manufacturing Practice), 모든 배치마다 배치 기록이 존재하고 그에 따라 추적 가능해야 한다는 규제 기대치.
- ALCOA+ — 규제 당국이 GMP 기록에 기대하는 데이터 무결성 표준: 데이터는 귀속 가능(Attributable)·판독 가능(Legible)·동시 기록(Contemporaneous)·원본(Original)·정확(Accurate)해야 하며(원래의 ALCOA), 여기에 완전(Complete)·일관(Consistent)·영속(Enduring)·이용 가능(Available)이 더해진다("+"). 23장의 감사 로그와
pgcrypto해시 체인이 여기서 이를 구현한다.
다음 이야기
모델은 이제 BR101과 레시피를 압니다. 하지만 2장의 측정값은 여전히 헐벗은 문자열 BR101.Temp.PV로 태깅되어 도착했고, 그 문자열이 히스토리안·대시보드·MQTT 토픽에서 똑같이 표기된다는 보장은 아직 아무것도 없습니다. 다음 장 이름 짓기: 태그, 계층, 그리고 통합 네임스페이스(Naming Things: Tags, Hierarchies, and the Unified Namespace)에서 우리는 통제된 태그 사전(tag dictionary)과 ISA-95에 정렬된 통합 네임스페이스(Unified Namespace)를 구축합니다. 즉흥적인 태그 문자열을, 통치되고(governed) 기계 검증 가능한(machine-checkable) 주소 공간으로 바꾸는 것이죠. 그리고 누군가 규칙에 맞지 않는 이름을 지어내면 빌드를 실패시키는 린터(linter)도 함께 작성합니다.