본문으로 건너뛰기

시드 트레인과 세포배양 오프라인 분석

📍 현재 위치: Part II · 공정 포착하기 — 10장. 히스토리안(historian, 공정 데이터를 보관하는 시계열 데이터베이스)은 이미 바이오리액터(bioreactor)의 실시간 태그(tag, 이름 붙은 센서 채널)를 받아들이고 있습니다. 이 장에서는 진실의 나머지 절반 — DCS(distributed control system, 장비를 구동하는 분산제어시스템)에는 결코 닿지 않는 수동 시드 트레인(seed train) 입력과 오프라인 벤치 분석 결과 — 를 포착하고, 그 각각을 배치(batch)와 그것이 대표하는 순간에 연결합니다.

쉽게 말하면

바이오리액터의 온라인(online) 센서는 손목에 찬 피트니스 트래커와 같습니다. 늘 켜져 있고, 자동이며, 끊임없이 데이터를 흘려보냅니다. 하지만 하루에 두 번, 기술자는 배양액 한 튜브를 뽑아 벤치 분석기로 가져가, 손목 밴드가 결코 줄 수 없는 숫자를 얻습니다. 살아 있는 세포가 몇 개인지, 당이 얼마나 남았는지, 항체가 얼마나 축적되었는지. 그 결과는 장비에서 이메일로 전송된 CSV로, 혹은 양식에 입력된 형태로 도착합니다. 어려운 부분은 그것을 측정하는 일이 아닙니다. 어려운 것은 각 결과를 올바른 배치시료가 채취된 정확한 시각에 다시 붙이는 일입니다. 규제 당국에게는 배치 기록에 닻을 내리지 못한 숫자란 곧 숫자가 아니기 때문입니다.

이 장에서 다루는 내용

7–9장에서는 DCS가 OPC UA(7장이 다루는 산업용 기계 간(machine-to-machine) 프로토콜로, 여기서는 센서 값을 장비에서 빼내는 데 쓰입니다)로 내보내는 모든 것 — 온도, pH, 용존산소(dissolved oxygen), 피드 펌프(feed pump) — 을 포착했습니다. 하지만 CHO(Chinese Hamster Ovary, 항체 생산의 주력 포유류 세포주) 유가식(fed-batch) 운전 — 하나의 용기에서 운전 내내 농축 영양 피드를 더해 가며 키우는 배양 — 은 DCS가 결코 보지 못하는 완전히 별개의 두 번째 데이터 스트림을 만들어냅니다. 시드 트레인 — 해동한 바이알(vial)부터 생산 바이오리액터까지 세포를 단계적으로 확장하는 과정 — 은 대부분 수기로 기록됩니다. 그리고 세포배양에서 의사결정에 가장 결정적인 숫자들, 즉 공정 과학자가 실제로 운전의 방향을 잡는 데 쓰는 숫자들은 벤치 분석기에서 나옵니다. 생존세포밀도(viable cell density), 생존율(viability), 글루코스(glucose), 락테이트(lactate), 역가(titer)가 그것입니다.

이 장은 그 DCS 바깥 세계에 관한 것입니다. 우리는 다음을 할 것입니다.

  • 결정론적(deterministic) 시뮬레이터로 현실적인 오프라인/앳라인(at-line) 벤치 결과를 생성합니다. 이 결과는 온라인 트레이스(trace)와 동일한 기저 배양 상태에서 뽑아냅니다.
  • 그 결과를 시료-배치 계보(genealogy)를 위해 설계된 관계형 랩(lab) 스키마(schema)에 적재합니다.
  • 분석기의 CSV 드롭(drop)을 자동으로 집어 올리는 파일 감시(file-watch) 인제스터(ingester)를 만듭니다.
  • 그리고 진짜로 어려운 부분과 마주합니다. 모든 오프라인 결과가 가진 두 개의 타임스탬프(timestamp)(시료가 채취된 시각 대 측정된 시각)를 조정하는 일, 그리고 수동 데이터를 데이터 무결성(data integrity)의 지뢰밭으로 만드는 지연된, 수정된, 정정된 결과를 다루는 일입니다.

이 장의 핵심에 있는 두 산출물은 모두 동반 저장소(repo)에 실제로 존재하며 테스트되어 있습니다. 시뮬레이터 모듈 examples/sim/bioproc_sim/offline_assays.py와 랩 스키마 examples/platform/db/30-lab-events.sql입니다. 파일 감시 코드는 watchdog 라이브러리 위에 구축된 저장소의 file-ingester 서비스(examples/services/file-ingester/app.py)를 응축한 발췌로 제시되며, 등장하는 곳에 표시해 둡니다.

오프라인 데이터가 전혀 다른 부류인 이유

FDA(미국 의약품 규제 당국)의 PAT(Process Analytical Technology, 공정분석기술) 프레임워크가 표준으로 만든 측정 분류 체계에서, 탱크에 배선된 센서는 온라인(on-line)(프로브가 배양액 — 용기를 채운 액상 세포배양 — 안에 잠겨 있으면 인라인(in-line))이고, 시료를 뽑아 몇 분 안에 근처 분석기로 돌리면 앳라인(at-line)이며, 시료를 별도의 실험실로 가져가면 오프라인(off-line)입니다 [6]. 이 프레임워크의 핵심은 인라인에서 한 걸음 멀어질 때마다 지연이 더해진다는 점입니다. 시료가 공정을 대표하는 시점과 마침내 답을 알게 되는 시점 사이의 시간 말입니다. 그 지연이야말로 우리가 기록해야 하는 바로 그것입니다. 결과는 측정과 동시적(contemporaneous)이지만, 그것은 배치의 더 이른 순간에 대한 증거이기 때문입니다.

그 분석기에는 무엇이 올라오나요? CHO 시드 트레인과 생산 배양에서 기본 패널(panel)은 생존세포밀도(VCD)와 생존율 — 역사적으로는 수동 혈구계산기(hemocytometer)와 트리판 블루(trypan blue) 계수(눈금이 새겨진 계수 슬라이드 위에서 현미경으로 세포를 세고, 죽은 세포만 물들이는 파란 염료를 사용)로, 오늘날에는 보통 자동 영상 계수기로 측정하며, 방법 선택 자체가 검증되고 무결성과 관련된 결정입니다 [8] — 그리고 모든 영양 공급 전략이 기반으로 삼는 대사물질(metabolite) 세트, 즉 글루코스, 락테이트, 글루타민(glutamine), 암모늄(ammonium), 삼투압(osmolality)입니다 [7]. 이들은 실험실 정보학(laboratory-informatics) 데이터입니다. 성숙한 공장에서는 공정 자동화 스택이 아니라 LIMS(laboratory information management system, 실험실 정보 관리 시스템), ELN(electronic lab notebook, 전자 실험 노트), 혹은 실험실 실행 시스템(laboratory execution system)을 통해 포착되며, 실험실 정보학에 관한 ASTM E1578 가이드가 바로 그렇게 규정하는 참조 문서입니다. DCS 태그와는 다른 데이터 계보, 다른 시스템, 다른 검증인 것입니다 [2].

그 분리가 이 장을 조직하는 사실입니다. 두 개의 스트림, 두 개의 보관 사슬(custody chain), 그리고 둘 다 함께 받들어야 하는 하나의 배치입니다.

온라인 트레이스와 일치하는 오프라인 결과 생성하기

시뮬레이터는 의도적이고 중요한 일을 합니다. 인라인 태그가 나오는 동일한 동역학적(kinetic) 상태에서 오프라인 패널을 샘플링한 다음, 분석 노이즈(noise)와 음수 방지 바닥(non-negativity floor)을 더합니다. 그래서 벤치 VCD는 온라인 세포 성장 모델과 일치합니다 — 다만 더 노이즈가 많고 훨씬 더 성긴 형태일 뿐입니다. 이것은 지름길이 아닙니다. 그것은 이 장이 풀기 위해 존재하는 바로 그 조정(reconciliation) 문제를, 테스트 데이터에 미리 구워 넣은 것입니다.

다음은 examples/sim/bioproc_sim/offline_assays.py의 핵심입니다.

# examples/sim/bioproc_sim/offline_assays.py
SAMPLES_PER_DAY = 2


def sample(result: BatchResult | None = None, batch_id: str = "BATCH-2026-001") -> pd.DataFrame:
"""Two offline samples per day from the fed-batch state, with assay noise + LoD."""
if result is None:
result = simulate(batch_id)
s = result.state
rng = stream_rng("offline_assays", result.batch_id)

minutes = []
day = 0.0
while day <= 14.0 + 1e-9:
for frac in (0.25, 0.75): # ~06:00 and ~18:00
m = int(round((day + frac) * 1440))
if m < len(s):
minutes.append(m)
day += 1.0
minutes = sorted(set(minutes))

