트루닥 EMR 진료 타임라인 성능 최적화
Gyumin Choi's Portfolio/에이치디정션 (HD Junction)/트루닥 EMR (truedoc)/트루닥 EMR 진료 타임라인 성능 최적화

트루닥 EMR 진료 타임라인 성능 최적화

 
제품의 성능 저하로 환자의 진료 기록을 살펴보는 데 어려움을 겪고 있다는 VoC가 수집되었습니다.
진료 타임라인의 스크롤 및 데이터 렌더링 시 약 2초의 프리징을 반복해서 겪던 문제를 분석하고 해결 전략을 제시, 이를 직접 해결했습니다.
 

 
고객으로부터 사용성에 심각한 악영향을 끼치는 성능 저하 현상을 수집:
  • 사용자가 약 1년 간의 진료 기록을 보유한 환자의 진료 타임라인을 스크롤 할 때마다, 데이터 로딩 및 렌더링 과정에서 프리징 발생
  • 사용자는 과거 기록에 한 번에 도달할 방법이 없어 탐색 시 ‘스크롤 → 프리징’ 과정을 반복해서 겪어야 함
  • 이는 팀이 이미 알고 있던 문제이며, 환자 1명에 대한 데이터가 많고 ‘그리드 뷰’ 옵션이 활성화 되어있을 때 발생함 (아래 진료 타임라인 UI 설명 참고)
  • 아직 데이터가 많지 않고 그리드 뷰 사용률도 낮아 추후 대응하기로 계획되어 있었으나, 신규 고객이 기존에 사용하던 타사 제품의 데이터를 마이그레이션 함으로써 문제가 예상보다 일찍 발생, 빠르게 해결이 필요한 상황
 
이해를 돕기 위한 진료 타임라인 UI 설명
간략화 된 진료실 UI (출처: 트루닥 홈페이지)
간략화 된 진료실 UI (출처: 트루닥 홈페이지)
  • 환자의 진료 기록을 날짜별로 나누어 컬럼으로 배치
  • 세로 스크롤을 제어해 진료 차트를, 가로 스크롤(Infinite Scroll)을 제어해 진료 이력을 탐색
  • 컬럼 안에 진단, 처방, 기록, 검사결과 등의 카드를 위에서 아래로 나열 (= 리스트 뷰)
  • 카드의 높이는 컨텐츠에 따라 유동적이며, ‘보기 옵션’에서 ‘카드 정렬’을 활성화하면 카드 타입별로 Y 좌표가 통일되어 가상의 행을 형성 (= 그리드 뷰)
  • 사용자가 진료 차트 시트에서 내용을 편집하거나 SSE(Server-Sent Events)에 의해 내용 변경이 발생하면 진료 타임라인에 실시간으로 반영
 
문제 재현 결과:
  • 1년 동안 약 10,000개의 카드를 기록한 환자 테스트 데이터 구성
  • 그리드 뷰 옵션 활성화
  • 스크롤이 마지막에 도달해 다음 페이징 데이터를 렌더링할 때 최대 2초 수준의 프리징(Long Task) 발생
 
프로파일링 결과, 치명적인 Recalculate Style과 Layout 발견:
  • 그리드 뷰를 구현하기 위한 엘리먼트 사이즈의 동기화 과정에서 Layout Thrashing 유발
      1. 카드 리사이징 감지
      1. 리사이징된 카드 컴포넌트에서 동일한 타입의 모든 카드 사이즈를 읽어 들여(Read) 사이즈 비교
      1. 리사이징된 카드의 사이즈가 가장 크다면, 해당 카드 컴포넌트에서 동기화 이벤트 발행
      1. 동일한 타입의 모든 카드 컴포넌트가 동기화 이벤트 수신
      1. 카드 리사이징(Write)
      1. 카드 리사이징에 의해 1단계로 돌아감
  • 카드 렌더링 이후 카드 내부 이미지가 로딩되어 Layout Shift 유발, 이는 다시 Layout Thrashing으로 이어짐
 
문제 진단:
  • 엘리먼트 사이즈를 동기화하는 구현체, SyncedSize 컴포넌트의 실행 흐름이 제어되지 않음
    • 동기화 과정의 5단계 리사이징으로 인해, 3단계 조건을 충족하지 않을 때까지 모든 과정을 반복
    • 동기화 과정의 5단계에서 만약 10개의 카드가 리사이징되었다면, 모든 과정이 최소 10번 더 실행됨
  • 카드 내부 이미지 사이즈가 HTML에 미리 입력되지 않음
  • 성능 저하와 무관하게, 진료 기록의 Infinite Scroll 페이지네이션이 실제 사용 패턴에 적합하지 않음
 
