제3장

제3장: 데이터 모델과 무결성 지표 — Sample·Aliquot·SIS·SQI

WIA-BIO-019가 냉동고 속 크라이오바이알 하나를 저장·교환·평가할 수 있는 레코드로 바꾸는 방식을 다룬다. v1.0이 정의한 두 정량 지표가 어디까지 성립하고 어디서 무너지는지도 함께 살펴본다.

제2장은 생체시료(biospecimen)를 실험실 연구자의 눈으로 보았다. 혈장 튜브, 추출한 DNA, 급속 동결한 조직, 액체질소 속의 살아 있는 세포가 그 대상이었다. 이 장은 같은 시료를 데이터베이스의 눈으로 본다. 튜브에 무엇이 들어 있는지, 누구의 것인지, 어디에 있는지, 어떤 처리를 거쳤는지, 여전히 목적에 맞게 쓸 수 있는지를 냉동고 앞까지 가지 않고는 답하지 못한다면 그 바이오뱅크는 차가운 창고에 불과하다. WIA-BIO-019는 이 질문에 참조 SDK(api/typescript/src/types.ts)가 정의하는 몇 가지 타입 레코드와, 명세서 API 절(§9.1.2)에 개략적으로 제시된 전송 형식으로 답한다.

이 장은 두 부분으로 나뉜다. 3.1~3.4절은 레코드 구조를 다룬다. Sample 자체, 비식별화된 DonorInfo, StorageLocation 계층, Aliquot 계보, 식별자와 라벨, QualityMetrics 컨테이너가 그 대상이다. 3.5절과 3.6절은 표준이 이 레코드로부터 계산하는 두 수치, 즉 시료 무결성 점수(Sample Integrity Score, SIS)와 보관 품질 지수(Storage Quality Index, SQI)를 다룬다. 3.7절은 직렬화 규칙으로 마무리한다. 장 전체에 걸쳐 v1.0 명세가 실제로 말하는 내용과 참조 구현이 실제로 하는 동작을 구분한다. 둘이 늘 같지는 않기 때문이다. 이 책의 해설은 그때마다 해설임을 밝힌다.

3.1 핵심 레코드: Sample

바이오뱅크가 추적하는 물리적 용기 하나는 Sample 객체 하나로 표현된다. 이 인터페이스는 최상위 수준을 의도적으로 평평하게 설계했다. 식별자, 유형, 양, 보관, 상태는 단순 필드이고, 더 풍부한 정보는 중첩 객체(donor, location, quality)로 밀어 넣었다. 아래 표는 types.ts에 선언된 모든 필드를 정리한 것이다. SDK의 registerSample()이 해당 필드를 요구하는지, 실무에서 어떤 의미인지도 함께 적었다.

Sample 인터페이스의 필드(WIA-BIO-019 참조 SDK v1.0.0)
필드타입등록 시 필수의미와 비고
idstring예전역적으로 유일한 시료 식별자이다. 빈 값이나 중복 ID는 B001 INVALID_SAMPLE_ID로 거부된다. 형식 규칙은 3.3절에서 다룬다.
typeSampleType예제2장에서 소개한 14개 열거값 중 하나이다(예: plasma, rna, tissue_ffpe).
donorDonorInfo예비식별화된 기증자 블록이다. 아래에서 설명한다.
collectionDateDate아니요(기본값 "현재")채취 일시이다. 등록 시각을 기본값으로 쓰면 편하지만, 나중에 등록하는 시료에는 틀린 값이 된다. 반드시 명시적으로 넣어야 한다.
volume, volumeUnitnumber, string예남은 양과 그 단위(mL, µL, mg)이다. 분주를 하면 모시료의 volume이 줄어든다.
storageConditionStorageCondition예의도한 온도 계층(LN₂, −80 °C, −20 °C, 4 °C, 실온)이다.
locationStorageLocation아니요(기본값 UNASSIGNED)물리적 위치이다. 3.2절 참조.
statusSampleStatusSDK가 설정항상 collected로 초기화된다. 이후의 상태 전이는 제5장의 주제이다.
qualityQualityMetrics아니요측정한 품질 지표를 담는 컨테이너이다. 3.4절 참조.
aliquotsAliquot[]SDK가 설정빈 배열로 초기화되며 createAliquot()이 채운다.
parentSampleIdstring아니요다른 시료에서 파생된 시료를 위해 선언된 필드이다. 3.2절의 주의 사항을 볼 것.
riskGroupRiskGroup아니요생물안전 위험군 rg1~rg4이다. 취급 규칙과 연결되며 제7장에서 다룬다.
notesstring아니요자유 기술 취급 메모이다.
createdAt, updatedAtDateSDK가 설정시스템 타임스탬프이다. 과학적 의미의 collectionDate와는 별개이다.

기증자 블록을 비식별 상태로 유지하기

DonorInfo의 필수 필드는 donorId 하나뿐이며, 타입 주석은 이를 "비식별화된" 값이라고 설명한다. 나머지는 모두 선택 필드이다. 채취 시점의 age, sex(male | female | other | unknown), ethnicity, collectionSite, medicalHistory[], medications[], fasting, consentId가 여기에 속한다. 이 설계는 명세 §8.3.1의 가명화(코드화) 모델을 따른다. 시료 레코드에는 코드만 담고, 그 코드를 실명과 잇는 연결 테이블은 접근이 통제된 별도 데이터베이스에 둔다. 이 연결 정보의 거버넌스는 제6장에서 다룬다.