rows = []
for i, m in enumerate(minutes, start=1):
st = s.iloc[m]
rows.append({
"sample_id": f"{result.batch_id}-OFF-{i:03d}",
"batch_id": result.batch_id,
"sample_time": st["ts"],
"sample_point": "BR101",
"VCD_e6_per_mL": max(0.0, round(st.Xv_e6_per_mL * (1 + rng.normal(0, 0.05)), 2)),
"viability_pct": float(np.clip(round(st.viability_pct + rng.normal(0, 1.2), 1), 0, 100)),
"glucose_g_L": max(0.0, round(st.glucose_g_L + rng.normal(0, 0.15), 2)),
"lactate_g_L": max(0.0, round(st.lactate_g_L + rng.normal(0, 0.10), 2)),
"glutamine_mM": max(0.0, round(st.glutamine_mM + rng.normal(0, 0.10), 2)),
"ammonia_mM": max(0.0, round(st.ammonia_mM + rng.normal(0, 0.20), 2)),
"osmolality_mOsm_kg": int(round(st.osmolality_mOsm_kg + rng.normal(0, 4))),
"titer_g_L": max(0.0, round(st.titer_g_L * (1 + rng.normal(0, 0.04)), 3)),
"pH_offline": round(float(np.clip(st.pH + rng.normal(0, 0.02), 6.6, 7.4)), 2),
})
return pd.DataFrame(rows)

천천히 짚어 볼 만한 세부가 셋 있습니다. 실제 실험실 관행을 부호화한 부분이기 때문입니다. 첫째, 스케줄입니다. 하루에 두 번 frac = 0.250.75 — 대략 06:00과 18:00 — 인데, 이는 현실적인 오프라인 주기이며, 태그당 약 20,160개의 1분 단위 온라인 행(14일 × 하루 1,440분)에 대비해 14일 운전 동안 28개의 결과를 줍니다. 오프라인 데이터는 성깁니다. 둘째, sample_time은 함수가 실행되는 순간이 아니라 시뮬레이션된 상태 행 자체의 타임스탬프 st["ts"] — 즉 시료가 대표하는 순간 — 에서 가져옵니다. 셋째, 노이즈는 분석물질별이며 물리적으로 스케일링되어 있습니다. VCD와 역가에는 곱셈형 4–5% 오차가 붙고(계수와 분석의 부정확성은 측정값의 크기와 함께 커집니다), 글루코스와 락테이트에는 작은 덧셈형 오차가 붙으며, 모든 값은 0에서 바닥이 막혀 있어(양의 검출 한계가 아니라 음수 방지 바닥) 0에 가까운 측정값이 음수로 내려가는 일이 없습니다. 실제로 Cedex 역가 분석(Roche Cedex 분석기에서 돌리는 실험실 측정으로, 뒤에서 자세히 다룹니다)은 약 0.01–0.05 g/L의 실질적 하한 검출 한계를 가지며, 그래서 첫 역가 행들(~0.002–0.008)은 실제 장비에서는 "검출되지 않음"으로 보고됩니다.

rng = stream_rng("offline_assays", result.batch_id) 줄은 이 책 전체가 재현 가능한 이유입니다. 모든 난수 스트림은 마스터 시드(seed)(SIM_SEED=2026)에 스트림별 라벨을 더해 유도되므로, 이 데이터셋은 모든 머신과 CI에서 바이트 단위로 동일합니다.

모듈을 직접 실행하면 자체 요약을 출력합니다.

$ SIM_SEED=2026 python -m bioproc_sim.offline_assays
offline samples: 28 rows over 14 days
sample_id VCD_e6_per_mL viability_pct glucose_g_L titer_g_L
0 BATCH-2026-001-OFF-001 0.34 96.6 6.18 0.002
1 BATCH-2026-001-OFF-002 0.43 96.6 6.26 0.008
2 BATCH-2026-001-OFF-003 0.56 99.0 6.01 0.014
3 BATCH-2026-001-OFF-004 0.72 97.5 5.99 0.022
4 BATCH-2026-001-OFF-005 0.96 96.7 5.69 0.033
release assays: 11 rows; OOS=0

이것이 모듈이 출력하는 요약입니다. 행 개수, 다섯 줄짜리 head(), 그리고 한 줄의 출하 분석 집계입니다. 배치별 전체 행은 generate.py가 모든 배치에 걸쳐 sample()을 이어 붙여(BATCH-2026-001부터 -006까지, 각 28행, 총 168행) 커밋된 골든 파일(golden file) examples/datasets/offline_assays.csv에 기록합니다. 참조 배치에 대한 그 첫 행들은 전체 와이드(wide) 패널 — 앳라인 분석물질 세트를 한곳에 모은 것 — 을 보여 줍니다.

sample_id,batch_id,sample_time,sample_point,VCD_e6_per_mL,viability_pct,glucose_g_L,lactate_g_L,glutamine_mM,ammonia_mM,osmolality_mOsm_kg,titer_g_L,pH_offline
BATCH-2026-001-OFF-001,BATCH-2026-001,2026-01-05 06:00:00+00:00,BR101,0.34,96.6,6.18,0.13,4.13,0.68,293,0.002,7.06
BATCH-2026-001-OFF-002,BATCH-2026-001,2026-01-05 18:00:00+00:00,BR101,0.43,96.6,6.26,0.19,4.31,0.38,292,0.008,7.04
BATCH-2026-001-OFF-003,BATCH-2026-001,2026-01-06 06:00:00+00:00,BR101,0.56,99.0,6.01,0.32,3.83,0.45,287,0.014,7.05

VCD_e6_per_mL 열을 아래로 읽어 보세요 — mL당 0.34, 0.43, 0.56백만 세포 — 그러면 생산 배양(BR101, 생산 바이오리액터의 용기 태그)이 ~0.3e6 접종물(inoculum, 용기에 처음 접종된 세포 배치. e6은 ×10⁶을 뜻하는 과학적 약기이므로 0.3e6 = mL당 0.3백만 세포)에서 지체기(lag, 세포를 넣은 직후 천천히 안정되는 단계)를 벗어나 지수 성장(개체군이 일정한 속도로 배가되는 단계)으로 올라서는 모습을 지켜보고 있는 셈입니다. 배양은 ~0.3e6 생존세포/mL로 접종되었으므로, 여섯 시간 뒤의 첫 벤치 측정값 0.34e6은 막 시작된 접종물입니다. 역가 열은 사실상 0에서 시작하는데, 항체 축적이 성장보다 뒤처지기 때문입니다. 시드 트레인(seed train) 자체 — 이 용기를 채워 준 단계적 확장 — 은 별개의 상류 로트(lot)로, 똑같이 오프라인 방식으로 기록됩니다. 다만 그것은 여기서 읽고 있는 트레이스가 아닙니다.

실험 설계하기: ambr와 DOE

지금까지 생성해 온 단일 유가식 운전은 상용 배치의 모습입니다 — 하나의 레시피, 하나의 조건 세트. 하지만 그 레시피의 조건들은 추측된 것이 아닙니다. 그것들은 공정이 입력에 어떻게 반응하는지 지도화하기 위해 수십 개의 작은 배양을 병렬로 돌린 공정 개발(process development, PD) 캠페인에서 찾아낸 것입니다. 거기서 대표적인 일꾼은 마이크로·병렬 바이오리액터 시스템입니다 — Sartorius ambr15ambr250이 가장 자주 만나게 되는 이름으로, 이 역시 업계 예시입니다 — ambr15는 최대 48개의 마이크로바이오리액터를, ambr250은 최대 24개(고처리량 구성에서는 48개)를 돌립니다. 그 용기 하나하나는 우리가 시뮬레이션하는 BR101의 축소판이며, 각각은 연속 트레이스(trace)가 아니라 실험의 한 입니다.

그 실험은 거의 언제나 실험 설계(design of experiments, DOE) — 소수의 운전만으로 각 인자와 그 상호작용의 효과가 드러나도록 선택된, 입력 조합의 구조화된 격자 — 입니다. 품질 설계 기반(Quality-by-Design, QbD) 프로그램 — 마지막에 시험으로 품질을 걸러내는 대신 공정을 이해함으로써 품질을 안에서부터 쌓아 올리는, 규제 당국이 지지하는 개발 접근 — 에서 DOE의 목표는 설계 공간(design space), 즉 공정이 규격 내 제품을 신뢰성 있게 만들어내는 입력의 다차원 영역을 그려내는 것입니다. 설계는 통계 패키지에서 만들어지고 반응 모델이 적합(fit)됩니다 — Sartorius BioPAT MODDE가 오래 자리 잡은 대표 도구입니다 — 그리고 여기 이 책에서 중요한 데이터 모양의 반전이 있습니다. MODDE의 네이티브 운전 테이블은 CSV가 아니라 독점 바이너리 파일(.mip)입니다. 그래서 공정 개발 전체에서 가장 의사결정이 풍부한 테이블이 기본적으로 벤더 형식 안에 갇혀 있고, 그것을 개방형 랩 스키마로 가져오려면 내보내기 단계가 필요합니다 — 위 Cedex .txt와 똑같습니다.

DOE 테이블은 또한 이 장의 다른 모든 것과 반대로 생겼습니다. 히스토리안은 길고 좁은 행을 저장합니다 — 타임스탬프 하나, 태그 하나, 값 하나가 수백만 번. DOE 운전 테이블은 넓고 짧습니다. 몇 개의 인자 열(피드 속도, 온도, pH 설정값…)과 몇 개의 반응 열(최종 역가, 생존율, 종합 품질 점수)이 있고, 운전 하나당 한 행입니다. 한 행을 가로질러 읽으면 실험 하나의 레시피와 결과 전체를 알 수 있고, 한 열을 따라 내려 읽으면 한 인자가 어떻게 움직였는지 알 수 있습니다. 이는 벤치 분석기의 패널(아래에서 자세히 다루는 Nova FLEX2)이 가진 바로 그 와이드 행 모양이며, lab.samplelab.result에 같은 방식으로 매핑됩니다 — 각 운전이 하나의 시료, 각 반응이 하나의 결과가 됩니다 — 그래서 PD 설계 공간과 상용 배치가 하나의 스키마 안에 함께 살게 됩니다.

