제4장

제4장: API·SDK·CLI — 등록에서 분양 요청까지

WIA-BIO-019의 REST 엔드포인트 세 개, BioBankingSDK 참조 구현, wia-bio-019 명령행 도구, 그리고 이들을 묶는 오류 모델을 실제 소스와 대조하며 읽는다. 문서끼리 서로 어긋나는 지점도 함께 짚는다.

제3장에서는 바이오뱅크 기록이 어떤 모양인지를 정의하였다. 이 장은 그 기록이 어떻게 들어오고 나가는지를 다룬다. LIMS나 임상 시스템, 연구 포털이 호출할 HTTP 인터페이스, 표준과 함께 배포되는 TypeScript SDK, 같은 기능의 일부를 셸에서 쓰게 해 주는 명령행 도구가 그 대상이다. 범위는 일부러 좁게 잡았다. 상태가 무엇을 뜻하는지, 분양 요청을 누가 승인하는지, 보관연속성을 어떻게 기록해야 하는지는 제5장의 생애주기 프로토콜에서 다루고, 동의의 의미는 제6장에서 다룬다. 이 장이 답하려는 질문은 더 단순하고 기계적이다. 어떤 필드가 어디로 가고, 무엇이 돌아오며, 무엇이 실패하는가.

인터페이스를 설명하는 출처는 세 가지이고, 이들은 서로 일치하지 않는다. 명세 §9.1.2는 엔드포인트 세 개의 JSON 예시를 보여 준다. 패키지 @wia/bio-019(파일 api/typescript/src/types.ts와 index.ts)는 타입과 메모리 기반 SDK를 정의한다. 스크립트 cli/wia-bio-019.sh는 독립적인 명령행 도구이다. 여기에 README가 네 번째 예시 묶음을 담고 있는데, 이것은 나머지 셋 어디와도 맞지 않는다. 출처가 갈라지는 곳에서 이 장은 코드가 실제로 하는 일을 인용한다. 개발자가 가장 먼저 부딪히는 것이 코드이기 때문이다. 그리고 어느 쪽을 권장하는지 분명히 밝힌다.

4.1 REST 인터페이스

명세 제9절은 인터페이스를 LIMS의 맥락 안에 놓는다. 고유 ID·바코드·냉동고/랙/박스/위치로 이루어진 시료 추적, 실시간 가용성과 품질 표시를 갖춘 재고, 비식별화된 기증자 데이터, 요청·반출·배송 워크플로가 그것이다. 이 가운데 명세가 확정한 HTTP 연산은 정확히 세 개뿐이다. 해당 절의 번호는 9.1 아래에 있으면서도 “9.9.2”로 잘못 매겨져 있어, 이 책에서는 §9.1.2로 부른다.

메서드경로용도주요 요청 필드응답 필드
POST/api/v1/samples새 생체시료 등록sample_id, type, donor_id, collection_date, volume, volume_unit, storage_condition, location{}, quality{}, consent_idsuccess, sample_id, barcode, registered_at
GET/api/v1/inventory보관 중인 시료 검색쿼리 문자열: type, available, min_volumetotal_count, available_count, samples[]
POST/api/v1/requests연구 과제용 시료 분양 요청requester_id, project_id, samples[], purpose, ethics_approval, return_daterequest_id, status, estimated_fulfillment

SDK에는 분주, 보관연속성 이벤트, 동의 기록, 온도 이탈, 기존 시료 수정을 위한 메서드가 모두 있지만, 이에 해당하는 엔드포인트는 없다. 이런 기능을 HTTP로 제공하려면 구현자가 직접 설계해야 한다. 가장 무난한 방법은 같은 /api/v1 접두어 아래에 SDK 메서드 이름을 그대로 따른 경로를 만들고, 그것이 v1.0의 일부가 아니라 자체 확장이라는 점을 서비스 문서에 명시하는 것이다.

4.1.1 시료 등록

등록은 누군가의 손에 들린 튜브 하나를 식별자·위치·바코드를 갖춘 기록으로 바꾸는 일이다. 아래 요청은 명세의 예시를 필드 하나하나 그대로 따르되 두 값만 바꾸었다. 명세는 "type": "blood_plasma"와 "storage_condition": "minus_80"이라고 적었지만, 두 문자열 모두 표준 자체의 열거형에 존재하지 않는다. SampleType에는 plasma가 있고, StorageCondition에는 ultra_low_freezer가 있다. 타입을 기준으로 검증하는 서버라면 명세의 표기를 거부할 것이므로, 이 책은 전송 형식에도 열거형 값을 쓴다. 시료 ID도 무작위로 보이는 SAM-7K2Q9X로 바꾸었다. 무작위 영숫자를 쓰고 날짜나 일련번호를 넣지 말라는 §8.3.1을 따른 것이다.

// POST /api/v1/samples  (책의 예시: 전송 형식에 열거형 어휘 사용)
요청
{
  "sample_id": "SAM-7K2Q9X",
  "type": "plasma",
  "donor_id": "D-789012",
  "collection_date": "2025-12-26T08:30:00Z",
  "volume": 10.5,
  "volume_unit": "mL",
  "storage_condition": "ultra_low_freezer",
  "location": { "freezer": "F-01", "rack": "R-03", "box": "B-25", "position": "A5" },
  "quality": { "hemolysis_index": 25, "total_protein": 72 },
  "consent_id": "CNS-456789"
}

