Part 1에서는 변환 레이어(translation layer)를 구축했습니다. Part 2에서는 이를 검사하는 파이프라인을 구축했습니다. 이번 부분은 그 자체의 번역 과정에 관한 것인데, 흥미로운 오류들이 바로 이 과정에서 발견되었습니다.
아래 내용 중 어느 것도 예외(exception)를 발생시키지는 않았습니다. 모든 경우에 서버가 수락할 수 있는 FHIR Bundle을 생성했지만, 내용은 잘못된 것이었습니다. 이것이 매핑 작업의 특정한 위험성입니다: 출력은 항상 잘 구성되어 있기 때문에, 문제를 발견하는 유일한 방법은 매퍼를 실행하고 결과물을 살펴보는 것입니다.
저는 아홉 가지 케이스를 발견했습니다. 그중 하나는 양성 배양(positive culture) 결과를 숫자 0으로 저장하는 것이었고, 나머지 아홉 개 중 세 가지는 제가 전혀 찾지 못했던 것들입니다: Part 1을 읽었던 독자들이 찾아냈습니다.
문제점
매핑은 수많은 작은 결정들의 긴 목록이며, 결정을 내리는 과정에서는 각각이 명백히 올바르게 보입니다. PID-5가 그 이름입니다. PID-7은 생년월일(date of birth)입니다. 한 줄을 작성하고 테스트를 통과하면 다음으로 넘어갑니다.
임상 환경에서 이것이 위험한 이유는 오류들이 조용하고 그럴듯하기 때문입니다. 중간 이름 누락은 오류가 아닙니다. 사라진 생년월일 역시 오류가 아닙니다. Bundle은 유효성 검사를 통과하고, 서버는 200을 반환하며, 잘못된 기록이 마치 올바른 것처럼 시스템의 기록(system of record)에 저장됩니다.
그래서 이번 주에는 매퍼를 읽는 것을 멈추고, 직접 데이터를 넣어보았습니다.
제가 발견한 것들
A table of nine mapping behaviours, each failing before the change and passing after
실제 매퍼를 실제 메시지에 실행하여 확인한 9가지 동작:
behaviour before after
--------------------------------------------------------
middle name kept FAIL pass
...
양성 배양 결과가 0으로 변함
이것은 만약 제가 속한 기관이었다면 가장 알고 싶었던 내용입니다.
OBX 세그먼트는 결과를 담고 있습니다. OBX-2는 해당 값의 유형을 알려주는데, 숫자는 NM, 텍스트는 ST, 코딩된 항목은 CE를 사용합니다. 매퍼(mapper)가 OBX-2를 완전히 무시하고 모든 값을 valueQuantity로 변환하려고 시도했습니다. 텍스트 값이 숫자로 파싱되지 않으면 다음과 같은 처리를 했습니다:
except ValueError:
return {"value": 0, "unit": units or "", "comparator": None, "_raw": value}
그 결과, 'POSITIVE'라는 미생물학 검사 결과는 다음과 같이 나왔습니다:
{ "valueQuantity": { "value": 0, "unit": "", "comparator": null, "_raw": "POSITIVE" } }
질적(qualitative) 결과가 숫자 0으로 변해버린 것입니다. valueQuantity.value를 읽는 모든 하위 시스템—차트, 규칙, 대시보드 등—은 측정값이 '무(nought)'인 것으로 인식합니다. 또한 _raw 역시 FHIR 요소가 아닙니다. 임의로 만들어진 것이기 때문에, 준수하는 서버(conformant server)는 이 리소스를 거부하거나 해당 속성을 누락할 수 있으며, 그와 함께 실제 결과의 유일한 복사본마저 사라지게 됩니다.
해결책은 OBX-2를 읽고 일치하는 value[x]를 선택하는 것입니다:
if vt in _CODED_VALUE_TYPES:
return {"valueCodeableConcept": {"text": value}}
if vt in _NUMERIC_VALUE_TYPES or vt == "":
...
이제 'POSITIVE'는 {"valueString": "POSITIVE"}가 됩니다. NM을 주장하며 텍스트를 담고 있는 세그먼트도 마찬가지로 문자열이 되는데, 이는 제가 피하고자 하는 실패 모드(failure mode)인 숫자를 임의로 생성하는 것을 막기 위함입니다.
모든 입원 기록이 새로운 환자를 만들게 되고, 명백한 해결책이 잘못된 방법이 됩니다
Bundle은 Patient 리소스를 다음과 같이 게시합니다:
{ "method": "POST", "url": "Patient" }
이는 무조건적인 생성(unconditional create)입니다. 같은 입원 기록을 두 번 보내면 환자가 두 명 생깁니다. 시스템 장애 후 하루치 트래픽을 재전송하면, 메시지 건수만큼 중복된 환자 레코드가 발생합니다.
이 내용은 Part 1의 독자로부터 온 질문입니다. Anjan Kumar Gundaboina는 LinkedIn을 통해 매퍼(mapper)가 Idempotent한지, 즉 재전송 시 중복된 Patient 리소스가 생성되는지 문의했습니다. 실제로 중복이 발생했습니다.
명백한 해결책은 조건부 생성(conditional create)입니다. FHIR에는 "Bundle.entry.request.ifNoneExist"라는 항목이 정의되어 있으며, 이는 "지정된 리소스가 이미 존재하는 경우 서버에 생성 작업을 수행하지 않도록 지시하는 것"을 의미합니다. 일치하는 항목이 하나만 있어도 200 응답 코드를 반환하며 아무것도 생성하지 않기 때문에, 재전송 시 중복 생성이 방지됩니다.
하지만 이것만으로는 충분하지 않습니다. A08은 환자 업데이트 메시지입니다. 만약 환자가 존재하고 해당 메시지에 수정된 생년월일(date of birth) 정보가 포함되어 있다면, 조건부 생성 기능은 일치하는 항목을 확인한 후 아무런 작업도 수행하지 않습니다. 중복 기록은 사라지고, 필요한 수정 정보 또한 함께 손실됩니다.
조건부 업데이트(conditional update)는 이 두 가지 경우를 모두 처리합니다. FHIR에서는 이를 PUT [base]/[type]?[search parameters]로 정의하며, 작동 방식은 다음과 같습니다: 일치하는 항목이 없을 경우 "서버가 리소스를 생성"하고, 일치하는 항목이 하나일 경우 "서버가 해당 리소스에 대해 업데이트를 수행"하며, 여러 개일 경우 412 응답 코드를 반환합니다.
{
"method": "PUT",
"url": "Patient?identifier=https://fhir.nhs.uk/Id/nhs-number|9000000009"
...
}
만약 NHS 번호가 없다면, 조건 자체가 존재하지 않아 일반적인 생성(plain create)으로 남아있습니다. 모든 것을 일치시키는 조건은 아예 없는 경우보다 더 위험합니다. 이는 NHS 번호가 없는 모든 환자들을 하나의 기록으로 병합해버릴 수 있기 때문입니다.
버려진 날짜 정보 (The dates it threw away)
PID-7은 단순한 날짜(date)가 아니라 타임스탬프(time stamp)이며, 전송자는 자신이 가진 만큼의 정보를 채워 넣습니다. 매퍼는 YYYYMMDD 형식만 수용하고 다른 모든 형식에 대해서는 아무것도 반환하지 않았습니다.
독자들 역시 이 문제점을 제기했습니다. Lee Wilson은 많은 EHR(Electronic Health Record) 시스템에서 출생 연도만 알고 있는 경우가 많다고 지적했으며, Anjan은 이러한 부분 날짜 대체(partial-date fallback)가 바로 대시보드 모니터링에 필요한 '조용한 실패'의 전형적인 예라고 언급했습니다.
FHIR는 매퍼보다 더 유연합니다. FHIR는 date를 "날짜 또는 부분 날짜 (예: 연도만, 또는 연도 + 월)"로 정의하며, 형식은 "YYYY, YYYY-MM, 또는 YYYY-MM-DD"입니다.
198001은 1980-01이 되어야 했습니다. 그것은 완전히 사라지고 있었습니다. 시간대 오프셋을 가진 완벽하게 평범한 전체 타임스탬프인 19800101120000+0100도 마찬가지였습니다. 왜냐하면 해당 오프셋이 시도되는 세 가지 형식 중 어느 것과도 일치하지 않았기 때문입니다. 시간대가 포함된 생년월일은 조용히 누락되었습니다.
둘 다 이제 매핑됩니다. FHIR 날짜는 "시간대를 가질 수 없음(no time zone)"이므로 오프셋은 제거됩니다.
손상된 이름들
HL7은 구분자(delimiter)로 다섯 개의 문자를 예약하고, 필드 내에 나타날 수 있도록 다섯 개의 이스케이프 시퀀스를 정의합니다. \T\는 하위 구성 요소 구분자이므로, 와이어를 통해 O&Brien이 전달되는 방식은 O\T\BRIEN입니다. 매퍼(mapper)는 이 이스케이프 시퀀스를 저장했습니다.
정확하게 이해할 가치가 있는 미묘한 부분이 있습니다. \E\는 이스케이프 문자 자체를 나타내므로, 디코딩(decoding)은 단일 패스로 이루어져야 합니다. 먼저 \F\를 대체하고 그 다음 \E\를 대체하면, 리터럴 텍스트 \F\를 의미하는 시퀀스 \E\F\E\가 대신 필드 구분자가 됩니다. 다섯 개 이외의 시퀀스(예: \H)는 제거되지 않고 그대로 통과됩니다.
중간 이름, 그리고 그것을 잃어버리는 것을 승인한 테스트
PID-5는 DOE^JOHN^A입니다. 구성 요소 3은 중간 이름입니다. FHIR은 주어진 이름(given)과 중간 이름을 순서대로 given 필드에 함께 넣습니다.
매퍼는 구성 요소 2를 읽고 멈췄기 때문에 A가 누락되었습니다. 이것은 아홉 가지 중 가장 심각하지 않지만, 테스트 파일에서 제가 발견한 것 때문에 가장 교육적입니다:
assert patient["name"][0]["given"] == ["JOHN"]
이 테스트는 버그를 단언했습니다. 그것은 세그먼트(segment)가 말하는 것이 아니라 매퍼가 하는 것을 읽어서 작성되었기 때문에 작성된 날부터 통과되었습니다. 녹색 스위트(green suite)는 매핑이 옳다고 저에게 말했지만, 실제로 저에게 알려준 것은 매핑이 변경되지 않았다는 것이었습니다.
식별자가 아니었던 식별자들
각 Bundle 항목은 urn:uuid:patient-1과 같은 fullUrl을 가지고 있었습니다. FHIR의 uuid 타입은 "URI로 표현된 UUID(별칭 GUID)"를 의미하며, 이는 RFC 4122를 의미합니다. patient-1은 그것이 아닙니다. HAPI가 이를 수용했기 때문에 살아남았고; 더 엄격한 서버는 그럴 필요가 없습니다. 이제 이것들은 실제 UUID입니다.
FHIR가 금지하는 곳에서의 Null 값들
PID-7이 전혀 파싱되지 않을 경우, birthDate는 None으로 반환되어 문자열화(serialized)되면서 "birthDate": null이 되었습니다. FHIR의 JSON 규칙은 속성(properties)은 "절대 null 값을 가질 수 없으며", 비어 있는 경우 해당 속성을 "생략해야 한다(Omit a property if it is empty)"고 명시합니다.
테스트를 진행하던 중 두 번째 사례가 발견되었습니다. 이름이 전혀 없는 PID는 "family": ""을 생성했습니다. 같은 페이지에는 "문자열 속성 값은 절대 비어 있을 수 없다(String property values can never be empty)"고 나와 있습니다.
현재 이 두 가지 경우 모두 빈 배열이나 객체와 함께 Bundle로 들어오기 전에 제거(pruned)됩니다. 0과 false는 살아남는데, 이는 수량(quantity)이 0인 것이 실제 측정값이기 때문이며, 바로 이것이 본 기사가 시작된 오류 지점입니다.
존재하지 않는다고 생각했던 답
저는 PID-8을 미해결 질문으로 작성했습니다. HL7 테이블 0001은 모호함(ambiguous)에 대해 A를, 해당 사항 없음(not applicable)에 대해 N을 가지고 있습니다. 반면 FHIR의 administrative-gender는 오직 male, female, other, 그리고 unknown만 가집니다. 저는 깨끗한 목표 지점(clean target)이 없다고 결론 내리고 매퍼가 둘 다 unknown으로 보내도록 두었습니다.
그러다 Dalisay Giffard가 LinkedIn에서 PID-8 매핑을 확인해 보라고 제안했습니다. 왜냐하면 행정 성별(administrative sex)과 성별(gender)은 의미가 다르기 때문입니다. 이 필드를 검토한 결과, FHIR R4는 Patient.gender를 행정 성별로 정의하고 있으며, HL7의 v2-to-FHIR 가이드 역시 PID-8을 여기에 배치합니다. 코드는 그렇지 않았습니다. 제대로 확인해 본 결과, HL7이 바로 이 내용에 대한 개념 지도(concept map)를 v2-to-FHIR 구현 가이드에서 발행하고 있음을 발견했습니다. 이 지도는 A를 other로, 그리고 N을 other로 보내며 둘 다 "동등하다(is equivalent to)"고 평가합니다.
이것은 당연히 맞는 내용입니다. 'unknown'은 우리가 모른다는 뜻입니다. 전송자는 알고 있었습니다. 답은 단순히 남성도 여성도 아니었던 것입니다. 저는 기록된 답변을 누락된 것으로 취급했고, 제가 찾아보지 않은 테이블에 이미 표준이 정한 결론으로 스스로를 이끌었습니다.
지도는 영토가 아니었다
저장소에는 HL7 필드와 해당 FHIR 대상에 대한 주의 깊은 표가 담긴 docs/hl7-fhir-mapping.md 파일이 있습니다. 저는 이 파일을 실행되는 매퍼(mapper)를 통해 각 행을 확인했습니다. 그 결과, 존재하지 않는 세 가지 매핑을 발견했습니다: PID-11에서 Patient.address로, RXE-4에서 dosageInstruction으로, 그리고 RXE-13에서 requester로의 매핑입니다. 또한 이 파일은 _raw를 확장(extension)이라고 불렀는데, 이는 지어낸 속성에 의도적인 설계라는 느낌을 부여했습니다.
이것은 가장 유용한 발견이 아무도 확인하지 않은 시스템을 설명하는 문서였던 3주간의 작업이었습니다. 2주차에는 규칙이 로드되지 않은 보안 스캐너가 있었습니다. 그리고 이번 주에는 제가 작성하지 않은 매퍼를 설명하는 매핑 테이블이 있습니다.
다시 작성된 버전은 사실과 달리 '매핑되지 않음(not mapped)'이라고 명시하고 있으며, 모든 행은 해당 도구를 실행하여 생성되었습니다.
독자들이 발견했지만 제가 아직 수정하지 못한 부분
파트 1과 파트 2는 공개되어 있고 저장소 자체도 공개되어 있어, 이 매핑이 저보다 더 많은 사람들의 눈을 거쳤다는 의미입니다. 이번 주 변경 사항에는 포함되지 않은 몇 가지 추가적인 문제들이 보고되었으며, 조용히 사라지지 않도록 여기에 명시합니다.
PID-3은 Kevin Mayfield, Ian Haywood, Lee Wilson에 의해 제기되었습니다. PID-3은 CX-1과 CX-4만 읽습니다. CX-5 식별자 유형 코드인 NH 또는 MR이 누락되며, use 필드가 없고, 모든 NHS가 아닌 식별자는 할당자(assigner) 없이 하나의 local-patient-identifier 시스템에 들어갑니다. 이로 인해 두 개의 다른 트러스트에서 온 의무 기록 번호(medical record numbers)가 충돌합니다. North West GMSA HL7 v2 가이드는 CX-1, CX-4, CX-5를 필수 항목으로 취급하며 ODS 코드를 할당 권한으로 사용합니다. 제 테스트 환경은 ODS 코드가 아닌 GENERAL_HOSP를 사용합니다. Ian의 경고는 실질적인 것이었습니다. 네임스페이스가 유지되지 않으면, 다른 출처에서 동일한 ID를 사용할 때 나중에 재미있는 일을 겪게 될 것이라고 말했습니다. Kevin은 MRN 시스템이 URI 또는 OID여야 한다고 추가했는데, 이는 EPR 공급업체와 트러스트들이 각자 자신만의 선택을 했으며, 그의 조직은 URI를 사용한다고 했습니다.
안잔(Anjan)의 질문에서 파생된 재전송 순서 문제입니다. 이제 쓰기 작업이 생성(create) 대신 업데이트를 수행하므로, 더 최신 메시지 이후에 오래된 데드 레터 메시지를 재전송하면 새로운 데이터를 이전 데이터로 덮어쓰게 됩니다. 제가 생각하는 해결책은 쓰기 지점에서 MSH-7 값을 마지막으로 적용된 값과 비교하는 것입니다. NHS 번호를 SQS FIFO 메시지 그룹 ID로 사용하는 것은 실시간 트래픽(live traffic) 순서를 보장하지만, 데드 레터 폴더에서 나오는 재전송에는 아무런 효과가 없습니다.
안잔이 직접 질문했던 필드 레벨 실패는 조용합니다(silent). 전체 메시지 파싱 실패가 발생했을 때만 데드 레터로 이동합니다. 매핑에 실패한 단일 필드는 플래그(flag), 메트릭(metric), 로그 라인(log line)을 전혀 생성하지 않으며, 이것이 이 아홉 가지 문제들이 살아남은 방식입니다.