프론트엔드 개발을 하다 보면 기능 구현을 넘어 성능까지 신경 쓰게 됩니다.
그 과정에서 자연스럽게 마주하는 것이 바로 '렌더링 최적화'입니다.
처음엔 "기능 구현하기도 바쁜데 렌더링 최적화까지 해야 하나?"라는 의문이 들지만, 프로젝트가 커지고 복잡해질수록 이 최적화가 사용자 경험에 미치는 중요성을 깨닫게 됩니다.
오늘은 프론트엔드 성능향상의 중심에 있는 '렌더링 최적화'에 대해 알아보, 리액트에서는 이런 문제점들을 어떻게 해결하려고 하는지, 자세히 살펴보겠습니다.
렌더링 최적화란 무엇인가?
렌더링 최적화는 웹 애플리케이션이 효율적으로 렌더링되도록 하여 사용자 경험을 향상시키는 방법입니다. 빠르게 로딩되고 반응성이 뛰어난 웹 페이지는 사용자 이탈을 줄이고 만족도를 높입니다.
최근 웹 페이지 평균 크기는 2024년 기준 2MB를 넘어섰고, 모바일 기기에서는 첫 페이지 로딩에 평균 3초 이상이 걸립니다. 이는 5년 전에 비해 약 50% 증가한 수치로, 웹 성능 문제가 심각해졌음을 의미합니다.
성능 측정 방법
성능을 효과적으로 최적화하려면, 먼저 문제를 정확하게 측정할 수 있어야 합니다. 내가 만든 기능, 페이지가 어디에서 과부하, 지연이 있는지를 알아야 효율적으로 코드를 개선할 수 있습니다.
다음은 리액트 개발을 위해 제공되는 브라우저의 여러 기능들입니다.
- React DevTools Profiler: 각 컴포넌트의 렌더링 시간을 측정하고 시각적으로 분석할 수 있습니다.
- Lighthouse: 구글이 제공하는 성능 진단 도구로, SEO 및 접근성도 함께 점검할 수 있습니다.
- 브라우저 개발자 도구: 브라우저 자체 개발자 도구에서도 정말 많은 것들을 제공합니다. 타임라인 기능을 통해 CPU, 메모리 사용량과 같은 리소스 사용을 분석할 수 있습니다.
리액트의 렌더링 과정
리액트의 렌더링은 크게 두 단계로 나뉩니다.
Render Phase (렌더 단계)
Render Phase는 리액트가 가상 DOM(Virtual DOM)을 생성하고, 이전 상태와 새로운 상태를 비교하여 변경된 부분을 찾는 단계입니다. 리액트는 상태(state)나 props가 변경되면 새로운 가상 DOM 트리를 만들고, 이를 이전 가상 DOM과 비교(diffing)하여 최소한의 변경점을 식별합니다.
리액트의 diffing 알고리즘은 다음과 같은 특징이 있습니다.
- 트리 전체를 비교하지 않고 같은 레벨의 노드 간에서만 비교합니다.
- key를 사용해 리스트 아이템의 정확한 변경을 탐지하며, key가 없거나 중복되면 성능이 저하될 수 있습니다.
불필요한 렌더링 문제
상태나 props가 변경되지 않았음에도 불구하고 부모 컴포넌트가 리렌더링되면서 자식 컴포넌트까지 불필요하게 렌더링될 수 있습니다. 이를 막으려면 메모이제이션을 활용해야 합니다.
Commit Phase (커밋 단계)
Commit Phase는 변경된 사항을 실제 DOM에 적용하는 단계입니다. 이 단계에서는 렌더 단계에서 계산한 변경사항을 실제 브라우저의 DOM에 반영합니다. 이 과정은 비용이 크며, 특히 DOM 변경이 많아질수록 브라우저의 리플로우와 리페인트 작업이 많이 일어나 성능이 저하됩니다.
브라우저는 DOM이 변경되면 레이아웃을 다시 계산(리플로우)하고 화면에 새로 그리기(페인트)를 수행합니다. 따라서 DOM 조작을 최소화하는 것이 성능 최적화의 핵심입니다.
React의 렌더링 최적화 전략
그럼 불필요한 리렌더링을 최소화하기 위해 리액트에서는 어떤 것들을 제공하고 있을까요?
이것들이 바로 useCallback, useMemo, React.memo입니다.
useCallback
useCallback은 특정 함수를 메모이제이션하여 불필요한 재생성을 방지합니다. 이로 인해 자식 컴포넌트로 전달된 콜백 함수의 참조가 변경되지 않아 자식 컴포넌트가 불필요하게 렌더링되는 것을 막을 수 있습니다.
사용 예시 및 실무 적용
useCallback은 이벤트 핸들러 함수가 자주 변경되어 자식 컴포넌트가 반복적으로 렌더링될 때 유용합니다.
예를 들어, 버튼 클릭 핸들러나 입력 폼 변경 핸들러에 적용하면 성능 개선을 경험할 수 있습니다.
import React, { useState, useCallback } from 'react';
const IncrementButton = React.memo(({ onIncrement }) => (
<button onClick={onIncrement}>증가</button>
));
function Counter() {
const [count, setCount] = useState(0);
const increment = useCallback(() => {
setCount((prev) => prev + 1);
}, []);
return (
<div>
<p>카운트: {count}</p>
<IncrementButton onIncrement={increment} />
</div>
);
React.memo
React.memo는 컴포넌트를 메모이제이션하여 props가 변경되지 않은 경우 렌더링을 방지합니다.
사용 예시 및 실무 적용
React.memo는 리스트 형태로 렌더링되는 컴포넌트에 주로 사용됩니다. 리스트 아이템 중 하나만 변경됐을 때 다른 아이템들이 리렌더링되지 않도록 방지합니다.
const Item = React.memo(({ name }) => {
console.log(`${name} 렌더링됨`);
return <div>{name}</div>;
});
function ItemList({ items }) {
return (
<div>
{items.map((item) => (
<Item key={item.id} name={item.name} />
))}
</div>
);
}
useMemo
useMemo는 비용이 높은 연산 결과를 캐싱하여, 같은 의존성 배열 값일 경우 연산을 반복하지 않게 합니다.
사용 예시 및 실무 적용
데이터를 정렬하거나 필터링하는 복잡한 연산이 자주 반복되는 컴포넌트에서 사용하면 성능을 크게 향상시킬 수 있습니다.
import React, { useMemo, useState } from 'react';
function SortedList({ items }) {
const sortedItems = useMemo(() => {
console.log('정렬 계산 수행됨');
return [...items].sort((a, b) => a.value - b.value);
}, [items]);
return (
<ul>
{sortedItems.map((item) => (
<li key={item.id}>{item.value}</li>
))}
</ul>
);
}
고급 최적화 전략
코드 스플리팅과 지연 로딩(Lazy Loading)
코드 스플리팅은 앱에서 바로 필요하지 않은 코드를 별도의 파일로 나누고, 필요할 때만 로딩하는 방식입니다. 초기에 불러오는 코드의 양이 줄어들어 페이지 로딩 속도가 빨라집니다.
예를 들어, 사용자가 특정 버튼을 클릭할 때만 나타나는 모달창이나 대화상자 같은 컴포넌트를 지연 로딩으로 처리하면 초기 페이지 로딩 시간을 단축할 수 있습니다.
const Modal = React.lazy(() => import('./Modal'));
function App() {
const [showModal, setShowModal] = useState(false);
return (
<div>
<button onClick={() => setShowModal(true)}>모달 보기</button>
<React.Suspense fallback={<div>로딩 중...</div>}>
{showModal && <Modal />}
</React.Suspense>
</div>
);
}
웹 워커(Web Worker)
웹 워커는 복잡하고 시간이 오래 걸리는 연산을 메인 스레드가 아닌 별도의 스레드에서 실행하도록 해줍니다. 이렇게 하면 연산이 진행되는 동안에도 웹 페이지가 멈추지 않고 원활하게 작동합니다.
예를 들어, 사용자가 대량의 데이터를 처리하는 동안 웹페이지가 멈추지 않고 계속해서 반응하도록 유지하려면 웹 워커를 사용하면 좋습니다.
// worker.js
self.onmessage = (event) => {
const data = event.data;
const result = heavyCalculation(data);
postMessage(result);
};
// App.jsx
const worker = new Worker('worker.js');
worker.postMessage(data);
worker.onmessage = (event) => {
console.log('결과:', event.data);
};
렌더링 최적화는 일회성이 아니라 지속적으로 진행되어야 합니다. 꾸준한 성능 측정과 개선을 통해 최적의 사용자 경험을 제공하는 것이 중요합니다.
'개발자일기 > 리액트 이야기' 카테고리의 다른 글
스켈레톤 로딩(Skeleton Loading)을 구현해보자! (CSS, React) (5) | 2024.11.07 |
---|---|
[프론트엔드] JWT, RefreshToken과 AccessToken (3) | 2024.11.05 |
[React] URL을 이용한 검색 상태 관리: Hooks를 활용한 효율적인 방법 (0) | 2023.05.13 |
[팀 프로젝트] 프로젝트 기본 세팅 - SCSS (0) | 2022.05.30 |
[팀 프로젝트] 첫 팀 프로젝트의 시작 (1) | 2022.05.09 |