제5장

제5장: API — @wia/bio-010 SDK와 CLI

참조 TypeScript 패키지와 셸 도구의 각 호출이 무엇을 약속하는지, 실제로 실행하면 무엇을 하는지, 그리고 알려진 결함이 남아 있는 동안 어떻게 안전하게 쓸 수 있는지를 다룬다.

제2장부터 제4장까지는 임상시험의 데이터와 통계를 표준의 관점에서 설명했다. §4의 공식, §7의 JSON 객체, §5의 안전성 규칙이 그 내용이었다. 이 장은 WIA-BIO-010 v1.0과 함께 배포되는 소프트웨어로 넘어간다. 저장소에는 명세 옆에 두 가지 산출물이 있다. 하나는 api/typescript/ 아래의 TypeScript 패키지 @wia/bio-010(버전 1.0.0, MIT 라이선스)이고, 다른 하나는 install.sh가 /usr/local/bin/wia-bio-010으로 복사하는 Bash 스크립트 cli/wia-bio-010.sh이다. 명세 §8.2는 시험 등록, 환자 데이터 수집, 통계 계산의 세 가지 인터페이스를 기술하며, 이 패키지는 그 세 인터페이스와 몇 가지 추가 기능을 구현한 참조 구현이다.

참조 구현의 가치는 실행해 볼 수 있다는 데 있다. 이 장에서 보고하는 내용은 모두 소스를 읽고 실제로 실행해서 얻은 것이다. TypeScript 파일을 컴파일해 각 메서드를 본문에 나온 입력으로 호출했고, CLI도 명령마다 실행했다. 동작이 명세나 README.md의 설명, 또는 건전한 임상 데이터 관행과 다르면 그렇다고 밝히고 우회 방법을 제시한다. 목적은 패키지 사용을 말리는 것이 아니다. 호출 하나하나에 대해 결과를 그대로 써도 되는지, 보호 장치를 둘러야 하는지, 다른 도구로 바꿔야 하는지를 판단할 수 있게 하는 것이다.

용어를 하나 정리해 둔다. 이 장에서 API는 라이브러리의 프로그래밍 인터페이스, 즉 함수와 그 매개변수 타입, 반환 타입을 뜻한다. 패키지에는 HTTP 서버가 없고, v1.0은 네트워크 엔드포인트를 정의하지 않는다. 조직에서 이 기능들을 웹 서비스로 제공한다면 여기서 설명하는 요청·응답 형태를 서비스 계약의 출발점으로 삼을 수 있지만, 전송 방식·인증·영속화는 직접 설계해야 한다(제7장).

5.1 패키지 구성과 진입점

저장소에 들어 있는 것

패키지 디렉터리에는 package.json, src/index.ts, src/types.ts 세 파일만 있다. 매니페스트는 tsup으로 dist/ 아래에 CommonJS와 ES 모듈 번들을 만드는 빌드, Jest 테스트 설정, ESLint·Prettier 설정, 그리고 타입만 필요한 사용자가 @wia/bio-010/types에서 가져올 수 있게 하는 ./types 하위 경로 export를 기술한다. 그러나 생성물은 하나도 커밋되어 있지 않다. dist/가 없고, Jest 패턴에 맞는 테스트 파일도 없으며, files에 나열된 README.md와 LICENSE도 패키지 디렉터리 안에는 없다. 따라서 사용하기 전에 직접 빌드해야 하며, 레지스트리에 공개된 버전이 있다거나 그것이 저장소와 일치한다고 가정해서는 안 된다.

빌드를 해 보면 첫 번째 결함이 드러난다. 두 소스 파일 모두 끝부분에 이미 선언 위치에서 export한 이름을 다시 export하는 블록이 있다(index.ts의 export { ClinicalTrialSDK }, types.ts의 export type { ClinicalTrial, … }와 export { CLINICAL_CONSTANTS, … }). 현재의 TypeScript 컴파일러는 이를 "Cannot redeclare exported variable", "Export declaration conflicts with exported declaration" 오류로 거부하므로, 배포된 상태로는 typecheck 스크립트가 실패한다. 세 번째 오류는 collectPatientData에서 나온다. chemistry 타입에 선언되지 않은 hba1c를 laboratoryResults.chemistry에서 구조 분해하기 때문이다. 끝부분의 export 블록 두 개를 지우고 chemistry 타입에 hba1c?: number를 추가하면 --strict에서도 문제없이 컴파일된다. 이 장의 예제는 정확히 그렇게만 고친 사본으로 실행했다.

두 가지 진입 방식: 클래스와 독립 함수

모든 기능은 기본 export이기도 한 ClinicalTrialSDK 클래스에 들어 있다. 생성자는 인수를 받지 않고, 버전 문자열 외에는 상태를 갖지 않는다. getVersion()은 "1.0.0"을 반환한다. 클래스와 함께 index.ts는 registerTrial, collectPatientData, recordAdverseEvent, calculateSampleSize, calculatePower, calculateStatistics, generateSubmission의 일곱 개 독립 함수를 export하며, 각각 새 인스턴스를 만들어 호출을 위임한다. 클래스에 상태가 없으므로 두 방식 중 무엇을 고르든 성능 차이는 없다. 차이는 다른 데 있다. 아래 참조표에서 보듯 여러 독립 함수의 시그니처가 감싸고 있는 메서드보다 좁다. 둘이 다를 때는 클래스를 쓰는 편이 낫다.

