제6장

제6장: SDK·CLI 인터페이스와 계산 규약

WIA-BIO-015의 참조 SDK와 CLI가 실제로 무엇을 계산하는지 한 줄씩 따라간다. 공식, 임계값, 자리표시 값, 오류, 그리고 명령줄 도구와 라이브러리가 서로 어긋나는 지점을 다룬다.

앞 장들에서는 성장 동역학, 산소 전달, 크로마토그래피, 품질 속성 같은 과학적 기초를 다졌다. 이 장은 표준과 함께 배포되는 소프트웨어로 눈을 돌린다. WIA-BIO-015 v1.0에는 TypeScript 패키지 @wia/bio-015와 Bash 명령줄 도구 wia-bio-015가 포함되어 있다. 명세서가 산문이나 부록 A로만 서술한 여러 규칙은 이 두 도구 안에서만 실행 가능한 형태로 정의된다. 명세서 §9.1이 요구하는 다섯 구성 요소, 즉 수율 계산기(Yield Calculator), 생물반응기 모니터(Bioreactor Monitor), 최적화 엔진(Optimization Engine), 정제 설계기(Purification Designer), 품질 관리(Quality Control)를 구현하는 사람은 실무에서 결국 이 도구들이 돌려주는 값과 비교되게 된다.

이 장은 의도적으로 감사(audit)하듯 하나하나 따져 본다. 함수마다 공식, 입력 검사, 분류 구간을 제시하고 명세서의 계산 예를 그대로 재현한다. 아울러 어떤 출력이 실제 계산 결과이고 어떤 출력이 고정된 자리표시 값인지 분명히 밝힌다. 계산된 것처럼 보이지만 실은 상수인 숫자야말로 공정 엔지니어가 배치 기록에 옮겨 적을 때 가장 위험하기 때문이다. 아래에 기술한 동작은 모두 v1.0 소스를 직접 실행해 확인한 것이다.

6.1 패키지 구성과 진입점

패키지는 작다. 소스 파일 두 개와 빌드 설정이 전부이다. src/types.ts(약 천 줄)는 데이터 모델을 담는다. 배양 조건, 생물반응기 상태, 수율, 정제 단계, 품질 속성, PAT 측정값에 대한 인터페이스와 함께 BIO_CONSTANTS 객체, BioErrorCode 열거형, BioManufacturingError 클래스가 여기에 있다. src/index.ts는 동작을 담는다. BioManufacturingSDK 클래스 하나와 이를 감싸는 독립 함수 여섯 개이다.

@wia/bio-015/
├── package.json          "main": dist/index.js   (CommonJS)
│                         "module": dist/index.mjs (ES module)
│                         "types": dist/index.d.ts
├── src/
│   ├── index.ts          BioManufacturingSDK 클래스 + 독립 함수 6개
│   └── types.ts          인터페이스, BIO_CONSTANTS, BioErrorCode, BioManufacturingError
└── dist/                 빌드 명령: tsup src/index.ts src/types.ts --format cjs,esm --dts --clean

import { monitorBioreactor, BioErrorCode } from '@wia/bio-015';          // "." 진입점
import type { BioreactorStatus } from '@wia/bio-015/types';            // "./types" 하위 경로

빌드는 tsup으로 두 소스 파일 각각에 대해 CommonJS, ES 모듈, 타입 선언 파일을 생성한다. 따라서 사용자는 값은 루트 진입점에서 가져오고, 타입만 필요할 때는 런타임 코드를 끌어오지 않고 ./types 하위 경로에서 가져올 수 있다. 런타임 의존성은 하나도 선언되어 있지 않으며 Node.js 16 이상을 요구한다. 테스트는 Jest와 ts-jest로 설정되어 __tests__ 아래 파일이나 *.test.ts, *.spec.ts 파일을 찾는다. 그러나 v1.0 소스 트리에는 테스트 파일이 하나도 없다. 제8장이 적합성 시험 모음을 제안하는 이유 가운데 하나이다.

기능제공 형태용도
calculateYield클래스 메서드 및 독립 함수공급한 기질 질량 대비 생산물 질량
calculateProductivity클래스 메서드 및 독립 함수g/L/h 및 g/L/day 단위 부피 생산성
monitorBioreactor클래스 메서드 및 독립 함수상태 스냅숏 한 건에 대한 임계값 검사
designPurification클래스 메서드 및 독립 함수회수율·시간·비용 추정을 붙인 크로마토그래피 순서 템플릿
validateBatch클래스 메서드 및 독립 함수역가·생존율·젖산·암모니아에 대한 합격/불합격 선별
optimizeFermentation클래스 메서드 및 독립 함수권장 설정값 반환(v1.0에서는 고정값)
calculateProcessCapability클래스 메서드만데이터 계열과 규격 한계로부터 Cp, Cpk 계산
calculateGrowthKinetics클래스 메서드만CellLine에서 μ, td와 경험식 값 산출(제2장)
getVersion클래스 메서드만'1.0.0' 반환

