본문으로 건너뛰기

충전·포장과 환경 모니터링

📍 현재 위치: 2부 공정을 포착하기. 분자는 이미 배양되고, 정제되고, 폴리싱(polishing)되고, 제제화되었습니다. 이제 우리는 그것이 거치는 가장 마지막 산업 공정 — 바이알(vial)로, 카톤(carton)으로, 케이스(case)로 들어가는 과정 — 을 따라가며, 그 주위의 공기를 지켜봅니다. 여기서부터 데이터는 더 이상 제품에 관한 것이 아니라 *낱개(units)*와 *공간(space)*에 관한 것이 됩니다.

쉽게 말하면

음료 충전 공장의 마지막 조립 라인을 떠올려 보되, 그 "음료"는 만드는 데 막대한 비용이 들었고 공기 중에 떠다니는 티끌 하나가 한 배치(batch) 전체를 망칠 수 있다고 상상해 보세요. 세 권의 장부가 나란히 돌아갑니다. 하나는 바이알을 세고 각각의 무게를 답니다(충전·포장, fill-finish). 하나는 모든 바이알에 고유하고 스캔 가능한 번호판(license plate)을 발급하고, 그것이 어느 카톤과 케이스에 들어갔는지 기록합니다(시리얼라이제이션, serialization). 그리고 하나는 결코 잠들지 않는 공기 질 모니터로서, 클린룸(cleanroom)에서 먼지와 미생물의 냄새를 맡습니다(환경 모니터링, environmental monitoring). 이 장에서는 실제 코드로 이 세 권의 장부를 모두 생성하여 데이터베이스에 안착시키고 — 그런 다음 규제 당국이 검사할 기록과, 그저 엔지니어를 위한 설비 텔레메트리(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 로그를 만들어냅니다. 아래의 모든 내용은 그 실제로 테스트된 코드에서 나옵니다.

충전 라인: 세고, 무게 달고, 거부하기

충전·포장은 겉보기에는 단순하지만 가차 없습니다. 펌프가 각 바이알에 목표 부피를 정량 주입하고, 체크웨이 스테이션이 그 무게를 답니다. 공차(tolerance)를 벗어나는 것은 무엇이든 거부됩니다. 데이터는 고카디널리티(high-cardinality)입니다 — 태그당 한 행이 아니라 바이알당 한 행이며 — 그리고 GxP입니다. 리젝트 결정이 곧 품질 결정이기 때문입니다.

시뮬레이터는 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.901.10 mL) 안의 명시적이고 좁은 밴드(0.951.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는 검사관이 물어볼 수 있는 기록입니다. 왜 부족했는가? 그 리젝트는 물리적으로 분리·배출되었는가? 작업 종료 시점에 수량이 대조·확인되었는가? 데이터 모델은 이런 질문들에 답할 수 있게 만들어져야 합니다.

PackML: 라인에는 상태가 있고, 그 상태가 곧 데이터다

충전 라인은 그저 바이알의 흐름이 아닙니다 — 그것은 Idle, 그다음 Starting, 그다음 Executing, 때로는 Held, 그다음 Completing 상태에 있는 기계입니다. 그 생애주기는 표준화되어 있습니다. PackML(OMAC 기계 상태 모델로, OPC Foundation이 OPC UA for PackML / OPC 30050으로 발행)은 유한 상태 기계(finite state machine)와, 적합한 포장 기계가 OPC UA를 통해 노출하는 일련의 "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)

작업이 첫 번째 바이알보다 10분 먼저 IdleStarting으로 시작하여, 작업 도중에 Holding/Held/Unholding 이탈 — 라인 정지, 즉 모든 충전 현장이 두려워하는 바로 그 일 — 을 거친다는 점에 주목하세요. 이 상태 전이들은 플랫폼의 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 12)
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
);

