제2장

제2장: WIA-BIO-017 핵심 데이터 모델

자주 바뀌지 않는 기록들 — 시설, 사람, 교육, 그리고 이들을 묶는 타입을 다룬다.

제1장에서는 기관의 생물안전 관리가 상당 부분 기록의 문제라고 주장하였다. 이 장은 그 주장을 구조로 옮긴다. WIA-BIO-017이 참조 TypeScript SDK(api/typescript/src/types.ts)에서 직접 정의하거나 워크플로를 통해 암묵적으로 전제하는 엔티티를 하나씩 살펴보고, 연구시설 등록 담당 부서나 IBC 사무국, 소프트웨어 개발팀이 이 엔티티를 일관되고 교환 가능한 형태로 보관하는 방법을 보인다.

범위는 의도적으로 좁게 잡는다. 이 장은 정적 레코드, 즉 시설, 그 시설에서 일하도록 승인된 사람, 그들이 이수한 교육, 그리고 다른 모든 레코드가 재사용하는 열거형과 결과 타입만 다룬다. 사고 워크플로는 제3장, 서비스 인터페이스는 제4장, 교환과 감사 추적은 제5장, 필드 수준 보안은 제6장의 주제이다. 주제가 이 경계를 넘을 때에는 이름만 밝히고 해당 장으로 넘긴다.

책 전체에 적용되는 경계가 하나 더 있다. 데이터 모델은 자격을 갖춘 사람과 관계 당국이 내린 결정을 기록한다. 시설의 등록 등급, 인증기관의 확인 결과, IBC 승인, 역량 평가 결과가 그러한 결정이다. 그 결정에 이르는 기술적 판단 근거는 데이터 모델에 담지 않는다. SDK에 관리 데이터베이스로 기술적 안전 정보를 끌어들이는 타입이 있으면, 이 장은 그 사실을 밝히고 해당 필드를 빼는 방법을 제시한다.

2.1 엔티티 지도

생물안전 기록 시스템에는 SDK 타입 목록이 보여 주는 것보다 많은 구성 요소가 있다. v1.0 참조 SDK는 BiosaftyFacility, TrainingRecord, IncidentReport, ComplianceResult를 독립 인터페이스로 선언하지만, 사람, IBC 승인, 저장된 위해성 평가 문서, 감사 이벤트를 위한 타입은 없다. 인원은 authorizedPersonnel, personnel, personnelId 안의 문자열 식별자로만 등장한다. 가리키는 대상이 없는 식별자로는 감사를 할 수 없으므로, 이 책은 얇은 엔티티 네 개를 추가하여 그림을 완성한다. 이 엔티티들은 등장할 때마다 이 책의 관리 프로파일이라고 표시하여 v1.0 규격의 규범적 부분으로 오해되지 않게 한다.

아래 그림은 어떤 레코드가 어떤 레코드를 참조하는지 보여 준다. 규칙은 단순하며 의도적이다. 레코드는 다른 레코드를 내장하지 않고 그 식별자만 저장한다. 이렇게 하면 각 레코드가 단독으로 검증할 수 있을 만큼 작게 유지되고, 부서마다 서로 다른 레코드를 소유할 수 있으며, 한 종류의 레코드(예: 교육 이수 증명)만 나머지 그래프 없이 교환할 수 있다.

                     approvedPersonnelIds[]
  Authorization (AUT-) ─────────────────────────────┐
   │  IBC approval                                   │
   │ facilityId                                      ▼
   ▼                     authorizedPersonnel[]
  BiosaftyFacility (FAC-) ─────────────────▶  Personnel (PER-)
   ▲         ▲                                     ▲
   │         │ facilityId                          │ personnelId
   │         │                                     │
   │   IncidentReport (INC-)              TrainingRecord (TRN-)
   │         (워크플로: 제3장)
   │ facilityId, authorizationId
   │
  RiskAssessmentRecord (RAR-) ──authorizationId──▶ Authorization
  ComplianceResult     (CHK-) ──facilityId, authorizationId──▶ ...
  AuditEvent           (AUD-) ──recordRef──▶ any record above
                             (해시 체인: 제5장)

  A ──필드──▶ B   :  레코드 A가 "필드"에 레코드 B의 ID를 저장한다

화살표는 "~의 ID를 보관한다"로 읽는다. Authorization은 승인된 작업이 이루어지는 공간의 facilityId와 승인 대상자의 PER- 식별자를 보관한다. TrainingRecord는 personnelId를, BiosaftyFacility는 출입이 허용된 사람의 목록을 보관한다. 사람에서 시설로 향하는 참조는 없다. 그 관계는 조회로 도출하며, 이렇게 해야 시설의 출입자 명단과 개인 프로필이라는 두 목록이 서로 어긋나는 고질적인 문제를 피할 수 있다.

엔티티출처ID 접두사보관하는 ID일반적 소유 부서
BiosaftyFacilitySDK 타입FAC-Personnel, Authorization, 물질 레지스트리생물안전 담당 부서
Personnel책 프로파일PER-(없음. 인사 디렉터리에서 해석)인사·연구행정 부서
TrainingRecordSDK 타입 + 레코드 IDTRN-Personnel(교육생, 평가자)교육 담당자
Authorization책 프로파일AUT-Facility, Personnel, 물질 레지스트리IBC 사무국
RiskAssessmentRecord책 프로파일RAR-Authorization, Facility, Personnel(평가자)연구책임자 작성, IBC 검토
IncidentReportSDK 타입INC-Facility, Personnel생물안전관리책임자
ComplianceResultSDK 타입(ID를 붙여 저장)CHK-Facility, Authorization시스템(자동 생성)
AuditEvent책 프로파일AUD-모든 레코드시스템(추가 전용)