응답 (명세 §9.1.2의 형태)
{
  "success": true,
  "sample_id": "SAM-7K2Q9X",
  "barcode": "data:image/png;base64,iVBOR...",
  "registered_at": "2025-12-26T10:45:30Z"
}

전송 형식은 snake_case이며 의도적으로 평평하다. 기증자는 donor_id 하나로, 동의는 consent_id 하나로 줄어들고, 혈액 품질 지수는 quality 바로 아래에 놓인다. 반면 SDK의 Sample 타입은 이 셋을 모두 중첩 구조로 둔다. 둘 사이의 변환이 어렵지는 않지만 반드시 명문화해야 한다. 패키지 어디에도 이 변환을 대신해 주는 코드가 없기 때문이다.

전송 필드 (§9.1.2)SDK 필드비고
sample_idid필수. 없거나 중복이면 B001.
typetype: SampleType열거형 값 14개 중 하나를 쓴다. blood_plasma는 그중에 없다.
donor_iddonor.donorId비식별 코드만 담는다. 연결 키는 별도로 보관한다(제6장).
collection_datecollectionDate: DateUTC 오프셋을 명시한 ISO 8601. 값이 없으면 SDK는 아무 경고 없이 “현재 시각”으로 채우므로, API 단계에서 거부하는 편이 옳다.
volume, volume_unitvolume, volumeUnit둘 다 필수. 단위를 암묵적으로 가정하지 않는다.
storage_conditionstorageConditionminus_80은 열거형 값이 아니다. ultra_low_freezer를 쓴다.
location{freezer, rack, box, position}location: StorageLocation전송 형식에는 fullLocation이 없으므로 서버가 F-01/R-03/B-25/A5를 조합해야 한다. 위치가 없으면 SDK는 UNASSIGNED를 저장한다.
quality.hemolysis_index, quality.total_proteinquality.blood.hemolysisIndex, quality.blood.totalProtein전송 형식에서는 평평하고, SDK에서는 blood 아래에 중첩된다.
consent_iddonor.consentId4.3.2의 동의 관련 주의점을 볼 것.

응답은 바코드를 data:image/png;base64,… 형태의 URI로 돌려준다. 명세 §3.3은 Code128과 DataMatrix 심볼로지를 허용하고 극저온에 견디는 라벨을 요구하지만, API는 PNG가 어느 심볼로지로 인코딩되었는지 밝히지 않는다. 따라서 라벨을 인쇄하는 클라이언트는 이미지를 단지 그려진 결과로 취급하고, 기준 값은 sample_id로 삼아야 한다. registered_at은 서버가 찍은 시각이며 collection_date와 혼동해서는 안 된다. 두 시각의 차이가 바로 제2장에서 품질 변수로 다룬 처리 소요 시간이다.

4.1.2 재고 조회

재고 엔드포인트는 뱅크가 분양할 수 있는 시료를 조건으로 걸러 검색한다. 명세의 예시는 쿼리 매개변수 세 개를 받고, 건수와 일치하는 시료 목록을 돌려준다.

GET /api/v1/inventory?type=plasma&available=true&min_volume=5

응답 (명세 예시에서 "total_count"만 유효한 JSON으로 고침)
{
  "total_count": 2450,
  "available_count": 2380,
  "samples": [
    {
      "sample_id": "SAM-7K2Q9X",
      "type": "plasma",
      "volume": 10.5,
      "collection_date": "2025-12-26",
      "storage_duration_days": 0,
      "quality_score": 0.98,
      "location": "F-01/R-03/B-25/A5"
    }
  ]
}

위 예시에는 두 가지 수정이 들어가 있다. 명세는 "total_count": 2,450이라고 적었는데, 이는 유효한 JSON이 아니어서 엄격한 파서는 쉼표에서 멈춘다. 또 쿼리 문자열에 blood_plasma를 썼는데, 이것도 열거형 값으로 바꾸었다. 건수 자체는 예시일 뿐 참고 자료가 아니다.

항목의 구조는 온전한 Sample이 아니라 요약이다. quality_score는 quality.integrityScore(제3장의 SIS)에 대응하고, location은 조합된 fullLocation 문자열이며, storage_duration_days는 조회 시점에 계산되는 값으로 SDK에 대응 필드가 없다. 예시에는 volume_unit이 빠져 있다. 표준의 나머지 부분이 단위를 항상 명시하라고 요구한다는 점을 생각하면 이것은 실제 결함이며, 서비스에서는 이 필드를 넣어야 한다. 세 쿼리 매개변수는 InventoryQuery와 이렇게 대응한다. type → sampleType, available → availableOnly, min_volume → minVolume. InventoryQuery에는 이 밖에도 storageCondition, collectionDateRange, minQualityScore, donorAgeRange, donorSex, limit, offset이 정의되어 있지만, 명세는 이들을 HTTP에 노출하지 않는다. 서비스가 이들을 추가한다면 다른 전송 필드와 맞추어 storage_condition, min_quality_score 같은 snake_case 형태를 써야 한다.

또 하나의 빈틈은 페이지 처리이다. 응답에는 총계만 있고 페이지 정보가 없다. 그런데 types.ts에는 items와 meta 객체(total, limit, offset, hasMore)를 갖는 PaginatedResponse<T>가 이미 정의되어 있다. 재고 경로가 이 봉투 구조를 그대로 돌려주면 추가 비용 없이 클라이언트가 짐작 없이 페이지를 넘길 수 있다.

