들어가며
우리는 병원 시스템의 예약관리에 사용되는 예약 현황판 기능을 자체 구현 및 관리하고 있었다. 이 기능은 한 눈에 전체 예약들을 파악하고, 실시간으로 병원의 예약들을 관리하는 등 비즈니스적으로 중요한 화면이다.
앞으로의 확장성을 고려해서 라이브러리 전환 필요성을 느꼈고, 팀내에서 vue-cal 캘린더 라이브러리 도입을 결정했다. 그러고나서 기능적인 마이그레이션을 2주만에 달성했다.
라이브러리 전환은 병원에서의 추가 요구사항 대응, 더 나은 안정성을 위한 거였다.
구현난이도 축에서 두 라이브러리 모두 UI 라이브러리를 쓰는 것 처럼 선언적으로 이용할 수 있었고, 문서화도 기준을 통과했다. 성능은 약 50개의 데이터에서 테스트했을 때 느껴지는 차이가 없어 유사하다고 느꼈다.
아래 두 요소가 결정적인 기준이었다.
| 기준 | FullCalendar | vue-cal 5 |
|---|---|---|
| 라이선스 | 일부 기능 유료 | MIT (무료) |
| 배포 제한 | 유료 라이선스 필요 | 자유로운 배포 |
우리 서비스의 주요 비즈니스 구현에 필요한 기능이 fullCalendar는 유료인 반면 vue-cal은 무료로 제공되었다.
하지만 도입 후 2주 정도 지났을 때, 예약건수가 많은 병원에서 인터랙션이 느려지는 이슈가 리포트됐다... 
병원의 예약수가 많은 경우 주별 현황판에서 표시되는 개수가 300개가 넘어간다.
해당 환경의 유저들로부터 인터랙션에 불편함을 느껴 사용이 어려운 상황이었다. 이에 따라 기존 현황판 v1로 롤백한 기능을 지원하는 동안, 성능 개선에 집중해서 개발했다.
병목 찾기
분석 도구
두 가지 도구를 조합해 병목 지점을 추적했다.
1. Chrome DevTools Performance
- INP, LCP 측정을 통한 수치화
- rendering path에서 어느 작업에서 많은 시간이 소요되는지 빠르게 파악
INP(Interaction to Next Paint)는 인터랙션 응답 지연을 측정하는 Core Web Vitals 지표다. p75는 75번째 백분위수, 75%의 사용자가 이 값 이하를 경험한다
2. Vue application API로 컴포넌트별 성능 추적
컴포넌트 단위로 렌더링 시간을 측정해, 어떤 컴포넌트가 얼마나 자주 리렌더링되는지 추적
const app = createApp(App);
app.use({ install (app){ app.config.performance = true }})
발견한 원인
vue-cal 라이브러리 내부에서 캘린더에 등록한 전체 event들을 대상으로 과도한 리렌더링이 발생하고 있었다.
예를 들어 이벤트들 중 하나라도 이벤트 시작 날짜가 수정되면, 내부 이벤트 상태를 다시 계산하게 되면서 여기에 의존하는 컴포넌트들의 리렌더링이 발생하게 된다. 변경이 발생한 이벤트만 리렌더링 될 거라는 내 예상과 다르게 동작했다.
아래의 노란 블록들은 실제 event 컴포넌트에 대한 patch(Virtual DOM 변경 사항을 실제 DOM에 반영하는 과정) 동작이 발생했음을 나타낸다. 이 동작이 모든 이벤트에 대해 연쇄적으로 발생하고 있었다.
즉, 시스템에 등록한 이벤트가 300개 이상인 경우 그 수에 비례한 비용이 발생한다.

VueCal 단에서 관리되는 컴포넌트들은 아래의 트리 구조를 갖는다. 이벤트 상태의 변경은 연쇄적으로 Cell 및 Event 컴포넌트의 리렌더링을 트리거한다.

문제의 핵심은 "내 코드"가 아니라 "라이브러리 내부"에 있었다. 이 지점을 파악하는 것이 이후 최적화 방향을 결정하는 분기점이 됐다.
시도와 실패
라이브러리 레벨 문제라는 걸 파악하기 전, 먼저 일반적인 Vue 최적화 기법들을 적용했지만 효과가 미미했다. 
vue 최적화 기법
VueCal이 내부적으로 Event 컴포넌트를 관리하지만, slot으로 컴포넌트 커스텀이 쉽게 가능하다.
ScheduleTimeSlot은 커스텀용으로 라이브러리가 아닌 우리가 관리하는 컴포넌트이다.
<VueCal>
<template #event="{ event }: { event: IEvent }">
<ScheduleTimeSlot />
</template>
</VueCal>
이벤트 수에 비례해서 성능이 안좋아지다보니, ScheduleTimeSlot 컴포넌트의 개선 지점을 찾는다면 빠르게 성능을 개선하는 방법도 알아낼 수 있을 거라고 생각했다.
실제 시도한 방법은 4가지다.
- 불필요한 의존성 수 줄이기
- 불필요한 중복 계산 함수 호출 줄이기
- computed 줄이기
- 서버에서 데이터 미리 포맷팅해서 계산 줄이기
실제로 이벤트 당 2ms 개선했고, 이벤트 수가 많은 환경 기준으로 대략 0.5s 개선이 있었다. 눈에 띄는 개선은 아니기도 했고, 이 방법의 한계가 있다고 생각해서 다른 방법이 필요했다.
왜 효과가 없었을까?
이 최적화들은 모두 "내 코드에서 발생하는 불필요한 작업을 줄이는" 접근이었다. 하지만 병목은 vue-cal이 내부적인 상태 관리 방식에 있었기 때문에, 내 코드를 아무리 다듬어도 개선 폭이 제한적이었다.
방향 전환
아이디어
문제의 근본 원인이, 라이브러리 상태 관리 방식에 있었다. 따라서 "내 코드를 최적화"하는 게 아니라 "렌더링되는 컴포넌트 수 자체를 줄이는" 방향으로 전환했다.
고려한 선택지
1. content-visibility CSS 속성
content-visibility CSS 속성은 요소가 가진 콘텐츠를 모두 렌 더할지 여부를 제어하며, 강력한 독립성 규칙을 통하여 사용자 에이전트가 큰 규모의 레이아웃 및 렌더링을 필요로 할 때까지 이 작업을 잠재적으로 생략할 수 있도록 합니다. - MDN Web docs
이 방식으로는 리렌더링 컴포넌트 수가 많은 현재 상황에서는 근본적 해결이 어려웠다.
- vue 렌더 파이프라인