이 가운데 세 가지는 이후 장들이 의존하므로 여기서 간단히 설명한다.

프로필이 아닌 참조로서의 Personnel

Personnel 엔티티는 다른 모든 레코드가 PER-7Q2K9처럼 안정적인 가명 식별자를 가리킬 수 있도록 존재한다. 이름, 연락처, 재직 상태, 그리고 의료 관련 정보는 이미 그 정보를 소유한 시스템(인사 시스템, 학습관리시스템, 보건관리 부서)에 그대로 둔다. 생물안전 기록 시스템은 PER- 식별자와 그 시스템들을 잇는 매핑 표만 유지하고, 화면에 표시해야 할 때에만 이름을 조회한다. 제6장에서 자세히 다루는 개인정보 보호의 대부분이 이 설계 하나로 해결된다.

Authorization: 레코드로서의 IBC 결정

대부분의 기관에서 IBC 승인은 모든 것을 묶는 문서이다. 연구책임자, 승인된 참여 인원, 공간, 작업이 승인된 등록 등급, 유효 기간이 모두 여기에 적힌다. 현실에서는 이메일에 첨부된 PDF로 존재하는 경우가 많고, SDK에도 이를 담을 타입이 없다. 이 책의 프로파일은 최소한의 형태를 부여한다. authorizationId, 위원회 자체의 승인 번호, facilityId, approvedLevel(BiosaftyLevel 값), principalInvestigatorId, approvedPersonnelIds[], materialRefs[](2.5절 참조), validFrom, validUntil, status가 그것이다. 위원회의 심의 내용은 회의록에 남기고, 레코드에는 결과만 담는다.

RiskAssessmentRecord, ComplianceResult, AuditEvent

RiskAssessmentRecord는 기관 위해성 평가 문서의 메타데이터(평가자, 평가일, 결과 등급, 다음 검토일)만 저장하며 평가의 기술적 입력값은 저장하지 않는다. SDK의 점수 함수를 불투명한 훅으로 취급하는 이유는 제4장에서 설명한다. ComplianceResult는 SDK 검증기가 반환하는 값이다. 프로파일은 여기에 ID, 시각, 검사 대상 레코드의 ID를 붙여 저장할 뿐이며, 그 덕분에 점검자는 시스템이 언제 어떤 결론을 냈는지 확인할 수 있다. AuditEvent는 다른 모든 레코드의 변경을 기록한다. 그 구조와 해시 체인은 제5장에서 다루며, 여기서는 이 장의 모든 레코드가 생성·수정될 때마다 감사 이벤트를 남긴다는 점만 알아 두면 된다.

2.2 BiosaftyFacility 레코드

시설 레코드는 점검자가 가장 먼저 묻는 질문에 답한다. 이 공간은 어디인가, 어떤 등급으로 등록되어 있는가, 누가 출입할 수 있는가, 인증은 유효한가. SDK 인터페이스는 필드를 여섯 묶음으로 나눈다. 아래 표는 각 필드와 v1.0 SDK에서의 타입, 그리고 관리 프로파일에서의 처리 방식을 정리한 것이다.

필드SDK 타입필수프로파일 처리
facilityIdstring예변하지 않으며 재사용하지 않음. FAC- 접두사(책의 관례)
namestring예사람이 읽는 명칭. 식별자로 쓰지 않음
bslLevelBiosaftyLevel예당국 결정에서 옮겨 적은 등록 등급
location.building, location.roomstring예기관 내부 건물·호실 코드
location.addressstring?아니오외부 교환 사본에서는 제외(제6장)
accessControls.restrictedAccessboolean예승인된 사람만 출입 가능한지 여부
accessControls.selfClosingDoors, lockableDoorsboolean예확인된 그대로 기록하며 해석하지 않음
accessControls.accessLogboolean예출입 기록이 실제로 존재하고 조회 가능할 때만 true
accessControls.biometricAccessboolean?아니오유무 표시만. 생체정보는 이 레코드에 들어오지 않음
certification.certifiedboolean예당국의 결정이며 계산값이 아님
certification.certificationDate, expirationDateDate?아니오certified가 true이면 프로파일에서 필수
certification.certifyingAuthoritystring?아니오certified가 true이면 프로파일에서 필수
certification.nextInspectionDateDate?아니오알림의 기준. 당국 일정에서 가져옴
engineeringControls객체예인증기관이 확인한 불투명 체크리스트
authorizedAgentsBiologicalAgent[]예materialRefs[]로 대체(2.5절)
authorizedPersonnelstring[]예PER- 식별자만

식별과 위치

facilityId는 등록된 공간을 그 수명 전체에 걸쳐 식별한다. 공간을 리모델링하여 다른 등급으로 다시 등록하면 레코드를 갱신하고 그 변경을 감사 추적에 남기되 ID는 유지한다. 한 공간이 둘로 나뉘어 각각 등록되면 기존 ID는 폐기하고 새 ID 두 개를 발급하며, 각각에 이전 ID를 가리키는 메모를 붙인다. 폐기된 ID를 새 공간에 재사용하는 것은 레지스트리가 저지를 수 있는 가장 치명적인 실수이다. 옛 ID를 가리키던 과거의 사고, 점검, 승인 기록이 모두 조용히 새 공간에 붙어 버리기 때문이다.

location 묶음은 의도적으로 거칠게 설계되어 있다. 기관 안에서 통하는 건물 코드와 호실 코드면 충분하다. 선택 필드인 address는 여러 캠퍼스에 걸친 내부 레지스트리에서는 유용하지만, 등록 공간 목록을 지도로 바꾸는 필드이기도 하다. 프로파일은 이 필드를 기관 내부에만 두고 외부로 교환하는 모든 사본에서 제거한다. 이 규칙은 제6장의 위협 모델에서 더 자세히 다룬다.