해결 전략 #1 - 엘리먼트 사이즈 동기화 과정에서 발생하는 Layout thrashing 회피:
  • 접근 방법:
    • 동기화 과정 중 2~3단계의 로직을 SyncedSize 컴포넌트 외부에서 처리하도록 추출하고 요청을 throttling, 중복된 Read & Publish 작업을 최소한으로 수행하도록 개선
    • 동기화 과정 중 5단계의 로직을 window.requestAnimationFrame으로 throttling, 다수의 Write 작업을 브라우저가 동시에 수행하도록 개선
    • 위 접근 방법이 통한다면, 동기화 과정 중 5단계(리사이징)가 모든 과정을 다시 유발하는 것은 프레임당 1회에 불과하고 3단계 조건에 의해 무시될 것이므로, 이는 더 이상 문제로 작용하지 않는다고 판단해 기존 설계를 유지
  • 결과:
    • 동시에 발생하는 DOM 스타일 계산(Read)은 프레임당 1회만 실행되며, 동시에 발생하는 DOM 스타일 변경(Write) 또한 브라우저가 batch update 함으로써 프리징 현상 해소
    • 복잡한 조건을 추가해서 동기화 이벤트 수신에 의한 리사이징을 예외 처리하는 대신, 이해하기 쉬운 실행 흐름을 유지한 채로 문제 해결
 
해결 전략 #2 - 카드 내부 이미지 렌더링에 의한 Layout shift 회피:
  • 접근 방법:
    • 이미지 사이즈를 서버에서 받아 HTML에 입력
    • 원본에 가깝던 이미지 용량을 서버에서 썸네일 용으로 압축
  • 결과:
    • UI가 뒤늦게 밀리는 현상과 이로 인해 이어지는 리렌더링 차단
    • 이미지 로딩 속도 개선
 
해결 전략 #3 - 진료 기록 페이지네이션 UX 개선:
  • 접근 방법:
    • 페이지네이션 UX를 Infinite Scroll 기법에서 Virtual Scroll + Lazy Loading 기법으로 대체
      • Infinite Scroll 라이브러리를 제거하고 content-visibility: auto; 활용 (CSS Containment Module Level 2 | W3C)
      • 컨텐츠 사이즈를 보장할 수 없는 off-screen 카드를 고려해 SyncedSize 컴포넌트에 사이즈 축소를 차단하는 옵션 추가
      • 사용자가 원하는 위치에서 스크롤을 멈출 수 있도록 환자의 모든 진료일만 가져와 카드 없이 컬럼 구성
      • 사용자가 스크롤을 멈추면 잠시 대기 후(Debounce) 해당 스크롤 위치(진료일)의 진료 기록 데이터를 요청하고 카드 렌더링
  • 결과:
    • 반복된 스크롤 행위나 기다림 없이 한 번에 과거 진료 기록에 도달 가능, 추후 검색 및 특정 날짜로 이동 기능 제공 계획
    • 테스트 환경 및 시나리오 기준, 화면 바깥의 렌더링 생략으로 평균 60ms의 렌더링 시간 감소
    • 불필요한 페이징 데이터를 요청하지 않아 서버 트래픽과 클라이언트 메모리 사용량 감소
    • SSE 등에 의한 화면 밖의 카드 컨텐츠 변경 및 리사이징으로 발생하는 카드 재정렬을 무시, 사용자가 이해할 수 없는 UI 변경 차단 (렌더링 생략된 DOM tree는 on-screen·invalidate 될 때까지 ResizeObserver가 감지하지 않음)
    • 간단한 방법(content-visibility: auto;)으로 Virtual Scroll을 구현해 고객의 문제를 필요한 만큼의 솔루션으로 빠르게 해결 (다른 Virtual Scroll 솔루션 대비 DOM 메모리 사용량은 줄이지 못했으나, 이는 정의한 문제가 아님)
 
내 역할:
  • 문제 재현
  • 프로파일링
  • 해결 전략 제시
  • 리팩토링 및 성능·사용성 개선