실무상 두 가지를 조심해야 한다. 첫째, "선택"이 "무해"를 뜻하지는 않는다. 나이, 채취 기관, medicalHistory 속의 희귀 진단명이 합쳐지면 이름이 없어도 특정인을 가려낼 수 있다. 바이오뱅크는 하위 용도가 실제로 필요로 할 때에만 이 필드를 채워야 하며, 정확한 나이 대신 연령대를 기록하는 식의 범주화도 검토해야 한다. 둘째, consentId는 시료와 동의 기록을 잇는 유일한 다리이다. 값이 있으면 registerSample()은 해당 동의를 조회하고, 저장된 동의가 무효로 표시되어 있으면 B005 CONSENT_EXPIRED로 등록을 거부한다. 그러나 값이 없거나 SDK 인스턴스가 본 적 없는 동의를 가리키면 등록은 아무 경고 없이 진행된다. 타입 정의는 선택으로 두었더라도 기관 정책에서는 consentId를 필수로 다루어야 하는 이유가 여기에 있다.

주석을 붙인 예

다음 객체는 SDK에 등록한 혈장 시료이다. 값은 예시이며, 위치와 품질 지수는 명세 §9.1.2의 등록 예제와 맞추었다. 3.7절의 전송 형식과 필드 단위로 비교하기 위해서이다.

import {
  Sample, SampleType, StorageCondition, SampleStatus, RiskGroup,
} from '@wia/bio-019';

const plasma: Sample = {
  id: 'SAM-7K2Q9X',                     // 무작위 코드, 날짜·일련번호 없음(§8.3.1)
  type: SampleType.PLASMA,              // 열거값 'plasma'
  donor: {
    donorId: 'D-789012',                // 가명화된 기증자 ID, 연결 키는 별도 보관
    age: 54,                            // 선택 필드, 필요할 때만 기록
    sex: 'female',
    fasting: true,
    consentId: 'CNS-456789',            // Consent 레코드로 가는 연결(제6장)
  },
  collectionDate: new Date('2025-12-26T08:30:00Z'),
  volume: 10.5,
  volumeUnit: 'mL',
  storageCondition: StorageCondition.ULTRA_LOW_FREEZER,   // -80 °C 계층
  location: {
    freezer: 'F-01', rack: 'R-03', box: 'B-25', position: 'A5',
    fullLocation: 'F-01/R-03/B-25/A5',
  },
  status: SampleStatus.STORED,
  quality: {
    blood: { hemolysisIndex: 25, totalProtein: 72 },      // 명세 기준값일 뿐, 3.4절 참조
    lastChecked: new Date('2025-12-26T10:40:00Z'),
  },
  aliquots: [],
  riskGroup: RiskGroup.RG2,             // 예시 값
  createdAt: new Date('2025-12-26T10:45:30Z'),
  updatedAt: new Date('2025-12-26T10:45:30Z'),
};

이 레코드에 무엇이 없는지도 눈여겨보아야 한다. 기증자 이름, 병원 등록번호, 자유 서술형 임상 기록이 없다. 이력도 없다. Sample 객체는 현재 상태의 스냅숏이다. 그 상태에 이르기까지의 사건 순서는 보관연속성(chain of custody)에 속하며, 제5장에서 해시로 연결된 별도 로그로 구축한다.

3.2 시료의 자리: StorageLocation과 분주물 계보

4단계 위치 계층

거의 모든 바이오뱅크의 물리적 보관 구조는 중첩되어 있다. 냉동고나 LN₂ 탱크에는 랙(탱크라면 케인과 타워)이 들어가고, 랙에는 박스가 들어간다. 박스는 행 문자와 열 번호로 주소를 매기는 칸의 격자이다. StorageLocation은 이 구조를 그대로 모델링한다. 네 필드 freezer(필수), rack, box, position에, 비정규화된 선택 문자열 fullLocation을 더했다. 명세의 API 예제는 슬래시로 이은 F-01/R-03/B-25/A5 형식을 쓰며, 이는 기술자가 출고 목록에서 읽는 형태 그대로이다.

냉동고 F-01 랙 R-01 랙 R-02 랙 R-03 박스 B-24 박스 B-25 칸 A5 fullLocation = "F-01/R-03/B-25/A5"
그림 3-1. StorageLocation 계층. 강조한 경로가 예제 레코드에 저장된 주소이다.

구성 요소와 결합 문자열을 함께 저장하는 것은 전형적인 비정규화이고, 전형적인 위험을 낳는다. 둘이 서로 어긋날 수 있다는 점이다. SDK는 구성 요소로부터 fullLocation을 유도하지 않으며, 둘이 일치하는지도 검사하지 않는다. 올바른 구현이라면 네 필드를 기준 원천으로 삼고 fullLocation은 출력할 때 계산해야 한다. 또 알 수 없는 냉동고를 가리키는 위치는 거부해야 한다. 오류 코드 B009 INVALID_LOCATION이 바로 그 용도이지만, v1.0 SDK는 이 코드를 실제로 발생시키지 않는다. 등록 시 위치를 주지 않으면 SDK는 자리표시값 { freezer: 'UNASSIGNED', fullLocation: 'UNASSIGNED' }를 저장한다. "미배치" 시료를 찾는 조회는 이 문자열 그대로를 검색해야 한다.