types.ts에는 제네릭 Result<T, E> 유니온({ success: true; data: T } | { success: false; error: E })과 그 Promise 형태인 AsyncResult도 선언되어 있다. 그러나 패키지의 어떤 함수도 이 타입을 반환하지 않는다. 모든 메서드는 동기식이고 실패를 ClinicalTrialError 예외로 알린다. 예외는 collectPatientData 하나로, 이 메서드는 자체 응답 타입의 success 플래그로 검증 실패를 알린다. SDK를 서비스로 감쌀 때는 자기 계층에서 쓸 규약을 하나로 정하고 경계에서 변환하는 것이 좋다.

API 참조표

메서드(클래스)입력출력예외독립 함수와의 차이
registerTrial객체: title, phase, indication, primaryEndpoint(문자열), sampleSize, 선택 항목 duration, sponsor, investigatorClinicalTrial없음매개변수 타입에 sponsor·investigator가 없음
enrollPatienttrialId, demographics, consentDatePatientEnrollment없음독립 함수 없음
collectPatientDataDataCollectionRequestDataCollectionResponse없음같음
recordAdverseEventOmit<AdverseEvent, 'aeId'>AdverseEvent + reportingRequired, reportingDeadline없음선언된 반환 타입이 그냥 AdverseEvent
calculateSampleSizeSampleSizeCalculationSampleSizeResultB005같음
calculatePowerPowerCalculationPowerResult없음같음
performHypothesisTestnumber[], number[], alpha = 0.05HypothesisTestResult군이 비어 있으면 B005독립 함수 없음
calculateDescriptiveStatsnumber[]DescriptiveStats비어 있으면 B005독립 함수 없음
generateSubmissiontrialId, agency, format, type, datasetsRegulatorySubmission없음trialId·agency·format만 받고 type: 'OTHER'와 빈 datasets를 강제
—calculateStatistics는 독립 함수로만 존재하며 검정력 또는 표본크기 계산으로 분기한다(5.5절)—

"예외" 열에서 두 가지를 읽을 수 있다. 첫째, 예외를 던지는 것은 통계 메서드뿐이며 코드도 B005 하나뿐이다. 둘째, 패키지는 아무것도 저장하지 않는다. 식별자를 생성하고 객체를 반환할 뿐 어디에도 기록하지 않는다. 명세 §3.4에 나열된 저장·감사추적·접근통제 의무는 모두 SDK를 호출하는 애플리케이션의 몫이다.

5.2 시험·피험자 API

registerTrial

registerTrial은 평평한 매개변수 6~8개를 제3장에서 소개한 중첩 구조의 ClinicalTrial 객체로 바꾼다. primaryEndpoint로 넘긴 문자열은 primaryEndpoint.parameter가 되고, 지표(metric)는 항상 "Change from baseline", 시점은 "Week duration"으로 채워진다. 계획 표본크기는 그대로 복사되고, 등록·무작위배정·완료·탈락 인원은 0에서 시작하며, dropoutRate는 계획서의 가정과 상관없이 상수 0.20으로 설정된다. 상태는 planned로 시작하는데, 이는 제6장에서 다룰 시험 상태 기계의 첫 상태다.

필드생성되는 값설명
id"NCT" + 현재 시각(밀리초)의 끝 8자리ClinicalTrials.gov 번호처럼 보이지만 아니다. 같은 밀리초에 두 번 호출하면 충돌한다
duration생략 시 52(주)평가변수 시점과 완료 예정일 계산에도 쓰인다
sponsor생략 시 "Not specified"자리표시 문자열은 값이 비어 있는 것보다 찾아내기 어렵다
principalInvestigator생략 시 { name: "Principal Investigator", institution: "Research Center" }같은 문제
startDate, registrationDate현재 시각시험 시작일은 보통 등록일이 아니라 첫 피험자 등록일이다
estimatedCompletionDate현재 + duration주모집 기간을 고려하지 않는다
protocolVersion"1.0"메서드로는 지정할 수 없다

식별자 문제는 강조해 둘 필요가 있다. NCT 번호는 ClinicalTrials.gov에 기록을 등록할 때 그쪽에서 부여하는 번호이며, 의뢰자의 소프트웨어가 만들어 내는 것이 아니다. 내부 키에 NCT 접두어를 쓰면 실제 번호가 부여되었을 때 혼동이 생기고, 시각에서 뽑은 8자리 값은 실재하는 등록 번호와 우연히 겹칠 수도 있다. 반환된 id는 임시 내부 키로만 취급하고, 자체 계획서 식별자(예: 의뢰자의 시험 코드. 나중에 SDTM STUDYID가 된다)로 대체하며, NCT·EU CT 번호나 국내 등록 번호 같은 등록처 번호는 별도 필드에 저장하는 것이 좋다.

이 메서드는 입력을 전혀 검증하지 않는다. TypeScript 타입은 실행 시점에 사라지므로, JavaScript 호출자가 phase: "V"나 음수 표본크기, 빈 제목을 넘겨도 형식상 완전한 객체가 돌아온다. 시스템의 등록 모듈이 사용자나 다른 서비스로부터 입력을 받는다면, phase를 TrialPhase에 대조하고 표본크기가 양의 정수인지 확인한 뒤 필요하면 직접 B001이나 B003을 발생시켜야 한다.

enrollPatient

enrollPatient는 상태 enrolled, 오늘 날짜의 enrollmentDate, 전달받은 consentDate로 PatientEnrollment를 만든다. 피험자 식별자는 시험 ID, 하이픈, 000~999 사이의 무작위 세 자리 숫자로 구성된다. 이미 발급한 식별자와 대조하지 않으므로 충돌 확률은 생일 문제를 따른다. 아래 확률은 시험 하나에 대한 정확한 계산값이며 추정치가 아니다.

등록 피험자 수식별자가 둘 이상 겹칠 확률
30약 36 %
50약 71 %
100약 99.4 %
1,001 이상반드시 겹침

