제3장

제3장: WIA-BIO-016 데이터 모델

표준은 바이오의약품과 그 특성분석·분석 시험·공정·규제 상태를 어떻게 기록하는가. types.ts의 TypeScript 타입 모델을 필드 단위로 읽으며, 구현자가 알아야 할 단위 규약과 빈틈, 결함을 정리한다.

3.1 모델의 설계: 타입이 곧 데이터 형식이다

많은 데이터 표준은 XML Schema, JSON Schema, ASN.1 모듈 같은 형식 스키마 문서를 따로 공개한다. WIA-BIO-016은 그렇지 않다. 명세서(spec/WIA-BIO-016-v1.0.md)는 과학적 내용을 문장과 수식으로 설명할 뿐이고, 레코드의 모양을 기계가 읽을 수 있게 정의한 것은 TypeScript 모듈 api/typescript/src/types.ts 하나뿐이다. 이 점을 처음부터 분명히 해 두어야 이후 장에서 표준을 읽는 방식이 정리된다.

TypeScript 인터페이스로 데이터 형식을 정의하는 것 자체는 합리적인 선택이다. 필드 이름, 선택 여부, 열거값이 정확히 드러나고, 모든 호출 지점에서 컴파일러가 기계적으로 검사한다. 그러나 JSON Schema라면 없었을 한계도 있다. TypeScript 타입은 실행 시점에 사라지므로 분석 장비나 다른 시스템에서 들어오는 JSON 문서를 검증해 주지 못한다. 또 TypeScript의 number는 단위·범위·정밀도에 대해 아무것도 말하지 않는다. WIA-BIO-016에서 단위는 각 필드 옆의 주석에 적혀 있다. 따라서 그 주석은 사실상 규범이며, 이 장도 주석을 규범으로 취급한다.

3.1.1 단위와 척도 규약

모델은 하나의 일관된 규약을 쓰지 않는다. 몰 농도는 mol/L(M), 청소율은 L/h, 온도는 섭씨, 안정성 기간은 개월로 적고, 어떤 비율은 0~1 척도로, 어떤 비율은 0~100 백분율로 적는다. 하나하나는 틀리지 않았지만, 규약이 섞여 있다는 사실이 여러 출처의 레코드를 합칠 때 조용한 오류를 만드는 가장 흔한 원인이 된다. 아래 표는 소스 주석에 적힌 규약을 모은 것이다.

물리량등장 위치types.ts의 단위·척도구현 시 유의점
분자 질량MolecularWeight.theoretical, .measured, .uncertaintyDaDrugCandidate.molecularWeight는 단위 주석이 없는 숫자다. Da로 간주한다.
질량 정확도MolecularWeight.accuracyppm부호 있는 오차인지 절댓값인지 타입이 정하지 않으므로 한 가지로 통일한다.
결합 농도, KdBindingAffinity.*Conc, BindingAffinityResult.kd, KineticParameters.kd, DrugCandidate.targetAffinityM(mol/L)1 nM은 1이 아니라 1e-9로 적어야 한다.
속도상수KineticParameters.kon / .koffM−1s−1 / s−1따라서 complexHalfLife는 초 단위다.
투여량PKParameters.dosemg체중 기준 용량(mg/kg)은 먼저 곱해서 넣는다. patientWeight는 기록만 되고 계산에 쓰이지 않는다.
청소율 / 분포용적PKParameters.clearance / .volumeOfDistributionL/h / L항체의 청소율은 보통 L/day로 보고되므로 24로 나눈다.
PK/PD 농도PKResult.c0, PDParameters.concentration, .ec50mg/L여기는 질량 단위, Kd는 몰 단위다. 분자 질량 없이는 둘을 비교할 수 없다.
온도BindingAffinity.temperature, StabilityConditions.temperature°C아레니우스 계산에는 켈빈으로 바꾼다.
안정성 기간StabilityConditions.duration, StabilityResult.predictedShelfLife개월한 달은 일수가 고정되지 않으므로 환산할 때 기준을 밝힌다.
0~1 비율Glycan.abundance, PTM.occupancy, PKParameters.bioavailability, ADAData.incidenceRate, 정제 단계 yield/purity, DrugCandidate.stability무차원 0~10.95를 뜻할 때 95를 적지 않는다.
0~100 백분율QualityAttributes(엔도톡신·HCP·DNA 제외), StabilityResult.potency/monomerContent/aggregates, CellCultureParams.viability%같은 개념(순도)이 PurificationProcess에서는 0~1, QualityAttributes에서는 0~100이다.
불순물QualityAttributes.endotoxin / .hcp / .residualDNAEU/mg / ppm / pg(1회 투여량당)정규화 기준이 셋(질량당, 질량비, 투여량당)으로 서로 다르다.

여기서 실무 규칙 하나가 나온다. 데이터를 받아들일 때 모든 값을 주석에 적힌 단위로 변환하되, 원래의 값과 단위를 확장 필드(3.7.4 참조)에 함께 보관한다. 모델에는 원래 단위를 적을 자리가 없으므로 구현자가 직접 보관하지 않으면, 나중에 검토하는 사람은 변환이 있었는지조차 알 수 없다.

3.1.2 단위 타입과 convertConcentration()

types.ts는 단위 타입 세 개를 선언한다. ConcentrationUnit('M' | 'mM' | 'μM' | 'nM' | 'pM' | 'mg/mL' | 'μg/mL'), TimeUnit(초부터 연까지), TemperatureUnit(섭씨·화씨·켈빈)이다. 그런데 어느 인터페이스 필드도 이 타입을 쓰지 않으며, 모든 필드는 단위가 고정되어 있다. 관련 코드는 SDK 메서드 convertConcentration(value, fromUnit, toUnit, molecularWeight?) 하나뿐인데, 그 매개변수조차 ConcentrationUnit이 아니라 일반 string으로 선언되어 있다. 실제 동작은 시그니처가 암시하는 것보다 좁다.