v1.0은 칸 주소 체계를 표준화하지 않는다. 9×9, 10×10 크라이오박스에는 행 문자와 열 번호(A5)를 쓰는 관행이 흔하지만, 칸을 1~81이나 1~100으로 번호 매기는 기관도 있다. 어느 방식이든 SOP에 적어 두어야 하며 바뀌어서는 안 된다.

분주물과 모시료–자시료 계보

분주(aliquoting)는 모시료를 작은 몫으로 나누어, 사용할 때마다 필요한 만큼만 녹이게 하는 작업이다. 동결–해동 손상을 줄이는 데 이것이 왜 중요한지는 제2장에서 설명했다. 데이터 모델은 분주를 두 곳에 기록한다.

SDK의 createAliquot(parentSampleId, volume, aliquotId?)는 첫 번째 방식만 쓴다. 모시료의 양이 충분한지 확인하고(부족하면 B003 INSUFFICIENT_VOLUME), ID가 주어지지 않으면 <모시료>-A<n> 형식으로 만든다. 이어서 모시료의 위치를 복사하고, 분주물의 상태를 stored로 두고, 모시료의 양에서 분주량을 빼고, aliquoting 보관연속성 이벤트를 기록한다. 이를 기반으로 개발하기 전에 알아 둘 결과가 세 가지 있다.

  1. 분주물은 SDK 저장소에서 시료가 아니다. 분주물은 모시료의 배열 안에만 존재하므로 getSample('SAM-7K2Q9X-A1')은 B002로 실패한다. 분주물을 직접 조회해야 하는 재고 시스템이라면 분주물도 시료로 등록해야 한다.
  2. 복사된 위치는 자리표시값이다. 같은 박스의 A5 칸에 튜브 두 개가 들어갈 수는 없다. 분주물을 랙에 넣은 뒤 실제 위치를 다시 기록해야 한다.
  3. registerSample()은 parentSampleId를 저장하지 않는다. 이 필드는 Sample에 선언되어 있지만 레코드를 구성할 때 복사되지 않는다. 파생 시료에 이 필드를 쓰려면 등록 후 updateSample()로 설정하거나 생성 코드를 고쳐야 한다.

계보는 단순한 장부 정리 이상의 의미를 가진다. 기증자가 동의를 철회하면 그 기증자의 모든 시료에서 나온 모든 후손을 찾아야 한다(제6장). 모시료가 잘못 취급되었음이 드러나면 그 분주물도 같은 의심을 물려받는다. "이 튜브에서 무엇이 나왔는가"에 답하려면 조회 가능한 단일한 모–자 연결이 최소한 필요하다. 모든 레코드에 parentSampleId를 두는 방식이든 별도의 간선 테이블을 두는 방식이든 상관없다.

3.3 식별자와 라벨

명세가 요구하는 것

명세 §8.3.1은 시료 식별자의 코드화 규칙을 정한다. SAM-XXXXXX 형태의 무작위 영숫자 코드여야 하고, "날짜나 일련번호 같은 정보를 담지 않아야" 하며, 기증자–시료 연결은 별도 데이터베이스에 두어야 한다. 근거는 개인정보 보호이다. 일련번호는 채취 순서를 드러내고, ID에 들어간 날짜는 진료 일정과 결합되어 기증자의 범위를 좁힐 수 있다. §3.3은 여기에 더해 ID가 전역적으로 유일해야 한다고 규정한다.

참조 SDK가 생성하는 것

보조 함수 generateSampleId(prefix = 'SAM')은 `${prefix}-${Date.now()}-${random}`를 반환한다. 가운데 부분은 생성 시점의 밀리초 단위 유닉스 타임스탬프이고, 무작위 접미사는 Math.random()에서 나온다. 이 출력은 §8.3.1을 두 가지 면에서 어긴다. 날짜를 담고 있으며, 시간 순으로 정렬되므로 사실상 순서도 담고 있다. 게다가 Math.random()은 암호학적으로 안전한 생성기가 아니므로, 접미사가 추측에 강하다고 기대해서는 안 된다. 이 책은 명세의 규칙은 유지하고 보조 함수는 교체할 것을 권한다.

import { randomBytes } from 'crypto';

// 이 책의 권고: 명세 §8.3.1에 맞는 ID
// 헷갈리는 글자(I, L, O, U)를 뺀 32자 알파벳에서 8자
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';

export function newSampleId(prefix = 'SAM'): string {
  const bytes = randomBytes(8);
  let body = '';
  for (const b of bytes) body += ALPHABET[b & 31];   // 32개 기호, 각 5비트
  return `${prefix}-${body}`;                         // 예: SAM-7K2Q9XHD
}
// 유일성은 무작위성에 기대지 않고 레지스트리가 강제한다(중복 시 B001).

32자 알파벳에서 8자를 뽑으면 가능한 코드는 240개(약 1.1 × 1012)이다. 단일 바이오뱅크에는 충분하지만, 그래도 레지스트리는 충돌이 없으리라 가정하지 말고 충돌을 거부해야 한다. 명세의 XXXXXX는 6자를 시사한다. 같은 알파벳으로 6자를 쓰면 약 1.07 × 109개로 역시 쓸 만하지만, 소장품이 늘어날수록 충돌 가능성이 훨씬 빨리 커진다(생일 문제). 명세가 전역 유일성을 요구하므로, 여러 기관에 걸쳐 ID를 연합하는 경우에는 기관 접두사를 붙여야 한다.