기록된 사실로서의 출입 통제

accessControls 묶음에는 플래그 다섯 개가 있다. 이 값들은 마지막 점검에서 확인된 공간에 관한 사실이며, 기록 시스템도 그렇게 다루어야 한다. 시스템은 이 값을 저장하고 날짜를 붙여 점검자에게 보여 줄 뿐, 특정 조합이 충분한지 스스로 판단하지 않는다. 그 판단은 인증기관과 생물안전관리책임자의 몫이다.

구현에서 주의할 플래그가 두 개 있다. accessLog: true는 출입 기록이 존재하며 제출할 수 있다는 약속이다. 출입통제 장치의 기록이 짧은 기간 뒤 순환 삭제된다면 이 플래그는 사실을 오도한다. 제6장에서는 출입통제 시스템의 내보내기 데이터를 이 레코드에 연결하여 플래그가 체크박스가 아닌 실제 연동 상태를 반영하게 하는 방법을 다룬다. biometricAccess는 유무 표시일 뿐이다. 생체 인식 템플릿은 대부분의 법역에서 민감한 개인정보이며 생물안전 레지스트리에 들어올 자리가 없다.

인증: 다른 주체의 결정을 기록하기

certification 묶음은 소프트웨어가 권한을 넘어서기 가장 쉬운 곳이다. SDK에서는 certified를 제외한 모든 필드가 선택 항목이므로, 날짜도 만료일도 인증기관도 없이 certified: true라고 주장하는 레코드가 만들어질 수 있다. 관리 프로파일은 조건부 규칙으로 이 틈을 막는다. certified가 true이면 certificationDate, expirationDate, certifyingAuthority가 필수가 된다. 2.6절에서 이 규칙을 JSON Schema의 if/then 구문으로 보인다.

이 묶음은 당국의 결정을 기록할 뿐 대신하지 않는다. 체크리스트가 다 채워졌다는 이유로 시스템이 스스로 certified를 true로 바꾸어서는 안 되며, 날짜가 지났다는 이유로 false로 바꾸어서도 안 된다. 대신 시스템은 화면 표시용으로 명확히 구분된 별도 상태(예: "기록된 날짜 기준 인증 만료")를 도출하고, 사람이 조치할 수 있도록 지적 사항을 올린다. 제7장에서 이 구분을 다시 다룬다. WIA 소프트웨어 적합성은 시설 인증이 아니다.

레코드 전체의 값어치를 만드는 필드는 nextInspectionDate이다. 이 필드가 일관되게 채워지면 레지스트리는 다음 분기에 점검이 도래하는 공간을 모두 나열할 수 있고, IBC 사무국은 어떤 승인도 그것이 의존하는 공간의 인증 만료일을 넘어서지 않는지 확인할 수 있다. 단순한 조회이지만 날짜가 존재하고 모호하지 않은 단일 형식으로 저장되어 있을 때에만 가능하다.

engineeringControls: 불투명 체크리스트

SDK에는 불리언 값과 몇 개의 열거 값으로 이루어진 engineeringControls 객체가 있다. 이 책은 이 묶음을 불투명한 것으로 취급한다. 그 내용은 인증기관이 점검 과정에서 정의하고 확인하는 것이며, 관리 시스템의 역할은 공학적 내용을 설명하거나 평가하는 것이 아니라 확인 결과와 그 출처 문서의 참조를 보관하는 것이다. 따라서 이 책은 개별 항목을 설명하지 않는다.

관리 프로파일에서 이 묶음은 확인서(attestation) 형태로 담긴다. 누가 확인했는지(attestedBy), 전체 체크리스트가 어느 문서에 있는지(checklistDocumentId), 확인이 현재 유효한지(attested)이다. 애플리케이션이 SDK의 구조화된 형태를 필요로 한다면 어댑터가 인증기관의 파일에서 값을 그대로 옮겨 온다. 어댑터는 값을 추론하거나 기본값을 채우거나 "고치지" 않는다. 빠진 불리언을 false로 채우는 어댑터는 레코드에 거짓 음성을 만들고, true로 채우는 어댑터는 그보다 더 나쁜 것을 만든다.

authorizedPersonnel

출입자 명단은 PER- 식별자의 목록이다. 이 목록은 해당 공간에 적용되는 유효한 승인과, 생물안전관리책임자가 부여한 상시 출입(예: 동행 조건의 시설관리 인력)으로부터 도출할 수 있어야 한다. 순수 조회 결과가 아니라 저장 필드로 두는 것은 현실적인 타협이다. 출입통제 장치와 점검자는 평면 목록을 원하기 때문이다. 그 대가는 불일치이므로, 프로파일은 제4장의 검증기가 확인할 수 있는 일관성 규칙 하나를 추가한다. 명단에 있는 모든 사람은 이 시설에 대한 유효한 승인이나 문서화된 상시 출입 항목 중 적어도 하나에 나타나야 하고, 그러한 근거를 가진 모든 사람은 명단에 있어야 한다.

2.3 TrainingRecord 교육 이수 기록

교육 이수 기록은 생물안전 시스템에서 가장 많은 레코드이며 감사 때 가장 자주 요구되는 레코드이다. SDK 인터페이스는 간결하고 대부분의 기관 관행과 잘 맞는다. 아래 표는 필드를 정리한 것이다. 프로파일은 필드 하나, trainingRecordId를 추가한다. SDK 타입에는 레코드 자체의 식별자가 없는데, 참조할 수 없는 레코드는 정정할 수도, 교환할 수도, 감사할 수도 없기 때문이다.