정직한 하이브리드 단서도 여기에 속합니다. 오픈소스 통계는 수학을 해내는 데는 전혀 문제가 없습니다. statsmodels가 회귀를 적합하고, pyDOE(와 그 후속들)가 고전적 요인 설계와 반응표면(response-surface) 설계를 생성하며, scikit-learn은 반응표면 모델을 적합하고 그 위에서 최적화합니다. OSS 스택이 건네주지 못하는 것은 그 수학에 대한 MODDE의 포장 — 문서화된 설계 근거, 진단, 그리고 QbD 제출이 기대는 규제 등급 보고서를 갖춘, 검증되고 벤더 책임이 명확한 설계 공간 산출물 — 입니다. 계산은 개방되어 있지만, 책임 소재가 분명한 설계 공간 산출물은 GxP(규제 당국이 검사할 수 있는 모든 데이터를 규율하는 우수 관리 기준 규정 — GMP, GLP, GCP — 의 총칭)의 마지막 1마일이며, 이 장 뒤에서 논의하는 lab.result를 감싸는 서명 래퍼(wrapper)와 같은 패턴입니다.

계보를 위해 만들어진 랩 스키마

결과가 담긴 스프레드시트는, 모든 행이 어느 배치어느 시료에 닻을 내리지 않는 한 규제 당국에게는 무가치합니다. ISA-88/IEC 61512(배치 제어 표준)는 절차적·물리적 계층 구조 — 프로세스 셀(process cell, 생산 구역)에서 그 아래 유닛(unit, 개별 용기), 배치(레시피의 한 실행), 그리고 로트(lot, 그것이 산출하는 출하된 물질)로 이어지는 중첩 — 를 우리에게 주며, 이는 "올바른 배치"라는 말이 무엇을 뜻하는지 정의하고, 시드 트레인이나 앳라인 시료가 올바른 계보에 붙을 수 있게 하는 척추입니다 [1]. 우리는 그 계층 구조를 4장에서 PostgreSQL(이 스택이 돌아가는 오픈소스 관계형 데이터베이스)로 모델링했습니다. 랩 계층은 그 위에 결과를 매다는 부분이며, examples/platform/db/30-lab-events.sql에 들어 있습니다.

-- examples/platform/db/30-lab-events.sql
CREATE TABLE lab.sample (
sample_id text PRIMARY KEY,
batch_id text REFERENCES s88.batch,
sample_time timestamptz NOT NULL,
sample_point text NOT NULL,
sample_type text NOT NULL DEFAULT 'in_process' -- in_process | release | stability
);

CREATE TABLE lab.test (
test_id text PRIMARY KEY,
name text NOT NULL,
unit text,
spec_low numeric,
spec_high numeric
);

CREATE TABLE lab.result (
result_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sample_id text NOT NULL REFERENCES lab.sample,
test_id text REFERENCES lab.test,
value numeric,
text_value text,
unit text,
result_ts timestamptz NOT NULL DEFAULT now(),
analyst text,
instrument_id text,
status text NOT NULL DEFAULT 'preliminary', -- preliminary | verified | rejected
UNIQUE (sample_id, test_id, result_ts)
);
CREATE INDEX ON lab.result (sample_id);

이 작은 스키마는 겉보기보다 많은 컴플라이언스(compliance) 설계를 담고 있습니다. 세 가지 지점이 무거운 일을 해냅니다.

두 개의 타임스탬프, 의도적으로. lab.sample.sample_time시료가 채취된 시각 — 그 값이 증거가 되는 배치 안의 순간 — 입니다. lab.result.result_ts결과가 기록된 시각입니다. 이들은 서로 다른 사건이며, 때로는 몇 시간 떨어져 있습니다. PostgreSQL의 timestamp with time zone은 둘 다 절대적인 UTC(Coordinated Universal Time, 협정 세계시 — 전 세계 하나의 기준 시계) 시점으로 저장하므로, "채취됨"과 "알게 됨" 사이의 간극이 사라지지 않고 질의 가능해집니다 [11]. 그 간극이 바로 PAT 프레임워크가 경고하는 시료-통찰(sample-to-insight) 지연이며, 하나의 열로 구현된 것입니다.

batch_id REFERENCES s88.batch 제약은 외래 키(foreign key)로 구현된 계보 링크입니다 — 데이터베이스는 실제 배치를 지칭하지 않는 시료에 대한 결과를 기록하기를 거부합니다. 시료-배치 추적성(traceability)은 관례이기를 멈추고, 엔진이 강제하는 불변식(invariant)이 됩니다.

statusUNIQUE (sample_id, test_id, result_ts) 제약 조건은 거짓말하지 않고 수정하는 방법입니다. 예비(preliminary) 결과와 그 이후의 검증된(verified) 값은 두 개의 행이지 덮어쓰기가 아닙니다 — 바로 다음 절이 필요로 하는 것입니다.

여기에 대응하는 CofA(certificate of analysis, 시험성적서)/출하 패널은 같은 시뮬레이터 모듈에서 나옵니다. release_results() 함수는 현실적인 mAb(monoclonal antibody, 단일클론항체) 규격 범위 — 각 결과가 반드시 들어와야 하는 허용 상한/하한 — 에 대해 배치당 하나의 시험성적서 행 세트를 내보냅니다.

# examples/sim/bioproc_sim/offline_assays.py
# realistic mAb release-assay specs: (name, low, high, unit, target, sd)
_RELEASE_SPECS = [
("SEC_monomer_pct", 95.0, 100.0, "%", 98.5, 0.4),
("SEC_HMW_pct", 0.0, 3.0, "%", 1.1, 0.3),
("SEC_LMW_pct", 0.0, 2.0, "%", 0.4, 0.15),
("CEX_main_pct", 60.0, 80.0, "%", 70.0, 2.0),
("CEX_acidic_pct", 10.0, 30.0, "%", 20.0, 1.8),
("CEX_basic_pct", 5.0, 20.0, "%", 10.0, 1.2),
# ... HCP, residual Protein A, host-cell DNA, endotoxin, bioburden follow
]

이들은 앞에서 포착한 세포배양 분석물질이 아니라 제품 품질 출하 시험입니다. SEC(size-exclusion chromatography, 크기 배제 크로마토그래피)는 온전한 단량체(monomer)의 백분율을 고분자량·저분자량 종(HMW/LMW — 응집체와 단편)에 대비해 보고하고, CEX(cation-exchange, 양이온 교환)는 main(주) 전하 변이체(charge variant)를 그것의 acidic(산성)·basic(염기성) 형태와 분리하며, 나머지 — HCP(host-cell protein, 숙주세포 단백질), 잔류 Protein A(제품으로 누출될 수 있는 포획 레진), 숙주세포 DNA, 엔도톡신(endotoxin), 생균수(bioburden) — 는 순도와 안전성 한계입니다. 각각은 값이 규격 범위 안에 들면 PASS, 그렇지 않으면 OOS로 판정됩니다.

>>> release_results().head(6).to_string(index=False)
batch_id test value unit spec_low spec_high result
BATCH-2026-001 SEC_monomer_pct 98.611 % 95.0 100.0 PASS
BATCH-2026-001 SEC_HMW_pct 1.287 % 0.0 3.0 PASS
BATCH-2026-001 SEC_LMW_pct 0.439 % 0.0 2.0 PASS
BATCH-2026-001 CEX_main_pct 70.686 % 60.0 80.0 PASS
BATCH-2026-001 CEX_acidic_pct 21.551 % 10.0 30.0 PASS
BATCH-2026-001 CEX_basic_pct 10.452 % 5.0 20.0 PASS

result 열은 그저 "PASS" if low <= val <= high else "OOS"입니다 — 실제 출하 결정(배치를 출하할지 거부할지에 대한 공식적 판단)이 의존하는 바로 그 규격 내(in-spec)/규격 외(out-of-specification, OOS) 로직입니다. 이 행들은 LIMS/ELN 장(14장), 지식 그래프(knowledge graph, 19장), 그리고 상용 LIMS 브리지(bridge, 22장)로 흘러갑니다.

시드 트레인 플라스크 아이콘과 벤치 분석기가 하루 두 개의 오프라인 시료를 CSV 파일 드롭으로 보낸다. watchdog 파일 감시기가 그 드롭을 집어 올려 파일이 담은 단 하나의 타임스탬프 — sample_time — 를 파싱하고, s88.batch 외래 키를 통해 시료를 배치에 연결한 뒤, status=preliminary 행을 lab.sample 및 lab.result 테이블에 기록한다(result_ts는 인제스트 시점에 발급된다). 이후 분석가의 검증이 두 번째 검증 행을 추가한다. 이 테이블은 하나의 배치 타임라인 위에서 온라인 히스토리안 트레이스 옆에 나란히 놓인다.

DCS 바깥 포착 경로: 벤치 결과는 파일 드롭으로 도착하여, 시료 시각과 결과 시각 양쪽이 파싱되고, 배치 계보에 연결된 뒤, 덮어쓰지 않고 수정할 수 있는 추가 전용(append-only) 행으로 적재된다. 저자가 AI의 도움을 받아 직접 제작한 그림입니다.