createAliquot()이 만드는 분주물 ID(SAM-7K2Q9X-A1, -A2, …)에는 일련번호가 있지만 모시료 기준의 상대 번호일 뿐이다. 기증자나 채취일에 관해서는 아무것도 드러내지 않으므로 §8.3.1의 취지와 어긋나지 않는다.

바코드와 극저온 라벨

§3.3은 두 가지 심볼로지를 허용한다. Code 128(선형 1차원 바코드)과 Data Matrix(2차원 매트릭스 코드)이다. 선택의 기준은 주로 형상이다. 선형 바코드는 튜브 벽면을 따라 길이가 필요하고, 일반 핸드헬드 스캐너로 잘 읽힌다. Data Matrix는 크라이오바이알의 평평한 바닥이나 뚜껑에 들어가므로 랙 리더가 박스 전체를 한 번에 촬영해 읽을 수 있다. 오류 정정 기능이 내장되어 있어 성에나 긁힘에도 더 잘 견딘다. 바닥에는 Data Matrix를, 옆면에는 선형 코드와 사람이 읽는 글자를 함께 인쇄하는 바이오뱅크가 많다.

라벨 내용과 내구성 요구사항(명세 §3.3)
항목요구사항구현 비고
심볼로지Code 128 또는 Data Matrix튜브 바닥 인쇄와 랙 스캔에는 Data Matrix가 유리하다.
시료 ID전역적으로 유일심볼에는 id 필드만 정확히 담고 다른 것은 넣지 않는다.
채취일YYYY-MM-DD라벨에 사람이 읽는 글자로 인쇄하며 ID에는 넣지 않는다.
시료 유형, 보관 조건인쇄SampleType, StorageCondition 열거형과 같은 어휘를 쓴다.
생물학적 위해성인쇄riskGroup에서 유도한다.
내구성극저온(−196 °C) 내성, 용매 내성, 영구 접착튜브가 도달할 수 있는 가장 차가운 계층을 기준으로 라벨 소재를 검증한다. 액상 보관을 한다면 LN₂ 침지까지 포함한다.

3.4 QualityMetrics 컨테이너

QualityMetrics는 시료의 적합성을 보여 주는 측정 지표를 선택적 객체 하나에 모은다. 계산 점수 두 개(integrityScore, storageQualityIndex, 명목상 둘 다 0~1), 물질별 하위 객체 다섯 개, 이탈 기록, 타임스탬프로 구성된다. 하위 객체는 시료 유형에 맞는 것만 채우면 된다. DNA 시료는 dna, PBMC 바이알은 cell, 혈장 튜브는 blood를 채운다.

QualityMetrics의 하위 객체와 척도
하위 객체주요 필드선언된 척도·단위명세 기준값
dnaconcentration, a260_280, a260_230, din?, fragmentSize?ng/µL, 비율, bpA260/A280 1.8~2.0, A260/A230 2.0~2.2, 단편 >10 kb(§6.3.1)
rnaconcentration, rin, a260_280, ratio_28s_18s?ng/µL, RIN 1~10RNA-seq·마이크로어레이 RIN ≥7, qRT-PCR ≥6, 28S/18S ≈2.0(§6.3.2)
proteinconcentration, method, degradation, activity?mg/mL 또는 g/L, 열거형, 활성 0~1동결–해동 최대 3회(§6.3.3)
cellcellCount, viability, viabilityMethod, postThawRecovery?, contamination?cells/mL, 생존율 0~100(%), 회수율 0~1생존율 >80%, 회수율 >70%(§6.3.4)
bloodhemolysisIndex?, icterusIndex?, lipemiaIndex?, totalProtein?, clottingTime?분석기 의존 지수, g/L용혈 지수는 명세 기준값 50 미만(§2.1.1, 단위는 분석기에 따라 다름)
temperatureExcursions[]startTime, endTime, targetTemp, actualTempRange, duration, severity°C, 분, minor | moderate | severe | critical이탈 처리는 제5장
lastCheckedDate전송 시 ISO 8601—

척도 열이 첫 번째 실무 함정을 보여 준다. 같은 인터페이스 안에서 CellQuality.viability는 백분율(0~100)이고, postThawRecovery와 ProteinQuality.activity는 분율(0~1)이다. 다음 절의 SIS 함수는 분율만 받는다. 저장된 생존율 85를 그대로 넣는 파이프라인은 점수 대신 예외를 받게 된다. 따라서 SIS 입력으로 가는 모든 변환은 하나의 명시적 정규화 함수를 거쳐야 하고, 그 함수에는 단위 테스트가 있어야 한다.

두 번째 함정은 혈액 지수에 단위 필드가 없다는 점이다. 용혈·황달·지질혈 지수는 분석기 플랫폼마다 다른 척도로 보고된다. 표준의 기준값 50은 그것이 작성된 척도에서만 의미가 있는데, v1.0은 그 척도를 밝히지 않는다. 표준에 정식 단위가 추가될 때까지 바이오뱅크는 값과 함께 분석기와 측정법을 기록해야 한다. notes나 확장 필드를 쓰면 된다.

마지막으로, temperatureExcursions[]는 온도 이탈(temperature excursion) 이력을 품질 기록의 일부로 만든다. SDK의 recordTemperatureExcursion()은 이 배열에 항목을 추가하고 quality_check 보관연속성 이벤트를 기록한다. 이 연결은 SIS에 중요하다. 표준의 무결성 공식은 온도 편차를 숫자 하나로 받는데, 그 숫자를 고르거나 더 나은 것으로 대체할 데이터가 바로 이 이탈 기록에 있기 때문이다(3.5절, 제8장).