피험자 식별자는 이후 모든 데이터셋의 뼈대이며 SDTM USUBJID도 여기서 만들어지므로, 이것은 겉모양의 문제가 아니다. 식별자는 결정적으로 생성해야 한다. 기관 번호와 기관별 일련번호(예: SITE01-0001)를 조합하고, 데이터베이스의 유일성 제약을 갖춘 단일 발급 주체에서 발급하는 방식이 좋다.

두 번째 빈틈은 동의다. 오류 열거형에는 MISSING_CONSENT(B006)가 있고 명세 §8.4도 피험자 동의 없이는 데이터 수집을 진행할 수 없다고 하지만, enrollPatient는 빈 문자열을 consentDate로 받아들이며 패키지 어디에서도 B006을 발생시키지 않는다. 무작위배정도 하지 않는다. 타입에는 randomization이 선택 필드로 있지만 배정 함수는 없다. 제7장에서 데이터를 쓰기 전에 B006을 던지는 동의 확인 장치를 보여 준다. 그런 장치가 생기기 전까지는 enrollPatient를 단독으로 호출하게 해서는 안 된다.

5.3 collectPatientData

요청과 응답

collectPatientData는 명세 §8.2의 두 번째 인터페이스를 구현한다. 요청에는 trialId, patientId, 자유 텍스트 visit 레이블과 최대 네 가지 데이터가 담긴다. 이름에서 숫자로의 맵인 measurements, 중첩 객체 LabResults 형태의 laboratoryResults, vitalSigns, 그리고 adverseEvents다. 응답에는 success, 성공했을 때만 채워지는 dataId, validationErrors, warnings, timestamp가 들어 있다. 오류와 경고를 나눈 설계 자체는 타당하다. 오류는 기록을 막고, 경고는 기관이나 데이터 관리자에게 표시되어 질의(query)로 이어진다. 문제는 어떤 검사가 어느 목록에 들어가느냐다.

실제로 검사하는 것

검증 오류를 내는 조건은 하나뿐이다. measurements의 값이 숫자가 아닐 때("7.1" 같은 문자열이나 NaN)다. 나머지 검사는 모두 경고만 내므로, 불가능한 값에도 success는 true로 남고 dataId가 발급된다. 아래 표는 각 검사를 명세 §8.3의 유효 한계와 비교한 것이다. 제7장에서 자세히 다루듯이 §8.3의 한계는 자료 입력의 타당성 경계이고, VITAL_SIGNS_RANGES와 LAB_RANGES 상수는 이상 표시에 쓰는 임상 참고 범위다. 코드는 이 둘을 섞어 쓴다.

필드SDK 검사명세 §8.3 유효 한계결과
measurements.*숫자여야 함(오류)—범위 검사가 전혀 없어 measurements.hba1c: 25도 조용히 통과
수축기 혈압< 90 또는 > 250이면 경고60–250 mmHg하한이 정상 범위 최솟값이다. 141–250은 조용히 통과하고, 0은 거짓 판정 때문에 검사를 건너뛴다
이완기 혈압없음40–150 mmHg300 mmHg도 경고 없이 통과
심박수< 30 또는 > 200이면 경고30–200 bpm명세와 같지만 오류가 아니라 경고
체온, 호흡수, SpO₂없음35–42 °C, 6–60회/분, —45 °C도 경고 없이 통과
chemistry.hba1c< 3 또는 > 20이면 경고4.0–15.0 %명세·CLI와 경계가 다르고, 타입에 선언되지 않은 필드
chemistry.glucose< 20 또는 > 600이면 경고20–600 mg/dL일치하지만 경고로만
chemistry.alt, ast> 200이면 "Grade 2+" 경고5–2000 U/LCTCAE 등급은 기관별 정상 상한의 배수로 정해지므로 고정값 200 U/L은 등급이 될 수 없다
크레아티닌, 혈액학, 지질없음크레아티닌 0.3–15.0 mg/dL검사하지 않음
adverseEvents무시—이 경로로 보낸 이상사례는 중대성 평가를 받지 않는다(제4장)

몇 가지 입력으로 실행하면 다음과 같이 동작한다. 실제 실행 결과를 옮긴 것이며, 식별자와 시각은 생략했다.

vitalSigns { systolicBP: 200 }          -> success true,  warnings []
vitalSigns { systolicBP: 85 }           -> success true,  ["Systolic BP (85) is out of normal range"]
vitalSigns { systolicBP: 260 }          -> success true,  ["Systolic BP (260) is critically high"]
vitalSigns { systolicBP: 0 }            -> success true,  warnings []
vitalSigns { heartRate: 25, diastolicBP: 300, temperature: 45 }
                                        -> success true,  ["Heart rate (25) is out of valid range"]
chemistry  { hba1c: 2.5, glucose: 700, alt: 250 }
                                        -> success true,  경고 3건
measurements { hba1c: "7.1" }           -> success false, ["Invalid measurement value for hba1c"]

두 가지 사실을 덧붙이면 그림이 완성된다. dataId는 무작위 문자열일 뿐이고 데이터는 저장되지 않는다. 같은 방문으로 두 번 호출하면 식별자가 두 개 생기며 중복 탐지도 없다. 또한 명세 §8.2.2의 결과 타입은 dataId를 필수로 선언하지만 SDK는 검증에 실패하면 이를 생략한다. 선택 필드로 둔 SDK 쪽이 더 나은 설계지만, 명세 문구를 기준으로 작성한 클라이언트는 이 필드가 없을 수 있다는 점을 예상해야 한다.

안전하게 쓰는 법

