들어가며
vue-cal 기반 캘린더 예약 현황판에서 이벤트가 300개 이상 등록되면 인터랙션이 급격히 느려지는 문제가 있었다. 병목은 라이브러리 내부에서 전체 이벤트를 대상으로 리렌더링이 발생하는 것이었고, 라이브러리 코드를 직접 수정할 수 없었기에 렌더링할 이벤트 수 자체를 줄이는 방향을 잡았다.
이전 글에서 이 병목의 원인 분석과 해결책을 찾아가는 과정을 다뤘고, 이 글에서는 그 전략을 구체적으로 어떻게 설계하고 구현했는지를 다룬다.
핵심 아이디어는 windowing 개념 자체로, 아래의 의미를 가진다.
전체 데이터, 영상, 혹은 신호 처리에서 특정 구간이나 범위(Window)만 선택하여 처리하는 기법으로, 주로 데이터 처리, 컴퓨터 그래픽스, 신호 처리 분야에서 성능 최적화와 품질 향상에 사용된다.
프론트엔드 맥락에서는 화면에 보이는 영역(뷰포트)에 해당하는 요소만 렌더링하고, 보이지 않는 요소는 렌더링에서 제외하여 DOM 수를 줄이는 기법을 의미한다.
이로부터 "뷰포트에 보이는 요일의 이벤트만 렌더링" 하고 이외의 요일에 대한 이벤트는 당장 보여줄 필요가 없기 때문에 렌더링하지 않는 방향으로 설계를 진행했다.
windowing 구현
windowing 구현을 역할에 따라 세 개의 레이어로 나눴다. 월 50개, 화 45개, 수 55개, 목 48개, 금 52개로 총 이벤트 개수가 250개이며, 수요일·목요일 셀이 뷰포트 안에 있는 상태라고 가정하자.