3.5 시료 무결성 점수(SIS)

공식

명세 §6.1.1은 시료가 얼마나 열화했는지를 0과 1 사이의 숫자 하나로 요약하는 지표를 정의한다.

SIS = (Vt / V0) × (1 − ΔT / Tmax) × (1 − Δt / tmax)

Vt    현재 생존율 또는 품질 지표
V0    초기 생존율 또는 품질 지표
ΔT    최적 온도로부터의 편차(°C)
Tmax  허용 가능한 최대 온도 편차(°C)
Δt    채취 후 경과 시간(일)
tmax  권장 최대 보관 기간(일)

세 인자는 각각 다른 질문에 답한다. 품질 인자 Vt/V0는 처음 측정한 품질이 얼마나 남았는지 묻는다. 온도 인자는 최악의 편차가 허용 한계에 얼마나 가까웠는지 묻는다. 시간 인자는 권장 보관 기간을 얼마나 소진했는지 묻는다. 세 인자를 곱하므로, 어느 하나라도 0이면 점수 전체가 0이 된다.

해석 구간과 SDK의 경계값

§6.1.2는 다섯 구간을 제시한다. 명세의 표현("0.8–0.9", "0.7–0.8")으로는 맞닿은 경계값이 어느 쪽에 속하는지 모호하다. SDK는 오른쪽 열처럼 이를 정한다. 같은 숫자가 두 가지 판정을 받는 일이 없도록 구현은 SDK를 따라야 한다.

SIS 해석 구간(명세 §6.1.2)과 calculateSampleIntegrity()의 정확한 경계
구간명세 표현SDK 조건SDK 권고(요약)
excellent> 0.9score > 0.9모든 용도에 적합
good0.8–0.9score >= 0.8대부분의 용도에 적합
acceptable0.7–0.8score >= 0.7목적 적합성 확인 필요
marginal0.6–0.7score >= 0.6주의해서 사용, 민감한 분석에는 부적합
poor< 0.6그 밖폐기 검토 또는 비핵심 용도로만 사용

따라서 정확히 0.9는 excellent가 아니라 good이고, 정확히 0.6은 marginal이다. SDK는 입력도 검사한다. 두 생존율 값 중 하나라도 0~1을 벗어나면 B004 QUALITY_BELOW_THRESHOLD를 던지는데, 실제로는 입력 검증 오류를 품질 코드로 보고하는 셈이다. 온도 인자와 시간 인자는 Math.max(0, …)로 하한을 둔다. 그래서 편차나 경과 시간이 최대치를 넘어도 점수가 음수가 되지 않고 0이 된다. 그러나 품질 인자와 최종 결과에는 상한을 두지 않는다. 재측정값이 처음보다 약간 높게 나오면 Vt/V0가 1을 넘고 점수도 1을 넘을 수 있다. V0가 0이면 나눗셈 결과가 Infinity나 NaN이 된다. 음수 ΔT(설정점보다 더 차갑게 보관된 경우)도 온도 인자를 1 위로 끌어올린다. 호출 측은 결과를 믿기 전에 이 세 경우를 모두 잘라 내야 한다.

계산 예

첫 행은 명세 §6.1.3의 예를 그대로 재현한 것이다. 나머지 두 행은 이 책이 만든 예시이다. 보관 기간 한계는 명세가 제시한 지침(예: RNA는 "−80 °C에서 5년")에서 골랐고, 정규화 과정을 명시했다.

SIS 계산 예(소수 셋째 자리 반올림)
사례V0 → Vt(정규화)품질 인자ΔT / Tmax온도 인자Δt / tmax(일)시간 인자SIS구간
DNA, 명세 §6.1.3A260/A280 1.85 → 1.82, ÷2.0 → 0.925 → 0.9100.9843 / 100.7001,825 / 18,2500.9000.620marginal
RNA(예시)RIN 9.0 → 8.2, ÷10 → 0.90 → 0.820.9110 / 51.000365 / 1,8250.8000.729acceptable
PBMC(예시)생존율 92% → 85%, ÷100 → 0.92 → 0.850.9242 / 100.800730 / 3,6500.8000.591poor
완벽한 시료, 보관 기간 절반 경과1.00 → 1.001.0000 / 101.0009,125 / 18,2500.5000.500poor

명세의 DNA 예는 산술 자체는 맞다(0.984 × 0.7 × 0.9 ≈ 0.62). 그러나 입력을 적힌 그대로 SDK에 넘길 수는 없다. A260/A280 값 1.85는 1보다 크므로 calculateSampleIntegrity()가 예외를 던진다. 두 측정값을 같은 상수로 나누어도 비율은 변하지 않으므로, 정규화한 행도 같은 점수를 낸다. 이 예에는 과학적인 단서도 붙여야 한다. A260/A280은 순도 비율이지 분해 정도를 나타내는 지표가 아니다. 1.85와 1.82는 모두 명세가 제시한 허용 범위 1.8~2.0 안에 있고, 0.03의 차이는 분광광도계의 통상적인 반복 측정 오차 수준이다. "marginal" 판정은 거의 전부 온도 인자와 시간 인자에서 나온 것이며, 측정된 품질 손실에서 나온 것이 아니다. DNA라면 DIN이나 단편 크기 같은 무결성 지표를 V로 쓰는 편이 훨씬 의미 있다.