필드SDK 타입필수비고
trainingRecordId(프로파일)예TRN- 접두사. 1인 1회 이수당 ID 하나
personnelIdstring예가명 PER- 식별자
namestring예(SDK)표시할 때 디렉터리에서 조회. 교환 사본에는 저장하지 않음
trainingType열거형예initial | refresher | agent-specific | emergency | equipment
trainingDateDate예이수일(UTC)
expirationDateDate?아니오이수 자격이 유효하지 않게 되는 시점
topicsCoveredstring[]예과정·모듈 제목. 개정 번호를 함께 적는 것이 좋음
competencyAssessment.passedboolean묶음이 있으면평가자의 판정
competencyAssessment.scorenumber?아니오척도는 과정이 정하며 표준이 정하지 않음
competencyAssessment.assessorstring묶음이 있으면평가자의 PER- 식별자
certificationIssuedboolean예이 이수에 대해 수료증이 발급되었는지
certificateNumberstring?아니오발급 기관의 번호. 기관 간 이동의 근거(제5장)
nextTrainingDueDate?아니오정책에 따른 계획일. expirationDate보다 앞설 수 있음

교육 유형

trainingType의 다섯 값은 내용이 아니라 목적을 나타낸다. initial은 모든 승인에 앞서는 신규 교육이다. refresher는 이미 있는 이수 자격을 갱신한다. agent-specific은 특정 물질이나 과제에 결부된 교육으로, 관리 레코드에서는 관련된 승인이나 물질 레지스트리 항목을 참조해야 하며 주제 목록에는 기술적 설명 대신 과정 제목을 적는다. emergency는 기관의 비상 절차 교육, equipment는 특정 장비 사용 교육이다. 레코드는 범주만 담고, 교육 자료 자체는 학습관리시스템에 둔다.

trainingType은 닫힌 열거형이므로, 더 세분된 범주(예: 학생용과 직원용 신규 교육의 구분)를 가진 기관은 SDK 값을 그대로 쓰고 세부 구분은 topicsCovered나 프로파일 확장 필드에 넣어야 한다. 열거 값을 기관 내부에서 임의로 추가하면 표준 목록으로 검증하는 모든 협력 기관과의 교환이 깨진다.

만료일, 예정일, 그리고 "유효"

레코드에는 헷갈리기 쉬운 날짜가 두 개 있다. expirationDate는 이수 자격이 효력을 잃는 시점이고, nextTrainingDue는 재교육을 받아야 할 시점이다. 합리적인 정책이라면 일정 조율의 여유를 두기 위해 후자를 만료일보다 조금 앞에 둔다. 두 날짜 모두 WIA-BIO-017이 규정하지 않는다. 보수교육 주기는 기관 정책과 관계 당국의 요구 사항에 따르는 문제이며, 이 책은 주기를 제시하지 않는다. 소프트웨어의 기본값도 마찬가지로 주기를 정해서는 안 된다. 시스템은 생물안전관리책임자가 관리하는 설정에서 주기를 읽어야 한다.

따라서 "유효"는 도출된 상태이다. 필수 유형별 최신 기록, 평가 결과, 만료일을 명시된 시점을 기준으로 평가하여 계산한다. 아래 함수는 SDK의 ValidationResult 형태를 이용한 계산 예이다. 이는 레코드 일관성 검사로서, 기록상 그 사람이 유효한 이수 상태인지를 알려 줄 뿐 그 사람이 실제로 역량을 갖추었는지를 판정하지 않는다.

import type { TrainingRecord, ValidationResult } from './biosafety-types';

// 정책 값은 WIA-BIO-017이 아니라 기관 생물안전 관리규정에서 온다.
// 아래 숫자는 예시일 뿐이다.
interface TrainingPolicy {
  requiredTypes: TrainingRecord['trainingType'][];
  warnDaysBeforeExpiry: number;
}

export const EXAMPLE_POLICY: TrainingPolicy = {
  requiredTypes: ['initial', 'emergency'],
  warnDaysBeforeExpiry: 30,
};

export function checkTrainingCurrency(
  personnelId: string,
  records: TrainingRecord[],
  policy: TrainingPolicy,
  asOf: Date = new Date()
): ValidationResult {
  const errors: string[] = [];
  const warnings: string[] = [];
  const info: string[] = [];
  const mine = records.filter((r) => r.personnelId === personnelId);

  for (const type of policy.requiredTypes) {
    // 같은 유형 중 가장 최근 기록이 판정 기준이고, 이전 기록은 이력으로 남는다.
    const latest = mine
      .filter((r) => r.trainingType === type)
      .sort((a, b) => +new Date(b.trainingDate) - +new Date(a.trainingDate))[0];

    if (!latest) {
      errors.push(`${personnelId}: no '${type}' training on record`);
      continue;
    }
    if (latest.competencyAssessment && !latest.competencyAssessment.passed) {
      errors.push(`${personnelId}: latest '${type}' assessment not passed`);
    }
    if (!latest.expirationDate) {
      info.push(`${personnelId}: '${type}' has no expirationDate; policy decides currency`);
      continue;
    }
    const daysLeft = (+new Date(latest.expirationDate) - +asOf) / 86_400_000;
    if (daysLeft < 0) {
      errors.push(`${personnelId}: '${type}' expired on ${new Date(latest.expirationDate).toISOString().slice(0, 10)}`);
    } else if (daysLeft <= policy.warnDaysBeforeExpiry) {
      warnings.push(`${personnelId}: '${type}' expires in ${Math.ceil(daysLeft)} days`);
    }
  }
  return { isValid: errors.length === 0, errors, warnings, info };
}