lab.result 행의 해부: 기록된 결과가 담는 것

이 장의 컴플라이언스 이야기 전체가 단 하나의 lab.result 행 안에 살아 있으므로, 7장이 OPC UA DataValue를 해체했던 것과 똑같이 그 행을 필드별로 해부해 볼 만합니다. 시료 BATCH-2026-001-OFF-003의 검증된 글루코스 결과 — 아래 생애주기 SQL이 만들어내는 행 — 를 봅시다. 순진한 시스템이라면 과학자가 묻는 그 숫자 하나, 6.01만 저장할 것입니다. 이 스키마는 열 가지를 저장하며, 그 하나하나가 그렇지 않았다면 규제 당국이 찾아냈을 구멍을 막습니다.

BATCH-2026-001-OFF-003 시료의 검증된 글루코스 결과에 대한 lab.result 행 하나를 필드별로 해부한 신원 카드: 데이터베이스가 생성하는 대리 키로서의 result_id, 외래 키로서의 sample_id와 test_id, value 6.01·단위 g/L·기록 시각 result_ts를 담은 강조된 초록 블록, 그리고 analyst, instrument_id, 예비에서 검증으로의 status, 수정 행이 공존하도록 허용하는 sample_id·test_id·result_ts에 대한 UNIQUE 제약을 설명하는 보라색 패널. 하나의 lab.result 행은 숫자보다 훨씬 많은 것을 담는다: 대리 신원, 두 개의 외래 키, 단위와 기록 시각을 동반한 값, 그것을 기록한 사람, 그리고 덮어쓰기 없이 수정을 허용하는 제약이다. 저자가 AI의 도움을 받아 직접 제작한 그림입니다.

카드를 위에서 아래로 읽으면 설계 의도가 분명합니다.

  • result_idbigint GENERATED ALWAYS AS IDENTITY 대리(surrogate) 기본 키입니다. GENERATED ALWAYS는 엔진이 값을 부여하고 INSERT가 그것을 공급하거나 덮어쓸 수 없음을 뜻합니다 — 그래서 모든 결과는 사람이 편집하지 않는, 안정적이고 기계가 발급한 핸들을 갖게 되며, 이는 감사 추적(audit trail)이 참조하고 싶어 하는 바로 그것입니다.
  • sample_idtest_id는 자유 텍스트가 아니라 외래 키입니다. sample_idsample_timebatch_id를 지닌 lab.sample 행을, test_id는 분석물질의 이름·단위·규격 범위를 정의하는 lab.test 행을 가리킵니다. 결과는 자신의 계보와 규격을 복사가 아니라 참조로 물려받습니다.
  • valuenumeric이고 text_valuetext입니다. 하나의 테이블이 정량적 글루코스 측정값(6.01, text_value는 NULL)과 PASS/OOS 판정이나 "검출되지 않음" 같은 정성적 결과를 모두 담도록, 숫자를 문자열로 또는 그 반대로 억지로 밀어넣지 않으려는 의도적 분리입니다.
  • unit은 값과 함께 다닙니다. 6.01은 의미가 없고 6.01 g/L이 결과입니다. lab.test에도 단위가 있지만, 결과는 장비가 실제로 보고한 것의 동시적(contemporaneous) 기록이므로 단위가 행에도 저장됩니다.
  • result_ts는 기록된 시각입니다 — 여기서는 2026-01-06 09:10:00+00. 이 필드가 이 장의 이중 타임스탬프 문제를 다룰 수 있게 만들며, 다음 절은 이것과 sample_time 사이의 간극 위에 통째로 세워져 있습니다.
  • analyst는 값을 기록한 주체를 남깁니다 — 기계가 쓴 예비 행에는 SVC_INGEST, 검증된 행에는 a.kowalski 같은 사람 로그인입니다. instrument_id는 그것을 만든 장비를 남깁니다. 오프라인 파일 드롭 경로에서는 인제스터가 이를 NULL로 둡니다(CSV가 장비를 명시하지 않으므로). 반면 22장의 CofA 브리지는 HPLC-07 같은 실제 장비 id로 채웁니다. 열을 생략하는 대신 널 허용으로 기록하는 것이 정직한 선택입니다. 열은 존재하고, 더 풍부한 피드가 그것을 채웁니다.
  • status는 생애주기 플래그입니다 — preliminary | verified | rejected이며 기본값은 preliminary. 이는 "기계가 그렇게 말했다"와 "자격 있는 사람이 그 뒤에 선다"의 차이입니다.

카드에서 단연 가장 중요한 줄은 맨 아래 줄입니다. UNIQUE (sample_id, test_id, result_ts). 이 튜플 — result_id가 아니라 — 이 진짜 비즈니스 키이며, 같은 시료와 같은 시험에 대한 두 결과가 기록 시각만 다르다면 공존하도록 의도적으로 넓게 잡혀 있습니다. 이것이 수정을 덮어쓰기가 아니라 추가(append)로 만드는 전체 메커니즘이며, 생애주기 절이 이를 구체화합니다.

3부작의 척추를 따라 거슬러 올라가기

그 카드의 lab.samplelab.result 행은 실제 시드 트레인에서 실제로 채취한 벤치 시료의 디지털 그림자입니다. 1권은 이 행들이 기록하는 물리적 단계를 따라갑니다. 세포주 개발에서 마스터 세포 은행(master cell bank, 전체 공정이 시작되는 자격 인증된 냉동 세포 원원료) 바이알을 해동하고 확장한 뒤, 시드 트레인을 통해 N-1 접종물(생산 반응기 바로 직전 단계의 시드 배양)을 단계적으로 키워 생산 바이오리액터까지 끌어올리는 과정입니다. 2권의 공정 데이터가 태어나는 곳 둘러보기는 각 오프라인 측정값을 그것의 배치와 진짜 시료 순간에 닻 내리는 열린 과제를 제시하며 — 이 스키마의 두 타임스탬프와 batch_id 외래 키가 바로 그 과제에 대한 구체적인 답입니다.

CSV 드롭 행의 해부: 분석기가 쓰는 것

lab.result 행은 목적지입니다. 출발지는 분석기 CSV 드롭의 한 줄이며, 나란히 해부해 볼 만한 다른 모양을 갖습니다 — 둘 사이의 이음매(seam)야말로 대부분의 작업과 대부분의 실수가 사는 곳이기 때문입니다. 다음은 offline_assays.csv의 첫 참조 배치 줄을 해체한 것입니다.

offline_assays.csv의 분석기 CSV 행 하나(BATCH-2026-001-OFF-001)를 해부한 신원 카드: lab.sample 행이 되는 네 열(sample_id, batch_id, sample_time, sample_point)을 묶은 호박색 블록, 그다음 각각 lab.result 행으로 매핑되는 VCD·글루코스·역가 열, 세 열만 예비로 자동 인제스트되고 나머지 와이드 패널은 분석가를 기다린다는 점을 적은 초록 블록, result_ts는 파일에 없고 인제스트 시 부여된다는 행, 그리고 네이티브 CSV 대 벤더 형식 파서 이음매에 관한 보라색 패널. 분석기의 와이드 행은 네 갈래로 갈라진다: 네 열은 하나의 lab.sample이 되고, 매핑된 세 열은 preliminary lab.result 행이 되며, 결과 시각은 인제스트 시점에 발급되고, 벤더별 파서 이음매가 그 파일이 애초에 CSV로 도착하는지를 결정한다. 저자가 AI의 도움을 받아 직접 제작한 그림입니다.

맨 위 호박색 블록은 어떤 측정값도 아니라 시료를 기술하는 네 열을 담습니다. sample_id, batch_id, sample_time, sample_point. 인제스터의 upsert_sample은 이 넷을 정확히 하나의 lab.sample 행으로 만들며, batch_id는 전체 행 세트를 GMP 배치에 닻 내리는 외래 키입니다. 결정적으로 sample_time은 파일이 담는 유일한 타임스탬프입니다 — 시료가 채취된 순간. 결과가 기록된 시점을 위한 열은 없으며, 그 간극이 바로 다음 절의 주제입니다.

호박색 블록 아래에서, 각 분석물질 열은 자신의 lab.result 행이 됩니다 — 다만 기계가 무인으로 포착하도록 신뢰받는 세 열만입니다. INGEST_TESTS 맵이 그것들을 지정합니다. VCD_e6_per_mL → VCD (e6/mL), glucose_g_L → Glucose (g/L), titer_g_L → Titer (g/L). 그 행 하나하나는 status = 'preliminary'로 기록됩니다. 나머지 와이드 패널 — 생존율, 락테이트, 글루타민, 암모니아, 삼투압, 오프라인 pH — 은 파일에 존재하지만 자동 인제스트되지 않으며, 분석가가 입력하고 검증하기를 기다립니다. result_ts는 파일이 아예 갖지 않는 필드입니다. DB 기본값 now()가 인제스트 시점에 그것을 발급하며, 바로 그래서 기록 시각이 채취 시각보다 늦습니다.