import { calculateSampleIntegrity } from '@wia/bio-019';

// 호출 전에 정규화한다. SDK는 0..1만 받는다.
const toFraction = (v: number, scale: number) => Math.min(1, Math.max(0, v / scale));

const r = calculateSampleIntegrity({
  initialViability: toFraction(9.0, 10),   // RIN 9.0
  currentViability: toFraction(8.2, 10),   // RIN 8.2
  tempDeviation: 0,
  maxTempDeviation: 5,
  storageTime: 365,                        // 일
  maxStorageTime: 1825,                    // 명세의 RNA -80 °C 지침
});
// r.score ≈ 0.729, r.interpretation === 'acceptable'
// r.details = { viabilityFactor: 0.911, temperatureFactor: 1, timeFactor: 0.8 }

이 점수가 말해 주는 것과 말해 주지 못하는 것

SIS는 투명하고 재현 가능한 선별용 숫자로서 쓸모가 있다. 저장된 세 입력만 있으면 누구든 다시 계산할 수 있다. 그러나 두 가지 구조적 성질 때문에 이 점수를 믿을 수 있는 범위는 제한된다.

시간 인자가 보관 기간에 대해 선형이다. 표의 마지막 행이 이를 보여 준다. 측정 가능한 품질 손실이 전혀 없고 설정 온도를 한 번도 벗어나지 않은 시료라도 tmax의 절반에 이르면 0.5, 즉 "poor"가 된다. 명세 자신의 DNA 예(tmax = 50년)를 적용하면, 결함 없는 시료가 5년 만에 excellent에서 밀려나고, 20년이 되면 측정된 변화가 전혀 없는데도 marginal/poor 경계에 이른다. 극저온에서 실제 분해는 선형 카운트다운이 아니다. 시간 인자는 사실상 나이 자체를 벌점으로 매기며, 그 결과 바이오뱅크가 멀쩡한 시료를 폐기하도록 떠밀 수 있다. 새로 측정한 품질값이 있다면 실제 변화는 이미 품질 인자에 반영되어 있다. 방어 가능한 기관 정책은 세 인자를 따로 보고하고, 보관·폐기 결정은 측정 인자를 근거로 내리며, 시간은 재검사를 시작하는 계기로만 쓰는 것이다.

ΔT가 숫자 하나이다. 공식은 지속 시간 정보 없이 온도 편차 하나만 쓴다. 문을 여닫는 동안 5분간 3 °C 올라간 냉동고와 주말 내내 3 °C 높게 머문 냉동고가 같은 인자를 받는다. temperatureExcursions[] 기록에는 이미 시작·종료 시각과 온도 범위가 있으므로, 이탈을 지속 시간으로 가중할 데이터는 갖추어져 있다. v1.0이 그것을 쓰지 않을 뿐이다. 제8장에서 v1.x를 위한 제안으로 시간 가중 대안을 개략적으로 제시한다.

작지만 중요한 점도 하나 있다. 명세는 Δt를 "채취 후 경과 시간"으로 정의하는 반면, SDK의 매개변수 이름은 storageTime이다. 동결 전에 수송이나 처리로 며칠을 보낸 시료라면 둘은 서로 다르다. 하나를 정해 문서화하고 일관되게 적용해야 한다.

3.6 보관 품질 지수(SQI)와 아레니우스 식

v1.0에 적힌 공식

§6.2.1은 특정 보관 온도에서 보낸 시간만으로 시료 품질이 얼마나 떨어졌어야 하는지 추정하는 두 번째 지표를 도입한다. 이 지표는 화학 반응 속도를 절대 온도와 연결하는 아레니우스 식의 응용으로 제시되어 있다.

SQI = exp(−k × t × exp(Ea / RT))          (명세 §6.2.1, 원문 그대로)

k   분해 속도 상수(1/년, 시료별)
t   보관 시간(년)
Ea  활성화 에너지(kJ/mol)
R   기체 상수, 8.314 J/(mol·K)
T   보관 온도(K)

같은 식이 SDK의 calculateStorageQuality()와 CLI의 storage-quality 명령에 구현되어 있다. 시료별 매개변수는 §6.2.2와 BIO_CONSTANTS.DEGRADATION_PARAMS에서 가져온다.

v1.0의 SQI 매개변수(명세 §6.2.2, BIO_CONSTANTS)
명세 행SDK 키k(1/년)Ea(kJ/mol)표기된 "−80 °C 반감기"ln 2 / k
DNAdna0.00180>100년≈ 693년
RNArna0.056014년≈ 13.9년
단백질plasma0.027035년≈ 34.7년
세포(동결)pbmc0.00590140년≈ 138.6년

마지막 두 열을 보면 "반감기" 수치의 출처가 드러난다. 각 값은 ln 2를 k로 나눠 반올림한 것일 뿐이며, 활성화 에너지와 온도는 전혀 관여하지 않는다. 따라서 이 수치는 −80 °C에 관해 구체적으로 말해 주는 것이 없다. RNA의 14년은 RNA가 −80 °C에서 5년간 안정하다는 명세 §2.2.2의 서술과도 충돌한다. 이 수치를 보관 수명으로 인용해서는 안 된다. 또 명세의 "단백질" 행은 SDK에서 SampleType.PLASMA 키에 대응되며, 항목이 없는 시료 유형은 k = 0.01, Ea = 70으로 대체된다는 점도 알아 두어야 한다. 이 대체 경로는 마지막의 [0, 1] 범위 제한을 거치지 않는다.