이 함수에서 코드 자체보다 중요한 설계 선택이 세 가지 있다. 첫째, 평가 시점이 매개변수이므로 감사인이 "사고 당일에 이 사람은 유효한 상태였는가?"라고 물으면 재현 가능한 답을 얻을 수 있다. 둘째, 만료일이 없으면 조용히 통과시키지 않고 info 메시지를 남긴다. 그래서 데이터에 드러나지 않는 정책에 유효 여부가 달린 레코드가 표면에 드러난다. 셋째, 첫 문제에서 멈추지 않고 찾은 문제를 모두 반환한다. 레코드를 정리하는 교육 담당자에게는 전체 목록이 필요하다.

역량 평가

선택 묶음인 competencyAssessment는 평가의 결과, 즉 통과 여부, 선택적 점수, 평가자를 기록한다. 점수의 척도는 과정이 정하며 표준은 그 의미를 정의하지 않으므로, 과정 간이나 기관 간 점수 비교는 지원하지 않는다. 평가자도 다른 사람과 마찬가지로 PER- 식별자이므로, 평가자 자신이 유효한 이수 자격을 갖추었는지 확인할 수 있다. 점검자가 실제로 묻는 질문이다.

다음은 관리 프로파일로 저장한 교육 이수 기록의 예이다. 날짜와 숫자는 예시이다.

{
  "schemaVersion": "1.0.0",
  "recordType": "training",
  "trainingRecordId": "TRN-2026-00481",
  "personnelId": "PER-7Q2K9",
  "trainingType": "refresher",
  "trainingDate": "2026-02-10T05:00:00Z",
  "expirationDate": "2027-02-10T05:00:00Z",
  "topicsCovered": [
    "Institutional biosafety manual, rev. 4",
    "Incident and near-miss reporting procedure",
    "Emergency contact list"
  ],
  "competencyAssessment": {
    "passed": true,
    "score": 88,
    "assessor": "PER-0BSO1"
  },
  "certificationIssued": true,
  "certificateNumber": "EXU-BS-2026-0481",
  "nextTrainingDue": "2027-01-10T00:00:00Z"
}

2.4 열거형과 결과 타입

모델의 모든 레코드는 소수의 열거형과 두 가지 결과 패턴을 재사용한다. 분량이 짧으므로 참조 SDK에서 인용한다. 타입 선언은 원문과 같고, 주석은 이 책이 붙인 것이다.

// Excerpt from api/typescript/src/types.ts (WIA-BIO-017 v1.0 reference SDK)
export type BiosaftyLevel = 'BSL-1' | 'BSL-2' | 'BSL-3' | 'BSL-4';
export type RiskGroup = 1 | 2 | 3 | 4;
export type SeverityLevel = 'minor' | 'moderate' | 'serious' | 'critical';
export type RiskLevel = 'low' | 'medium' | 'high' | 'extreme';

export type Result<T, E = Error> =
  | { success: true; data: T }
  | { success: false; error: E };

export interface ValidationResult {
  isValid: boolean;   // false when errors.length > 0
  errors: string[];   // blocking
  warnings: string[]; // non-blocking
  info: string[];
}
타입값등장 위치레코드에서의 의미
BiosaftyLevel'BSL-1'~'BSL-4'(문자열)Facility, Authorization, RiskAssessmentRecord등록되거나 승인된 생물안전 등급
RiskGroup1~4(숫자)물질 참조당국 목록에 따른 분류 라벨
SeverityLevelminor, moderate, serious, critical화면 표시, 보고사고 심각도의 이름
(숫자 심각도)1~4IncidentReport.severitySeverityLevel과 같은 척도를 숫자로 저장
RiskLevellow, medium, high, extremeSDK 점수 훅의 출력라벨일 뿐임. 아래 주의 사항 참조

등급과 위험군은 종류가 다른 값이다

BiosaftyLevel은 문자열 열거형이고 RiskGroup은 숫자 열거형이며, 이 차이는 유용하다. 등급은 공간과 그 운영 방식을 나타내고, 위험군은 당국의 목록이 물질에 붙이는 분류 라벨이다. 둘은 고정된 대응표가 아니라 기관의 위해성 평가와 IBC의 결정을 통해 연결된다. WHO 실험실 생물안전 매뉴얼(제4판)이 밀폐 수준을 위험 기반 평가의 결과로 설명하는 것도 같은 이유이다. 데이터 모델은 어느 한쪽에서 다른 쪽을 자동으로 도출해서는 안 된다. 둘을 서로 다른 원시 타입으로 두면 facility.bslLevel === material.riskGroup 같은 실수 비교가 조용한 버그가 아니라 타입 오류로 잡힌다.

심각도의 두 가지 표기

SDK는 심각도를 두 번 표현한다. 문자열 타입 SeverityLevel과 IncidentReport의 숫자 필드 severity: 1 | 2 | 3 | 4이다. 규격 7.1.1절은 숫자 등급에 Minor, Moderate, Serious, Critical이라는 이름을 붙이므로 대응 관계는 명확하다. 권장 방식은 정렬과 비교가 자연스러운 숫자를 저장하고, 화면 표시용 이름은 공유되는 대응표 하나(아래 별칭 파일에 포함)로 도출하는 것이다. 둘을 모두 저장하면 언젠가 두 값이 어긋나는 날이 온다.

RiskLevel에 관한 주의

RiskLevel은 SDK의 위험 점수 함수가 만들어 낸다. 네 값을 가르는 임계값은 보정되지 않았으므로(산술상의 문제는 제4장에서 설명한다), 저장된 RiskLevel은 그것을 만든 기관 밖에서는 의미가 없는 기관 내부 라벨로 취급해야 한다. 관리 프로파일은 IBC가 결정한 등급을 Authorization 레코드에 저장하고, 계산된 RiskLevel은 기껏해야 참고용 메타데이터로 다룬다.

Result와 ValidationResult

