React 렌더링 최적화란 불필요한 렌더링을 줄여 성능을 향상시키는 것을 말합니다.
특정 상태나 props의 변화가 있을 때만 컴포넌트를 리렌더링해서 렌더링 비용을 줄일 수 있는데요,
속도와 효율성을 높여 사용자가 웹 페이지를 더 빠르고 원활하게 사용할 수 있도록 합니다!
데이터가 많거나 컴포넌트가 복잡한 경우 성능 차이가 커서 최적화는 매우 중요합니다.
따라서 React 렌더링 최적화를 하는 방법에 대해 알아보겠습니다.
메모이제이션이란 이전에 계산한 결과를 캐싱해서 동일한 계산을 다시할 할 때, 캐싱된 결과를 재사용하는 기법을 말합니다. 불필요한 계산을 줄이기 때문에 작업 속도를 향상시켜 성능을 최적화할 수 있습니다.
useCallback을 사용하지 않으면, 부모 컴포넌트가 리렌더링될 때마다 자식 컴포넌트에 전달되는 함수가 새로 생성되어서 리렌더링이 발생할 수 있음위의 메모이제이션을 실제로 사용하는 예시를 살펴볼까요?
레벨별로 숫자를 순서대로 클릭하는 게임으로 숫자를 클릭하면 게임 시간을 측정하는 게임을 만들었습니다.
저는 App.jsx에서 Header랑 GamePage를 렌더링하는 구조로 구현했습니다.
이때 시간이 계속 증가하면서 부모 컴포넌트가 리렌더링될 때마다 GamePage가 같이 리렌더링되는 문제가 발생했습니다.
따라서 시간의 변화가 GamePage의 로직은 직접적인 연관이 없었기 때문에 불필요한 렌더링을 방지하기 위해 GamePage에서 React.memo를 사용했습니다.
const GamePage = React.memo(({ level, startTimer, stopTimer, toggleResetGame }) => {
// ... 내부 로직
});
타이머 함수를 최적화하기 위해서 useCallback을 사용했는데요,
startTimer와 stopTimer 함수를 useCallback으로 감싸, 동일한 참조값을 유지하여 부모 컴포넌트가 리렌더링되어도 함수가 새로 생성되지 않도록 했습니다.
// 타이머 시작 함수
const startTimer = useCallback(() => {
if (!timerWorker.current) {
timerWorker.current = new Worker(new URL("./utils/worker.js", import.meta.url));
timerWorker.current.postMessage("start");
timerWorker.current.onmessage = (e) => {
setTime((e.data / 1000).toFixed(2));
};
}
}, []);
// 타이머 정지 함수
const stopTimer = useCallback(() => {
if (timerWorker.current) {
timerWorker.current.postMessage("stop");
timerWorker.current.terminate();
timerWorker.current = null;
}
setIsOpen(true);
}, []);
게임판의 숫자 리스트를 새롭게 생성하고 초기화할 때에는 숫자를 클릭할 때마다 새로운 리스트가 생성되는 것을 막기 위해서 반복적으로 계산을 하지 않도록 하기 위해 useMemo를 사용했습니다.
const remainingNumbers = useMemo(
() =>
shuffle(Array.from({ length: halfNum }, (_, i) => ({ value: halfNum + i + 1, isNew: true }))),
[level, toggleResetGame]
);
useCallback으로 타이머 함수를 메모이제이션하고, React.memo로 GamePage 컴포넌트를 감싸서 불필요한 리렌더링을 줄이고,
useMemo로 게임판 숫자를 생성하는 작업을 최적화하여 상태 변경에 의한 재계산을 방지하면서 렌더링을 최적화했습니다.
Automatic Batching은 여러 상태 업데이트를 모아서 한번에 렌더링하는 최적화 기법입니다.
React 18 이후 자동으로 상태 업데이트가 여러번 있어도 한번만 렌더링하게 되었습니다.
더 나은 성능을 위해 여러개의 상태 업데이트를 단 한번의 리렌더링으로 처리하는 것
React 18 이전
- React 이벤트 핸들러 내에서의 상태 업데이트만 batching
Promise, setTimeout 등 비동기로 처리되는 이벤트들은 React에서 기본적으로 batching을 수행하지 않았음
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// 마지막에 단 한 번만 리렌더링
}
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// 각 상태 업데이트마다 한 번씩, 총 두 번 렌더링
}, 1000);
```
🤔 이벤트 핸들러 내에서의 상태 업데이트가 마지막 한번만 리렌더링 되는것이
setState의 비동기와 연관이 있나요?
setState를 할 때마다 매번 즉각적으로 리렌더링하는 것이 아니라, React의 업데이트 큐에 담겨서 대기하다가 batching하여 한번의 렌더링으로 묶어서 처리됩니다.
setState가 비동기적으로 작동하므로 React는 모든 업데이트가 끝난 뒤 한번만 렌더링을 수행할 수 있습니다.
😒 근데 왜 setTimeout, Promise 같은 작업에서는 batching이 되지 않았을까요?
React에서 발생하는 이벤트(
onClick,onChange)를 감지하고 이 이벤트 핸들러에서 발생하는 상태 업데이트만 자동으로 batching되는 이유는 React의 이벤트 시스템은 Syntheic Event라는 가상의 이벤트 계층을 사용해서 React가 내부적으로 상태 업데이트를 감지하고, 한번에 렌더링을 처리할 수 있는 구조를 제공하기 때문입니다.
setTimeout,Promise에서 발생하는 이벤트는 React 외부에서 발생하는 비동기 작업이기 때문에, React는 이를 관리하거나 batching할 수 없었는데요, 브라우저의 이벤트 루프에서 발생하는 이벤트이기 때문에, React가 업데이트를 감지하는 시점이 달랐던 것입니다!
setTimeout,XMLHttpRequest(AJAX 요청),Promise와 같은 작업들은 Web API(브라우저가 제공하는 기능)을 통해 실행되고, 자바스크립트 엔진의 콜 스택에서 직접 실행되는 것이 아니라 Web API 영역에서 처리된 후 완료되면 콜백 함수가 Callback Queue에 들어가고 이벤트 루프가 이를 콜 스택으로 보내는 방식으로 작동하고 있습니다.
Web API 비동기 작업은 자바스크립트 엔진 외부에서 관리되기 때문에 React가 직접적으로 추적하고 배칭하기 어려웠던 것입니다!
Web API 영역에서 완료된 후 이벤트 루프를 통해 콜백이 콜 스택으로 들어오기 때문에 React가 상태 업데이트를 감지하고 배칭하기에는 시점이 달랐습니다.
🤔 그러면 어떻게 상태 업데이트를 batching을 했던거죠?
React 18 이전에는 unstable_batchedUpdates라는 API를 사용해 비동기 작업에서도 상태 업데이트를 batching했다고 합니다!
import { unstable_batchedUpdates } from 'react-dom'; unstable_batchedUpdates(() => { setCount(c => c + 1); setFlag(f => !f); });
createRoot로 Automatic Bathcing이 가능해...React 18을 시작으로 createRoot를 사용하면서 모든 업데이트가 자동으로 batch될 수 있었습니다.
즉, setTimeout, Promise 등 다른 업데이트도 React 이벤트 내의 업데이트처럼 같이 batch되게 되었습니다.
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// 마지막에 단 한 번만 리렌더링
}
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// 마지막에 단 한 번만 리렌더링
}, 1000);
React 18에서는 동시성 렌더링 기능을 도입했는데 createRoot는 동시성 모드의 진입점 역할을 합니다.
이전의 방식이었던 ReactDOM.render는 동기적으로 렌더링했습니다.
비록 setState는 비동기적으로 동작하지만 최종적으로 DOM에 반영되는 작업은 ReactDOM.render가 동기적으로 작동하기 때문에 React 18 이전에는 외부 작업은 상태 업데이트마다 렌더링이 발생했습니다.
createRoot는 상태 업데이트들을 즉시 렌더링하는 것이 아니라
내부적으로 큐에 저장해서 이벤트 루프가 끝날 때까지 기다렸다가 한번에 batching으로 모든 상태 업데이트를 한번의 렌더링으로 처리합니다.
따라서 React가 알아서 최적화를 처리해주기 때문에 개발자는 불필요한 렌더링 감소를 별도로 batch 처리를 신경쓰지 않고 개발하는 것이 가능합니다 :)
🤔 자동으로 batching하고 싶지 않으면 어떡하나요?
import { flushSync } from 'react-dom'; function handleClick() { flushSync(() => { setCounter(c => c + 1); }); // 이 시점에서 React는 DOM을 업데이트합니다. flushSync(() => { setFlag(f => !f); }); // 이 시점에서 React는 DOM을 업데이트합니다. }즉시 DOM 업데이트가 필요한 경우에는
flushSync를 사용합니다.
애니메이션이나 사용자 입력에 즉각적으로 반응해야 할 때는 사용할 수 있지만,
너무 많이 사용하면 오히려 성능 저하가 발생할 수 있다는 점은 주의해주세요...!
windowing은 긴 리스트를 다룰 때 사용하는 최적화 기법입니다.
현재 화면에 보이는 요소만 렌더링하고 나머지는 렌더링을 하지 않기 때문에 메모리와 CPU의 부하를 줄일 수 있습니다.

뉴스 피드, 채팅 기록, 제품 리스트 등 무한 스크롤이 필요한 경우나
큰 테이블이나 그리드 레이아웃에서 특정 범위의 요소만 렌더링할 때 사용할 수 있습니다.
react-windowreact-virtualizedreact-virtuoso실제로 리디북스에서 다량의 콘텐츠를 처리할 때 windowing 기법을 사용했다고 합니다.
리디북스 블로그에서 소개된 방법을 함께 알아봅시다!
리디북스의 경우에는 아이템들의 높이를 이미 알고 있기 때문에 크기 계산이 필요하지 않았는데요,
직접 window의 스크를과 연동하는 작업이 window의 scroll 이벤트를 받아서 동기화하면 구현이 가능하기 때문에 크게 어렵지 않아서 react-window를 선택했다고 합니다.
// `ref`에 react-window` 컴포넌트 참조를 저장
const ref = useRef<ReactWindowRef>(null);
// `outerRef`에 `react-window` 외부 div 요소의 참조를 저장
const outerRef = useRef<HTMLDivElement | null>(null);
// `컴포넌트가 마운트되었을 때 스크롤 동기화
useEffect(() => {
// `window`의 스크롤 이벤트 발생 시 호출
const handleWindowScroll = () => {
// `outerRef`의 `offsetTop`을 가져와서 페이지 상단에서부터 이 div까지의 거리 계산
const { offsetTop = 0 } = outerRef.current || { offsetTop: 0 };
// 현재 `window.scrollY`에서 `offsetTop`을 뺀 값을 `scrollTop`으로 설정
const scrollTop = window.scrollY - offsetTop;
// `ref.current`가 존재하면 `scrollTo` 메서드를 호출하여 `react-window` 컴포넌트를 스크롤
if (ref.current) {
ref.current.scrollTo({ scrollLeft: 0, scrollTop });
}
};
// `handleWindowScroll`을 한 번 실행하여 현재 스크롤 위치와 동기화
handleWindowScroll();
// `window`의 `scroll` 및 `resize` 이벤트에 `handleWindowScroll`을 연결하여 스크롤 동기화
window.addEventListener('scroll', handleWindowScroll);
window.addEventListener('resize', handleWindowScroll);
// `useEffect` 클린업 (컴포넌트가 언마운트될 때 이벤트 리스너 제거)
return () => {
window.removeEventListener('scroll', handleWindowScroll);
window.removeEventListener('resize', handleWindowScroll);
};
}, []);
빠르게 스크롤하는 경우에는 스크롤 속도보다 새롭게 보여지는 아이템을 그리는 속도가 더 느려서 아이템이 뒤늦게 보이는 상황이 발생할 수 있어서 스켈레톤으로 보완하지만,
스켈레톤도 DOM으로 그려지는 작업이기 때문에 뒤늦게 나타났다고 합니다.
// `useBookSkeleton` 훅은 스켈레톤 이미지를 생성해 `background-image`로 사용하기 위한 URL을 반환
const useBookSkeleton = () => {
// `useTheme`를 통해 현재 테마를 가져와서 스켈레톤 색상으로 사용
const theme = useTheme();
// `useBookListSize`로 현재 화면의 크기에 따라 스켈레톤의 폭과 높이를 계산
const { columnWidth, rowHeight } = useBookListSize();
// `useMemo`로 `columnWidth`나 `rowHeight`가 변경될 때만 스켈레톤 이미지를 다시 계산
return useMemo(() => {
// SVG 코드 시작 부분을 문자열로 작성
let svg = `<svg width="${columnWidth}" height=${rowHeight}` +
` viewBox="0 0 ${columnWidth} ${rowHeight}` +
` xmlns="http://www.w3.org/2000/svg">`;
// 썸네일의 폭과 높이를 설정 (여기서 PADDING과 THUMBNAIL_RATIO는 상수로 정의됨)
const thumbnailWidth = columnWidth - 2 * PADDING; // 양쪽 여백을 고려한 폭
const thumbnailHeight = Math.round(thumbnailWidth * THUMBNAIL_RATIO); // 썸네일 비율에 맞춰 높이 계산
// 썸네일의 스켈레톤을 SVG `rect` 태그로 생성
svg += buildRect(
{
x: PADDING, // 썸네일의 x 위치 (여백)
y: PADDING, // 썸네일의 y 위치 (여백)
width: thumbnailWidth, // 썸네일의 폭
height: thumbnailHeight, // 썸네일의 높이
fill: theme.colors.grey080 // 테마 색상 사용
}
);
// SVG 코드 닫기
svg += '</svg>';
// SVG를 base64로 인코딩하여 `background-image`에 사용 가능한 데이터 URL 반환
return `url(data:image/svg+xml;base64,${btoa(svg)})`;
}, [theme, columnWidth, rowHeight]); // `theme`, `columnWidth`, `rowHeight` 변경 시 다시 계산
};
이렇게 리디북스의 사례를 통해서 windowing 기법으로 어떻게 렌더링을 최적화할 수 있는지 알아보았습니다.
그런데 scroll 이벤트는 자주 발생해서 성능에 영향을 줄 수 있습니다.
이 부분은 throttle이나 debounce 기법으로 보완할 수 있는데 추후에 아티클을 작성하도록 하겠습니다!!
pre-loading이나 pre-fetching을 통해서 사용자가 스크롤할 때 보일 가능성이 높은 영역을 미리 렌더링할 수도 있습니다.
react-window나 react-virtualized 같은 라이브러리는 overscan 속성을 제공해 추가로 렌더링할 아이템을 미리 지정할 수 있어서 스크롤 속도가 빠를 때도 부드럽게 보일 수 있다고 합니다.
또, 리디의 경우 스켈레톤을 svg로 작업했지만 정적 이미지라면 background-image로 설정해서 메인 스레드 부담을 줄일 수 있다고 하네요..!!
import { FixedSizeList as List } from 'react-window';
const MyList = ({ items }) => (
<List
height={400} // 리스트의 전체 높이
itemCount={items.length} // 아이템 개수
itemSize={35} // 각 아이템의 높이
width={300} // 리스트의 너비
>
{({ index, style }) => (
<div style={style}>
{items[index]}
</div>
)}
</List>
);
react-window에서는 위와 같은 특정 구조와 사용 방식을 따라야 해서, 리스트 외부에 추가 요소를 넣거나 무한 스크롤과 같은 유연한 구성이 어렵고,
위에서 말했듯이 스크롤은 컴포넌트 내부에서만 작동하므로 페이지 전체 스크롤을 제어하는 데 한계가 있습니다.
따라서 직접 windowing을 구현하는 방법도 알아보겠습니다.
IntersectionObserver를 사용해 뷰포트와 지정한 요소 간의 교차 상태를 감지rootMargin을 0px로 설정하여 뷰포트에 가까운 아이템도 사전에 렌더링하고, threshold를 설정하여 10% 이상 보이면 감지하도록startIdx)와 endIdx를 계산해 보이는 영역의 아이템만 상태에 저장key 속성으로 불필요한 리렌더링 방지visibleItems.map을 사용할 때, 고유한 key 속성을 설정해 React가 기존 DOM을 재활용하여 불필요한 리렌더링 방지import React, { useState, useEffect, useRef } from 'react';
const VirtualizedList = ({ items, itemHeight, buffer = 5 }) => {
const [visibleItems, setVisibleItems] = useState([]); // 현재 보이는 아이템들
const containerRef = useRef(null);
const observerRef = useRef(null); // IntersectionObserver 참조
const totalHeight = items.length * itemHeight; // 전체 리스트의 높이 계산
useEffect(() => {
// 가시 영역 내 아이템을 감지하고 업데이트하는 함수
const handleIntersect = (entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const startIdx = Math.max(0, Math.floor(entry.target.offsetTop / itemHeight) - buffer);
const endIdx = Math.min(
items.length - 1,
Math.ceil((entry.target.offsetTop + entry.target.clientHeight) / itemHeight) + buffer
);
setVisibleItems(items.slice(startIdx, endIdx + 1));
}
});
};
// IntersectionObserver 생성
const observer = new IntersectionObserver(handleIntersect, {
root: containerRef.current,
rootMargin: '0px',
threshold: 0.1, // 10% 이상 보이면 감지
});
observerRef.current = observer;
// 모든 아이템에 대해 IntersectionObserver 적용
const children = containerRef.current.children;
Array.from(children).forEach((child) => observer.observe(child));
return () => {
// 클린업: 옵저버를 해제해 메모리 누수를 방지
Array.from(children).forEach((child) => observer.unobserve(child));
};
}, [items, itemHeight, buffer]);
return (
<div ref={containerRef} style={{ overflowY: 'auto', height: '400px', position: 'relative' }}>
<div style={{ height: totalHeight, position: 'relative' }}>
{visibleItems.map((item, index) => (
<div
key={item.id || index} // 각 아이템의 고유 ID를 key로 설정하여 불필요한 리렌더링 방지
style={{
position: 'absolute',
top: `${(index + visibleItems[0].index) * itemHeight}px`,
height: `${itemHeight}px`,
width: '100%',
}}
>
{item.content}
</div>
))}
</div>
</div>
);
};
이 방식은 단순한 고정 높이의 리스트에서 효율적인데요,
브라우저 엔진의 최적화를 활용하여 스크롤 이벤트를 직접 계산할 때보다 CPU 사용량을 줄일 수 있습니다.
IntersectionObserver와 key 속성을 결합하여 다량 콘텐츠의 리스트에서 렌더링 성능을 최적화할 수 있습니다.
하지만 초고속 스크롤이나 아이템의 높이가 바뀔 수 있다면, 라이브러리를 사용하는 것이 더 정교하게 메모리를 관리할 수 있습니다.
🤔 windowing 기법을 적용하면 스크롤 할 때마다 연산을 하게 되는데, 오히려 불필요한 연산을 하는거 아닌가요?
windowing 기법을 적용하지 않으면 아래와 같은 문제가 발생한다고 생각하기 때문에 windowing 기법이 필요하다고 합니다!
- 많은 메모리 사용
: 예를 들어, 10,000개의 데이터를 화면에 한 번에 렌더링한다고 가정해보면, 각 데이터가 DOM 요소로 추가되면서 브라우저가 관리해야 할 요소가 10,000개나 되기 때문에 메모리 사용량이 크게 증가함!- Virtual DOM 비교 시간 증가
: React는 상태가 변경되면 Virtual DOM을 새로 만들어서 이전 Virtual DOM과 비교하고, 변화가 있는 부분만 실제 DOM에 업데이트하는데, DOM 요소가 10,000개 이상이면 Virtual DOM의 노드 수도 비례해 늘어나기 때문에, 상태가 변경될 때마다 비교 시간이 오래 걸림- 상태 업데이트 시 전체 비교 필요
: 상태 업데이트가 발생할 때 React는 10,000개의 노드를 모두 일일이 비교해야 해서 많은 시간과 리소스를 소모함windowing 기법을 적용하여 10,000개의 데이터 중에서 지금 화면에 보여야 할 10개만 렌더링하면, React가 상태 업데이트 시 비교해야 할 Virtual DOM 노드도 10개로 줄어듭니다.
메모리 사용이 줄고, 상태 업데이트 시 비교하는 노드 수도 확 줄어들기 때문에 성능이 크게 개선된다고 합니다 :)
React 18로 업그레이드하는 방법 – React
Automatic batching for fewer renders in React 18 · reactwg react-18 · Discussion #21
성능 최적화 – React
Victor Log | windowing 기법
예상보다 24배 많은 콘텐츠에 프론트가 대처하는 법 - 리디주식회사
Windowing 직접 구현하기
안녕하세요 YB 김태욱입니다.
채현님 아티클을 통해 리엑트 렌더링 최적화 방법에 대해 많이 배울 수 있었습니다.
지난 과제에 렌더링 최적화를 적용시킨 예시를 보면서 저는 과제때 미처 고려하지 못했던 부분인데 이렇게 하면 좋은 코드를 작성할 수 있겠구나 라는 생각이 들었던 것 같습니다.
또한 automatic batching이 리액트 18 이전에는 왜 비동기 작업에 적용되지 못했는지, windowing을 직접 구현하는 방법 등 저는 놓쳤던 부분들도 덕분에 많이 알게 되었고 복습을 하기에도 좋았습니다.
이번 주도 고생 많으셨습니다!~!
안녕하세요! YB 유서연입니다.
우선 아티클 잘 읽었습니다! 내용이 정말 자세해서 새로 알게 된 부분들이 많았습니다.
특히, 3주차 과제에서 어떻게 렌더링을 최적화했는지 예시를 직접 들어주셔서 실제로 어떤 식으로 사용해야하는지 감을 잡을 수 있었습니다! 타이머 때문에 시간 값이 계속 변화하면서 게임 컴포넌트가 계속 리렌더링되는 문제는 정말 제가 과제하면서 머리 싸매고 고민했던 부분인데 이런 식으로 해결할 수 있었군요….
또한, windowing을 구현할 수 있게 해주는 라이브러리 간의 비교도 깔끔하게 해주셔서 차이점을 한눈에 파악할 수 있었습니다. 수고하셨어요~!