문제 상황
클럽 통계 페이지에 차트가 8개 있었습니다. 각 차트는 서로 다른 API를 호출해 데이터를 가져오는 구조였는데, 페이지에 진입하는 순간 8개의 API 요청이 동시에 발생했습니다.
초기 로드 시간이 10~30초까지 걸렸습니다. 페이지가 뜨긴 하는데 데이터가 없는 빈 차트들이 먼저 보이다가 하나씩 채워지는 식이었습니다. QA를 하면서도, 직접 써보면서도 명확하게 체감이 됐습니다. 이건 그냥 두면 안 되겠다 싶었습니다.
1차 시도: API 분리와 캐싱
처음에는 서버 쪽에서 해결하려 했습니다. 백엔드와 논의해서 병목이 가장 심한 API들을 먼저 파악했습니다.
가장 느린 두 가지 API가 문제였습니다. 전체 사용자 수와 전체 라운드 수를 집계하는 쿼리였는데, 데이터 양이 많아 쿼리 자체가 오래 걸리는 구조였습니다. 이 둘을 다른 API들과 분리해 독립적으로 로딩하도록 했고, 나머지 차트 API에는 캐싱을 적용해 재방문 시 빠르게 불러올 수 있게 했습니다.
지속적으로 느린 현상은 해결됐습니다. 그런데 Lighthouse로 측정해보니 여전히 성능 점수가 낮았습니다.
특히 TBT(Total Blocking Time) 수치가 높게 나왔습니다. TBT는 메인 스레드가 50ms 이상 블로킹된 시간의 합산인데, 8개의 API 요청과 차트 렌더링이 한꺼번에 일어나면서 메인 스레드가 계속 점유되는 것이 원인이었습니다. API 속도를 올려도 여전히 8개를 동시에 그리는 구조 자체가 문제였습니다. API만 빠르게 한다고 해결될 문제가 아니었습니다.
2차 시도: Intersection Observer
방향을 바꿔서 생각해봤습니다. 사용자는 페이지에 진입했을 때 화면에 보이는 차트 1~2개만 봅니다. 스크롤을 내리지 않으면 나머지 차트는 보이지도 않습니다. 그런데 안 보이는 차트 6개의 API도 지금 당장 전부 불러오고 있었습니다.
뷰포트에 들어오는 시점에만 해당 차트의 API를 호출한다면?
이 방식을 구현하려면 DOM 요소가 화면에 들어오는 타이밍을 감지해야 합니다. 여기서 선택지가 둘이었습니다.
스크롤 이벤트 vs Intersection Observer
처음에는 스크롤 이벤트를 떠올렸습니다. 스크롤할 때마다 각 차트의 위치를 계산해서 뷰포트 안에 있으면 API를 호출하는 방식이었습니다.
그런데 이 방식의 문제가 보였습니다. 스크롤 이벤트는 스크롤하는 내내 계속 발생합니다. 이벤트가 발생할 때마다 8개 차트에 대해 getBoundingClientRect() 를 호출하면 레이아웃 재계산이 반복됩니다. 성능을 개선하려고 도입하는데 오히려 또 다른 성능 문제를 만들 수 있는 상황이었습니다.
Intersection Observer는 이 문제를 다른 방식으로 접근합니다. 브라우저에게 "이 요소가 뷰포트와 교차하면 알려줘"라고 등록해두면, 브라우저가 내부적으로 감시하다가 교차 상태가 바뀔 때만 콜백을 실행해줍니다.
| 구분 | 스크롤 이벤트 | Intersection Observer |
|---|---|---|
| 실행 빈도 | 스크롤마다 지속 실행 | 교차 상태 변경 시에만 실행 |
| 성능 비용 | 메인 스레드에서 직접 계산 | 브라우저 내부에서 비동기 처리 |
| 구현 복잡도 | getBoundingClientRect() 직접 계산 필요 | API에 요소만 등록하면 됨 |
결국 저는 Intersection Observer를 선택했습니다.
구현
각 차트 DOM 요소를 chartRefs 배열에 모아두고, 페이지가 마운트될 때 Observer에 등록합니다.
const observer = new IntersectionObserver(handleIntersection);
onMounted(() => {
chartRefs.value.forEach((el) => {
if (el) observer.observe(el);
});
});
onUnmounted(() => {
observer.disconnect();
});
교차 감지 콜백에서는 뷰포트에 들어온 요소의 인덱스를 찾아 해당 API만 호출합니다.
const handleIntersection = (entries) => {
entries.forEach((entry) => {
const { isIntersecting } = entry;
if (isIntersecting) {
const index = chartRefs.value.indexOf(entry.target);
// 이미 데이터가 있으면 재요청하지 않음
if (chartDataList.value[index]) return;
if (index !== -1) {
fetchData(index, formData);
}
}
});
};
const fetchData = async (chartNo, formData) => {
try {
const params = { chartNo: chartNo + 1, formData };
await execute(params);
chartDataList.value[chartNo] = data.value;
} catch (error) {
console.error("Error fetching data:", error);
}
};
중요한 부분이 두 곳입니다.
중복 요청 방지 : chartDataList[index] 에 이미 데이터가 있으면 API를 다시 호출하지 않습니다. 사용자가 스크롤을 위아래로 반복해도 한 번 불러온 차트는 다시 요청하지 않습니다. 처음 구현할 때 이 부분을 빠뜨렸다가 스크롤할 때마다 API가 중복으로 날아가는 걸 발견했습니다. 중복 호출 방지는 꼭 넣어야 합니다.
인덱스 기반 매핑 : chartRefs 배열에서 DOM 요소의 인덱스를 찾아 그 인덱스에 해당하는 API만 호출합니다. 각 차트가 어떤 데이터를 불러와야 하는지가 인덱스로 매핑되어 있어서, Observer 콜백 안에서 복잡한 상태 관리 없이도 어떤 차트인지 특정할 수 있습니다.
onUnmounted 에서 observer.disconnect() 를 호출해 정리하는 것도 중요합니다. 페이지를 떠났는데도 Observer가 살아있으면 메모리 누수로 이어질 수 있습니다.
결과
초기 로드 시 8개가 아니라 화면에 보이는 1~2개 차트의 API만 호출되면서, TBT가 약 90% 감소했습니다. 페이지를 처음 열었을 때 빈 차트가 한꺼번에 채워지던 경험도 없어졌습니다. 스크롤하면서 차트가 자연스럽게 로드되는 방식이 오히려 더 자연스러운 UX가 됐습니다.
마무리
이번 작업에서 배운 게 두 가지입니다.
하나는 문제의 레이어를 잘 봐야 한다는 것입니다. 처음에는 API가 느리다고 생각해서 API를 개선했습니다. 틀리지 않았지만 충분하지 않았습니다. 실제 문제는 8개를 동시에 처리하는 구조 자체에 있었습니다. 증상만 보고 원인을 특정하면 엉뚱한 곳을 고치게 될 수 있다는 걸 다시 한번 실감했습니다.
또 하나는 브라우저 API를 잘 활용하면 직접 구현하는 것보다 훨씬 효율적이라는 것입니다. 스크롤 이벤트로 비슷한 걸 만들 수 있지만, Intersection Observer는 브라우저가 이미 최적화된 방식으로 처리해줍니다. 이미지 지연 로딩, 무한 스크롤, 광고 노출 감지처럼 뷰포트 기반의 로직이 필요할 때마다 이제는 자연스럽게 먼저 떠오르게 됐습니다.