클래스는 의미 있는 상태를 갖지 않는다. 생성자는 아무도 읽지 않는 비공개 initialized 플래그를 설정할 뿐이고, 독립 함수는 매번 새 인스턴스를 만들어 호출을 위임한다. 결과적으로 모든 계산은 인자만의 순수 함수이므로 동시에 호출해도 안전하고 시험하기도 쉽다. 다만 두 함수는 클래스를 통해서만 접근할 수 있다. 이름 있는 함수 내보내기만 쓰는 통합 개발자는 Cp/Cpk 계산을 찾지 못하며, new BioManufacturingSDK().calculateProcessCapability(...)처럼 호출해야 한다.

v1.0의 빌드 결함. index.ts는 export class BioManufacturingSDK로 선언한 뒤 끝에서 export { BioManufacturingSDK }를 한 번 더 쓴다. types.ts도 모든 인터페이스와 상수를 선언 지점에서 내보낸 다음 마지막 export type { … }, export { … } 목록에서 다시 내보낸다. ECMAScript 모듈은 같은 이름을 두 번 내보내는 것을 금지한다. 수정하지 않은 소스를 모듈로 불러오자 Node.js는 SyntaxError: Duplicate export of 'BioManufacturingSDK'를 내고 멈췄다. 빌드 전에 끝부분의 중복 내보내기 문을 삭제해야 한다. 덧붙여 사소한 패키징 문제도 하나 있다. exports 맵에서 "types" 조건이 "require", "import" 뒤에 놓여 있는데, 조건은 나열된 순서대로 대조되므로 TypeScript 문서는 이를 맨 앞에 두도록 권고한다.

TypeScript 데이터 모델은 명세서 §9.2에 인쇄된 인터페이스와 같지 않다. 명세서는 평평한 요청·응답 형태를 대략 그려 두었고, SDK는 더 풍부한 중첩 타입을 쓴다. 그러므로 명세서 본문에서 출발한 구현은 SDK가 받지 않는 객체를 만들고, 그 반대도 마찬가지이다. 가장 중요한 차이는 다음과 같다.

명세서 §9.2의 형태SDK 타입차이
YieldResponse.efficiency가 'excellent' | 'good' | 'fair' | 'poor' 구간YieldResult구간 이름은 performance이고, efficiency는 수율을 백분율로 나타낸 숫자이다
cellDensity, pH, productTiter가 최상위에 있는 평평한 BioreactorStatusBioreactorStatusconditions(CultureConditions)와 metrics(CellCultureMetrics)로 중첩되며, timestamp, phase, alarms, status가 필수이다
치명 알람이 없는 MonitoringResultMonitoringResultreactorId, timestamp, criticalAlarms, 선택 항목 predictedHarvest가 추가된다
PurificationConfig(출발 농도 없음)PurificationDesignstartingConcentration이 필수이고 선택 항목 budgetConstraint가 추가되며, productType은 모든 ProductType 값을 받는다
estimatedRecovery, estimatedPurity, estimatedCostPurificationProtocol이름이 overallRecovery, finalPurity, costPerGram이고 id, name, viralInactivation이 추가된다

이 책은 SDK 타입을 실제 작업 인터페이스로, §9.2의 목록을 비공식 요약으로 취급할 것을 권하며 이후 장도 그 원칙을 따른다. 이 불일치들은 제8장에서 정오표 제안으로 다시 정리한다.

6.2 calculateYield: 대표 수율

WIA-BIO-015의 수율은 전체 질량비이다. 수확 시점의 생산물 그램 수를 공급한 기질 그램 수로 나눈다. 명세서 §1.4는 정의를 "소비된 기질 대비" 회수한 생산물이라고 적었지만, 부록 A와 SDK는 모두 공급한 기질(초기 농도 × 부피에 피드를 더한 값)로 나누며 수확 시 잔류 기질은 빼지 않는다. 따라서 이 값은 YP/S 같은 화학량론적 수율 계수가 아니며 세포 질량에 대해서도 아무것도 말해 주지 않는다. SDK의 계산은 다음과 같다.

totalProduct = productConcentration × finalVolume  [g]
totalSubstrate = substrateConcentration × initialVolume + feedSubstrate × feedVolume  [g]
Y = totalProduct / totalSubstrate  [무차원]
efficiency = 100 × Y  [%]

두 피드 인자의 기본값은 0이므로, 생략하면 공식은 부록 A.1의 단순 회분식 경우로 줄어든다. 함수는 계산에 앞서 입력을 검증한다. productConcentration이 음수이거나 finalVolume이 0 이하이면 코드 B007(INVALID_PARAMETERS)과 "Product concentration and volume must be positive" 메시지를 담은 BioManufacturingError를 던진다. substrateConcentration이나 initialVolume이 0 이하이면 "Substrate concentration and volume must be positive"와 함께 B007을 던진다. 비대칭에 주의해야 한다. 생산물 농도가 정확히 0인 경우는 허용되어 수율 0을 돌려준다. 실패한 배양에는 이것이 올바른 답이므로 오류로 다뤄서는 안 된다.

결과는 엄격한 부등호로 네 구간 중 하나에 배정된다. Y > 0.15이면 excellent, Y > 0.10이면 good, Y > 0.05이면 fair, 그 밖은 poor이다. 따라서 정확히 0.10인 수율은 "good"이 아니라 "fair"이다. 이 구간은 표준 자체의 약속일 뿐 공개된 벤치마크에서 도출된 것이 아니며, 포도당을 기준 기질로 삼는 동물세포 배양에서만 의미가 있다. 다른 탄소원을 쓰는 미생물 발효나 "기질"을 다르게 정의하는 공정은 별도의 구간이 필요하다.

