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- | 이펙트-이벤트 통합, 안정적인 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> 경계를 데이터 가까이에 배치하여 느린 하위 트리가 스트리밍되는 동안 페이지가 가능한 부분을 먼저 그리도록 합니다. 단점: 콘텐츠가 도착하면 레이아웃 이동이 발생합니다 — 공간을 예약하세요 (스켈레톤 또는 min-height).
서버 컴포넌트: 구성을 통한 병렬 처리
// 올바르지 않음 — 형제 await가 하나의 컴포넌트 내에서 순차적으로 실행됨
export default async function Page() {
const user = await getUser();
const cart = await getCart();
return <View user={user} cart={cart} />;
}
// 올바름 — 자식으로 분할, React가 병렬로 실행
export default async function Page() {
return (
<View>
<UserSection />
<CartSection />
</View>
);
}
2. 번들 크기 최적화 (치명적)
직접 임포트, 배럴 사용 금지
배럴 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, // 클라이언트 전용일 때
});
써드파티 스크립트 지연
분석, 로깅, 위젯 지원은 하이드레이션 이후에 로드하세요. strategy="afterInteractive" (기본값) 또는 "lazyOnload"와 함께 next/script를 사용하세요.
조건부 모듈 로딩
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 캐시
요청별로 변경되지 않는 데이터(설정, 조회 테이블)의 경우, React 외부에서 LRU 캐시 또는 unstable_cache로 캐싱하세요.
RSC 프롭에서 중복 직렬화 방지
서버 컴포넌트가 동일한 데이터를 여러 클라이언트 컴포넌트에 렌더링할 때, 데이터는 소비자당 한 번씩 직렬화됩니다. 클라이언트 컴포넌트를 끌어올리고 자식을 전달하세요.
정적 I/O를 모듈 스코프로 호이스팅
// 올바름 — 모듈 로드 시 한 번 실행
const fontData = readFileSync(fontPath);
export async function Page() {
return <Banner font={fontData} />;
}
RSC/SSR에서 가변 모듈 수준 상태 사용 금지
서버의 모듈 상태는 모든 요청 간에 공유됩니다 — 사용자 간 경쟁 조건입니다. 대신 요청 범위 저장소(headers(), cookies(), async context)를 사용하세요.
클라이언트 컴포넌트에 전달되는 데이터 최소화
클라이언트가 필요한 것만 직렬화하세요. 필드를 제거하고, 페이지네이션하고, DB 계층에서 열을 프로젝션하세요.
항목별로 Promise.all로 중첩된 페치 병렬화
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
스크롤을 위한 수동 리스너
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} />
이펙트의 원시 의존성
// 올바르지 않음 — 렌더링마다 새 객체 식별
useEffect(() => {}, [{ id, name }]);
// 올바름 — 원시값
useEffect(() => {}, [id, name]);
파생된 불리언에 구독, 원시 값에는 구독하지 마세요
// 올바르지 않음 — 모든 장바구니 변경에 리렌더링
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">는 트리 상태와 이펙트를 마운트된 상태로 유지하지만 숨깁니다 — 탭과 아코디언의 경우 언마운트/리마운트보다 저렴합니다.
조건부 렌더링에 && 대신 삼항 연산자
// 올바르지 않음 — `0`이 텍스트 노드로 렌더링됨
{count && <Badge>{count}</Badge>}
// 올바름
{count > 0 ? <Badge>{count}</Badge> : null}
로딩 상태에 useTransition
startTransition을 액션과 쌍으로 사용하세요; React는 다음 상태가 계산되는 동안 isPending으로 이전 UI를 표시합니다.
React DOM 리소스 힌트
import { preload, preconnect } from "react-dom";
preload("/api/critical", { as: "fetch" });
preconnect("https://api.example.com")
…