원문 공식이 언제나 0을 내는 이유

아레니우스 식에서 속도 상수는 k = A·exp(−Ea/RT)이다. 지수가 음수이므로 온도가 내려가면 속도가 느려진다. v1.0 공식은 exp(+Ea/RT)를 쓰는데, 이 부호 오류는 결과를 조금 왜곡하는 정도가 아니라 완전히 압도한다. −80 °C(T = 193 K)의 DNA라면 Ea/RT = 80,000 / (8.314 × 193) ≈ 49.9이고, exp(49.9) ≈ 4.5 × 1021이다. 여기에 k = 0.001과 1년을 곱하면 바깥 지수는 약 −4.5 × 1018이 되고, 그 exp는 어떤 부동소수점 연산에서도 0이다. 더 차가울수록 더 나빠진다. 77 K에서는 안쪽 인자가 약 1.9 × 1054에 이른다. 보관 시간이 하루뿐이어도 결과는 여전히 0이다. 이 공식은 t = 0에서만 정확히 1을 내고, 표에 있는 모든 온도에서 양의 보관 시간에 대해서는 모두 0을 낸다. 따라서 v1.0 도구가 산출한 SQI 값은 모두 무시해야 한다.

물리적으로 일관된 형태(이 책의 해설이며 규범이 아님)

한 온도에서 측정한 속도로 다른 온도의 속도를 예측할 때는 기준 온도에 대한 상대 배율로 환산하는 것이 아레니우스 식의 표준적인 사용법이다.

k(T)   = k_ref × exp( −(Ea / R) × (1/T − 1/T_ref) )
SQI(t) = exp( −k(T) × t )

T = T_ref이면 배율은 1이다. 기준 온도보다 높으면 속도가 커지고 낮으면 작아지며, 이것이 표준이 의도한 동작이다. 명세는 k 값이 어느 온도에서의 값인지 밝히지 않는다. 아래 스케치는 매개변수 표에 "−80 °C"라는 표기가 있으므로 −80 °C(193.15 K) 기준이라고 가정한다. 이는 이 책의 가정이지 표준의 서술이 아니다.

const R = 8.314;                          // J/(mol·K)

// v1.0 원문(명세 §6.2.1, SDK calculateStorageQuality, CLI storage-quality)
function sqiAsWritten(k: number, EaKJ: number, tYears: number, T: number): number {
  return Math.exp(-k * tYears * Math.exp((EaKJ * 1000) / (R * T)));
}

// 이 책의 해설, 규범 아님: 기준 온도 아레니우스 환산
function sqiArrhenius(kRef: number, EaKJ: number, tYears: number,
                      T: number, TRef = 193.15): number {
  const kT = kRef * Math.exp(-(EaKJ * 1000) / R * (1 / T - 1 / TRef));
  return Math.exp(-kT * tYears);
}

sqiAsWritten(0.001, 80, 1, 193);          // 0      (안쪽 exp ≈ 4.5e21)
sqiAsWritten(0.001, 80, 1 / 365, 193);    // 0      (하루만 지나도)
sqiArrhenius(0.001, 80, 10, 193.15);      // 0.990  DNA, −80 °C에서 10년
sqiArrhenius(0.001, 80, 10, 198.15);      // 0.966  DNA, −75 °C에서 10년
sqiArrhenius(0.001, 80, 10, 203.15);      // 0.890  DNA, −70 °C에서 10년

네 매개변수 세트에 보정된 식을 적용하면 10년 값은 아래와 같다. v1.0 매개변수로 계산했는데, 이 매개변수는 인용된 어떤 측정값으로도 추적되지 않는다. 따라서 이 숫자는 실제 시료의 거동이 아니라 모델의 거동을 보여 준다.

보정된 식에 따른 10년 SQI, k_ref를 −80 °C 기준으로 가정(예시, v1.0 매개변수)
SDK 키−80 °C−75 °C−70 °C−150 °C 이하
dna(Ea 80)0.9900.9660.8901.000
rna(Ea 60)0.6070.2770.0431.000
plasma(Ea 70)0.8190.5480.1811.000
pbmc(Ea 90)0.9510.8140.4541.000

여기서 두 가지 교훈을 얻는다. 첫째, 이 범위의 활성화 에너지라면 몇 도 차이가 매우 크다. −80 °C에서 −70 °C로 옮기면 DNA의 속도 상수는 약 12배, PBMC의 속도 상수는 약 16배가 된다. 제5장과 제8장이 설정 온도 결정과 이탈 처리에 공을 들이는 이유이다. 둘째, 기준점에서 멀리 외삽하면 모델이 무너진다. −20 °C에서는 보정된 식이 모든 시료 유형을 1년 안에 사실상 0으로 떨어뜨린다. 명세 스스로 그 온도에서 수십 년 안정하다고 적은 DNA도 예외가 아니다. 이는 역설이 아니다. 아레니우스 환산은 하나의 물리 상태에서 하나의 반응 기전이 작동한다고 가정한다. 반면 동결된 생체 물질은 온도에 따라 동결 농축된 액상, 유리 상태, 결정질 얼음 같은 서로 다른 영역을 거친다. 한 영역에서 맞춘 매개변수를 다른 영역으로 가져갈 수는 없다. v1.x 개정판은 기준 온도를 공표하고, 각 k와 Ea의 출처를 인용하고, 그 값이 유효한 온도 범위를 밝혀야 한다.