import { calculateYield } from '@wia/bio-015';

// 명세서 부록 A.1 (회분식, 피드 없음)
const a1 = calculateYield({
  productConcentration: 5.2,     // 수확 시점 g/L
  finalVolume: 1000,             // L
  substrateConcentration: 50,    // 접종 시점 g/L
  initialVolume: 1000,           // L
});
// a1.yield === 0.104, a1.totalProduct === 5200, a1.totalSubstrate === 50000
// a1.efficiency === 10.4, a1.performance === 'good', a1.yieldFormatted === '10.40% (0.1040)'

// 예시용 유가식 사례: 기질 대부분이 피드로 공급된다
const fb = calculateYield({
  productConcentration: 5.2,
  finalVolume: 1000,             // 투입된 피드 부피가 이미 포함된 값이어야 한다
  substrateConcentration: 6,     // 초기 배지 800 L의 포도당 g/L
  initialVolume: 800,
  feedVolume: 200,               // 배양 중 투입한 피드 L
  feedSubstrate: 100,            // 피드의 포도당 g/L
});
// totalSubstrate = 6*800 + 100*200 = 24,800 g  ->  yield ≈ 0.2097, performance 'excellent'

부록 A.1의 입력을 넣으면 공개된 결과가 정확히 나온다. 기질 50,000 g에서 생산물 5,200 g, Y = 0.104, 효율 10.40 %, 구간 "good", 서식 문자열 10.40% (0.1040)이다. 예시용 유가식 호출은 피드 항이 왜 중요한지 보여 준다. 피드 항을 빼면 5,200 g을 4,800 g으로만 나누게 되어 물리적으로 말이 되지 않는 1 초과의 수율이 보고된다.

결과를 어딘가에 기록하기 전에 알아 두어야 할 구현상의 한계가 세 가지 있다. 첫째, 함수는 finalVolume이 초기 부피에 피드 부피를 더하고 시료 채취·증발 손실을 뺀 값과 일치하는지 확인하지 않는다. 호출자가 넘긴 값을 그대로 믿는다. 둘째, 유한하지 않은 수를 거부하지 않는다. NaN과의 비교는 모두 거짓이므로 생산물 농도에 NaN을 넣으면 검증을 통과해 NaN 수율이 나오고 "poor"로 분류된다. 운영용 래퍼는 모든 입력에 Number.isFinite 검사를 해야 한다. 셋째, 단위 처리가 없다. SDK는 전 구간에서 g/L와 L를 가정하므로, mg/L를 넘긴 호출자는 아무 경고 없이 천 배 작은 수율을 받는다.

6.3 calculateProductivity: 부피 생산성

부피 생산성은 반응기 용량 1리터가 단위 시간당 생산물을 얼마나 만들어 내는지를 알려 주며, 설비 처리량을 좌우하는 수치이다. SDK는 배양 시간 동안의 생산물 농도 변화로 이를 계산한다.

Qp = (finalConcentration − initialConcentration) / cultureTime  [g/L/h]
Qp,day = 24 × Qp  [g/L/day]
totalProduct = finalConcentration × volume  [g], volume이 주어진 경우에만

검증 단계에서 cultureTime이 0 이하이면("Culture time must be positive"), 최종 농도가 초기 농도보다 낮으면("Final concentration cannot be less than initial") B007을 던진다. 두 번째 검사는 단일 생산 배양에는 합리적이지만, 수확 흐름으로 생산물이 쌓이는 동안 반응기 안의 농도는 떨어질 수 있는 관류 공정의 정상 데이터를 거부하게 된다. 관류 공정에서는 반응기 농도가 아니라 수확한 질량으로 생산성을 계산해야 한다.

분류는 일 단위 수치로, 역시 엄격한 부등호로 한다. 0.8 g/L/day 초과는 excellent, 0.5 초과는 good, 0.3 초과는 fair, 그 밖은 poor이다. 소스 주석은 이것이 단일클론항체용 기준이라고 밝히고 있고, 명세서 부록 A.2도 0.3–0.8 g/L/day를 일반적인 mAb 범위로 기술한다. 효소, 백신, 미생물 생산물에는 의미가 없다.

부록 A.2를 재현해 보자. 240 h 동안 0에서 5.2 g/L에 도달했다면 Qp = 5.2 / 240 = 0.021667 g/L/h이며, SDK는 이를 0.0217 g/L/h, 일 단위로는 0.520 g/L/day로 표시한다. 0.520은 0.5보다 크므로 구간은 부록과 같은 "good"이다. 서식 문자열은 0.520 g/L/day (0.0217 g/L/h)이고, volume: 1000을 주면 총 생산물은 5,200 g이다. 또 다른 예로, 14일(336 h)에 12 g/L에 도달하면 약 0.857 g/L/day로 "excellent"가 된다.