맨 아래 보라색 패널은 다중 벤더 현장을 가장 먼저 물어뜯는 이음매입니다. 이 깔끔한 와이드 행 모양은 소수의 장비에서만 네이티브입니다 — Nova FLEX2와 Vi-CELL BLU는 CSV를 직접 쓰는 반면, Roche Cedex Bio HT는 탭으로 구분된 .txt를, Vi-CELL XR은 .txt/.xls를 쓰며, MODDE .mip 같은 독점 바이너리는 무엇이든 읽기 전에 먼저 내보내야 합니다. 실제 배포는 on_created 훅에 작은 벤더별 파서를 매달아, ingest()가 실행되기 전에 각 레이아웃을 이 하나의 행 모양으로 정규화합니다.

파일 드롭 인제스팅하기

실제 분석기 — Vi-CELL 계열 세포 계수기, Nova/Cedex 계열 대사물질 분석기 — 는 CSV나 벤더(vendor) 파일을 감시 폴더로 내보냅니다.

대표적인 벤치 분석기 하나 — Nova Biomedical BioProfile FLEX2가 흔한 예입니다 — 를 놓고 보면 이 계층의 이중적 성격이 또렷해집니다. FLEX2는 한 번 뽑은 시료에 폭넓은 화학 패널(panel)(글루코스, 락테이트, 글루타민, 암모늄, 삼투압, 그리고 가스와 세포 밀도)을 돌리며, 디스크 내보내기 형식이 네이티브 CSV(native CSV)인 몇 안 되는 장비 중 하나입니다 — 이 장에서 네이티브 CSV인 경우는 FLEX2와 아래의 Vi-CELL BLU뿐입니다. 그 CSV는 시료당 한 행의 와이드(wide) 테이블입니다. 시료 하나에 한 줄, 분석물질 하나에 한 열입니다. 그 행 모양이 바로 위의 INGEST_TESTS 열-test_id 매핑의 근거이며, 각 열이 자신의 시험으로 태깅된 lab.result 행이 됩니다. 그런데 FLEX2는 같은 결과를 OPC UA로도 내보낼 수 있는데, 이는 하나의 물리적 장비가 두 경로에 동시에 올라탈 수 있음을 뜻합니다. 이 감시 폴더로의 파일 드롭(drop), 그리고 "OT의 언어: OPC UA, MQTT, 그리고 Sparkplug B"(7장)의 OPC UA 경로를 통해 히스토리안으로 들어가는 실시간 태그가 그것입니다. 두 경로는 중복이 아닙니다 — OPC UA 태그는 값을 더 빨리 주고, CSV 파일 드롭은 검사 가능한 전체 패널을 줍니다 — 그리고 그 둘을 하나의 시료로 다시 조정하는 일은 이 장이 중심에 둔 바로 그 이중 타임스탬프 문제입니다.

이것을 Roche Cedex Bio HT — 역시 대표적인 업계 예시입니다 — 와 대조해 보십시오. 이것은 대사물질 패널과 IgG 역가(titer) 분석을 함께 돌리는 화학 분석기입니다. 그 내보내기는 네이티브 CSV가 전혀 아닙니다. 탭으로 구분된 .txt 아카이브, 즉 자체 헤더와 열 규칙을 가진 벤더 파일을 기록합니다. 인제스터가 CSV를 가정하는 대신 "CSV나 벤더 파일"을 잡도록 쓰인 이유가 바로 이것입니다 — 위 OfflineDrop.on_created.csv 필터는 쉬운 경우이고, 실제 배포에서는 ingest()가 보기도 전에 Cedex .txt(혹은 다른 어떤 장비의 레이아웃이든)를 동일한 와이드 행 모양으로 정규화하는 작은 벤더별 파서(parser)를 덧붙입니다.

세포 계수기는 이 혼란이 한 벤더의 제품군 안에서조차 얼마나 심해지는지 보여 줍니다. Beckman Coulter Vi-CELL 계열 — 우리의 대표적인 자동 세포 계수기 — 은 두 갈래로 나뉩니다. 더 오래된 Vi-CELL XR.txt.xls/.xlsx 내보내기를 기록하므로 Cedex와 똑같이 파서가 필요하고, 더 새로운 Vi-CELL BLU는 네이티브 CSV를 기록하며, Benchling의 오픈소스 allotropy 파이썬(Python) 라이브러리를 거쳐 그 CSV를 Allotrope ASM JSON으로 정규화할 수 있습니다. (allotropy는 텍스트·엑셀·CSV 내보내기만 ASM으로 정규화하며, 독점 바이너리 형식은 열지 못합니다.) 계수기 둘, 제품 세대 하나, 그 사이에 디스크 형식 셋 — "장비가 CSV를 기록한다"가 결코 안전한 가정이 아닌 이유입니다. ASM 경로 자체 — 벤더 중립적이고 온톨로지(ontology)로 태깅된 결과 파일이 무엇을 안겨 주며 어디서 멈추는지 — 는 "분석 실험실: 기기, LIMS, ELN"(14장)의 주제입니다. 여기서는 깔끔한 네이티브 CSV 장비가 운 좋은 소수라는 점만 알면 충분합니다.

우리는 그것이 떨어지는 순간 watchdog 라이브러리를 사용해 잡아챕니다. watchdogObserverFileSystemEventHandler 조합은 파일시스템 이벤트에 반응하는 표준적인 파이썬(Python) 방식입니다 [9]. 다음은 저장소의 file-ingester 서비스 examples/services/file-ingester/app.py의 핵심입니다(DB 헬퍼와 main() 보일러플레이트는 지면을 위해 생략).

# examples/services/file-ingester/app.py (excerpt)
import pandas as pd
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer

# offline CSV column -> (test_id, unit): the preliminary panel the machine captures
INGEST_TESTS = {
"VCD_e6_per_mL": ("VCD", "e6/mL"),
"glucose_g_L": ("Glucose", "g/L"),
"titer_g_L": ("Titer", "g/L"),
}


class OfflineDrop(FileSystemEventHandler):
def on_created(self, event):
if event.is_directory or not event.src_path.endswith(".csv"):
return
ingest(event.src_path)


def ingest(path: str) -> int:
df = pd.read_csv(path, parse_dates=["sample_time"]) # parse, don't guess
df["sample_time"] = df["sample_time"].dt.tz_convert("UTC") # one canonical zone
with psycopg.connect(DSN, autocommit=False) as conn:
for _, row in df.iterrows():
upsert_sample(conn, row.sample_id, row.batch_id,
row.sample_time.to_pydatetime(), row.sample_point)
for col, (test_id, unit) in INGEST_TESTS.items():
insert_result(conn, row.sample_id, test_id, float(row[col]), unit,
analyst="SVC_INGEST", status="preliminary")
conn.commit()

화려하지 않은 줄들이 정작 중요한 줄들입니다. parse_dates=["sample_time"]tz_convert("UTC")는 pandas의 시계열 처리에 기대어, 장비가 기록한 어떤 로컬 문자열이든 단일한 타임존 인식(timezone-aware) UTC 시점으로 바꿉니다 [10] — 뉴어크(Newark) 작업장의 분석기와 UTC 서버가 언제에 대해 합의해야 하며, 그렇지 않으면 다음 절의 조정은 모래 위에 세워진 셈이기 때문입니다. autocommit=False 연결과 루프 뒤의 단 한 번의 conn.commit()은 파일 전체를 하나의 데이터베이스 트랜잭션(transaction, 엔진이 전부-아니면-전무로 적용하는 작업 단위)으로 감쌉니다 — 어떤 행이라도 실패하면 엔진이 나머지를 되돌리므로, 부분적인 CSV가 한 시료의 결과를 반쪽만 남기는 일이 없습니다. 그리고 인제스트된 모든 행은 status="preliminary"로 기록됩니다. 장비가 값을 포착하지만, 그 값이 출하 결정에 반영되기 전에 사람이 여전히 검증해야 합니다.

어려운 부분: 타임스탬프 조정과 지연·수정 결과 처리

이제 진짜로 어렵고 진짜로 규제적인 작업입니다. 수동 입력과 지연되거나 수정된 데이터는 고전적인 데이터 무결성 위험 영역입니다. FDA의 데이터 무결성 지침은, 당신을 보호하는 것은 원래 값을 기록하는 것, 동시적 타임스탬프를 남기는 것, 그리고 모든 변경에 대해 문서화된 사유를 남기는 것이라고 단호하게 말합니다 [3]. MHRA(영국 의약품 규제 당국)는 여기서 가장 위험에 처한 두 가지 ALCOA 속성을 명시합니다 — ALCOA+는 데이터가 귀속 가능(Attributable)하고, 판독 가능(Legible)하며, 동시적(Contemporaneous)이고, 원본(Original)이며, 정확(Accurate)해야 한다는 규제 당국의 데이터 무결성 체크리스트입니다("+"는 완전성(Complete), 일관성(Consistent), 영속성(Enduring), 가용성(Available)을 더합니다). 동시성(Contemporaneous)(활동이 수행되는 시점에 기록할 것)과 원본성(Original)(전사된 요약이 아니라 최초 포착을 보존할 것)입니다 [4]. 그리고 PIC/S(여러 규제 당국에 걸쳐 조사관을 조율하는 국제 협력 체계) 데이터 관리 가이드는 생애주기 관점을 제공합니다. 수정과 정정은 정상적이고 예상되는 일이지만, 원본이 계속 보이고 변경이 추적 가능하도록 다뤄져야 한다는 것입니다 [5].

결과 생애주기: 채취, 예비, 검증, 수정