const sdk = new BiopharmaSDK();

sdk.convertConcentration(2.5, 'nM', 'M');
// 2.5e-9  (몰 단위끼리의 변환은 동작한다)

sdk.convertConcentration(10, 'mg/mL', 'μM', 150000);
// BiopharmaError 발생, 코드 'BIO007':
// "Unit conversion not implemented for these units"
// (질량 단위는 선언만 되어 있고 변환되지 않으며, molecularWeight는 무시된다)

sdk.convertConcentration(2.5, 'µM', 'nM');
// 이것도 BIO007: 코드는 U+03BC(그리스 소문자 뮤)와 비교하는데,
// 많은 키보드와 장비 소프트웨어는 U+00B5(마이크로 기호)를 출력한다

마지막 경우는 놓치기 쉽다. 소스의 'μM'은 그리스 문자 뮤(U+03BC)를 쓴다. 스프레드시트나 장비 소프트웨어에서 내보낸 텍스트는 화면에서 똑같아 보이는 마이크로 기호(U+00B5)를 쓰는 경우가 많다. 데이터 수집 계층은 SDK를 호출하기 전에 마이크로 기호를 그리스 뮤로 정규화해야 하며(유니코드 NFKC 정규화가 이 변환을 한다), 더 나은 방법은 직접 몰 농도로 변환해서 단위 문자열을 아예 넘기지 않는 것이다.

3.2 제품 식별 타입

레코드가 어떤 종류의 제품에 관한 것인지는 다섯 개의 유니온 타입이 표현한다. 모두 문자열 리터럴 유니온이어서 JSON에서는 평범한 문자열로 직렬화되며, 대소문자를 구분한다. 'CHO'는 유효한 ExpressionSystem이 아니고 'cho'가 유효하다.

타입허용값비고
DrugClassmonoclonal-antibody, antibody-fragment, fusion-protein, cytokine, enzyme, vaccine, antibody-drug-conjugate, peptide, recombinant-protein제품당 하나를 고르는 아홉 개 분류. DrugCandidate.drugType에서만 쓰인다.
AntibodyIsotypeIgG1, IgG2, IgG3, IgG4, IgM, IgA, IgE선언만 있고 어느 인터페이스도 참조하지 않는다.
AntibodyFormatfull-igg, fab, scfv, diabody, bite, nanobody, dart선언만 있고 어느 인터페이스도 참조하지 않는다.
VaccineTypemrna, viral-vector, protein-subunit, inactivated, live-attenuated, toxoid, conjugate참조되지 않으며, 백신 전용 레코드도 없다.
ExpressionSysteme-coli, yeast, cho, hek293, ns0, sp2-0, insect, plantDrugCandidate와 CellCultureParams에서 쓰인다.

이 표는 구조적인 문제 하나를 드러낸다. 모델에는 이 사실들을 묶어 주는 최상위 "제품" 엔터티가 없다. DrugCandidate가 drugType과 expressionSystem을 갖고 있지만, 그 후보가 전장 IgG 형식의 IgG1이라고 적을 필드는 없고, 제품 식별자 필드는 아예 없다. 따라서 둘 이상의 제품을 저장하는 애플리케이션은 자체 식별 계층을 두어야 한다. 아래는 표준이 이미 정의한 타입만 조합한 최소 형태다.

import type {
  DrugClass, AntibodyIsotype, AntibodyFormat, ExpressionSystem,
} from '@wia/bio-016/types';   // 경로는 package.json의 "exports"를 따른다

/** 애플리케이션 수준의 식별 레코드(WIA-BIO-016 v1.0에는 없음). */
interface ProductIdentity {
  productId: string;            // 자체적으로 부여하는 고정 키
  drugClass: DrugClass;
  isotype?: AntibodyIsotype;    // 항체 기반 분류에만 해당
  format?: AntibodyFormat;
  expressionSystem: ExpressionSystem;
}

이 계층에서 강제할 만한 일관성 규칙이 두 가지 있다. 타입만으로는 막을 수 없기 때문이다. 첫째, isotype과 format은 drugClass가 monoclonal-antibody, antibody-fragment, antibody-drug-conjugate일 때만 의미가 있다(Fc 융합단백질이라면 Fc의 아이소타입이 참고가 될 수는 있다). 둘째, 분류와 형식이 겹친다. Fab는 분류 antibody-fragment로도, 분류 monoclonal-antibody에 형식 fab로도 기록할 수 있다. 한 가지 규약을 정해 문서로 남겨야 한다. 그렇지 않으면 "모든 단편" 같은 조회가 일부 레코드를 소리 없이 빠뜨린다.

식별 타입이 표현하지 못하는 것도 짚어 둔다. 명세서 §2.2가 다루는데도 항체-약물 접합체의 링커, 페이로드, 약물-항체 비율(DAR)을 적을 필드가 없다. BiTE나 DART가 아닌 이중특이 항체에는 해당하는 형식 값이 없고, 세포·유전자 치료제는 열거값 밖에 있다. 제8장에서 이 빈틈을 다시 다루고 추가 필드를 제안한다.

3.3 특성분석 레코드

특성분석 그룹은 분자 자체, 즉 1차 서열, 질량, 전하, 당쇄, 그 밖의 변형을 기술한다. 중심 인터페이스는 ProteinCharacterization이다.

interface ProteinCharacterization {
  sequence: string;                        // 아미노산 서열
  molecularWeight: MolecularWeight;
  isoelectricPoint: number;
  extinctionCoefficient: number;           // M^-1 cm^-1
  glycosylationSites?: number[];
  glycans?: Glycan[];
  ptms?: PTM[];
  disulfideBonds?: Array<[number, number]>;
}

3.3.1 다중 사슬 분자에 서열 문자열 하나