4.1.3 시료 분양 요청

세 번째 엔드포인트는 분양 요청을 연다. 누가, 어떤 과제를 위해, 어떤 윤리 승인 아래에서, 각 시료를 얼마나 원하는지를 기록한다.

// POST /api/v1/requests
요청
{
  "requester_id": "RES-001",
  "project_id": "PRJ-2025-042",
  "samples": [
    { "sample_id": "SAM-7K2Q9X", "volume_requested": 0.5, "volume_unit": "mL" }
  ],
  "purpose": "Biomarker analysis",
  "ethics_approval": "IRB-2025-123",
  "return_date": "2025-12-30"
}

응답 (명세 §9.1.2 그대로)
{
  "request_id": "REQ-987654",
  "status": "pending_approval",
  "estimated_fulfillment": "2025-12-27T14:00:00Z"
}

타입 정의와 맞지 않는 곳이 세 군데 있으며, 어떤 구현이든 이를 바로잡는 것이 좋다. 첫째, 응답 상태 pending_approval은 RequestStatus의 값이 아니다. 열거형은 pending에서 시작하여 under_review, approved, rejected, fulfilled, cancelled로 이어진다. 열거형으로 분기하는 클라이언트는 명세의 문자열을 알아보지 못한다. 둘째, 전송 형식에서는 return_date가 요청 단위에 있지만, RequestedSample은 시료마다 returnDate를 두고 필수 불리언 permanent도 함께 둔다. 반납할 조직 블록과 소모될 혈장을 한 요청에 섞으면 전송 형식으로는 표현할 수 없으므로, 시료 단위 필드를 보내야 한다. 셋째, SampleRequest는 organization과 projectTitle을 필수로, 물질이전계약(MTA) 참조인 mta를 선택으로 두는데, 예시에는 어느 것도 나오지 않는다. §8.2.2의 분양심의위원회(access committee)가 MTA를 검토하므로 그 참조는 요청에 들어가야 한다.

SDK에는 분양 요청을 다루는 메서드가 아예 없다. SampleRequest, RequestedSample, RequestStatus 타입은 존재하지만 BioBankingSDK는 이들을 한 번도 쓰지 않는다. 요청을 pending에서 fulfilled까지 옮기는 심의 워크플로는 제5장에서 설명하며, 구현자가 직접 만들어야 한다.

4.2 보안 외피

명세 제9절은 인증이나 전송 보안에 대해 아무 말도 하지 않는다. 패키지 안에서 보안을 언급하는 문장은 같은 폴더의 PHASE-* 문서에서만 나온다. 이 파일들은 잘못된 식별자(WIA-BIO-018)를 단 템플릿 문안이므로, 여기서는 일반적이고 논란의 여지가 없는 줄만 참고 맥락으로 쓰며 규범으로 삼지 않는다.

같은 PHASE 문서는 기본 URL(api.wia-bio.org/bio-banking/v1)과 시간당 1000건이라는 호출 한도도 제시한다. 그러나 표준의 다른 어느 곳에서도 이를 뒷받침하지 않는다. 그 기본 URL은 §9.1.2의 /api/v1/… 경로와 맞지 않고, 모든 배포에 하나의 고정 한도를 강제하는 것은 표준이 합리적으로 정할 수 있는 사항이 아니다. 둘 다 검증되지 않은 값으로 취급해야 한다. 호스트 이름과 할당량은 배포마다 자신의 OpenAPI 문서에 공개한다.

감사 로그와 보관연속성은 서로 다른 것이며 하나로 합쳐서는 안 된다. 감사 로그는 누가 어느 엔드포인트를 호출했고 그것이 허용되었는지를 기록하며, 보안 검토에 쓰인다. 보관연속성은 튜브에 물리적으로 무슨 일이 일어났는지를 기록하며, 시료의 이력 증명에 쓰인다(제5장의 주제이다). 거부된 POST /requests는 감사 로그에만 남고 보관연속성에는 절대 들어가지 않는다. 성공한 분주는 두 곳 모두에 각기 다른 형태로 남는다. RBAC 역할도 같은 선을 따라야 한다. 등록과 분주는 기술원의 행위이고, 요청 승인은 분양심의위원회의 몫이며, 재고 요약 열람은 연구자에게 열어 둘 수 있다. 권한 실패는 이 경우를 위해 마련된 오류 코드 B012 UNAUTHORIZED_ACCESS(4.4)로 보고한다.

4.3 BioBankingSDK

npm 패키지 @wia/bio-019(버전 1.0.0, Node.js 16 이상)는 BioBankingSDK 클래스, 제3장의 모든 타입, 그리고 몇 가지 독립 도우미 함수를 내보낸다. 쓰기 전에 이것이 어떤 종류의 SDK인지 알아 두는 것이 중요하다. 이것은 HTTP 클라이언트가 아니다. 시료, 보관연속성 체인, 동의를 메모리 안의 Map 세 개에 담아 둘 뿐이고, 아무것도 저장하거나 외부로 보내지 않는다. 표준의 동작을 실행 가능한 형태로 옮긴 참조 모델로 이해하는 것이 가장 정확하다. 시험의 기준으로 삼거나, 시뮬레이터에 넣거나, 실제 저장 계층과 API 뒤에 감싸서 쓰는 대상이다. 프로세스가 등록한 모든 것은 프로세스가 끝나면 사라진다.