우리의 스키마는 단 한 번의 덮어쓰기 없이 이 세 가지를 모두 지키도록 만들어졌습니다. 하나의 오프라인 글루코스 결과 — 해부 카드가 분석한 바로 그 BATCH-2026-001-OFF-003 글루코스 결과 — 가 그 생애를 거치는 모습을 따라가 봅니다.

-- 1. The sample is pulled at 06:00; that moment is recorded immediately.
INSERT INTO lab.sample (sample_id, batch_id, sample_time, sample_point)
VALUES ('BATCH-2026-001-OFF-003', 'BATCH-2026-001',
'2026-01-06 06:00:00+00', 'BR101');

-- 2. The analyzer reports at 06:25; ingester writes a PRELIMINARY value.
INSERT INTO lab.result (sample_id, test_id, value, unit, result_ts, analyst, status)
VALUES ('BATCH-2026-001-OFF-003', 'Glucose', 6.01, 'g/L',
'2026-01-06 06:25:00+00', 'SVC_INGEST', 'preliminary');

-- 3. The analyst reviews and VERIFIES — a NEW row, not an update.
INSERT INTO lab.result (sample_id, test_id, value, unit, result_ts, analyst, status)
VALUES ('BATCH-2026-001-OFF-003', 'Glucose', 6.01, 'g/L',
'2026-01-06 09:10:00+00', 'a.kowalski', 'verified');

두 행 모두 살아남습니다. UNIQUE (sample_id, test_id, result_ts) 제약 조건은 result_ts가 서로 다르기 때문에 둘이 공존하도록 허용하며, 원래의 예비 포착은 결코 파괴되지 않습니다 — 원본성과 동시성, 둘 다 보존됩니다. 진정한 수정(가령 다음 날 잡아낸 전사 오류)도 같은 동작입니다. 수정된 값을 담은 새 행, 새 result_ts, 그리고 — 변경 사유 감사 추적(audit trail)과 변조 방지(tamper-evident) 해시 체인(hash chain)을 추가하는 23장에서는 — 기록된 사유와 서명자를 더하는 것입니다. 이 장은 추가 전용 뼈대를 만들고, 23장이 근육을 붙입니다.

두 개의 타임스탬프, 그리고 그 간극이 각주가 아니라 열인 이유

위 세 개의 INSERT가 조용히 확립하는 것을 보십시오. 시료의 sample_time06:00, 예비 result_ts06:25, 검증 result_ts09:10입니다. 세 개의 시계, 하나의 결과. 자체 제작 시스템에서의 유혹은 타임스탬프 하나만 두고 그것을 "결과의 시각"이라 부르는 것이며 — 바로 그 단 하나의 지름길이 오프라인 데이터를 데이터 무결성 부채로 만듭니다. 그 값이 시료가 대표하는 더 이른 순간이 아니라 기록된 순간의 배치를 특징짓는다고 조용히 단언하기 때문입니다.

스키마는 열 하나가 아니라 둘을 쓰며 그 혼동을 거부합니다. lab.sample.sample_time은 시료에, lab.result.result_ts는 각 결과 행에 속합니다. 둘 다 timestamptz이므로 PostgreSQL은 이들을 절대적 UTC 시점으로 저장하고, 둘 사이의 간극 — PAT 프레임워크가 경고하는 시료-통찰 지연 — 은 전사로 잃어버리는 사실이 아니라 빼고, 거르고, 그래프로 그릴 수 있는 값이 됩니다 [11]. 근실시간 앳라인 측정값은 몇 분의 간극을 보일 수 있고, 위탁 시험소로 보내는 오프라인 안정성 분석은 며칠의 간극을 보일 수 있습니다. 어느 쪽이든 간극은 기록되며, 이것이 검토자가 나중에 동시적 포착을 사후 작성된 것과 구별할 수 있는 유일한 방법입니다. MHRA가 정확히 이 이유로 동시성과 원본성을 위험 목록 최상단에 두는 것입니다 [4]. 값싼 실수는 원본 포착을 덮어쓰거나 잘못된 시계로 그것을 찍는 것입니다.

두 개의 보관 사슬, 하나의 타임라인: 오프라인과 온라인 조정하기

시료 타임스탬프를 온라인 트레이스와 조정하는 일은 이제 깔끔한 조인(join)이 됩니다. 히스토리안이 같은 batch_id를 지니고, 시료가 자신의 sample_time을 지니기 때문입니다.

-- Online DO at (or just after) the moment OFF-003 was pulled.
SELECT s.sample_id, s.sample_time, r.value AS bench_glucose,
(SELECT value FROM ts.sensor_reading t
WHERE t.batch_id = s.batch_id AND t.tag = 'BR101.DO.PV'
AND t.ts >= s.sample_time
ORDER BY t.ts LIMIT 1) AS online_do_at_sample
FROM lab.sample s
JOIN lab.result r ON r.sample_id = s.sample_id AND r.test_id = 'Glucose'
WHERE s.sample_id = 'BATCH-2026-001-OFF-003' AND r.status = 'verified';
sample_id | sample_time | bench_glucose | online_do_at_sample
------------------------+------------------------+---------------+---------------------
BATCH-2026-001-OFF-003 | 2026-01-06 06:00:00+00 | 6.01 | 39.059

06:00에 채취한 벤치 글루코스가 이제 그 동일한 순간의 온라인 용존산소 측정값 옆에 나란히 놓입니다 — 태그 BR101.DO.PV는 바이오리액터 101, 용존산소(dissolved oxygen), 공정값(process value)으로 읽히며, 7장이 도입한 규칙입니다. 두 개의 보관 사슬, 하나의 타임라인 — 이것이 오프라인 데이터를 스프레드시트에 고립시켜 두지 않고 제대로 포착하는 일의 전부입니다.

같은 행, 그래프가 추론할 수 있는 트리플로

관계형 lab.result 행은 기록의 시스템이지만, 행이 아니라 사실(fact)로 보기 시작하면 그것은 또한 RDF(Resource Description Framework) 그래프의 원자인 주어–술어–목적어 트리플(triple)의 묶음이기도 합니다. 그 재해석은 지식 그래프 장이 캠페인 전체에 대해 하는 일이며, 오프라인 패널이야말로 디지털 스레드(digital thread)가 가장 필요로 하는 데이터이므로 여기서 미리 보여 줄 만합니다. 검증된 글루코스 결과는 세 개의 트리플(triple)로 읽히며, Turtle(사람이 읽기 좋은 RDF 텍스트 형식)로 쓰면 각 값이 QUDT 단위 IRI를 지녀 6.01이 결코 문자열에 붙인 맨숫자가 되지 않습니다.

@prefix bp: <https://example.org/bioproc#> .
@prefix qudt: <http://qudt.org/schema/qudt/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .

bp:BATCH-2026-001-OFF-003 bp:fromBatch bp:BATCH-2026-001 ; # s88.batch FK, an edge
bp:sampleTime "2026-01-06T06:00:00Z"^^xsd:dateTime .
bp:RESULT-OFF-003-Glu bp:ofSample bp:BATCH-2026-001-OFF-003 ;
bp:glucose "6.01"^^xsd:float ; # qudt:unit grams-per-litre
bp:resultStatus "verified" .

batch_id 외래 키는 bp:fromBatch 걸어 다닐 수 있는 엣지(edge)가 되며, 그래서 이 스키마가 관계형으로 강제하는 시료-배치 계보가 SPARQL 속성 경로(property path)가 한 홉(hop)에 순회하는 바로 그 derivedFrom/fromBatch 계보입니다. 세 개의 개방형 어휘가 그 용어들에 로컬 전용이 아닌 공유된 의미를 부여합니다. Allotrope ASM(위 allotropy 경로가 내보내는 벤더 중립 JSON)은 결과·장비·시료를 Cedex에서 왔든 위탁 시험소에서 왔든 똑같이 태깅하고, IOF/BMIC(Industrial Ontologies Foundry의 바이오의약품 콘텐츠로, ISO/IEC 21838-2 BFO 상위 온톨로지에 근거)는 배치와 물질을 유형화하며, QUDT는 수량과 단위를 유형화합니다 — 클래스와 분류 체계식별자와 단위 장이 구축하는 정렬이고, 관계와 계보 장이 derivedFrom을 전이적(transitive) 엣지로 만드는 데 쓰는 바로 그 스택입니다.

데이터베이스가 제약으로 강제하는 것을 그래프는 SHACL(Shapes Constraint Language — 트리플에 대한 닫힌 세계 게이트)로 강제합니다. batch_id REFERENCES s88.batch 외래 키와 status 생애주기는 노드 형상(node shape)이 됩니다. 모든 결과는 정확히 하나의 실제 시료를 가리키고, 단위를 지니며, 통제된 집합에서 뽑은 상태를 지녀야 합니다 — 출하 게이트 장이 CofA 패널에 대해 모델링하는 바로 그 패턴이며, 거기서 누락된 필수 시험은 열린 질문이 아니라 지금 곧 실패입니다.

# 예시 — 관계형 제약을 닫힌 세계 SHACL 형상으로 다시 표현한 것.
@prefix bp: <https://example.org/bioproc#> .
@prefix sh: <http://www.w3.org/ns/shacl#> .