여기서 가장 영향이 큰 설계 선택은 sequence가 문자열 하나라는 점이다. 단일클론항체는 중쇄 두 개와 경쇄 두 개로 이루어진 이종 4량체인데, 모델의 위치 기반 필드(glycosylationSites, PTM.position, disulfideBonds의 쌍)는 모두 사슬 정보 없는 정수다. 사슬 식별자와 번호 체계가 없으면 "297번 위치"는 모호하다. 이어 붙인 문자열의 색인일 수도, 중쇄를 1부터 센 위치일 수도, 이 분야 사람들이 Fc의 보존된 당질화 부위를 말할 때 뜻하는 EU 번호 체계의 Asn297일 수도 있다.

IgG1을 예로 들면 문제의 크기가 보인다. IgG1에는 이황화 결합이 16개 있다. 각 면역글로불린 도메인마다 하나씩인 사슬 내 결합이 12개, 사슬 간 결합이 4개(힌지의 중쇄-중쇄 결합 2개, 각 팔의 중쇄-경쇄 결합 1개씩)다. 사슬 간 결합은 사슬을 어떤 순서로 이어 붙였는지 알지 못하면 한 문자열 안의 두 위치로 적을 수 없다. 표준이 사슬을 구분한 위치 표기를 도입하기 전까지, 적합한 구현은 세 가지를 해야 한다. 서열을 문서화된 방식으로 이어 붙여 저장하고(예: 중쇄 한 벌 다음 경쇄 한 벌, 구분자 없음), 번호 체계를 PTM.details나 확장 필드에 밝히며, 각 부위의 사슬 구분 사본을 확장 필드에 보관해 내보내기 과정에서 사라지지 않게 한다.

3.3.2 MolecularWeight

MolecularWeight는 theoretical 값(서열과 예상 변형으로 계산한 값)과 measured 값을 짝지으며, 둘 다 달톤 단위다. method는 mass-spec, sec-mals, sds-page, calculated 중 하나다. 선택 필드로 uncertainty(Da)와 accuracy(ppm)가 있다. 질량분석 결과라면 정확도는 상대 질량 오차, 즉 (측정값 − 이론값) / 이론값 × 106이다. 예를 들어 이론값 148,220 Da, 측정값 148,224 Da이면 오차는 약 27 ppm이다. SEC-MALS나 SDS-PAGE에서는 ppm 필드가 의미가 없으므로, 방법이 갖지 못한 정밀도를 암시하는 계산값을 채우기보다 필드를 생략해야 한다.

기록되지 않는 것이 두 가지 있다. 이론 질량이 어느 당형(glycoform)을 기준으로 한 것인지(온전한 IgG는 당형의 혼합물이므로 "그" 온전 질량은 보통 G0F/G0F처럼 이름 붙인 한 분자종의 질량이다), 그리고 측정 전에 탈당화나 환원을 했는지다. 둘 다 확장 필드에 적어야 한다.

3.3.3 Glycan

Glycan 항목은 type(n-glycan 또는 o-glycan), G0F 같은 자유 텍스트 composition 라벨, 0~1 척도의 상대 abundance, 선택 필드인 sialicAcids 개수, 그리고 불리언 fucosylated와 highMannose를 갖는다. 가장 많이 조회되는 두 성질을 불리언으로 드러내어 모든 사용자가 조성 라벨을 파싱하지 않아도 되게 한 것은 합리적이다. 제2장에서 설명했듯이 비푸코실화 분자종은 항체 의존성 세포독성(ADCC)에, 고만노스 분자종은 청소율에 영향을 줄 수 있다.

composition이 자유 텍스트이므로 두 실험실이 같은 구조를 다르게 표기할 수 있다(예: Man5와 M5). 가장 값싼 해결책은 라벨의 통제 어휘를 정해 구현과 함께 공개하는 것이다. 수집 단계에서 확인할 불변 조건도 있다. 한 분석 안에서 abundance 합은 1을 넘으면 안 된다. 미량 분자종을 보고하지 않았다면 1보다 작을 수 있지만, 1을 넘는다면 척도 오류(백분율을 비율 자리에 입력)이거나 중복 집계다.

3.3.4 PTM

PTM은 한 residue의 한 position에 있는 변형 하나를 기록하며, 선택 필드로 details와 점유율 occupancy를 갖는다. type 유니온은 glycosylation, phosphorylation, acetylation, methylation, oxidation만 허용한다. 치료용 항체에서 핵심 품질 속성이 되는 일이 가장 잦은 변형 여럿이 이 목록에 없다. 아스파라긴 탈아미드화, 아스파르트산 이성질화, N-말단 피로글루탐산 형성, C-말단 라이신 변이체, 당화(glycation)가 그렇다. 어느 것도 확장 없이는 기록할 수 없으므로, 펩타이드 매핑 보고서를 이 형식으로 담으려는 사람에게는 실질적인 제약이다. 원래 유니온만 이해하는 사용자를 깨뜨리지 않고 이를 추가하는 방법은 3.7.4에서 보인다.

3.4 분석 시험 레코드

분석 시험 그룹은 제4장과 제5장에서 설명하는 계산의 입력과 출력을 담는다. 여기에는 반복되는 패턴이 있다. 계산마다 매개변수 인터페이스(측정했거나 가정한 것)와 결과 인터페이스(SDK가 계산한 것)가 짝을 이룬다. 이 중 일부만 1차 데이터로 저장할 대상이고, 나머지는 저장된 입력에서 다시 계산할 수 있어야 하는 파생값이다.

3.4.1 결합: BindingAffinity, BindingAffinityResult, KineticParameters

BindingAffinity는 항체·항원·복합체의 평형 농도(모두 M), 섭씨 온도, 선택 필드인 pH와 buffer를 담는다. BindingAffinityResult는 파생값 kd, 자유 농도, bindingStrength 등급, fractionBound, 불리언 therapeuticSuitability를 담는다. 분류 경계값과 그것이 명세서 자체의 표와 어긋나는 문제는 제4장의 주제이고, 여기서는 저장 관점만 본다. 직렬화에 영향을 주는 세부 사항이 둘 있다.