그보다 앞서 부딪히는 실무상의 걸림돌이 하나 있다. 배포된 그대로의 index.ts는 클래스를 두 번 내보내고(export class BioBankingSDK와 파일 끝의 export { BioBankingSDK }), types.ts도 export로 선언한 열거형과 인터페이스를 파일 끝의 내보내기 목록에서 다시 내보낸다. Node.js의 내장 TypeScript 지원으로 소스를 불러오자 “Duplicate export of 'BioBankingSDK'” 오류로 실패하였고, 중복된 내보내기 목록을 지우는 것만으로 실행되었다. 이 절의 예시는 그렇게 최소한으로 고친 사본에서 실행하였으며, 주석에 적은 출력은 실제로 관찰한 값이다.

4.3.1 메서드 일람

메서드반환알아 둘 동작발생 오류
registerSample(data)Sampleid, type, donor, volume, volumeUnit, storageCondition이 필수. status를 collected로 고정하고, aliquots를 비우며, parentSampleId는 무시한다. 운영자 SYSTEM의 collection 이벤트로 보관연속성 체인을 연다.B001, B005
getSample(id)Sample등록된 시료만 찾는다. 분주물은 시료로 등록되지 않는다.B002
updateSample(id, patch)Sample얕은 병합. id는 보호하고 updatedAt을 갱신한다. 보관연속성 이벤트 없이 status나 location을 바꿀 수 있다.B002
createAliquot(parentId, volume, id?)Aliquot기본 ID는 <모시료>-A<n>. 모시료의 위치를 복사하고 상태는 stored. 모시료에서 부피를 빼고 aliquoting 이벤트를 붙인다.B002, B003
addCustodyEvent(id, event)CustodyEvent직전 해시에 연결하고 currentCustodian을 갱신한다.B010
getChainOfCustody(id)ChainOfCustody복사본이 아니라 내부 객체를 그대로 돌려준다.B010
verifyChainOfCustody(id)boolean모든 해시를 다시 계산하고 연결을 확인한다. 실패하거나 ID를 모르면 예외 대신 false를 돌려준다.—
registerConsent(consent)voidconsentId 기준으로 저장하거나 덮어쓴다. 검증은 하지 않는다.—
validateConsent(id, purpose)boolean유효 여부, 철회, 만료, 목적을 확인한다(제6장).없는 ID면 B005
queryInventory(query)Sample[]필터를 AND로 결합한 뒤 offset/limit를 적용하고, 맨 배열을 돌려준다.—
getInventorySummary()InventorySummary유형별·보관 조건별 건수를 센다. 모든 부피를 mL인 것처럼 더한다.—
recordTemperatureExcursion(id, exc)voidEXC-… ID를 붙여 quality.temperatureExcursions에 추가하고, quality_check 보관연속성 이벤트를 남긴다.B002
calculateSampleIntegrity(p), calculateStorageQuality(p)IntegrityResult, number제3장의 지표. 독립 함수로도 제공된다.B004
getVersion()string'1.0.0'.—

4.3.2 등록·수정·분주

registerSample은 필수 필드 여섯 개를 갖춘 Partial<Sample>을 받고 나머지를 채운다. 연동 개발자에게 중요한 선택이 세 가지 있다. 첫째, 호출자가 무엇을 넘기든 상태는 언제나 collected이다. 이미 처리되어 선반에 오른 시료를 나중에 등록하는 LIMS라면 보관연속성 이벤트와 상태 갱신을 뒤이어 보내야 한다. 그러지 않으면 보관 중인 튜브를 아직 채취 현장에 있는 것으로 기록하게 된다. 둘째, 위치가 없으면 { freezer: 'UNASSIGNED', fullLocation: 'UNASSIGNED' }가 된다. 채취 시점에는 합리적인 값이지만, 재고 서비스는 이런 시료를 분양 가능으로 보고해서는 안 된다. 셋째, 동의 확인이 느슨하다. donor.consentId가 이미 등록된 동의를 가리키고 그 isValid가 false이면 B005로 실패한다. 하지만 등록된 적 없는 동의라면 등록은 조용히 성공한다. 동의 없이 시료를 받아서는 안 되는 서비스는 등록 전에 직접 validateConsent를 호출해야 한다.

updateSample은 객체를 얕게 병합한다. 편리하지만, 보관연속성 모델을 깨뜨리는 가장 쉬운 길이기도 하다. 이 메서드로 status: 'in_use'나 새 location을 넣으면 보관연속성 이벤트가 전혀 남지 않는다. 실제 서비스에서는 상태와 위치 변경을 전용 연산으로 처리하여 이벤트 기록과 상태 변경이 함께 일어나게 해야 한다. SDK는 그 규율을 호출자에게 맡긴다.