collectPatientData는 에디트 체크(edit check) 엔진이 아니라 1차 이상 표시 함수로 다뤄야 한다. EDC 계층에서는 §8.3 유효 한계를 자체 에디트 체크로 적용해 한계를 벗어난 값마다 DATA_OUT_OF_RANGE(B007)를 제기하고, 이 소견을 임상 플래그인 SDK의 정상 범위 경고와 구분하며, SDK가 빠뜨린 검사를 추가한다. 한계를 벗어난 값을 곧바로 거부하지 않고 원자료 확인을 위한 질의와 함께 저장해야 하는 이유는 제7장에서 설명한다. 교차 서식 검사를 포함한 에디트 체크와 질의 관리 모델 전체는 제7장에서 다룬다. 입력 형태도 정규화해야 한다. 명세 §7.2의 환자 JSON은 HbA1c, sbp, dbp를 measurements에 넣는데, SDK는 HbA1c를 laboratoryResults.chemistry에서만, 혈압을 vitalSigns에서만 범위 검사한다. 명세 자체의 형태로 보낸 데이터는 범위 검사를 전혀 받지 못한다는 뜻이다.

5.4 recordAdverseEvent

이 메서드는 제4장에서 규정과 대조해 자세히 검토했으므로, 여기서는 API로서만 살펴본다. aeId가 없는 AdverseEvent를 받아 AE-<시각>-<무작위> 형식의 식별자를 만들고, 필드 두 개를 덧붙여 반환한다. reportingRequired는 seriousness.serious 플래그를 그대로 옮긴 값이다. reportingDeadline은 그 플래그가 참일 때만 생기며, death나 lifeThreatening이 설정되어 있으면 startDate에 7일을, 그렇지 않으면 15일을 더한 날짜다.

startDate "2026-03-02", serious + death            -> true,  2026-03-09
startDate "2026-03-02", serious + hospitalization  -> true,  2026-03-17
startDate "2026-03-02", hospitalization 만 참
                        (serious: false)           -> false, undefined

예시의 날짜는 설명을 위한 것이다. 세 줄은 이 메서드를 호출할 때 기억해야 할 세 가지 성질을 보여 준다. 기한은 규제상 시계가 시작되는 의뢰자 인지 시점이 아니라 발생일부터 센다. 판단에 인과관계와 예상성이 빠져 있으므로 "신속보고 대상인가"가 아니라 "SAE인가"에 답한다. 그리고 세부 기준보다 요약 플래그를 믿는다. 기준에는 입원이 표시되어 있는데 serious가 거짓인 기록은 아예 보고 대상이 되지 않는다. 호출 전에 여섯 가지 기준에서 serious를 도출하고, 인지일을 자체 기한 함수(제4장의 수정된 시계)에 넘기며, 이 메서드는 주로 식별자와 객체 형태를 얻는 용도로 쓰는 것이 좋다.

API 사용자에게 중요한 타입 문제가 하나 있다. 클래스 메서드의 반환 타입에는 추가 필드 두 개가 포함되지만, 독립 함수 recordAdverseEvent()는 반환 타입을 그냥 AdverseEvent로 선언한다. 실행 시점에는 필드가 있지만, 독립 함수를 호출하는 TypeScript 코드는 타입 단언 없이는 이를 읽을 수 없다. SDK 인스턴스의 메서드를 쓰는 편이 낫다.

5.5 통계 API

여기서 쓰이는 공식은 모두 제2장에서 유도했고, 표본크기·검정력 함수는 손계산과도 비교했다. 이 절은 코드에서 함수를 호출할 때 중요한 세부 사항을 보탠다. 어떤 매개변수가 실제로 반영되는지, 결과 필드가 무엇을 뜻하는지, 구현이 어디서 근사를 쓰는지다.

calculateSampleSize

이 메서드는 명시적인 평가변수 유형이 아니라 매개변수의 존재 여부로 분기한다. standardDeviation이 있으면 명세 §4.1의 연속형 공식을 쓰고, 없으면서 controlRate와 treatmentRate가 모두 있으면 이분형 분기로 가며, 둘 다 아니면 B005를 던진다. 검증하는 것은 alpha와 power가 0과 1 사이에 있는지뿐이다. 결과에는 올림한 sampleSizePerGroup, 그 두 배인 totalSampleSize, 전체를 (1 − 중도탈락률)로 나눠 올림한 adjustedSampleSize(기본 탈락률 0.20), 입력을 되돌려 주는 power와 effectSize, 그리고 method 문자열이 들어 있다. 군별 값을 두 배 하기 전에 올림하므로, 연속형 분기는 제2장의 수정된 값을 그대로 재현한다. 부록 A.1은 군당 91명, §4.1 예시는 군당 142명이다.

그러나 임계값은 하드코딩되어 있다. alpha는 범위 검사에만 쓰인다. 양측 검정은 항상 Z = 1.96, 단측 검정은 Z = 1.645, 즉 α = 0.05의 값을 쓴다. 검정력은 0.90 이상이면 Z = 1.28, 그 밖에는 Z = 0.84로 대응된다. 그래서 σ = 1.5, Δ = 0.5에서 검정력 95 %를 요청하면 90 %의 답인 군당 189명이 돌아온다. Z = 1.645를 쓴 올바른 값은 234명이다. 85 %를 요청하면 80 %의 답이 돌아온다. 이분형 분기에는 2.3절에서 설명한 공식 결함(두 항을 따로 제곱함)이 있고 effectSize도 무시한다. 규칙은 제2장과 같다. 이 메서드는 연속형 평가변수, 양측 α = 0.05, 검정력 80 % 또는 90 %에만 쓰고, 그 밖의 경우는 검증된 통계 패키지를 써야 한다. 또 SDK의 반올림된 Z값은 더 많은 소수 자리를 쓴 계산과 피험자 한 명 차이가 날 수 있으므로, 계획서가 어느 규약을 쓰는지 문서화해 두어야 한다.