bp:ResultShape a sh:NodeShape ;
sh:targetClass bp:LabResult ;
sh:property [ sh:path bp:ofSample ; sh:minCount 1 ; sh:maxCount 1 ;
sh:class bp:Sample ; # genealogy, as a constraint
sh:message "A result must attach to exactly one real sample." ] ;
sh:property [ sh:path bp:resultStatus ; sh:minCount 1 ;
sh:in ( "preliminary" "verified" "rejected" ) ] .

이는 이 장의 두-스트림 조인(join)을 역량 질문(competency question) — 어휘가 반드시 답할 수 있어야 하는 질문이자, 4권이 역량 질문 장에서 실행 가능한 PASS/FAIL 검사로 바꾸는 수용 시험 단위 — 으로 만듭니다. "검증된 글루코스로 배양은 어디에 있었고, 같은 채취 순간의 용존산소는 얼마였나?"는 위 SQL 조인의 SPARQL 쌍둥이이고, "어떤 랩 결과가 검증된 행을 갖지 않는가?"는 SHACL 게이트의 부정형입니다. 정직한 범위 단서는 그래프 장의 것과 같습니다. 글루코스 같은 단일 유형 스칼라는 트리플로 깔끔하게 매핑되지만, 원시 스펙트럼이나 크로마토그램은 그래프가 IRI로 가리키는 참조된 Allotrope/AnIML 파일로 남습니다 — 스레드의 색인이지, 그 위 모든 배열의 창고가 아닙니다. 그리고 두 타임스탬프 모두 이 들어올림에서 살아남습니다. sample_timeresult_ts는 유형이 지정된 xsd:dateTime 리터럴이 되므로 PROV-O 방식 출처(provenance, 누가 무엇을 언제 기록했는가)가 묻히지 않고 질의 가능해집니다 — 4권의 다음 장들이 거버닝하는 그래프 안에서 표현된 귀속 가능(Attributable)과 동시성(Contemporaneous) 속성입니다.

왜 중요한가

공정 과학자는 온라인 태그가 아니라 오프라인 패널로 세포배양의 방향을 잡습니다. 글루코스와 락테이트는 피드 전략을 결정하고, VCD와 생존율은 수확(harvest) 시점을 결정하며, 역가는 캠페인 전체가 평가받는 숫자입니다. 그런데 이것이 가장 잘못 다뤄지기 쉬운 데이터입니다. 사람이 손대는 데이터이기 때문입니다. 엉뚱한 배치에 입력된 값, 사후에 추측한 시료 시각, 조용히 덮어써진 규격 외 결과 — 이것들이 바로 규제 경고장(warning letter, 조사에서 심각한 위반이 드러났을 때 규제 당국이 발부하는 공식 집행 통지)을 채우는 지적 사항입니다. 원본이 보존되고, 두 타임스탬프가 구별되며, 배치 링크가 단단한 외래 키가 되도록 포착 경로를 구축하면, 공장에서 가장 위험한 데이터가 가장 방어 가능한 데이터로 바뀝니다.

경고장이 실제로 말하는 것

이것은 가설적 위험이 아니라 측정된 위험입니다. 2010년부터 2020년까지 제약 회사에 발행된 모든 FDA 경고장을 다룬 회고적 분석은, 세 가지 지배적인 cGMP(current Good Manufacturing Practice, 의약품이 어떻게 제조되어야 하는지를 정의하는 현행 우수 제조 관리 기준 규정) 결함 범주 가운데 문서화와 데이터 무결성 지적이 cGMP 경고장의 약 21%를 차지했으며 — 공정 검증(26%)에만 뒤지고 품질 관리(15%)를 앞섰고 — 문서화는 평균적으로 경고장의 약 20–25%에서 주요 결함으로 인용되었다는 것을 발견했습니다 [12]. 그 지적들 뒤에 있는 메커니즘이야말로 이 스키마가 맞서 만들어진 것들입니다. FDA의 데이터 무결성 지침은 보호책이 원래 값을 동시적 타임스탬프와 모든 변경에 대한 문서화된 사유와 함께 기록하는 것이라고 단호하게 말하지만 [3], 조사관들은 계속 그 반대를 발견합니다. 동시적으로 기록되지 않은 결과, 덮어써진 원본 포착, 그리고 정밀 검토를 견디지 못하는 타임스탬프 — 정확히 MHRA의 동시성과 원본성 속성이 현장에서 무너지는 모습입니다 [4]. 두 개의 구별된 timestamptz 열과 batch_id 외래 키를 가진 추가 전용 lab.result는 과잉 설계가 아닙니다. 그것은 가장 흔한 실험실 기록 인용 범주에 대한 공학적 해답입니다.

이는 또한 모든 하위 단계의 그림을 완성합니다. 이제 온라인과 오프라인 데이터가 둘 다 batch_id와 신뢰할 수 있는 시각을 지니기 때문에, 단 한 번의 조인(join)으로 둘을 조정할 수 있습니다 — 컨텍스트화(contextualization) 뷰(17장)가 온라인 스트림에 대해 하는 바로 그 동작을, 여기서 오프라인 패널로 확장한 것입니다. 29장의 소프트 센서(soft-sensor) — 측정하기 어려운 값을 측정하기 쉬운 실시간 신호로부터 예측하는 모델로, 여기서는 라만(Raman, 빛 산란 분광 프로브) 측정값을 역가 추정으로 바꿉니다 — 는 오프라인 역가를 학습 라벨(label, 모델이 재현하도록 학습하는 알려진 참값)로 필요로 합니다. 잘못 연결된 시료 하나가 모델을 오염시킵니다. 오프라인 데이터는 히스토리안에 대한 각주가 아닙니다. 그것은 나머지 절반입니다.

모델링 장이 이 스키마에 기대는 이유

이 작은 랩 스키마의 세 가지 속성은 단지 컴플라이언스 장부 기록이 아닙니다. 그것들은 방어 가능한 소프트 센서를 애초에 가능하게 만드는 것이며, 5권은 그 하나하나에 의존합니다. 첫째, batch_id 외래 키가 모델을 정직하게 검증할 수 있게 합니다. 스펙트럼은 모델에 701개의 공선(collinear) 열을 주지만, 유가식 운전 하나는 진정으로 독립적인 관측 하나를 줍니다. 그래서 역가 센서를 평가하는 유일하게 건전한 방법은 그룹화된 리브-원-배치-아웃(leave-one-batch-out) 분할 — 다섯 배치로 학습하고 따로 떼어 둔 여섯 번째 배치 전체로 시험하며, 한 운전의 행을 학습/시험 경계를 가로질러 결코 섞지 않는 것 — 입니다. 대신 무작위 행을 분할하면 누수(leak)됩니다. 한 배양에서 몇 시간 간격으로 뽑은 두 시료는 거의 동일하므로, 행 단위 분할은 모델이 나중에 "예측"하는 배치를 외워 버리게 하고, 보고된 R²가 스스로를 부풀립니다. 이 스키마가 단단한 외래 키로 강제하는 batch_id가 바로 그 분할이 필요로 하는 그룹화 키입니다 — 모델과 검증 장이 정확히 이 열 위에 배치 그룹화 분할을 구축하고, 학습 문제 장이 결속 제약은 행 수가 아니라 독립적 배치의 수라는 콜드스타트(cold-start) 현실을 명명합니다.

둘째, 두-스트림 조인은 곧 일어날 적용 범위(applicability domain) 검사입니다. 잠긴(locked) 소프트 센서는 자신이 보정된 입력 범위 안에서만 신뢰할 수 있습니다. 실시간 스펙트럼이 학습 세트가 결코 보지 못한 구조를 담는 순간, 그 숫자는 신뢰가 아니라 플래그가 되어야 합니다. 그 라벨 없는 게이트 — 모델 평면에서 떨어진 거리를 재는 호텔링 T²와 제곱 예측 오차(SPE) — 는 온라인 스트림에서 돌지만, 그것을 접지(ground)하는 것은 오프라인 패널입니다. 하루 두 번 내려앉는 검증된 역가가 센서의 드리프트가 진짜인지 알려 주는 기준입니다. 모델과 검증 장이 그 적용 범위 게이트를 PLS 센서에 배선하고, 이 스키마가 그 진실의 출처를 공급합니다.

셋째, sample_time/result_ts 간극이 공정 드리프트와 모델 드리프트의 차이입니다. 라만 역가 예측이 벤치 기준과 어긋날 때, 질문은 배양이 움직였는지(모델이 올바르게 추적한 실제 공정 이탈)인지 아니면 모델이 낡았는지(프로브 파울링(fouling), 새 배지 로트 — 진짜 모델 드리프트)입니다. 둘을 구별하는 유일한 방법은 각 채취 순간에서 모델-빼기-기준 잔차(residual)를 차트로 그리는 것이고, 이는 벤치 결과가 측정된 순간이 아니라 시료가 대표하는 순간에 닻을 내려야 함을 요구합니다 — 바로 이 스키마가 두 열을 들여 지키는 이중 타임스탬프 규율입니다. MLOps와 생애주기 장이 그 잔차 관리도(그리고 그 선행 지표 짝인, 입력에 대한 모집단 안정성 지수(Population Stability Index))를 이 포착 경로가 만들어내는 오프라인 접지점 위에 구축하고, 각 모델 버전을 이 행들이 이루는 **바로 그 데이터셋의 sha256**에 고정합니다 — "어떤 데이터가 이것을 학습시켰나?"를 추측이 아니라 해시로 만드는 모델 계보 기록입니다. 정직한 한계는 그 장이 명명하는 것입니다. 그 오프라인 접지점은 하루에 한두 번만 도착하므로, 잔차 관리도는 드리프트가 시작된 며칠 뒤에야 낡아가는 모델을 잡아냅니다 — 바로 그래서 이 스키마의 임무는 그 성기고, 귀하고, 사람 손을 탄 결과 하나하나를 신뢰할 수 있게 만드는 것입니다.