SDK는 두 가지 결과 패턴을 선언한다. Result<T, E>는 판별 유니온으로, { success: true, data } 또는 { success: false, error } 중 하나이다. ID로 레코드를 불러오는 것처럼 값을 내거나 실패하는 연산에 알맞다. ValidationResult는 한 번에 여러 문제를 찾아낼 수 있는 검사에 알맞으며, 문제를 심각도에 따라 errors(차단), warnings(비차단), info로 나눈다.

v1.0에서 두 타입은 types.ts에 선언되어 있지만 SDK 자체의 메서드는 이를 사용하지 않고, 잘못된 입력에는 BiosaftyError를 던진다. SDK 위에 만드는 레코드 수준 코드는 그래도 이 패턴을 채택할 수 있으며, 이 책은 전체에 걸쳐 그렇게 한다. 불러오기 함수는 Result를, 검증 함수는 ValidationResult를 반환하고, 예외는 프로그래밍 오류에만 쓴다. 이 관례는 시스템 경계에서 가장 중요하다. 인사 시스템에서 내보낸 천 행을 처리하는 가져오기 모듈은 첫 오류에서 중단하지 않고 잘못된 행을 모두 보고해야 한다.

ValidationResult를 신뢰할 수 있게 만드는 규칙이 하나 있다. isValid는 반드시 errors.length === 0과 같아야 한다. isValid: true이면서 errors 배열이 비어 있지 않은 결과는 그 자체로 결함이며, 이를 잡는 적합성 시험 벡터가 있어야 한다(제7장).

"Biosafty" 철자 문제

참조 SDK의 식별자 여러 개에 오타가 있다. BiosaftyLevel, BiosaftyFacility, BiosaftyErrorCode, BiosaftyError이다. 이 오타는 실제로 존재하며 공개된 인터페이스의 일부이므로, SDK에서 import하는 코드는 틀린 철자를 그대로 써야 한다. 한 모듈에서만 조용히 "고치면" import 오류가 나고, 교환용 JSON에서 고치는 것은 아무 효과가 없으면서(타입 이름은 JSON에 나타나지 않는다) 검토자만 혼란스럽게 한다.

권장 방법은 틀린 철자를 SDK 타입을 import하는 파일 하나에 가두고, 그 파일에서 올바른 철자의 별칭을 다시 내보내는 것이다. TypeScript의 타입 별칭은 컴파일 시 지워지므로 런타임 비용이 없고 원래 타입과 완전히 대입 호환된다. 이후 SDK 버전에서 철자가 고쳐지면 그 파일 하나만 바꾸면 된다. 심각도 대응표도 이 파일에 두면 좋다.

// biosafety-types.ts : 기관 코드베이스의 단일 import 지점
import type {
  BiosaftyLevel,          // SDK 철자 그대로 유지(호환성)
  BiosaftyFacility,
  TrainingRecord,
  ValidationResult,
} from '@wia/bio-017/types';

// 올바른 철자의 별칭. 타입 수준 선언이므로 런타임 비용이 없고
// SDK 타입과 그대로 대입 호환된다.
export type BiosafetyLevel = BiosaftyLevel;
export type BiosafetyFacility = BiosaftyFacility;
export type { TrainingRecord, ValidationResult };

// 심각도: IncidentReport는 1~4 숫자를, SeverityLevel은 같은 척도의 이름을 쓴다.
export type SeverityCode = 1 | 2 | 3 | 4;
export const SEVERITY_LABEL: Record<SeverityCode, 'minor' | 'moderate' | 'serious' | 'critical'> = {
  1: 'minor',
  2: 'moderate',
  3: 'serious',
  4: 'critical',
};

import 문에 쓴 패키지 이름 @wia/bio-017은 SDK의 package.json에 선언된 이름이다. 이 책을 읽는 시점에 공개 레지스트리에서 받을 수 있는지는 직접 확인해야 하며, 예제는 그것을 전제하지 않는다.

2.5 데이터 최소화: 물질은 참조로

SDK의 BiologicalAgent 타입은 BiosaftyFacility.authorizedAgents와 IncidentReport.agent에서 쓰이며, 생물학적 인자의 특성을 기술하는 필드를 담고 있다. 그러한 정보는 관계 당국이 유지하는 공식 참고 자료에 속하며 관리 레코드에서는 쓸 곳이 없다. 이를 복사하는 레지스트리는 아무도 관리하지 않는 비공식 참고 자료를 하나 더 만들 뿐 아니라, 행정이 목적인 데이터베이스에 민감한 기술 정보를 모으게 된다. 그래서 이 책은 이 타입을 사용하지 않으며, 그 안의 필드를 나열하거나 논의하지도 않는다.

관리 프로파일은 인자 객체를 물질 참조로 대체한다. 물질 참조의 필드는 정확히 두 개이다. 기관 자체의 물질·재고 레지스트리 항목을 가리키는 registryId(예: MAT-EXU-0042)와, 선택적으로 riskGroup 분류 라벨이다. 기관의 레지스트리는 다시 관계 당국의 레지스트리나 목록에 있는 해당 항목으로 연결된다. 연결 고리는 관리 레코드 → 내부 레지스트리 ID → 당국 레지스트리 순이며, 각 단계의 정확성은 그 단계를 책임지는 주체가 관리한다.

감사인이 묻는 질문답하는 필드인자 특성 정보가 필요한가
이 공간과 연결된 승인 물질은 무엇인가?Facility와 Authorization의 materialRefs[].registryId아니오
물질의 분류 라벨이 승인상 공간의 등록 등급과 부합하는가?riskGroup 라벨, bslLevel, IBC 결정아니오
이곳에서 이 물질을 다루도록 승인된 사람은 누구인가?Authorization.approvedPersonnelIds아니오
물질에 관한 공식 설명은 어디에 있는가?내부 레지스트리 항목을 거쳐 당국 레지스트리그곳에 있으며 여기에는 없음