시간 기준의 선택에는 미묘한 점이 있다. SDK는 호출자가 준 cultureTime으로 그대로 나눈다. 그것이 접종부터 수확까지인지, 접종부터 하류 공정 종료까지인지, 세척·멸균 준비 시간을 포함하는지에 따라 숫자가 크게 달라진다. 배치 간 비교에서는 값과 함께 정의를 기록하고, 설비 계획에서는 준비 시간을 명시적으로 더해야 한다.

6.4 monitorBioreactor: 임계값과 상태 집계

생물반응기 모니터는 실제 제어실에 연결될 가능성이 가장 큰 구성 요소이므로 동작을 가장 꼼꼼히 읽어야 한다. 함수는 BioreactorStatus 스냅숏 하나를 받아 여섯 가지 값을 검사한다. 생존율, pH, 온도, 용존 산소는 항상 검사하고, 젖산과 암모니아는 metrics에 값이 있을 때만 검사한다. 각 검사는 criticalAlarms나 warnings에 항목을 추가할 수 있고, 일부는 권고를 덧붙인다. 비교는 모두 엄격한 부등호이므로 경계값에 정확히 놓인 값은 더 약한 쪽으로 분류된다.

항목치명 알람 조건경고 조건지적 없음추가되는 권고
생존율(%)< 70 (BIO_CONSTANTS.MIN_VIABILITY)70 이상 85 미만≥ 85치명 시: 조기 수확 검토 또는 배양 조건 조사
pH< 6.8 또는 > 7.46.8–<6.9 또는 >7.3–7.46.9–7.3치명 시: pH 제어와 교정 점검
온도(°C)< 35 또는 > 3835–<36 또는 >37.5–3836–37.5치명 시: 온도 제어 점검
용존 산소(공기 포화 %)< 20 또는 > 6020–<30 또는 >50–6030–50치명 시: 교반 또는 통기 조정
젖산(mM), 선택> 4030 초과 40 이하≤ 30치명 시: 포도당 피드 감축·피딩 전략
암모니아(mM), 선택없음> 8≤ 8경고 시: 면밀히 관찰

이 임계값은 37 °C의 동물세포(대개 CHO) 배양을 전제로 쓰였다. 대장균이나 효모 발효는 흔히 다른 pH·용존 산소 목표로 운전되므로 거의 모두 치명으로 표시될 것이다. 여러 발현 시스템을 다루는 구현이라면 생물종별 임계값 프로파일이 필요하지만 v1.0 SDK에는 그런 매개변수가 없다.

알람 문구에는 자체 "target" 범위가 들어 있는데, 그중 두 개는 로직과 맞지 않는다. pH 치명 메시지는 "target: 6.9-7.4"라고 쓰지만 지적 없음 구간은 6.9–7.3이다. 온도 메시지는 "target: 36-37°C"라고 쓰지만 37.5 °C까지는 아무것도 발생하지 않는다. 문구를 읽는 운전원은 코드가 실제로 적용하지 않는 범위를 안내받는 셈이다. 모니터를 사용자 화면 뒤에 둘 때는 문자열이 아니라 하나의 설정 표에서 구간을 보여 주어야 한다.

검사가 끝나면 전체 상태를 건수로 집계한다.

  1. criticalAlarms에 항목이 하나라도 있으면 critical
  2. 그렇지 않고 경고가 두 건을 넘으면(세 건 이상) warning
  3. 그렇지 않고 경고가 한두 건이면 acceptable
  4. 그 밖에는 optimal

이 집계 규칙에서 짚어 둘 결과가 있다. 생존율 80 %와 젖산 35 mM처럼 경고가 두 건인 배양은 "acceptable"로 보고되지만, 사소한 경고가 세 건인 배양은 "warning"이 된다. 심각도가 합산되는 것이 아니라 건수가 합산된다. 많은 공장은 가중 규칙을 선호하겠지만, v1.0에 적합한 구현은 이 규칙을 그대로 재현해야 하며 제8장에는 이를 위한 경계 시험이 포함되어 있다.

다음 예제는 중첩 스냅숏을 만들고 모니터를 실행한 뒤 결과에 따라 분기한다. 계산 오류가 예외로 드러나는 모습도 함께 보여 준다.

import {
  monitorBioreactor,
  calculateYield,
  BioManufacturingError,
  BioErrorCode,
} from '@wia/bio-015';
import type { BioreactorStatus, MonitoringResult } from '@wia/bio-015/types';

// CHO 유가식 배양 9일차 스냅숏 (수치는 예시)
const snapshot: BioreactorStatus = {
  reactorId: 'BR-201',
  timestamp: new Date('2026-01-01T08:00:00Z'),
  conditions: {
    temperature: 37.0,       // °C
    pH: 6.85,                // 6.8–7.4 안, 6.9–7.3 밖  -> 경고
    dissolvedOxygen: 25,     // 공기 포화 %, 20–60 안, 30–50 밖 -> 경고
    agitation: 90,           // rpm (v1.0 모니터는 평가하지 않음)
  },
  metrics: {
    cellDensity: 2.5e6,      // 생존 세포/mL
    viability: 82,           // %  -> 경고 (< 85)
    lactate: 33,             // mM -> 경고 (> 30)
    ammonia: 6,              // mM -> 지적 없음 (<= 8)
  },
  phase: 'stationary',
  alarms: [],                // v1.0 monitorBioreactor는 읽지 않음
  status: 'running',
};