createAliquot은 SDK에서 가장 완성도가 높은 연산이다. 모시료의 남은 부피를 확인하고(요청이 이를 넘으면 B003), SAM-7K2Q9X-A1 같은 ID를 만들고, 모시료에서 부피를 빼고, 모시료의 새 부피를 condition.volume에 담은 aliquoting 보관연속성 이벤트를 남긴다. 이 연산이 생략한 부분도 그만큼 많은 것을 알려 준다. 분주물은 모시료의 위치를 그대로 물려받는데, 같은 박스의 A5 칸에 튜브 두 개가 들어갈 수는 없다. 분주물은 시료 등록부에 추가되지 않으므로 getSample('SAM-7K2Q9X-A1')은 B002로 실패하고, 자기 보관연속성 체인도 갖지 않는다. 모시료의 부피가 0이 되어도 depleted로 표시하지 않는다. queryInventory의 availableOnly 필터가 바로 이 상태에 기대는데도 그렇다. 분주물은 모시료의 단위를 물려받으며 다른 단위로 표현할 수 없다. SDK 위에 구현을 올린다면 분주물마다 위치를 배정하고, parentSampleId(제3장의 계보 필드)를 채운 Sample로 등록하고, 모시료의 부피가 0이 되면 depleted로 바꾸어야 한다.

4.3.3 조회와 요약

queryInventory는 주어진 필터를 차례로 적용하여 AND로 결합한다. 놓치기 쉬운 동작이 두 가지 있다. availableOnly는 depleted 시료만 제외하므로, 폐기되었거나 운송 중이거나 사용 중인 시료도 여전히 분양 가능으로 집계된다. 그리고 minQualityScore는 integrityScore가 아예 없는 시료를 “알 수 없음”으로 따로 다루지 않고 모두 빼 버린다. 페이지 처리는 필터링 뒤에 적용되며 limit의 기본값은 “전부”이다. 결과는 맨 배열이어서 페이지 컨트롤에 필요한 총계가 없다. getInventorySummary는 유형별·보관 조건별 건수를 돌려주지만, totalVolume은 혈장의 밀리리터와 조직의 밀리그램을 그대로 더하고 mL라는 단위를 붙인다. 건수로는 쓸모가 있지만, 부피 수치는 그대로 화면에 내보이면 안 된다.

4.3.4 온도 이탈과 독립 도우미 함수

recordTemperatureExcursion은 ID를 뺀 TemperatureExcursion을 받아 시료의 품질 기록에 추가하고, 심각도와 지속 시간을 메모에 담은 quality_check 보관연속성 이벤트를 남긴다. critical 등급의 이탈에도 오류를 내지 않는다. 그런 시료를 여전히 분양해도 되는지 판단하는 일은 제5장 온도 이탈 프로토콜의 몫이다. B008이 바로 그 목적으로 정의되어 있지만 SDK는 쓰지 않는다.

패키지는 독립 함수도 내보낸다. calculateSampleIntegrity와 calculateStorageQuality는 제3장의 클래스 메서드를 감싼 것이다. 후자는 제3장에서 다룬 아레니우스 식의 부호 오류를 그대로 안고 있으므로 판단 근거로 써서는 안 된다. celsiusToKelvin과 kelvinToCelsius는 273.15를 쓰는 반면, BIO_CONSTANTS.STORAGE_TEMPS는 반올림한 정수(−80 °C = 193 K)를 쓴다. 해로운 차이는 아니지만, 시험 벡터에서 생기는 작은 불일치의 원인이 된다. formatVolume(value, unit)은 단위가 리터 같은 기본 단위라고 가정한다. 0.5 L는 “500.00 mL”로 바꾸지만 0.5 mL는 “500.00 mmL”로 만든다. generateSampleId(prefix)는 SAM-1790301482493-JH63K1WIN처럼 밀리초 타임스탬프가 박힌 ID를 만든다. 이는 날짜나 일련번호를 넣지 않은 무작위 코드를 요구하는 §8.3.1과 어긋난다. 타임스탬프는 외래 방문 기록과 맞대어 볼 수 있어 코드화의 효과를 약하게 만들기 때문에 명세가 이를 금지한 것이다. 재식별 위험을 신경 쓰는 서비스라면 암호학적 난수원에서 ID를 만들고 중복 여부를 확인해야 한다.

4.3.5 처음부터 끝까지 따라가는 예제

다음 스크립트는 4.1.1의 혈장 시료를 등록하고, 분주물 하나를 만들고, 남은 양보다 많이 꺼내려 시도하고, GET 예시와 같은 조건으로 재고를 조회한 뒤, 보관연속성 기록을 살펴본다. 주석은 실제로 관찰한 값이다.

import {
  BioBankingSDK, SampleType, StorageCondition, BioBankingError,
} from '@wia/bio-019';

const sdk = new BioBankingSDK();

// 1. 등록: id, type, donor, volume, volumeUnit, storageCondition 이 필수
const plasma = sdk.registerSample({
  id: 'SAM-7K2Q9X',
  type: SampleType.PLASMA,
  donor: { donorId: 'D-789012', consentId: 'CNS-456789' },
  collectionDate: new Date('2025-12-26T08:30:00Z'),
  volume: 10.5,
  volumeUnit: 'mL',
  storageCondition: StorageCondition.ULTRA_LOW_FREEZER,
  location: {
    freezer: 'F-01', rack: 'R-03', box: 'B-25', position: 'A5',
    fullLocation: 'F-01/R-03/B-25/A5',
  },
});
console.log(plasma.status);            // 'collected'  (무엇을 넘기든 항상)

// 2. 0.5 mL 분주: 모시료 부피가 줄고 보관연속성 이벤트가 붙는다
const a1 = sdk.createAliquot(plasma.id, 0.5);
console.log(a1.id, a1.status);         // 'SAM-7K2Q9X-A1' 'stored'
console.log(sdk.getSample(plasma.id).volume);  // 10