calculatePower

여기서 sampleSize는 군당 피험자 수를 뜻한다. standardDeviation이 있으면 검정력 = Φ(Δ / (σ√(2/n)) − Z)를 계산하는데, Z는 역시 1.96 또는 1.645로 고정되어 있어 alpha는 결과에 그대로 되돌려지는 것 외에는 무시된다. 정규분포 누적분포함수는 잘 알려진 다항 근사식(Abramowitz와 Stegun 26.2.17)으로 소수점 약 일곱 자리까지 정확하므로, 이 조건 안에서는 결과를 믿을 수 있다. 군당 91명, σ = 1.2, Δ = 0.5이면 0.802가 나온다. 표준편차가 없으면 메서드는 거부하지 않고 min(0.99, 0.5 + 0.4 × (n/100) × Δ)를 반환한다. 통계적 근거가 없는 경험식으로, n = 100, Δ = 0.5이면 변동성과 무관하게 0.70을 보고한다. 표준편차는 항상 넘겨야 한다.

performHypothesisTest와 calculateDescriptiveStats

performHypothesisTest(group1, group2, alpha)는 합동분산 이표본 t-검정을 수행하고 검정통계량, 자유도, p값, 신뢰구간, 평균 차이(effectEstimate), 그 표준오차, significant 플래그를 반환한다. 검정통계량과 표준오차는 올바르게 계산된다. p값과 구간은 표본이 작으면 틀린다. 자유도가 30을 넘으면 t분포를 정규분포로 대체하는데, 이는 합리적인 근사다. 자유도가 30 이하이면 1 − 0.5·(df/(df + t²))^(df/2)를 쓰는데, 이것은 t분포 함수가 아니다. 정확한 5 % 임계값(t = 2.228, df = 10)에서 양측 p가 0.05가 아니라 약 0.13으로 나온다. 구간은 항상 t 분위수 대신 1.96을 쓰므로 소표본에서는 지나치게 좁고, 다른 alpha를 넘겨도 95 % 구간이다.

작은 예제로 영향을 확인해 보자. HbA1c 값 여섯 개씩으로 된 두 군이다(설명용 데이터).

const g1 = [7.1, 6.8, 7.4, 6.9, 7.6, 7.0];
const g2 = [7.9, 8.1, 7.5, 8.4, 7.8, 8.0];
sdk.performHypothesisTest(g1, g2);

// SDK 출력                                정확값 (t분포, df = 10)
// testStatistic       -4.643              -4.643
// standardError        0.1759              0.1759
// pValue               0.0032              0.00092
// confidenceInterval  [-1.161, -0.472]    [-1.209, -0.425]

이 예에서는 결론이 우연히 같지만, p값은 약 3.5배 어긋나고 구간은 약 12 % 좁다. 유의성 경계 근처에서는 significant 플래그가 어느 방향으로든 틀릴 수 있다. Welch(이분산) 검정이나 대응표본 검정 옵션은 없다.

calculateDescriptiveStats는 n, 평균, 표준편차(분모 n − 1), 표준오차, 중앙값, 최솟값, 최댓값, 사분위수, 평균의 95 % 구간을 반환한다. 사분위수는 보간 없이 sorted[floor(0.25·n)]와 sorted[floor(0.75·n)]로 구한다. 데이터 1, 2, 3, 4에 대해 SDK는 Q1 = 2, Q3 = 4를 보고하는데, 흔히 쓰는 보간 정의로는 1.75와 3.25다. 통계 소프트웨어마다 사분위수 정의가 여러 가지이며 어떤 정의를 쓰는지는 통계분석계획서에 명시해야 하는데, 이 방식은 통상적인 선택지에 들지 않는다. 값이 하나뿐이면 표준편차·표준오차·구간이 오류 없이 NaN(JSON에서는 null)이 된다. 구간은 여기서도 1.96을 쓴다.

calculateStatistics

이 독립 함수는 §8.2.3의 "통계 계산" 인터페이스에 해당한다. 어떤 필드가 있느냐에 따라 분기한다. power 없이 sampleSize가 있으면 검정력을, sampleSize 없이 power가 있으면 표본크기를 계산하고, 그 밖에는 B005를 던진다. 매개변수 타입에 standardDeviation도 사건 발생률도 없고, JavaScript 호출자가 이를 추가해도 전달하지 않는다. 그 결과 검정력 분기는 항상 경험식을 쓰고(n = 300, Δ = 0.5이면 0.99), 표본크기 분기는 항상 B005를 던진다. 반환값도 pValue와 confidenceInterval을 가진 명세의 StatisticalResult 타입이 아니라 PowerResult | SampleSizeResult 유니온이다. 이 함수는 쓰지 말고 calculatePower나 calculateSampleSize를 직접 호출해야 한다.

호출실제로 반영되는 입력안전하게 쓸 수 있는 조건
calculateSampleSizestandardDeviation, effectSize, testType, dropoutRate; power는 0.90 이상인지 여부만연속형 평가변수, α = 0.05, 검정력 0.80 또는 0.90
calculatePowersampleSize(군당), effectSize, standardDeviation, testTypeσ 제공, α = 0.05
performHypothesisTest두 배열, alpha는 플래그에만df > 30(대표본), 등분산 가정이 타당할 때
calculateDescriptiveStats배열n·평균·표준편차·중앙값·최솟값·최댓값. 사분위수는 제외
calculateStatistics—쓰지 않는다

5.6 generateSubmission