실제 라인에서 여러분은 이 상태들을 지어내지 않을 것입니다 — 제어기에서 읽어올 것입니다. 충전 기계는 거의 항상 Siemens S7 PLC이며, python-snap7(MIT 라이선스)은 S7 제어기에서 state, count, reject 데이터 블록(data-block) 태그를 끌어올 때 쓸 오픈 소스 라이브러리입니다 [3]. 이 저장소는 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로 인쇄됩니다 [5]. 시뮬레이터에서 일련번호는 {GTIN}.{i:07d}로 만들어집니다 — 인코딩된 SGTIN을 의도적으로 읽기 좋게 대신한 형태이며 — 그래서 위의 모든 vial_serial00361414000017.0000152처럼 보이는 것입니다.

시리얼라이제이션은 그 자체만으로는 그저 숫자의 목록일 뿐입니다. 가치는 **집약(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)으로 답할 수 있습니다.

충전·포장과 환경 모니터링 데이터 흐름도: PackML 상태와 바이알당 충전/리젝트 이벤트를 내보내는 Siemens S7 충전 라인, 바이알-카톤-케이스 집약 트리를 구축하는 시리얼라이제이션 단계, 그리고 두 경로로 데이터를 보내는 클린룸 입자 계수기 고리 — PostgreSQL로 가는 GxP 기록과 VictoriaMetrics로 가는 고카디널리티 설비 텔레메트리 — 와 GxP 경계를 표시하는 점선.

마지막 한 구간과 그 주위의 공기. 충전 라인(왼쪽)은 PackML 상태와 바이알당 기록을 만들어내고, 시리얼라이제이션은 집약 트리를 구축하며, 클린룸 센서(위쪽)는 끊임없이 스트리밍합니다. 점선 경계가 이 장의 핵심 전부입니다. GxP 기록(바이알 리젝트, EM 이탈, 시리얼라이제이션)은 감사되는 PostgreSQL 기록 시스템으로 흘러가고, 고카디널리티 설비 관측성(observability)은 VictoriaMetrics로 흘러가 거기서는 유용하지만 규제 기록은 아닙니다.

Original diagram by the authors, created with AI assistance.

환경 모니터링: 공기를 지켜보기

바이알이 충전되는 동안, 클린룸은 끊임없이 모니터링됩니다. EU GMP 부속서 1(2022년 개정판)은 일상적인 EM 프로그램 — 생존 및 비생존 입자 계수, 공기·표면·인원 모니터링 — 을 요구하며, 이 모든 것은 문서화된 오염 관리 전략(Contamination Control Strategy, CCS)이 지배합니다 [6]. 비생존 입자 텔레메트리는 ISO 14644-1이 정의한 공기 청정도 등급에 따라 분류되며, 0.1~5 µm 임계치에서 광산란(light-scattering) 부유 입자 계수기로 측정됩니다 [7]. 충전 현장에서 핵심 충전 구역은 (가장 까다로운) 등급 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)이므로, 등급 A에서 CFU가 2라는 것은 그 자체로 경보입니다. 그리고 이탈 하나가 5시째 FILL-A-01에서 의도적으로 심어져 있습니다. 입자가 int(limit * 1.4) = 4,928로 치솟아 3,520 한계를 한참 넘어서고, excursionTrue로 뒤집힙니다. 시뮬레이션된 nonviable_5um_per_m3 열은 포착되지만 한계 검사는 하지 않는다는 점에 유의하세요. 2022년 부속서 1 개정판은 ≥5 µm 값을 분류 표에서 삭제했으므로(클린룸 분류는 이제 입자 계수와 CCS 기반입니다), 여기서 단언할 ≥5 µm 한계가 없습니다.

커밋된 골든 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 골든을 쓰고 플랫폼은 스키마를 정의합니다. 이 행들을 안착시키는 12장 로더는 가동 중인 흐름이 아니라 스키마가 예상하는 설계로 남겨져 있습니다.)