구현자를 위한 실무 규칙. v1.0 공식으로 계산한 SQI 값은 저장하지도 표시하지도 말아야 한다. 보관 조건에 근거한 추정이 필요하다면 기준 온도 형태를 구현하되, 기관 자체의 비규범 모델임을 표시하고, 매개변수를 도출한 온도 범위 안에서만 쓰며, 새로 측정한 품질값보다 우선하게 해서는 안 된다.

3.7 직렬화: 객체에서 파일과 메시지로

용도별 형식

§9.2는 교환의 종류마다 형식을 지정한다.

명세 §9.2의 데이터 형식과 적용 방법
형식명세상 용도실무 지침
JSONAPI 교환§9.1.2와 같이 snake_case 필드 이름, UTF-8, 시료당 객체 하나.
CSV / TSV대량 데이터시료나 분주물당 한 행. location은 네 열과 full_location으로 펼친다. 모든 수량에 별도의 단위 열을 둔다.
XMLHL7 메시지XML은 HL7 v3/CDA와 FHIR의 XML 표현에 해당한다. HL7 v2 메시지는 보통 파이프 구분 형식이고, FHIR에는 JSON 표현도 있다. FHIR 리소스 매핑은 제6장에서 다룬다.
PDF보고서와 라벨인쇄 레이아웃 전용이다. 데이터 교환 경로로 쓰지 않는다.

예제 시료의 전송 형식

§9.1.2의 API는 snake_case 이름과 SDK보다 평평한 구조를 쓴다. 3.1절의 혈장 시료를 §9.1.2 등록 요청 본문으로 표현하면 다음과 같다.

{
  "sample_id": "SAM-7K2Q9X",
  "type": "blood_plasma",
  "donor_id": "D-789012",
  "collection_date": "2025-12-26T08:30:00Z",
  "volume": 10.5,
  "volume_unit": "mL",
  "storage_condition": "minus_80",
  "location": {
    "freezer": "F-01",
    "rack": "R-03",
    "box": "B-25",
    "position": "A5"
  },
  "quality": {
    "hemolysis_index": 25,
    "total_protein": 72
  },
  "consent_id": "CNS-456789"
}

SDK 객체와 나란히 놓으면 v1.0이 암묵적으로 남겨 둔 매핑 몇 가지가 드러난다. 실제 연동에서는 이를 명시적으로 문서화해야 한다.

SDK 객체 경로와 v1.0 전송 이름 비교(등록 요청)
SDK(camelCase)전송(명세 §9.1.2)차이
idsample_id이름 변경.
type: 'plasma'"type": "blood_plasma"어휘가 다르다. 명세 예제의 토큰은 SDK 열거값이 아니다.
donor.donorIddonor_id기증자 블록에서 꺼내 평탄화.
donor.consentIdconsent_id평탄화하여 최상위로 이동.
storageCondition: 'ultra_low_freezer'"storage_condition": "minus_80"어휘가 다르다.
quality.blood.hemolysisIndexquality.hemolysis_index중첩 제거.
location.fullLocation(요청에는 없음, 재고 응답에서는 "location": "F-01/R-03/B-25/A5" 문자열)같은 키가 엔드포인트마다 다른 타입.

v1.0은 열거형 전송 토큰을 두 개(blood_plasma, minus_80)만 보여 줄 뿐 완전한 전송 어휘를 정의하지 않는다. 현실적인 방법은 구현마다 14개 시료 유형과 5개 보관 조건 전체에 대한 매핑 표를 공개하고, 전환 기간에는 입력에서 두 형식을 모두 받는 것이다. 가장 위험이 적은 선택은 이미 닫힌 집합으로 존재하는 SDK 열거값을 전송에도 그대로 쓰는 것이다. 어느 쪽을 택하든 클라이언트마다 알아서 하게 두지 말고 API 기술 문서에 고정해야 한다. 제4장에서 엔드포인트를 살펴볼 때 이 문제를 다시 다룬다. 또 명세의 같은 절에 있는 재고 예제에는 "total_count": 2,450이 들어 있는데, 이는 유효한 JSON이 아니다. 전송되는 숫자에는 천 단위 구분 기호를 쓰지 않는다.

단위와 시간

직렬화 결함의 대부분은 두 가지 규칙으로 막을 수 있다.

3.8 요약

복습 문제

  1. 명세 §8.3.1이 시료 ID 안에 날짜와 일련번호를 금지하는 이유는 무엇인가? SDK의 generateSampleId()는 어떤 두 가지 점에서 이 규칙에 못 미치는가?
  2. PBMC 바이알에 저장된 생존율 값이 88이다. 이 값을 calculateSampleIntegrity()에 넘기기 전에 무엇을 해야 하는가? 그대로 넘기면 SDK는 어떻게 동작하는가?
  3. SIS 공식에서, 측정된 품질 손실과 온도 편차가 전혀 없는 시료는 tmax의 몇 분의 몇이 지난 뒤부터 excellent 판정을 받지 못하는가?
  4. −80 °C에서 1년 보관한 RNA에 대해 v1.0 SQI 공식이 0을 내는 이유를 수치로 보이고, 이를 바로잡는 기준 온도 형태를 쓰라.
  5. SDK Sample 객체와 §9.1.2 등록 요청 본문의 차이를 세 가지 들고, 각각에 명시적 매핑 규칙이 필요한 이유를 설명하라.