이름이 주는 기대만큼의 일을 하지는 않는다. generateSubmission은 생성된 id, 입력값, 현재 시각의 submissionDate, status: 'draft', 빈 comments 배열로 RegulatorySubmission 기록을 만든다. 데이터셋을 읽지 않고, SDTM·ADaM 파일이나 define.xml을 만들지 않으며, eCTD를 조립하지도 않는다. 이 기록은 제출 추적 테이블의 첫 행으로는 쓸모가 있고, 이후 상태는 draft → submitted → under-review → approved | rejected 생애주기를 따른다(제6장). 초안을 만들 때 submissionDate를 채우는 것은 오해를 부르므로, 상태가 submitted로 바뀔 때 실제 전송일을 기록하는 것이 좋다.

열거형값비고
RegulatoryAgencyFDA, EMA, PMDA, NMPA, MHRA, OTHER한국 식약처(MFDS)처럼 목록에 없는 규제기관은 OTHER와 별도 필드가 필요하다
SubmissionFormatCDISC, eCTD, NeeS, SEND같은 종류가 아니다. eCTD와 NeeS는 제출 문서 형식이고, CDISC는 데이터 표준군이며, SEND는 비임상 데이터용 CDISC 표준이다
typeIND, NDA, BLA, CTA, MAA, OTHERIND·NDA·BLA는 미국의 신청 유형이고, CTA와 MAA는 임상시험 신청과 판매허가 신청을 가리키는 유럽의 용어다
statusdraft, submitted, under-review, approved, rejected정보 요청, 임상 보류, 취하를 나타내는 상태가 없다

독립 함수 generateSubmission()은 trialId, agency, format만 받고 type: 'OTHER'와 datasets: []를 채워 넣는다. 신청 유형과 데이터셋 목록을 처음부터 기록하려면 클래스 메서드를 써야 한다. CTD 모듈, IND 서식, EU CTA 구성 등 각 규제 제출 패키지의 내용은 제6장에서 다룬다.

5.7 오류

실패는 ClinicalTrialError로 보고된다. Error의 하위 클래스로, ClinicalTrialErrorCode 타입의 code와 선택 항목인 details 레코드를 갖는다. 명세 §8.4는 여섯 개 코드를 나열하고 SDK 열거형은 네 개를 더한다. 패키지 자체가 실제로 던지는 코드는 하나뿐이다.

코드열거형 이름명세 §8.4SDK가 발생시키는가애플리케이션이 발생시켜야 할 곳
B001INVALID_TRIAL_ID있음아니요trialId가 등록된 시험이 아닌 모든 호출
B002PATIENT_NOT_ENROLLED있음아니요enrolled/randomized/active 상태가 아닌 피험자의 데이터나 이상사례
B003VALIDATION_FAILURE있음아니요(success: false로 보고)데이터의 구조·타입 오류
B004SAE_NOT_REPORTED있음아니요기한이 지난 신속보고를 찾아내는 예약 작업
B005STATISTICAL_VIOLATION있음예: 잘못된 α·검정력, SD·발생률 누락, 빈 데이터지원하지 않는 α·검정력에도(5.9절)
B006MISSING_CONSENT있음아니요유효한 동의 기록 없는 등록이나 데이터 수집
B007DATA_OUT_OF_RANGE없음(SDK 전용)아니요§8.3 유효 한계를 벗어난 값
B008PROTOCOL_DEVIATION없음(SDK 전용)아니요방문 허용 구간 이탈, 투약 오류(제6장)
B009REGULATORY_NONCOMPLIANCE없음(SDK 전용)아니요예: 필수 데이터셋이 빠진 제출 기록
B010EDC_SYSTEM_ERROR없음(SDK 전용)아니요자체 계층의 인프라 장애

코드는 문자열 열거값이므로 로그나 HTTP 오류 본문에 그대로 직렬화되고, 자체 매핑을 만드는 안정적인 식별자로 쓸 수 있다. 실무 권고는 두 가지다. 다른 예외도 같은 처리기에 도달할 수 있으므로 err.code를 읽기 전에 err instanceof ClinicalTrialError를 확인한다. 그리고 문제가 된 필드와 값을 details에 넣어 데이터 관리자가 스택 추적을 읽지 않고도 조치할 수 있게 한다. 협력 기관과 주고받는 메시지에 B007~B010이 나타난다면, 이것이 SDK의 확장이며 v1.0 명세 본문에는 없는 코드라는 점을 밝혀 두어야 한다.

5.8 CLI와 그 한계

wia-bio-010은 동작하는 명령 여섯 개와 version, help로 이루어진 Bash 스크립트다. TypeScript 패키지를 호출하지 않고, 파일을 읽거나 쓰지 않으며, 실행 사이에 상태를 유지하지 않는다. 상수는 스크립트 앞부분에 하드코딩되어 있다. 몇 가지 시나리오에 대해 그럴듯한 출력을 찍어 주는 시연용 출력기로 이해하는 것이 정확하다. 아래 표는 실제로 실행해 확인한 각 명령의 동작을 요약한 것이다.