KineticParameters는 kon, koff, 파생값 kd와 complexHalfLife(초), method(spr, bli, ifa, elisa, kinexA 중 하나), temperature를 저장한다. SDK의 calculateKinetics(kon, koff)는 이 인터페이스를 반환하지만 상수를 어떻게 측정했는지 알 방법이 없으므로, 항상 method: 'spr'와 temperature: 25를 채운다.

const k = sdk.calculateKinetics(5.0e5, 3.0e-4);
// { kon: 500000, koff: 0.0003, kd: 6e-10,
//   complexHalfLife: 2310.49..., method: 'spr', temperature: 25 }

// 자리표시 값을 실제 수행한 내용으로 덮어쓴다:
const record: KineticParameters = { ...k, method: 'bli', temperature: 30 };

자리표시 값을 그대로 저장하면 데이터베이스의 모든 동역학 레코드가 25 °C SPR 측정이라고 주장하게 된다. 이는 계산 문제가 아니라 데이터 무결성 문제이며, 위의 전개 후 덮어쓰기 패턴으로 쉽게 막을 수 있다.

3.4.2 PK와 PD: PKParameters, PKResult, PDParameters, PDResult

PKParameters는 dose(mg), bioavailability(0~1), clearance(L/h), volumeOfDistribution(L), route(iv, sc, im, oral, inhalation), 선택 필드 patientWeight(kg)를 기록한다. PKResult는 auc(mg·h/L), halfLife(h), c0(mg/L), ke(h−1), dosingInterval(h), 그리고 SDK가 한 번도 채우지 않는 선택 필드 cmax와 tmax를 갖는다.

단위 선택은 저분자보다 항체에서 더 중요하다. 항체 청소율은 관례적으로 하루 단위로 보고된다. 청소율 0.2 L/day를 저장하려면 0.2 / 24, 즉 약 0.00833 L/h로 적는다. 분포용적을 6 L(예시 값)로 두면 반감기는 0.693 × 6 / 0.00833 ≈ 499시간, 약 21일로, IgG에서 기대되는 크기와 맞는다. 변환 없이 0.2를 입력하면 반감기가 24배 짧게 나오는데, 타입은 이를 전혀 경고하지 않는다. 투여 경로와 환자 체중은 저장되지만 계산에 들어가지 않으므로, 피하 투여라면 생체이용률을 명시해 기록해야 하고 체중 기준 용량은 레코드를 만들기 전에 밀리그램으로 환산해야 한다.

PDParameters는 mg/L 단위의 concentration과 ec50, emax, 선택 필드 hillCoefficient와 baseline을 받는다. 모델의 다른 곳에서 Kd는 몰 단위이므로, 같은 분자에 대해 결합 결과와 PD 결과를 함께 담은 레코드는 같은 종류의 양을 두 단위계로 갖게 된다. 둘 사이를 변환하려면 분자 질량이 필요하다(약 150 kDa인 IgG라면 1 mg/L은 대략 6.7 nM이다). 모든 제품 레코드에 MolecularWeight를 두어야 하는 이유가 하나 더 생기는 셈이다.

3.4.3 면역원성: 관찰 데이터와 휴리스틱 입력

면역원성 인터페이스 두 개는 비슷해 보이지만 지위가 다르며, 저장도 다르게 해야 한다. ADAData는 연구에서 얻은 관찰이다. incidenceRate(0~1), 선택 필드 titer, neutralizing 플래그, 선택 필드 timeToADA(일), clinicalImpact 등급으로 이루어진다. 반면 ImmunogenicityParams(adaRate, 1~10의 severity, durationFactor, toleranceFactor, priorData)는 제5장에서 다루는 선별용 휴리스틱인 WIA 면역원성 위험 점수(IRS)의 입력이다. 중증도·기간·내약성 계수는 측정값이 아니라 판단이다. 데이터베이스는 이런 레코드를 평가(assessment)로 표시하고 누가 판단했는지 기록해야 하며, 연구 결과와 같은 테이블에 섞어서는 안 된다. 출력인 ImmunogenicityRisk 역시 다시 계산할 수 있는 파생값이다.

3.4.4 안정성: StabilityConditions와 StabilityResult

StabilityConditions는 보관 조건을 나타낸다. temperature(°C), 선택 필드 humidity(% RH), duration(개월), type(long-term, accelerated, stress, real-time), 선택 필드 lightExposed로 이루어진다. StabilityResult는 조건 하나를 내장하고 potency(%), monomerContent(%), aggregates(%), pH, appearance 등급(clear, slightly-opalescent, opalescent, turbid, precipitate), 불리언 passesSpec, 선택 필드 predictedShelfLife(개월)를 보고한다.

StabilityResult 하나는 채취 시점 하나다. 안정성 시험은 조건별·배치별로 여러 채취 시점이 이어지는 계열인데, 모델에는 이를 묶을 시험 식별자나 배치 식별자가 없다. 또 단편과 전하 변이체는 시점별로 추세를 보는 것이 보통인데도 이를 적을 필드가 없고, passesSpec을 어떤 규격에 비추어 판정했는지도 기록되지 않는다. 건전한 구현은 시험 테이블(시험 ID, 배치, 조건)을 따로 두고 각 StabilityResult를 외래 키와 시점 값과 함께 저장한다. duration에서 시점을 추론하는 것보다 이쪽이 정확하다.

3.4.5 QualityAttributes

QualityAttributes는 출하 시험 형태의 스냅숏으로, assessBiosimilarity() 등이 사용한다. purity, monomer, aggregates, fragments(모두 %), 전하 변이체 분포 mainPeak, acidicVariants, basicVariants(%), potency(대조 대비 %), endotoxin(EU/mg), 선택 필드 hcp(ppm)와 residualDNA(투여량당 pg)를 담는다.