// 3. 남은 양보다 많이 요구하면 B003 으로 거절
try {
  sdk.createAliquot(plasma.id, 50);
} catch (e) {
  if (e instanceof BioBankingError) console.log(e.code);  // 'B003'
}

// 4. 조회: GET /inventory?type=plasma&available=true&min_volume=5 에 해당
const hits = sdk.queryInventory({
  sampleType: SampleType.PLASMA,
  availableOnly: true,
  minVolume: 5,
  limit: 20,
  offset: 0,
});
console.log(hits.length);              // 1

// 5. 1·2단계의 부수 효과로 생긴 보관연속성 기록
console.log(sdk.getChainOfCustody(plasma.id).events.map((e) => e.action));
// [ 'collection', 'aliquoting' ]
console.log(sdk.verifyChainOfCustody(plasma.id));  // true

이 출력에서 눈여겨볼 점이 두 가지 있다. 0.5 mL 분주물 하나를 만든 뒤 모시료의 부피는 기대대로 10.5가 아니라 10이다. 그런데 보관연속성 기록에는 collection과 aliquoting만 있다. 시료를 냉동고 위치와 함께 등록했는데도 SDK는 storage 이벤트를 남기지 않았다. 따라서 SDK 위에 만든 서비스는 튜브가 실제로 선반에 오르는 순간 그 이벤트를 직접 추가해야 한다.

4.4 오류 모델

SDK가 보고하는 모든 실패는 BioBankingError이다. 이는 Error의 하위 클래스로, BioErrorCode 열거형에서 온 code와 선택적인 details 객체를 갖는다. 코드는 열두 개가 정의되어 있다. 그중 v1.0 SDK가 실제로 던지는 것은 여섯 개뿐이며, 그 가운데 둘은 이름이 암시하지 않는 용도로 쓰인다. 아래 표는 현재 코드가 하는 일과 서비스가 해야 한다고 이 책이 권하는 일을 나누어 보여 준다. HTTP 상태 열은 이 책의 제안이며 표준의 일부가 아니다.

코드이름v1.0 SDK에서 발생하는 경우서비스에서 권장하는 용도HTTP (제안)
B001INVALID_SAMPLE_IDID 없이 등록하거나 이미 있는 ID로 등록할 때형식 규칙에 맞지 않는 ID에도 사용400 / 409
B002SAMPLE_NOT_FOUND모르는 시료를 조회할 때그대로404
B003INSUFFICIENT_VOLUMEcreateAliquot이 남은 부피를 넘을 때volume_requested가 남은 양을 넘는 분양 요청에도 사용409
B004QUALITY_BELOW_THRESHOLDSIS 입력이 0–1 범위 밖일 때(입력 검증)요청된 용도의 품질 하한에 못 미치는 시료. 잘못된 입력에는 400을 쓴다422
B005CONSENT_EXPIRED등록된 동의의 isValid가 false일 때, validateConsent에 모르는 동의 ID가 올 때만료되었거나 철회된 동의에만 사용403
B006CONSENT_NOT_COMPATIBLE발생하지 않음유효한 동의이지만 요청 목적을 포괄하지 않을 때403
B007STORAGE_FULL발생하지 않음대상 장비에 빈 위치가 없을 때409
B008TEMPERATURE_EXCURSION발생하지 않음온도 이탈 평가가 끝날 때까지 분양을 막을 때409
B009INVALID_LOCATION발생하지 않음알 수 없는 냉동고/랙/박스이거나 이미 찬 위치일 때400 / 409
B010CHAIN_OF_CUSTODY_BROKEN시료의 보관연속성 체인이 없을 때검증이 실패할 때에도 사용409
B011EQUIPMENT_FAILURE발생하지 않음대상 장비가 alarm 또는 offline 상태일 때503
B012UNAUTHORIZED_ACCESS발생하지 않음인증 또는 RBAC 실패401 / 403

SDK의 오용 두 가지는 그대로 따라 하지 말고 바로잡는 것이 좋다. 호출자가 무결성 계산에 생존율 1.85를 넘기면 B004가 발생한다. 이것은 형식이 잘못된 입력이지 품질 기준에 못 미치는 시료가 아니다. B004를 “이 시료를 폐기하라”로 해석하는 클라이언트는 상황을 잘못 읽게 된다. 또 validateConsent는 동의 ID를 모를 때 B005를 던진다. 기록이 없는 것은 만료된 것과 다르며, 둘을 뒤섞으면 입력 오류가 가려진다. 서비스는 잘못된 입력에는 별도의 검증 오류를 쓰고, 모르는 동의는 독립된 조건으로 보고해야 한다. 호출자는 메시지 문자열이 아니라 code로 분기해야 한다. 메시지에는 ID와 부피가 끼워 넣어지며, 사람이 읽으라고 만든 것이다.

types.ts에는 성공/오류 합집합인 Result<T, E>와 AsyncResult<T, E>도 정의되어 있지만, 이를 돌려주는 SDK 메서드는 하나도 없고 모든 메서드가 예외를 던진다. 이 타입은 API 핸들러가 연산 결과를 응답 본문으로 바꾸는 경계에서 쓸모가 있다. 작은 어댑터 하나로 도메인 오류는 값으로 다루고, 진짜 프로그래밍 오류는 그대로 전파되게 할 수 있다.