function handle(result: MonitoringResult): void {
  switch (result.status) {
    case 'critical':
      // criticalAlarms가 하나 이상: 교대 책임자 호출
      result.criticalAlarms.forEach((a) => console.error(`[CRITICAL] ${a}`));
      break;
    case 'warning':
      // 경고 3건 이상, 치명 알람 없음
      result.warnings.forEach((w) => console.warn(`[WARN] ${w}`));
      break;
    case 'acceptable':
      // 경고 1~2건: 기록만
      console.info(`${result.reactorId}: ${result.warnings.length} warning(s)`);
      break;
    default:
      console.info(`${result.reactorId}: optimal`);
  }
  result.recommendations.forEach((r) => console.info(`  -> ${r}`));
}

handle(monitorBioreactor(snapshot));   // status 'warning' (경고 4건, 치명 0건)

// 계산 오류는 반환되지 않고 예외로 던져진다
try {
  calculateYield({
    productConcentration: 5.2,
    finalVolume: 1000,
    substrateConcentration: 0,       // 부적합: 0보다 커야 함
    initialVolume: 1000,
  });
} catch (err) {
  if (err instanceof BioManufacturingError && err.code === BioErrorCode.INVALID_PARAMETERS) {
    // err.code === 'B007', err.message === 'Substrate concentration and volume must be positive'
    console.error(`${err.code}: ${err.message}`);
  } else {
    throw err;                       // 그 밖의 예외는 실제 결함이다
  }
}

이 스냅숏에 대해 모니터는 아래 객체를 돌려준다. 경고 네 건, 치명 알람 0건이므로 상태는 "warning"이다. recommendations 배열은 비어 있는데, v1.0은 치명 지적과 암모니아 상승에 대해서만 권고를 추가하기 때문이다. 이미 경고 임계를 넘은 젖산 33 mM에도 조언은 붙지 않는다.

{
  "reactorId": "BR-201",
  "timestamp": "2026-01-01T08:00:00.000Z",
  "status": "warning",
  "warnings": [
    "Viability declining: 82.0%",
    "pH near limits: 6.85",
    "DO suboptimal: 25.0%",
    "Lactate accumulating: 33.0 mM"
  ],
  "criticalAlarms": [],
  "recommendations": [],
  "trends": [
    { "parameter": "Cell Density", "current": 2500000, "trend": "stable",
      "rateOfChange": 0, "alert": "none" },
    { "parameter": "Viability", "current": 82, "trend": "decreasing",
      "rateOfChange": -0.5, "alert": "none" }
  ]
}

이 출력의 여러 부분은 자리표시 값이므로 분석 결과처럼 제시해서는 안 된다. trends 배열에는 항상 정확히 두 항목이 들어간다. "Cell Density"는 언제나 변화율 0의 'stable'로 보고된다. "Viability"는 현재 생존율이 90 % 미만이면 이력과 관계없이 고정 변화율 −0.5의 'decreasing'으로 보고된다. 함수가 스냅숏 하나만 보기 때문이다. predictedHarvest는 채워지지 않는다. 입력의 phase, alarms, status와 선택 항목 agitation, aeration, pressure, co2는 전혀 평가되지 않는다. 실제 모니터에는 시계열이 필요하며(SensorReading, TimeSeriesPoint 타입이 이를 위해 있다), 변화율은 그 시계열에서 계산해야 한다. 그 루프는 제7장에서 설명한다.

6.5 정제 설계, 공정 능력, 배치 검증, 최적화

나머지 함수들은 §9.1의 다른 세 구성 요소를 다룬다. 실제 계산이 담긴 정도가 함수마다 크게 다르므로, 계산된 출력과 고정된 출력을 분명히 구분해 설명한다.

6.5.1 designPurification

정제 설계기는 생산물 유형 하나만으로 고른 크로마토그래피 순서 템플릿을 돌려준다. 'antibody'이면 전형적인 3컬럼 경로, 즉 Protein A 포획, 통과(flow-through) 방식의 음이온 교환 연마, 소수성 상호작용 크로마토그래피(HIC)를 만든다. 그 밖의 모든 생산물 유형에는 양이온 교환 포획 뒤 HIC의 두 단계를 만든다. 각 단계의 레진 부피는 적재 질량을 해당 레진에 가정한 동적 결합 용량(DBC)으로 나누어 계산한다. 이 나눗셈이 함수 안의 유일한 공학 계산이며, 나머지는 모두 상수에서 나온다.

출력v1.0이 값을 얻는 방법항체 예(5 g/L, 1,000 L)
단계별 회수율고정: 0.95, 0.92, 0.90(항체) / 0.90, 0.90(기타)95 %, 92 %, 90 %
단계별 순도고정: 95, 98, 99 %(항체) / 80, 99 %(기타)최종 99 %
출발 순도30 %로 가정한 뒤 덮어씀, 보고되지 않음—
DBC고정: 40 g/L(Protein A), 60 g/L(AEX), 50 g/L(CEX), 40 g/L(HIC)레진 125 L, 79.2 L, 109.25 L
전체 회수율고정 회수율의 곱0.95 × 0.92 × 0.90 = 78.66 %
소요 시간크로마토그래피 단계당 8 h + 바이러스 불활화 2 h + UF/DF·제형화 4 h30 h
그램당 비용항체: 50 + 20 × 단계 수, 기타: 30 + 15 × 단계 수(US$/g)US$ 110 /g
id'PURIF-' + Date.now()같은 밀리초 안에서는 고유하지 않음