명령과 플래그출력 내용알아 둘 점
register-trial --title --phase --sample-size시각에서 만든 새 NCT… ID, 상태 PLANNED, 고정된 일정(모집 12개월, 52주, 추적 4주, 18개월 후 완료)phase를 검증하지 않고(--phase V도 통과), 일정은 입력에서 도출되지 않는다
record-data --trial-id --patient-id --hba1cHbA1c를 4.0–15.0 %와 목표 7.0 %에 비교한 뒤 "Data collection completed"와 데이터 ID범위를 벗어난 값이나 숫자가 아닌 값도 오류 줄만 찍고 "완료"되며 종료 코드는 0
report-ae --trial-id --patient-id --severity항상 같은 저혈당 사례와 고정된 코딩·인과관계·결과강도로 중대성을 추정하고, 항상 오늘로부터 15일 기한(제4장)
calc-sample-size --alpha --power --effect-size --sdZ값, 군당·전체·탈락 보정 표본크기, 실현 가능성 레이블산술·반올림 오류가 있고 --alpha를 무시한다
calc-power --sample-size --alpha --effect-size검정력 백분율과 적정성 레이블0.5 + 0.4·(n/100)·Δ 경험식을 쓰며 SD 입력이 없다
generate-submission --trial-id --agency --format제출 ID, "New Drug Application", 데이터셋 목록, "SDTM v1.7 compliant"·"ADaM v1.1 compliant" 줄아무것도 만들거나 검사하지 않는다. 적합 문구는 고정된 텍스트다
version, help버전 1.0.0, 사용법README에 적힌 wia-bio-010 --version은 알 수 없는 명령으로 처리된다

표본크기 명령을 보면 CLI의 숫자를 인용해서는 안 되는 이유가 분명해진다. 기본값(α 0.05, 검정력 80 %, Δ 0.5, σ 1.5)으로 실행하면 다음과 같이 출력한다.