강제는 정책 문서가 아니라 스키마에서 한다. 2.6절의 JSON Schema는 materialRefs 항목에 additionalProperties: false를 선언하므로, 참조에 설명 필드를 덧붙이려는 시도는 쓰기 시점에 검증 단계에서 실패한다. 자유 텍스트 필드에도 같은 규칙이 적용된다. 시설의 name과 교육 기록의 topicsCovered에는 기술적 설명이 아니라 행정상의 명칭을 적는다. 제6장은 자유 텍스트를 위한 비식별·삭제 점검표를 제시하고, 제3장은 같은 참조 방식을 사고 보고서에 적용한다.

2.6 JSON 직렬화 규칙

TypeScript 타입은 메모리 속 객체를 기술한다. 시스템 사이를 오가는 레코드에는 특정 프로그래밍 언어에 의존하지 않는 전송 형식과 규칙이 필요하다. 아래 규칙은 모델의 모든 레코드에 적용된다. 방향은 PHASE-1(데이터 형식)의 구현 노트와 버전 관리 부속서에서 가져왔으며, 그 부속서들이 말하지 않거나 서로 어긋나는 곳에서는 이 책의 선택을 밝히고 그렇게 표시한다.

시각 표기

SDK는 여러 필드를 Date로, IncidentReport.date는 Date | string으로 선언한다. Date 객체에는 고유한 JSON 표현이 없으므로 전송 형식에서 하나로 정해야 한다. 규칙은 다음과 같다. 모든 시각은 RFC 3339가 정한 ISO 8601 프로파일의 문자열이며, UTC 기준으로 Z 접미사를 붙인다. 예를 들면 "2026-03-02T00:00:00Z"이다. 규격의 사고 보고서 예제도 이미 이 형식을 쓴다. 현지 시각 변환은 클라이언트의 화면 표시 문제로 다룬다.

날짜만 있는 값은 따로 짚어 둘 필요가 있다. "3월 1일 만료"는 서울과 제네바에서 서로 다른 순간을 뜻한다. 프로파일은 발급 기관이 의도한 정확한 순간을 저장한다. 기관이 달력상 날짜만 밝혔다면, 가져오기 모듈이 그 날짜를 발급 기관 시간대의 그날 마지막 순간으로 변환하고 변환 사실을 감사 이벤트에 기록한다. 아래 시설 예제가 이를 보여 준다. 한국 표준시(UTC+9) 기준 3월 1일 자정 직전 만료는 2027-03-01T14:59:59Z로 저장된다.

식별자

식별자는 의미를 담지 않는 안정적인 문자열이다. 접두사 관례(FAC-, PER-, TRN-, AUT-, RAR-, INC-, CHK-, AUD-)는 이 책이 정한 것이다. 규격의 사고 보고서 예제가 INC-2025-001을 쓰므로, 이 책은 같은 방식을 다른 레코드 유형으로 넓혔다. 접두사가 있으면 잘못 들어간 ID가 로그에서 바로 눈에 띄고, 스키마가 예컨대 출입자 명단 필드에 들어온 교육 기록 ID를 거부할 수 있다. 접두사 뒤에는 바뀔 수 있는 의미를 넣지 않는 것이 좋다. 시설 ID에 호실 번호를 넣으면 편리하지만 건물 번호 체계가 바뀌는 순간 문제가 된다. 아래 예제는 읽기 쉽도록 건물·호실 코드를 넣었지만, 실제 레지스트리에서는 무작위 접미사를 선호할 수 있다. 식별자는 대소문자를 구분하며, 재사용하지 않고, 삭제된 뒤에도 다시 쓰지 않는다.

레코드가 기관의 경계를 넘으면 FAC- ID만으로는 더 이상 유일하지 않다. 제5장에서는 메시지 봉투에 발신 기관 식별자를 붙여 교환 ID를 한정한다. 한 기관 안에서는 접두사 형식만으로 충분하다.

스키마 검증

모든 레코드 유형에는 JSON Schema가 있고, 모든 쓰기는 그 스키마로 검증한다. 이 책은 JSON Schema draft 2020-12를 쓴다. 아래는 관리 프로파일의 시설 스키마 일부이다. 2.2절의 조건부 인증 규칙, 접두사 패턴, 닫힌 형태의 물질 참조가 모두 담겨 있다.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.org/schemas/wia-bio-017/facility/1.0.0",
  "title": "WIA-BIO-017 facility registration record (management profile)",
  "type": "object",
  "required": ["schemaVersion", "recordType", "facilityId", "name", "bslLevel",
               "location", "accessControls", "certification", "authorizedPersonnel"],
  "properties": {
    "schemaVersion": { "type": "string", "pattern": "^1\\.[0-9]+\\.[0-9]+$" },
    "recordType":    { "const": "facility" },
    "facilityId":    { "type": "string", "pattern": "^FAC-[A-Z0-9-]{4,40}$" },
    "bslLevel":      { "enum": ["BSL-1", "BSL-2", "BSL-3", "BSL-4"] },
    "location": {
      "type": "object",
      "required": ["building", "room"],
      "properties": {
        "building": { "type": "string" },
        "room":     { "type": "string" },
        "address":  { "type": "string" }
      }
    },
    "certification": {
      "type": "object",
      "required": ["certified"],
      "properties": {
        "certified":          { "type": "boolean" },
        "certificationDate":  { "type": "string", "format": "date-time" },
        "expirationDate":     { "type": "string", "format": "date-time" },
        "certifyingAuthority":{ "type": "string" },
        "nextInspectionDate": { "type": "string", "format": "date-time" }
      },
      "if":   { "properties": { "certified": { "const": true } } },
      "then": { "required": ["certificationDate", "expirationDate", "certifyingAuthority"] }
    },
    "authorizedPersonnel": {
      "type": "array",
      "uniqueItems": true,
      "items": { "type": "string", "pattern": "^PER-[A-Z0-9]{4,20}$" }
    },
    "materialRefs": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["registryId"],
        "properties": {
          "registryId": { "type": "string" },
          "riskGroup":  { "enum": [1, 2, 3, 4] }
        },
        "additionalProperties": false
      }
    }
  }
}