입력 targetPurity, targetRecovery, budgetConstraint는 받기만 하고 사용하지 않는다. 위 예에서 호출자는 회수율 80 %를 요구했지만 아무 경고 없이 78.66 %짜리 프로토콜을 받았다. 비용 수치는 비용 모델이 아니다. 규모, 역가, 레진 가격, 레진 수명과 무관하다. 단계별 순도도 공급액의 함수가 아니다. 음이온 교환 단계는 통과 방식이라고 표시되어 있지만, 레진 부피는 결합·용출 단계처럼 생산물 결합 용량으로 산정된다. 후속 단계의 적재 부피는 수확 부피에 질량 회수율을 곱한 값이어서 부피와 질량이 뒤섞여 있다. 요컨대 designPurification은 사용자 화면의 뼈대로는 쓸모 있고 mAb의 기본 순서로도 합리적이지만, 레진 부피를 제외한 출력 숫자는 화면에 보일 때 모두 "템플릿 값"으로 표시해야 한다. 실행해 보니 같은 밀리초에 두 번 호출하면 id도 같았다. 영속 계층은 자체 식별자를 만들어야 한다.

6.5.2 calculateProcessCapability

이 함수는 교과서대로 올바르게 구현되어 있다. 평균과 표본 표준편차 s(n − 1로 나눔)를 구한 다음

Cp = (USL − LSL) / (6s)    Cpk = min[(USL − x̄) / (3s), (x̄ − LSL) / (3s)]

를 계산하고 Cpk로 분류한다. 1.33 이상이면 capable, 1.0 이상 1.33 미만이면 marginally-capable, 1.0 미만이면 not-capable이다. 규격 한계 98 %와 100 %에 대해 순도 데이터 98.9, 99.1, 99.0, 98.8, 99.2 %를 넣으면 평균 99.0 %, s ≈ 0.158, Cp = Cpk ≈ 2.11("capable")이다. 다섯 점은 s를 믿을 만하게 추정하기에 턱없이 적으며, 제8장이 표본 크기를 다루는 이유가 바로 여기에 있다. 이 함수에는 막아야 할 경계 사례가 두 가지 있다. 데이터가 한 점이면 분산이 0/0이 되어 s, Cp, Cpk가 모두 NaN이 되고, 결과는 공정 개선을 권하는 "not-capable"로 나와 오해를 부른다. 이때 올바른 응답은 B010(INSUFFICIENT_DATA)이다. 값이 모두 같으면 s = 0이 되어 지수가 무한대가 된다.

6.5.3 validateBatch

배치 검증은 요약값 네 개를 선별한다. 매개변수 이름은 targetTiter, targetViability이지만 함수는 이를 배치가 실제로 달성한 값으로 취급한다. 규칙은 다음과 같다.

오류가 없으면 결과는 유효하다. 품질 점수는 100에서 시작해 오류당 20점, 경고당 5점을 빼며 하한은 0이다. 각별히 주의할 곳은 compliance 객체이다. gmp는 GMP가 요구되지 않거나, 요구되더라도 오류가 없으면 참이다. cqas는 오류가 없으면 참이다. documentation은 gmpRequired를 그대로 복사할 뿐이다. sterility는 언제나 true이며, 소스 주석도 실제 무균 시험이 필요하다고 적고 있다. 역가 1.8 g/L, 생존율 88 %, 최대 젖산 42 mM, 암모니아 6 mM인 배치는 isValid: true, 경고 두 건, 점수 90을 돌려받는다. 이것은 출하 판정이 아니라 사전 선별이다. sterility 표시는 반드시 이 함수가 아니라 QC 실험실의 실제 결과에서 가져와야 한다.

6.5.4 optimizeFermentation

v1.0의 최적화 엔진은 최적화를 하지 않는다. 어떤 입력에도 같은 설정값을 돌려준다. 초기 밀도 3 × 105 cells/mL, 포도당 6.0 g/L, 글루타민 4.0 mM, 온도 37.0 °C, pH 7.1, 예측 반응값 5 × 106 cells/mL와 고정된 ±10 % 구간, 그리고 7일차 온도 전환을 동반한 지수 포도당 피딩을 권하는 고정 문구이다(반환된 37.0 °C 설정값에는 그 온도 전환이 반영되어 있지 않다). 입력에 따라 달라지는 필드는 improvement 하나뿐이다. 초기 밀도를 μ = 0.03 h−1로 앞으로 외삽한 값이 5 × 106에 얼마나 못 미치는지를 보고하며 하한은 0이다. 3 × 105 cells/mL에서 시작하면 96 h 배양은 이미 목표를 넘어 0 %를, 48 h 배양은 약 295 %를 돌려준다. volume, initialSubstrate, feedRate, feedConcentration은 무시된다. 실제 최적화 엔진이라면 제8장에서 설명하듯 DOEConfig, DOEFactor 타입을 써서 DOE 데이터에 반응 표면 모델을 적합해야 한다.

6.6 오류 모델