해결해야할 문제는 불필요한 렌더함수 실행 및 패치의 실행이었지만, 이 방식은 뷰 시스템의 렌더링 파이프라인 흐름이 아니라 브라우저 렌더링의 흐름을 제어하는 방식이였기 때문이다.
2. windowing
전체 데이터, 영상, 혹은 신호 처리에서 특정 구간이나 범위(Window)만 선택하여 처리하는 기법으로, 주로 데이터 처리, 컴퓨터 그래픽스, 신호 처리 분야에서 성능 최적화와 품질 향상에 사용된다.
이 개념을 우리 시스템에 맞게 적용시켰다.
처음엔 이를 지원하는 라이브러리(vue-virtual-scroller, vueuse - useVirtualList 등)를 고려했지만, 적용하기에는 어렵다고 판단했다. 해당 라이브러리들은 단일 축의 리스트 구조를 전제로 설계되어 있다. 하지만 우리 현황판은 요일 × 시간의 2차원 그리드 위에 이벤트가 배치되는 구조여서 적합하지 않았다.
이 개념으로부터 다음처럼 접근했다. 뷰포트 내에 표시되는 요일 셀의 이벤트들만 캘린더로 전달해 렌더링하고, 나머지 이벤트는 캐시만하자.
실제 구현은 IntersectionObserver를 활용해 요일별 이벤트 분리 저장 → 뷰포트에 보이는 요일 셀 감지 → 보이는 요일의 이벤트만 캘린더에 전달하는 3단계 구조로 진행했고, 그 결과 아래 목표를 달성했다.
- 리렌더링 대상 자체를 줄이는 근본적 해결
- 이벤트 수가 더 많은 환경에서도 안정적인 인터랙션 제공
구체적인 설계 판단과 구현 과정은 별도 글에서 다룰 예정이다.
개선 결과
예약 CRUD, 드래그 앤 드롭, 뷰 모드(일별/주별) 변경, 일자 내비게이션 등 주요 인터랙션 케이스에 대해 각 5번의 테스트를 반복해 분석한 결과, p75 기준 63% 개선이 있었다. (1,632ms -> 592ms)
아래는 그 중 하나의 케이스에 대한 분석결과다.
| before | after |
|---|---|
![]() | ![]() |
위 리포트 바탕으로 성능 분석을 했을 때, 메인 스레드 자원을 오랜 시간 잡아먹었던 script와 rendering 작업 순으로 소요시간이 대폭 개선됐다.
- Scripting: 1,604ms → 554ms (-1,050ms)
- Rendering: 387ms → 124ms (-263ms)
이 개선을 운영에 적용했고, 이벤트 수가 많은 곳에서도 인터랙션 지연 없이 서비스를 이용할 수 있게 됐다.
반 년이 지난 지금까지도 인터랙션에는 별다른 문제가 보고되지 않았다. (편-안 ☕️)
교훈
이번 경험에서 얻은 교훈은 명확하다.
1. 성능적인 부분의 목표치를 정하고 검증해야한다.
-
기능 요구사항, 비기능 요구사항 중에서는 기능 요구사항에 더 높은 우선순위를 두고 개발하는 경향이 있다. 이번에 리서치 단계에서 고려하지 못했던, 300개 이상의 예약을 관리하는 병원 케이스로부터 이슈가 발견됐다.
기능 요구사항 분석 뿐만 아니라 실제 유저 사용 케이스로부터 성능 요구사항 분석도 필수적임을 느꼈다.
2. 최적화 방향은 병목 위치가 결정한다
-
병목 지점이 어디인지에 따라 최적화 전략이 달라져야 한다 — 같은 "느리다"는 증상이라도 원인이 내 코드인지, 라이브러리인지, DOM 수인지에 따라 해결책이 완전히 다르다.
-
측정 → 원인 파악 → 전략 수립 — 이 순서를 지키는 것이 시간적인 비용이 들지만, 결국 가장 빠른 길이었다.