수집 단계에서 유용한 확인이 두 가지 있다. 세 값이 같은 SEC 분석에서 나왔다면 크기 변이체 합 monomer + aggregates + fragments는 100 가까이 되어야 하고, 같은 전하 변이체 분석에서 나온 mainPeak + acidicVariants + basicVariants도 마찬가지다. 두 합이 100에서 벗어나면 대개 출처가 섞인 레코드다. 두 번째는 의미상의 문제다. purity의 주석은 "Purity by SEC (%)"이어서 대부분의 데이터에서 monomer와 같은 숫자가 된다. 순도가 왜 직교적인 방법에서 나온 진술이어야 하는지는 제2장에서 설명했다. 데이터 모델에서는 purity를 낸 방법을 확장 필드에 기록해, 이 중복이 숨지 않고 드러나게 해야 한다.

3.5 공정·규제 레코드

3.5.1 CellCultureParams와 PurificationProcess

CellCultureParams는 cellLine 이름, expressionSystem, productivity, cultureDuration(일), viability(%), cellDensity(cells/mL)를 기록한다. productivity의 주석은 "Volumetric productivity (mg/L)"이다. 바이오공정에서 mg/L은 역가(titer), 즉 수확 시점의 제품 농도이고, 체적 생산성은 mg/L/day 같은 속도다. 단위가 가리키는 대로 이 필드에는 수확 역가를 저장하고, 속도가 필요하면 확장 필드에 둔다. 이 레코드에는 배치·런 식별자도, 시계열도 없으므로 공정 이력이 아니라 배양 요약을 기술한다고 보아야 한다.

PurificationProcess는 steps 목록과 overallYield, finalPurity, processDuration(시간)으로 이루어진다. 각 단계는 name, type(affinity, ion-exchange, hydrophobic, size-exclusion, ultrafiltration), 0~1 척도의 단계 yield와 purity를 갖는다. 두 가지를 짚어 둔다.

3.5.2 DrugCandidate와 DrugValidationResult

DrugCandidate는 validateDrugCandidate()가 검사하는 요약 레코드다(제6장). 모델의 다른 곳에 있는 여러 사실을 평면화해 다시 적는다. targetAffinity는 M 단위의 Kd, molecularWeight는 단위 없는 숫자, stability는 0~1 점수이고, immunogenicityRisk와 manufacturingComplexity는 3단계 등급이다. developmentStage는 자체 목록(discovery, preclinical, phase1~phase3, approved)을 쓰는데, 이는 별도의 ClinicalPhase 타입(preclinical, phase1~phase4)과 다르다. 둘을 대응시키려면 discovery, 그리고 approved와 phase4의 관계에 대한 규칙이 필요하다. 출력 인터페이스 DrugValidationResult에는 estimatedTimeline, estimatedCost, successProbability가 있는데, 제6장에서 보듯 코드에 고정된 추정치이므로 분석 결과인 것처럼 영구 저장해서는 안 된다.

3.5.3 RegulatorySubmission

RegulatorySubmission은 pathway, 제출 type(ind, bla, maa, amendment, annual-report), indication, 선택 필드 phase, 선택 필드인 estimatedSubmission·estimatedApproval 날짜, 불리언 orphanDrug와 fastTrack를 기록한다. RegulatoryPathway 유니온은 다음과 같다.

type RegulatoryPathway =
  | 'fda-bla' | 'ema-maa' | 'pmda-japan' | 'nmpa-china'
  | 'biosimilar-351k' | 'accelerated' | 'breakthrough';

이 목록에는 성격이 다른 세 가지가 섞여 있다. fda-bla, ema-maa, pmda-japan, nmpa-china는 규제기관과 그 신청 경로를 가리킨다. biosimilar-351k는 미국의 특정 법정 경로(공중보건서비스법 351(k)조)인 반면, 유럽의 바이오시밀러는 여전히 MAA로 신청한다. accelerated와 breakthrough는 신청의 대안이 아니라 신청 위에 얹히는 신속 프로그램이며, 같은 종류의 세 번째 프로그램에는 이미 별도의 불리언 fastTrack이 있다. 또 한국 식품의약품안전처(MFDS), 캐나다 보건부 등 다른 규제기관을 나타낼 값이 없다. 국내 제출을 관리하려는 한국 사용자에게는 특히 눈에 띄는 공백이다. 더 깔끔한 모델은 규제기관, 신청 경로, 지정 목록을 분리한다. 제7장은 모델을 CTD 모듈에 대응시킬 때 이 분리를 사용한다. 그때까지는 규제기관이 중요한 레코드의 pathway로 accelerated나 breakthrough를 쓰지 않아야 한다. 그렇게 하면 규제기관 정보가 사라진다.

두 날짜 필드는 JavaScript Date 타입이다. 메모리 안에서는 문제없지만 전송 형식에서는 결과가 따르며, 이는 3.7.3에서 다룬다.

3.6 상수, 오류, 결과 타입

3.6.1 BIOPHARMA_CONSTANTS

types.ts는 상수를 모은 불변 객체를 내보낸다. 일부는 물리 상수이고, 대부분은 SDK가 쓰는 대표값이나 정책 경계값이다. 이 구분이 중요하다. 물리 상수는 바꿀 수 없지만 경계값은 설계상의 결정이어서 구현자가 재정의해야 할 수도 있다.