SDK가 발생시키는 오류는 모두 BioManufacturingError의 인스턴스이다. Error의 하위 클래스로서 code(BioErrorCode), message, 구조화된 맥락을 담는 선택 항목 details의 세 필드를 가진다. name은 'BioManufacturingError'로 설정되는데, 직렬화를 거치면 클래스 정체성은 사라져도 이 이름은 남는다. 열거형은 코드 열 개를 정의한다. 명세서 §9.3은 그중 앞의 여섯 개만 권장 조치와 함께 기술하며, B007부터 B010까지는 SDK에만 존재한다.

코드열거형 멤버명세서 §9.3의 의미와 조치v1.0 SDK에서 발생 여부
B001LOW_VIABILITY세포 생존율 저하: 배양 조건 점검아니오(모니터가 문자열 알람을 냄)
B002HIGH_LACTATE젖산 과다: 포도당 피드 감축아니오(문자열 알람)
B003LOW_TITER역가 저하: 발현 최적화아니오(validateBatch 문자열)
B004pH_EXCURSIONpH 이탈: 센서 교정아니오(문자열 알람)
B005CONTAMINATION오염 검출: 조사 후 배치 폐기아니오
B006EQUIPMENT_FAILURE장비 고장: 정비 필요아니오
B007INVALID_PARAMETERS명세서에 없음예: calculateYield, calculateProductivity
B008GMP_VIOLATION명세서에 없음아니오
B009OUT_OF_SPEC명세서에 없음아니오
B010INSUFFICIENT_DATA명세서에 없음아니오(n < 2인 Cp/Cpk에 적합)

이 코드들은 서로 다르게 다뤄야 할 두 계열로 나뉜다. B001–B006은 공정의 상태를 기술한다. 살아 있는 세포와 장비에 대한 관찰이며, 소프트웨어가 완벽하게 정상 동작하는 중에도 발생한다. 이런 지적은 데이터에 속한다. 심각도와 확인 플래그를 가진 BioreactorAlarm 레코드로, 그리고 MonitoringResult 안에 담겨야 한다. 배양 생존율이 떨어졌다고 예외를 던지면 바로 그 사실을 보고해야 할 코드 경로가 중단된다. 반면 B007과 B010은 계산에 들어온 잘못된 입력을 기술한다. 호출자의 문제이므로 예외로 던지는 것이 적절하다. B008과 B009는 그 중간에 있다. 대개 검증·출하 업무 흐름에서 생기는 품질 지적이므로 예외로 던지기보다 일탈(deviation)로 기록해야 한다.

v1.0의 모니터는 공정 지적을 자유 형식 문자열로만 보고한다. 이를 B001–B004로 되돌리려면 "Low viability"나 "pH out of range" 같은 메시지 접두어를 대조해야 하는데, 문구가 바뀌거나 번역되는 순간 깨진다. 구현은 각 지적에 코드를 붙이고(예를 들어 문자열과 함께 BioreactorAlarm 객체를 생성), 표준의 다음 개정판은 모니터 지적에 code 필드를 추가할 것을 권한다. 그러면 §9.3의 운전원 조치가 산문이 아니라 코드에 자연스럽게 연결된다.

끝으로 types.ts는 판별 합집합 Result<T, E>({ success: true; data } 또는 { success: false; error })와 AsyncResult 별칭을 선언하지만, 이를 반환하는 SDK 함수는 없다. 예외가 네트워크 너머로 새어 나가서는 안 되는 서비스 경계에서 유용하다. 작은 어댑터 하나로 예외 방식을 선언된 합집합으로 바꾸면서 실제 결함은 그대로 전파할 수 있다.

import { calculateProductivity, BioManufacturingError, BioErrorCode } from '@wia/bio-015';
import type { Result, ProductivityParameters } from '@wia/bio-015/types';

// 어댑터: SDK의 예외 방식을 types.ts에 선언된 Result<T, E> 합집합으로 바꾼다
export function attempt<T>(fn: () => T): Result<T, BioManufacturingError> {
  try {
    return { success: true, data: fn() };
  } catch (err) {
    if (err instanceof BioManufacturingError) {
      return { success: false, error: err };
    }
    throw err;   // 프로그래밍 결함을 업무 오류로 바꾸지 않는다
  }
}

// 서비스 경계에서의 사용; `body`는 파싱된 요청 본문
export function productivityEndpoint(body: ProductivityParameters) {
  const r = attempt(() => calculateProductivity(body));
  if (!r.success) {
    const httpStatus = r.error.code === BioErrorCode.INVALID_PARAMETERS ? 400 : 500;
    return { httpStatus, body: { code: r.error.code, message: r.error.message } };
  }
  return { httpStatus: 200, body: r.data };
}

어댑터는 BioManufacturingError가 아닌 것은 모두 다시 던진다. 이 구분이 중요하다. 필드 누락으로 생긴 TypeError는 통합 코드의 버그이며, 이를 정중한 400 응답으로 바꾸면 버그가 가려진다. SDK는 details를 채우지 않으므로 어댑터도 이를 반환하지 않는다. 자체적으로 B007을 던지는 구현이라면 문제가 된 필드 이름을 details에 넣어 클라이언트가 해당 필드를 강조 표시할 수 있게 해야 한다.

