리액트로 서비스를 만들다 보면 어느 순간부터 초기 로딩이 느려지고, 입력할 때 버벅이며, 컴포넌트가 필요 이상으로 리렌더링되는 문제를 겪게 된다.
이 글에서는 리액트 최적화 기법 3가지를 중심으로 “왜 필요한지 → 언제 쓰는지 → 어떻게 적용하는지”를 코드와 함께 정리해보겠다.

리액트 앱은 기본적으로 한 번에 모든 JS 번들을 내려받아 실행한다.
페이지 수가 많아질수록, 또는 차트·에디터·지도 같은 무거운 컴포넌트가 많아질수록 첫 화면을 보기까지의 시간(LCP) 이 급격히 늘어난다.
하지만 모든 컴포넌트가 첫 화면에 꼭 필요하지는 않다. 예를 들어
이런 컴포넌트는 “필요해질 때 로드” 하는 것이 훨씬 효율적이다.
import React, { Suspense } from "react";
const HeavyChart = React.lazy(() => import("./HeavyChart"));
export default function Dashboard() {
return (
<Suspense fallback={<div>차트 로딩 중...</div>}>
<HeavyChart />
</Suspense>
);
}
React.lazy → 동적 importSuspense → 로딩 중 보여줄 UI 처리👉 초기 번들에는 HeavyChart가 포함되지 않고, 실제 렌더링 시점에 로드된다.
import React, { Suspense } from "react";
import { Routes, Route } from "react-router-dom";
const Home = React.lazy(() => import("./pages/Home"));
const Admin = React.lazy(() => import("./pages/Admin"));
const Payment = React.lazy(() => import("./pages/Payment"));
export default function App() {
return (
<Suspense fallback={<div>페이지 로딩 중...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/admin" element={<Admin />} />
<Route path="/payment" element={<Payment />} />
</Routes>
</Suspense>
);
}
import React, { useState, Suspense } from "react";
const ImageEditorModal = React.lazy(() => import("./ImageEditorModal"));
export default function Page() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>모달 열기</button>
{open && (
<Suspense fallback={<div>모달 준비 중...</div>}>
<ImageEditorModal onClose={() => setOpen(false)} />
</Suspense>
)}
</>
);
}
👉 모달을 열 때만 무거운 라이브러리를 로드하기 때문에 초기 진입 속도와 전체 UX 모두 개선된다.
검색창에서 키를 누를 때마다 API를 호출하면:
와 같은 현상이 일어날 수 있다.
👉 debounce는 “마지막 입력 후 일정 시간 동안 추가 입력이 없을 때만 실행” 되도록 만든다.
import { useEffect, useState } from "react";
export default function SearchBox() {
const [keyword, setKeyword] = useState("");
const [debouncedKeyword, setDebouncedKeyword] = useState("");
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedKeyword(keyword);
}, 400);
return () => clearTimeout(timer);
}, [keyword]);
useEffect(() => {
if (!debouncedKeyword) return;
console.log("API 호출:", debouncedKeyword);
}, [debouncedKeyword]);
return (
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="검색어 입력"
/>
);
}
keyword → 즉시 반영debouncedKeyword → 입력 멈춘 뒤 확정import { useEffect, useState } from "react";
export function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
const debouncedKeyword = useDebouncedValue(keyword, 400);
👉 검색, 필터, 자동완성 등 거의 모든 입력 UX에 활용 가능하다.

const handleClick = () => {
setCount(count + 1);
};
이 함수는 컴포넌트가 렌더링될 때마다 새로 생성된다.
이걸 props로 내려주면, 자식 컴포넌트는 “props가 바뀌었다”고 인식하고 다시 렌더링된다.
const Child = React.memo(({ onClick }) => {
console.log("Child 렌더링");
return <button onClick={onClick}>+</button>;
});
부모의 다른 state가 바뀌어도
이 일어난다.
import { useCallback, useState } from "react";
const Child = React.memo(({ onClick }) => {
return <button onClick={onClick}>+</button>;
});
export default function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState("");
const handleClick = useCallback(() => {
setCount((prev) => prev + 1);
}, []);
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<Child onClick={handleClick} />
</>
);
}
React.memo와 함께 사용할 때 효과 극대화prev 패턴으로 의존성 배열 단순화