임계치를 한 번 넘는 것은 명백한 경보이지만, 오염 관리는 단순한 합격/불합격이 아니라 통계적입니다. 미생물 EM 데이터는 *추세화(trended)*됩니다. 경고(alert) 및 조치(action) 한계는 SPC 관리 한계처럼 작동하며, 한계를 향한 느린 표류(drift)는 단 한 번의 위반만큼이나 중요합니다 [8]. 저장소의 분석(analytics) 장이 추세화를 수행합니다. 여기서는 추세화가 소비하는 원시 계수와 표본별 이탈 플래그를 포착합니다.

모든 것이 어디로 가는가: 도구로 그어진 GxP 경계

여기 이 장의 정직하고 핵심을 떠받치는 구별이 있습니다. EM 계수, 충전 리젝트, 시리얼라이제이션 기록은 GxP 데이터입니다 — 데이터 중요도 평가(data-criticality assessment), 완전한 감사 추적(audit trail)을 갖춘 진본 사본(true copy), 그리고 MHRA 및 PIC/S 데이터 무결성(data-integrity) 기대치에 따라 동적(dynamic, 재처리 가능) 형태로의 보존이 요구되는 기록입니다 [9]. 검사관은 이 기록들에 위험 기반(risk-based)의 ALCOA+ 렌즈를 적용하며, 바로 그렇기에 규제 기록과 편의용 대시보드 사이의 선이 명시적으로 그어져야 합니다 [10].

그래서 플랫폼은 충전·포장과 EM 데이터를 두 갈래로 라우팅합니다.

두 경로의 수집 에이전트는 동일합니다. Telegraf, 즉 단일 바이너리(single-binary)의 플러그인 기반 메트릭 에이전트(300개 이상의 플러그인, MIT 라이선스)로, 고카디널리티 충전 라인 및 설비 텔레메트리에 이상적입니다 [11]. 목적지가 다릅니다. 차압(differential pressure), 상대 습도(relative humidity), 온도 추세 — 분당 수천 개의 점, 모든 도어 인터록(door interlock)과 HVAC 판독값 — 는 VictoriaMetrics(Apache-2.0, 플랫폼 compose에 victoriametrics/victoria-metrics:v1.108.1로 고정)로 가며, 그 카디널리티 탐색기(cardinality explorer)와 리미터(limiter)는 정확히 이런 소방 호스(firehose) 같은 폭주 데이터를 위해 만들어졌습니다 [12]. 그 텔레메트리는 방을 적격 상태로 유지하는 데 엄청나게 유용하지만 — 그것은 규제 기록이 아닙니다. 생존 CFU 결과, 등급 A 이탈, 거부된 바이알, 집약 트리, 이것들은 PostgreSQL에 기록됩니다. 신뢰(trust) 장들이 구축하는 바로 그 감사 트리거와 해시 체인(hash chain) 아래에 말이죠. 왜냐하면 그것들은 증거이기 때문입니다.

왜 그냥 전부를 VictoriaMetrics에 넣지 않는가? 시계열 관측성 저장소는 GxP에 대해 잘못된 기록 시스템이기 때문입니다. 그것은 보존 윈도우(retention window)와 다운샘플링(downsampling)에 최적화되어 있지, 수년 뒤 검사를 위해 재구성할 수 있는 불변(immutable)이고 귀속 가능(attributable)하며 완전히 감사 추적된 이력에 최적화되어 있지 않습니다. EM 이탈을 그 저장소로 라우팅한다면 규제 기록을 조용히 대시보드 메트릭으로 강등시키는 셈입니다. 도구 안에 경계를 긋는 것 — 관측성을 위한 Telegraf-to-VictoriaMetrics, 기록을 위한 Telegraf-to-PostgreSQL — 이 곧 정직한 하이브리드를 정직하게 유지하는 방법입니다.

왜 중요한가