6.7 wia-bio-015 명령줄 도구

CLI는 독립된 Bash 스크립트이다. TypeScript SDK를 호출하지 않고, 부동소수점 산술에 bc를 써서 같은 규칙을 다시 구현한다. 따라서 호스트에 bc가 설치되어 있어야 한다. 스크립트는 set -e를 쓰고, ANSI 이스케이프 코드로 색을 입혀 출력하며, 인식하지 못한 옵션은 무시한다.

명령옵션(기본값)출력 내용
calc-yield--product(5.2 g/L), --substrate(50 g/L), --volume(1000 L)총 생산물, 총 기질, 수율, 구간
calc-productivity--final(5.2 g/L), --initial(0), --time(240 h)g/L/h, g/L/day, 구간
monitor--reactor(BR-001), --cells(2.5e6), --viability(95), --ph(7.2), --temp(37), --do(40)항목별 검사와 전체 상태
design-purification--product(antibody), --scale(1000)고정 단계 목록과 합계
optimize--cell-line(CHO), --product(mAb), --target(8.0 g/L)고정 설정값, 피딩 계획, 기간 = 목표 / 0.6일
version, help—버전 배너, 사용법

부록 A.1과 6.4절의 모니터 스냅숏을 재현한 세션은 다음과 같다(배너와 구분선은 생략했다).

$ wia-bio-015 calc-yield --product 5.2 --substrate 50 --volume 1000
▶ Product Yield Calculation
  Product Concentration: 5.2 g/L
  Final Volume: 1000 L
  Substrate Concentration: 50 g/L
  Initial Volume: 1000 L
  Total Product: 5200.00 g
  Total Substrate: 50000.00 g
▶ Results
✓ Yield: 0.1040 (10.40%)
✓ Performance: GOOD

$ wia-bio-015 monitor --reactor BR-201 --viability 82 --ph 6.85 --do 25
▶ Bioreactor Monitoring
  Reactor ID: BR-201
  Timestamp: 2026-09-25 02:06:36
▶ Culture Metrics
  Viable Cell Density: 2.5e6 cells/mL
  Viability: 82%
  pH: 6.85
  Temperature: 37°C
  Dissolved Oxygen: 25%
▶ Status Checks
⚠ Viability: WARNING (<85%)
✗ pH: OUT OF RANGE (6.9-7.4)
✓ Temperature: OPTIMAL
⚠ Dissolved Oxygen: SUBOPTIMAL (30-50%)
▶ Overall Status
⚠ Status: WARNING - Monitor closely

수율 출력은 SDK와 정확히 일치한다. 모니터 출력은 일치하지 않으며, 그 차이가 시사하는 바가 크다. 같은 pH 6.85에 대해 SDK는 경고를 낸다(6.85는 6.8–7.4의 치명 경계 안에 있다). 반면 CLI는 경고 구간 없이 6.9–7.4 단일 구간을 쓰므로 "OUT OF RANGE"를 출력한다. CLI는 젖산과 암모니아를 아예 받지 않는다. 온도 검사는 36–37 °C를 벗어나면 경고만 내므로, SDK에서 치명 알람인 38.5 °C는 CLI에서 경고이고, SDK에서 아무 일도 없는 37.4 °C는 CLI에서 경고가 된다. 전체 상태는 생존율과 pH만 보기 때문에, 상태 검사에서 범위 밖으로 표시된 용존 산소 15 %도 결국 "Status: OPTIMAL"로 끝난다. CLI는 시연에는 편리하지만 적합한 생물반응기 모니터가 아니다.

그 밖에 v1.0 스크립트로 확인한 알려진 결함은 다음과 같다.

이 책의 실무 원칙은 간단하다. CLI와 SDK가 다를 때는 SDK가 기준이다. SDK는 데이터 모델의 실행 가능한 형태이고 그 숫자가 부록 A를 재현하기 때문이다. CLI는 교육용 도구이자 수율·생산성 간이 계산기로 쓰고, 운영 결정을 CLI의 종료 코드에 기대어 스크립트로 만들지 말아야 한다.

요약

참조 SDK는 의존성 없는 간결한 순수 함수 모음이다. 그중 calculateYield와 calculateProductivity는 부록 A의 계산을 정확히 구현하여 0.104와 0.0217 g/L/h(0.52 g/L/day)를 재현한다. calculateProcessCapability는 올바른 Cp/Cpk 계산이지만 작은 표본에 대한 방어가 필요하다. monitorBioreactor는 적합한 구현이 그대로 맞춰야 할 정밀한 임계값 표와 건수 기반 상태 규칙을 담고 있으나, 추세 값은 자리표시에 불과하다. designPurification, validateBatch, optimizeFermentation은 대부분 템플릿이며 출력에 그렇게 표시해야 한다. 실제로 던져지는 오류는 B007뿐이며, 공정 지적은 예외가 아니라 코드가 붙은 알람으로 전달해야 한다. CLI는 모니터 구간이 다르고 입력 처리에 여러 결함이 있는 별도의 Bash 재구현이다. 제7장은 이 함수들을 가동 중인 공장 안에 배치하고, 제8장은 여기서 기록한 동작을 적합성 시험 모음으로 바꾼다.