이름코드상 값단위성격비고
AVOGADRO6.022e23mol−1물리 상수반올림한 값.
GAS_CONSTANT1.987cal/(mol·K)물리 상수칼로리 단위로는 맞지만, predictShelfLife()는 자체 지역 변수 R = 8.314 J/(mol·K)를 쓴다. 단위만 다른 같은 상수다.
BOLTZMANN1.381e-23J/K물리 상수SDK에서 쓰이지 않는다.
STANDARD_TEMP298.15K관례25 °C.
IGG_MW150000Da대표값근사값. 실제 IgG 질량은 서열과 당형에 따라 다르다.
IGG_HALF_LIFE21일대표값IgG1·IgG2·IgG4의 규모. IgG3는 더 짧다.
FCRN_KD200e-9M대표값한 점의 값. 명세서(§10.4)는 pH 6.0에서의 범위로 제시한다.
PLASMA_VOLUME3.5L대표값성인 기준값. 명세서는 범위로 제시한다.
MIN_THERAPEUTIC_KD10e-9MWIA 경계값10e-9는 1×10−8 M, 곧 10 nM이다. 1 nM으로 잘못 읽기 쉽다.
MAX_AGGREGATES5%WIA 경계값예시 규격. 실제로는 제품마다 다르다.
MIN_MONOMER95%WIA 경계값위와 같다.
MAX_ENDOTOXIN1EU/mgWIA 경계값위와 같다. 실제 한도는 용량과 투여 경로에 따라 정해진다.
MIN_SHELF_LIFE242~8 °C에서 개월WIA 경계값목표치이며 규제 요건이 아니다.

기체 상수는 전형적인 함정이므로 한 번 더 강조한다. 아레니우스 항 exp(−Ea/RT)에서 Ea와 R은 같은 에너지 단위여야 한다. J/mol 단위의 활성화 에너지를 BIOPHARMA_CONSTANTS.GAS_CONSTANT(칼로리)와 함께 쓰면 지수가 약 4.184배 틀어진다. 이 모델 위에 직접 코드를 작성할 때는 상수를 일반적인 이름으로 가져오지 말고 변수 이름에 단위를 넣는 편이 안전하다(R_J_PER_MOL_K).

3.6.2 오류 코드

오류는 Error의 하위 클래스인 BiopharmaError로 표현되며, BiopharmaErrorCode 열거형의 code와 선택적인 details 객체를 갖는다. 정의된 코드는 열 개다.

코드열거형 멤버의도된 의미v1.0 SDK가 실제로 던지는가
BIO001INVALID_AFFINITY친화도 값이 쓸 수 없거나 범위를 벗어남아니오
BIO002INSUFFICIENT_STABILITY안정성이 요건에 미달아니오
BIO003HIGH_IMMUNOGENICITY면역원성 위험을 수용할 수 없음아니오
BIO004POOR_PK_PROPERTIESPK 특성이 부적합아니오
BIO005QUALITY_FAILURE품질 속성이 규격 이탈아니오
BIO006REGULATORY_NONCOMPLIANCE규제 요건 미충족아니오
BIO007INVALID_PARAMETERS입력이 허용 범위 밖예: index.ts가 던지는 모든 오류
BIO008MANUFACTURING_FAILURE공정 단계 실패아니오
BIO009BIOSIMILARITY_FAILURE비교 결과 유사하지 않음아니오
BIO010SAFETY_CONCERN안전성 신호아니오

v1.0에서 실제로 발생하는 것은 BIO007뿐이다. 약한 친화도나 바이오시밀러 비교 실패 같은 도메인 판정은 오류가 아니라 값(경고, conclusion 문자열, 불리언)으로 보고된다. 친화도가 낮은 후보도 유효한 입력이고 유효한 답을 받아야 하므로 변호할 수 있는 선택이지만, 나머지 아홉 개 코드는 예약된 어휘일 뿐이라는 뜻이기도 하다. 구현은 자체 검증에 이 코드들을 쓸 수 있지만, 적합성 시험이 SDK가 이 코드들을 던지리라 기대해서는 안 된다.

직렬화와 관련된 세부 사항이 하나 있다. BiopharmaError에 JSON.stringify를 적용하면 {"code":"BIO007","details":{...},"name":"BiopharmaError"}가 나오고 메시지는 빠진다. Error의 message는 열거 가능한 고유 속성이 아니기 때문이다. 오류를 JSON으로 반환하는 서비스는 메시지를 명시적으로 복사해야 한다.

3.6.3 Result<T, E>

types.ts는 실패할 수 있는 연산을 위한 판별 유니온과 그 비동기형도 정의한다.

type Result<T, E = Error> =
  | { success: true;  data: T }
  | { success: false; error: E };

type AsyncResult<T, E = Error> = Promise<Result<T, E>>;

그러나 이를 반환하는 SDK 함수는 없고, 모두 예외를 던진다. 모든 응답이 JSON이어야 하는 서비스 경계처럼 예외보다 값을 선호하는 곳이라면 호출을 한 번 감싸면 된다.

import { BiopharmaError, type Result } from '@wia/bio-016';

function attempt<T>(fn: () => T): Result<T, { code: string; message: string }> {
  try {
    return { success: true, data: fn() };
  } catch (e) {
    if (e instanceof BiopharmaError) {
      return { success: false, error: { code: e.code, message: e.message } };
    }
    throw e;   // 프로그래밍 오류는 데이터로 바꾸지 않는다
  }
}

const r = attempt(() => sdk.calculatePharmacokinetics(pk));
if (!r.success) console.error(r.error.code, r.error.message);

3.6.4 배포된 그대로는 타입 검사를 통과하지 못한다