import { BioBankingError, BioErrorCode, type Result } from '@wia/bio-019';

// SDK 는 예외를 던진다. API 계층은 보통 직렬화할 수 있는 값을 원한다.
export function attempt<T>(fn: () => T): Result<T, BioBankingError> {
  try {
    return { success: true, data: fn() };
  } catch (e) {
    if (e instanceof BioBankingError) return { success: false, error: e };
    throw e;                          // 프로그래밍 오류는 그대로 드러낸다
  }
}

const r = attempt(() => sdk.createAliquot('SAM-7K2Q9X', 50));
if (!r.success && r.error.code === BioErrorCode.INSUFFICIENT_VOLUME) {
  // 예: 409 와 { code: 'B003', message: r.error.message } 로 응답
}

전송 형식에서는 { "success": false, "code": "B003", "message": "…", "details": { … } }처럼 일관된 오류 본문을 쓰면, 등록 응답이 이미 쓰고 있는 success 플래그와 짝이 맞는다. 이를 OpenAPI 문서에 공유 스키마로 한 번만 선언하면 세 엔드포인트가 모두 같은 형태를 유지한다.

4.5 명령행 도구

wia-bio-019는 산술에 bc를 쓰는 Bash 스크립트 한 개이다. SDK를 호출하지 않으며 상태도 저장하지 않는다. 입력을 받아 실제로 계산하는 명령은 두 개뿐이고, 나머지는 시연용 문구를 출력한다. 따라서 SIS 공식을 탐색하거나 운영자에게 워크플로의 모습을 보여 주는 데는 쓸모가 있지만, 실제 재고 관리에는 쓸 수 없다.

명령플래그(스크립트가 실제로 해석하는 것)실제로 하는 일
register--id --type --donor --volume --temp온도를 보관 대역(−150 °C 이하 LN₂, −70 °C 이하 ULT, −15 °C 이하 냉동, 8 °C 이하 냉장, 그 밖은 상온)으로 분류하고 확인 문구를 출력한다. 기본 ID는 SAM-<epoch 초>. 아무것도 저장하지 않는다.
integrity--initial --current --temp-dev --max-temp-dev --time --max-time제3장과 똑같이 SIS와 해석 구간을 계산한다. 기본값은 0.98, 0.95, 2, 5, 365, 3650.
storage-quality--type(dna, rna, plasma, cells) --time(년) --temp(켈빈)부호 오류가 있는 v1.0 SQI 식을 계산한다. 우리 시험 환경에서는 bc가 중단되었고(“scale too large”, “Out of memory”), 도구는 빈 SQI 값 뒤에 “DEGRADED”를 출력하였다.
retrieve--id --purpose --requester입력을 되풀이한 뒤, ID와 상관없이 고정된 점검표(가용 5.5 mL, SIS 0.92, 위치 F-01/R-03/B-25/A5)를 출력한다.
inventory--storage고정된 시연용 보고서를 출력한다. 필터 값은 되풀이만 하고 적용하지 않는다.
quality--id --type(dna, rna, plasma, cells)유형별로 고정된 예시 지표를 출력한다.
version, help—버전 1.0.0과 사용법 문구.

짧은 세션 하나로 쓸모 있는 부분과 오해를 부르는 부분을 함께 볼 수 있다. 출력은 줄였고 색상 코드는 지웠다.

$ wia-bio-019 register --type plasma --donor D-789012 --volume 10.5 --temp -80
  ...                                    (배너와 입력 반복 출력은 생략)
✓ Sample registered: SAM-1790301495
  Storage Condition: Ultra-Low Freezer (-80°C)
  Status: COLLECTED
  Chain of Custody: Initialized

$ wia-bio-019 integrity --initial 0.98 --current 0.95 --temp-dev 2 \
      --max-temp-dev 5 --time 365 --max-time 3650
  Viability Factor: .9693
  Temperature Factor: .6000
  Time Factor: .9000
✓ Sample Integrity Score (SIS): .5233
✗ Interpretation: POOR

$ wia-bio-019 integrity --id SAM-001 --current-viability 0.50   # README 의 플래그
  Current Viability: 0.95                # 모르는 플래그는 무시되고 기본값 사용
✓ Sample Integrity Score (SIS): .5233

$ wia-bio-019 retrieve --id SAM-7K2Q9X --purpose research
✓ ✓ Volume available: 5.5 mL            # 조회 결과가 아니라 고정된 시연 문구
✓ ✓ Quality check: SIS 0.92 (Excellent)
  Removed from: F-01/R-03/B-25/A5

함정은 세 번째 명령이다. README는 integrity --id SAM-001 --current-viability 0.95와 inventory --storage-type liquid-nitrogen을 안내하지만, 스크립트의 인자 처리 반복문은 *) shift ;;로 끝나서 알아보지 못한 플래그를 그냥 버린다. 그래서 README의 플래그는 경고 없이 무시되고 계산은 기본값으로 진행된다. 위에서 현재 생존율 0.50을 요청했지만, 결과는 0.95로 계산된 기본값 그대로였다. 도움말 자체도 --max-temp-dev 없이 integrity … --time 365를 예로 들기 때문에 최대 편차 기본값 5 °C가 적용된다. 그 값들로 계산한 점수가 세션에 보이는 0.52(“poor”)이며, 손실의 대부분은 0.6인 온도 인자에서 나온다. 도구를 스크립트에서 쓸 때는 모든 플래그를 명시적으로 넘기고, 되풀이 출력된 입력값을 반드시 확인해야 한다.

