HL7 메시지를 DB에서 파싱할 때 실제로 터지는 것들

검사 결과 메시지를 받아서 결과 테이블에 적재하는 인터페이스를 맡았다. 구조는 단순하다. 외부 장비가 HL7 v2 메시지를 보내면 미들웨어가 받아 큐 테이블에 원문을 넣고, DB 패키지가 그걸 파싱해서 정규화된 테이블에 넣는다.

처음 붙인 장비 세 대는 별문제 없이 돌았다. 네 번째 장비를 붙이면서 일이 시작됐다.

증상

새 장비에서 온 메시지만 필드가 한 칸씩 밀려 들어갔다. 검사 코드 자리에 검사명이 들어오고 결과값 자리에 단위가 들어오는 식이다. 그런데 모든 메시지가 아니라 일부만 그랬다.

밀린 메시지들을 모아 놓고 보니 공통점이 있었다. 결과 코멘트에 한글이 길게 들어간 건들, 그리고 수치 범위가 5.0-10.0 형태로 들어온 건들이었다.

원인 분석

HL7 v2는 구분자로 필드를 나누는 텍스트 포맷이다. 파서를 처음 짤 때 대부분 이렇게 쓴다.

v_fields := regexp_substr(v_seg, '[^|]+', 1, v_pos);

여기에 잘못된 가정이 세 가지 숨어 있다.

첫째, 구분자가 항상 같다는 가정. HL7 표준에서 구분자는 고정값이 아니다. MSH 세그먼트가 스스로 선언한다. MSH-1이 필드 구분자이고 MSH-2가 나머지 인코딩 문자 네 개다. 관례적으로 |^~\&를 쓰지만 표준상 강제가 아니고, 장비 벤더에 따라 다르게 보내는 경우가 실제로 있다. 하드코딩하면 그 장비를 만나는 날 조용히 깨진다.

둘째, 데이터에 구분자가 안 들어온다는 가정. 이게 이번 장애의 직접 원인이었다. 데이터 값 자체에 파이프나 캐럿이 들어가야 할 때, HL7은 이스케이프 시퀀스로 치환해서 보낸다.

\F\  ->  필드 구분자
\S\  ->  컴포넌트 구분자
\T\  ->  서브컴포넌트 구분자
\R\  ->  반복 구분자
\E\  ->  이스케이프 문자 자신
\X0D\ ->  16진 문자 코드

그런데 이걸 지키지 않고 원문 그대로 흘려보내는 장비가 있다. 코멘트에 5.0-10.0 파이프 재검요망 같은 문자열이 구분자 그대로 들어오면, 파서 입장에서는 필드가 하나 더 생긴 것으로 보인다. 그 뒤 필드가 전부 한 칸씩 밀린다.

셋째, 세그먼트 구분자가 깨끗하다는 가정. HL7의 세그먼트 구분자는 CR(0x0D) 하나다. LF가 아니다. 그런데 메시지가 파일로 떨어졌다가 윈도우를 거치면 CRLF로 바뀌어 있고, 리눅스 미들웨어를 타면 LF만 남기도 한다. 섞여 들어오면 마지막 필드 끝에 정체불명의 공백 문자가 붙는다. 이것 때문에 코드 비교가 실패하는 건을 따로 찾느라 반나절을 썼다.

문자셋 문제도 같이 나왔다. MSH-18이 문자셋 필드인데 이걸 비워서 보내는 장비가 많다. 비어 있으면 표준상 ASCII로 해석해야 하지만 실제로는 EUC-KR이나 UTF-8이 들어온다. 한글 코멘트가 깨진 건들이 여기 걸렸다.

해결

파싱을 한 덩어리로 짜지 않고 단계를 분리했다.

1단계, 원문 보존. 무조건 먼저 한다. 파싱에 실패하든 성공하든 원문 CLOB은 남긴다. 이게 없으면 나중에 재현이 불가능하다.

insert into hl7_raw (msg_id, recv_dt, raw_msg, parse_stat)
values (seq_hl7.nextval, systimestamp, v_raw, 'N');

2단계, 정규화. 줄바꿈부터 정리한다.

v_raw := replace(replace(v_raw, chr(13) || chr(10), chr(13)), chr(10), chr(13));

3단계, 구분자를 메시지에서 읽는다. 고정값을 쓰지 않는다.

v_msh := regexp_substr(v_raw, '^MSH[^' || chr(13) || ']*');
v_fld := substr(v_msh, 4, 1);
v_cmp := substr(v_msh, 5, 1);
v_rep := substr(v_msh, 6, 1);
v_esc := substr(v_msh, 7, 1);
v_sub := substr(v_msh, 8, 1);

MSH-1은 4번째 문자 한 개, MSH-2는 5번째부터 8번째까지 네 개다. MSH 세그먼트만 필드 번호 세는 방식이 다르다는 점도 함정이다. MSH에서는 필드 구분자 자체가 MSH-1이기 때문에 다른 세그먼트와 인덱스가 한 칸 어긋난다.

4단계, 이스케이프 복원. 필드를 잘라낸 다음에 한다. 순서를 바꾸면 복원한 구분자를 다시 구분자로 인식해 버린다.

function unescape_hl7(p_val varchar2, p_esc varchar2,
p_fld varchar2, p_cmp varchar2, p_rep varchar2, p_sub varchar2)
return varchar2
is
v varchar2(4000) := p_val;
begin
v := replace(v, p_esc || 'F' || p_esc, p_fld);
v := replace(v, p_esc || 'S' || p_esc, p_cmp);
v := replace(v, p_esc || 'R' || p_esc, p_rep);
v := replace(v, p_esc || 'T' || p_esc, p_sub);
v := replace(v, p_esc || 'E' || p_esc, p_esc);
return v;
end;

5단계, 필드 개수 검증. 기대한 세그먼트 구조와 다르면 적재하지 않고 에러 큐로 보낸다. 밀린 데이터가 조용히 들어가는 것보다 안 들어가고 알람이 울리는 편이 훨씬 낫다.

if v_field_cnt < c_obx_min_field then
update hl7_raw set parse_stat = 'E', err_msg = 'field count ' || v_field_cnt
where msg_id = v_msg_id;
return;
end if;

그리고 문제 장비 쪽에는 이스케이프 처리를 해서 보내 달라고 요청했다. 두 달쯤 걸렸고 그동안은 위 검증 로직이 버텨 줬다.

남은 교훈

인터페이스 장애는 대부분 표준대로 올 것이라는 가정에서 나온다. 표준 문서를 읽고 짜면 세 번째 장비까지는 잘 돈다. 네 번째 장비가 표준을 조금 다르게 읽었을 뿐인데 전부 무너진다.

그래서 파서에는 두 가지가 반드시 있어야 한다. 원문을 통째로 남기는 자리, 그리고 이상하면 적재를 거부하고 알리는 자리. 이 둘이 없으면 밀려 들어간 데이터를 나중에 어디서부터 고쳐야 할지 알 수 없게 된다.

특히 의료 데이터는 한 번 잘못 들어가면 되돌리는 비용이 크다. 파서를 관대하게 만들고 싶은 유혹이 늘 있지만, 애매한 메시지는 통과시키는 것보다 세우는 게 맞다.

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