1. 문제상황
웹뷰로 앱에서 동작할 수 있도록 구현 웹에서 위 화면처럼 높이를 조절할 수 있는 BottomSheet
를 구현했다. 하지만 List내부 아이템이 50개~100개 정도만 되더라도 드래그를 통해 높이를 변경할 때 버벅거리는 성능 문제가 발생하였다.
const handleMouseMove = (e) => {
const newHeight = calculateNewHeight(e);
setHeight(newHeight); // 매번 상태 업데이트 → 리렌더링 발생!
};
드래그를 통해 높이를 변경할 때 버벅이는 성능 문제는 setHeight
라는 state
의 변경에 따라 BottomSheet
컴포넌트가 리렌더링 되기 때문일 것이라고 생각하였다.
BottomSheet
를 구현할 때 처음에는 state
를 통해 높이변경을 하면 리렌더링이 많이 발생할 것이라고 생각하여 css의 transformY()
을 사용하려 했지만 이 경우 실제 높이를 변경하는 것이 아니라, 보이는 위치만 변경하는 것이었다.
하지만 BottomSheet
내부 List의 아이템의 개수는 동적이기 때문에 해당 리스트는 동적으로 선언이 된다면, 사용자가 원하는 위치에 있는 BottomSheet
구현이 어려웠다. 또한 웹과 웹뷰앱에서 둘 다 사용하는 반응형 웹인 구조였기 때문에, 전체 화면에서 overflow-y
에서 스크롤이 생기는 문제가 발생하였기 때문에, state
로 높이조절의 문제점을 알면서 state로
구현을 하게 되었다.
그렇다면 이 리렌더링 문제를 어떻게 해결할 수 있을까?
2. 초기 접근법과 문제점
우선 첫 번째로 "List내부에 아이템 개수가 많아지면서 버벅거리는 성능 문제가 나기 때문에, 초기 페이지 로드에서 아이템 개수를 줄여 해결하는 것은 어떨까?"라고 생각하였다.
기존 프로젝트에서 @tanstack/react-virtual
을 사용해서 최적화를 진행하기도 했었기 때문에, 가상화 무한 스크롤을 통해 초기 로드 시 아이템 개수를 줄여 최적화를 진행하였다.
// 가상화 적용 시도
import { useVirtualizer } from '@tanstack/react-virtual';
const virtualizer = useVirtualizer({
count: slopeData.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 120,
measureElement: (element) => element?.getBoundingClientRect().height,
overscan: 5,
});
하지만 가상화를 진행하면서 발생한 문제점은 높이를 지정해야 한다는 것이었다.
위코드에서 볼 수 있는 estimateSize: () => 120
부분에서 높이를 명확하게 지정해야 했다.
물론 동적으로 높이를 측정할 수도 있는 하다.measureElement: (element) => element?.getBoundingClientRect().height
를 통해 높이를 측정하고자 했지만, measureElement
는 렌더링 후 높이를 측정했을 때 기존 예측했던 값과 다르면 높이를 재측정한다 즉 높이가 계속 변동되는 BottomSheet
에서는 높이를 지속적으로 측정하며 오버헤드가 많이 발생하고, 심지어 가상화도 제대로 적용이 안 되는 상황에 도착한다.
가상화에 대해서는 아래 링크를 참고 하시면 됩니다.
TanStack Virtual
Virtualize only the visible content for massive scrollable DOM nodes at 60FPS in TS/JS, React, Vue, Solid, Svelte, Lit & Angular while retaining 100% control over markup and styles.
tanstack.com
"그렇다면 이 성능 문제를 어떻게 해결할 수 있을까?"
다시 처음으로 돌아가 문제 상황을 보았다.
- 동적으로 높이가 변경되는
BottomSheet
- 높이 state가 변경될 때마다 재 렌더링이 일어남.
- 재렌더링이 일어날 때 리스트 내부 많은 아이템이 있는 경우 성능 문제 발생
가상화 기법을 도입한 이유는 3번 문제상황을 해결하고자 도입했었다. 하지만 사실 2번 문제 때문에 3번 문제상황이 발생하는 것이 아닌가?
" 재 렌더링을 최대한 막자"로 타겟을 바꾸게 되었다. 오히려 가상화 기법 적용이 어렵게 되면서 다른 최적화 방법을 시도할 수 있게 된 것이다.
3. 스로틀링 최적화(requestAnimationFrame)
const optimizedHeight = useMemo(() => {
return calculateHeight(); // height가 바뀔 때마다 다시 계산됨
}, [height]); // height가 계속 바뀌니까 useMemo 무의미
const handleMove = useCallback((e) => {
const newHeight = calculateNewHeight(e);
setHeight(newHeight); // 결국 매번 상태 업데이트
}, [height]); // height 의존성 때문에 콜백도 매번 재생성
그냥 useMemo
와 useCallback
을 사용해서 최적화를 진행하면 안 되나?useMemo
와 useCallback
은 의존성이 변하지 않을 때만 최적화가 되는데, height
가 계속 바뀌면 의미가 없어진다.
1. 스로틀링(Throttling)이란?
보통 디바운스와 스로틀링을 같이 설명하기에, 이해를 위한 비교를 하기 위해 둘 다 간략히 소개하자면,
디바운스는 사용자가 검색을 할 때 입력이 끝나면 검색이 동작하는 것처럼 사용자 이벤트가 끝나면 실행되는 것을 말한다.
스로틀링은 함수의 호출 빈도를 제한하는 기법입니다. 평소에는 실행을 하지 않고, 일정한 간격으로 실행하는 것을 말한다.
그렇기에 스로틀링은 여러가지 방법이 있을 수 있다. 간단하게 setTimeOut
을 사용하여 스로틀링을 구현할 수도 있고, 다른 무수히 많은 방법으로 구현할 수 있다.
필자는 시간에 따라 일정하게 재 렌더링을 하기보다, requestAnimationFrame
라는 메스드를 통해 화면 주사율에 맞게 재 렌더링할 수 있도록 최적화를 진행하였다.
2. requestAnimationFrame()란?
requestAnimationFrame
은 브라우저에 내장된 Web API로, 화면이 다시 그려지기 직전에 함수를 실행해 주는 메서드이다.
화면 주사율이 60fps인 경우 16.7ms마다 한번 깜빡이는 것을 의미하는데, 60fps의 모니터에서requestAnimationFrame
은 16.7ms마다 한번 실행시키는 것을 의미한다.
"근데 js가 어떻게 모니터의 주사율을 측정할 수 있지?"
// 브라우저의 전역 객체 window에 있음
console.log(typeof window.requestAnimationFrame); // "function"
console.log(typeof requestAnimationFrame); // "function" (window. 생략 가능)
requestAnimationFrame
은 브라우저의 전역 객체 window에 있으며, Node.js에는 없다. Node.js 환경에서는 undefined를 출력한다. 브라우저가 모니터의 주사율을 측정한다고 할 수 있다.
이렇게 위에서 설명한 스로틀링 개념과 requestAnimationFrame
메서드를 이용해서 높이 state가 변경에 따라 리렌더링이 일어나는 것이 아닌, 높이가 변하더라도 화면이 그려지는 주사율에 맞춰 리렌더링이 일어난다면, 60fps기준 16.7ms사이에는 한 번의 리렌더링만 발생하는 것이다.
requestAnimationFrame(RAF)의 동작은 다음과 같다.
// 1프레임(16ms) 동안의 시나리오
터치이벤트1: throttledSetHeight(100) → RAF 예약됨
터치이벤트2: throttledSetHeight(105) → 무시됨 (이미 예약됨)
터치이벤트3: throttledSetHeight(110) → 무시됨
터치이벤트4: throttledSetHeight(115) → 무시됨
// 16ms 후 RAF 실행
RAF 콜백: setHeight(100) 실행 → 리렌더링 1번만 발생!
requestAnimationFrame메서드는 아래 MDN에서 확인할 수 있다.
Window: requestAnimationFrame() method - Web APIs | MDN
The window.requestAnimationFrame() method tells the browser you wish to perform an animation. It requests the browser to call a user-supplied callback function before the next repaint.
developer.mozilla.org
이러한 방법을 토대로 기본에 동적으로 높이가 변하는 BottomSheet
에 이를 적용하면 아래와 같이 구현할 수 있다.
- 최적화 코드
const BottomSheet = () => {
const rafId = useRef<number | null>(null);
// RAF 기반 스로틀된 높이 설정 함수
const throttledSetHeight = useMemo(() => {
return (newHeight: number) => {
if (rafId.current) return; // 이미 예약됨
rafId.current = requestAnimationFrame(() => {
setHeight(newHeight);
rafId.current = null;
});
};
}, [setHeight]);
// 마우스/터치 이벤트에서 throttled 함수 사용
const handleMouseMove = useRef((e: globalThis.MouseEvent) => {
if (!isDragging.current) return;
const diff = startY.current - e.clientY;
let newHeight = currentHeight.current + diff;
newHeight = Math.max(100, Math.min(window.innerHeight * 0.8, newHeight));
throttledSetHeight(newHeight); //스로틀링 적용
}).current;
// 터치 이벤트도 동일하게 적용
const handleTouchMove = (e: TouchEvent<HTMLDivElement>) => {
// ... 높이 계산 로직
throttledSetHeight(newHeight); //스로틀링 적용
};
// 나머지 컴포넌트 로직...
};
4. 결과
'React' 카테고리의 다른 글
[React/Axios] JWT 토큰 재발급 시 발생하는 Race Condition 문제 해결(대기열 큐+락) (0) | 2025.06.19 |
---|---|
[Vite] vite.config.js 설정 (proxy, console제거) (0) | 2025.05.26 |
[React] 사진 슬라이드 쉽게 직접 구현하기 (0) | 2025.05.18 |
[React] State(상태) 끌어올리기로 리렌더링 문제 해결하기 (0) | 2025.04.24 |
[React] GlobalStyle 폰트 적용 안됨, 폰트 깜빡임 현상 원인 및 해결방법 (0) | 2025.04.14 |