MSA 기반 대규모 임상 데이터 처리 시스템 구축기
현대 의료의 핵심 패러다임인 '환자 중심 의료(Patient-Centered Care)'를 뒷받침하는 핵심 데이터가 바로 PROMs(환자자기평가결과)입니다. 저는 한 기업 PROMs 플랫폼의 백엔드 개발을 담당하며, 방대한 임상 데이터를 안정적으로 수집·분석하고 거대한 병원 코어 시스템(AMIS)과 연동하는 미션을 수행했습니다. 이 과정에서 마주한 인증 한계, 실시간 데이터 시각화 병목, 대용량 소급 계산 OOM, 장애 전파 위험이라는 네 가지 기술적 난제와 이를 아키텍처 관점에서 해결한 과정을 공유하고자 합니다.
1. 도입 및 기술적 도전 과제
PROMs 플랫폼은 단순한 설문 저장 시스템이 아니라, 병원 코어 시스템과의 안정적인 연동, 임상 데이터의 신속한 분석, 의료진을 위한 실시간 시각화, 그리고 대규모 재계산 작업까지 감당해야 하는 복합적인 백엔드 시스템이었습니다. 특히 엔터프라이즈 의료 환경에서는 표준적인 기술만으로 해결되지 않는 레거시 제약과 고가용성 요구가 동시에 존재했습니다.
이번 프로젝트에서 저는 다음과 같은 네 가지 기술적 난제와 정면으로 마주했습니다.
| 구분 | 주요 과제 |
|---|---|
| 인증 | 표준 HTTP Authorization Header를 사용할 수 없는 레거시 연동 환경 수용 |
| 조회 성능 | 복잡한 의학 수식 계산으로 인한 실시간 대시보드 조회 병목 해소 |
| 메모리 최적화 | 대규모 소급 재계산 시 JPA 영속성 컨텍스트 포화에 따른 OOM 방지 |
| 장애 격리 | 외부 코어 시스템 지연이 내부 플랫폼 전체로 확산되는 장애 전파 차단 |
2. HTTP Header 제약을 극복한 비표준 Payload 기반 인증 어댑터
문제 상황: 레거시 환경의 HTTP Header 조작 한계
최신 MSA 환경과 Spring Security는 일반적으로 HTTP Authorization Header를 통해 JWT를 주고받는 표준 규약을 따릅니다. 하지만 병원 코어 시스템(AMIS)은 구조적 제약으로 인해 표준 HTTP 헤더에 토큰을 실어 보낼 수 없었고, 대신 Request Body(AmcData) 내부의 특정 필드인 encToken에 암호화된 인증 정보를 담아 전송해야 했습니다.
해결 방안: ContentCachingRequestWrapper 및 커스텀 필터 구현
비표준 통신을 수용하면서도 내부 보안 표준을 유지하기 위해 앞단에 커스텀 Security Filter를 구현했습니다. 서블릿 특성상 InputStream은 한 번 읽으면 소실되므로, ContentCachingRequestWrapper를 적용해 Request Body를 캐싱했습니다.
이후 필터에서 캐싱된 Body의 encToken을 추출하고, 이를 Keycloak 서버로 검증한 뒤, 유효한 인증 정보를 ThreadLocal 기반 ContextHolder에 주입했습니다. 결과적으로 외부의 비표준 연동을 유연하게 수용하면서도, 서버 내부에서는 표준 인증·인가 흐름을 유지할 수 있는 인증 어댑터를 완성했습니다.
// 핵심 아이디어 예시
ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request);
String requestBody = extractBody(wrappedRequest);
String encToken = parseEncToken(requestBody);
AuthInfo authInfo = keycloakVerifier.verify(encToken);
ContextHolder.set(authInfo);
3. 실시간 임상 데이터 시각화를 위한 Kafka & CQRS 패턴 도입
문제 상황: 복잡한 의학 수식 계산에 따른 실시간 DB 병목
환자가 문진을 제출하고 진료실에 들어가기 전, 의사의 모니터에는 이미 통증 지수나 삶의 질(QoL) 변화 추이가 시각화되어 있어야 합니다. 하지만 의료진이 대시보드를 열 때마다 과거의 방대한 설문 응답 데이터를 Join하고, 그 위에 복잡한 의학적 규칙을 실시간 계산한다면 심각한 조회 지연이 발생할 수밖에 없습니다.
해결 방안: Event-Driven 및 분리된 Query Model(QM) 구축
실시간성을 확보하기 위해 CQRS(Command Query Responsibility Segregation) 아키텍처와 Kafka 이벤트 스트리밍을 결합했습니다.
- 명령(Command)의 경량화: 환자가 설문을 제출하면 원천 데이터만 빠르게 Insert하고 즉시 응답을 반환한 뒤, Kafka Topic으로 이벤트를 비동기 발행했습니다.
- 비동기 사전 집계(Pre-calculation): Consumer가 이벤트를 구독하여 다이내믹 규칙 엔진(SpEL) 기반으로 조건부 문항 처리와 점수 합산 같은 무거운 연산을 백그라운드에서 수행하도록 분리했습니다.
- 조회(Query) 모델 최적화: 계산 결과는 조회 전용으로 극단적으로 평탄화한 통계 테이블에 적재하여, 의료진은 무거운 Join이나 계산 없이 요약 데이터만 즉시 조회할 수 있도록 만들었습니다.
그 결과 대시보드 조회 시점에는 이미 계산된 결과만 읽으면 되기 때문에, 데이터가 누적되어도 지속적으로 쾌적한 조회 성능을 유지할 수 있었습니다.
// 흐름 예시
[Survey Submit]
→ Raw Data Insert
→ Kafka Event Publish
→ Consumer Subscribe
→ SpEL Rule Calculation
→ Statistics Table Update
→ Dashboard Query
4. 대용량 소급 계산 처리 시 JPA OOM 방지 최적화
문제 상황: 수십만 건 조회 시 영속성 컨텍스트(L1 Cache) 포화
통계 기준이 변경되어 수년 치의 과거 문진 데이터를 한 번에 재계산해야 하는 상황에서는, 10만 건 이상의 엔티티가 영속성 컨텍스트(1차 캐시)에 적재되며 GC가 제때 동작하지 못해 OOM이 발생하는 치명적인 문제가 있었습니다. 단순히 로직을 최적화하는 것만으로는 해결되지 않았고, 메모리 사용량 자체를 통제하는 방식이 필요했습니다.
해결 방안: Chunk Processing 및 명시적 메모리 반환
시스템 가용성을 해치지 않기 위해 청크 기반 메모리 제어 기법을 적용했습니다. 전체 데이터를 Offset/Limit 기반으로 1,000건 단위로 분할 조회하고, 계산 후 Bulk Insert를 수행했습니다. 가장 핵심적인 튜닝 포인트는 각 청크 작업이 끝날 때마다 entityManager.clear()를 명시적으로 호출해 영속성 컨텍스트를 비워주는 것이었습니다.
이 방식은 데이터 대상이 수백만 건으로 늘어나더라도 JVM Heap 사용량을 청크 크기 수준으로 안정적으로 유지하게 만들었고, 결과적으로 대규모 소급 재계산 작업을 안전하게 수행할 수 있게 해주었습니다.
int chunkSize = 1000;
int offset = 0;
while (true) {
List<Survey> surveys = repository.findSlice(offset, chunkSize);
if (surveys.isEmpty()) break;
process(surveys);
bulkInsertStatistics(surveys);
entityManager.clear();
offset += chunkSize;
}
5. 코어 시스템 연동 시 장애 전파(Cascading Failure) 차단 설계
문제 상황: 외부 API 지연으로 인한 내부 Thread Pool 고갈
AMIS 시스템에 일시적인 부하나 지연이 발생하면, 그 응답을 기다리는 PROMs 서버의 Tomcat Thread들이 장시간 Block 상태에 빠지게 됩니다. 이러한 상황이 누적되면 결국 내부 Thread Pool이 고갈되고, 외부 시스템의 문제 하나가 전체 플랫폼의 응답 불능으로 이어지는 장애 전파(Cascading Failure) 위험이 존재했습니다.
해결 방안: 지능형 RestClient와 결함 감내(Fault Tolerance) 로직
위험 원천을 차단하기 위해 Spring 6의 RestClient를 도입해 통신 클라이언트를 방어적으로 재설계했습니다.
- 엄격한 Timeout 격리: Connect Timeout 5초, Read Timeout 30초를 강제하여 상대 서버 장애 시 연결을 빠르게 정리하고 자원을 회수하도록 설계했습니다.
- Error Bypass 기법: 연동 실패 시 무조건 Exception을 발생시키는 대신, 무시 가능한 특정 에러 코드(
ignorableMessageIds)는 가변 인자로 받아 선택적으로 우회하도록 구현했습니다.
이를 통해 병원 코어 시스템의 일시적 불안정성이 PROMs 플랫폼 전체 장애로 확산되는 것을 방지했고, 실제 사용자 경험 측면에서도 환자의 문진 흐름이 훼손되지 않도록 보호할 수 있었습니다.
RestClient restClient = RestClient.builder()
.requestFactory(customRequestFactoryWithTimeout(5000, 30000))
.build();
// ignorableMessageIds 에 해당하면 예외 대신 우회 처리
Response response = callAmis(request, ignorableMessageIds);
6. 결론
이번 프로젝트는 트래픽이 급증하고 데이터가 무한히 누적되는 엔터프라이즈 환경에서 백엔드 시스템이 어떻게 버텨내야 하는지를 치열하게 고민하고, 끝내 성공적으로 완수해 낸 값진 경험이었습니다.
비표준 인증 방식의 유연한 수용, CQRS를 통한 데이터 실시간성 확보, 1차 캐시 제어를 통한 대용량 메모리 최적화, 그리고 장애 전파를 막는 방어적 프로그래밍 기법들은 단순한 기능 구현을 넘어 시스템에 확장성과 견고함(Robustness)을 불어넣는 든든한 기술적 자산이 되었습니다.
지난 6개월 동안 프로젝트를 완수하기 위해 팀원 전체가 밤낮없이 헌신했고, 수많은 난관을 함께 극복해 냈습니다. 그 치열했던 과정의 끝에서 고객의 진심 어린 미소와 감사가 담긴 편지를 건네받았을 때의 감동은 오래도록 잊을 수 없을 것입니다.
고객의 마음속에 '넥스트리(NEXTREE)'라는 이름을 믿고 맡길 수 있는 최고의 기술 파트너로 각인시킬 수 있었다는 점은 엔지니어로서 큰 보람이었습니다. 앞으로도 이 경험을 자양분 삼아, 고객의 비즈니스 가치를 극대화하는 더욱 견고한 아키텍처를 설계해 나가겠습니다.
Eric