이 설계대로면 전체 이벤트 250개가 저장되어 있지만, 실제 화면에 그려지는 이벤트는 (수 55 + 목 48 =) 103개뿐이다. 각 레이어의 역할을 살펴보자.
왜 요일 단위인가? 캘린더는 요일 × 시간의 2차원 그리드이므로, 열(요일) 단위가 자연스러운 관찰 단위다.
데이터 레이어 — 요일별 이벤트 분류
(1) 주간 이벤트
모든 요일에 대한 이벤트를 저장하고 있다. 뷰포트에 어떻게 보이고 있는지는 관심사가 아니다.
전체 이벤트를 요일별(sun~sat)로 분리 저장한다. 데이터 자체는 항상 메모리에 유지하면서, 요일 키로 빠르게 접근할 수 있게 관리하기 위함이다.
이를 통해 특정 요일 셀이 뷰포트에 들어오는 순간 즉시 렌더링이 가능하다.
(2) 렌더링 이벤트
주간 이벤트에 저장된 것들 중, 뷰포트에 표시할 이벤트들을 저장한다.
관찰 레이어 — IntersectionObserver
IntersectionObserver로 vue-cal의 요일 셀(.vuecal__cell--{weekday})이 뷰포트에 진입/이탈하는 걸 감지한다.
viewDays(즉시 반영)와debouncedViewDays(200ms 디바운스) 두 상태를 분리했다. 디바운스 없이 viewDays가 변경되면 많은 렌더링을 유발하면서, 스크롤 사용성이 떨어지기 때문에 디바운싱이 필수적이었다.
import { onUnmounted, ref, Ref } from 'vue'
import { watchDebounced } from '@vueuse/core'
interface ObserverParam {
delay: number
getDayFromTarget: (target: Element) => string
}
const weekdays = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const
function isWeekdayType(text: string): text is (typeof weekdays)[number] {
return weekdays.some((weekday) => weekday === text)
}
export default function useObserver({ delay = 200, getDayFromTarget }: ObserverParam) {
let observer: IntersectionObserver | null = null
const viewDays = ref(new Set<string>())
const debouncedViewDays = ref(new Set<string>())
function reset() {
viewDays.value.clear()
debouncedViewDays.value.clear()
observer?.disconnect()
}
function init(root: HTMLElement, targets: HTMLElement[]) {
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const day = getDayFromTarget(entry.target)
if (!isWeekdayType(day)) return
if (entry.isIntersecting) {
viewDays.value.add(day)
} else {
viewDays.value.delete(day)
}
})
},
{ threshold: 0, root },
)
targets.forEach((target) => observer?.observe(target))
}
watchDebounced(
viewDays.value,
() => {
debouncedViewDays.value = new Set(viewDays.value)
},
{ debounce: delay, deep: true, immediate: true },
)
onUnmounted(() => reset())
return { viewDays, debouncedViewDays, init, reset }
}
비즈니스와 분리되는 기능이기 때문에, composable로 분리하기 좋은 녀석이다.
렌더링 레이어 — computed 필터링
debouncedViewDays에 포함된 요일의 이벤트만 캘린더에 전달한다. 이 분기 하나가 실제 화면에 표시할 이벤트 수를 제어하는 핵심이다.
import { computed } from 'vue'
// weekdayEvents: 요일별로 분류된 이벤트 맵
// Record<Weekday, CalendarEvent[]>
const renderEvents = computed(() => {
const result: CalendarEvent[] = []
debouncedViewDays.value.forEach((day) => {
result.push(...weekdayEvents.value[day])
})
return result
})
주간 뷰 기준으로 한 화면에 보이는 요일은 최대 7일이며, 많으면 약 300개의 이벤트가 등록된다.
뷰포트에 해당하는 요일만 필터링하면, 캘린더에 표시되는 이벤트는 약 절반 이상이 줄어든다. 캘린더가 한 번에 처리하는 이벤트가 절반 이하로 감소하는 것이다.
구현에서 마주친 문제
레이어를 나누고 연결함으로써 코어 기능 얘기는 끝이지만, 적용하면서 마주칠 수 있는 문제들이 남았다..! 
관찰 레이어를 실제로 동작시키면서 마주치는 엣지케이스를 짚어보자
Observer 생명주기 관리
주별 현황판은 주차를 변경하는 내비게이션이 탑재되어 있다. 주차가 변경되면 캘린더 전체가 새롭게 그려지기 때문에, 기존 DOM에 대한 구독을 해제하고 새 DOM을 다시 구독해야 한다.
이 시점을 놓치면 예상한 시점에 이벤트가 안 그려지거나, 이전 DOM에 대한 참조가 남아 메모리가 누수되는 문제가 생길 수 있다.
(이러한 디테일한 생명주기 관리는 처음 보는 사람에게 어렵고, 하물며 내가 봐도 어렵기 때문에 composable로 깔끔하게 추상화하는 걸 추천한다.
)
스크롤 시 빈 화면 — 로딩 피드백
앞서 관찰레이어에서 사용성 개선을 위해 디바운스를 적용했었다.
하지만 이 동작은 또 다른 사용성 문제를 낳았다. 스크롤 직후 지연시간 동안 "빈 화면"이 보였다.
문제를 해결하고자 viewDays, debouncedViewDays 두 상태의 업데이트 시점이 다른 점을 이용해서, 로딩 스피너 표시를 제어했다. 상태 흐름을 정리하면 다음과 같다.

viewDays에는 있지만 debouncedViewDays에는 아직 없는 요일이 "로딩 중"인 요일이다.
ex.
viewDays = ['월', '화', '목'], debouncedViewDays = ['월', '화'] 인 경우 차집합인 '목'요일은 로딩 예정인 요일로 가정하고 빠른 피드백을 제공한다.
마무리
windowing의 가치는 기존 라이브러리를 교체하지 않고도 렌더링 비용을 줄일 수 있다는 점에 있다. 라이브러리 내부를 건드리지 못하는 상황에서, 라이브러리에 전달하는 데이터 자체를 제어하는 방식으로 문제를 우회했다.
이처럼 렌더링 대상의 양을 줄이는 건 의미 있는 개선이었다. INP p75 기준 1,632ms에서 592ms로, 약 63% 더 빠른 인터랙션을 제공했다.
이전 글과 이어졌던 이 글을 끝으로 측정 → 원인 → 전략 → 구현으로 이어지는 뷰캘 적용기는 여기서 마무리한다.
