Alvin Gyumin Choi

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

소속: 에이치디정션 HD Junction

트루닥 EMR에서 성능 저하로 인해 환자의 진료 기록을 살펴보기 어렵다는 VoC가 수집되었습니다.

진료 타임라인을 스크롤하거나 데이터를 렌더링할 때마다 발생하던 최대 2초의 프리징을 재현하고, 반복되는 렌더링 과정의 병목을 제거, UX를 개선하여 문제를 해결했습니다.


진행 과정

고객으로부터 사용성에 심각한 악영향을 끼치는 성능 저하 현상을 수집

  • 약 1년간의 진료 기록을 보유한 환자의 진료 타임라인을 스크롤할 때마다 데이터 로딩 및 렌더링 과정에서 프리징 발생
  • 사용자는 과거 기록에 한 번에 도달할 방법이 없어 탐색할 때마다 ‘스크롤 → 프리징’ 과정을 반복
  • 이는 팀이 이미 알고 있던 문제로, 환자 1명의 데이터가 많고 ‘그리드 뷰’ 옵션이 활성화되어 있을 때 발생 (아래 진료 타임라인 UI 설명 참고)
  • 기존에는 데이터가 많지 않고 그리드 뷰 사용률도 낮아 추후 대응할 계획이었으나, 신규 고객이 타사 제품의 데이터를 마이그레이션하면서 예상보다 일찍 문제 발생
진료 타임라인 UI 이미지와 설명
간략화된 진료실 UI (출처: 트루닥 홈페이지)
  • 환자의 진료 기록을 날짜별로 나누어 컬럼으로 배치
  • 세로 스크롤로 진료 차트를, 가로 스크롤로 진료 이력을 탐색
  • 컬럼 안에 진단, 처방, 기록, 검사 결과 등의 카드를 위에서 아래로 나열 (= 리스트 뷰)
  • 카드 높이는 콘텐츠에 따라 달라지며, ‘보기 옵션’에서 ‘카드 정렬’을 활성화하면 카드 타입별 Y 좌표가 통일되어 가상의 행을 형성 (= 그리드 뷰)
  • 사용자가 진료 차트 시트에서 내용을 편집하거나 SSE(Server-Sent Events)로 내용이 변경되면 진료 타임라인에 실시간으로 반영

문제 재현 결과

  • 1년 동안 약 10,000개의 카드를 기록한 환자 테스트 데이터 구성
  • 그리드 뷰 옵션 활성화
  • 스크롤이 마지막에 도달해 다음 페이지의 데이터를 렌더링할 때 최대 2초 수준의 Long Task와 프리징 발생

프로파일링 결과: 반복되는 레이아웃 재계산을 주요 병목으로 확인

  • 그리드 뷰를 위한 엘리먼트 사이즈 동기화 과정에서 layout thrashing 발생
    1. 카드 리사이즈 감지
    2. 리사이징 된 카드 컴포넌트에서 같은 타입의 모든 카드 사이즈를 측정(Read)하고 비교
    3. 해당 카드가 가장 크다면 동기화 이벤트 발행(Publish)
    4. 동일 타입의 모든 카드 컴포넌트가 동기화 이벤트 수신
    5. 카드 리사이즈(Write)
    6. 카드 리사이징이 다시 1단계를 유발하며 반복
  • 카드 렌더링 후 내부 이미지가 로딩되면서 layout shift 발생
  • 이미지 layout shift가 다시 동기화 과정을 실행해 layout thrashing 반복

문제 진단

  • 엘리먼트 사이즈를 동기화하는 SyncedSize 컴포넌트의 실행 흐름이 제어되지 않음
    • 동기화 과정의 5단계에서 발생한 리사이징이 3단계의 조건을 충족하지 않을 때까지 전체 과정을 반복
    • 5단계에서 만약 10개 카드가 리사이징되면, 모든 과정이 최소 10번 더 실행됨
  • 카드 내부 이미지의 사이즈가 HTML에 미리 입력되지 않아 로딩 전 영역을 확보하지 못함
  • 프리징을 제거하더라도 사용자는 과거 기록에 도달하기 위해 여러 페이지를 순서대로 로딩해야 하므로, Infinite Scroll 방식이 실제 탐색 UX에 적합하지 않음

