![[React] Scroll event부터 Intersection Observer API까지](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOHJhB%2FbtsMBfRmwff%2FAyS7URx5HmM4YHseZkc4dk%2Fimg.jpg)
개요
Denamu 서비스를 개발하면서 포스트를 보여주는 기능을 만들어야 했다. 여러 페이지네이션 방식 중 무한 스크롤 방식이 있다는 것을 알게 되었고, 서비스와 결이 맞는 것 같아 구현해보기로 했다. 인터넷에서 찾아보니 Intersection Observer API와 React Query 조합이 많이 사용되는 것을 알게 되었고, 이 방식으로 무한 스크롤을 구현했다. 나중에 추가적으로 자료를 찾아보니 Intersection Observer API가 리플로우를 방지한다는 사실을 알게 되었고, 이게 왜 그런지 궁금증이 생겼다.
사실 최신 기술만 접하게 되다보면 이전 방식의 문제점과 기술이 어떻게 발전해왔는지 이해하기 어렵다. 그래서 무한 스크롤 구현 기술이 어떻게 발전해왔는지 살펴보고자 한다. 예전에 주로 사용하던 스크롤 이벤트 기반 방식부터 요즘에 사용되는 Intersection Observer API와 React Query를 활용해 어떻게 최적화할 수 있는지 단계별로 알아볼 것이다.
무한 스크롤?
무한 스크롤은 사용자가 페이지 하단에 도달했을 때 추가 콘텐츠를 자동으로 로드하는 UX 패턴이다. 전통적인 페이지네이션$_{Pagination}$처럼 다음 페이지 버튼을 클릭하는 방식과 달리, 무한 스크롤은 사용자가 한 페이지 내에서 스크롤 동작만으로 새로운 콘텐츠를 끊김 없이 계속해서 볼 수 있게 해준다. 이런 사용자 경험은 인스타그램이나 페이스북 같은 SNS 피드에서 쉽게 찾아볼 수 있다. 콘텐츠를 찾는 과정이 더 자연스럽고 연속적으로 느껴지도록 한다.
scroll event 기반 구현
무한 스크롤은 초기에 scroll
이벤트 리스너 방식으로 구현했다. 사용자의 스크롤 위치와 문서의 전체 높이를 비교해 페이지 하단에 도달했는지 확인한다. 스크롤 이벤트만 사용해 구현하는 것은 정말 단순하게 스크롤이 발생할 때마다 즉시 이벤트 핸들러가 실행된다. 구현이 간단하지만, 성능 측면에서는 비효율적일 수 있는 방법이다.
const useScrollInfiniteRaw = (fetchNextPage, hasNextPage, isFetchingNextPage, threshold = 0.8) => {
const [isNearBottom, setIsNearBottom] = useState(false);
useEffect(() => {
const handleScroll = () => {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const scrollHeight = document.documentElement.scrollHeight;
const clientHeight = document.documentElement.clientHeight;
// 스크롤이 하단에 가까워졌는지 확인
const isNearBottom = (scrollTop + clientHeight) / scrollHeight > threshold;
setIsNearBottom(isNearBottom);
if (isNearBottom && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [fetchNextPage, hasNextPage, isFetchingNextPage, threshold]);
return { isNearBottom };
};
useScrollInfiniteRaw
훅은 스로틀링 없이 모든 스크롤 이벤트를 처리하고 매 이벤트마다 현재 스크롤 위치를 계산해 페이지 하단 도달 여부를 확인한다. 이러한 방식은 의도치 않게 여러 문제점이 생길 수 있다. 스크롤 이벤트가 매우 빈번하게 발생하고(초당 수십~수백 회), 사용자가 스크롤할 때마다 연속적으로 이벤트 핸들러가 호출된다. scrollTop
, scrollHeight
, clientHeight
같은 레이아웃 관련 속성을 읽을 때마다 브라우저는 최신 값 계산을 위해 리플로우$_{reflow}$를 발생시킨다.
React 환경에서는 성능 문제가 발생할 수 있다. 앞서 본 코드에서 setIsNearBottom
같은 상태 업데이트가 스크롤할 때마다 실행되면, 불필요한 컴포넌트 리렌더링이 빈번하게 발생한다.
또 다른 문제점은 초기 데이터가 화면을 채우지 못하는 경우에 발생한다. 예를 들어 처음에 로드된 게시물이 2-3개뿐이고 그 높이가 화면보다 작다면 브라우저에는 스크롤바가 생성되지 않는다. 스크롤바가 없으니 사용자는 스크롤을 할 수 없고 스크롤 이벤트도 발생하지 않는다. 그 결과 페이지 끝에 도달했을 때 데이터를 추가적으로 로드하는 로직이 실행되지 않아 초기에 보이는 적은 콘텐츠 외에는 데이터를 볼 수 없게 된다.
스로틀과 디바운스 적용
스크롤 이벤트가 너무 자주 발생하면서 성능 문제가 생겼다. 조사해보니 이런 문제를 해결하기 위한 두 가지 방법인 스로틀$_{Throttle}$과 디바운스$_{Debounce}$가 있다는 것을 알게 되었다. 디바운스는 이벤트를 그룹화해 특정 시간이 지난 후에 하나의 이벤트만 실행되게 하는 기술이고, 스로틀은 일정 시간 간격으로 이벤트를 한 번씩만 실행되도록 제한하는 기술이다.
여러 자료를 찾아보니 스로틀이 디바운스보다 비교적 적합하다는 것을 알게 되었다. 디바운스는 이벤트가 계속 발생하면 타임아웃이 계속 갱신되어 실제 이벤트 처리가 무한히 지연될 수 있는 반면, 스로틀은 최소한 일정 주기마다 이벤트 실행을 보장해주기 때문이다.
스로틀링을 적용하면 이벤트 핸들러의 실행 빈도를 제한할 수 있어 스크롤과 같이 빈번하게 발생하는 이벤트를 다룰 때 구현 과정에서 스크롤 이벤트 핸들러가 200밀리초마다 한 번씩만 실행되도록 설정했다. 이를 통해 불필요한 연산을 상당히 줄일 수 있었다.
useScrollInfinite 훅을 사용해 현재 스크롤 위치를 계산하고 페이지 하단에 도달했는지 확인한다. 이 계산은 window.scrollY
(또는 document.documentElement.scrollTop
), documentElement.scrollHeight
, documentElement.clientHeight
값을 사용해 이루어지고 설정한 threshold
값에 따라 다음 페이지를 로드할지 결정한다.
function throttle<T extends (...args: unknown[]) => unknown>(func: T, delay: number): (...args: Parameters<T>) => void {
let lastCall = 0;
return (...args: Parameters<T>) => {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
func(...args);
}
};
}
const useScrollInfinite = (fetchNextPage, hasNextPage, isFetchingNextPage, threshold = 0.8) => {
const [isNearBottom, setIsNearBottom] = useState(false);
useEffect(() => {
const handleScroll = throttle(() => {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const scrollHeight = document.documentElement.scrollHeight;
const clientHeight = document.documentElement.clientHeight;
// 스크롤이 하단에 가까워졌는지 확인
const isNearBottom = (scrollTop + clientHeight) / scrollHeight > threshold;
setIsNearBottom(isNearBottom);
if (isNearBottom && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, 200); // 200ms 간격으로 스로틀링
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [fetchNextPage, hasNextPage, isFetchingNextPage, threshold]);
return { isNearBottom };
};
스로틀을 적용함으로써 스크롤 이벤트 핸들러 호출 빈도가 감소해 성능이 향상되었다. 불필요한 계산과 API 호출도 줄어들면서 개선되었다. 그러나 이 방식에도 한계가 존재한다. 스로틀은 Date.now()
를 사용해 시간을 비교하는 방식으로, 이벤트 발생 시점에 항상 시간을 체크해야 한다는 점과 리플로우 문제가 완전히 해결되지 않는다는 한계가 있다. 또한 스로틀을 사용한 방식은 일반적인 자바스크립트 이벤트 처리와 마찬가지로 메인 스레드에서 실행된다. 자바스크립트는 기본적으로 싱글 스레드로 동작하기 때문에 스크롤 이벤트 핸들러와 스로틀링 함수는 UI 렌더링과 사용자 입력 처리 등이 수행되는 것과 같은 메인 스레드에서 실행된다. 따라서 복잡한 계산이 포함된 스크롤 이벤트 처리는 여전히 성능에 영향을 미칠 수 있다.
Intersection Observer API
IntersectionObserver API는 요소가 뷰포트에 들어오거나 나갈 때 비동기적으로 이벤트를 발생시키는 브라우저 API다. 이 API를 사용하면 스크롤 이벤트를 직접 처리하는 대신 브라우저가 최적화된 방식으로 요소의 가시성 변화를 감지할 수 있다.
IntersectionObserver는 별도의 스레드에서 동작하기 때문에 메인 스레드의 성능에 영향을 주지 않고 브라우저가 최적의 타이밍에 관찰을 수행해 불필요한 이벤트 발생을 최소화할 수 있다.
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.3, rootMargin: "100px" }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
성능 측정
성능 측정 지표
성능을 정확하게 비교하기 위해 여러 가지 측정 지표를 사용했다. 렌더링 횟수는 컴포넌트가 다시 그려진 총 횟수다. 마지막 렌더링 시간은 가장 최근 렌더링에 소요된 시간을 밀리초 단위로 측정해 성능을 평가한다. 평균 렌더링 시간은 전체 렌더링의 평균 소요 시간을 계산하고 총 렌더링 시간은 모든 렌더링에 소요된 누적 시간을 측정한다. 추가로 자바스크립트 힙 메모리 사용량을 메가바이트 단위로 측정해 메모리 효율성도 평가했다.
성능 측정 구현
usePerformanceMeasure 훅은 컴포넌트의 렌더링 성능을 측정하기 위해 작성했다. 먼저 컴포넌트의 초기 상태를 설정한다. 이때 렌더링 횟수, 마지막 렌더링 시간, 평균 렌더링 시간, 총 렌더링 시간, 메모리 사용량 등의 메트릭을 포함하는 상태를 만든다. 값들이 렌더링 사이에서 유지되어야 하므로 useRef를 사용해 관리한다.
컴포넌트가 마운트되면 useEffect를 통해 언마운트 시점에 실행될 cleanup 함수를 등록한다. 컴포넌트의 생명주기가 끝날 때 최종적인 성능 정보를 계산하고 출력하는 역할을 한다. 실제 렌더링 시간 측정은 performance.now()
를 사용해 이루어지는데, 첫 렌더링에서는 시작 시간만 기록하고 이후 렌더링에서 종료 시간을 측정해 렌더링 시간을 계산한다. 이렇게 측정된 시간은 누적되어 총 렌더링 시간과 평균 렌더링 시간 계산에 사용된다.
메모리 사용량 측정은 브라우저의 performance.memory API를 통해 이루어진다. 측정된 메모리 사용량은 메가바이트 단위로 변환되어 저장된다. 성능 메트릭의 업데이트는 매 10회 렌더링마다, 또는 초기 5회 렌더링 동안 이루어지고 업데이트된 메트릭은 콘솔에 출력됨과 동시에 컴포넌트의 상태로 저장된다.
export const usePerformanceMeasure = (name: string, enabled: boolean = true): PerformanceMetrics => {
const [metrics, setMetrics] = useState<PerformanceMetrics>({
renderCount: 0,
lastRenderTime: null,
averageRenderTime: null,
totalRenderTime: 0,
memoryUsage: null,
});
const startTimeRef = useRef<number | null>(null);
const renderCountRef = useRef(0);
const totalRenderTimeRef = useRef(0);
const lastRenderTimeRef = useRef<number | null>(null);
const isFirstRenderRef = useRef(true);
// 컴포넌트 마운트 시 한 번만 실행
useEffect(() => {
if (!enabled) return;
return () => {
const averageRenderTime = renderCountRef.current > 0 ? totalRenderTimeRef.current / renderCountRef.current : null;
const finalMetrics: PerformanceMetrics = {
renderCount: renderCountRef.current,
lastRenderTime: lastRenderTimeRef.current,
averageRenderTime: averageRenderTime,
totalRenderTime: totalRenderTimeRef.current,
memoryUsage: metrics.memoryUsage,
};
console.log(`[Performance: ${name}] Final Metrics:`, finalMetrics);
};
}, []);
// 렌더링 시작 시간 기록 및 측정
if (enabled && isFirstRenderRef.current) {
startTimeRef.current = performance.now();
isFirstRenderRef.current = false;
} else if (enabled && startTimeRef.current !== null) {
const endTime = performance.now();
const renderTime = endTime - startTimeRef.current;
// 렌더링 횟수 및 총 렌더링 시간 업데이트
renderCountRef.current += 1;
totalRenderTimeRef.current += renderTime;
lastRenderTimeRef.current = renderTime;
let memoryUsage = null;
const extendedPerformance = performance as ExtendedPerformance;
if (extendedPerformance.memory) {
memoryUsage = extendedPerformance.memory.usedJSHeapSize / (1024 * 1024);
}
// 평균 렌더링 시간 계산
const averageRenderTime = totalRenderTimeRef.current / renderCountRef.current;
// 메트릭 업데이트 (주기적으로)
if (renderCountRef.current % 10 === 0 || renderCountRef.current <= 5) {
const updatedMetrics: PerformanceMetrics = {
renderCount: renderCountRef.current,
lastRenderTime: renderTime,
averageRenderTime: averageRenderTime,
totalRenderTime: totalRenderTimeRef.current,
memoryUsage,
};
console.log(`[Performance: ${name}]`, updatedMetrics);
setMetrics(updatedMetrics);
}
startTimeRef.current = performance.now();
}
return metrics;
};
성능 측정 결과 저장
성능 측정 결과의 저장은 Zustand 상태 관리 라이브러리를 통해 구현된다. 스토어는 세 가지 구현 방식에 대한 메트릭을 각각 관리하고 IntersectionObserver 기반, 스로틀링 적용 스크롤 이벤트 기반, 순수 스크롤 이벤트 기반 구현의 성능 데이터를 독립적으로 저장한다. 각 메트릭에 대한 업데이트 함수가 제공되고 모든 메트릭을 초기화할 수 있는 resetAllMetrics
함수도 포함된다.
Zustand의 persist 미들웨어로 측정된 성능 데이터는 로컬 스토리지에 자동으로 저장되고 페이지가 새로고침되거나 브라우저가 다시 시작되어도 데이터가 유지된다. 이러한 영속성 처리를 통해 장시간에 걸친 성능 비교 실험이 가능해진다.
// 성능 측정 결과 저장을 위한 Zustand 스토어
export const usePerformanceStore = create<PerformanceState>()(
persist(
(set) => ({
intersectionMetrics: { ...initialMetrics },
throttledScrollMetrics: { ...initialMetrics },
rawScrollMetrics: { ...initialMetrics },
updateIntersectionMetrics: (metrics) =>
set((state) => ({
intersectionMetrics: { ...state.intersectionMetrics, ...normalizeMetrics(metrics) },
})),
updateThrottledScrollMetrics: (metrics) =>
set((state) => ({
throttledScrollMetrics: { ...state.throttledScrollMetrics, ...normalizeMetrics(metrics) },
})),
updateRawScrollMetrics: (metrics) =>
set((state) => ({
rawScrollMetrics: { ...state.rawScrollMetrics, ...normalizeMetrics(metrics) },
})),
resetAllMetrics: () =>
set({
intersectionMetrics: { ...initialMetrics },
throttledScrollMetrics: { ...initialMetrics },
rawScrollMetrics: { ...initialMetrics },
}),
}),
{
name: "performance-metrics-storage",
storage: createJSONStorage(() => localStorage),
}
)
);
성능 측정 결과 표시
성능 측정 결과를 시각화하고 분석하기 위해 PerformanceDisplay 컴포넌트를 구현했다. 숫자 데이터가 잘 표시될 수 있도록 null이나 undefined 값을 "0"으로 처리하고, 숫자가 아닌 값도 "0"으로 변환한다. 유효한 숫자는 소수점 둘째 자리까지 표시하도록 구현되어 있다. 성능 차이를 계산할 때는 두 메트릭 간의 차이를 계산하되 null 값이 있는 경우 "측정 중..."으로 표시하고, 차이가 없는 경우 "동일"로 표시한다. 양수인 경우에는 "+" 기호를 붙여 직관적인 비교가 가능하도록 했다. 측정을 새롭게 시작할 수 있도록 모든 메트릭을 초기화하는 버튼도 구현했다.
export const PerformanceDisplay = ({
intersectionMetrics,
throttledScrollMetrics,
rawScrollMetrics,
}: PerformanceDisplayProps) => {
const [isOpen, setIsOpen] = useState(false);
const { resetAllMetrics } = usePerformanceStore();
// 안전하게 숫자 포맷팅하는 함수
const safeFormat = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "0";
if (typeof value !== "number") return "0";
return value.toFixed(2);
};
// 성능 차이 계산
const calculateDiff = (metric1: number | null, metric2: number | null): string => {
if (metric1 === null || metric2 === null) return "측정 중...";
if (typeof metric1 !== "number" || typeof metric2 !== "number") return "측정 중...";
const diff = metric1 - metric2;
return diff === 0 ? "동일" : diff > 0 ? `+${diff.toFixed(2)}` : diff.toFixed(2);
};
// 측정 결과 초기화 핸들러
const handleResetMetrics = () => {
resetAllMetrics();
setIsOpen(false);
};
// 컴포넌트 렌더링 (UI 부분 생략)
// ...
};
주요 성능 지표는 아래와 같다.
renderCount
: 컴포넌트가 렌더링된 총 횟수. 낮을수록 효율적이다.
lastRenderTime
: 가장 최근 렌더링에 소요된 시간(ms). 이 값은 개별 렌더링의 성능을 보여준다.
averageRenderTime
: 평균 렌더링 시간(ms). 전체적인 렌더링 성능을 나타내는 지표다.
totalRenderTime
: 모든 렌더링에 소요된 총 시간(ms). 누적된 성능 비용을 보여준다.
memoryUsage
: 메모리 사용량(MB). 두 방식 간 메모리 사용 차이를 보여준다.
측정 결과를 분석하면, IntersectionObserver
의 이점은 Scroll Event 방식보다 렌더링 횟수가 크게 적다는 점이다. 개별 렌더링 시간이 약간 더 길어도 몇 밀리초 차이는 사용자가 체감하기 어렵지만, 전체 렌더링 횟수 감소는 CPU 사용량을 줄여줘 다른 컴포넌트에서 사용할 수 있는 리소스를 확보할 수 있을 것이다. 코드적으로도 유지보수가 쉬워지기 때문에 더 효율적인 선택이라고 볼 수 있을 것 같다.
'Web, Front-end > 문제 해결' 카테고리의 다른 글
테스트 커버리지가 제대로 인식되지 않는 현상 해결 (0) | 2025.01.19 |
---|---|
[React] 좋아요 기능 버그 해결 및 서버 데이터 활용 (0) | 2024.12.23 |
[Javascript] 브라우저 팝업 차단으로 인한 문제와 해결책 (0) | 2024.12.01 |
[Typescript] 사진과 영상을 FormData로 서버에 전송하기 (6) | 2024.09.19 |
컴퓨터 전공 관련, 프론트엔드 개발 지식들을 공유합니다. React, Javascript를 다룰 줄 알며 요즘에는 Typescript에도 관심이 생겨 공부하고 있습니다. 서로 소통하면서 프로젝트 하는 것을 즐기며 많은 대외활동으로 개발 능력과 소프트 스킬을 다듬어나가고 있습니다.
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!