실제 현장에서는

가동 중인 바이오의약품 제조 공장에서 이 경로는 LIMS나 실험실 실행 시스템이 소유하며, 장비는 흔히 미들웨어(middleware)를 통해 연결되어 원시 파일과 분석가의 검증 단계를 포착합니다. ASTM E1578 가이드가 그 지형의 지도이며, 솔직히 말하자면 그 오픈소스 영역은 빈약합니다 [2]. 우리는 인제스터와 스키마를 순수 오픈소스 소프트웨어(open-source software, OSS) — 파이썬, watchdog(Apache-2.0), pandas(BSD), PostgreSQL — 로 구축할 수 있고, 그것은 노트북에서 결정론적으로 작동합니다. 순수 OSS가 건네주지 못하는 것은, 내장된 제2자 검토(second-person review, 자격 있는 두 번째 사람이 첫 번째 사람의 입력을 확인하고 서명) 워크플로(workflow)와 21 CFR Part 11(전자 기록과 전자 서명을 규율하는 FDA 규칙) 전자 서명(electronic signature)을 기본 제공하는, 검증되고 벤더 책임이 명확한 장비 인터페이스 계층입니다. 우리가 나중에 사용하는 OSS LIMS인 SENAITE는 유능한 교육용 시스템이지만, 그 유일하게 공개된 Part-11 갭(gap) 분석은 2019년 것이며 실제 갭(전자 서명, 보존, 패스워드 통제)을 나열합니다 — 그래서 이 책은 그 갭 목록을 정직한 한계로 함께 제시하고, 컴플라이언스를 주장하는 대신 SENAITE를 별도의 서명 서비스와 짝지어 둡니다. 여기 추가 전용 lab.result 테이블은 OSS로 깔끔한 약 80% — 올바르고, 검사 가능하며, Git 안에 있습니다. 그 주위를 감싸는 검증된 검토-서명 래퍼(wrapper)가 GxP의 마지막 1마일이며, 그것은 하이브리드입니다 — 어느 한쪽만이 아니라, 오픈소스 코어에 독점적이고 검증된 서명 계층을 짝지은 것입니다.

이곳이 또한 다중 벤더 시설이 가장 먼저 고통을 느끼는 지점입니다. 여러 스키드(skid)와 여러 벤치 분석기로 꿰매어진 파일럿(pilot) 규모 cGMP 라인은 누군가 운전에 대해 추론할 수 있기 전에 열두 대의 장비에서 나온 오프라인 결과가 모두 하나의 배치와 하나의 시료 시각으로 조정되어야 하는 바로 그런 곳이며, 이 DCS 바깥 포착 경로가 봉사하도록 만들어진 종류의 물리적 환경입니다.

핵심 용어

  • 시드 트레인(seed train) — 해동한 바이알에서 점점 더 큰 용기를 거쳐 생산 바이오리액터까지 세포를 단계적으로 확장하는 과정. 그 데이터의 상당 부분이 수기로 기록된다.
  • 오프라인/앳라인/온라인/인라인(off-line / at-line / on-line / in-line) — 값을 얻는 위치와 속도에 따른 PAT 측정 분류 체계. 오프라인(시료를 별도 실험실로), 앳라인(시료를 몇 분 내 근처 분석기로), 온라인(루프 안의 센서), 인라인(배양액 안의 프로브).
  • VCD(viable cell density, 생존세포밀도) — mL당 살아 있는 세포 수. 수동 또는 자동 계수로 측정하는 주요 오프라인 세포배양 측정값.
  • 대사물질 패널(metabolite panel) — 글루코스, 락테이트, 글루타민, 암모늄, 삼투압. 피드와 수확 결정을 좌우하는 오프라인 분석물질들.
  • 역가(titer) — 축적된 제품(항체) 농도. 캠페인이 평가받는 오프라인 결과이자 소프트 센서의 학습 라벨.
  • sample_timeresult_ts — 시료가 채취된 순간 대 그 결과가 기록된 순간. 서로 다른 사건이므로 별개의 timestamptz 열로 저장된다.
  • 예비/검증 결과(preliminary / verified result) — 장비가 보고한 값과 사람이 확인한 값을 원본을 덮어쓰지 않고 포착하는 두 행 패턴.
  • 시료-배치 계보(sample-to-batch genealogy) — 각 랩 결과를 그것이 특징짓는 특정 배치와 로트에 묶는 ISA-88 기반 연결(batch_id 외래 키).
  • CofA(certificate of analysis, 시험성적서) — 규격에 대해 PASS/OOS로 판정되는 출하 분석 행 세트(SEC, CEX, HCP, 잔류 Protein A, 엔도톡신).
  • DOE / 설계 공간(design space)(QbD) — 실험 설계(DOE)는 공정을 지도화하기 위해 (흔히 병렬 마이크로 바이오리액터에서) 돌리는 입력 조합의 구조화된 격자이고, 설계 공간은 규격 내 제품을 신뢰성 있게 산출하는 입력의 QbD 영역임. 그 네이티브 운전 테이블은 넓고 짧음(인자 열과 반응 열, 운전 하나당 한 행).
  • .mip — BioPAT MODDE DOE 패키지의 독점 바이너리 네이티브 파일. Cedex .txt처럼 개방형 랩 스키마가 읽으려면 먼저 내보내야 함 — DOE 데이터는 본래 CSV가 아님.
  • ALCOA+(원본성, 동시성)(Original, Contemporaneous) — 규제 당국의 데이터 무결성 체크리스트(귀속 가능·판독 가능·동시적·원본·정확, 그리고 완전·일관·영속·가용). 수동·지연 결과에서 가장 위험에 처한 두 속성은 원본성(최초 포착을 보존하라)과 동시성(시점에 맞춰 기록하라)이다.
  • result_id(대리 키, surrogate key) — 엔진이 모든 lab.result 행에 발급하는 bigint GENERATED ALWAYS AS IDENTITY 기본 키. GENERATED ALWAYS는 사람이 값을 공급하거나 덮어쓰는 것을 막아, 각 결과에 감사 추적용의 안정적 핸들을 부여한다. 진짜 비즈니스 키는 수정 행이 공존하도록 허용하는 더 넓은 UNIQUE (sample_id, test_id, result_ts) 튜플이다.
  • INGEST_TESTS — 파일 인제스터의 열-test_id 매핑(VCD_e6_per_mL → VCD, glucose_g_L → Glucose, titer_g_L → Titer)으로, 와이드 CSV의 어느 열이 preliminary 결과로 자동 포착될지를 선택한다. 패널의 나머지는 분석가를 기다린다.
  • RDF 트리플 / SHACL / 역량 질문(competency question) — 같은 lab.result 행을 주어–술어–목적어 사실로 다시 읽은 것. batch_id 외래 키는 걸어 다닐 수 있는 fromBatch 엣지가 되고, status 생애주기는 SHACL 닫힌 세계 게이트가 되며, 두-스트림 조인은 SPARQL 역량 질문이 된다 — 지식 그래프 장에서 Allotrope ASM, IOF/BMIC, QUDT에 정렬된다.
  • 그룹화된 / 리브-원-배치-아웃 분할(leave-one-batch-out split) — 무작위 행이 아니라 배치 전체를 (batch_id로 그룹화하여) 떼어 두어 소프트 센서를 검증하는 것. 한 배양에서 나온 두 시료가 서로 누수되기 때문이며, 작고 공선적인 배치 데이터에서 유일하게 정직한 평가다.
  • 적용 범위(applicability domain) — 잠긴 모델이 보정된 입력 범위. 그 바깥의 실시간 스펙트럼(호텔링 T²/SPE 게이트가 잡아냄)은 플래그되어야 하며, 오프라인 패널이 그 검사를 접지하는 기준이 된다.
  • 공정 드리프트 대 모델 드리프트(process drift vs. model drift) — 이중 타임스탬프 간극은 라만 예측과 벤치 기준 사이의 어긋남을 배양이 실제로 움직인 탓(공정)인지 모델이 낡은 탓(프로브 파울링, 새 로트)인지 귀속할 수 있게 한다. sample_time에 닻 내린 잔차-대-기준 차트만이 둘을 분리한다.

다음 이야기

우리는 이제 상류(upstream) 진실의 두 절반 — 스트리밍 DCS 태그와 성기고 사람 손을 탄 오프라인 패널 — 을 모두 포착하여 하나의 배치 타임라인에 묶었습니다. 하지만 모든 신호가 현대적인 프로토콜이나 깔끔한 CSV로 도착하는 것은 아닙니다. 다음 장 레거시 및 상용 스키드 연결하기: Modbus, Siemens S7, PLC4X는 공장의 가장 오래되고 가장 고집스러운 계층으로 내려가, 데이터가 레지스터 맵(register map)과 독점 PLC 프로토콜 뒤에 숨어 있는 곳에서, 그것을 오픈소스 드라이버로 동일한 히스토리안과 동일한 배치 모델 안으로 끌어들이는 방법을 보여 줍니다.