소스에서 빌드하는 구현자는 위의 어느 것보다 먼저 결함 하나를 만난다. 두 파일 모두 끝부분에, 선언 시점에 이미 내보낸 이름을 다시 내보내는 블록이 있다. types.ts는 모든 인터페이스와 타입을 마지막 export type { ... } 목록에서 반복하고 BIOPHARMA_CONSTANTS, BiopharmaErrorCode, BiopharmaError를 export { ... }에서 반복한다. index.ts는 export class BiopharmaSDK를 선언한 뒤 export { BiopharmaSDK }를 다시 적는다. 최신 TypeScript 컴파일러로 확인하면 반복된 이름마다 TS2323("Cannot redeclare exported variable")과 TS2484("Export declaration conflicts with exported declaration") 오류가 나며, 따라서 패키지의 typecheck 스크립트(tsc --noEmit)가 실패한다. 수정은 기계적이다. 끝부분의 재내보내기 블록을 지우고 index.ts의 export * from './types' 줄은 남긴다. 이 책의 예제는 이 수정을 적용했다고 가정한다.

3.7 JSON 직렬화

3.7.1 레코드 간의 관계

표준은 레코드 타입을 정의하지만 그것들이 어떻게 연결되는지는 정의하지 않는다. 아래 그림은 개발 작업 흐름의 순서를 따라 애플리케이션에 필요한 관계를 보여 준다. 실선 화살표는 타입이 중첩으로 표현할 수 있는 관계이고, 점선 화살표는 모델이 제공하지 않는 식별자가 있어야 표현되는 관계다.

  ProductIdentity (애플리케이션 계층: id, DrugClass, format, ExpressionSystem)
        |
        +--> ProteinCharacterization --> MolecularWeight
        |          +--> Glycan[]   +--> PTM[]   +--> disulfideBonds
        |
        +- - > 분석 시험
        |      BindingAffinity --> BindingAffinityResult     (파생)
        |      KineticParameters                              (kd 파생)
        |      PKParameters --> PKResult                      (파생)
        |      PDParameters --> PDResult                      (파생)
        |      ADAData (관찰)   ImmunogenicityParams --> ImmunogenicityRisk
        |
        +- - > 공정: CellCultureParams, PurificationProcess
        |
        +- - > 배치 - - > QualityAttributes
        |          +- - > 안정성 시험 --> StabilityResult[] --> StabilityConditions
        |
        +- - > RegulatorySubmission[]

  DrugCandidate: 위 내용 여럿을 평면화한 요약(단계 판정용)

3.7.2 완전한 예시 레코드

아래 문서는 "EX-01"이라는 가상의 IgG1 항체에 대한 예시다. 모든 숫자는 예시이며 내부적으로 서로 맞도록만 골랐을 뿐, 실제 제품을 기술하지 않는다. 바깥 틀의 필드(wia, schemaVersion, product, ext)는 이 책이 제안하는 관례이며 WIA-BIO-016 v1.0의 일부가 아니다. 표준이 정의하는 것은 안쪽 레코드뿐이다. sequence는 지면 때문에 줄였다. 실제 레코드에는 3.3.1에서 설명한 방식으로 이어 붙인 전체 서열이 들어간다.

{
  "wia": "WIA-BIO-016",
  "schemaVersion": "1.0.0",
  "product": {
    "productId": "EX-01",
    "drugClass": "monoclonal-antibody",
    "isotype": "IgG1",
    "format": "full-igg",
    "expressionSystem": "cho"
  },
  "characterization": {
    "sequence": "EVQLVESGGGLVQPGGSLRLSCAAS",
    "molecularWeight": {
      "theoretical": 148220,
      "measured": 148224,
      "method": "mass-spec",
      "accuracy": 27
    },
    "isoelectricPoint": 8.4,
    "extinctionCoefficient": 210000,
    "glycosylationSites": [297],
    "glycans": [
      { "type": "n-glycan", "composition": "G0F", "abundance": 0.42, "fucosylated": true,  "highMannose": false },
      { "type": "n-glycan", "composition": "G1F", "abundance": 0.38, "fucosylated": true,  "highMannose": false },
      { "type": "n-glycan", "composition": "G2F", "abundance": 0.10, "fucosylated": true,  "highMannose": false },
      { "type": "n-glycan", "composition": "Man5", "abundance": 0.04, "fucosylated": false, "highMannose": true },
      { "type": "n-glycan", "composition": "G0",  "abundance": 0.03, "fucosylated": false, "highMannose": false }
    ],
    "ptms": [
      { "type": "oxidation", "position": 252, "residue": "M",
        "details": "heavy chain, EU numbering", "occupancy": 0.03 }
    ]
  },
  "kinetics": {
    "kon": 500000, "koff": 0.0003, "kd": 6e-10,
    "complexHalfLife": 2310.49, "method": "bli", "temperature": 30
  },
  "pk": {
    "parameters": {
      "dose": 700, "bioavailability": 1,
      "clearance": 0.00833, "volumeOfDistribution": 6,
      "route": "iv", "patientWeight": 70
    }
  },
  "quality": {
    "purity": 99.1, "monomer": 99.1, "aggregates": 0.7, "fragments": 0.2,
    "mainPeak": 68.0, "acidicVariants": 22.0, "basicVariants": 10.0,
    "potency": 102, "endotoxin": 0.05, "hcp": 12, "residualDNA": 5
  },
  "stability": [
    {
      "conditions": { "temperature": 5, "duration": 24, "type": "long-term" },
      "potency": 98, "monomerContent": 98.9, "aggregates": 0.9,
      "pH": 6.0, "appearance": "clear", "passesSpec": true
    }
  ],
  "submissions": [
    { "pathway": "fda-bla", "type": "ind", "indication": "example indication",
      "phase": "phase1", "estimatedSubmission": "2027-03-31T00:00:00.000Z" }
  ],
  "ext": {
    "org.example.lims": {
      "sitesChainQualified": [{ "chain": "HC", "scheme": "EU", "position": 297 }],
      "clearanceSource": { "value": 0.2, "unit": "L/day" }
    }
  }
}