Sample Size per Group: 141.5 patients
Total Sample Size: 283.0 patients
Adjusted for 20% Dropout: 353.5 patients
[: 283.0: integer expression expected      (표준 오류로 세 번)
Very large trial (mega-trial)

스크립트는 bc 안에서 0.5를 더해 올림하려 하지만, 덧셈 결과가 피연산자의 소수 자릿수를 물려받아 정수가 아니라 141.5가 된다(올바른 답은 142). 이어서 소수점이 붙은 합계가 Bash의 정수 비교를 깨뜨려 실현 가능성 검사가 모두 실패하고, 스크립트는 마지막 분기인 "mega-trial"로 떨어진다. 피험자가 283명이든 11명이든 마찬가지다. --alpha 0.01을 넘기면 출력의 α 줄은 바뀌지만 계산에 쓰는 Z값 1.96은 그대로다. calc-power 명령은 군당 100명, Δ = 0.5에서 "70.0000 %"를 보고한다. σ = 1.5일 때 정규근사 검정력은 65 %이고(제2장), CLI는 σ를 아예 반영할 수 없다.

CLI를 스크립트로 감싸 쓰려는 경우 두 가지 성질이 중요하다. 명령은 오류 줄을 찍어도 종료 상태 0으로 끝나므로 파이프라인이 실패를 감지할 수 없다. 또 알 수 없는 플래그는 조용히 건너뛰므로, --effectsize 같은 오타가 있으면 아무 경고 없이 기본값이 쓰인다. CLI는 시연과 교육용으로만 두고, 계획서·보고서·데이터베이스에 들어가는 숫자에는 검증된 도구를 써야 한다.

5.9 종합: 수정한 빠른 시작과 보호 계층

README 예제

README.md에는 적힌 그대로는 동작하지 않는 TypeScript 예제가 두 개 있다. "Quick Start"는 표준편차나 사건 발생률 없이 effectSize: 0.3만으로 calculateSampleSize를 호출하므로 아무것도 출력하기 전에 B005를 던진다. "Components" 예제는 표본크기를 넣어 calculateStatistics를 호출한 뒤 power.statisticalPower와 power.pValue를 읽는다. 두 속성 모두 존재하지 않으므로 TypeScript는 코드를 거부하고, 순수 JavaScript로는 undefined undefined가 출력된다. 실제 power 필드에는 경험식 값 0.99가 들어 있다. 아래 판은 이 장에서 확인한 한계 안에서 설명대로 동작하는 호출만 쓴다. 5.1절에서 설명한 방식으로 고친 패키지에 대해 --strict로 컴파일되며, 제시한 출력을 낸다.

import { ClinicalTrialSDK, ClinicalTrialError } from '@wia/bio-010';

const sdk = new ClinicalTrialSDK();

// 1. 표본크기: 연속형 평가변수, standardDeviation 필수
const ss = sdk.calculateSampleSize({
  alpha: 0.05,            // 0.05만 반영됨 (Z = 1.96 고정)
  power: 0.80,            // 0.80 또는 0.90만 반영됨
  effectSize: 0.5,        // HbA1c 차이, %p
  standardDeviation: 1.2, // %p
  testType: 'two-tailed',
  dropoutRate: 0.20,
});
console.log('per group:', ss.sampleSizePerGroup,
            'total:', ss.totalSampleSize,
            'with dropout:', ss.adjustedSampleSize);

// 2. 탈락 보정 표본크기로 시험 등록
const trial = sdk.registerTrial({
  title: 'Phase III HbA1c study (example)',
  phase: 'III',
  indication: 'Type 2 diabetes mellitus',
  primaryEndpoint: 'HbA1c',
  sampleSize: ss.adjustedSampleSize ?? ss.totalSampleSize,
  duration: 24,
});
console.log('status:', trial.status, 'timepoint:', trial.primaryEndpoint.timepoint);

// 3. 검정력 확인: standardDeviation을 넘겨야 경험식을 피한다
const pw = sdk.calculatePower({
  sampleSize: ss.sampleSizePerGroup, // 군당
  alpha: 0.05,
  effectSize: 0.5,
  standardDeviation: 1.2,
  testType: 'two-tailed',
});
console.log('power:', pw.power.toFixed(3));

// 4. 방문 데이터: 활력징후·검사치는 검사되고, measurements는 숫자여야 한다
const res = sdk.collectPatientData({
  trialId: trial.id,
  patientId: 'SITE01-0001',
  visit: 'Week 12',
  measurements: { hba1c: 6.8, fasting_glucose: 121 },
  vitalSigns: { systolicBP: 86, heartRate: 72 },
});
console.log('success:', res.success, 'warnings:', res.warnings);

// 5. 오류에는 B 코드가 붙는다
try {
  sdk.calculateSampleSize({ alpha: 0.05, power: 0.8, effectSize: 0.3, testType: 'two-tailed' });
} catch (e) {
  if (e instanceof ClinicalTrialError) console.log('error:', e.code, '-', e.message);
}

예상 출력:

per group: 91 total: 182 with dropout: 228
status: planned timepoint: Week 24
power: 0.802
success: true warnings: [ 'Systolic BP (86) is out of normal range' ]
error: B005 - Must provide either standardDeviation (continuous) or controlRate/treatmentRate (binary)

첫 줄은 명세 부록 A.1을 재현하고, 셋째 줄은 설계가 의도한 80 % 검정력을 갖는다는 것을 확인한다. 넷째 줄은 5.3절의 이상 표시 동작을 보여 준다. 수축기 혈압 86 mmHg는 있을 수 있는 값이고 정상 범위보다 낮으므로, 거부되지 않고 경고로 올라오는 것이 맞다. 마지막 줄은 README의 빠른 시작이 실제로 출력하게 될 내용이다.

보호 계층

호출하는 곳마다 제약을 일일이 기억하기보다, 통계 메서드를 한 번 감싸서 지원하지 않는 입력이 분명하게 실패하도록 하는 편이 낫다. 아래 래퍼는 v1.0 코드가 틀린 답을 내는 것으로 알려진 입력을 거부하고, 나머지는 그대로 위임한다.

import {
  ClinicalTrialSDK, ClinicalTrialError, ClinicalTrialErrorCode,
  SampleSizeCalculation, SampleSizeResult, PowerCalculation, PowerResult,
} from '@wia/bio-010';

const sdk = new ClinicalTrialSDK();
const B005 = ClinicalTrialErrorCode.STATISTICAL_VIOLATION;

/** v1.0 코드가 실제로 올바르게 계산하는 입력만 받는다. */
export function guardedSampleSize(p: SampleSizeCalculation): SampleSizeResult {
  if (p.alpha !== 0.05) throw new ClinicalTrialError(B005, 'alpha must be 0.05 in v1.0');
  if (p.power !== 0.8 && p.power !== 0.9)
    throw new ClinicalTrialError(B005, 'power must be 0.80 or 0.90 in v1.0');
  if (p.standardDeviation === undefined)
    throw new ClinicalTrialError(B005, 'binary branch disabled: use a validated tool');
  return sdk.calculateSampleSize(p);
}

export function guardedPower(p: PowerCalculation): PowerResult {
  if (!p.standardDeviation || p.standardDeviation <= 0)
    throw new ClinicalTrialError(B005, 'standardDeviation is required');
  if (p.testType !== 'two-tailed' || p.alpha !== 0.05)
    throw new ClinicalTrialError(B005, 'only two-sided alpha = 0.05 is supported');
  return sdk.calculatePower(p);
}

// guardedSampleSize({ alpha: 0.05, power: 0.9, effectSize: 0.5,
//   standardDeviation: 1.5, testType: 'two-tailed' })   -> 군당 189
// 같은 입력에 alpha: 0.01                                 -> B005 alpha must be 0.05 in v1.0

다른 호출에도 같은 방식을 적용한다. 등록 래퍼는 phase와 표본크기를 검증하고 생성된 ID 대신 자체 시험 코드를 넣는다. 등록(enrolment) 래퍼는 순차적인 피험자 ID를 발급하고 동의 없이는 진행을 거부한다(B006 확인 장치는 제7장). 데이터 수집 래퍼는 SDK 호출과 함께 §8.3 한계를 벗어난 값에 B007 질의를 제기한다. 이상사례 래퍼는 기준에서 serious를 도출하고 인지일로부터 기한을 계산한다. 래퍼는 얇게 유지하고 제8장의 테스트 벡터로 검증해 두어야 한다. 그래야 이후 SDK 릴리스에서 결함이 고쳐졌을 때 해당 보호 장치를 걷어 내고도 시험이 계속 통과하는지 확인할 수 있다.

v1.0 SDK와 CLI의 알려진 결함(API 관점)

장 점검표

SDK에 의존하기 전에 확인할 것절
export 블록을 고쳐 소스에서 직접 빌드하고, 검증한 정확한 커밋을 고정했다.5.1
시험·피험자 식별자는 SDK가 아니라 자체 일련번호에서 나온다.5.2
등록 전과 모든 데이터 기록 전에 동의를 확인한다.5.2, 제7장
§8.3 유효 한계는 SDK 밖에서 검사해 B007 질의를 제기하고, SDK의 정상 범위 경고는 임상 플래그로 둔다.5.3
중대성은 기준에서 도출하고, 보고 시계는 인지 시점에서 시작한다.5.4, 제4장
통계 호출은 보호 계층을 거치며, α 0.05·검정력 0.80–0.90·연속형 평가변수를 벗어나면 검증된 패키지를 쓴다.5.5, 5.9
CLI가 낸 숫자는 계획서·보고서·데이터베이스 어디에도 들어가지 않는다.5.8
자체 코드가 B001–B010을 일관되게 발생시키고 세부 정보를 기록한다.5.7

한계까지 포함해 인터페이스를 이해했으니, 다음 장에서는 개별 호출에서 한 걸음 물러나 프로토콜 전체를 본다. 시험의 생애주기 동안 이 연산들이 어떤 순서로 일어나는지, 어떤 상태 전이를 일으키는지, 그리고 그 주위를 둘러싼 모니터링과 규제 절차가 무엇인지를 다룬다.