충전·포장과 환경 모니터링
📍 현재 위치: 2부 공정을 포착하기. 분자는 이미 배양되고, 정제되고, 폴리싱(polishing)되고, 제제화되었습니다. 이제 우리는 그것이 거치는 가장 마지막 산업 공정 — 바이알(vial)로, 카톤(carton)으로, 케이스(case)로 들어가는 과정 — 을 따라가며, 그 주위의 공기를 지켜봅니다. 여기서부터 데이터는 더 이상 제품에 관한 것이 아니라 낱개(units)와 공간(space)에 관한 것이 됩니다.
음료 충전 공장의 마지막 조립 라인을 떠올려 보되, 그 "음료"는 만드는 데 막대한 비용이 들었고 공기 중에 떠다니는 티끌 하나가 한 배치(batch) 전체를 망칠 수 있다고 상상해 보세요 — 이것은 무균적으로(aseptically) 충전되는 멸균 주사제이고, 밀봉된 바이알을 나중에 멸균할 수 있는 단계가 없으므로, 어떤 오염물이든 그대로 출하되기 때문입니다. 세 권의 장부가 나란히 돌아갑니다. 하나는 바이알을 세고 각각의 무게를 답니다(충전·포장, fill-finish). 하나는 모든 바이알에 고유하고 스캔 가능한 번호판(license plate)을 발급하고, 그것이 어느 카톤과 케이스에 들어갔는지 기록합니다(시리얼라이제이션, serialization). 그리고 하나는 결코 잠들지 않는 공기 질 모니터로서, 클린룸(cleanroom)에서 먼지와 미생물의 냄새를 맡습니다(환경 모니터링, environmental monitoring). 이 장에서는 실제 코드로 이 세 권의 장부를 모두 커밋된 CSV 골든(golden)으로 생성하며 — 플랫폼 스키마는 이미 그것들을 받을 준비가 되어 있고 로더(loader)는 뒤의 장에서 구축합니다 — 그런 다음 규제 당국이 검사할 기록과, 그저 엔지니어를 위한 설비 텔레메트리(telemetry) 사이에 선을 긋습니다.
이 장에서 다루는 내용
- 충전 라인(fill line): 바이알당 충전 부피, 공정 관리(in-process control, IPC) 체크웨이(checkweigh), 그리고 PackML 기계 상태 모델이 지배하는 리젝트(reject) 로직.
- 시리얼라이제이션과 집약(aggregation): GS1 SGTIN "번호판"과 바이알 → 카톤 → 케이스 부모/자식(parent/child) 트리.
- 환경 모니터링(environmental monitoring, EM): EU GMP 부속서 1(Annex 1) 등급 A/B/C 한계에 대한 비생존 입자(non-viable particle) 계수와 생존 CFU, 그리고 의도적으로 심은 이탈(excursion) 하나.
- 단단한 GxP 경계(boundary)가 어디에 떨어지는지 — 그리고 설비 대시보드에 쓸 법한 바로 그 Telegraf-와-VictoriaMetrics 도구가 왜 기록 시스템(system of record, 규제 기록의 그 공식 버전이 되는 단일한 권위 있는 감사 저장소)이 되는 것은 허용되지 않는지.
이 장 전체는 단 하나의 시뮬레이터 파일 examples/sim/bioproc_sim/em_fill.py로 돌아가며, 이 파일은 서로 연결된 세 개의 데이터셋과 PackML 로그를 만들어냅니다. 아래의 모든 내용은 그 실제의 결정론적(deterministic) 코드에서 나오며 — 바이트 단위 재현성을 위해 datasets/MANIFEST.sha256에 매니페스트로 고정되어 있습니다.
충전 라인: 세고, 무게 달고, 거부하기
충전·포장은 겉보기에는 단순하지만 가차 없습니다. 펌프가 각 바이알에 목표 부피를 정량 주입하고, 체크웨이 스테이션이 그 무게를 답니다. 공차(tolerance)를 벗어나는 것은 무엇이든 거부됩니다. 데이터는 고카디널리티(high-cardinality)입니다 — 서로 다른 식별자가 엄청나게 많다는 뜻으로, 여기서는 반복되는 몇 개의 태그 이름이 아니라 바이알마다 고유한 일련번호 하나입니다 — 즉 태그당 한 행(앞 장들의 타임스탬프당 한 행짜리 센서 신호)이 아니라 바이알당 한 행이며 — 그리고 GxP입니다, 즉 Good-Practice 규제 기록입니다(GxP는 보건 당국이 검사할 수 있는 기록을 규율하는 "Good x Practice" 규제군 — 제조·시험·유통 관리 기준, GMP·GLP·GDP 등 — 을 아우르는 우산 용어입니다). 리젝트 결정이 곧 품질 결정이기 때문입니다.
시뮬레이터는 480개의 바이알을 6초 주기로 충전하며, 목표는 1.0 mL입니다. examples/sim/bioproc_sim/em_fill.py에서 가져온 코드입니다.
# examples/sim/bioproc_sim/em_fill.py
def fill_events(batch_id: str = "BATCH-2026-001") -> pd.DataFrame:
rng = stream_rng("fill", batch_id)
rows = []
for i in range(1, N_VIALS + 1):
ts = FILL_START + pd.Timedelta(seconds=i * 6)
vol = float(np.clip(rng.normal(TARGET_FILL_ML, 0.020), 0.90, 1.10))
weight = round(vol * 1.01 + rng.normal(0, 0.004), 4) # ~1.01 g/mL formulation
# serial: SGTIN-style (GTIN + serial)
serial = f"{GTIN}.{i:07d}"
low, high = 0.95, 1.05
reject = not (low <= vol <= high)
rows.append({
"batch_id": batch_id, "vial_serial": serial, "ts": ts,
"fill_volume_mL": round(vol, 4), "fill_weight_g": weight,
"ipc_checkweigh_g": weight, "reject": bool(reject),
"reject_reason": "low_fill" if vol < low else ("high_fill" if vol > high else None),
})
return pd.DataFrame(rows)
여기서 잠시 짚어볼 만한 것이 세 가지 있습니다. 첫째, stream_rng("fill", batch_id)는 SIM_SEED=2026에서 파생된 자체의 재현 가능한 난수 스트림을 충전 라인에 부여하므로, 아래의 바이트 단위까지 동일한 숫자들이 모든 컴퓨터와 CI에서 똑같이 나옵니다. 둘째, IPC 체크웨이는 별도로 기록되는 값입니다 — 실제 라인에서 체크웨이어는 충전기와는 다른 계측기이며, 둘 다를 포착하면 정량 주입된 부피와 측정된 무게를 대조할 수 있습니다. 셋째, 리젝트 규칙은 더 넓은 물리적 클립(clip) 범위(0.90–1.10 mL) 안의 명시적이고 좁은 밴드(0.95–1.05 mL)입니다. 충전된 일부 바이알은 물리적으로는 가능하지만 상업적으로는 용납되지 않으며, 라인은 그것들을 거부합니다.
커밋된 골든(golden) 데이터는 examples/datasets/fill_events.csv에 있습니다(CI 스모크 테스트용으로 50행짜리 fill_events.sample.csv도 커밋되어 있습니다). 첫 몇 행입니다.
batch_id,vial_serial,ts,fill_volume_mL,fill_weight_g,ipc_checkweigh_g,reject,reject_reason
BATCH-2026-001,00361414000017.0000001,2026-01-22 08:00:06+00:00,0.9984,1.0032,1.0032,False,
BATCH-2026-001,00361414000017.0000002,2026-01-22 08:00:12+00:00,1.0116,1.0203,1.0203,False,
BATCH-2026-001,00361414000017.0000003,2026-01-22 08:00:18+00:00,0.9936,0.9923,0.9923,False,
그리고 거부된 바이알 하나 — 152번 바이알은 0.9463 mL로 정량되어, 0.95 mL 하한 아래입니다.
BATCH-2026-001,00361414000017.0000152,2026-01-22 08:15:12+00:00,0.9463,0.9559,0.9559,True,low_fill
저 단 하나의 True는 검사관이 물어볼 수 있는 기록입니다. 왜 부족했는가? 그 리젝트는 물리적으로 분리·배출되었는가? 작업 종료 시점에 수량이 대조·확인되었는가? 데이터 모델은 이런 질문들에 답할 수 있게 만들어져야 합니다.
충전 기록 해부: 바이알 하나가 담는 것
바이오리액터 태그는 같은 숫자를 반복해서 표본화한 것이지만, 충전 기록은 그 정반대입니다 — 낱개당 한 행이며, 그 낱개는 출하되거나 폐기되는 물리적 객체입니다. fill_events의 한 행을 필드별로 해부해 볼 가치가 있습니다. 모든 열이 핵심을 떠받치고 있고, 거부된 152번 바이알이 데이터셋에서 가장 유익한 표본이기 때문입니다.
fill_events의 한 행을 해부: 위쪽에 신원과 정량, 강조된 리젝트 결정, 그리고 그것을 결정한 두 개의 중첩된 공차 밴드.
저자가 AI의 도움을 받아 직접 제작한 그림입니다.
위에서 아래로 읽으면, 이 행은 하나의 완결된 이야기를 들려줍니다. batch_id는 조인 키 — 이 바이알 행을 GMP(Good Manufacturing Practice, 제조 관리 기준) 배치 기록(ISA-88 배치 표준을 본뜬 s88.batch)으로 되돌아가 잇는 공유 값 — 이므로, 이 바이알은 결코 고아가 아닙니다. vial_serial은 낱개의 신원으로 — 다음 절에서 해독할 SGTIN입니다. ts는 충전 시각을 2026-01-22 08:15:12+00에 놓는데, 정확히 151번 바이알보다 6초 뒤입니다. 시뮬레이터가 바이알 i에 대해 FILL_START + pd.Timedelta(seconds=i * 6)를 전진시키기 때문입니다. 그다음 중요한 세 개의 수치 측정값이 옵니다. fill_volume_mL = 0.9463(정량 주입된 부피), fill_weight_g = 0.9559(약 1.01 g/mL 제제 밀도에서 측정된 질량), 그리고 ipc_checkweigh_g = 0.9559 — 여기서는 시뮬레이터의 체크웨이가 무게를 재보고하기에 같은 값이지만, 실제 라인에서는 별도 계측기의 판독값이며, 정량된 부피와 측정된 무게를 대조할 수 있도록 바로 그 목적으로 포착됩니다.
결정적인 필드는 reject입니다. 152번 바이알의 경우 True이고 reject_reason은 low_fill인데, 0.9463 mL가 0.95 mL 하한 아래로 떨어졌기 때문입니다. 그것은 텔레메트리가 아니라 품질 결정이며, 이 장 전체가 그것을 품질 결정으로 다루는 데 달려 있습니다. 아래 패널의 두 공차 밴드가 그것을 만들어낸 논리입니다. 좁은 상업적 리젝트 밴드(0.95–1.05 mL)가 np.clip이 강제하는 더 넓은 물리적 클립(0.90–1.10 mL) 안에 중첩되어 있습니다. 바이알은 물리적으로는 가능하지만 상업적으로는 용납되지 않을 수 있으며, 이 바이알이 그러했습니다 — 이것은 이번 작업이 거부하는 두 개의 바이알 중 하나이며(다른 하나는 0000342번으로, 역시 low_fill), 480개 중 478개가 합격으로 남습니다. reject가 True이기 때문에 그 결과가 연쇄됩니다 — 곧 보겠지만, 이 일련번호는 집약 트리에서 완전히 배제됩니다.
PackML: 라인에는 상태가 있고, 그 상태가 곧 데이터다
충전 라인은 그저 바이알의 흐름이 아닙니다 — 그것은 Idle, 그다음 Starting, 그다음 Execute, 때로는 Held, 그다음 Completing 상태에 있는 기계입니다. 그 생애주기는 표준화되어 있습니다. PackML(OMAC 기계 상태 모델로, OPC Foundation이 OPC UA for PackML / OPC 30050으로 발행)은 유한 상태 기계(finite state machine, 이름이 붙은 정해진 상태 집합과 그 사이에 허용된 일부 전이만 가능한 모델)와, 적합한 포장 기계가 OPC UA(7장에서 소개한 개방형 산업용 기계-소프트웨어 통신 표준)를 통해 노출하는 일련의 "PackTags" — 이름이 붙은 값들로, 여기서는 Command, Status, Admin 태그 — 를 정의합니다 [1]. Admin PackTags는 생산 수량과 알람 통계가 들어 있는 곳으로, 이는 정확히 이 장이 관심을 두는 리젝트와 IPC 텔레메트리입니다 [2]. PackML은 ISA-88의 절차 상태 모델에서 파생되었기에, 여러분이 이미 구축한 배치-및-장비(batch-and-equipment) 모델에 깔끔하게 들어맞습니다.
시뮬레이터는 정규(canonical) 상태 시퀀스를 내보냅니다. examples/sim/bioproc_sim/em_fill.py에서 가져온 코드입니다.
# examples/sim/bioproc_sim/em_fill.py
PACKML_STATES = ["Idle", "Starting", "Execute", "Holding", "Held",
"Unholding", "Execute", "Completing", "Complete", "Resetting", "Idle"]
def packml_log(batch_id: str = "BATCH-2026-001") -> pd.DataFrame:
rows = []
t = FILL_START - pd.Timedelta(minutes=10)
for st in PACKML_STATES:
rows.append({"batch_id": batch_id, "ts": t, "unit": "FILL-LINE-01",
"packml_state": st})
t += pd.Timedelta(minutes=5)
return pd.DataFrame(rows)
위 packml_state 필드는 사실 PackTag 하나 — Status 패밀리의 현재 상태 멤버인 Status.StateCurrent — 일 뿐이며, 시뮬레이터는 라인 정지 이야기에 필요한 상태 시퀀스만 포착하므로 그것만 담습니다. 적합한 기계는 나머지 집합도 노출합니다. 운영자나 라인 제어기가 써 넣는 Command 태그(Command.UnitMode, 시작/홀드/리셋을 위한 Command.CntrlCmd, Command.MachSpeed), 기계가 읽어 내보내는 나머지 Status 태그(모드, 속도, 인터록), 그리고 위 주장이 가리키는 생산 수량과 알람 통계가 실제로 사는 Admin 카운터입니다.
Status.StateCurrent → "Held" # 시뮬레이터가 packml_state로 내보내는 그 하나의 멤버
Admin.ProdProcessedCount[].Count # 누적 양품 바이알 수량
Admin.ProdDefectiveCount[].Count # 리젝트 수량 — 이 장이 관심을 두는 리젝트 텔레메트리
Admin.AlarmHistory[].Message # 알람 통계
그래서 위 packml_log는 의도적으로 상태 멤버로 축약되어 있습니다. 이 장에서 그 Admin 카운터들은 Admin PackTags로 되읽는 대신 따로 모델링됩니다 — fill_events의 리젝트 행과 events.equipment_state 로그로 말이죠. 실제 라인에서는 상태 멤버와 Admin 카운터가 제어기에서 함께 도착하며, 그것이 바로 다음 절이 읽어 들이는 것입니다.
PackML 상태 기계: 기록된 라인 정지
작업이 첫 번째 바이알보다 10분 먼저 Idle → Starting으로 시작하여, 작업 도중에 Holding/Held/Unholding 이탈 — 라인 정지, 즉 모든 충전 현장이 두려워하는 바로 그 일 — 을 거친다는 점에 주목하세요. 이 시퀀스는 임의적이지 않습니다. PackML의 상태 모델은 유한 상태 기계로, Holding은 전이적인 "대기" 상태, Held는 운영자가 해제하는 안정적인 정지 상태, Unholding은 Execute로 되돌아가는 전이적인 복귀 상태입니다. 단 하나의 "정지" 플래그가 아니라 이 세 가지를 모두 기록하는 것이, 라인이 얼마나 오래 멈춰 있었고 언제 복구되었는지를 재구성하게 해줍니다 — 이것이 방어 가능한 배치 기록과 어깨를 으쓱하는 것 사이의 차이입니다. 5분 주기에서 시뮬레이션된 정지는 Holding → Held → Unholding에 걸쳐 있으며, 즉 FILL-LINE-01이 충전하지 않은 15분이 검사관이 인용할 수 있는 타임스탬프로 묶여 있습니다.
이 상태 전이들은 플랫폼의 events.equipment_state 테이블에 안착하도록 모양이 잡혀 있으며, 공유 스키마는 바로 이 목적을 위해 그 테이블을 한 번 정의합니다. examples/platform/db/30-lab-events.sql에서 가져온 코드입니다.
-- examples/platform/db/30-lab-events.sql
CREATE TABLE events.equipment_state ( -- PackML / serialization (Ch 15)
state_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
unit_id text NOT NULL,
ts timestamptz NOT NULL,
state text NOT NULL,
batch_id text REFERENCES s88.batch
);
라인을 실제로 읽기: S7 데이터 블록과 python-snap7
실제 라인에서 여러분은 이 상태들을 지어내지 않을 것입니다 — 제어기에서 읽어올 것입니다. 충전 기계는 거의 항상 Siemens S7 PLC(programmable logic controller, 프로그래머블 로직 컨트롤러 — 기계를 구동하는 견고한 산업용 컴퓨터)이며, python-snap7(MIT 라이선스)은 S7 제어기에서 state, count, reject 데이터 블록(data-block) 태그를 끌어올 때 쓸 오픈 소스 라이브러리입니다 [11]. S7에서 이 태그들은 데이터 블록(DB) — 이름이 붙고 바이트 단위로 주소가 매겨진 메모리 영역 — 에 들어 있으므로, 리더는 PLC의 랙(rack)과 슬롯(slot)에 연결을 연 다음 DB에서 바이트 범위를 읽어 언팩합니다. PackML 상태는 정수 열거형(enum)으로, 누적 양품 수량과 리젝트 수량은 DWORD로 말이죠. 그 원시 읽기가 바로 quality가 태어나는 지점이기도 합니다. 오래되었거나 도달 불가능한 DB 읽기는 0이라는 수량이 아니며, 그 둘을 뒤섞는 것이 라인 정지가 슬그머니 완벽한 작업처럼 보이게 되는 방식입니다.
이 저장소는 snap7 리더를 동봉하지 않습니다. 가장 가까운 실제 예시는 examples/chapters/09-legacy-skids-modbus-s7/modbus_reader.py에 있는 Modbus-TCP 스키드 리더(pymodbus 사용)로, 원시 PLC 레지스터를 읽어 태그 네임스페이스로 정규화하는 유사한 패턴을 보여줍니다. 여기서는 이 장이 노트북에서 돌아가도록 결정론적 시뮬레이터가 PLC를 대신하지만, 데이터의 모양 — 유닛(unit), 타임스탬프, 상태 — 은 여러분이 실제로 영속화할 PackML 모양입니다.
시리얼라이제이션: 모든 바이알에 번호판을 단다
바이알이 충전되고 합격하면, 그것은 개별적으로 추적 가능해져야 합니다. 미국 의약품 공급망 보안법(Drug Supply Chain Security Act, DSCSA)에 따라, 포장에는 규제 당국이 검사하여 공급망을 따라 제품을 추적하는 표준화된 숫자 식별자가 붙습니다 [4]. 그 인코딩이 GS1입니다. 국제거래단품식별코드(Global Trade Item Number, GTIN, GS1 애플리케이션 식별자 01)와 고유한 일련번호(AI 21)가 함께 직렬화된 GTIN(Serialized GTIN, SGTIN)을 이루며, 라벨에 GS1 DataMatrix로 인쇄됩니다 [3]. 시뮬레이터에서 일련번호는 {GTIN}.{i:07d}로 만들어집니다 — 인코딩된 SGTIN을 의도적으로 읽기 좋게 대신한 형태이며 — 그래서 위의 모든 vial_serial이 00361414000017.0000152처럼 보이는 것입니다.
GS1 SGTIN 해부: 번호판 해독하기
저 vial_serial 문자열은 불투명하지 않습니다 — 그것은 구조화된 번호판이며, 그것을 끝까지 해독하는 것이 두 번째로 해볼 가치가 있는 해부입니다. 00361414000017.0000152의 점(dot)은 시뮬레이터 자체의 읽기 좋은 구분자입니다. 실제 라벨에서는 두 부분이 GS1 애플리케이션 식별자 아래에 담깁니다. GTIN은 AI (01) 아래에, 일련번호는 AI (21) 아래에 말이죠.
SGTIN 해부: 14자리 GTIN(인디케이터 숫자, 컴퍼니 프리픽스와 아이템 레퍼런스, mod-10 검사 숫자)이 AI (01) 아래에, 낱개 일련번호가 AI (21) 아래에.
저자가 AI의 도움을 받아 직접 제작한 그림입니다.
GTIN-14는 인디케이터/포장 수준 숫자, GS1 컴퍼니 프리픽스(GS1 Company Prefix)와 아이템 레퍼런스(Item Reference)(라이선스를 받은 브랜드 소유자와 특정 제품), 그리고 스캐너가 나머지 13자리를 스스로 검증하게 해주는 끝자리 mod-10 검사 숫자로 나뉩니다. 일련번호 0000152는 번호판을 고유하게 만드는 것으로 — 이 한 바이알을 작업 중의 나머지 479개와 구별합니다. AI (01) 아래의 GTIN과 AI (21) 아래의 일련번호가 함께 SGTIN을 이루며, 물리적 라벨에서 그 쌍은 카메라가 리젝트와 패킹 스테이션에서 읽는 GS1 DataMatrix로 인쇄됩니다. (여기서의 GTIN 00361414000017은 시뮬레이터의 가상 예시입니다 — 그 끝자리 7은 검사 숫자 자리에 서 있지만 계산된 유효한 GS1 검사 숫자는 아니며, 그래서 우리는 그 값을 단언하기보다 그 자리를 설명합니다.) 핵심은 녹색 블록입니다. 합격한 바이알만이 집약 트리에 자리를 얻으므로, 거부된 152번 바이알은 카톤이나 케이스로 결코 집약되지 않는 번호판을 갖습니다.
이 장이 Python으로 구축하는 fill_events 행과 집약 트리는, 1권의 두 물리적 단계를 코드와 SQL의 형태로 옮긴 것입니다. 정량되고 체크웨이되고 마개가 닫힌 바이알은 제제화와 충전·포장의 라인에서 만들어지고, SGTIN 번호판과 바이알 → 카톤 → 케이스 부모/자식 트리는 포장과 시리얼라이제이션에서 부여됩니다. 거기서 무균 정량·라벨링 작업이었던 것이, 여기서는 한 행과 한 계보 간선이 됩니다.
집약 트리: 쿼리 가능한 계보
시리얼라이제이션은 그 자체만으로는 그저 숫자의 목록일 뿐입니다. 가치는 집약(aggregation)에서 나옵니다. 어떤 바이알이 어떤 카톤에 들어갔고, 어떤 카톤이 어떤 케이스에 들어갔는지를 기록하여, 하역장에서 케이스 하나를 스캔하면 그것을 열지 않고도 안에 든 바이알 120개가 정확히 무엇인지 알 수 있게 하는 것입니다. 그것은 부모/자식 트리이며, 합격한 바이알만이 그 안에 속합니다. examples/sim/bioproc_sim/em_fill.py에서 가져온 코드입니다.
# examples/sim/bioproc_sim/em_fill.py
def aggregation_tree(fills, vials_per_carton: int = 24, cartons_per_case: int = 5):
"""Parent/child serialization aggregation: vial -> carton -> case (accepted vials only)."""
rows = []
accepted = fills[~fills.reject].reset_index(drop=True)
for vi, r in accepted.iterrows():
carton = vi // vials_per_carton + 1
case = vi // (vials_per_carton * cartons_per_case) + 1
rows.append({
"batch_id": r.batch_id, "child": r.vial_serial, "child_level": "vial",
"parent": f"CARTON-{r.batch_id}-{carton:03d}", "parent_level": "carton",
})
rows.append({
"batch_id": r.batch_id, "child": f"CARTON-{r.batch_id}-{carton:03d}", "child_level": "carton",
"parent": f"CASE-{r.batch_id}-{case:03d}", "parent_level": "case",
})
return pd.DataFrame(rows).drop_duplicates().reset_index(drop=True)
~fills.reject 필터는 실제 규제 업무를 수행하고 있습니다. 거부된 바이알은 판매 가능 재고에 결코 들어가지 않았으므로 집약 트리에 절대 나타나서는 안 됩니다. 정수 나눗셈 연산(vi // 24, vi // 120)이 곧 전체 패킹 기하(packing geometry)입니다 — 카톤당 24개 바이알, 케이스당 5개 카톤 — 그리고 drop_duplicates()는 반복되는 카톤→케이스 간선(edge)을 압축하여 각 부모 링크가 한 번만 단언되도록 합니다. 그 결과는 깨끗하고 쿼리 가능한 계보(genealogy)이며, 배치와 조인하면 "0000152번 바이알을 담고 있는 케이스는 무엇인가?"를 단 한 번의 SQL 홉(hop)으로 답할 수 있습니다. 이 부모/자식 트리의 형식적 RDF 처리 — 어떤 것이 지금 어느 상자 안에 있는지(contains, 케이스가 그 카톤 안의 모든 바이알을 자동으로 담도록 연쇄되는 관계)를 어떤 자재가 어디서 왔는지(그 derivedFrom 계보)와 엄격히 분리하여, 바이알을 다른 카톤으로 재포장하는 것이 결코 그 기원의 변경으로 오인되지 않게 하는 것 — 은 4권 형식화: 공리와 제약에 있습니다.
같은 트리를 트리플로, 형상으로, 그리고 역량 질문으로
위의 관계형 트리는 이 아티팩트의 한쪽 얼굴입니다. 그 의미론적 얼굴은 같은 간선들을 RDF(Resource Description Framework — 이 시리즈가 지식그래프를 구축하는 토대인 주어-술어-목적어 트리플 모델)로 적은 것입니다. 각 집약 행은 하나의 contains 트리플 — CARTON-… contains VIAL-…, CASE-… contains CARTON-… — 이 되고, 우리가 해독한 vial_serial은 벌거벗은 문자열이 아니라 전역 신원입니다. SGTIN은 그 낱개에 공장을 떠나도 살아남는 안정적인 이름을 부여하며, 이는 정확히 4권 식별자와 단위가 구축하는 IRI(Internationalized Resource Identifier, 국제화 자원 식별자 — 단 하나의 데이터베이스 안에서만 의미를 갖는 기본 키가 아니라, 전역 웹 이름) 규율입니다. 모델로 표현하면, 포장되어 합격한 바이알 하나는 다음과 같이 읽힙니다.
# the aggregation tree as triples — containment, kept off the lineage spine
bp:VIAL-00361414000017-0000017 a bp:SerializedUnit ;
bp:gtin "00361414000017" ; bp:serial "0000017" ;
bp:derivedFrom bp:DP-2026-001 . # lineage: which lot it was filled from
bp:CARTON-BATCH-2026-001-001 bp:contains bp:VIAL-00361414000017-0000017 .
bp:CASE-BATCH-2026-001-001 bp:contains bp:CARTON-BATCH-2026-001-001 .
이 책이 줄곧 신경 써 온 두 가지가 여기서 깔끔하게 떨어집니다. 첫째, contains와 derivedFrom은 엄격히 분리된 채로 남습니다 — 바이알은 카톤에 담겨 있고(가변적: 케이스를 열면 바뀝니다) 약품(drug-product) 로트로부터 유래합니다(영구적). 이는 개념화: 관계와 계보가 그리는 바로 그 구분으로, 계보로 범위를 잡은 리콜이 출하용 카톤과 결코 혼동되지 않게 합니다. 둘째, bp:contains가 추이적(transitive)으로 선언되어 있으므로, 추론기는 누구도 장거리 간선을 단언하지 않아도 CASE-… contains VIAL-…을 추론합니다 — 위의 SQL "케이스까지 한 홉"의 그래프 대응물이되, 임의의 깊이까지 추론된 것입니다.
아래 현장-실패 절이 두려워하는 무결성 규칙 — 합격한 모든 낱개가 정확히 하나의 부모를, 한 번 단언받는다 — 은 그 자체로 닫힌 세계(closed-world) SHACL 형상(Shapes Constraint Language — 필수 사실을 추론으로 메우지 않고 누락되면 실패하는 RDF의 검증기)으로 표현 가능하며, 이는 4권 출하 관문이 쓰는 추론-아닌-관문 패턴과 동일합니다. bp:SerializedUnit에 대한 sh:NodeShape에 sh:path bp:containedIn ; sh:minCount 1 ; sh:maxCount 1을 두면 "카톤 링크가 없는 바이알"이 조용한 공백에서 플래그된 적합성 위반으로 바뀝니다. 그리고 케이스-스캔 질문은 추이적 간선에 대한 한 줄짜리 SPARQL(RDF의 쿼리 언어) 역량 질문(competency question)이 됩니다 — "이 케이스가 담은 시리얼 바이알은 무엇인가?" — 손으로 쓴 재귀 SQL CTE가 아니라 프로퍼티 경로 bp:contains+로 답합니다. 요점은 그래프가 테이블을 대체한다는 것이 아니라, 같은 집약 아티팩트를 의미론적으로 모델링하면 누락-링크 실패가 검증 오류가 되고 내용물 질문이 단 한 번의 프로퍼티-경로 홉이 된다는 것입니다.
현장에서 시리얼라이제이션이 무너질 때
이 모든 것이 해결된 문제라고 읽고 싶어집니다. 표준이 존재하고(GS1), 법이 그것을 의무화하니(DSCSA), 포장 수준 추적성은 당연히 일상적으로 작동하는 현실이어야 한다고 말이죠. 현장의 증거는 더 냉정하며, 그것을 분명히 말할 가치가 있습니다. 이 책이 얼버무리기를 거부하는 바로 그런 종류의 것이기 때문입니다. DSCSA는 미국 공급망 전반의 상호운용 가능하고 전자적이며 포장 수준의 추적에 대해 2023년 이정표를 세웠는데 — FDA는 광범위한 산업계의 미비 상태에 직면하자, 일정대로 집행하는 대신 컴플라이언스 정책 재량(compliance-policy discretion)을 사용해 2024년 11월까지 1년간의 안정화 기간(stabilization period)을 부여했고, 그 이후로도 더 작은 디스펜서(dispenser)들에게 단계적 면제를 추가로 겹쳐 두었습니다 [4]. 표준이 명확하다고 해서 데이터가 흐른 것은 아니었습니다. 특히 집약 — 이 절이 아홉 줄의 Python으로 구축하는 부모/자식 트리 — 은 실제 프로그램이 걸려 넘어지는 지점입니다. 빠지거나 잘못 단언된 바이알→카톤 링크 하나는 케이스 스캔이 더 이상 그 내용물로 해소되지 않는다는 뜻이고, "이 바이알을 담은 케이스는 무엇인가?"에 답하기로 되어 있던 계보가 침묵을 돌려줍니다. 데이터 시스템에 주는 교훈은 시리얼라이제이션이 모델링하기 어렵다는 것이 아니라 — 명백히 어렵지 않습니다 — 모델의 무결성이 전적으로 모든 합격 낱개가 빠짐없이, 패킹 시점에, 단 한 번 실제로 링크를 단언받느냐에 달려 있다는 것입니다. 그것은 스키마 기능이 아니라 운영 규율이며, 바로 거기에 미흡한 구현이 숨습니다.
마지막 한 구간과 그 주위의 공기. 충전 라인(왼쪽)은 PackML 상태와 바이알당 기록을 만들어내고, 시리얼라이제이션은 집약 트리를 구축하며, 클린룸 센서(위쪽)는 끊임없이 스트리밍합니다. 점선 경계가 이 장의 핵심 전부입니다. GxP 기록(바이알 리젝트, EM 이탈, 시리얼라이제이션)은 감사되는 PostgreSQL 기록 시스템으로 흘러가고, 고카디널리티 설비 관측성(observability)은 VictoriaMetrics로 흘러가 거기서는 유용하지만 규제 기록은 아닙니다.
저자가 AI의 도움을 받아 직접 제작한 그림입니다.
환경 모니터링: 공기를 지켜보기
바이알이 충전되는 동안, 클린룸은 끊임없이 모니터링됩니다. EU GMP 부속서 1(2022년 개정판)은 일상적인 EM 프로그램 — 생존 및 비생존 입자 계수(비생존 = 먼지나 섬유 같은 불활성 부유 입자 총량으로, 광산란으로 계수; 생존 = 자라서 셀 수 있는 집락을 형성하는 살아 있는 미생물로, 집락 형성 단위 CFU로 보고), 공기·표면·인원 모니터링 — 을 요구하며, 이 모든 것은 문서화된 오염 관리 전략(Contamination Control Strategy, CCS)이 지배합니다 [5]. 비생존 입자 텔레메트리는 ISO 14644-1이 정의한 공기 청정도 등급에 따라 분류되며, 0.1–5 µm 임계치에서 광산란(light-scattering) 부유 입자 계수기로 측정됩니다 [6]. 충전 현장에서 핵심 충전 구역은 (가장 까다로운) 등급 A이고, 그 주위를 등급 B가 감싸며, 등급 C/D 지원 구역이 있습니다.
시뮬레이터는 8시간 교대 근무에 걸쳐 다섯 개 위치를 모니터링하며, 매시간 표본을 채취하고, 입자와 미생물 계수에 푸아송(Poisson) 통계를 사용합니다 — 드물고 독립적인 오염 사건에 알맞은 분포입니다. examples/sim/bioproc_sim/em_fill.py에서 가져온 코드입니다.
# examples/sim/bioproc_sim/em_fill.py
# Annex 1 non-viable particle limits (>=0.5 um, per m3), in operation
GRADE_LIMITS = {"A": 3520, "B": 352000, "C": 3520000, "D": None}
def em_samples(batch_id: str = "BATCH-2026-001") -> pd.DataFrame:
rng = stream_rng("em", batch_id)
rows = []
locations = [("FILL-A-01", "A"), ("FILL-A-02", "A"), ("BKGD-B-01", "B"),
("BKGD-B-02", "B"), ("CORR-C-01", "C")]
sid = 1
for hour in range(8): # an 8-hour shift, hourly samples
ts = EM_START + pd.Timedelta(hours=hour)
for loc, grade in locations:
limit = GRADE_LIMITS[grade]
base = {"A": 1500, "B": 120000, "C": 1200000}.get(grade, 2000)
particles = int(rng.poisson(base))
viable = int(rng.poisson({"A": 0.05, "B": 1.0, "C": 4.0}.get(grade, 6.0)))
# seed one Grade-A excursion in hour 5
excursion = grade == "A" and hour == 5 and loc == "FILL-A-01"
if excursion:
particles = int(limit * 1.4)
viable = 2
rows.append({
"em_id": f"EM-{batch_id}-{sid:03d}", "batch_id": batch_id, "ts": ts,
"location": loc, "grade": grade,
"nonviable_0_5um_per_m3": particles,
"nonviable_5um_per_m3": int(particles * rng.uniform(0.0, 0.02)),
"viable_CFU": viable,
"limit_0_5um_per_m3": limit,
"excursion": bool(limit is not None and particles > limit),
})
sid += 1
return pd.DataFrame(rows)
m³당 ≥0.5 µm 입자 3,520개라는 등급 A 한계는 추측이 아니라 부속서 1의 수치입니다 — 그리고 등급 A의 경우 정지(at-rest) 상태와 가동(in-operation) 상태에서 동일하므로, GRADE_LIMITS 딕셔너리에는 그 하나의 값만 있으면 됩니다. (등급 B와 C는 두 상태에서 서로 다릅니다. 딕셔너리는 그것들의 가동 상태 한계를 담고 있습니다.) 등급 A의 생존 기대치는 본질적으로 0(푸아송 평균 0.05)입니다 — 부속서 1은 등급 A 생존 조치(action) 한계를 능동 공기(active air), 침강 평판(settle plate, 90 mm, 4시간), 접촉 평판(contact plate, 55 mm), 5손가락 글러브에 대해 1 CFU 미만으로 정합니다 — 그래서 등급 A에서 CFU가 2라는 것은 그 자체로 경보입니다. 그 자체가 미생물 일탈(deviation)을 촉발할 단단한 위반입니다. 그리고 이탈 하나가 5시째 FILL-A-01에서 의도적으로 심어져 있습니다. 입자가 int(limit * 1.4) = 4,928로 치솟아 3,520 한계를 한참 넘어서고, excursion은 True로 뒤집힙니다. 여기서 단 하나의 excursion 플래그는 비생존 입자 계수만으로 구동된다는 점에 유의하세요 — 단순화를 위해 생존 한계는 의도적으로 무시하므로, 완전한 EM 모델이라면 그 1 CFU 미만 잣대에 대한 생존 조치 한계 검사를 병렬로 추가할 것입니다(심어진 CFU=2가 그것을 촉발했을 것입니다). 또한 시뮬레이션된 nonviable_5um_per_m3 열은 포착되지만 한계 검사는 하지 않는다는 점에도 유의하세요. 2022년 부속서 1 개정판은 ≥5 µm 값을 분류 표에서 삭제했으므로(클린룸 분류는 이제 입자 계수와 CCS 기반입니다), 여기서 단언할 ≥5 µm 한계가 없습니다 — 하지만 그 거대 입자(macroparticle) 계수는 여전히 추세화할 가치가 있는 모니터링 진단 지표이며, 시뮬레이터가 그 열을 버리지 않고 유지하는 이유가 바로 그것입니다.
커밋된 골든 examples/datasets/em_samples.csv는 차분한 기준선과 그다음의 급등을 보여줍니다.
em_id,batch_id,ts,location,grade,nonviable_0_5um_per_m3,nonviable_5um_per_m3,viable_CFU,limit_0_5um_per_m3,excursion
EM-BATCH-2026-001-001,BATCH-2026-001,2026-01-22 06:00:00+00:00,FILL-A-01,A,1500,6,0,3520,False
EM-BATCH-2026-001-003,BATCH-2026-001,2026-01-22 06:00:00+00:00,BKGD-B-01,B,120150,1037,0,352000,False
EM-BATCH-2026-001-026,BATCH-2026-001,2026-01-22 11:00:00+00:00,FILL-A-01,A,4928,56,2,3520,True
저 마지막 행은 GxP 이벤트입니다. EM 이탈은 조사(investigation), 일탈 기록(deviation record), 그리고 배치에 대한 품질 결정을 촉발합니다. 플랫폼에서 이 행은 그저 CSV에 머물러 있으라고 있는 것이 아닙니다 — 이 행은 event_type = 'excursion'으로 events.operation_event에 안착하도록 모양이 잡혀 있으며, 이는 크로마토그래피(chromatography) 페이즈 검출기와 바이오리액터(bioreactor) 로직이 겨냥하는 바로 그 테이블입니다. 그리하여 공정 전체에 걸친 이탈이 한곳에 모여 배치로 다시 조인됩니다. (저장소의 이 단계에서 시뮬레이터는 CSV 골든을 쓰고 플랫폼은 스키마를 정의합니다. 이 행들을 안착시키는 15장 로더는 가동 중인 흐름이 아니라 스키마가 예상하는 설계로 남겨져 있습니다.)
임계치를 한 번 넘는 것은 명백한 경보이지만, 오염 관리는 단순한 합격/불합격이 아니라 통계적입니다. 미생물 EM 데이터는 추세화(trended)됩니다. 경고(alert) 및 조치(action) 한계는 SPC 관리 한계처럼 작동하며, 한계를 향한 느린 표류(drift)는 단 한 번의 위반만큼이나 중요합니다 [12]. 저장소의 분석(analytics) 장이 추세화를 수행합니다. 여기서는 추세화가 소비하는 원시 계수와 표본별 이탈 플래그를 포착합니다.
컬럼의 형태가 그 위에 세워질 모든 모델에 중요한 이유
표본별 플래그는 바닥이지 천장이 아닙니다. 누군가 이 계수 위에 모델 — 등급 A 위치가 한계를 넘기 전에 한계를 향해 추세화되고 있음을 표시하는 조기 경보 예측기 — 을 원하는 순간, 이 장이 데이터를 어떻게 형성했는지가 그 모델을 신뢰할 수 있는지를 결정하며, 5권의 두 가지 함정이 바로 이 컬럼들 위에 떨어집니다. 첫째는 검증 누수(validation leakage)입니다. 한 위치에서 한 교대 근무 동안 얻은 8개의 시간별 표본은 독립적인 추출이 아니라 자기상관(autocorrelated)된 궤적이므로, 순진한 행 단위(row-wise) 학습/시험 분할은 거의 쌍둥이인 이웃들을 경계 양쪽으로 흩뿌려 다음 교대에서 무너질 듣기 좋은 점수를 보고합니다. 정직한 분할은 배치 전체(또는 위치 전체)를 한쪽으로 몰아넣습니다 — 5권 데이터, 그 연료가 기본값으로 삼는 GroupKFold와 배치 단위 제외(leave-one-batch-out) 규율 — 그래서 시험 집합은 진정으로 처음 보는 방이 되며, 이것이 제조가 신경 쓰는 유일한 질문입니다. 바로 그래서 이 장은 batch_id, location, grade를 일급(first-class) 컬럼으로 유지하고 결코 뭉개지 않습니다. 그것들이 곧 정직한 교차검증(cross-validation)이 필요로 하는 그룹화 키입니다.
둘째 함정은 공정 드리프트(process drift)와 모델 드리프트(model drift)의 차이입니다. 의도적으로 심은 5시간째 이탈은 공정 드리프트입니다 — 방이 실제로 더 더러워진 것이고, 올바른 대응은 일탈(deviation)이지 재학습이 아닙니다. 모델 드리프트는 반대의 실패입니다. 계수기가 천천히 오염되거나 새 배지(media) 로트가 입자 배경을 옮겨, 입력이 모델이 본 적 없는 영역(그 적용 범위(applicability domain) — 데이터 기반 모델을 신뢰할 수 있는 입력 범위로, 그 밖에서 모델은 외삽 중이며 가장 신뢰할 수 없습니다)으로 이동하는데, 방은 실제로는 멀쩡한데도 예측이 조용히 틀어집니다. 5권 MLOps와 생애주기는 이를 서로 다른 수학적 종류 — 입력 계수의 공변량 이동(covariate shift) 대 계수-대-위험 매핑의 개념 드리프트(concept drift) — 로 이름 붙이고, 서로 다른 두 계측기로 잡으며, 품질 결정에 닿는 모델은 잠겨 있고 오직 의도적으로만, 변경 통제 아래에서만 바뀌어야 한다고 못 박습니다. 이 장이 이미 고집한 quality 규율 — 낡았거나 도달 불가한 DB 읽기는 0 계수가 아니다 — 은 드리프트 모니터가 소비하는 바로 그 신호입니다. 얼어붙은 입자 계수기 피드는 순진한 모델에게는 완벽한 방처럼 보이고 좋은 모델에게는 공변량-이동 경보로 보입니다. 같은 추론이 충전 라인의 하이브리드 모델로 확장되는데, 거기서는 물리(오염-수송 가드레일)가 소수의 EM 배치로는 메울 수 없는 부분을 메웁니다. 이 모델링 가운데 어느 것도 이 장에 살지 않습니다 — 그러나 여기서의 스키마 선택이 그것을 하류에서 정직하게 할 수 있게 만들고, 모든 예측을 그것을 만든 정확한 데이터셋 해시와 batch_id로 되짚을 수 있게 만듭니다.
모든 것이 어디로 가는가: 도구로 그어진 GxP 경계
여기 이 장의 정직하고 핵심을 떠받치는 구별이 있습니다. EM 계수, 충전 리젝트, 시리얼라이제이션 기록은 GxP 데이터입니다 — 데이터 중요도 평가(data-criticality assessment), 완전한 감사 추적(audit trail)을 갖춘 진본 사본(true copy), 그리고 MHRA 및 PIC/S 데이터 무결성(data-integrity) 기대치에 따라 동적(dynamic, 재처리 가능) 형태로의 보존이 요구되는 기록입니다 [7]. 검사관은 이 기록들에 위험 기반(risk-based)의 ALCOA+ 렌즈(귀속 가능Attributable·판독 가능Legible·동시 기록Contemporaneous·원본Original·정확Accurate에, 더하여 완전Complete·일관Consistent·영속Enduring·가용Available한 데이터 무결성 속성으로, 신뢰 관련 장들이 구축해 나갑니다)를 적용하며, 바로 그렇기에 규제 기록과 편의용 대시보드 사이의 선이 명시적으로 그어져야 합니다 [8].
그래서 플랫폼은 충전·포장과 EM 데이터를 두 갈래로 라우팅합니다.
두 경로의 수집 에이전트는 동일합니다. Telegraf, 즉 단일 바이너리(single-binary)의 플러그인 기반 메트릭 에이전트(300개 이상의 플러그인, MIT 라이선스)로, 고카디널리티 충전 라인 및 설비 텔레메트리에 이상적입니다 [9]. 목적지가 다릅니다. 차압(differential pressure), 상대 습도(relative humidity), 온도 추세 — 분당 수천 개의 점, 모든 도어 인터록(door interlock)과 HVAC 판독값 — 는 VictoriaMetrics(Apache-2.0, 플랫폼 compose에 victoriametrics/victoria-metrics:v1.108.1로 고정)로 가며, 그 카디널리티 탐색기(cardinality explorer)와 리미터(limiter)는 정확히 이런 소방 호스(firehose) 같은 폭주 데이터를 위해 만들어졌습니다 [10]. 그 텔레메트리는 방을 적격 상태로 유지하는 데 엄청나게 유용하지만 — 그것은 규제 기록이 아닙니다. 생존 CFU 결과, 등급 A 이탈, 거부된 바이알, 집약 트리, 이것들은 PostgreSQL에 기록됩니다. 신뢰(trust) 장들이 구축하는 바로 그 감사 트리거와 해시 체인(hash chain) 아래에 말이죠. 왜냐하면 그것들은 증거이기 때문입니다. (동봉된 compose에서 VictoriaMetrics는 스택 자체 모니터링을 위해 analytics/ops 프로파일로 돌아갑니다. Telegraf를 통해 실제 설비 텔레메트리를 그 안으로 라우팅하는 것은 이 절이 서술하는 아키텍처이지, 저장소가 지금 배선한 흐름은 아닙니다 — PostgreSQL 쪽의 15장 로더와 같은 정직한 괄호 처리입니다.)
왜 그냥 전부를 VictoriaMetrics에 넣지 않는가? 시계열 관측성 저장소는 GxP에 대해 잘못된 기록 시스템이기 때문입니다. 그것은 보존 윈도우(retention window)와 다운샘플링(downsampling)에 최적화되어 있지, 수년 뒤 검사를 위해 재구성할 수 있는 불변(immutable)이고 귀속 가능(attributable)하며 완전히 감사 추적된 이력에 최적화되어 있지 않습니다. EM 이탈을 그 저장소로 라우팅한다면 규제 기록을 조용히 대시보드 메트릭으로 강등시키는 셈입니다. 도구 안에 경계를 긋는 것 — 관측성을 위한 Telegraf-to-VictoriaMetrics, 기록을 위한 Telegraf-to-PostgreSQL — 이 곧 정직한 하이브리드를 정직하게 유지하는 방법입니다.
왜 중요한가
충전·포장은 수백만 달러어치 배치가 판매 가능한 제품이 되거나 일탈(deviation)이 되는 지점입니다. 여기서의 데이터는 그 양에 비해 유난히 위험 부담이 큽니다. 이탈 행 하나, 리젝트 플래그 하나, 빠진 집약 링크 하나가 한 로트(lot)를 보류시키거나 망칠 수 있습니다. 이것을 올바르게 모델링하는 것 — 라인에는 PackML 상태, 낱개에는 GS1 SGTIN, 공기에는 부속서 1 등급 — 은 검사관이나 품질 조사관이 던지는 질문에 대한 답이, 정신없는 스프레드시트 재구성이 아니라 쿼리 하나에서 곧바로 떨어진다는 뜻입니다. 그리고 GxP 경계를 제대로 잡는다는 것은, 기록 시스템을 실수로 Grafana 패널로 바꿔버리지 않으면서 설비 데이터의 소방 호스 같은 폭주를 위해 쾌활하고 확장 가능한 오픈 소스 관측성 도구를 쓸 수 있다는 뜻입니다.
실제 현장에서는
상업용 충전 라인은 분당 수백 개의 바이알로 돌아가며, 시리얼라이제이션은 카메라 시스템과 프린터와 통신하는 전용 레벨-2/레벨-3 소프트웨어(참조 아키텍처의 ISA-95 자동화 계층 중 라인 수준과 사이트 수준 계층 — Systech, Optel, SAP ATTP)가 처리하고, EM은 검증된 EM 데이터 관리자(data manager)에 데이터를 공급하는 검증된 입자 계수기 네트워크(Lighthouse, TSI, Particle Measuring Systems)가 처리합니다. 그 시스템들은 독점적(proprietary)이며 진정으로 노트북에서 돌아갈 수 없습니다 — 그래서 이 장은 책의 나머지와 마찬가지로 데이터 모양을 시뮬레이션하며, 벤더 고유의 세부 사항(카메라 리젝트 시그널링, 검증된 계수기 교정, DataMatrix에 새겨지는 정확한 GS1 인코딩)이 곧 실제 적격성 평가(qualification)가 일어나는 지점임을 분명히 합니다. 여기서 OSS에 대한 정직한 판정은 이렇습니다. Telegraf와 VictoriaMetrics는 설비 관측성과 엔지니어링 대시보드에 대해 탁월하고 프로덕션급(production-grade)이며, PostgreSQL은 일단 후속 장들이 구축하는 검증된 감사 추적·보존·접근 제어로 감싸면 완전히 신뢰할 만한 GxP 기록 시스템입니다 — 하지만 이 스택의 어느 부분도 즉시 사용 가능한(turnkey) 검증된 EM 데이터 관리자나 시리얼라이제이션 저장소는 아닙니다. 부속서 1, ISO 14644 분류, DSCSA 시리얼라이제이션은 여전히 운영자가 입증해야 할 부담으로 남아 있습니다. 플랫폼은 여러분이 데이터를 올바르게 포착하고 그것을 GxP 선의 올바른 쪽에 둘 수 있음을 보여줍니다.
핵심 용어
- 충전·포장(fill-finish) — 벌크 원료 의약품(bulk drug substance)을 바이알/주사기에 정량 주입하고, 마개를 닫고, 캡을 씌우고, 검사하는 최종 무균(sterile) 제조 단계.
- IPC(공정 관리, in-process control) — 품질을 실시간으로 통제하기 위해 생산 중에 취하는 측정 — 여기서는 각 바이알의 체크웨이.
- PackML / OMAC(OPC 30050) — ISA-88에서 파생된, OPC UA를 통해 노출되는 표준화된 포장 기계 상태 모델(Idle/Starting/Execute/Held/…)과 PackTags.
- GS1 / GTIN / SGTIN — 전 세계 제품 식별 표준. GTIN(AI 01)과 고유한 일련번호(AI 21)가 직렬화된 GTIN, 즉 낱개 단위의 "번호판"을 이룬다.
- 집약(aggregation) — 직렬화된 품목의 부모/자식 포함 관계(바이알 → 카톤 → 케이스)를 기록하여, 부모를 한 번 스캔하면 그 자식들이 드러나게 하는 것.
- GTIN 검사 숫자(check digit, mod-10) — GTIN의 마지막 숫자로, GS1 모듈로-10 알고리즘으로 나머지 숫자들로부터 계산되어, 스캐너가 읽은 숫자를 스스로 검증할 수 있게 한다.
- S7 데이터 블록(data block, DB) — Siemens S7 PLC에서 PackML 상태와 양품/리젝트 수량 같은 태그가 들어 있는, 이름이 붙고 바이트 단위로 주소가 매겨진 메모리 영역.
python-snap7같은 라이브러리로 네트워크를 통해 읽는다. - 환경 모니터링(environmental monitoring, EM) — 클린룸 공기/표면 오염의 일상적 측정 — 비생존 입자와 생존 집락 형성 단위(colony-forming unit, CFU).
- 부속서 1 등급(Annex 1 grades, A/B/C/D) — EU GMP 청정도 등급. 등급 A는 핵심 충전 구역으로, 가동 상태에서 m³당 ≥0.5 µm 입자 3,520개로 제한된다.
- 이탈(excursion) — 측정값이 경고/조치 한계를 넘는 것 — 조사를 촉발하는 GxP 이벤트.
- GxP — 보건 당국이 검사할 수 있는 기록을 규율하는 "Good x Practice" 규제군을 아우르는 우산 용어 — 제조 관리 기준(GMP), 시험 관리 기준(GLP), 유통 관리 기준(GDP) 등.
- GxP 경계(GxP boundary) — 규제 기록(감사·보존됨)과 비-GxP 설비 관측성(엔지니어링 대시보드) 사이의 명시적인 선.
- CFU(집락 형성 단위, colony-forming unit) — EM 표본에서 회수된 생존 미생물의 수.
- 고카디널리티(high-cardinality) — 서로 다른 레이블 값이 매우 많은 데이터(예: 바이알마다 고유한 일련번호 하나)로, VictoriaMetrics 같은 시계열 저장소가 흡수하도록 만들어진 것.
- RDF 트리플 / IRI — 집약 트리를 지식그래프로 모델링할 때 그것이 매핑되는, 주어-술어-목적어 사실(예:
CASE-… contains CARTON-…)과 전역 웹 이름으로, 데이터베이스 로컬 키와 대비된다. - SHACL / SPARQL — 필수 사실이 누락되면(예: 카톤 링크가 없는 바이알) 실패하는 RDF의 검증기, 그리고 추이적
contains경로를 걸어 "이 케이스에 어떤 바이알이 있는가?"에 답하는 그 쿼리 언어. - 적용 범위(applicability domain) — 데이터 기반 모델을 신뢰할 수 있는 입력 범위로, 그 밖에서 모델은 외삽 중이며 가장 신뢰할 수 없다 — 드리프트 모니터가 지켜보는 잣대.
- 모델 드리프트 대 공정 드리프트(model drift vs process drift) — 입력이 이동해(오염되는 계수기, 새 로트) 모델이 낡는 것 대 공정이 실제로 바뀌는 것(진짜 이탈) — 서로 다른 해법(통제 아래 재학습 대 일탈)을 갖는 서로 다른 문제.
- 그룹화 / 배치 단위 제외 교차검증(grouped / leave-one-batch-out cross-validation) — 뒤섞인 행이 아니라 배치 전체나 위치 전체를 시험용으로 떼어 두어, 자기상관된 궤적이 학습/시험 경계를 가로질러 누수해 모델의 보고 점수를 부풀리지 못하게 하는 것.
- 기록 시스템(system of record) — 규제 당국이 GxP 기록의 공식 사본으로 취급하는 단일한 권위 있는 감사 저장소 — 편의용 대시보드나 분석용 사본과 대비된다.
- 감사 추적(audit trail) — 누가 어떤 데이터를 언제 왜 바꿨는지를 기록한, 검사관이 재구성할 수 있는 변조 방지(tamper-evident) 로그(Part 11 / 부속서 11 기대치).
- 검증됨(validated, GxP 의미) — 시스템이 해야 할 일을 하고 데이터 무결성을 보호한다는, 형식적으로 문서화된 증거 — 후속 신뢰 장들이 구축해 나가는 부담.
다음 이야기
이제 우리는 공정이 내보내는 모든 것 — 바이오리액터 태그, 크로마토그래피 결정, 실험실 결과, 그리고 이 장의 마지막 한 구간인 충전·시리얼라이제이션·환경 데이터 — 을 포착했습니다. 그 모든 것이 거의 아무 언급 없이 TimescaleDB와 PostgreSQL에 안착해 왔습니다. 이제 그 저장소를 의도적인 선택으로 만들 때입니다. 다음 장 오픈 소스 히스토리언: 시계열 저장소 선택과 운영은 우리가 조용히 의존해 온 히스토리언을 열어 보입니다 — 하이퍼테이블(hypertable), 연속 집계(continuous aggregate), 보존이 실제로 어떻게 작동하는지, TimescaleDB가 IoTDB 같은 대안과 어떻게 견주는지, 그리고 어떤 오픈 소스 기능을 안전하게 기반으로 삼을 수 있고 어떤 라이선스 함정을 피해 가야 하는지를 다룹니다.