retrieve, inventory, quality의 출력은 고정 문구이므로, 그 화면을 캡처하여 실제 뱅크 상태의 증거로 제시해서는 안 된다. CLI를 시연 이상의 도구로 만들려면, 4.1의 HTTP API를 호출하는 얇은 클라이언트로 바꾸고, 모르는 플래그가 오면 0이 아닌 종료 코드로 끝내게 하는 것이 자연스러운 다음 단계이다.

4.6 문서에서 동작하는 코드로 옮길 때의 함정

아래 어댑터는 §9.1.2의 등록 본문을 SDK 입력으로 바꾼다. 기존 클라이언트가 계속 동작하도록 입력에서는 명세의 옛 표기를 받아 주지만, 출력에서는 절대 쓰지 않는다. 또 fullLocation을 조합하고, 평평한 품질 필드를 중첩된 제자리로 옮긴다.

import { SampleType, StorageCondition, BioBankingSDK } from '@wia/bio-019';

// POST /api/v1/samples 의 전송 형태 (snake_case, donor·quality 가 평평함)
interface RegisterSampleWire {
  sample_id: string;
  type: string;
  donor_id: string;
  collection_date: string;          // ISO 8601, UTC ("Z")
  volume: number;
  volume_unit: string;
  storage_condition: string;
  location?: { freezer: string; rack?: string; box?: string; position?: string };
  quality?: { hemolysis_index?: number; total_protein?: number };
  consent_id?: string;
}

type RegisterInput = Parameters<BioBankingSDK['registerSample']>[0];

// 명세 §9.1.2 에 보이는 옛 표기 — 입력에서만 받아 준다
const TYPE_ALIASES: Record<string, SampleType> = { blood_plasma: SampleType.PLASMA };
const STORAGE_ALIASES: Record<string, StorageCondition> = {
  minus_80: StorageCondition.ULTRA_LOW_FREEZER,
};

function asEnum<T extends string>(value: string, allowed: readonly T[],
                                  aliases: Record<string, T>, field: string): T {
  if ((allowed as readonly string[]).includes(value)) return value as T;
  if (value in aliases) return aliases[value];
  throw new Error(`${field}: unknown value "${value}"`);
}

export function fromWire(w: RegisterSampleWire): RegisterInput {
  const loc = w.location;
  return {
    id: w.sample_id,
    type: asEnum(w.type, Object.values(SampleType), TYPE_ALIASES, 'type'),
    donor: { donorId: w.donor_id, consentId: w.consent_id },
    collectionDate: new Date(w.collection_date),
    volume: w.volume,
    volumeUnit: w.volume_unit,
    storageCondition: asEnum(w.storage_condition, Object.values(StorageCondition),
                             STORAGE_ALIASES, 'storage_condition'),
    location: loc && {
      ...loc,
      fullLocation: [loc.freezer, loc.rack, loc.box, loc.position]
        .filter(Boolean).join('/'),
    },
    quality: w.quality && {
      blood: {
        hemolysisIndex: w.quality.hemolysis_index,
        totalProtein: w.quality.total_protein,
      },
    },
  };
}

같은 방식으로 반대 방향의 toWire 함수를 만들면 재고 항목과 요청 본문도 처리할 수 있다. 두 방향을 한 모듈에 두고 각각을 왕복 변환으로 시험하는 것이, 향후 개정에서 Sample에 추가된 필드가 API에서 조용히 사라지지 않게 하는 가장 값싼 방법이다.

요약

WIA-BIO-019 v1.0은 등록, 재고 조회, 분양 요청이라는 세 가지 HTTP 연산을 확정한다. 여기에 분주·보관연속성·동의·온도 이탈까지 훨씬 많은 기능을 구현한 메모리 기반 TypeScript 모델과, 무결성 계산만 실제이고 나머지 출력은 대부분 시연 문구인 Bash 도구가 함께 제공된다. 세 부분은 그 위에 무언가를 쌓을 수 있을 만큼 가깝지만, 어댑터 없이 바로 이어 붙일 만큼 가깝지는 않다. 전송 형식은 snake_case와 평평한 객체를 쓰고, SDK는 camelCase와 중첩 구조를 쓴다. 예시 값 몇 개는 유효한 열거형 멤버가 아니고, 예시 하나는 유효한 JSON이 아니며, README는 패키지에 존재하지 않는 API를 설명한다.

견고한 연동은 따라서 네 가지를 한다. 모든 전송 값을 열거형 기준으로 검증한다. 두 명명 방식 사이의 변환을 정확히 한곳에서 처리한다. 명세가 빠뜨린 연산(보관연속성 이벤트, 상태 변경, 분주물의 위치 배정)을 이벤트를 기록하는 명시적 엔드포인트로 추가한다. 그리고 실패를 B001–B012 코드로 보고하되, 각 코드를 이름이 뜻하는 대로 쓴다. 제5장은 이 인터페이스를 바탕으로, 연산들이 어떤 순서로 허용되며 각 단계가 보관연속성 체인에 어떻게 기록되는지를 설명한다.