실무상 짚을 점이 두 가지 있다. 첫째, draft 2020-12에서 format 키워드는 기본적으로 주석(annotation)이다. 많은 검증기는 형식 단언을 명시적으로 켜야만 date-time을 검사한다(예를 들어 Ajv에서는 별도 패키지 ajv-formats를 통해 켠다). 따라서 엄격해 보이는 스키마도 검증기 설정이 틀리면 "다음 주 화요일"을 날짜로 받아들일 수 있으며, 그렇지 않음을 증명하는 음성 시험 벡터가 있어야 한다. 둘째, 최상위에는 additionalProperties: false를 두지 않았다. 의도된 것이다. 읽는 쪽은 더 새로운 마이너 버전이 추가한 필드를 허용해야 하기 때문이다(아래 참조). 쓰는 쪽은 같은 스키마의 더 엄격한 변형으로 자체 CI에서 정확한 형태를 강제할 수 있다.

스키마 진화

PHASE-1 Annex E는 새 선택 필드나 새 오류 코드 같은 추가형 변경은 하위 호환을 깨지 않는다고 밝힌다. 이 책은 이 규칙을 가장 강한 형태로 채택한다. 스키마는 추가만으로 진화한다. 새 필드는 선택 항목으로 도입하고, 기존 필드는 이름을 바꾸거나 용도를 바꾸지 않으며, 더 이상 원하지 않는 필드는 스키마 설명에 폐기 예고로 표시하되 다음 메이저 버전까지 그대로 둔다.

이름 변경과 삭제에 관해서는 PHASE 부속서들이 서로 일치하지 않는다. Annex E는 필드 이름 변경이나 삭제가 마이너 버전 증가를 일으켜야 한다고 하는 반면, Annex H는 Semantic Versioning 2.0.0을 적용하며 그에 따르면 하위 호환을 깨는 변경은 메이저 버전 증가가 필요하다. 이름 변경은 옛 이름을 찾는 모든 읽는 쪽을 깨뜨리므로, 이 책은 Annex H를 따른다. 이름 변경과 삭제는 메이저 버전 변경이며, 제7장에서 설명하는 병행 지원 기간과 지원 종료 기간을 거쳐 처리한다. 모든 레코드는 schemaVersion을 담아, 읽는 쪽이 작성자가 어떤 규칙을 따랐는지 알 수 있게 한다.

예제: 연구시설 등록 레코드

아래 레코드는 가상의 실험실을 등록한 것이다. 모든 식별자, 명칭, 날짜는 예시이다. 이 레코드는 위의 스키마를 통과하고, 물질에 관한 설명을 담지 않으며, 프로파일의 대체 방식을 보여 준다. engineeringControls는 확인서 형태로, 인자 객체 대신 materialRefs로, 그리고 해당 공간에 적용되는 IBC 결정을 authorizationIds로 연결한다.

{
  "schemaVersion": "1.0.0",
  "recordType": "facility",
  "facilityId": "FAC-EXU-B07-0312",
  "name": "Example University Molecular Biology Lab 3",
  "bslLevel": "BSL-2",
  "location": {
    "building": "B07",
    "room": "0312"
  },
  "accessControls": {
    "restrictedAccess": true,
    "selfClosingDoors": true,
    "lockableDoors": true,
    "accessLog": true,
    "biometricAccess": false
  },
  "certification": {
    "certified": true,
    "certificationDate": "2026-03-02T00:00:00Z",
    "expirationDate": "2027-03-01T14:59:59Z",
    "certifyingAuthority": "EXAMPLE-AUTHORITY",
    "nextInspectionDate": "2026-09-01T00:00:00Z"
  },
  "engineeringControls": {
    "attestedBy": "EXAMPLE-AUTHORITY",
    "checklistDocumentId": "DOC-CERT-2026-0117",
    "attested": true
  },
  "authorizedPersonnel": ["PER-7Q2K9", "PER-3M8XD", "PER-0BSO1"],
  "authorizationIds": ["AUT-2026-0019"],
  "materialRefs": [
    { "registryId": "MAT-EXU-0042", "riskGroup": 2 }
  ],
  "createdAt": "2026-03-03T01:12:40Z",
  "updatedAt": "2026-03-03T01:12:40Z"
}

몇 가지 세부 사항을 눈여겨볼 만하다. nextInspectionDate가 expirationDate보다 한참 앞서 있으므로 알림이 제때 울릴 수 있다. 출입자 명단에는 가명 식별자 세 개가 있고, 그중 PER-0BSO1은 교육 기록 예제의 평가자이기도 하다. 두 레코드 어디에도 이름이 없지만 레코드 간 조회가 가능함을 보여 준다. createdAt과 updatedAt은 편의 필드이며, 레코드의 공식 이력은 감사 이벤트에 남는다.

장 요약

기술적 안전 내용은 공식 생물안전 당국에 문의할 것. 이 장은 레코드 구조만 다룬다. 밀폐 요건, 특정 물질의 분류, 시설 공학, 그 밖의 모든 기술적 생물안전 지침은 관계 국가 당국(한국의 경우 질병관리청, 과학기술정보통신부 및 유전자변형생물체법에 따라 지정된 기관), WHO 실험실 생물안전 매뉴얼, 그리고 소속 기관의 생물안전관리책임자와 IBC로부터 확인해야 한다.