충전·포장은 수백만 달러어치 배치가 판매 가능한 제품이 되거나 일탈(deviation)이 되는 지점입니다. 여기서의 데이터는 그 양에 비해 유난히 위험 부담이 큽니다. 이탈 행 하나, 리젝트 플래그 하나, 빠진 집약 링크 하나가 한 로트(lot)를 보류시키거나 망칠 수 있습니다. 이것을 올바르게 모델링하는 것 — 라인에는 PackML 상태, 낱개에는 GS1 SGTIN, 공기에는 부속서 1 등급 — 은 검사관이나 품질 조사관이 던지는 질문에 대한 답이, 정신없는 스프레드시트 재구성이 아니라 쿼리 하나에서 곧바로 떨어진다는 뜻입니다. 그리고 GxP 경계를 제대로 잡는다는 것은, 기록 시스템을 실수로 Grafana 패널로 바꿔버리지 않으면서 설비 데이터의 소방 호스 같은 폭주를 위해 쾌활하고 확장 가능한 오픈 소스 관측성 도구를 쓸 수 있다는 뜻입니다.

실제 현장에서는

상업용 충전 라인은 분당 수백 개의 바이알로 돌아가며, 시리얼라이제이션은 카메라 시스템과 프린터와 통신하는 전용 레벨-2/레벨-3 소프트웨어(Systech, Optel, SAP ATTP)가 처리하고, EM은 검증된 EM 데이터 관리자(data manager)에 데이터를 공급하는 검증된 입자 계수기 네트워크(Lighthouse, TSI, Particle Measuring Systems)가 처리합니다. 그 시스템들은 독점적(proprietary)이며 진정으로 노트북에서 돌아갈 수 없습니다 — 그래서 이 장은 책의 나머지와 마찬가지로 데이터 모양을 시뮬레이션하며, 벤더 고유의 세부 사항(카메라 리젝트 시그널링, 검증된 계수기 교정, DataMatrix에 새겨지는 정확한 GS1 인코딩)이 곧 실제 적격성 평가(qualification)가 일어나는 지점임을 분명히 합니다. NIIMBL의 SABRE 시설 — 델라웨어에서 건설 중인 NIIMBL / 델라웨어 대학교 파일럿 규모 cGMP(current Good Manufacturing Practice) 시설로, 2024년 4월에 착공 — 은 이런 데이터 흐름이 사후에 덧붙여지는 것이 아니라 첫날부터 설계에 반영되어야 하는 바로 그런 종류의 현장입니다. 여기서 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) — 직렬화된 품목의 부모/자식 포함 관계(바이알 → 카톤 → 케이스)를 기록하여, 부모를 한 번 스캔하면 그 자식들이 드러나게 하는 것.
  • 환경 모니터링(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 경계(GxP boundary) — 규제 기록(감사·보존됨)과 비-GxP 설비 관측성(엔지니어링 대시보드) 사이의 명시적인 선.
  • CFU(집락 형성 단위, colony-forming unit) — EM 표본에서 회수된 생존 미생물의 수.

다음 이야기

이제 우리는 공정이 내보내는 모든 것 — 바이오리액터 태그, 크로마토그래피 결정, 실험실 결과, 그리고 이 장의 마지막 한 구간인 충전·시리얼라이제이션·환경 데이터 — 을 포착했습니다. 그 모든 것이 거의 아무 언급 없이 TimescaleDB와 PostgreSQL에 안착해 왔습니다. 이제 그 저장소를 의도적인 선택으로 만들 때입니다. 다음 장 오픈 소스 히스토리언: 시계열 저장소 선택과 운영은 우리가 조용히 의존해 온 히스토리언을 열어 보입니다 — 하이퍼테이블(hypertable), 연속 집계(continuous aggregate), 보존이 실제로 어떻게 작동하는지, TimescaleDB가 IoTDB 같은 대안과 어떻게 견주는지, 그리고 어떤 오픈 소스 기능을 안전하게 기반으로 삼을 수 있고 어떤 라이선스 함정을 피해 가야 하는지를 다룹니다.