제3장
WIA-BIO-010이 시험·피험자·방문을 어떻게 기록하는지, 그리고 같은 사실이 CDISC의 세 층위 — 수집, 표 작성(tabulation), 분석 — 를 거치는 동안 의미를 잃지 않으려면 무엇을 지켜야 하는지를 다룬다.
제2장은 임상시험을 하나의 계산으로 보았다. 피험자가 몇 명 필요한지, 검정력은 얼마인지, 어떤 검정을 쓸 것인지가 관심사였다. 이 장은 임상시험을 하나의 기록으로 본다. 시험 하나는 수십만 건의 개별 사실을 만들어 낸다. 화요일 오전 9시 40분에 잰 혈압, 중앙검사실이 자기 단위로 보고한 혈당, 피험자가 공복 상태였다는 메모 같은 것들이다. 이 사실 하나하나가 긴 여정을 버텨야 한다. 증례기록서(CRF)에 적히고, 전자자료수집(EDC) 시스템에 입력되거나 전송되며, 제출용 표로 재구성되고, 분석 변수로 파생되어, 마지막에는 규제기관이 읽는 표·목록·그림(TLF)으로 요약된다. 도중 어느 지점에서든 단위가 빠지거나 식별자가 모호해지면 그 사실은 훼손되고, 그 훼손은 대개 몇 달 뒤 고치기 가장 비싼 시점에 드러난다.
WIA-BIO-010은 이 여정을 두 층위에서 다룬다. 명세 §7은 세 가지 JSON 객체 — 시험 등록, 피험자 데이터, 이상사례 — 를 정의하고, 참조 TypeScript 패키지 @wia/bio-010이 그 뒤의 타입을 정의한다. 명세 §3은 같은 데이터를 수집·표 작성·분석하는 형식으로 CDISC 계열 표준(CDASH, SDTM, ADaM)을 지목한다. 명세는 각 층위의 윤곽만 제시할 뿐 층위 사이의 매핑은 적어 두지 않았다. 이 장은 그 매핑을 채우고, JSON 예시·TypeScript 타입·CDISC 관행이 서로 어긋나는 지점을 표시한 뒤, 피험자 한 명을 처음부터 끝까지 따라가는 사례로 마무리한다. 안전성 데이터(이상사례 객체, MedDRA, CTCAE)는 제4장에서 따로 다루며, 여기서는 미리보기로만 등장한다.
명세 §7은 짧다. JSON 예시 세 개가 전부이며, 모두 하나의 시나리오, 즉 가상의 "Drug X"를 제2형 당뇨병에서 시험하고 HbA1c를 일차 평가변수로 삼는 제3상 시험을 바탕으로 한다. 세 예시를 함께 읽으면 WIA-BIO-010 구현이 최소한 무엇을 저장하고 교환할 수 있어야 하는지가 드러난다. 다만 이것은 예시이지 스키마가 아니다. 명세는 JSON Schema도, 필수 필드 목록도 공개하지 않는다. 따라서 3.2절의 TypeScript 타입이 형식적 정의에 가장 가까운 자료이며, 둘은 서로 똑같지 않다.
등록 객체는 시험을 식별하고, 그 시험이 무엇을 입증하려 하는지를 밝힌다. 아래는 명세 예시에 이 책이 주석을 단 것이다. JSON 문법은 주석을 허용하지 않으므로 실제로 파싱하기 전에는 주석을 지워야 한다.
{
"trialId": "NCT12345678", // 등록번호 — 아래 설명 참조
"title": "Phase III Study of Drug X in Type 2 Diabetes",
"phase": "III", // 'I' | 'II' | 'III' | 'IV'
"indication": "Type 2 Diabetes Mellitus",
"sponsor": "Pharma Company Inc.",
"principalInvestigator": {
"name": "Dr. Jane Smith",
"institution": "University Medical Center"
},
"primaryEndpoint": {
"parameter": "HbA1c", // 무엇을 재는가
"metric": "Change from baseline",// 피험자별로 어떻게 요약하는가
"timepoint": "Week 52" // 언제 평가하는가
},
"sampleSize": {
"planned": 600, // 계획서의 표본크기 절에서 옴
"enrolled": 0, // 시스템이 갱신하는 누적 계수
"randomized": 0,
"completed": 0
},
"startDate": "2025-01-15",
"estimatedCompletionDate": "2026-12-31",
"status": "recruiting"
}
여기서 눈여겨볼 설계 선택이 세 가지 있다. 첫째, 일차 평가변수가 parameter, metric, timepoint로 나뉘어 있다. 자유 서술형 평가변수보다 훨씬 쓸모가 있는데, 각 부분에 하류의 대응물이 있기 때문이다. parameter는 SDTM의 검사 코드와 ADaM의 분석 파라미터(PARAMCD)가 되고, metric은 CHG 같은 파생 변수가 되며, timepoint는 분석 방문(AVISIT)이 된다. "HbA1c 개선"이라고만 적힌 평가변수는 이렇게 추적할 수 없지만 세 필드로 적힌 평가변수는 추적할 수 있다. 다만 이 세 짝은 구제약물 투여나 투약 중단 같은 중간사건(intercurrent event)을 어떻게 처리하는지, 곧 제2장과 제8장에서 다루는 추정대상(estimand)의 문제는 담지 못한다. 그러므로 권위 있는 근거는 여전히 계획서 본문이다.
둘째, sampleSize에는 계획된 상수 하나와 누적 계수 세 개가 섞여 있다. 계획 인원은 계획서에서 오며 계획서 변경 없이는 바뀌면 안 된다. 반면 계수들은 피험자 기록에서 계산되는 값이다. 한 객체에 함께 두면 대시보드를 만들기에는 편하지만, 계수를 손으로 올리지 말고 피험자 테이블에서 파생해야 한다. 그렇지 않으면 두 값은 언젠가 어긋난다.
셋째, trialId가 NCT 번호로 나와 있다. NCT 번호는 ClinicalTrials.gov에 시험을 등록할 때 그쪽이 부여하는 번호로, 의뢰자가 스스로 만들어 낼 수 없다. 많은 시험은 의뢰자 계획서 번호, EU 시험 번호, 국가 등록번호(국내라면 예컨대 임상연구정보서비스(CRIS) 등록번호)를 함께 갖는다. WIA 객체에는 식별자 칸이 하나뿐이므로, 이 책은 trialId에 의뢰자의 안정적인 시험 식별자 — 나중에 SDTM의 STUDYID가 될 바로 그 값 — 를 넣고, 등록번호들은 구현 자체의 확장 필드에 별도 속성으로 보관할 것을 권한다.
피험자 객체는 한 피험자의 인구학적 정보, 무작위배정, 방문 목록을 한데 묶는다.
{
"subjectId": "STUDY01-001",
"demographics": { "age": 58, "sex": "M", "race": "Asian",
"ethnicity": "Not Hispanic or Latino" },
"enrollmentDate": "2025-02-01",
"randomization": {
"date": "2025-02-01",
"treatment": "Drug X",
"stratification": {
"baseline_hba1c": "≥8.0%",
"diabetes_duration": ">10 years"
}
},
"visits": [
{
"visitId": "V1",
"visitName": "Baseline",
"visitDate": "2025-02-01",
"measurements": {
"hba1c": 8.5, // % (NGSP 단위)
"fasting_glucose": 165, // mg/dL
"weight": 85.2, // kg
"sbp": 132, // mmHg
"dbp": 84 // mmHg
}
}
]
}
이 객체는 읽기 쉽고, 그것이 바로 이 객체의 목적이다. 시험자가 피험자 한 명을 떠올리는 방식 그대로이기 때문이다. 그러나 데이터가 표로 정리되는 방식은 이와 다르다. 나중에 중요해질 속성 여럿이 여기서는 암묵적이다. 단위는 읽는 사람의 머릿속에만 있다(위 주석은 이 책이 덧붙인 것이다). measurements 맵에는 검사실 값(HbA1c, 혈당)과 활력징후(체중, 혈압)가 나란히 들어 있지만, CDISC는 이들을 서로 다른 도메인에 둔다. 층화 요인은 코드화된 범주가 아니라 자유 텍스트 라벨("≥8.0%")이어서, 같은 층이 기관마다 ">=8.0", "8.0 이상", "high"처럼 다르게 나타날 수 있다. 또한 이 객체에는 시험기관, 동의 일자, 공복 여부가 없다. 셋 모두 SDTM이 요구하는 사실이다.
눈가림 문제도 있다. 이중눈가림 시험에서는 treatment 필드가 시험기관 직원이나 데이터 정제 담당자에게 보여서는 안 된다. 실제로 무작위배정 목록은 IRT(interactive response technology) 시스템에 있고, 실제 투여군은 눈가림 해제 시점에야 임상 데이터와 결합된다. 따라서 WIA 객체를 구현할 때는 눈가림 해제 전까지 treatment를 비워 두거나 읽기 권한을 제한해야 한다. EDC에 이 값을 공개된 채로 저장하면 시험 자체가 훼손된다.
세 번째 객체는 피험자 한 명의 이상사례 하나를 기록한다. 보고된 원문 용어, MedDRA 코드, 시작일과 종료일, 중증도, 중대성 기준 묶음, 인과관계 평가, 취한 조치, 결과가 들어간다. 구조가 SDTM AE 도메인과 매우 가까워서 피험자 객체보다 매핑이 쉽다. 중증도와 중대성이 왜 별개의 개념인지, MedDRA 코딩이 어떻게 이루어지는지, 등급과 보고 기한을 어떻게 적용하는지는 제4장의 주제다. 이 장에서는 AE 객체가 자기 subjectId를 따로 가지므로 피험자 객체와 독립적으로 저장·교환될 수 있고, 같은 식별자 규칙이 여기에도 적용된다는 점만 짚어 둔다.
@wia/bio-010 패키지(파일 api/typescript/src/types.ts)는 명세 예시를 인터페이스로 옮겨 놓았다. 이 장과 관련된 것을 줄여서 보이면 다음과 같다. 선택 필드는 ?로 끝난다.
type TrialPhase = 'I' | 'II' | 'III' | 'IV';
type TrialStatus = 'planned' | 'recruiting' | 'active'
| 'suspended' | 'completed' | 'terminated';
interface ClinicalTrial {
id: string; // 주의: 명세 JSON에서는 "trialId"
title: string; phase: TrialPhase; indication: string; sponsor: string;
principalInvestigator: Investigator; // { name, institution, email?, credentials? }
primaryEndpoint: Endpoint; // { parameter, metric, timepoint, target?, unit? }
secondaryEndpoints?: Endpoint[];
sampleSize: SampleSizeInfo; // { planned, enrolled, randomized, completed,
// withdrawn?, dropoutRate? }
startDate: Date | string; estimatedCompletionDate: Date | string;
status: TrialStatus; protocolVersion?: string;
registrationDate: Date | string; // 필수, 명세 예시에는 없음
duration?: number; // 주 단위
}
interface PatientEnrollment {
subjectId: string; trialId: string;
demographics: Demographics; // age, sex 'M'|'F'|'Other', race, ethnicity,
// weight?, height?, bmi?
enrollmentDate: Date | string;
consentDate: Date | string; // 필수
randomization?: Randomization; // date, treatment, randomizationNumber?,
// stratification?: Record<string,string>, arm?
status: 'enrolled' | 'randomized' | 'active' | 'withdrawn' | 'completed';
}
interface PatientVisit {
visitId: string; visitName: string; visitDate: Date | string;
subjectId: string;
visitWindow?: number; // SDK 주석: "기저 시점부터의 일수"
visitDay?: number; // SDK 주석: "실제 방문일"
status: 'scheduled' | 'completed' | 'missed' | 'unscheduled';
measurements?: ClinicalMeasurements; // hba1c?, fasting_glucose?, weight?,
// [key: string]: number | undefined
laboratoryResults?: LabResults; // hematology, chemistry, lipids, [key]: any
vitalSigns?: VitalSigns; // systolicBP, diastolicBP, heartRate,
// temperature, respiratoryRate, oxygenSaturation
}
명세 예시와 타입이 다른 곳에서는 구현이 어느 한쪽을 골라야 한다. 아래 표는 저장과 교환에 영향을 주는 차이를 모두 모으고, 이 책이 권하는 처리 방법을 붙인 것이다. 이 선택들은 v1.0이 강제하는 것이 아니며, 구현 자체의 데이터 명세서에 적어 두어야 할 공학적 결정이다.
| 주제 | 명세 JSON(§7) | TypeScript 타입 | 권장 처리 |
|---|---|---|---|
| 시험 식별자 | trialId | ClinicalTrial.id | 입력 시에는 둘 다 받고, 출력 시에는 한 이름으로 통일해 문서화한다. 값은 STUDYID가 된다. |
| 등록 일자 | 없음 | registrationDate 필수 | 필수. 시스템에 기록이 생성된 날짜를 저장한다. |
| 피험자 → 시험 연결 | subjectId 접두어로 암시 | trialId 필수 | 명시적으로 저장한다. 식별자 문자열을 쪼개 관계를 복원하지 않는다. |
| 동의 일자 | 없음 | consentDate 필수 | 임상 데이터를 받기 전에 반드시 있어야 한다(제7장, 오류 B006). |
| 피험자 상태 | 없음 | status 필수 | 필수. 상태 전이는 제6장에서 다룬다. |
| 방문 | 피험자 안에 visits[]로 중첩 | 자기 subjectId를 가진 별도 PatientVisit. PatientEnrollment에는 visits 필드 없음 | 방문은 피험자를 키로 하는 별도 레코드로 저장하고, 중첩은 내보내기 화면에서만 한다. |
| 방문 상태 | 없음 | status 필수 | 필수. 결측 방문도 데이터이지 데이터의 부재가 아니다. |
| 활력징후 | measurements 안의 sbp, dbp, weight | 전용 vitalSigns 객체(체중 필드 없음) | 혈압·맥박은 vitalSigns를 쓴다. 체중은 measurements나 demographics에 두되 매핑 시 VS로 보낸다. |
| 단위 | 없음 | 주석에만 있음 | 주석의 단위를 정규 단위로 간주한다. API 경계에서 다른 단위의 값은 거부하거나, 단위 필드를 명시적으로 함께 싣는다. |
| 날짜 | ISO 8601 문자열 | Date | string | ISO 8601 문자열로만 교환한다. JavaScript Date는 원천에 없던 시각과 시간대를 붙여 직렬화된다. |
타입 수준의 세부 사항 두 가지도 매핑에 영향을 준다. ClinicalMeasurements는 열린 인덱스 시그니처를 가지므로 숫자 값이기만 하면 어떤 키든 받아들인다. {"hba1c": 8.5}와 {"HbA1c": 8.5}가 모두 유효하며 서로 다른 측정값으로 취급된다. 따라서 매퍼는 알려진 키의 명시적 목록을 기준으로 동작하고 나머지는 거부해야 한다. 3.6절의 코드가 그렇게 한다. LabResults는 한 걸음 더 나아가 [key: string]: any를 선언하므로 추가 키가 숫자일 필요조차 없다. 이름이 붙은 chemistry 묶음에는 glucose, creatinine, BUN, ALT, AST, albumin이 있지만 HbA1c는 없다. 그런데 SDK의 검증 코드는 chemistry.hba1c를 찾는다. 이 문제는 제5장에서 다시 다룬다. 모델링 관점에서 안전한 규칙은 이렇다. 제출될 모든 측정값에는 정의된 키, 정의된 단위, 정의된 대상 도메인이 필요하며, 열린 맵은 이 셋 중 어느 것도 보장하지 않는다.
CDISC는 임상시험 데이터의 생애 전체를 덮는 표준들의 사슬을 관리한다. 아래 그림은 각 층위가 어디에 놓이고 무엇을 위해 존재하는지를 보여 준다.
CDASH(Clinical Data Acquisition Standards Harmonization)는 증례기록서에서 묻는 질문과 그 답을 저장하는 변수 이름을 표준화한다. 명세 §3.1은 구현이 지원해야 할 핵심 도메인을 다음과 같이 든다.
SDTM이 이미 있는데 수집 층위에 왜 별도의 표준이 필요한가. 간호사에게 자연스러운 질문이 통계 담당자에게 필요한 행과 다른 경우가 많기 때문이다. 네 가지 예가 그 간극을 보여 준다.
--DAT, --TIM). SDTM은 이를 --DTC 하나에 ISO 8601 값으로 담으며, 월까지만 알려진 경우라면 부분 날짜가 된다.--STAT = NOT DONE과 --REASND(사유)를 가진 레코드가 된다.WIA 피험자 객체는 그 경계에 놓인다. 모양은 수집 데이터(피험자 한 명, 방문별)이지만 동시에 시스템 간 교환 단위이기도 하다. 그러므로 CDASH 변수가 있는 곳에서는 키를 CDASH 이름에 맞추고, 없는 곳에서는 매핑을 문서로 남기는 것이 합리적이다.
SDTM(Study Data Tabulation Model)은 수집된 데이터를 미국 FDA나 일본 PMDA 같은 규제기관에 제출할 때 쓰는 형식이다. 데이터를 도메인으로 조직하며, 각 도메인은 한 행이 관찰 하나인 표다. 명세 §3.2는 일반 목적 변수를 나열한다. 앞의 "--"는 두 글자 도메인 코드로 바뀐다(예: LBTESTCD, VSORRES).
| 변수 | 의미 | 실무 규칙 |
|---|---|---|
STUDYID | 시험 식별자 | 시험의 모든 도메인에서 동일하다. |
DOMAIN | 두 글자 도메인 코드 | DM, AE, VS, LB 등. |
USUBJID | 고유 피험자 식별자 | 한 시험 안에서가 아니라 제출에 포함된 해당 제품의 모든 시험에 걸쳐 고유해야 한다. |
--SEQ | 일련번호 | 도메인 안에서 피험자별로 고유하며, 다른 데이터셋이 이 행을 참조할 때 쓴다. |
--TESTCD / --TEST | 검사의 짧은 코드 / 이름 | 기관의 관습이 아니라 CDISC Controlled Terminology에서 코드를 가져온다. |
--ORRES / --ORRESU | 받은 그대로의 결과와 단위 | 문자형. "<0.5"나 "trace" 같은 텍스트를 보존한다. |
--STRESC / --STRESU | 표준화 후의 결과와 단위 | 시험 전체에서 검사당 단위 하나. 숫자 사본은 --STRESN에 둔다. |
참조 패키지는 이 모델을 두 선언에 반영했다. SDTMDomain은 도메인 코드 열 개의 유니온이다. DM(인구학), AE(이상사례), VS(활력징후), LB(검사실), CM(병용약물), EX(노출), DS(시험 종료 상태), MH(병력), PE(신체검사), QS(설문)다. SDTMRecord는 STUDYID, DOMAIN, USUBJID와 SEQ라는 필드를 선언하고 나머지는 열린 인덱스 시그니처로 받는다. SDTM 변수에는 항상 접두어가 붙는다는 점(LBSEQ, VSSEQ)에 주의해야 한다. SDK의 맨 SEQ는 메모리 안에서의 편의일 뿐이므로 내보낼 때 이름을 바꿔야 한다. 열 개 도메인은 의약품 시험의 출발점으로는 합리적이지만 완전한 목록은 아니다. 실제 제출에는 시험 설계 도메인(TA, TV, TS 등), 피험자 방문(SV), 보충 한정자(SUPP--)도 필요하다.
명세 §3.2는 SDTM을 LB 두 행으로 예시한다. 아래는 그 예시에 LBSTRESN 열을 더한 것이다. 분석 프로그램이 실제로 읽는 것은 숫자형 표준 결과이기 때문이다.
| STUDYID | DOMAIN | USUBJID | LBSEQ | LBTESTCD | LBORRES | LBORRESU | LBSTRESC | LBSTRESN | LBSTRESU |
|---|---|---|---|---|---|---|---|---|---|
| STUDY01 | LB | STUDY01-01 | 1 | HBA1C | 7.2 | % | 7.2 | 7.2 | % |
| STUDY01 | LB | STUDY01-01 | 2 | GLUC | 135 | mg/dL | 7.49 | 7.49 | mmol/L |
혈당 환산에는 포도당의 몰질량 180.16 g/mol이 쓰인다. 1 mmol/L는 180.16 mg/L, 즉 18.016 mg/dL이므로 135 mg/dL ÷ 18.016 = 7.493 mmol/L이고, 7.49로 보고한다. 이 계수는 포도당에만 해당한다. 콜레스테롤, 크레아티닌, 요소는 몰질량이 달라 계수도 각각 다르며, U/L로 보고하는 효소 활성처럼 몰 환산 자체가 없는 검사도 있다. 매핑 프로그램은 검사 코드와 단위 쌍을 키로 하는 환산표를 가져야 하며, 모르는 쌍을 만나면 추측하지 말고 실패해야 한다.
HbA1c는 또 다른 종류의 표준화를 보여 준다. 명세는 HbA1c를 퍼센트(미국과 국내에서 흔히 쓰는 NGSP/DCCT 척도)로 둔다. 많은 검사실은 IFCC 단위인 mmol/mol도 함께 보고하는데, 두 척도는 공개된 기준식 IFCC = 10.929 × (NGSP − 2.15)로 연결된다. 따라서 기저치 8.5 %는 약 69 mmol/mol에 해당한다. 어느 단위를 표준으로 삼을지는 통계분석계획서에 적어야 할 시험 수준의 결정이다. 중요한 것은 검사마다 단위를 하나로 정하고, --ORRES에는 실제로 받은 값을 남기는 것이다.
예시를 그대로 가져다 쓰기 전에 손봐야 할 작은 점이 두 가지 있다. 예시의 USUBJID는 "STUDY01-01"인데 §7.2의 피험자는 "STUDY01-001"이다. 아래 사례에서는 긴 형식으로 통일한다. 또한 검사 코드 HBA1C는 예시용이다. CDISC Controlled Terminology는 당화혈색소에 자체 코드를 정의하므로, 실제 시험은 예시가 아니라 고정해 둔 용어집 배포판에서 코드를 가져와야 한다.
SDTM이 관찰된 것을 기술한다면 ADaM(Analysis Data Model)은 분석될 것을 기술한다. 이 구분이 중요한 이유는 분석에 직접 관찰된 적이 없는 값이 필요하기 때문이다. 규칙으로 정한 기저치, 그 기저치로부터의 변화량, 어느 레코드가 52주 값으로 쓰이는지 알려 주는 플래그, 피험자가 치료의향(ITT) 집단에 속하는지 알려 주는 집단 플래그 같은 것들이다. ADaM 데이터셋은 파생 과정을 빠짐없이 문서화하면서 SDTM으로부터 만들어지며, 그래서 검토자는 분석된 모든 숫자를 원천 행까지 거슬러 올라갈 수 있다. 명세 §3.3은 세 데이터셋을 든다.
TRT01P, TRT01A), 집단 플래그(ITTFL, SAFFL, PPROTFL), 시험 종료 상태, 무작위배정에 쓴 층화 요인을 포함한 주요 기저 공변량을 담는다.TRTEMFL을 담는다. 제4장에서 다룬다.명세가 나열한 파생 변수가 BDS의 핵심이다.
AVAL 분석값 (숫자, 표준 단위)
BASE 해당 피험자·파라미터의 기저치
CHG 기저치 대비 변화량 = AVAL − BASE
PCHG 변화율(%) = 100 × (AVAL − BASE) / BASE
BNRIND 기저치 참고 범위 지시자 (LOW / NORMAL / HIGH)
ANRIND 분석값 참고 범위 지시자 (LOW / NORMAL / HIGH)
이 파생을 정직하게 유지하는 규칙이 세 가지 있다. 첫째, 기저치는 분석계획서에서 정의해야 한다. 흔히 첫 투여일 또는 그 이전의 마지막 비결측값으로 정하며, 그 값을 제공한 레코드에 플래그(ABLFL = "Y")를 단다. 단순히 "Baseline이라는 이름의 방문"이어서는 안 된다. 둘째, BASE가 0이면 PCHG는 정의되지 않으므로 나누지 말고 결측으로 남긴다. 셋째, 참고 범위 지시자는 값을 검사실의 정상 범위와 비교한 것이다. 이는 임상적 분류이지 데이터 유효성 점검이 아니며, 제7장에서 둘을 엄밀히 구분한다.
BNRIND와 ANRIND를 교차하면 전이표(shift table)가 나온다. 기저치에서 NORMAL이던 피험자가 12주에 HIGH로 옮겨 간 경우가 몇 명이고, NORMAL에 머문 경우가 몇 명인지 등을 보여 주는 표다. 전이표는 검사실 안전성 보고의 일상적인 구성 요소이며, 각 검사 결과에 붙은 참고 범위만큼만 믿을 수 있다. 중앙검사실 데이터를 받을 때마다 참고 범위도 함께 전송받아야 하는 또 하나의 이유다.
추적성은 한 방향으로 흐르며 반드시 기록되어야 한다. ADaM 레코드마다 SDTM 원천을 식별할 수 있어야 하고(BDS 데이터셋에서는 흔히 SRCDOM, SRCVAR, SRCSEQ를 쓰거나 원천 행의 --SEQ를 쓴다), 보통 define.xml로 전달되는 데이터셋 메타데이터는 검토자가 확인할 수 있는 말로 모든 파생을 설명해야 한다. 명세는 참조 버전으로 SDTM 1.7과 ADaM 1.1을 인용한다. CDISC는 그 뒤로도 구현 지침을 새로 냈으므로, 시험은 어느 버전을 따르는지 가정하지 말고 명시해야 한다.
이 절은 피험자 STUDY01-001을 §7.2 JSON에서 출발해 SDTM을 거쳐 ADLB의 기저치 대비 변화량 하나에 이르기까지 따라간다. 기저치 데이터는 명세의 것이다. 12주 값은 설명을 위해 만든 예시이며 그렇게 표시해 둔다. 단순화를 위해 시험 기준 시작일(RFSTDTC, 보통 첫 투여일)은 무작위배정일인 2025-02-01로 가정한다.
| WIA JSON 필드 | SDTM 대상 | 비고 |
|---|---|---|
trialId / id | STUDYID(모든 도메인) | 등록번호가 아니라 의뢰자 시험 식별자. |
subjectId | USUBJID, 기관 수준 부분은 SUBJID | 제출 전체에서 고유해야 한다. |
| — | DM.SITEID | DM에서는 필수지만 WIA 객체에는 없다. 추가해야 한다. |
demographics.age | DM.AGE + AGEU = "YEARS" | 나이 산출 기준(동의 시점인지 무작위배정 시점인지)을 기록한다. |
demographics.sex | DM.SEX | 타입은 "Other"를 허용하지만 SDTM 용어는 M, F, U, UNDIFFERENTIATED이므로 매핑을 정의해야 한다. |
demographics.race, ethnicity | DM.RACE, DM.ETHNIC | 자유 텍스트를 통제 용어로 바꾼다("Asian" → ASIAN). |
consentDate | DM.RFICDTC | 동의 일자. |
randomization.treatment | DM.ARM/ARMCD, ACTARM/ACTARMCD | 계획 투여군과 실제 투여군. 눈가림 해제 전에는 가려 둔다. |
randomization.stratification | ADSL 층 변수(필요하면 SUPPDM) | 라벨이 아니라 코드화된 수준을 저장한다. |
visits[].visitName, visitId | VISIT, VISITNUM | VISITNUM은 숫자이며 정렬 가능해야 한다. |
visits[].visitDate | --DTC, --DY 파생 | 시험일에는 0일이 없다. |
measurements.hba1c, fasting_glucose | LB 행, 혈당은 LBFAST = "Y" | "fasting_glucose"라는 키는 조건을 품고 있다. SDTM은 이를 LBFAST로 옮긴다. |
measurements.weight, sbp, dbp | VS 행: WEIGHT, SYSBP, DIABP | JSON에서는 검사값과 함께 있어도 활력징후다. |
다음 TypeScript 코드는 알려진 측정 키를 각각의 도메인·검사 코드·단위로 보내고, 혈당을 mmol/L로 환산하며, 시험일을 파생하고, 도메인별 일련번호를 매긴다. 모르는 키는 조용히 버리거나 추측하지 않고 오류를 낸다. 감사자가 기대하는 동작이 바로 이것이다.
import type { SDTMRecord } from '@wia/bio-010/types';
type Route = {
domain: 'LB' | 'VS'; testcd: string; test: string; unit: string;
fast?: 'Y';
std?: (v: number) => { n: number; u: string };
};
const GLUCOSE_MGDL_PER_MMOLL = 18.016; // 180.16 g/mol ÷ 10
const ROUTE: Record<string, Route> = {
hba1c: { domain: 'LB', testcd: 'HBA1C', test: 'Hemoglobin A1C', unit: '%' },
fasting_glucose: { domain: 'LB', testcd: 'GLUC', test: 'Glucose', unit: 'mg/dL', fast: 'Y',
std: v => ({ n: v / GLUCOSE_MGDL_PER_MMOLL, u: 'mmol/L' }) },
weight: { domain: 'VS', testcd: 'WEIGHT', test: 'Weight', unit: 'kg' },
sbp: { domain: 'VS', testcd: 'SYSBP', test: 'Systolic Blood Pressure', unit: 'mmHg' },
dbp: { domain: 'VS', testcd: 'DIABP', test: 'Diastolic Blood Pressure', unit: 'mmHg' },
};
// SDTM 시험일: 기준 시작일이 1일이며 0일은 없다.
function studyDay(dtc: string, rfstdtc: string): number {
const d = (Date.parse(dtc) - Date.parse(rfstdtc)) / 86_400_000;
return d >= 0 ? d + 1 : d;
}
export function mapVisit(
studyId: string, usubjid: string, rfstdtc: string, visitNum: number,
visit: { visitName: string; visitDate: string; measurements?: Record<string, number> },
): SDTMRecord[] {
const seq = { LB: 0, VS: 0 };
const rows: SDTMRecord[] = [];
for (const [key, value] of Object.entries(visit.measurements ?? {})) {
const r = ROUTE[key];
if (!r) throw new Error(`No SDTM route for measurement "${key}"`);
const std = r.std ? r.std(value) : { n: value, u: r.unit };
const p = r.domain;
rows.push({
STUDYID: studyId, DOMAIN: p, USUBJID: usubjid, SEQ: ++seq[p], // 내보낼 때 LBSEQ/VSSEQ로
[`${p}TESTCD`]: r.testcd, [`${p}TEST`]: r.test,
[`${p}ORRES`]: String(value), [`${p}ORRESU`]: r.unit,
[`${p}STRESC`]: std.n.toFixed(2).replace(/\.?0+$/, ''),
[`${p}STRESN`]: std.n, [`${p}STRESU`]: std.u,
...(r.fast ? { LBFAST: r.fast } : {}),
VISITNUM: visitNum, VISIT: visit.visitName.toUpperCase(),
[`${p}DTC`]: visit.visitDate, [`${p}DY`]: studyDay(visit.visitDate, rfstdtc),
});
}
return rows;
}
const baseline = { visitName: 'Baseline', visitDate: '2025-02-01',
measurements: { hba1c: 8.5, fasting_glucose: 165, weight: 85.2, sbp: 132, dbp: 84 } };
for (const r of mapVisit('STUDY01', 'STUDY01-001', '2025-02-01', 1, baseline)) {
const p = r.DOMAIN;
console.log([p, r.SEQ, r[p + 'TESTCD'], r[p + 'ORRES'], r[p + 'ORRESU'],
r[p + 'STRESC'], r[p + 'STRESU'], r[p + 'DY']].join(' | '));
}
기대 출력:
LB | 1 | HBA1C | 8.5 | % | 8.5 | % | 1
LB | 2 | GLUC | 165 | mg/dL | 9.16 | mmol/L | 1
VS | 1 | WEIGHT | 85.2 | kg | 85.2 | kg | 1
VS | 2 | SYSBP | 132 | mmHg | 132 | mmHg | 1
VS | 3 | DIABP | 84 | mmHg | 84 | mmHg | 1
LBSTRESC는 표시용으로 반올림되었지만 LBSTRESN은 전체 정밀도(9.1585…)를 유지한다는 점을 보라. 하류의 파생은 숫자형 값을 써야 한다. 두 번 반올림하는 것은 의뢰자의 표와 검토자의 재계산 사이에 작고 설명하기 어려운 차이를 만드는 전형적인 원인이다. 실제 운영용 매퍼라면 일련번호 계수도 호출마다 1에서 다시 시작하지 않고 방문을 넘어 이어져야 한다. --SEQ는 도메인 전체에서 피험자별로 고유해야 하기 때문이다.
인구학·동의·무작위배정 묶음으로 만든 DM 레코드는 한 행이다(일부 변수만 보인다. SITEID "101"과 ARMCD "DRUGX"는 WIA 객체가 제공하지 않는 자리표시 값이다).
| USUBJID | SUBJID | SITEID | RFSTDTC | AGE | AGEU | SEX | RACE | ETHNIC | ARMCD | ARM |
|---|---|---|---|---|---|---|---|---|---|---|
| STUDY01-001 | 001 | 101 | 2025-02-01 | 58 | YEARS | M | ASIAN | NOT HISPANIC OR LATINO | DRUGX | Drug X |
VS와 LB 행은 위 매퍼가 출력한 것이며, 각각 STUDYID = STUDY01, VISITNUM = 1, VISIT = BASELINE과 수집 일자를 갖는다. 혈당 LB 행에는 LBFAST = Y가 붙는다. JSON 키 fasting_glucose에 무슨 일이 일어났는지 보라. "공복"이라는 조건이 검사 이름에서 빠져나와 독립된 변수가 되었다. 그래서 다른 방문의 비공복 혈당은 서로 무관한 별개의 측정이 아니라, 같은 검사(GLUC)에 다른 한정자가 붙은 것이 된다.
(예시 값) 12주 방문일인 2025-04-26(시험일 85일)에 이 피험자의 HbA1c가 7.9 %, 공복 혈당이 142 mg/dL였다고 하자. 12주 LB 행은 같은 매퍼로, 예컨대 VISITNUM = 3로 만든다. 그러면 ADLB 레코드는 다음과 같다.
| USUBJID | PARAMCD | AVISIT | ADY | AVAL | ABLFL | BASE | CHG | PCHG |
|---|---|---|---|---|---|---|---|---|
| STUDY01-001 | HBA1C | Baseline | 1 | 8.5 | Y | 8.5 | ||
| STUDY01-001 | HBA1C | Week 12 | 85 | 7.9 | 8.5 | −0.6 | −7.06 | |
| STUDY01-001 | GLUC | Baseline | 1 | 9.1585 | Y | 9.1585 | ||
| STUDY01-001 | GLUC | Week 12 | 85 | 7.8819 | 9.1585 | −1.2766 | −13.94 |
계산은 손으로 확인할 수 있다. CHG = 7.9 − 8.5 = −0.6이고 PCHG = 100 × (−0.6) / 8.5 = −7.06 %다. 혈당은 142 / 18.016 = 7.8819 mmol/L이므로 CHG = 7.8819 − 9.1585 = −1.2766 mmol/L이고 PCHG = −13.94 %다. 이 값은 원래 척도에서 계산한 (142 − 165) / 165와 같은데, 환산이 순수한 척도 배수이므로 당연한 결과다. 기저치 레코드의 CHG와 PCHG를 비워 두는 것은 흔한 관행이다(CHG를 0으로 채우는 의뢰자도 있으며, 문서화만 되어 있다면 어느 쪽이든 받아들여진다). §7.1 시험의 일차 평가변수는 52주 값이다. 52주 레코드도 똑같은 방식으로 파생되며, 분석은 방문 라벨이 아니라 분석 플래그로 그 레코드를 고른다.
USUBJID는 제출에 통합되는 여러 시험 사이에서 절대 겹치면 안 된다. SDK의 enrollPatient는 시험 ID 뒤에 무작위 세 자리 숫자를 붙여 피험자 ID를 만든다. 가능한 접미사가 1,000개뿐이므로, 생일 문제 근사식 √(2 × 1000 × ln 2) ≈ 37에 따르면 약 37명을 등록한 뒤부터는 충돌이 일어날 확률이 그렇지 않을 확률보다 커진다. 피험자 번호는 중앙 계수기에서 기관별로 순차 할당하고, USUBJID는 시험–기관–피험자 형식으로 만든다.Date를 toISOString()으로 직렬화하면 "2025-01-31T15:00:00.000Z"가 된다. 이 문자열에서 날짜 부분만 잘라 쓰면 방문이 전날로 옮겨 가고 시험일도 함께 밀린다. 부분 날짜("2025-02")는 SDTM에서 적법하며, 지어낸 일자로 채워 넣으면 안 된다. 대체(imputation)가 필요하다면 ADaM에서 대체 플래그와 함께 한다.fasting_glucose여서 매퍼가 mg/dL로 가정하는 경우다. 값과 함께 단위를 싣거나, 단위가 설정되지 않은 원천의 값은 거부한다.--ORRES는 항상 받은 그대로 보존한다. 이를 표준화된 숫자로 덮어쓰면 실태조사관이 요구할 수 있는 증거가 사라진다.BASE를 결정하는 것은 라벨이 아니라 ADaM 규칙이다.measurements에 HbA1c, hba1c, a1c 같은 키가 공존할 수 있다. 위와 같은 명시적 라우팅 표는 이 조용한 불일치를 눈에 보이는 오류로 바꿔 준다.다음 점검 항목은 WIA-BIO-010 데이터 모델의 출력을 하류에서 신뢰하기 전에 충족해야 할 조건을 요약한 것이다. 명세 §3과 §7, 그리고 CDISC 관행에서 이끌어 낸 이 책의 권고이며, v1.0 자체가 적합성 요건으로 명시한 것은 아니다.
| # | 점검 항목 | 근거 |
|---|---|---|
| 1 | 시험 식별자의 이름이 하나로 문서화되어 있고, 그 값이 STUDYID와 같다. | §7.1, ClinicalTrial.id |
| 2 | 모든 피험자에 대해 피험자·동의 일자·상태·기관이 명시적으로 저장된다. | PatientEnrollment, SDTM DM |
| 3 | 모든 측정 키에 단위와 대상 도메인이 정의되어 있고, 모르는 키는 거부된다. | §7.2, §3.1 |
| 4 | 날짜는 ISO 8601 텍스트로 교환되며, 시험일은 입력하지 않고 파생한다. | SDTM --DTC, --DY |
| 5 | 원래 결과는 --ORRES/--ORRESU에 보존되고, 표준 단위는 검사마다 고정된다. | §3.2 |
| 6 | 단위 환산은 검사·단위 쌍 기준의 표로 이루어지며, 모르는 쌍에서는 실패한다. | §3.2 혈당 예시 |
| 7 | 기저치·CHG·PCHG는 분석계획서에 적힌 규칙을 따르고 ABLFL이 설정된다. | §3.3 |
| 8 | 모든 ADaM 값은 식별 가능한 SDTM 행으로 추적되며, 파생은 데이터셋 메타데이터에 기술된다. | §3.3 |
| 9 | 투여군 배정은 눈가림 해제 전까지 눈가림 대상 역할에게 보이지 않는다. | §7.2 randomization |
| 10 | 시험에 사용한 CDISC 버전과 통제 용어 배포판이 기록된다. | §10.2 |
핵심 레코드가 갖춰졌으니, 다음 장에서는 데이터 가운데 규제상 가장 무거운 부분으로 넘어간다. 이상사례, 그 MedDRA 코딩과 등급, 그리고 보고를 지배하는 기한이다.