React 성능 최적화은(는) 무엇을 하나요?
React 18/19 및 Next.js를 위한 성능 최적화 패턴입니다. Vercel Labs react-best-practices (MIT, v1.0.0) 에서 가져왔습니다. 이 스킬은 규칙을 우선순위별로 정리하고, 능동적인 코드 리뷰 및 리팩토링을 위한 의사 결정 트리 가이드를 제공합니다.
활성화 시점
- React/Next.js 코드의 성능을 작성하거나 리뷰하는 경우
- 느린 페이지 로드, 느린 상호작용 또는 클라이언트의 높은 CPU 사용량을 진단하는 경우
- 번들 크기 또는 Lighthouse Core Web Vitals 회귀를 감사하는 경우
- 서버 컴포넌트/API 라우트에서 워터폴을 제거하는 경우
- 클라이언트 사이드 리렌더링을 줄이는 경우
- 긴 목록, 애니메이션 또는 하이드레이션을 최적화하는 경우
app/,pages/,components/또는 데이터 레이어를 다루는 PR에서 최적화 선택을 감사하는 경우
우선순위 인덱스
| 우선순위 | 카테고리 | 접두사 | 중요 시점 |
|---|---|---|---|
| 1 — 중요 | 워터폴 제거 | async- | await 뒤에 독립적인 await이 오는 모든 경우 |
| 2 — 중요 | 번들 크기 최적화 | bundle- | 초기 로드 JS, 라우트 수준 임포트, 서드파티 라이브러리 |
| 3 — 높음 | 서버 사이드 성능 | server- | RSC, 서버 액션, API 라우트, SSR |
| 4 — 중간-높음 | 클라이언트 데이터 페칭 | client- | SWR / TanStack Query / 훅 내 fetch |
| 5 — 중간 | 리렌더링 최적화 | rerender- | 빈번한 상태 업데이트, 부모-자식 팬아웃 |
| 6 — 중간 | 렌더링 성능 | rendering- | 긴 목록, 애니메이션, 하이드레이션 |
| 7 — 낮음-중간 | JavaScript 성능 | js- | 핫 루프, 빈번한 할당 |
| 8 — 낮음 | 고급 패턴 | advanced- | Effect-event 통합, 안정적인 refs |
1. 워터폴 제거 (중요)
"워터폴은 성능 저하의 가장 큰 원인입니다" — 모든 순차적
await은 전체 네트워크 지연 시간을 추가합니다.
await 전에 저렴한 조건 확인
원격 데이터를 기다리기 전에 동기 조건(속성, 환경 변수, 하드코딩된 플래그)을 확인하세요.
// 잘못된 예
async function Page({ id }: { id: string }) {
const flag = await getFlag("show-page");
if (!flag || !id) return null;
const data = await getData(id);
// ...
}
// 올바른 예 — 저렴한 동기 조건을 먼저 단축 평가
async function Page({ id }: { id: string }) {
if (!id) return null;
const flag = await getFlag("show-page");
if (!flag) return null;
const data = await getData(id);
}
사용 시점까지 await 연기
await을 해당 데이터를 사용하는 분기로 이동하세요.
// 잘못된 예 — 데이터가 필요한지 결정하기 전에 await
const user = await getUser(id);
if (mode === "guest") return renderGuest();
return renderUser(user);
// 올바른 예
if (mode === "guest") return renderGuest();
const user = await getUser(id);
return renderUser(user);
독립적인 작업에 Promise.all 사용
// 잘못된 예 — 순차적
const user = await getUser(id);
const posts = await getPosts(id);
const followers = await getFollowers(id);
// 올바른 예 — 병렬
const [user, posts, followers] = await Promise.all([
getUser(id),
getPosts(id),
getFollowers(id),
]);
부분 종속성 — 일찍 시작하고 늦게 await
// 올바른 예 — 모든 프로미스를 시작하고, 각 결과가 필요할 때만 await
const userP = getUser(id);
const postsP = getPosts(id);
const profile = await getProfile(id);
if (profile.private) return null;
const [user, posts] = await Promise.all([userP, postsP]);
스트리밍을 위한 Suspense
<Suspense> 경계를 데이터에 가깝게 배치하여 느린 하위 트리가 스트리밍되는 동안 페이지가 가능한 부분을 먼저 그리게 합니다. 단점: 콘텐츠가 도착할 때 레이아웃 이동이 발생합니다 — 공간(skeleton 또는 min-height)을 예약하세요.
서버 컴포넌트: 구성을 통한 병렬 처리
// 잘못된 예 — 형제 await이 하나의 컴포넌트 내에서 순차적으로 실행됨
export default async function Page() {
const user = await getUser();
const cart = await getCart();
return <View user={user} cart={cart} />;
}
// 올바른 예 — 자식으로 분할, React가 병렬로 실행
2. 번들 크기 최적화 (중요)
직접 임포트, 배럴(barrel) 사용 금지
배럴 index.ts 파일은 트리 쉐이킹이 대부분을 제거하더라도 번들러가 전체 모듈 그래프를 탐색하도록 강제합니다. 직접 임포트는 많은 실제 앱에서 초기 로드 JS를 200-800ms 절약합니다.
// 잘못된 예
import { Button, Card, Modal } from "@/components";
// 올바른 예
import { Button } from "@/components/Button";
import { Card } from "@/components/Card";
import { Modal } from "@/components/Modal";
Next.js 13.5+에는 Optimize Package Imports가 있어 나열된 패키지에 대해 이 작업을 자동화합니다. 사용하세요. 나열되지 않은 라이브러리는 수동 직접 임포트가 여전히 필요합니다.
정적으로 분석 가능한 경로
// 잘못된 예 — 번들러/추적 분석을 무력화
const mod = await import(`./pages/${name}`);
// 올바른 예 — 분기별로 명시적
const mod = name === "home" ? await import("./pages/home") : await import("./pages/about");
무거운 컴포넌트를 위한 동적 임포트
import dynamic from "next/dynamic";
const HeavyChart = dynamic(() => import("./HeavyChart"), {
loading: () => <Skeleton />,
ssr: false, // 클라이언트 전용인 경우
});
서드파티 스크립트 지연 로드
분석, 로깅, 지원 위젯은 하이드레이션 후에 로드하세요. next/script와 strategy="afterInteractive" (기본값) 또는 "lazyOnload"를 사용하세요.
조건부 모듈 로딩
if (user.role === "admin") {
const { AdminPanel } = await import("./admin/AdminPanel");
// ...
}
호버/포커스 시 사전 로드
사용자가 클릭할 때까지 번들이 캐시에 있도록 호버 시 <link rel="preload"> 또는 import()를 트리거하세요.
3. 서버 사이드 성능 (높음)
API 라우트처럼 서버 액션 인증
모든 "use server" 함수는 공개 엔드포인트입니다. 액션 내에서 인증 AND 권한 부여를 수행하세요. 호출하는 클라이언트 컴포넌트의 게이팅에 절대 의존하지 마세요.
"use server";
export async function deleteUser(formData: FormData) {
const session = await getSession();
if (!session?.user) throw new Error("Unauthorized");
const targetId = String(formData.get("id"));
if (session.user.role !== "admin" && session.user.id !== targetId) {
throw new Error("Forbidden");
}
await db.user.delete({ where: { id: targetId } });
}
React.cache()를 사용한 요청별 중복 제거
import { cache } from "react";
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
React.cache는 단일 요청 내에서 중복을 제거합니다. 동일한 렌더에서 세 개의 서버 컴포넌트가 getUser("1")을 호출하면 = 하나의 DB 쿼리.
교차 요청 데이터를 위한 LRU 캐시
요청마다 변경되지 않는 데이터(구성, 조회 테이블)의 경우 LRU 캐시 또는 unstable_cache를 사용하여 React 외부에서 캐시하세요.
RSC 속성에서 중복 직렬화 방지
서버 컴포넌트가 동일한 데이터를 여러 클라이언트 컴포넌트로 렌더링할 때 데이터는 소비자당 한 번씩 직렬화됩니다. 클라이언트 컴포넌트를 끌어올리고 대신 children을 전달하세요.
정적 I/O를 모듈 범위로 이동
// 올바른 예 — 모듈 로드 시 한 번만 실행
const fontData = readFileSync(fontPath);
export async function Page() {
return <Banner font={fontData} />;
}
RSC/SSR에서 가변 모듈 수준 상태 금지
서버의 모듈 상태는 모든 요청 간에 공유됩니다 — 사용자 간 경쟁 조건입니다. 대신 요청 범위 저장소(headers(), cookies(), 비동기 컨텍스트)를 사용하세요.
클라이언트 컴포넌트에 전달되는 데이터 최소화
클라이언트가 필요한 것만 직렬화하세요. 필드 제거, 페이지네이션, DB 계층에서 열 프로젝션.
항목별 Promise.all로 중첩된 fetch 병렬화
const users = await getUsers();
const enriched = await Promise.all(
users.map(async (u) => ({ ...u, posts: await getPostsFor(u.id) })),
);
after()를 사용한 비차단 작업
Next.js 15 after()는 응답이 전송된 후 작업(로깅, 캐시 워밍, 분석)을 실행합니다.
import { after } from "next/server";
export async function GET() {
const data = await getData();
after(() => logAnalytics(data));
return Response.json(data);
}
4. 클라이언트 사이드 데이터 페칭 (중간-높음)
SWR / TanStack Query를 사용한 중복 제거
여러 컴포넌트가 useUser(id)를 호출하면 하나의 네트워크 요청과 하나의 캐시 항목을 공유해야 합니다. SWR 또는 TanStack Query를 사용하세요. 공유 데이터를 위해 직접 useEffect + fetch를 절대 구현하지 마세요.
전역 이벤트 리스너 중복 제거
// 잘못된 예 — 모든 컴포넌트가 자체 리스너를 추가
useEffect(() => {
window.addEventListener("scroll", handler);
return () => window.removeEventListener("scroll", handler);
}, []);
// 올바른 예 — 훅 + 전역 subject를 통한 단일 공유 리스너
const useScroll = createScrollHook(); // 내부적으로 싱글톤 subject
스크롤을 위한 수동(passive) 리스너
window.addEventListener("scroll", handler, { passive: true });
스크롤 부드러움을 향상시킵니다. 리스너는 preventDefault()를 호출할 수 없습니다.
localStorage: 버전 + 최소화
- 항상
version필드를 저장하세요. 스키마가 변경되면 버전을 올리고 이전 데이터를 마이그레이션하거나 폐기하세요. - 페이로드를 작게 유지하세요 —
localStorage는 동기식이며 메인 스레드를 차단합니다.
5. 리렌더링 최적화 (중간)
콜백에서만 사용되는 상태 구독 금지
// 잘못된 예 — count가 변경될 때마다 리렌더링
const count = useStore((s) => s.count);
const handler = () => doSomething(count);
// 올바른 예 — 호출 시 한 번만 읽기
const handler = () => {
const count = useStore.getState().count;
doSomething(count);
};
비용이 많이 드는 작업을 메모이제이션된 컴포넌트로 추출
// 올바른 예 — 자식은 `items`가 변경될 때만 리렌더링
const Heavy = memo(function Heavy({ items }: { items: Item[] }) {
return <Chart data={transform(items)} />;
});
기본 비-원시 속성을 호이스트
// 잘못된 예 — 각 렌더링에서 새 배열 생성, memo 깨짐
<List items={items ?? []} />
// 올바른 예
const EMPTY: Item[] = [];
<List items={items ?? EMPTY} />
Effect에서 원시 종속성 사용
// 잘못된 예 — 매 렌더링에서 새로운 객체 식별성
useEffect(() => {}, [{ id, name }]);
// 올바른 예 — 원시 값
useEffect(() => {}, [id, name]);
원시 값이 아닌 파생된 boolean 구독
// 잘못된 예 — 모든 카트 변경에 대해 리렌더링
const cart = useStore((s) => s.cart);
const hasItems = cart.length > 0;
// 올바른 예 — 비어 있음 상태가 변경될 때만 리렌더링
const hasItems = useStore((s) => s.cart.length > 0);
렌더링 중에 파생, 절대 useEffect 사용 금지
// 잘못된 예
const [full, setFull] = useState("");
useEffect(() => setFull(`${first} ${last}`), [first, last]);
// 올바른 예
const full = `${first} ${last}`;
안정적인 콜백을 위한 함수형 setState
// 올바른 예
const increment = useCallback(() => setCount((c) => c + 1), []);
비용이 많이 드는 값을 위한 지연 상태 초기화
const [tree] = useState(() => parseTree(largeInput));
단순 원시 값에는 memo 사용 자제
useMemo(() => x + 1, [x])는 오버헤드입니다. Memo는 객체 식별성과 비용이 많이 드는 계산에서 진정한 가치를 발휘합니다.
독립적인 종속성을 가진 훅 분리
// 잘못된 예 — 두 selector 모두 소스 중 하나가 변경되면 다시 실행
const { a, b } = useSomething(source1, source2);
// 올바른 예
const a = useA(source1);
const b = useB(source2);
상호작용 로직을 이벤트 핸들러로 이동
이벤트 핸들러는 사용자 액션에서만 실행됩니다. useEffect는 종속성이 변경될 때마다 다시 실행됩니다.
긴급하지 않은 업데이트를 위한 startTransition
const [pending, startTransition] = useTransition();
startTransition(() => setFilters(newFilters));
비용이 많이 드는 렌더링을 위한 useDeferredValue
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => expensiveSearch(deferredQuery), [deferredQuery]);
일시적이고 빈번한 값을 위한 useRef
자주 변경되지만 리렌더링을 트리거해서는 안 되는 값(타임스탬프, 마지막 키, 누산기)에 사용하세요.
컴포넌트 내부에 컴포넌트 정의 금지
// 잘못된 예 — Inner는 Outer가 렌더링될 때마다 새 컴포넌트
function Outer() {
const Inner = () => <span />;
return <Inner />;
}
각 렌더링은 새로운 Inner 타입을 생성하여 재조정을 무력화하고 자식을 마운트 해제합니다.
6. 렌더링 성능 (중간)
SVG가 아닌 래퍼에 애니메이션 적용
SVG 주변의 <div> 래퍼를 변환하는 것은 GPU 가속입니다. SVG 자체를 변환하면 페인트가 트리거됩니다.
긴 목록을 위한 content-visibility: auto
.row { content-visibility: auto; contain-intrinsic-size: auto 80px; }
브라우저가 화면 밖의 렌더링을 건너뜁니다 — 수백 개의 행이 있는 목록에 큰 이점.
정적 JSX 호이스트
const STATIC_HEADER = <h1>Title</h1>;
function Page() {
return <>{STATIC_HEADER}<Body /></>;
}
SVG: 좌표 정밀도 축소
d="M10.123456,20.654321" → d="M10.12,20.65". 각 숫자는 바이트를 소비합니다. 시각적 차이는 서브 픽셀 수준입니다.
인라인 스크립트를 통한 하이드레이션 깜빡임 없음
하이드레이션 전에 필요한 값(테마, 로케일)은 React가 마운트되기 전에 document.documentElement.dataset.*을 설정하는 인라인 <script>를 사용하세요.
예상되는 하이드레이션 불일치를 좁게 억제
<time suppressHydrationWarning>{new Date().toLocaleString()}</time>
알려진 발산 리프 노드에만 사용하세요 — 다른 자식을 포함하는 트리에는 절대 사용하지 마세요.
마운트/언마운트 대신 <Activity>를 사용한 표시/숨김
React 19 <Activity mode="visible|hidden">는 트리 상태와 effect를 마운트된 상태로 유지하지만 숨깁니다 — 탭과 아코디언의 경우 언마운트/리마운트보다 저렴합니다.
조건부 렌더링에 && 대신 삼항 연산자 사용
// 잘못된 예 — `0`이 텍스트 노드로 렌더링됨
{count && <Badge>{count}</Badge>}
// 올바른 예
{count > 0 ? <Badge>{count}</Badge> : null}
로딩 상태를 위한 useTransition
startTransition을 액션과 쌍으로 사용하세요. React는 다음 상태가 계산되는 동안 이전 UI를 isPending으로 표시합니다.
React DOM 리소스 힌트
import { preload, preconnect } from "react-dom";
preload("/api/critical", { as: "fetch" });
preconnect("https://api.example.com")