해결 시도 및 결과 #1: 카드 엘리먼트 사이즈 동기화 과정의 Layout thrashing 회피, 프리징 제거

  • 접근 방법:
    • 2단계(카드 사이즈 측정)와 3단계(동기화 이벤트 발행)를 SyncedSize 외부 인스턴스로 분리
    • 같은 프레임의 동기화 요청을 하나로 병합해 중복 Read·Publish 제거
    • 5단계(카드 리사이즈)를 window.requestAnimationFrame으로 예약해 같은 프레임에 일괄 반영
    • Write 이후 ResizeObserver가 다시 실행되더라도 최대 사이즈가 바뀌지 않았다면 새 동기화 이벤트를 발행하지 않도록 기존 조건 활용
  • 결과:
    • 컴포넌트마다 반복되던 DOM 측정과 이벤트 발행을 프레임당 한 번으로 축소
    • DOM Read와 Write를 분리해 강제 동기 레이아웃의 반복 차단
    • 복잡한 예외 조건을 추가하지 않고도 이해하기 쉬운 실행 흐름을 유지하며 프리징 제거

해결 시도 및 결과 #2: 카드 내부 이미지의 Layout Shift 회피, 사용자 입장에서 어색한 리렌더링 제거

  • 접근 방법:
    • 이미지 사이즈를 서버에서 받아 HTML에서 이미지 로딩 전에 미리 영역 확보
    • 원본에 가깝던 이미지를 서버에서 썸네일용으로 압축
  • 결과:
    • 이미지 로딩 후 발생하던 layout shift와 이어지는 동기화 실행 차단
    • 이미지 로딩 속도 개선

해결 시도 및 결과 #3: 진료일 기반 지연 로딩으로 페이지네이션 구현 변경, 탐색 UX 개선

  • 접근 방법:
    • Infinite Scroll 라이브러리 제거
    • 환자의 모든 진료일을 먼저 가져와 카드가 없는 컬럼을 구성하고, 중간 페이지의 데이터 로딩 없이 원하는 날짜까지 스크롤할 수 있도록 변경
    • 사용자가 스크롤을 멈추면 잠시 대기한 후(debounce) 해당 위치의 진료 기록을 요청하고 카드 렌더링
    • content-visibility: auto;를 적용해 화면 밖(off-screen) 콘텐츠의 레이아웃 및 렌더링 생략 (CSS Containment Module Level 2 | W3C), Virtual Scroll 기법을 흉내
    • 콘텐츠 사이즈를 보장할 수 없는 off-screen 카드를 고려해 SyncedSize 컴포넌트에 축소 차단(늘어난 사이즈 유지)하는 옵션 추가
  • 결과:
    • 거쳐가는 페이지의 로딩을 반복하지 않고 원하는 진료일까지 한 번에 스크롤하여 해당 위치의 진료 기록만 로딩
    • 추후 검색 및 특정 날짜로 이동하는 기능을 추가할 수 있는 탐색 구조 마련
    • 불필요한 페이징 데이터를 요청하지 않아 서버 트래픽과 클라이언트 메모리 사용량 감소
    • content-visibility로 생략된 콘텐츠는 off-screen 상태에서 리사이즈 이벤트를 유발하지 않으므로, SSE 등에 의한 화면 밖 카드 변경이 즉시 레이아웃 재정렬을 유발하는 현상 차단
    • 테스트 환경 및 시나리오 기준, 화면 밖 렌더링을 생략해 렌더링 작업 시간 60ms~120ms 감소

관련 문서
돌아가기