HL7 악몽을 살아남기: 레거시 병원 시스템에서 현대 SaaS를 분리하기 위한 전략
Source: Dev.to
위에 제공된 소스 링크 아래에 번역하고자 하는 텍스트를 붙여 주시면, 해당 내용을 한국어로 번역해 드리겠습니다. (코드 블록, URL, 마크다운 구문 등은 그대로 유지됩니다.)
소개
현대 개발자라면 여러분의 세계는 아마도 REST, GraphQL, JSON, 그리고 기분이 좋다면 약간의 gRPC를 중심으로 돌아갈 것입니다. 그러던 어느 날 병원이나 헬스케어 스타트업과 계약을 체결하게 됩니다. “좋아! 환자 입원 데이터를 얻으려면 API만 호출하면 되겠군.” 라고 생각하죠.
그런데 그들이 보내온 사양서는
JSON도 아니고 XML도 아닙니다. 파이프(|)로 구분된 문자열이 마치 고양이가 키보드를 밟은 듯한 형태이며, 절대 닫히지 않는 원시 TCP 소켓을 통해 전송됩니다. 바로 HL7 v2—30년 된 표준이지만 여전히 전 세계 의료 인프라를 운영하고 있는 시스템입니다.
현대 SaaS 엔지니어에게 이를 통합하는 일은 악몽과도 같습니다. 하지만 그 레거시 혼란을 깔끔한 현대 코드베이스에 그대로 두면, 수년간 여러분을 괴롭힐 기술 부채를 만들게 됩니다.
해결책? 안티‑코러프션 레이어(ACL).
이 글에서는 ACL 패턴을 활용해 HL7 인터페이스와 싸우면서도 정신(또는 클린 아키텍처)을 유지하는 방법을 살펴보겠습니다.
괴물: HL7 v2와 MLLP
괴물을 잡기 전에, 먼저 이해해야 합니다. HL7 v2 메시지는 다음과 같습니다:
MSH|^~\&|EPIC|HOSPITAL|MYAPP|SaaS|202501011230||ADT^A01|MSG00001|P|2.3
PID|1||12345^^^MRN||DOE^JOHN^^^^||19800101|M
PV1|1|I|200^Bed1^Room2||||1234^Doctor^Smith
설상가상으로, 이 메시지는 HTTP를 통해 전송되지 않습니다. **MLLP (Minimum Lower Layer Protocol)**를 사용합니다. 따라서 다음과 같이 해야 합니다:
- 원시 TCP 소켓을 연다.
- 특정 시작 바이트(
0x0B)를 대기한다. - 종료 바이트(
0x1C 0x0D)가 나올 때까지 읽는다. - 즉시 확인 응답(ACK)을 보내야 하며, 그렇지 않으면 병원 시스템이 오류를 발생시킨다.
PID|1||… 를 메인 비즈니스 로직 컨트롤러 안에서 직접 파싱하는 코드를 작성한다면, 도메인 모델을 성공적으로 손상시킨 것입니다.
전략: Anti‑Corruption Layer (ACL)
Anti‑Corruption Layer는 Domain‑Driven Design (DDD)에서 나온 패턴입니다. 이는 하위 시스템(병원)과 상위 시스템(당신의 반짝이는 SaaS) 사이에 방어 경계를 만듭니다.
내부 시스템은 절대로 HL7 메시지가 어떻게 생겼는지 알지 못해야 합니다. 내부 시스템은 오직 깨끗한 내부 도메인 언어만 사용해야 합니다.
HL7 ACL 구성 요소
- Facade (Ingestion) – 못생긴 MLLP 소켓 연결을 처리합니다.
- Adapter (Parsing) – 파이프‑구분 텍스트를 사용 가능한 객체로 변환합니다.
- Translator (Mapping) – 가장 중요한 부분. HL7 객체를 귀하의 도메인 모델로 변환합니다.
구현해 보자 (Node.js 예제)
병원이 ADT^A01 (입원) 메시지를 보낼 때마다 PatientAdmission 이벤트를 생성하고 싶습니다.
저수준 문자열 분할을 위해 가벼운 HL7 파서(예: node-hl7-client)를 사용할 것이지만, 중요한 것은 아키텍처입니다.
Step 1: 깨끗한 도메인 모델 정의
// domain/models.ts
// This is pure. No HL7 junk here.
export interface PatientAdmission {
patientId: string;
fullName: string;
admittedAt: Date;
location: string;
attendingPhysician: string;
}
Step 2: 오염 (HL7 입력)
const rawHL7 = "MSH|^~\\&|...|ADT^A01|...\rPID|1||12345^^^MRN||DOE^JOHN...\rPV1|..."
Step 3: 변환 서비스
여기서 마법이 일어납니다. 이 함수는 **코드베이스에서 “PID‑5” 또는 “PV1‑3”**을 알 수 있는 유일한 장소입니다.
// services/AntiCorruptionLayer.ts
import { parse } from 'some-hl7-library';
import { PatientAdmission } from '../domain/models';
export class HL7ToDomainTranslator {
static translateAdmission(rawMessage: string): PatientAdmission {
const hl7 = parse(rawMessage);
// Verify that the message is an admission (ADT A01)
if (hl7.get('MSH.9.1') !== 'ADT' || hl7.get('MSH.9.2') !== 'A01') {
throw new Error('Message is not an admission event');
}
// MAP legacy fields to modern domain
return {
patientId: hl7.get('PID.3.1').toString(), // MRN
fullName: `${hl7.get('PID.5.2')} ${hl7.get('PID.5.1')}`, // Family^Given
admittedAt: this.parseHL7Date(hl7.get('MSH.7').toString()),
location: hl7.get('PV1.3.1').toString(), // Nursing Unit
attendingPhysician: `${hl7.get('PV1.7.2')} ${hl7.get('PV1.7.1')}`
};
}
// Helper to deal with HL7's weird YYYYMMDDHHMM formats
private static parseHL7Date(dateString: string): Date {
// … specialized date parsing logic
return new Date(); // Simplified for demo
}
}
Step 4: 파사드 (인프라스트럭처 레이어)
이제 연결합니다. 메인 애플리케이션 로직은 깨끗한 PatientAdmission 객체만 받습니다.
// infrastructure/TcpServer.ts
import net from 'net';
import { HL7ToDomainTranslator } from '../services/AntiCorruptionLayer';
import { admissionController } from '../controllers/admissionController';
function stripMLLP(message: string): string {
// Remove the start (0x0B) and end (0x1C 0x0D) framing characters
return message.replace(/^\x0b/, '').replace(/\x1c\x0d$/, '');
}
function createAck(originalMessage: string): Buffer {
// Build a minimal ACK message – details omitted for brevity
const ack = `MSH|^~\\&|...|ACK|...|${Date.now()}|...`;
return Buffer.from(`\x0b${ack}\x1c\x0d`);
}
const server = net.createServer((socket) => {
socket.on('data', (data) => {
try {
// 1️⃣ Ingest (strip MLLP framing)
const rawMessage = stripMLLP(data.toString());
// 2️⃣ Translate (the ACL at work!)
const cleanEvent = HL7ToDomainTranslator.translateAdmission(rawMessage);
// 3️⃣ Hand off to modern business logic
console.log('New clean event:', cleanEvent);
admissionController.handleNewAdmission(cleanEvent);
// 4️⃣ Acknowledge (required by HL7)
socket.write(createAck(rawMessage));
} catch (err) {
console.error('Failed to process HL7', err);
// TODO: send NACK (negative ACK) here
}
});
});
server.listen(5000, () => console.log('Listening for Hospital Data on port 5000'));
요약
- Facade – 원시 TCP/MLLP를 처리합니다.
- Adapter – 파이프 구분 문자열을 구조화된 객체로 파싱합니다.
- Translator – 해당 객체를 깔끔한 도메인 모델에 매핑하여 HL7 지식을 격리합니다.
HL7‑특화 로직을 ACL에 한정함으로써, 레거시 오염으로부터 코드베이스의 나머지를 보호하고, 아키텍처를 깔끔하게 유지하며, 막대한 기술 부채를 방지할 수 있습니다. 🚀
전체 화면 제어
- 전체 화면 모드 진입
- 전체 화면 모드 종료
왜 이것이 승리하는가
- 디커플링: 병원이 내년에 HL7 v2에서 FHIR로 전환하더라도
Translator클래스만 다시 작성하면 됩니다.admissionController와PatientAdmission모델은 그대로 유지됩니다. - 테스트 용이성: 실제 TCP 연결이 없어도 샘플 텍스트 파일로
Translator를 단위 테스트할 수 있습니다. - 건전성: 핵심 비즈니스 로직에
split('^')와PID.5같은 참조가 뒤섞여 있지 않습니다.
“구매 vs. 구축”에 대한 메모
위 코드는 작성하기는 재미있지만, 실제 운영 환경에서 MLLP 소켓을 다루는 것은 까다롭습니다(타임아웃, 버퍼 단편화, VPN 터널 등).
실제 기업 환경에서는 ACL의 Infrastructure 부분을 구매 서비스로 간주할 수 있습니다. **Mirth Connect (NextGen Connect)**와 같은 도구나 AWS HealthLake, Google Cloud Healthcare API와 같은 클라우드 서비스가 물리적인 ACL 역할을 할 수 있습니다. 이들은 원시 MLLP를 수집하고 정제된 JSON을 HTTP webhook으로 전달합니다.
하지만 JSON을 받더라도, 여전히 그들의 스키마를 여러분의 스키마에 매핑하기 위한 논리적인 ACL이 필요합니다. 외부 스키마를 무조건 신뢰하지 마세요!
마무리
레거시 병원 시스템과의 통합은 헬스‑테크 개발자에게는 일종의 통과 의식과 같습니다. 복잡하고 난잡하지만, 견고한 안티‑코러프션 레이어(ACL)를 사용하면 파괴적일 필요가 없습니다. 혼란을 격리하고, 한 번만 번역한 뒤 도메인을 깔끔하게 유지하세요.
개인 블로그에 구체적인 엔지니어링 패턴과 통합 전략에 대해 더 많이 작성했으니, 비슷한 아키텍처 과제에 직면했다면 더 많은 기술 가이드를 확인해 보세요.
행복한 코딩 되시길, 그리고 소켓 연결이 언제나 열려 있기를 바랍니다!