제4장
WIA-BIO-005가 세포 제품을 데이터로 표현하는 방식을 다룬다. 규격 §8.3.1의 기준 JSON 레코드, types.ts의 TypeScript 모델, 둘 사이의 간극, 그리고 레코드가 채집 기관에서 제조소를 거쳐 투여 병동에 이르기까지 신뢰성을 잃지 않게 하는 규칙을 설명한다.
제2장과 제3장은 세포에 일어나는 일, 즉 채집, 활성화, 형질도입, 증식, 수확, 출하 시험, 동결을 설명하였다. 이 모든 단계는 사실(fact)을 만들어 내며, 그 사실을 기록하는 사람과 그 사실에 근거해 행동하는 사람은 거의 언제나 다르다. 백혈구성분채집술 담당 간호사는 채집을 기록하고, 제조 작업자는 배양을 기록하며, QC 분석자는 생존율과 무균 결과를 기록하고, 동결 재고 담당자는 탱크 위치를 기록한다. 병원 약사와 투여 간호사는 이 모든 기록을 며칠 또는 몇 주 뒤에 읽는다. 자가 제품이라면 허용되는 결과는 하나뿐이다. 환자에게 투여되는 백에는 그 환자 자신의 세포가 들어 있어야 하고, 투여를 정당화하는 모든 수치가 바로 그 백을 가리켜야 한다.
이 요구는 두 개념으로 정리된다. 신원 연속성(chain of identity)은 환자(또는 공여자), 출발 물질, 제조 배치, 최종 용기를 끊김 없이 잇는 연결이다. 관리 연속성(chain of custody)은 누가, 어디서, 어떤 조건으로 물질을 보유했는지를 매 순간 끊김 없이 남긴 기록이다. 종이 양식으로도 두 연속성을 유지할 수는 있지만, 그러려면 모든 당사자가 식별자를 손으로 정확히 옮겨 적어야 한다. 기관 사이에 공유되는 기계 판독형 레코드를 쓰면 옮겨 적는 일이 대부분 사라지고, 소프트웨어가 연속성을 자동으로 점검할 수 있다. 일치하지 않는 식별자, 거꾸로 흐르는 날짜, 너무 따뜻한 보관 온도를 사람이 서명하기 전에 걸러 낼 수 있는 것이다.
WIA-BIO-005는 이 문제를 두 곳에서 다룬다. §8.3 「Data Formats」에는 세포 제품 JSON 예시가 하나 있고, 참조 SDK에는 이보다 훨씬 풍부한 TypeScript 타입 모델이 있다. JSON 예시는 짧다. 식별자, 세포 유형, 가명 환자 참조, 제조 사실 세 가지, 품질관리 결과 일곱 가지, 보관 사실 세 가지가 전부이다. 출하 레코드의 모양을 보여 주기에는 충분하지만, 그것만으로 §8.1의 다섯 가지 필수 구성요소를 돌릴 수는 없다. TypeScript 모델은 빈 곳을 상당 부분 채우지만 이름, 값 인코딩, 일부 의미가 다르다. 이 장은 둘 다 규범적 입력으로 다루고, 어디서 어긋나는지를 정확히 짚은 뒤, 규격의 JSON을 받아들이면서도 SDK와 함께 동작하는 매핑 규율을 제안한다.
이 장의 범위. 이 장은 데이터의 모양만 다룬다. 생존율, 특이 세포용해율, 체중당 엔도톡신 공식은 제3장에서 유도하였고, 이 레코드를 소비하는 알고리즘(용량 계산, 품질 등급, 배치 추적)은 제5장의 주제이다. 림프구제거 화학요법 같은 임상 필드는 여기서 데이터 구조로만 등장하며, 예시 속 어떤 값도 치료 지시가 아니다. 언제나 현행 제품 허가사항과 기관 프로토콜이 우선한다.
아래는 규격의 세포 제품 레코드이다. JSON은 주석을 허용하지 않으므로 해설은 뒤따르는 본문에서 따로 한다. 코드 블록 자체는 §8.3.1의 예시 그대로이며, 유효한 JSON으로 파싱된다.
{
"product_id": "CT-2024-001",
"cell_type": "CAR-T CD19",
"patient_id": "PT-12345",
"manufacturing": {
"start_date": "2024-01-15",
"completion_date": "2024-01-29",
"facility": "GMP Suite A"
},
"quality_control": {
"viability": 92.5,
"total_cells": 5.2e8,
"car_positive": 85.3,
"potency": 78.5,
"sterility": "negative",
"endotoxin": 0.25,
"mycoplasma": "negative"
},
"storage": {
"location": "LN2-Tank-3-A5",
"freeze_date": "2024-01-29",
"temperature": -185.5
}
}
레코드는 최상위 식별자 세 개와 중첩 그룹 세 개로 이루어진다. 필드별로 읽어 보면 레코드가 말하는 것과 수신 시스템이 추론해야 하는 것이 함께 드러난다.
product_id(예시에서는 "CT-2024-001")는 제품의 이름이다. 규격은 형식을 정하지 않으므로 연도와 일련번호로 된 패턴은 예시로만 읽어야 한다. 중요한 것은 발행 기관 안에서 유일하고, 배치가 실패하더라도 결코 재사용되지 않는다는 점이다. cell_type은 "CAR-T CD19"라는 자유 텍스트이며, 제품 계열(CAR-T)과 표적 항원(CD19)이 한 문자열에 섞여 있다. patient_id("PT-12345")는 수혜자에 대한 가명 참조이다. 이름, 생년월일, 주민등록번호 같은 국가 식별번호가 아니며, 왜 그래야 하는지는 4.7절에서 설명한다.
manufacturing 그룹
start_date와 completion_date는 ISO 8601 확장 형식(YYYY-MM-DD)의 달력 날짜이다. 예시에서는 두 날짜 사이가 14일이며, 이는 제2장의 10~14일째 수확 구간에 들어맞는다. 다만 레코드 자체는 어느 날이 수확일이었는지 밝히지 않는다. facility는 제조 구역을 사람이 읽을 수 있게 붙인 라벨이다. 시험성적서에는 유용하지만 식별자는 아니다. 두 제조소가 모두 "GMP Suite A"를 가지고 있을 수 있기 때문이다.
quality_control 그룹
이 그룹에는 제3장에서 설명한 출하 결과가 담긴다. viability(92.5)는 생존 유핵세포의 백분율이며, 규격 §4.2의 출하 기준 ≥85%를 충족한다. total_cells(5.2e8)는 JSON 지수 표기로 적혀 있고, 표준을 따르는 JSON 파서라면 모두 받아들인다. 제품의 총 유핵세포 수를 뜻한다. car_positive(85.3)는 CAR를 발현하는 세포의 백분율로, CAR-T 제품의 출하 하한 CAR+ ≥20%를 크게 웃돈다. potency(78.5)는 백분율이지만 그 근거가 무엇인지 레코드에 적혀 있지 않다. §4.1.3에 따르면 보통 작용세포:표적세포 비 10:1에서의 특이 세포용해율이며, 기준은 ≥70%이다. sterility와 mycoplasma는 둘 다 문자열 "negative"로, 미생물이 검출되지 않았다는 뜻이다. endotoxin(0.25)은 단위 없는 숫자이다. 이 레코드에서 가장 중대한 빈틈이며 4.5절에서 다룬다.
storage 그룹
location("LN2-Tank-3-A5")은 동결 재고 시스템 안의 기관별 주소이다. 예시에서 freeze_date는 완료일과 같은데, 수확 당일 제형화와 동결을 마치는 경우에 흔한 모습이다. temperature(−185.5)의 단위는 섭씨이다. 기상(vapour-phase) 액체질소 탱크로서 그럴듯한 값이며, §5.3의 −150 °C 이하 요건을 만족한다. 다만 숫자 하나는 한 순간만 기술할 수 있다는 점에 유의해야 한다. 동결 시점부터 판독 시점 사이의 온도 이탈에 대해 레코드는 아무것도 말하지 않는다. 그 이력은 연속 온도 모니터링 시스템의 몫이며, 레코드는 그것을 복제하기보다 참조해야 한다.
이 JSON만으로 업무를 처리하려는 수신 시스템은 필요한데 읽을 수 없는 필드를 곧 만나게 된다. 자가인지 동종인지, 제조 배치 식별자, 동종 원료의 공여자 식별자, 채집일, 측정된 생존 세포 수(유도만 가능), CAR+ 이외의 확인 시험 패널(예: CD3+), 백 용량(이것 없이는 엔도톡신을 EU/kg로 환산할 수 없다), 동결–해동 횟수, 유효기한, 그리고 결과를 출하 판정으로 바꾸는 서명이 모두 빠져 있다. 이 가운데 일부는 TypeScript 모델에 있고, 일부는 표준 어디에도 없다. 구현자는 이 JSON을 최소 교환 레코드로 취급해야 하며, 빠진 필드를 추측한 기본값으로 채워서는 안 된다.
참조 SDK(@wia/bio-005, 버전 1.0.0)는 api/typescript/src/types.ts에서 데이터 모델을 공개한다. 이 모델은 JSON 레코드보다 범위가 넓어 제품, 보관, 제조 공정, 품질관리, 투여, 안전성 모니터링, 동결보존을 모두 포괄한다. 이 절에서는 제품 쪽 타입을 살펴보고, 임상 타입은 4.6절에서 다룬다.
CellProduct
CellProduct는 중심 엔터티이다. 필수 속성은 productId, cellType, source, batchId, totalCells, viableCells, viability, dates(필수 collection·started, 선택 completed)이고, 선택 속성은 patientId, donorId, targetMarker, storage이다. 설계상 세 가지 결정은 따로 짚어 둘 만하다.
cellType은 열거형이다. 값은 CAR-T, CAR-NK, TIL, MSC, iPSC, HSC, DC, Treg, Other의 아홉 가지이다. 표적 항원은 타입의 일부가 아니며 targetMarker.name이나 제품 메타데이터에 들어간다. 뒤의 네 값은 규격 §2의 제품 계열에 대응하는 것이 없다(그 간극의 의미는 제1장에서 다루었다).source는 필수이다. 값은 autologous, allogeneic, xenogeneic 중 하나이다. JSON 레코드에는 이 필드가 없으므로 변환기는 다른 곳에서 값을 얻어 와야 한다.patientId와 donorId는 둘 다 선택이다. 따라서 타입 시스템은 둘 다 없는 제품도 허용한다. 검증기는 자가 제품에는 patientId를, 동종 제품에는 donorId를 요구해야 한다. 이것은 타입으로는 표현할 수 없는 업무 규칙이다.StorageInfo
StorageInfo는 location, temperature(°C), freezeDate, 선택 필드 expirationDate, 필수 필드 freezeThawCycles를 담는다. 마지막 두 필드는 JSON에 대응 항목이 없지만 운영상 모두 중요하다. 출하된 동결 제품의 동결–해동 횟수는 0이어야 하며, 다른 값이 있다면 QA가 설명해야 하는 관리 이벤트이다. expirationDate는 해당 제품의 검증된 안정성 자료에서 나와야 한다. 규격의 안정성 시험 프로그램은 24개월까지 이어지고(§4.3.1), 보관은 "최대 24개월까지 검증"되어 있다(§5.3). 이는 프로그램이 입증한 상한이지, 모든 제품에 자동으로 찍을 값이 아니다.
ManufacturingProcess와 ManufacturingStage
ManufacturingProcess는 아직 제조 중인 배치를 기술한다. batchId, cellType, currentStage, startDate, expectedCompletion, startingMaterial 블록(총 세포 수, 생존율, 선택적 CD3%·CD56%), parameters 블록(배지, 사이토카인, MOI, 목표 증식 배수, 배양 일수), currentMetrics 블록, alerts 배열, 그리고 cpp의 핵심 공정 변수가 들어간다. currentStage는 ManufacturingStage 열거형을 쓰며 값은 collection, activation, transduction, expansion, harvest, cryopreservation, complete, failed이다. 규격 자체의 ManufacturingBatch 인터페이스(§8.2.3)는 앞의 일곱 단계만 나열하고 failed는 없다. SDK가 추가한 이 값은 데이터 관점에서 중요하다. 실패한 배치도 레코드로 남아야 하며 조용히 사라져서는 안 되기 때문이다. 제6장에서 이 단계들을 전이 조건이 걸린 상태 기계로 다룬다. 여기서는 단계가 단일 값이라는 점만 기억하면 된다. 감사 추적을 재구성하려면 단계 변경 이력을 별도로 보관해야 한다.
QualityMetrics, IdentityMarkers, PotencyAssay
QualityMetrics는 SDK의 출하 데이터 구조로, viability, potency, sterility(불리언), endotoxin(숫자, 주석상 EU/mL), mycoplasma(불리언), identity, cellCount(총수, 생존 수, 선택적 표적 마커 양성 수), appearance(색, 투명도, 입자)로 이루어진다. IdentityMarkers는 마커 이름을 백분율에 대응시키는 두 사전 positive와 negative를 가진다(예: {"CD3": 96.1, "CAR": 85.3}). 이 모양은 규격 §8.2.2의 두 숫자짜리 identity 객체(targetMarker, contaminants)보다 §4.1.1의 제품별 패널에 훨씬 잘 맞는다. PotencyAssay는 역가를 어떻게 측정했는지 기록한다. type(cytotoxicity, cytokine-secretion, proliferation, other), method, 짝을 이루는 배열 etRatios와 specificLysis, pg/mL 단위의 선택적 사이토카인 사전, 요약값 potency, passed, comments가 그것이다.
QualityMetrics.potency(숫자 하나)와 PotencyAssay(근거)를 짝지은 구조는 본받을 만하다. 백분율 하나는 출하 대시보드에서 편리하고, 검토자가 그 백분율이 주장하는 바를 정말 의미하는지 판단하려면 시험 기록이 필요하다. 둘을 함께 저장해 두면, 여러 E:T 비에서 얻은 세포용해 곡선이 어느 비를 골랐는지 아무도 모르는 채 숫자 하나로 축약되는 일을 막을 수 있다.
CELLULAR_THERAPY_CONSTANTS는 검증기와 알고리즘이 쓰는 기준값을 모아 둔다. MIN_VIABILITY 85, MIN_POTENCY 70, MAX_ENDOTOXIN 5(주석상 EU/kg), MIN_RECOVERY 70, cells/kg 단위의 CAR-T·MSC 용량 범위, 제2장에서 소개한 CPP 한계, 그리고 보관 온도 세 가지(CRYOGENIC −150, ULTRA_LOW −80, REFRIGERATED 4)이다. 데이터 계층은 숫자를 다시 타이핑하지 말고 이 상수를 가져다 써야 한다. 새 SDK 버전에서 기준값이 바뀌면 가져오기 한 곳만으로 모든 검증기가 함께 갱신된다.
JSON 레코드와 TypeScript 모델은 같은 제품을 기술하지만 네 가지 면에서 체계적으로 다르다. 첫째는 명명 규칙이다. JSON은 snake_case(product_id), TypeScript는 camelCase(productId)를 쓴다. 둘째는 구조이다. JSON은 QC 결과를 제품 안의 quality_control에 묶지만, SDK는 이를 별도의 QualityMetrics 객체에 두고 CellProduct에는 생존율, 세포 수, 표적 마커만 남긴다. 셋째는 인코딩이다. JSON은 미생물 결과를 "negative"/"positive" 문자열로 쓰는 반면, TypeScript는 필드마다 의미가 다른 불리언을 쓴다. 넷째는 포괄 범위이다. 양쪽 모두 상대에게 없는 필드를 가지고 있다. 아래 표가 이 책이 권하는 매핑이다. "검증 규칙" 열은 수신 시스템이 값을 받아들이기 전에 적용해야 할 점검을 적은 것이며, 기준값은 규격 §4.2와 §5.3을 따른다.
| JSON (§8.3.1) | TypeScript | 단위 / 형 | 검증 규칙 |
|---|---|---|---|
product_id | CellProduct.productId | 문자열 | 필수, 비어 있지 않음, 유일, 재사용 금지 |
cell_type ("CAR-T CD19") | cellType + targetMarker.name / 표적 항원 | 열거형 + 자유 텍스트 | 첫 토큰이 CellType 값과 일치해야 함. 나머지는 표적으로 보존하고 버리지 않음 |
patient_id | patientId | 가명 문자열 | 자가 제품은 필수. 직접 식별자를 포함해서는 안 됨 |
| — | source | CellSource | TS에서 필수. 송신 측 맥락에서 받아야 하며 기본값 금지 |
| — | batchId | 문자열 | TS에서 필수. ManufacturingProcess와 연결 |
| — | donorId | 가명 문자열 | 동종 제품은 필수 |
| — | dates.collection | 날짜 | TS에서 필수. dates.started 이전이거나 같음 |
manufacturing.start_date | dates.started | ISO 8601 날짜 → Date | 유효한 달력 날짜. 완료일 이전이거나 같음 |
manufacturing.completion_date | dates.completed | ISO 8601 날짜 → Date | 시작일 이후, 동결일 이전이거나 같음 |
manufacturing.facility | CertificateOfAnalysis.manufacturing.facility | 문자열 라벨 | 참고용. 제조소 식별자와 함께 기록 |
quality_control.viability | viability, QualityMetrics.viability | % (0–100) | 범위 0–100. 출하 ≥85 |
quality_control.total_cells | totalCells, QualityMetrics.cellCount.total | 세포 수(숫자) | 유한값, > 0 |
| (유도값) | viableCells | 세포 수 | 측정값 우선. 유도했다면 유도값임을 표시 |
quality_control.car_positive | targetMarker = {name: "CAR", percentage}, identity.positive.CAR | % (0–100) | 범위 0–100. CAR-T 출하 ≥20 |
quality_control.potency | QualityMetrics.potency (+ PotencyAssay) | % (근거 명시 필수) | 범위 0–100. 근거가 10:1 특이 세포용해율이면 ≥70 |
quality_control.sterility ("negative") | QualityMetrics.sterility | 불리언, true = 적합 | "negative"만 true로. 그 밖의 값은 false |
quality_control.endotoxin | QualityMetrics.endotoxin | JSON은 단위 없음, TS 주석은 EU/mL | 단위와 제품 용량을 모르면 거부(4.5절 참조) |
quality_control.mycoplasma ("negative") | QualityMetrics.mycoplasma | 불리언, false = 적합 | "negative"만 false로. 그 밖의 값은 true |
storage.location | storage.location | 문자열 | 동결 재고 시스템에서 조회 가능해야 함 |
storage.freeze_date | storage.freezeDate | ISO 8601 날짜 → Date | 시작일 이후이거나 같음 |
storage.temperature | storage.temperature | °C | 동결 제품은 ≤ −150 |
| — | storage.freezeThawCycles | 정수 | TS에서 필수. ≥ 0. 출하 시 0이어야 함 |
| — | storage.expirationDate | 날짜 | 고정 오프셋이 아니라 검증된 안정성 자료에서 |
아래 함수는 규격 형식의 JSON 레코드를 CellProduct로 바꾼다. 일부러 엄격하게 만들었다. JSON이 제공할 수 없는 필드(source, batchId, 채집일, 동결–해동 횟수)는 호출자가 채집 오더나 MES 배치 기록 같은 권위 있는 시스템에서 받아 넘겨야 하며, 지어내서는 안 된다. 검증에 실패한 값은 코드 C010(INVALID_PARAMETERS)으로 CellularTherapyError를 던진다. SDK가 calculateDose의 잘못된 입력에 쓰는 것과 같은 코드이다.
import {
CellProduct, CellType, CellSource,
CellularTherapyError, CellularTherapyErrorCode,
} from '@wia/bio-005';
// 규격 §8.3.1 레코드의 모양(이 스케치가 읽는 필드만)
interface CellProductJson {
product_id: string;
cell_type: string;
patient_id?: string;
manufacturing: { start_date: string; completion_date?: string; facility?: string };
quality_control: { viability: number; total_cells: number; car_positive?: number };
storage?: { location: string; freeze_date: string; temperature: number };
}
// JSON에 없는 사실 — 권위 있는 시스템에서 받아야 한다
interface SenderContext {
source: CellSource;
batchId: string;
collectionDate: string; // ISO 8601
donorId?: string;
freezeThawCycles: number; // 동결 재고의 관리 기록에서
}
const CELL_TYPES: readonly CellType[] =
['CAR-T', 'CAR-NK', 'TIL', 'MSC', 'iPSC', 'HSC', 'DC', 'Treg', 'Other'];
function invalid(field: string, value: unknown): CellularTherapyError {
return new CellularTherapyError(
CellularTherapyErrorCode.INVALID_PARAMETERS,
`Invalid ${field}`, { field, value });
}
function isoDate(s: string, field: string): Date {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) throw invalid(field, s);
const d = new Date(`${s}T00:00:00Z`); // UTC 자정으로 명시
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== s) {
throw invalid(field, s); // 2024-02-30 같은 값 거부
}
return d;
}
function percent(v: unknown, field: string): number {
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 100) {
throw invalid(field, v);
}
return v;
}
export function toCellProduct(j: CellProductJson, ctx: SenderContext): CellProduct {
const [head, ...rest] = j.cell_type.trim().split(/\s+/);
if (!CELL_TYPES.includes(head as CellType)) throw invalid('cell_type', j.cell_type);
const target = rest.join(' ') || undefined; // 예: "CD19"
if (ctx.source === 'autologous' && !j.patient_id) throw invalid('patient_id', undefined);
if (ctx.source === 'allogeneic' && !ctx.donorId) throw invalid('donorId', undefined);
const qc = j.quality_control;
const viability = percent(qc.viability, 'viability');
if (!Number.isFinite(qc.total_cells) || qc.total_cells <= 0) {
throw invalid('total_cells', qc.total_cells);
}
const collection = isoDate(ctx.collectionDate, 'collectionDate');
const started = isoDate(j.manufacturing.start_date, 'start_date');
const completed = j.manufacturing.completion_date
? isoDate(j.manufacturing.completion_date, 'completion_date') : undefined;
if (collection > started) throw invalid('collectionDate', ctx.collectionDate);
if (completed && completed < started) throw invalid('completion_date', completed);
const product: CellProduct = {
productId: j.product_id,
cellType: head as CellType,
source: ctx.source,
patientId: j.patient_id,
donorId: ctx.donorId,
batchId: ctx.batchId,
totalCells: qc.total_cells,
viableCells: qc.total_cells * viability / 100, // 유도값 — 표 4-1 참조
viability,
dates: { collection, started, completed },
};
if (qc.car_positive !== undefined) {
product.targetMarker = { name: target ? `CAR (${target})` : 'CAR',
percentage: percent(qc.car_positive, 'car_positive') };
}
if (j.storage) {
product.storage = {
location: j.storage.location,
temperature: j.storage.temperature,
freezeDate: isoDate(j.storage.freeze_date, 'freeze_date'),
freezeThawCycles: ctx.freezeThawCycles,
// expirationDate는 일부러 비워 둔다 — 안정성 자료에서 부여
};
}
return product;
}
스케치의 몇몇 세부는 의도된 것이다. "CAR-T" 뒤의 표적 항원은 버리지 않고 마커 이름에 보존한다. CD19 제품과 BCMA 제품은 하류의 어떤 점검에서도 서로 바꿔 쓸 수 있어서는 안 되기 때문이다. 날짜는 UTC 자정으로 명시해 파싱한 뒤 다시 문자열로 되돌려 비교한다. 그래야 2월 30일 같은 불가능한 날짜가 조용히 3월 1일로 넘어가지 않고 거부된다. Node.js처럼 V8 기반 런타임의 Date 생성자는 실제로 그렇게 넘겨 버린다. 생존 세포 수는 계산하되 유도값임을 표시한다. 세포 계수기가 측정한 생존 세포 수를 보고했다면 그 값을 써야 한다. expirationDate를 일부러 비워 둔 것은 SDK의 시험성적서 생성기를 그대로 따르는 것보다 안전하기 때문이다(4.5절 참조).
반대 방향, 즉 §8.3.1을 기대하는 상대에게 CellProduct를 규격 JSON으로 내보내는 경우에는 정보가 사라진다. source, batchId, donorId, freezeThawCycles를 넣을 자리가 없다. 따라서 내보내기 모듈은 이 값들을 버리지 말고, 상대와 합의한 이름공간의 확장 필드(예: "x_wia_ext" 객체)로 명확히 구분해 덧붙여야 한다. 규격은 확장 메커니즘을 정의하지 않으므로, 사용하는 이름은 양자 간 합의이며 그렇다는 사실을 문서화해야 한다.
데이터 교환의 실패는 대부분 필드가 없어서가 아니라, 값은 있는데 쓴 사람과 읽는 사람에게 다른 의미를 가져서 생긴다. 아래는 WIA-BIO-005가 암묵적으로 전제하는 규칙과, 규격과 SDK가 열어 둔 함정이다.
표준의 모든 백분율(생존율, CAR+, 역가, 회수율, 확인 마커)은 0–1의 분수가 아니라 0–100 척도로 표현한다. SDK는 calculateDose에서 생존율에 대해 이를 강제하며, 0–100 밖의 값은 C010을 던진다. 같은 범위 점검을 모든 백분율 필드에 적용해야 한다. 생존율 0.925는 부주의한 사용자가 입력한 "92.5%"가 아니라 생존율 0.925%이며, 그렇다면 출하 기준에 부적합으로 판정되는 것이 옳다. 검증기는 (0, 1] 구간의 생존율 값을 조용히 100배 하지 말고, 척도 오류 가능성을 알리는 메시지와 함께 수동 확인 대상으로 보류해야 한다.
세포 수는 평범한 숫자이며 편의상 지수 표기(5.2e8)로 쓴다. IEEE 754 배정밀도 실수는 253까지의 정수를 정확히 표현하므로 어떤 세포 수도 부동소수점 저장에서 잃는 것이 없다. 실제 위험은 다른 데 있다. 세포 수는 반드시 분모를 밝혀야 한다. §8.3.1의 "total cells"는 제품의 총 유핵세포 수이고, "cells/kg"는 용량이며, "cells/mL"는 농도이다(CryopreservationProtocol.cellConcentration, CPP의 세포 밀도처럼). 필드 이름이 분모를 드러내야 한다. totalCells와 cellDensity는 그렇게 하지만, 맨 count라는 이름은 그렇지 못하다.
음의 보관 온도를 포함해 모든 온도의 단위는 섭씨이다. 동결 제품 검증기는 CELLULAR_THERAPY_CONSTANTS.STORAGE.CRYOGENIC(−150)과 비교해야 하며, "더 차갑다"는 "더 음수"라는 뜻임을 잊지 말아야 한다. 점검식은 temperature <= -150이다. 보관 필드에 양수가 들어 있다면 거의 확실히 부호 오류나 단위 오류이므로, 경고가 아니라 거부로 처리해야 한다.
규격 §4.2의 출하 기준은 1회 투여량 기준 엔도톡신 체중 1 kg당 5 EU 미만이다. 그런데 JSON 레코드는 단위 없이 "endotoxin": 0.25라고만 적는다. QualityMetrics.endotoxin의 주석은 EU/mL이고 CELLULAR_THERAPY_CONSTANTS.MAX_ENDOTOXIN의 주석은 EU/kg인데, SDK는 두 값을 그대로 비교한다. 농도와 체중당 용량은 투여 용량과 환자 체중이라는 두 사실 없이는 비교할 수 없다. 환산식(EU/mL × 용량 mL ÷ 체중 kg)은 제3장에서 유도하였다. 데이터 모델에 주는 함의는 분명하다. 제품 레코드는 측정 농도를 단위와 함께, 그리고 최종 용기 용량을 함께 담아야 한다. 그래야 환자 체중이 확정되는 시점에 kg당 값을 계산할 수 있다.
"quality_control": {
"endotoxin": { "value": 0.25, "unit": "EU/mL" },
"container_volume_ml": 50
}
위 조각은 권장 확장이며 §8.3.1의 일부가 아니다. 50 mL는 예시 값이다. 단위 없는 숫자를 만난 수신 시스템은 단위를 추측하지 말고, 출하 목적으로는 레코드를 거부(C010)해야 한다. 너그러운 쪽으로 추측하면 엔도톡신 부하를 모르는 제품이 출하되고, 엄격한 쪽으로 추측하면 멀쩡한 제품이 폐기된다. QC 레코드에서는 둘 다 받아들일 수 없다. 5 EU/kg은 비경구 투여 제품의 일반 한계이다. 레코드가 정맥 이외의 경로를 기술한다면(AdministrationRoute에는 intrathecal이 있다) 적용 한계는 훨씬 낮다(척수강 내 투여는 0.2 EU/kg). 엔도톡신을 경로와 용량 맥락 없이 판정할 수 없는 또 하나의 이유이다.
JSON의 날짜는 ISO 8601 문자열이고, TypeScript는 Date 객체를 쓴다. 여기서 두 함정이 생긴다. 첫째, ECMAScript 명세에 따르면 "2024-01-15" 같은 날짜 전용 문자열은 UTC 자정으로 파싱되지만, 오프셋 없는 날짜-시각 문자열은 로컬 시각으로 파싱된다. 따라서 같은 레코드가 시간대가 다른 서버에서 서로 다른 순간을 가리킬 수 있다. 둘째, UTC 자정 날짜를 그리니치보다 서쪽 시간대에서 표시하면 하루 전 날짜가 나타난다. 안전한 규칙은 이렇다. 달력 날짜는 날짜 전용 문자열로, 순간(동결 시작, 해동 시작, 투여 시작)은 명시적 오프셋이나 "Z"가 붙은 완전한 날짜-시각으로 저장하고, 한 필드에 둘을 섞지 않는다. 해동부터 투여까지의 시간처럼 규격이 4시간 미만을 요구하는 시간 임계 구간에는 완전한 순간 값만 허용된다.
SDK의 두 미생물 불리언은 서로 반대 방향을 가리킨다. sterility: true는 무균 시험이 적합(증식 없음)하다는 뜻이며, SDK는 metrics.sterility === true를 점검한다. mycoplasma: false는 마이코플라스마가 검출되지 않았다는 뜻이며, SDK는 metrics.mycoplasma === false를 점검한다. JSON 레코드는 둘 다 "negative"로 적어 모호함을 피한다. 그러나 두 필드 모두 "negative"를 false로 옮기는 변환기는 무균인 제품을 전부 부적합으로 판정하고, 두 필드 모두 true로 옮기는 변환기는 오염된 제품을 전부 적합으로 판정한다. 뒤의 실패는 조용하고 위험하다. 표 4-2가 매핑을 고정한다. 스키마를 직접 정할 수 있다면, JSON이 이미 그렇게 하듯이 두 시험 모두 명시적인 결과 문자열을 쓰는 편이 낫다.
| 시험 | JSON (§8.3.1) | SDK 불리언 | 의미 | 출하 |
|---|---|---|---|---|
| 무균 | "negative" | true | 증식 검출 안 됨 | 적합 |
| 무균 | "positive" | false | 증식 검출 | 부적합(C003) |
| 마이코플라스마 | "negative" | false | 검출 안 됨 | 적합 |
| 마이코플라스마 | "positive" | true | 검출 | 부적합 |
| 공통 | 누락, "pending", 기타 | — | 최종 결과 없음 | 출하 불가. 불리언으로 옮기지 말 것 |
실무에서는 마지막 행이 가장 중요하다. 무균 배양에는 14일이 걸리며(제3장), 제품은 제조사의 승인된 공정이 정한 조건에 따라 그 결과가 확정되기 전에 출고될 수도 있다. 레코드는 "pending"을 명시적으로 표현할 수 있어야 한다. 불리언은 그럴 수 없으므로, 중간 레코드에는 결과 문자열이나 null 허용 필드가 필요하다. "pending"을 false나 true에 억지로 밀어 넣으면 QA에게 꼭 필요한 정보가 사라진다.
SDK의 generateCertificate는 여러 필드를 고정값으로 채운다. 동결일로부터 24개월 뒤의 유효기한, 승인자 "QC Manager", 제조소 "GMP Manufacturing Suite", 온도가 주어지지 않았을 때의 보관 온도 −150, 그리고 운송 조건 "Dry ice or liquid nitrogen vapor phase"가 그것이다. 이 가운데 측정되었거나 승인된 사실은 하나도 없다. 운송 문구는 동결 세포 제품에 대해 명백히 틀렸다. 드라이아이스는 약 −78 °C에서 승화하므로 ≤−150 °C 보관 요건보다 훨씬 따뜻하며, 규격과 모순되지 않는 방법은 액체질소 기상 운송 용기뿐이다. 데이터 계층은 성적서 생성기의 출력을 QA가 완성해야 할 서식으로 다루고, 권한 있는 사람이 실제 값을 입력하기 전까지 기본값으로 채워진 필드를 모두 "미설정"으로 표시해야 한다.
제품이 제조소를 떠나면 데이터 모델의 중심은 백에서 환자로 옮겨 간다. types.ts는 CellProduct와 나란히 놓이는 임상·동결생물학 구조들을 정의한다. 여기서는 데이터로만 설명한다. 이 구조를 어떻게 채우고 어떻게 행동으로 옮기는지는 제6장과 제7장의 주제이며, 그 안에 들어가는 모든 임상 값은 이 책이 아니라 현행 제품 허가사항과 기관 프로토콜이 정한다.
PatientDose와 AdministrationRoute
PatientDose는 환자와 제품을 묶는다. patientId, productId, cellType, 계산된 dose(권장 범위와 그 단위 cells/kg 또는 total cells를 포함한 DoseResult 전체), route, scheduledDate, 선택 필드 actualDate·preMedications·lymphodepletion로 이루어진다. AdministrationRoute는 intravenous, intra-arterial, intrathecal, intraperitoneal, local-injection을 열거한다. 숫자 하나 대신 DoseResult 전체를 저장하는 것은 좋은 관행이다. 그러면 용량 기록이 어떤 범위와 어떤 단위에 대해 판정되었는지까지 보여 주며, 제5장에서 다루는 TIL 총용량 문제 같은 단위 오류가 바로 여기서 드러난다.
LymphodepletionRegimen
요법은 name, drugs 배열(각 항목에 name, 자유 텍스트 dose 문자열, days 목록), startDate, completionDate, daysBeforeInfusion으로 이루어진다. 자유 텍스트 용량은 자동 점검에는 약점이지만 정직한 선택이기도 하다. 약제마다 용량 단위가 다르고(예: 체표면적 m²당과 체중 kg당), 문자열로 두면 적어도 처방자가 쓴 표현이 그대로 남는다. 기계로 점검 가능한 용량이 필요한 구현이라면 문자열을 파싱하지 말고 값·단위·기준 필드를 구조화해 추가해야 한다. 아래 예시는 구조만 보여 주며, 용량 문자열은 자리표시자이다.
const regimen: LymphodepletionRegimen = {
name: 'Flu/Cy',
drugs: [
{ name: 'fludarabine', dose: '[기관 오더 세트에 따름]', days: [-5, -4, -3] },
{ name: 'cyclophosphamide', dose: '[기관 오더 세트에 따름]', days: [-5, -4, -3] },
],
startDate: new Date('2024-02-05T08:00:00+09:00'),
completionDate: new Date('2024-02-07T12:00:00+09:00'),
daysBeforeInfusion: 3,
};
// 구조 예시일 뿐이며 치료 지시가 아니다. 약제·용량·투여일은
// 현행 제품 허가사항과 기관 프로토콜이 정한다.
days는 상대 표기(투여일 0일 이전은 음수)를 쓰고 daysBeforeInfusion은 양의 개수라는 점에 주의해야 한다. 타입은 둘 사이의 일관성을 강제하지 않는다. 검증기는 강제할 수 있다. 투여일을 0일로 둘 때 마지막 투약일 이후의 간격이 기관의 계산 규칙에 따라 daysBeforeInfusion과 같아야 하며, 그 계산 규칙 자체도 문서화해야 한다.
CRSAssessment와 ICANSAssessment
둘 다 한 환자에 대한 시각 기록된 관찰이며, patientId, timestamp, daysPostInfusion을 키로 하고 grade는 0–4로 제한된다. CRSAssessment는 temperature(°C), hypotension, vasopressors(none / single / multiple), hypoxia, oxygenRequirement(none / low-flow / high-flow / ventilator), 선택적 biomarkers(CRP mg/dL, 페리틴 ng/mL, IL-6·IFN-γ pg/mL), 선택적 CRSTreatment를 기록한다. ICANSAssessment는 iceScore, iceComponents, consciousness, seizures, motorFindings, cerebralEdema, 선택적 ICANSTreatment를 기록한다.
여기서 짚어야 할 데이터 품질 문제가 하나 있다. 문제가 타입 자체에 있기 때문이다. iceComponents 객체에는 지남력 0–4, 이름 대기 0–3, 명령 수행 0–2, 쓰기 0–1의 네 필드만 있고 주의력 필드가 없다. ASTCT 2019 합의안이 정의한 ICE 점수는 다섯 영역, 즉 지남력(4점), 이름 대기(3점), 명령 수행(1점), 쓰기(1점), 주의력(1점)으로 이루어진다. 따라서 SDK 타입으로는 올바른 ICE 세부 점수를 표현할 수 없다. 구현은 attention 필드를 추가하고, following을 0–1로 제한하며, 세부 점수의 합이 iceScore와 같은지 검증해야 한다. 이 정오(erratum)의 임상적 측면은 제7장에서 다룬다.
SafetyEvent
SafetyEvent는 일반 이상사례 레코드이다. eventId, patientId, type(CRS, ICANS, infection, cytopenia, other), 1–5의 severity, description, onsetDate, 선택 필드 resolutionDate·treatment·outcome(resolved, resolving, ongoing, fatal), serious 플래그, 그리고 인과성 척도 related(definite, probable, possible, unlikely, unrelated)로 이루어진다. 두 필드는 혼동하기 쉽다. severity는 강도이며, 통상적 독성 등급 방식을 따른 1–5 척도에서 5는 관례상 사망을 뜻한다. serious는 규제상 분류이다(ICH E2A의 의미로, 사망, 생명 위협, 입원 또는 입원 연장 등). 중증(severe) 사례가 반드시 중대(serious)한 것은 아니며, 그 역도 마찬가지이다. 타입처럼 두 필드를 모두 두는 것이 옳다. 다만 검증기는 자명한 교차 점검을 강제할 수 있다. severity: 5이거나 outcome: 'fatal'이면 serious: true여야 한다.
SafetyEvent에는 productId가 없다. 한 번 투여받은 자가 환자라면 PatientDose를 통해 추론할 수 있지만, 둘 이상의 제품을 투여받은 환자라면 불가능하다. 약물감시(WIA-PHARMA, 제7장)에 보고하는 구현은 제품 참조를 명시적으로 추가해야 한다.
CryopreservationProtocol은 protocolId, cryoprotectant 객체(dmso, 선택적 hsa·dextran, 모두 %, 그리고 자유 텍스트 other), freezingMethod, 선택적 coolingRate(°C/min), targetTemperature, storageTemperature, containerType(cryovial, cryobag, straw), fillVolume(mL), cellConcentration(cells/mL)을 기록한다. 백분율 필드에는 타입이 밝히지 않은 규칙이 하나 필요하다. 각 값이 동결 제품 속 최종 농도인지, 구성 용액의 비율인지를 정해야 한다. 그 규칙이 없으면 두 기관이 같은 제형을 서로 다른 숫자로 기록할 수 있다. 프로토콜 문서가 기준을 밝히고, 검증기는 선언된 기준에서 공존할 수 없는 조합을 거부해야 한다. 제형은 제6장에서 정성적으로 설명한다.
ThawingProtocol은 method(water-bath, dry-thaw, bedside), temperature, 분 단위 duration, washSteps, 선택적 dilutionMedia·restTime, qualityCheck를 기록한다. PostThawQuality는 viability, recovery, potencyRetained, acceptable, 그리고 시간 단위의 선택적 timeToInfusion을 기록한다. 해동 시간은 분, 투여까지의 시간은 시간처럼 시간 단위가 섞이는 것은 전형적인 오류 원인이다. 데이터 사전은 모든 기간 필드의 단위를 밝혀야 하고, 사용자 화면은 값 옆에 단위를 함께 표시해야 한다.
아래 다이어그램은 제품 쪽 레코드와 환자 쪽 레코드가 어떻게 연결되는지 보여 준다. 관계 차수는 가장 흔한 자가 제품의 경우를 따른다. 동종 제품에서는 공여자가 추가되고, 한 배치가 여러 환자에게 쓰일 수 있다.
Donor (동종) Patient (가명 patientId)
| 1 | 1 | 1 | 1
| N | N | N | N
+------------+ +---------+ CRSAssessment / SafetyEvent
| | ICANSAssessment (+ productId, 확장)
v v
ManufacturingProcess (batchId) --- 1:N --- Alert
| 1 \
| \ 1:N
| N QualityMetrics / PotencyAssay
CellProduct (productId) (공정 중 + 출하)
| 1 | 1 \
| 1 | 0..1 \ 1:0..1
StorageInfo CertificateOfAnalysis PatientDose ---- 1:0..1 --- LymphodepletionRegimen
(+ 관리 기록) |
+-- ThawingProtocol / PostThawQuality
SDK 타입에 드러나지 않아 구현이 추가해야 하는 연결이 세 가지 있다. StorageInfo 뒤의 관리 기록(모든 이동과 모든 온도 이탈), SafetyEvent의 제품 참조, 그리고 실제로 투여된 특정 백의 해동·해동 후 품질 기록과 PatientDose를 잇는 연결이다.
수신 시스템은 모든 레코드를 세 층위에서 검증하고, 층위마다 실패를 다르게 보고해야 한다.
patientWeight <= 0에 대해 C010을 던진다), 날짜 순서가 맞는지(채집 ≤ 시작 ≤ 완료 ≤ 동결), 동결 제품의 보관 온도가 −150 °C 이하인지, ICE 세부 점수의 합이 ICE 점수와 같은지, 공여자·환자 참조가 선언된 원천과 맞는지를 점검한다. 여기서의 실패도 레코드가 잘못되었다는 뜻이다. 품질 부적합이 아니라 데이터 오류이다.2층과 3층을 분리하는 것이 이 장에서 가장 유용한 규율이다. 생존율 84.9%는 출하 기준에 부적합한 유효한 데이터이고, 생존율 184.9%는 무효한 데이터이다. 둘 다 "생존율 부적합"으로 보고하는 시스템은 전사 오류를 품질 이벤트 뒤에 숨기거나 그 반대가 되며, 두 경우는 조사 방식이 전혀 다르다.
| 레코드 | 업무 규칙상 추가로 필수인 항목 | 이유 |
|---|---|---|
CellProduct(자가) | patientId | 수혜자까지의 신원 연속성 |
CellProduct(동종) | donorId | 공여자 선별 검사까지의 추적성(제2장) |
출하 시 CellProduct | expirationDate를 포함한 storage | 이것 없이는 C009를 점검할 수 없음 |
출하 시 QualityMetrics | 엔도톡신 단위와 용기 용량 | 기준이 체중 kg당이므로 |
QualityMetrics.potency | 연결된 PotencyAssay | 방법과 E:T 없이는 백분율이 의미 없음 |
SafetyEvent | 제품 참조 | 둘 이상의 제품을 투여한 경우의 귀속 |
| 모든 시간 임계 구간 | 오프셋을 포함한 완전한 날짜-시각 | 해동–투여 시간을 계산할 수 있어야 함 |
규격의 예시는 "patient_id": "PT-12345"를 쓰며, 이 책은 이 필드를 설계 단계부터 가명으로 다룰 것을 권한다. 제품 레코드는 채집 기관, 제조소, 운송 업체, 병원을 오가며, 그중 일부는 환자가 누구인지 알 필요가 없다. 손에 든 백이 오더의 주인과 같은 사람의 것이라는 사실만 알면 된다. 치료 기관(또는 제조사의 환자 일정 관리 시스템)이 발급한 가명이 그 연결을 제공하고, 가명과 실제 신원의 대응표는 이미 그것을 보유한 임상 시스템 안에 머문다. EU GDPR에서 가명처리된 데이터는 여전히 개인정보이다(어딘가에 키가 존재하기 때문이다). 가명처리는 노출을 줄여 주지만 의무를 없애 주지는 않는다. 미국 HIPAA 체계와 한국 개인정보 보호법에서도 비슷한 논리가 적용된다.
가명이라고 해서 무의미한 값이라는 뜻은 아니다. 투여 직전 병상 신원 확인(제6장)에서는 병원이 인식하는 식별자로 백 라벨과 눈앞의 환자를 대조한다. 인체 유래 의료 제품의 라벨링을 위해 ISBT 128 같은 체계가 있지만, WIA-BIO-005는 특정 체계를 의무화하지 않는다. 표준의 데이터 모델이 보장해야 하는 것은, 제품 레코드의 환자 참조를 병원이, 그리고 권한 있는 사람만이 언제든 병상의 그 사람으로 해석할 수 있다는 점이다.
notes나 comments 같은 자유 텍스트 필드는 레코드가 발생 시스템을 떠나기 전에 식별자가 섞였는지 걸러 내야 한다.세포치료 기록에는 관할권별 보존 요건이 적용되며, 올바른 기간은 제품의 규제상 지위와 사용 국가에 따라 달라진다. WIA-BIO-005는 보존 기간을 정하지 않으며, 구현도 이를 하드코딩해서는 안 된다. 보존 기간은 QA와 규제 담당 부서가 소유하는 관할권별 설정이어야 한다(규제 지형은 제8장에서 다룬다). 기간이 얼마이든, 감사 추적은 그것이 설명하는 모든 레코드보다 오래 살아남아야 한다. 출하에 관련된 필드가 바뀔 때마다 이전 값, 새 값, 변경자, 변경 시각, 변경 사유를 남겨야 한다. 0.925를 92.5로 바꾸는 식으로 값을 조용히 "고치는" 검증기는 이 규칙을 깨뜨린다. 4.4절의 변환기를 엄격하게 만든 또 하나의 이유이다.
장 요약
types.ts는 이름·인코딩·포괄 범위가 다른 더 풍부한 모델을 정의한다.true와 마이코플라스마 false는 둘 다 "적합"이다. "pending"은 불리언이 담을 수 없는 세 번째 상태이다.