Project at 에이치디정션 (HD Junction): 트루닥 EMR (truedoc)
제품의 성능 저하로 환자의 진료 기록을 살펴보는 데 어려움을 겪고 있다는 VoC가 수집되었습니다.
진료 타임라인의 스크롤 및 데이터 렌더링 시 약 2초의 프리징을 반복해서 겪던 문제를 분석하고 해결 전략을 제시, 이를 직접 해결했습니다.
고객으로부터 사용성에 심각한 악영향을 끼치는 성능 저하 현상을 수집:
- 사용자가 약 1년 간의 진료 기록을 보유한 환자의 진료 타임라인을 스크롤 할 때마다, 데이터 로딩 및 렌더링 과정에서 프리징 발생
- 사용자는 과거 기록에 한 번에 도달할 방법이 없어 탐색 시 ‘스크롤 → 프리징’ 과정을 반복해서 겪어야 함
- 이는 팀이 이미 알고 있던 문제이며, 환자 1명에 대한 데이터가 많고 ‘그리드 뷰’ 옵션이 활성화 되어있을 때 발생함 (아래 진료 타임라인 UI 설명 참고)
- 아직 데이터가 많지 않고 그리드 뷰 사용률도 낮아 추후 대응하기로 계획되어 있었으나, 신규 고객이 기존에 사용하던 타사 제품의 데이터를 마이그레이션 함으로써 문제가 예상보다 일찍 발생, 빠르게 해결이 필요한 상황
이해를 돕기 위한 진료 타임라인 UI 설명
- 환자의 진료 기록을 날짜별로 나누어 컬럼으로 배치
- 세로 스크롤을 제어해 진료 차트를, 가로 스크롤(Infinite Scroll)을 제어해 진료 이력을 탐색
- 컬럼 안에 진단, 처방, 기록, 검사결과 등의 카드를 위에서 아래로 나열 (= 리스트 뷰)
- 카드의 높이는 컨텐츠에 따라 유동적이며, ‘보기 옵션’에서 ‘카드 정렬’을 활성화하면 카드 타입별로 Y 좌표가 통일되어 가상의 행을 형성 (= 그리드 뷰)
- 사용자가 진료 차트 시트에서 내용을 편집하거나 SSE(Server-Sent Events)에 의해 내용 변경이 발생하면 진료 타임라인에 실시간으로 반영
문제 재현 결과:
- 1년 동안 약 10,000개의 카드를 기록한 환자 테스트 데이터 구성
- 그리드 뷰 옵션 활성화
- 스크롤이 마지막에 도달해 다음 페이징 데이터를 렌더링할 때 최대 2초 수준의 프리징(Long Task) 발생
프로파일링 결과, 치명적인 Recalculate Style과 Layout 발견:
- 그리드 뷰를 구현하기 위한 엘리먼트 사이즈의 동기화 과정에서 Layout Thrashing 유발
- 카드 리사이징 감지
- 리사이징된 카드 컴포넌트에서 동일한 타입의 모든 카드 사이즈를 읽어 들여(Read) 사이즈 비교
- 리사이징된 카드의 사이즈가 가장 크다면, 해당 카드 컴포넌트에서 동기화 이벤트 발행
- 동일한 타입의 모든 카드 컴포넌트가 동기화 이벤트 수신
- 카드 리사이징(Write)
- 카드 리사이징에 의해 1단계로 돌아감
- 카드 렌더링 이후 카드 내부 이미지가 로딩되어 Layout Shift 유발, 이는 다시 Layout Thrashing으로 이어짐
문제 진단:
- 엘리먼트 사이즈를 동기화하는 구현체,
SyncedSize
컴포넌트의 실행 흐름이 제어되지 않음
- 카드 내부 이미지 사이즈가 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 메모리 사용량은 줄이지 못했으나, 이는 정의한 문제가 아님)
내 역할:
- 문제 재현
- 프로파일링
- 해결 전략 제시
- 리팩토링 및 성능·사용성 개선