독자는 이 장의 규칙에 비추어 예시를 확인해 볼 수 있다. 당쇄 존재비의 합은 0.97로 1을 넘지 않는다. 크기 변이체 합은 100.0(99.1 + 0.7 + 0.2), 전하 변이체 합도 100.0이다. 동역학 Kd는 koff/kon과 같다. 청소율은 0.2 L/day에서 환산했고 원래 값은 ext 아래에 보관했다. PK 결과는 아예 저장하지 않았다. 매개변수로부터 다시 계산할 수 있기 때문이며, 저장해 두면 SDK가 바뀔 때 낡아 버릴 수 있는 두 번째 진실 원천이 생긴다.

3.7.3 직렬화 규칙

  1. 기본 단위만 쓴다. 숫자는 타입 주석이 지정한 단위(M, L/h, mg, °C, 개월)로 적으며, nM 같은 표시용 단위로 적지 않는다.
  2. 유한하지 않은 수를 쓰지 않는다. Infinity와 NaN은 JSON에서 null이 된다. 필드를 생략하거나 명시적 상태를 추가한다.
  3. 날짜는 ISO 8601 문자열로 쓴다. JSON.stringify는 Date를 "2027-03-31T00:00:00.000Z" 같은 UTC 문자열로 바꾸지만, JSON.parse는 이를 되돌리지 않는다. 파싱 후 날짜 필드를 명시적으로 복원하지 않으면 객체가 실행 시점에 타입과 맞지 않는다. 날짜만 중요하다면 시간대 이동을 피하도록 "2027-03-31"로 저장하고 그 사실을 밝힌다.
  4. 튜플은 원소 두 개짜리 배열로 쓴다. disulfideBonds는 [[22, 96], ...]로 직렬화되며, 안쪽 배열마다 정수가 정확히 두 개인지 검증한다.
  5. 열거값은 정확한 문자열로 쓴다. 아이소타입(IgG1)과 kinexA를 제외하면 모두 하이픈을 쓰는 소문자다. 모르는 값은 가장 가까운 값으로 바꾸지 말고 거부한다.
  6. 파생값은 선택 사항이다. 입력을 저장하고, 출력은 그것을 만든 SDK 버전과 함께일 때만 저장한다.

TypeScript 타입은 실행 시점에 사라지므로, 이 규칙들은 JSON이 시스템에 들어오는 모든 지점에서 명시적 검증기로 강제해야 한다. types.ts에서 생성한 JSON Schema(오픈소스 생성기가 여럿 있다)가 실용적인 출발점이며, 위의 단위·범위 규칙은 타입에 없으므로 손으로 추가한다.

3.7.4 모델이 다루지 않는 것과 확장 방법

WIA-BIO-016 v1.0은 계산 중심의 모델이다. 규제 대상 실험실이나 제조 시스템이 기록해야 하는 여러 사항이 범위 밖에 있다.

확장은 두 원칙을 따라야 한다. 표준 필드의 의미를 절대 바꾸지 않을 것, 그리고 추가 항목은 사용자가 안전하게 무시할 수 있는 네임스페이스에 둘 것. TypeScript에서는 인터페이스를 고치는 대신 확장한다는 뜻이다.

import type { PTM, StabilityResult } from '@wia/bio-016/types';

/** PTM['type']에 없는 변형을 원래 타입을 넓히지 않고 추가한다. */
interface PTMExt extends Omit<PTM, 'type'> {
  type: PTM['type'] | 'deamidation' | 'isomerization'
      | 'pyroglutamate' | 'c-terminal-lysine' | 'glycation';
  chain?: 'HC' | 'LC';
  numbering?: 'EU' | 'Kabat' | 'sequential';
}

/** 채취 시점을 시험·배치·시점에 연결한다. */
interface StabilityPullPoint extends StabilityResult {
  studyId: string;
  batchId: string;
  timePointMonths: number;
  fragments?: number;          // %
}

/** 엄격한 v1.0 사용자에게 내보낼 때 확장 PTM을 핵심 타입으로 낮춘다. */
function toCorePTM(p: PTMExt): PTM | null {
  const core: PTM['type'][] =
    ['glycosylation', 'phosphorylation', 'acetylation', 'methylation', 'oxidation'];
  return core.includes(p.type as PTM['type'])
    ? { type: p.type as PTM['type'], position: p.position, residue: p.residue,
        details: p.details, occupancy: p.occupancy }
    : null;   // 조용히 버리지 말고 "ext" 아래에 보관한다
}

이 하향 변환 함수는 손실을 눈에 보이게 만든다. v1.0 사용자가 표현할 수 없는 탈아미드화 부위는 null로 돌아오고, 호출한 쪽은 그것을 버리는 대신 ext 네임스페이스에 보관하기로 결정한다. 이것이 모델 전체에 적용되는 일반 패턴이다. 표준이 정의한 것에는 엄격하고, 정의하지 않은 것은 명시적으로 다루며, 어떤 변환도 정보를 숨기지 않게 한다.

3.8 장 요약

WIA-BIO-016은 TypeScript 타입으로 데이터 형식을 정의하고, 단위는 주석에 적는다. 타입은 제품 식별, 특성분석, 결합, PK/PD, 면역원성, 안정성, 품질, 공정, 규제 레코드를 다루지만, 제품 최상위 엔터티, 사슬 구분 위치, 배치·시험 식별자, 감사 추적은 없다. 단위는 몰 농도와 질량 농도, 0~1 척도와 0~100 척도가 섞여 있다. SDK는 BIO007만 던지고, calculateKinetics()는 자리표시용 method와 temperature를 반환하며, 결합하지 않은 복합체에 대해 Infinity를 내놓는다. 배포된 소스는 중복 내보내기 블록을 지워야 타입 검사를 통과한다. 신중한 구현은 수집 단계에서 기본 단위로 변환하고, 타입이 표현하지 못하는 불변 조건을 검증하며, 출처 정보를 네임스페이스 확장에 보관하고, 파생 출력 대신 입력을 저장한다. 제4장에서는 이 레코드들을 결합과 PK/PD 계산에 실제로 사용한다.