제5장
규격 §8.2의 인터페이스에서 CellularTherapySDK 클래스와 wia-bio-005 명령줄 도구까지 — 각 메서드가 무엇을 계산하는지, 어디서 규격과 어긋나는지, 그리고 어떻게 안전하게 감싸야 하는지를 다룬다.
제2장과 제3장은 WIA-BIO-005의 실험실 쪽, 즉 배치를 어떻게 키우고 어떤 공정 중 시험을 거치며 어떤 출하 시험으로 시설 밖 반출 여부를 정하는지를 설명했다. 제4장은 그 결과를 담는 데이터 형태를 제시했다. 이 장에서는 그 숫자들이 실제로 동작하는 코드가 된다. 규격 §8.1은 적합한 시스템이라면 다섯 가지 구성요소 — 용량 계산기(Dose Calculator), 품질 평가기(Quality Assessor), 제조 추적기(Manufacturing Tracker), 안전성 모니터(Safety Monitor), 규정 준수 점검기(Compliance Checker) — 를 반드시 갖추어야 한다고 규정하고, §8.2에서 앞의 세 가지에 대한 인터페이스를 개략적으로 제시한다. 표준과 함께 공개된 참조 구현은 TypeScript 패키지(@wia/bio-005, 버전 1.0.0, MIT 라이선스)이며 주된 내보내기(export)는 CellularTherapySDK 클래스이다. 여기에 Bash 스크립트 cli/wia-bio-005.sh가 더해지는데, install.sh가 이를 wia-bio-005라는 이름으로 실행 경로에 복사한다.
필자들은 두 산출물을 한 줄씩 읽고 소스 사본에서 직접 실행해 보았다. 그 결과를 있는 그대로 보고한다. 구현이 규격과 다르게 동작하는 곳, 임상 시스템이 의존해서는 안 되는 곳도 빠짐없이 적었다. 이는 의도적인 선택이다. 참조 코드가 정확히 어디서 멈추는지 아는 통합 개발자는 코드를 포크하거나 무조건 신뢰하는 대신, 얇고 감사 가능한 계층으로 감쌀 수 있다. 코드에서 인용하는 모든 임상 수치 — 용량, 약물명, 모니터링 간격 — 는 소프트웨어에 기록된 값일 뿐 치료 지침이 아니다. 언제나 현행 제품 허가사항(라벨)과 기관의 자체 프로토콜이 우선한다.
규격 §8.2는 TypeScript 인터페이스 세 쌍, 즉 DoseRequest/DoseResponse, QualityMetrics/QualityResult, ManufacturingBatch를 정의한다. 이것이 어떤 종류의 API인지 정확히 짚어 둘 필요가 있다. 규격이 기술하는 것은 프로세스 내부의 함수 시그니처이지 네트워크 프로토콜이 아니다. WIA-BIO-005에는 규범적인 URL, HTTP 메서드, 인증 방식, 호출 한도가 없으며, SDK 역시 네트워크 호출을 전혀 하지 않는다. 모든 메서드는 전달받은 객체에 대한 순수한 동기 계산이다(타임스탬프를 위해 시스템 시계를 읽는 것만 예외이다). 아키텍처상 구성요소를 웹 서비스로 노출한다면, 엔드포인트 설계와 전송 보안, Date 필드의 직렬화는 모두 구현 조직이 정해야 하며 표준의 범위 밖에 있다.
SDK는 §8.2의 취지는 따르지만 문면까지 따르지는 않는다. 여러 타입의 허용 범위를 넓히고, 규격에 없는 필드를 더하며, 열거값 하나의 이름을 바꾸었다. 어떤 시스템이 "§8.2를 구현했다"고 주장하려 할 때 마주치게 될 대응 관계를 아래 표에 정리했다.
| 규격 §8.2 | SDK 타입 | 통합 시 처리해야 할 차이 |
|---|---|---|
DoseRequest | DoseParameters | strategy가 추가되었고 타입상 필수이다('weight-based' | 'flat-dose' | 'split-dose'). cellType은 다섯 가지가 아니라 아홉 가지 값을 받는다(HSC, DC, Treg, Other 추가). |
DoseResponse | DoseResult | targetMarkerCells, assessment, notes, 그리고 recommendedDose 안의 unit이 추가되었다. dose 필드는 cells/kg로 문서화되어 있지만 고정 용량 모드에서는 총 세포 수를 담는다. |
QualityMetrics | QualityMetrics | 이름은 같고 형태는 다르다. 규격의 identity{targetMarker, contaminants}가 마커별 키를 갖는 identity{positive:{…}, negative:{…}}로 바뀌었고, cellCount와 appearance가 추가되어 필수가 되었다. |
QualityResult | QualityResult | 규격의 권고값은 release | conditional | reject인데 SDK는 release | conditional-release | reject | retest를 반환하고, 기준별 체크리스트 criteria를 덧붙인다. |
ManufacturingBatch | ManufacturingProcess | stage가 currentStage로 바뀌고 failed가 추가되었다. startingMaterial, parameters, cpp(핵심 공정 변수)가 추가되었으며 expansionRatio는 선택 입력이다. |
여기서 실무적 결과가 두 가지 나온다. 첫째, 규격의 인터페이스에 맞는 JSON 문서는 어댑터 없이 SDK 타입 검사를 통과하지 못하며 그 반대도 마찬가지이다. 제4장에서 제품 레코드의 필드 매핑을 제시했는데, 같은 규율이 여기에도 적용된다. 둘째, 외부에 공개하는 인터페이스는 어느 어휘를 쓰는지 명시해야 한다. conditional을 기대하는 수신 시스템이 conditional-release를 받으면 대개 "알 수 없는 값" 분기로 빠지는데, 출하 판정 워크플로에서 그것은 가장 피하고 싶은 종류의 뜻밖의 상황이다.
그림 5-1은 다섯 가지 필수 구성요소를 이를 구현하는 SDK 메서드와 나란히 배치한 것이다. 네 구성요소에는 코드가 있다. 다섯 번째인 규정 준수 점검기에는 없다. SDK에는 규제상의 완결성, 서명, 보고 기한, 감사 추적을 점검하는 메서드가 하나도 없다. 이 구성요소가 무엇을 검증해야 하는지는 제8장에서 다룬다. 지금은 그것을 직접 만들어야 한다는 사실만 알아 두면 충분하다.
CellularTherapySDK 메서드가 뒷받침하며(독립 함수 제공 여부는 5.2~5.5절에서 설명), 점선 상자에는 참조 구현이 없다.
패키지는 독립 함수 형태의 래퍼 — calculateDose, assessQuality, trackManufacturing, monitorSafety, generateCertificate — 도 내보낸다. 각 함수는 새 CellularTherapySDK를 생성해 그대로 위임할 뿐이다. 클래스는 버전 문자열 외에 상태를 갖지 않으므로 두 방식은 서로 바꿔 써도 된다. assessCRS, assessICANS, assessPostThawQuality는 메서드로만 존재한다.
calculateDose: 용량 계산기
calculateDose(params: DoseParameters)는 세 가지 방어 검사로 시작한다. totalCells가 0 이하이거나, viability가 0–100을 벗어나거나, patientWeight가 0 이하이면 각각 코드 C010(INVALID_PARAMETERS)의 CellularTherapyError를 던진다. 입력 검사는 이것이 전부이다. cellType 누락, 100을 넘는 targetMarkerPercent, NaN 체중은 거부되지 않는다(NaN과의 비교는 모두 거짓이므로 NaN은 검사를 통과해 결과로 번진다). 이런 값은 호출 전에 검증해야 한다.
계산은 두 단계이다. 총 생존 세포 수는 totalCells × viability / 100이다. targetMarkerPercent가 주어지면 표적 마커 양성 세포(CAR-T 제품이라면 CAR+ 분획)는 totalViableCells × targetMarkerPercent / 100이다. 여기서 totalCells의 의미에 주의해야 한다. 이는 생존율을 적용하기 전의 제품 총 유핵세포 수이다. 규격 §6.1.1의 계산 예시는 입력을 "총 생존 세포 2.5 × 10⁸, 생존율 95%"라고 기술하면서도 다시 0.95를 곱한다. LIMS가 이미 생존 세포 수를 보고한다면, 그 값을 생존율과 함께 totalCells로 넘기는 순간 생존율이 두 번 적용되어 일반적인 제품에서 용량이 5–15% 과소 산출된다. 어느 세포 수를 함수에 넣을지 정하고, 자체 인터페이스의 필드 이름도 그에 맞게 붙여야 한다.
strategy: 'weight-based'(실행 시점에 속성이 없으면 이것이 기본값이다)이면 용량은 체중 kg당 생존 세포 수이고, 권장 범위는 cellType에 따라 달라진다. CAR-T에 한해서만, 표적 마커 세포 수가 있으면 그것으로 용량을 다시 계산하므로 결과가 CAR+ cells/kg가 된다. 다른 모든 유형에서는 targetMarkerPercent가 계산되어 반환되기는 하지만 용량에는 영향을 주지 않는다. 그 밖의 전략 — flat-dose, 그리고 의외로 split-dose까지 — 는 모두 하나의 분기로 들어가, 세포 유형과 관계없이 총 생존 세포 수를 1 × 10⁸–6 × 10⁸ "총 세포"라는 고정 범위와 함께 반환한다.
| 조건 | 최소 | 최대 | 단위 | 코드상 출처 | 비고 |
|---|---|---|---|---|---|
체중 기반, CAR-T | 1 × 10⁶ | 5 × 10⁶ | cells/kg (주어지면 CAR+) | CAR_T_DOSE | 규격 §6.1.1에 나열된 허가 용량 예시와 부합한다. |
체중 기반, MSC | 1 × 10⁶ | 2 × 10⁶ | cells/kg | MSC_DOSE | 마커 비율은 무시한다. |
체중 기반, CAR-NK | 1 × 10⁷ | 1 × 10⁹ | cells/kg | 리터럴 | 발표된 CAR-NK 연구에서 보고된 용량보다 훨씬 높다. 전형적인 값으로 취급하지 말 것(5.8절). |
체중 기반, TIL | 1 × 10⁹ | 2 × 10¹¹ | cells/kg | 리터럴 | 규격 §2.1.2는 TIL 용량을 총 세포 수로 기술한다. 이를 kg당으로 검사하는 것은 단위 오류이다(5.8절). |
| 체중 기반, 기타 유형 | 1 × 10⁶ | 1 × 10⁷ | cells/kg | 리터럴 기본값 | iPSC, HSC, DC, Treg, Other에 적용되며 규격에 근거가 없다. |
flat-dose 또는 split-dose, 모든 유형 | 1 × 10⁸ | 6 × 10⁸ | 총 세포 | 리터럴 | CAR-T에서도 CAR+ 세포가 아닌 총 생존 세포를 쓴다. |
용량을 계산한 뒤 함수는 withinRange를 min ≤ dose ≤ max로 정하고, 네 가지 assessment 라벨 중 하나를 붙인다. 최소의 절반 미만은 out-of-range, 최소의 절반 이상 최소 미만은 suboptimal, 범위 안은 optimal, 최대 초과 최대의 1.5배 이하는 acceptable, 그 이상은 다시 out-of-range이다. 이 비대칭성에 유의해야 한다. 권장 최대보다 40% 높은 용량은 acceptable로 표시되는 반면 withinRange는 false이고 notes는 감량을 권고한다. assessment 단어만 보여 주는 사용자 화면은 범위를 넘은 용량을 안심시키는 말로 표시하게 된다. withinRange와 notes를 일차 신호로, 라벨은 보조 정보로 다루어야 한다. 생존율이 85% 미만이면 노트가 하나 더 붙지만 용량은 그대로 반환된다.
규격 §6.1.1은 체중 70 kg 성인, 세포 2.5 × 10⁸개, 생존율 95%, CAR+ 80%라는 예시 하나를 풀어 보인다. 손으로 계산하면 2.5 × 10⁸ × 0.95 × 0.80 = 1.9 × 10⁸ CAR+ 세포이고, 1.9 × 10⁸ ÷ 70 = 2.71 × 10⁶ CAR+ cells/kg로 1–5 × 10⁶ 범위 안에 든다. 실제 실행에서 얻은 SDK 호출과 출력은 다음과 같다.
import { CellularTherapySDK } from '@wia/bio-005';
const sdk = new CellularTherapySDK();
const result = sdk.calculateDose({
cellType: 'CAR-T',
totalCells: 2.5e8, // 백(bag) 안의 총 유핵세포 수
viability: 95, // %
patientWeight: 70, // kg
targetMarkerPercent: 80, // 생존 세포 중 CAR+ 비율(%)
strategy: 'weight-based',
});
console.log(JSON.stringify(result, null, 2));
// 출력:
{
"dose": 2714285.714285714,
"totalViableCells": 237500000,
"targetMarkerCells": 190000000,
"withinRange": true,
"recommendedDose": {
"minimum": 1000000,
"maximum": 5000000,
"unit": "cells/kg"
},
"assessment": "optimal",
"notes": []
}
반올림하지 않은 2,714,286 CAR+ cells/kg는 규격의 반올림 값 2.7 × 10⁶과 일치한다. 같은 호출을 두 가지로 바꿔 보면 배울 점이 있다. strategy를 'split-dose'로 바꾸면 단위 total cells와 함께 dose: 237500000이 반환된다. CAR+ 세포 1.9 × 10⁸이 아니라 생존 세포 전체이며, 분할 계산은 전혀 이루어지지 않았는데도 1–6 × 10⁸ 범위에 대해 optimal로 판정된다. 'weight-based'를 유지한 채 targetMarkerPercent: 0을 넘기면 3.39 × 10⁶ cells/kg가 반환된다. 코드가 if (targetMarkerCells)로 검사하는데 0은 거짓으로 평가되기 때문이다. 유세포 분석에서 CAR+ 세포가 전혀 검출되지 않은 제품이 모든 생존 세포가 CAR+인 것처럼 계산되는 셈이다. 두 동작 모두 5.8절의 래퍼 권고에서 다룬다. 분할 비율(10/30/60% 일정) 자체는 이 함수가 아니라 제6장의 투여 프로토콜에 속한다.
assessQuality: 품질 평가기
assessQuality(metrics: QualityMetrics)는 CELLULAR_THERAPY_CONSTANTS의 상수에 비추어 다섯 가지 기준을 평가한다. 생존율 ≥ 85(MIN_VIABILITY), 역가 ≥ 70(MIN_POTENCY), 무균이 true, 엔도톡신이 5(MAX_ENDOTOXIN) 미만, 마이코플라스마가 false이다. 제4장에서 소개한 불리언 극성에 주의해야 한다. sterility: true는 "균 증식 없음, 적합"이고 mycoplasma: false는 "검출되지 않음, 적합"이다. 규격의 문자열 "negative"를 두 필드 모두 false로 변환하는 매핑 계층은 무균인 모든 로트를 부적합으로 만든다.
함수는 potency를 하나의 백분율로 받을 뿐, 어떤 시험에서 나온 값인지 알지 못한다. 제3장에서 규격의 세포독성 기준을 E:T 비 10:1에서 특이 세포용해율 ≥ 70%로 정의했으며, 상수 70은 그 해석에 들어맞는다. 역가 출하 시험이 다른 방법이라면(예컨대 규격 기준이 배수 증가로 표현되는 사이토카인 분비 시험) 원시 결과를 이 필드에 넣는 것은 엉뚱한 척도와 비교하는 일이 된다. 제품별로 어떤 수치를 넣을지 정하고, 그 결정을 제품 기준서에 기록해야 한다.
반환되는 체크리스트 중 두 기준은 아예 평가되지 않는다. identity 항목은 언제나 {required: 'Target markers present', actual: 'Verified', pass: true}이며, 입력한 identity 객체는 읽히지 않는다. cellCount와 appearance.color, appearance.clarity도 무시된다. appearance.particulates만 사용되며 그것도 경고를 내는 데만 쓰인다.
경고를 만드는 조건은 세 가지이다. 적합이지만 90% 미만인 생존율, 적합이지만 한도의 80%(즉 4)를 넘는 엔도톡신, 그리고 육안 입자이다. 등급은 부적합과 경고로부터 도출된다. 부적합이 하나라도 있으면 F이다. 부적합이 없을 때, 경고가 없고 생존율 ≥ 95, 역가 ≥ 85이면 A, 그렇지 않고 경고가 하나 이하이면 B, 나머지는 C이다. 권고는 다음과 같이 정해진다. 부적합 중 무균 또는 마이코플라스마 부적합이 있으면 reject, 그 외의 부적합(생존율, 역가, 엔도톡신)이면 retest, 부적합이 없으면 경고가 두 개를 넘을 때 conditional-release, 그렇지 않으면 release이다.
경고 조건이 세 가지뿐이므로 conditional-release는 세 경고가 동시에 발생할 때만 나온다. 실제로는 적합 로트에 대해 거의 언제나 release를 반환한다. 소프트웨어의 기본값으로는 합리적이지만, 그것이 곧 처분 결정은 아니다. 등급 문자는 이 구현의 참고용 기능이다. 규격은 §8.2에서 등급 타입을 나열할 뿐 계산 방법을 정의하지 않으며, CLI는 이를 다르게 계산한다(5.7절). WIA-BIO-005 어디에도 "A 등급"을 규제상의 범주로 만드는 규정은 없다. 마찬가지로 생존율·역가·엔도톡신 부적합에 대한 retest는 SDK의 선택일 뿐이다. 규격 §8.4는 C001에 대해 "부적합 처리 또는 조사", C002에 대해 "부적합 처리 또는 재시험"이라고 하여 결정을 품질 부서에 맡긴다.
다음 입력은 경고 하나와 함께 적합 판정을 받는 로트를 나타낸다. 출력은 SDK 소스를 실행해 얻은 것이다.
// 입력: QualityMetrics
{
"viability": 88.4,
"potency": 78.5,
"sterility": true,
"endotoxin": 0.25,
"mycoplasma": false,
"identity": {
"positive": { "CD3": 96.1, "CAR": 85.3 },
"negative": { "CD19": 0.4 }
},
"cellCount": { "total": 520000000, "viable": 460000000 },
"appearance": { "color": "pale yellow", "clarity": "slightly turbid", "particulates": false }
}
// 출력: QualityResult
{
"passRelease": true,
"grade": "B",
"failures": [],
"warnings": [
"Viability (88.4%) is acceptable but below optimal (≥90%)"
],
"recommendation": "release",
"criteria": {
"viability": { "required": 85, "actual": 88.4, "pass": true },
"identity": { "required": "Target markers present", "actual": "Verified", "pass": true },
"potency": { "required": 70, "actual": 78.5, "pass": true },
"sterility": { "required": true, "actual": true, "pass": true },
"endotoxin": { "required": 5, "actual": 0.25, "pass": true },
"mycoplasma": { "required": false, "actual": false, "pass": true }
}
}
엔도톡신 행을 자세히 보자. 0.25는 EU/mL 측정값이고 QualityMetrics의 주석도 이 필드를 그렇게 표기하는데, 이 값이 5와 비교된다. 그런데 그 상수는 MAX_ENDOTOXIN (EU/kg)로 문서화되어 있다. 규격의 출하 한도(§4.1.5)는 투여량 기준 체중 kg당 5 EU 미만이므로, 올바르게 비교하려면 제3장에서 유도한 대로 백 용량과 환자 체중이 필요하다. 같은 0.25 EU/mL이 50 mL 백에 들어 있고 환자가 70 kg이라면(예시 수치), 1회 투여분에는 12.5 EU, 즉 0.18 EU/kg이 들어 있다. 이 경우에는 적합 판정이 우연히 같지만 계산 자체는 일반적으로 틀렸다. 체중이 적은 환자가 많은 부피를 투여받으면 kg당 한도는 넘는데도 SDK는 적합이라고 보고할 수 있다. 같은 입력에서 생존율만 84.9로 바꾸면 등급 F, 권고 retest, 그리고 부적합 항목 "Viability (84.9%) below minimum (85%)" 하나가 반환되며, 호출 측은 이를 C001로 매핑하면 된다.
trackManufacturing: 제조 추적기
trackManufacturing(process: ManufacturingProcess)는 배양 중인 배치의 스냅숏을 받아, 새 경보를 덧붙이고 cpp.withinRanges를 다시 계산한 사본을 반환한다. 제2장의 핵심 공정 변수 중 세 가지를 검사한다. 온도가 36.5–37.5 °C를 벗어나거나 pH가 7.2–7.4를 벗어나면 각각 critical 경보, 세포 밀도가 0.5–2.0 × 10⁶ cells/mL를 벗어나면 warning이다. 용존 산소(20–40%)와 삼투압(260–320 mOsm/kg)의 상수는 CELLULAR_THERAPY_CONSTANTS.CPP에 있지만 함수는 이를 전혀 읽지 않는다. 용존 산소 15%, 삼투압 340 mOsm/kg을 넣어 확인한 결과, 어느 쪽도 경보를 내지 않았다.
증식 규칙은 호출 측이 넣은 currentMetrics.expansionRatio를 parameters.targetExpansion과 비교한다. 증식 배수가 목표의 절반 미만이고 동시에 계획된 배양 일수의 70%가 지났을 때만 경고를 낸다. 목표 100배의 10일 배양이라면, 8일째 40배인 배치는 경고를 받지만 같은 배치가 7일째라면 받지 않는다. 7은 7.0보다 크지 않기 때문이다. 함수는 세포 수로부터 배수를 계산하지 않는다. 제2장에서 보았듯 그 산술은 호출 측의 몫이다. 배양 중 생존율이 85% 미만이면 critical, 85% 이상 90% 미만이면 warning이다.
마지막으로 withinRanges는 목록에 미해결 critical 경보가 하나도 없을 때만 true가 되며, 입력에 이미 있던 경보도 여기에 포함된다. 이 때문에 설계 시 고려할 점이 두 가지 있다. 함수는 경보를 교체하지 않고 덧붙이므로, 출력을 매시간 다시 입력으로 넣으면 같은 pH 이탈이 매시간 다시 기록된다. 저장 전에 경보 유형과 미해결 상태를 기준으로 중복을 제거해야 한다. 또 critical 경보는 누군가 resolution 메모와 함께 resolved: true를 설정할 때까지 withinRanges를 거짓으로 유지하는데, 이는 일탈 처리 절차(C006)에 꼭 필요한 사람의 확인 단계와 정확히 대응한다. 경보 식별자는 접두어와 Date.now()로 만들어지므로 유형별·밀리초별로는 유일하지만 전역적으로 유일하지는 않다. 저장하는 모든 것에는 자체 식별자를 써야 한다. 함수는 currentStage를 바꾸지 않는다. 단계 전이와 관문 검사는 제6장의 프로토콜 주제이다.
// 계획 10일 배양의 8일째 (예시 값)
const out = sdk.trackManufacturing({
...batch, // batchId, cellType, 날짜, startingMaterial…
parameters: { medium: 'X-VIVO 15', cytokines: ['IL-2'], moi: 5,
targetExpansion: 100, cultureDays: 10 },
currentMetrics: { cellCount: 4e9, viability: 88, dayInCulture: 8, expansionRatio: 40 },
alerts: [],
cpp: { temperature: 37.0, pH: 7.1, dissolvedOxygen: 15,
cellDensity: 1.2e6, osmolality: 340, withinRanges: true },
});
// out.alerts (severity, message):
// critical pH out of range: 7.1 (acceptable: 7.2-7.4)
// warning Expansion ratio (40.0×) below target (100×) at day 8
// warning Viability (88%) below optimal (≥90%)
// out.cpp.withinRanges === false
// (용존 산소 15%와 삼투압 340 mOsm/kg은 아무 경보도 내지 않음)
assessCRS와 assessICANS
두 메서드는 평가 객체(제4장에서 설명한 CRSAssessment 또는 ICANSAssessment)를 받아 {grade, treatment, urgency}를 반환한다. 핵심은 두 메서드 모두 아무것도 등급화하지 않는다는 점이다. grade는 입력에서 읽어 그대로 돌려줄 뿐이며, 체온·저혈압·승압제·산소·ICE 점수·의식 수준·경련 필드는 참조되지 않는다. 따라서 등급은 임상의가 정하거나, 제7장에서 전체를 제시하는 ASTCT 2019 합의 기준을 자체적으로 구현해 산출해야 한다. ICANS에 관해서는 규격에서 물려받은 오류도 알아 두어야 한다. iceComponents는 명령 수행에 0–2점을 허용하고 주의력 항목이 없지만, ASTCT ICE 점수는 명령 수행 1점, 주의력 1점을 준다. ASTCT 구성 항목을 자체 레코드에 저장하고 총점도 그곳에서 계산해야 한다.
두 메서드가 더해 주는 것은 고정된 긴급도 매핑 — 0·1등급 routine, 2등급 urgent, 3·4등급 emergency — 과 등급별로 약물명과 용량을 적은 고정 treatment 문자열이다. 이 문자열은 코드 1.0.0 버전에 기록된 영어 텍스트 상수이다. 처방집과 연결되어 있지 않고, 환자 체중을 반영하지 않으며, 제품 허가사항이 개정되어도 바뀌지 않는다. 보여 주더라도 기관 프로토콜 옆의 참고 문구로만 보여야 하며, 처방이나 미리 채워진 오더로 제시해서는 안 된다. 더 쓸모 있는 출력은 긴급도 값이다. 누구를 얼마나 빨리 호출할지 결정하는 데 쓸 수 있다.
monitorSafety
monitorSafety(patientId, daysPostInfusion, events)는 SafetyEvent 목록에서 resolutionDate가 없는 사건만 골라 위험 수준으로 집계한다. 중증도 4 이상의 활성 사건이 하나라도 있으면 critical, 그렇지 않고 serious로 표시된 활성 사건이 있거나 CRS와 ICANS가 동시에 활성이면 high, 그렇지 않고 활성 사건이 하나라도 있으면 moderate, 활성 사건이 없으면 low이다. patientId 인수는 받기만 하고 쓰이지 않는다.
권고는 두 출처에서 조립되는 문자열 목록이다. 하나는 경과 일수이고(10일째까지는 입원 모니터링과 4–6시간 간격의 CRS/ICANS 평가, 11–30일째는 치료 기관에서 2시간 이내 거리 유지와 주 1회 외래 방문, 30일 이후에는 일수 기반 권고 없음), 다른 하나는 활성 사건의 유형(CRS, ICANS, 감염, 혈구감소)이다. 일수 기반 문구는 규격의 과거 기본값을 재현한 것이다. 제7장에서 설명하듯, 허가된 자가 CAR-T 제품에 대한 현행 규제 관행은 더 짧은 근거리 체류 기간을 적용하며, 고정된 입원 기간은 규제 요건이 아니다. 이 출력은 제품과 기관별로 설정되는, 진료팀을 위한 알림 문구로 다루어야 한다.
assessPostThawQuality
이 메서드는 동결 전과 해동 후 측정값을 비교한다. 해동 후 viability, recovery(해동 후 세포 수 ÷ 동결 전 세포 수 × 100), potencyRetained(해동 후 역가 ÷ 동결 전 역가 × 100), 그리고 acceptable을 반환하는데, acceptable은 생존율 ≥ 85, 회수율 ≥ 70(MIN_RECOVERY), 역가 유지율 ≥ 70을 모두 요구한다. 예컨대 동결 전 {94%, 2.5 × 10⁸ 세포, 역가 80}, 해동 후 {87%, 1.9 × 10⁸, 역가 62}라면 회수율 76%, 역가 유지율 77.5%로 허용 가능이다. 동결 전 세포 수나 역가가 0이면 Infinity 또는 NaN이 나오고, NaN은 아무 표시 없이 acceptable을 거짓으로 만들므로 그런 입력은 명시적으로 거부해야 한다. PostThawQuality의 선택 필드 timeToInfusion은 채워지지 않는다. 제6장의 해동–투여 시간 창은 다른 곳에서 추적해야 한다.
generateCertificate
generateCertificate(product, quality, potency)는 내부에서 assessQuality를 실행해 passRelease가 거짓이면 C007(QUALITY_FAILURE)을 던진다. 그렇지 않으면 COA-<batchId>-<밀리초> 형식의 번호를 가진 CertificateOfAnalysis를 반환한다. 이 객체의 여러 필드는 데이터가 아니라 하드코딩된 자리표시자이다. approved: true와 approvedBy: 'QC Manager', 시설명 'GMP Manufacturing Suite', 빈 deviations 목록, 동결일로부터 24개월 뒤(보관 기록이 없으면 생성 시점으로부터 24개월 뒤)의 유효기한, 그리고 운송 조건 'Dry ice or liquid nitrogen vapor phase'가 그것이다. 마지막 항목은 안전하지 않다. 드라이아이스는 약 −78 °C로, 규격이 요구하는 ≤ −150 °C 보관보다 훨씬 따뜻하며, 동결보존된 세포 제품을 드라이아이스로 운송해서는 안 된다. 이 생성기는 QA가 실제 승인자, 시설, 일탈 목록, 검증된 유효기간, 검증된 액체질소 기상(vapour-phase) 운송 용기로 자리표시자를 덮어쓰는 초안 템플릿으로 사용해야 한다. 시험성적서는 함수 호출이 아니라 사람이 서명하는 문서이다.
규격 §8.4는 C001부터 C006까지 여섯 개의 오류 코드를 정의하고, 각각에 의미와 조치를 붙였다. SDK의 열거형 CellularTherapyErrorCode는 이 여섯 개를 유지하면서 C007–C010 네 개를 더하는데, 이는 규범적 규격의 일부가 아니라 구현상의 확장이다. 오류는 CellularTherapyError로 던져지는데, 이는 code와 선택적 details 레코드를 가진 Error의 하위 클래스이다(SDK 자신은 details를 채우지 않으므로, 자체 래퍼에서 배치나 환자 맥락을 담는 데 쓰면 된다).
이 표에서 가장 중요한 것은 네 번째 열이다. SDK가 실제로 던지는 코드는 열 개 중 두 개뿐이다. calculateDose의 C010과 generateCertificate의 C007이다. 나머지 코드는 모두 통합 계층이 SDK 결과나 자체 검사를 해석해 직접 발생시켜야 한다. 오염된 로트를 막기 위해 SDK가 C003을 던지기를 기다리는 시스템은 영원히 기다리게 된다.
| 코드 | 열거 이름 | 정의 위치 | SDK가 던지는가 | 통합 시 전형적 발생 조건 | 운영상 조치 |
|---|---|---|---|---|---|
| C001 | VIABILITY_LOW | 규격 §8.4 | 아니오 | assessQuality 또는 해동 후 검사에서 생존율 기준 부적합 | 로트 보류. 부적합 처리 또는 조사(규격). 재시험 여부는 QA가 결정 |
| C002 | POTENCY_FAILURE | 규격 §8.4 | 아니오 | 역가 기준 부적합 | 제품 규격에 따라 부적합 처리 또는 재시험 |
| C003 | STERILITY_FAILURE | 규격 §8.4 | 아니오 | 무균(그리고 SDK 규칙상 마이코플라스마) 부적합. SDK 권고 reject | 즉시 부적합 처리. QA에 통보하고, 제품이 이미 시설을 떠났다면 치료팀에도 통보 |
| C004 | DOSE_OUT_OF_RANGE | 규격 §8.4 | 아니오 | calculateDose의 withinRange === false | 재계산. 불출 전 의사·약제부 검토 |
| C005 | IDENTITY_MISMATCH | 규격 §8.4 | 아니오 | 인계 지점에서 제품과 환자 식별자가 불일치하거나 확인 시험이 부적합 | 중지하고 조사. 투여 금지 |
| C006 | MANUFACTURING_DEVIATION | 규격 §8.4 | 아니오 | trackManufacturing의 미해결 critical 경보 | 일탈을 문서화하고 영향 평가(CAPA) |
| C007 | QUALITY_FAILURE | SDK 전용 | 예, generateCertificate | passRelease === false인 로트에 대한 성적서 요청 | 성적서 발행 불가. QA 처분 절차로 이관 |
| C008 | STORAGE_VIOLATION | SDK 전용 | 아니오 | 보관 온도가 ≤ −150 °C 요건을 넘거나 계획되지 않은 동결–해동 주기 발생 | 격리. 안정성 자료에 비추어 평가 |
| C009 | EXPIRED_PRODUCT | SDK 전용 | 아니오 | 현재 날짜가 StorageInfo.expirationDate 이후 | 불출 차단 |
| C010 | INVALID_PARAMETERS | SDK 전용 | 예, calculateDose | 세포 수나 체중이 0 이하, 생존율이 0–100 밖 | 원천에서 입력 수정. 기본값으로 대체하지 말 것 |
각 코드를 담당자가 있는 사건으로 다루는 것이 유용한 규율이다. C001–C003과 C007은 품질 부서, C006은 제조 부서, C008과 C009는 동결 재고를 관리하는 부서, C004와 C005는 임상 기관의 몫이다. 코드와 함께 배치 식별자, 발동한 규칙과 임계값, 이를 확인한 사람을 기록하면 제8장에서 요구할 감사 추적이 갖추어진다. catch 블록은 모든 오류를 받으므로 err.code를 읽기 전에 err instanceof CellularTherapyError를 확인하고, 예상하지 못한 오류는 C010으로 바꾸지 말고 그대로 전파해야 한다.
wia-bio-005
명령줄 도구는 독립된 Bash 스크립트이다. SDK를 호출하지 않고, bc로 산술을 수행하며 로직의 일부를 다시 구현한다. 동작하는 명령은 네 가지이고 여기에 version과 help가 더해진다. 알 수 없는 옵션은 조용히 건너뛰며, 생략한 플래그에는 아래 표의 기본값이 쓰인다.
| 명령 | 플래그(기본값) | 기능 |
|---|---|---|
calc-dose | --cells (5e8), --weight (70), --viability (92.5), --car (0 = 없음) | 생존 세포, CAR+ 세포, cells/kg 계산. CAR-T 범위 1–5 × 10⁶/kg만 검사하며 세포 유형·전략 플래그는 없다. |
assess-quality | --viability (92.5), --potency (85), --endotoxin (0.3), --sterility (true), --mycoplasma (false) | 다섯 가지 적합/부적합 검사, 등급, 권고(확인 시험·입자 검사 없음). |
track-batch | --batch (CT-2024-001), --stage (expansion), --day (7), --cells (5e7), --viability (92) | 다음 단계, 생존율 구간, 그리고 expansion 단계에서는 고정 시작값 5 × 10⁶ 세포 대비 증식 배수를 출력한다. |
monitor-safety | --patient (PT-001), --days (7), --crs (0), --icans (0) | 등급과 짧은 치료 문구, 일수 기반 모니터링 권고를 출력한다. |
version, help | — | help는 --help, -h로도 호출된다. --version은 인식되지 않는다. |
README에는 monitor-safety --day 7이라고 되어 있지만 플래그는 --days이다. 알 수 없는 옵션은 무시되므로 --day 7은 기본값 7로 조용히 실행되며, 결과가 맞는 것은 우연일 뿐이다. 설치 스크립트가 안내하는 wia-bio-005 --help는 동작하지만, README의 wia-bio-005 --version은 "Unknown command"로 종료된다. 아래 세션은 공개된 스크립트 그대로에서 캡처한 것이다(색상 코드와 배너는 제거). 세포 수를 일반 정수로 적은 이유는 5.8절에서 설명한다.
$ wia-bio-005 calc-dose --cells 250000000 --weight 70 --viability 95 --car 80
▶ Dose Calculation
Total Cells: 2.50e+08
Patient Weight: 70 kg
Viability: 95%
CAR+ Percentage: 80%
Viable Cells: 2.38e+08
CAR+ Cells: 1.90e+08
▶ Results
✓ Dose: 2.71e+06 cells/kg
✓ Dose is within recommended range (1e6 - 5e6 cells/kg)
$ wia-bio-005 monitor-safety --patient PT-001 --days 14 --crs 2 --icans 0
▶ Toxicity Assessment
CRS Grade: 2
⚠ CRS Grade 2: Hypotension and/or hypoxia
Treatment: Tocilizumab 8 mg/kg IV
ICANS Grade: 0
✓ ICANS: None
▶ Monitoring Recommendations
Patient should remain within 2 hours of center
Weekly clinic visits required
⚠ Active Grade ≥2 toxicity - urgent intervention required
$ wia-bio-005 assess-quality --viability 92.5 --potency 85 --endotoxin 0.3
▶ Release Criteria
✓ Viability: PASS (≥85%)
$ echo $?
1
$ wia-bio-005 calc-dose --cells 2.5e8 --weight 70 --viability 95 --car 80
Viable Cells: 0.00e+00
(standard_in) 1: syntax error
✓ Dose: 0.00e+00 cells/kg
⚠ Dose exceeds recommended maximum (5e6 cells/kg)
앞의 두 명령은 의도대로 동작한다. 뒤의 두 명령은 그렇지 않으며, 그 이유는 5.8절에서 설명한다. 이런 결함과 별개로, CLI와 SDK를 함께 쓸 때는 세 가지 설계상 차이를 기억해야 한다. CLI는 생존율·역가 구간으로 등급을 매기고(≥ 95%·≥ 85%이면 A, ≥ 90%·≥ 80%이면 B, 그 밖은 C), SDK는 경고 개수로 매기므로 같은 로트가 다른 문자를 받을 수 있다. 생존율 88.4%, 역가 78.5%라면 SDK는 B를, 결함을 고친 CLI는 C를 반환한다. CLI는 conditional-release를 반환하지 않는다. 그리고 CLI 메시지는 엔도톡신을 "< 5 EU/mL"로 표기해 5.3절의 단위 혼동을 되풀이한다. CLI는 시연과 간단한 점검용으로 쓰고, 출하 판정 도구로 쓰지 말아야 한다.
아래의 문제 중 참조 코드를 포크해야 해결되는 것은 없다. 대부분은 입력을 검증하고, SDK가 빠뜨린 것을 보충하고, 출력을 다시 표기하는 얇은 래퍼로 무력화할 수 있다. SDK를 수정하지 않고 두는 데에는 실질적인 이점이 있다. 수정된 버전이 나오면 업그레이드하고 래퍼 코드를 지우면 되며, 밸리데이션 증거(제8장)도 공개된 버전에 묶인 채로 유지된다.
| 영역 | 관찰된 동작 | 자체 계층에서의 완화 |
|---|---|---|
| 모듈 내보내기 | index.ts가 export class CellularTherapySDK 뒤에 export { CellularTherapySDK }로 끝나고, types.ts는 이미 내보낸 이름을 다시 내보낸다. Node.js 타입 제거(type stripping)로 소스를 불러오면 중복 내보내기 지점에서 SyntaxError로 실패했다. | 중복된 끝부분 내보내기 줄을 지운 사본으로 빌드하고, 타입 전용 가져오기에는 import type을 쓴다. |
| 엔도톡신 단위 | EU/mL 값을 EU/kg로 문서화된 한도 5와 비교한다. | 농도·투여 부피·체중으로 EU/kg를 계산하고, 투여 경로별 한도(정맥 5 EU/kg, 척수강 내는 훨씬 낮은 0.2 EU/kg)로 판정한다. |
| 확인 시험 | 언제나 pass: true이며 입력은 무시된다. | 제3장의 확인 시험 기준을 직접 평가해 해당 기준을 교체한다. |
| CAR+ = 0 | 생존 세포 전체로 대체 계산된다. | CAR-T에서 CAR+ 결과가 없거나 0이면 호출 전에 거부한다. |
| 분할·고정 용량 | split-dose가 고정 용량과 같고, 고정 용량은 CAR+가 아닌 생존 세포 전체를 쓴다. | CAR+ 총량을 직접 계산하고, 분할 비율은 프로토콜 계층(제6장)에서 도출한다. |
| TIL·CAR-NK kg당 범위 | TIL의 총 용량 범위를 kg당으로 검사하고, CAR-NK 범위는 발표된 경험보다 훨씬 높다. | 제품 허가사항이나 프로토콜의 범위를 설정으로 주입하고, 이 유형들에 대해서는 SDK 범위를 무시한다. |
| 검사되지 않는 CPP | 용존 산소와 삼투압을 평가하지 않는다. | 같은 상수를 써서 두 검사를 추가한다. |
| 경보 누적 | 호출할 때마다 출력 경보가 입력 경보에 덧붙는다. | 저장 전에 유형별로 미해결 경보의 중복을 제거한다. |
| 성적서 자리표시자 | 승인자, 시설, 일탈, 24개월 유효기한, 드라이아이스 운송이 상수이다. | 모든 자리표시자를 QMS 데이터로 덮어쓰고, 드라이아이스로는 절대 운송하지 않는다. |
| CLI 산술 | bc는 5e8을 해석하지 못한다. 기본 calc-dose는 용량 0을 출력한 뒤 엉뚱한 경고를 내고, track-batch의 증식 배수는 하드코딩된 시작값이 5e6이라 항상 0.0이다. | 일반 정수를 넘기고, bc 호출 전에 지수 표기를 변환한다. |
| CLI 종료 | set -e 아래에서 ((pass_count++))는 카운터가 0일 때 상태 1을 반환하므로, assess-quality는 첫 PASS 뒤 종료 코드 1로 멈춘다. | pass_count=$((pass_count+1))로 바꾸고 fail_count도 같이 고친다. 이렇게 고치면 전체 보고서가 출력된다. |
아래 래퍼는 가장 영향이 큰 두 품질 공백, 즉 엔도톡신과 확인 시험에 대한 패턴을 보여 준다. SDK가 올바르게 처리하는 기준은 SDK에 맡기고, 그렇지 못한 두 기준을 교체한다. 경로별 한도는 제3장에서 언급한 일반적인 약전 값이며, 해당 제품에 적용되는 한도는 제품 기준서에서 확인해야 한다.
import {
CellularTherapySDK, QualityMetrics, QualityResult,
} from '@wia/bio-005';
type Route = 'intravenous' | 'intrathecal';
const ENDOTOXIN_LIMIT_EU_PER_KG: Record<Route, number> = {
intravenous: 5,
intrathecal: 0.2,
};
interface ReleaseContext {
endotoxinEUperML: number; // 최종 제품의 LAL 결과
doseVolumeML: number; // 실제 투여할 부피
patientWeightKg: number;
route: Route;
identityPass: boolean; // 제3장의 확인 시험 기준으로 평가한 결과
}
export function assessRelease(
sdk: CellularTherapySDK, metrics: QualityMetrics, ctx: ReleaseContext,
): QualityResult {
if (!(ctx.doseVolumeML > 0 && ctx.patientWeightKg > 0)) {
throw new Error('volume and weight must be positive numbers');
}
const euPerKg = (ctx.endotoxinEUperML * ctx.doseVolumeML) / ctx.patientWeightKg;
const limit = ENDOTOXIN_LIMIT_EU_PER_KG[ctx.route];
// SDK에는 생존율·역가·무균·마이코플라스마만 판정하게 한다.
const base = sdk.assessQuality({ ...metrics, endotoxin: 0 });
const failures = [...base.failures];
const warnings = [...base.warnings];
const endoPass = euPerKg < limit;
if (!endoPass) failures.push(`Endotoxin ${euPerKg.toFixed(3)} EU/kg ≥ ${limit} EU/kg`);
else if (euPerKg > 0.8 * limit) warnings.push('Endotoxin above 80% of limit');
if (!ctx.identityPass) failures.push('Identity criteria not met');
const pass = failures.length === 0;
return {
...base,
passRelease: pass,
grade: pass ? base.grade : 'F', // 참고용
failures,
warnings,
recommendation: pass ? base.recommendation
: base.recommendation === 'reject' ? 'reject' : 'retest',
criteria: {
...base.criteria,
endotoxin: { required: limit, actual: euPerKg, pass: endoPass },
identity: { required: 'Chapter 3 identity limits',
actual: ctx.identityPass ? 'met' : 'not met',
pass: ctx.identityPass },
},
};
}
용량 계산에도 같은 방식을 적용한다. CAR-T 호출에 양수의 CAR+ 비율이 들어 있는지 검증한 뒤 체중 기반 모드로 calculateDose를 호출하고, 고정 또는 분할 투여라면 CAR+ 총량을 직접 계산해 제품 허가사항을 출처로 명시한 설정값의 범위와 비교한다. 래퍼가 SDK 값을 덮어쓸 때는 두 값과 그 이유를 감사 로그에 모두 남겨, 검토자가 참조 코드가 무엇을 말했고 시스템이 왜 다르게 판단했는지 볼 수 있게 해야 한다.
장 요약. 규격 §8.2는 웹 API가 아니라 함수 수준의 인터페이스를 정의하며, @wia/bio-005는 다섯 가지 필수 구성요소 중 네 가지를 조금 다른 타입으로 구현한다. calculateDose는 규격의 70 kg 예시(2.71 × 10⁶ CAR+ cells/kg)를 정확히 재현하지만 CAR+ 0, 분할 투여, 일부 유형별 단위를 잘못 처리한다. assessQuality는 다섯 기준을 판정하되 엔도톡신을 잘못된 척도로 비교하고 확인 시험은 검사하지 않는다. trackManufacturing은 다섯 CPP 중 세 가지만 검사한다. 안전성 보조 메서드는 등급을 계산하지 않고 되돌려줄 뿐이다. 실제로 던져지는 코드는 C007과 C010뿐이며 나머지 여덟 개는 통합 계층이 발생시켜야 한다. CLI는 기본 실행을 깨뜨리는 결함 두 가지를 가진 시연용 도구이다. SDK는 포크하지 말고 감싸야 하며, 결정권자는 언제나 임상의 또는 품질 부서여야 한다.