ywc.life
Performance 2026년 1월 16일 24분 읽기

React Best Practices - Vercel 엔지니어링 팀의 성능 최적화 가이드 (전문 번역)

reactnextjsvercelperformanceoptimization
React Best Practices - Vercel 엔지니어링 팀의 성능 최적화 가이드 (전문 번역)

참고: 이 문서는 Vercel Engineering의 React Best Practices 전문을 번역한 것입니다.

Note: 이 문서는 주로 AI 에이전트와 LLM이 Vercel에서 React 및 Next.js 코드베이스를 유지보수, 생성 또는 리팩토링할 때 따르도록 설계되었습니다. 사람도 유용하게 활용할 수 있지만, 여기의 지침은 AI 지원 워크플로우의 자동화와 일관성에 최적화되어 있습니다.


개요

React 및 Next.js 애플리케이션을 위한 종합 성능 최적화 가이드입니다. AI 에이전트와 LLM을 위해 설계되었습니다. 8개 카테고리에 걸쳐 40개 이상의 규칙을 포함하며, Critical(워터폴 제거, 번들 크기 감소)부터 점진적 개선(고급 패턴)까지 영향도 기준으로 우선순위가 매겨져 있습니다. 각 규칙에는 상세한 설명, 잘못된 구현과 올바른 구현을 비교하는 실제 예제, 그리고 자동화된 리팩토링 및 코드 생성을 안내하는 구체적인 영향도 지표가 포함되어 있습니다.


목차

  1. 워터폴 제거CRITICAL
  2. 번들 크기 최적화CRITICAL
  3. 서버 사이드 성능HIGH
  4. 클라이언트 사이드 데이터 페칭MEDIUM-HIGH
  5. 리렌더 최적화MEDIUM
  6. 렌더링 성능MEDIUM
  7. JavaScript 성능LOW-MEDIUM
  8. 고급 패턴LOW

1. 워터폴 제거

영향도: CRITICAL

워터폴은 성능의 #1 킬러입니다. 순차적인 await마다 전체 네트워크 지연 시간이 추가됩니다. 워터폴을 제거하면 가장 큰 개선 효과를 얻을 수 있습니다.

1.1 필요할 때까지 Await 지연하기

영향도: HIGH (사용되지 않는 코드 경로 차단 방지)

await 작업을 실제로 사용되는 분기로 이동시켜 필요하지 않은 코드 경로를 차단하지 않도록 합니다.

잘못된 예: 두 분기 모두 차단

async function handleRequest(userId: string, skipProcessing: boolean) {
  const userData = await fetchUserData(userId);

  if (skipProcessing) {
    // 즉시 반환하지만 여전히 userData를 기다림
    return { skipped: true };
  }

  // 이 분기만 userData를 사용함
  return processUserData(userData);
}

올바른 예: 필요할 때만 차단

async function handleRequest(userId: string, skipProcessing: boolean) {
  if (skipProcessing) {
    // 기다리지 않고 즉시 반환
    return { skipped: true };
  }

  // 필요할 때만 fetch
  const userData = await fetchUserData(userId);
  return processUserData(userData);
}

또 다른 예: 조기 반환 최적화

// 잘못된 예: 항상 permissions를 fetch
async function updateResource(resourceId: string, userId: string) {
  const permissions = await fetchPermissions(userId);
  const resource = await getResource(resourceId);

  if (!resource) {
    return { error: "Not found" };
  }

  if (!permissions.canEdit) {
    return { error: "Forbidden" };
  }

  return await updateResourceData(resource, permissions);
}

// 올바른 예: 필요할 때만 fetch
async function updateResource(resourceId: string, userId: string) {
  const resource = await getResource(resourceId);

  if (!resource) {
    return { error: "Not found" };
  }

  const permissions = await fetchPermissions(userId);

  if (!permissions.canEdit) {
    return { error: "Forbidden" };
  }

  return await updateResourceData(resource, permissions);
}

이 최적화는 건너뛰는 분기가 자주 실행되거나, 지연된 작업이 비용이 많이 드는 경우 특히 유용합니다.

1.2 의존성 기반 병렬화

영향도: CRITICAL (2-10배 개선)

부분적인 의존성이 있는 작업의 경우 better-all을 사용하여 병렬성을 최대화합니다. 각 작업을 가능한 가장 빠른 시점에 자동으로 시작합니다.

잘못된 예: profile이 불필요하게 config를 기다림

const [user, config] = await Promise.all([fetchUser(), fetchConfig()]);
const profile = await fetchProfile(user.id);

올바른 예: config와 profile이 병렬로 실행

import { all } from "better-all";

const { user, config, profile } = await all({
  async user() {
    return fetchUser();
  },
  async config() {
    return fetchConfig();
  },
  async profile() {
    return fetchProfile((await this.$.user).id);
  },
});

참고: https://github.com/shuding/better-all

1.3 API 라우트에서 워터폴 체인 방지

영향도: CRITICAL (2-10배 개선)

API 라우트와 Server Actions에서는 아직 await하지 않더라도 독립적인 작업을 즉시 시작합니다.

잘못된 예: config가 auth를 기다리고, data가 둘 다 기다림

export async function GET(request: Request) {
  const session = await auth();
  const config = await fetchConfig();
  const data = await fetchData(session.user.id);
  return Response.json({ data, config });
}

올바른 예: auth와 config가 즉시 시작

export async function GET(request: Request) {
  const sessionPromise = auth();
  const configPromise = fetchConfig();
  const session = await sessionPromise;
  const [config, data] = await Promise.all([configPromise, fetchData(session.user.id)]);
  return Response.json({ data, config });
}

더 복잡한 의존성 체인이 있는 작업의 경우 better-all을 사용하여 자동으로 병렬성을 최대화합니다(의존성 기반 병렬화 참조).

1.4 독립적인 작업에 Promise.all() 사용

영향도: CRITICAL (2-10배 개선)

비동기 작업 간에 상호 의존성이 없으면 Promise.all()을 사용하여 동시에 실행합니다.

잘못된 예: 순차 실행, 3번의 라운드 트립

const user = await fetchUser();
const posts = await fetchPosts();
const comments = await fetchComments();

올바른 예: 병렬 실행, 1번의 라운드 트립

const [user, posts, comments] = await Promise.all([fetchUser(), fetchPosts(), fetchComments()]);

1.5 전략적 Suspense 경계

영향도: HIGH (더 빠른 초기 페인트)

비동기 컴포넌트에서 JSX를 반환하기 전에 데이터를 await하는 대신, Suspense 경계를 사용하여 데이터가 로드되는 동안 래퍼 UI를 더 빠르게 표시합니다.

잘못된 예: 래퍼가 데이터 페칭에 의해 차단됨

async function Page() {
  const data = await fetchData(); // 전체 페이지를 차단

  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <div>
        <DataDisplay data={data} />
      </div>
      <div>Footer</div>
    </div>
  );
}

중간 섹션만 데이터가 필요한데도 전체 레이아웃이 데이터를 기다립니다.

올바른 예: 래퍼가 즉시 표시되고, 데이터가 스트리밍됨

function Page() {
  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <div>
        <Suspense fallback={<Skeleton />}>
          <DataDisplay />
        </Suspense>
      </div>
      <div>Footer</div>
    </div>
  );
}

async function DataDisplay() {
  const data = await fetchData(); // 이 컴포넌트만 차단
  return <div>{data.content}</div>;
}

Sidebar, Header, Footer는 즉시 렌더링됩니다. DataDisplay만 데이터를 기다립니다.

대안: 컴포넌트 간 promise 공유

function Page() {
  // fetch를 즉시 시작하지만 await하지 않음
  const dataPromise = fetchData();

  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <Suspense fallback={<Skeleton />}>
        <DataDisplay dataPromise={dataPromise} />
        <DataSummary dataPromise={dataPromise} />
      </Suspense>
      <div>Footer</div>
    </div>
  );
}

function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
  const data = use(dataPromise); // promise를 언래핑
  return <div>{data.content}</div>;
}

function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
  const data = use(dataPromise); // 같은 promise를 재사용
  return <div>{data.summary}</div>;
}

두 컴포넌트가 같은 promise를 공유하므로 fetch는 한 번만 발생합니다. 레이아웃은 즉시 렌더링되고 두 컴포넌트가 함께 기다립니다.

이 패턴을 사용하지 말아야 할 때:

  • 레이아웃 결정에 필요한 중요한 데이터(위치에 영향)
  • 스크롤 없이 볼 수 있는 영역의 SEO 중요 콘텐츠
  • suspense 오버헤드가 가치 없는 작고 빠른 쿼리
  • 레이아웃 시프트를 피하고 싶을 때(로딩 → 콘텐츠 점프)

트레이드오프: 더 빠른 초기 페인트 vs 잠재적인 레이아웃 시프트. UX 우선순위에 따라 선택하세요.


2. 번들 크기 최적화

영향도: CRITICAL

초기 번들 크기를 줄이면 Time to Interactive와 Largest Contentful Paint가 개선됩니다.

2.1 Barrel 파일 Import 피하기

영향도: CRITICAL (200-800ms import 비용, 느린 빌드)

barrel 파일 대신 소스 파일에서 직접 import하여 수천 개의 사용되지 않는 모듈 로딩을 피합니다. Barrel 파일은 여러 모듈을 re-export하는 진입점입니다(예: export * from './module'을 하는 index.js).

인기 있는 아이콘 및 컴포넌트 라이브러리는 진입 파일에 최대 10,000개의 re-export가 있을 수 있습니다. 많은 React 패키지에서 import하는 데만 200-800ms가 소요되어 개발 속도와 프로덕션 콜드 스타트 모두에 영향을 미칩니다.

트리 쉐이킹이 도움이 되지 않는 이유: 라이브러리가 external로 표시되면(번들되지 않음) 번들러가 최적화할 수 없습니다. 트리 쉐이킹을 활성화하기 위해 번들하면 전체 모듈 그래프를 분석하느라 빌드가 상당히 느려집니다.

잘못된 예: 전체 라이브러리를 import

import { Check, X, Menu } from "lucide-react";
// 1,583개 모듈 로드, dev에서 ~2.8초 추가
// 런타임 비용: 매 콜드 스타트마다 200-800ms

import { Button, TextField } from "@mui/material";
// 2,225개 모듈 로드, dev에서 ~4.2초 추가

올바른 예: 필요한 것만 import

import Check from "lucide-react/dist/esm/icons/check";
import X from "lucide-react/dist/esm/icons/x";
import Menu from "lucide-react/dist/esm/icons/menu";
// 3개 모듈만 로드 (~2KB vs ~1MB)

import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
// 사용하는 것만 로드

대안: Next.js 13.5+

// next.config.js - optimizePackageImports 사용
module.exports = {
  experimental: {
    optimizePackageImports: ["lucide-react", "@mui/material"],
  },
};

// 그러면 편리한 barrel import를 유지할 수 있습니다:
import { Check, X, Menu } from "lucide-react";
// 빌드 시 자동으로 직접 import로 변환됨

직접 import는 15-70% 더 빠른 dev 부팅, 28% 더 빠른 빌드, 40% 더 빠른 콜드 스타트, 그리고 상당히 더 빠른 HMR을 제공합니다.

일반적으로 영향받는 라이브러리: lucide-react, @mui/material, @mui/icons-material, @tabler/icons-react, react-icons, @headlessui/react, @radix-ui/react-*, lodash, ramda, date-fns, rxjs, react-use.

참고: https://vercel.com/blog/how-we-optimized-package-imports-in-next-js

2.2 조건부 모듈 로딩

영향도: HIGH (필요할 때만 대용량 데이터 로드)

기능이 활성화될 때만 대용량 데이터나 모듈을 로드합니다.

예: 애니메이션 프레임 지연 로딩

function AnimationPlayer({ enabled }: { enabled: boolean }) {
  const [frames, setFrames] = useState<Frame[] | null>(null);

  useEffect(() => {
    if (enabled && !frames && typeof window !== "undefined") {
      import("./animation-frames.js")
        .then((mod) => setFrames(mod.frames))
        .catch(() => setEnabled(false));
    }
  }, [enabled, frames]);

  if (!frames) return <Skeleton />;
  return <Canvas frames={frames} />;
}

typeof window !== 'undefined' 체크는 이 모듈이 SSR용으로 번들되는 것을 방지하여 서버 번들 크기와 빌드 속도를 최적화합니다.

2.3 비핵심 서드파티 라이브러리 지연 로딩

영향도: MEDIUM (hydration 후 로드)

Analytics, 로깅, 에러 트래킹은 사용자 상호작용을 차단하지 않습니다. hydration 후에 로드합니다.

잘못된 예: 초기 번들을 차단

import { Analytics } from "@vercel/analytics/react";

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  );
}

올바른 예: hydration 후 로드

import dynamic from "next/dynamic";

const Analytics = dynamic(() => import("@vercel/analytics/react").then((m) => m.Analytics), {
  ssr: false,
});

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  );
}

2.4 무거운 컴포넌트에 Dynamic Import 사용

영향도: CRITICAL (TTI와 LCP에 직접 영향)

next/dynamic을 사용하여 초기 렌더링에 필요하지 않은 대용량 컴포넌트를 지연 로딩합니다.

잘못된 예: Monaco가 메인 청크와 함께 번들됨 ~300KB

import { MonacoEditor } from "./monaco-editor";

function CodePanel({ code }: { code: string }) {
  return <MonacoEditor value={code} />;
}

올바른 예: Monaco가 필요할 때 로드됨

import dynamic from "next/dynamic";

const MonacoEditor = dynamic(() => import("./monaco-editor").then((m) => m.MonacoEditor), {
  ssr: false,
});

function CodePanel({ code }: { code: string }) {
  return <MonacoEditor value={code} />;
}

2.5 사용자 의도 기반 프리로드

영향도: MEDIUM (체감 지연 시간 감소)

무거운 번들이 필요하기 전에 프리로드하여 체감 지연 시간을 줄입니다.

예: hover/focus 시 프리로드

function EditorButton({ onClick }: { onClick: () => void }) {
  const preload = () => {
    if (typeof window !== "undefined") {
      void import("./monaco-editor");
    }
  };

  return (
    <button onMouseEnter={preload} onFocus={preload} onClick={onClick}>
      Open Editor
    </button>
  );
}

예: feature flag가 활성화될 때 프리로드

function FlagsProvider({ children, flags }: Props) {
  useEffect(() => {
    if (flags.editorEnabled && typeof window !== "undefined") {
      void import("./monaco-editor").then((mod) => mod.init());
    }
  }, [flags.editorEnabled]);

  return <FlagsContext.Provider value={flags}>{children}</FlagsContext.Provider>;
}

typeof window !== 'undefined' 체크는 프리로드된 모듈이 SSR용으로 번들되는 것을 방지하여 서버 번들 크기와 빌드 속도를 최적화합니다.


3. 서버 사이드 성능

영향도: HIGH

서버 사이드 렌더링과 데이터 페칭을 최적화하면 서버 사이드 워터폴을 제거하고 응답 시간을 줄입니다.

3.1 요청 간 LRU 캐싱

영향도: HIGH (요청 간 캐싱)

React.cache()는 하나의 요청 내에서만 작동합니다. 순차적인 요청 간에 공유되는 데이터(사용자가 버튼 A를 클릭한 다음 버튼 B를 클릭)의 경우 LRU 캐시를 사용합니다.

구현:

import { LRUCache } from "lru-cache";

const cache = new LRUCache<string, any>({
  max: 1000,
  ttl: 5 * 60 * 1000, // 5분
});

export async function getUser(id: string) {
  const cached = cache.get(id);
  if (cached) return cached;

  const user = await db.user.findUnique({ where: { id } });
  cache.set(id, user);
  return user;
}

// 요청 1: DB 쿼리, 결과 캐싱
// 요청 2: 캐시 히트, DB 쿼리 없음

순차적인 사용자 액션이 몇 초 내에 같은 데이터가 필요한 여러 엔드포인트에 도달할 때 사용합니다.

Vercel의 Fluid Compute 사용 시: LRU 캐싱은 특히 효과적입니다. 여러 동시 요청이 같은 함수 인스턴스와 캐시를 공유할 수 있기 때문입니다. 이는 Redis 같은 외부 스토리지 없이도 요청 간에 캐시가 유지된다는 것을 의미합니다.

기존 서버리스에서: 각 호출이 격리되어 실행되므로 프로세스 간 캐싱을 위해 Redis를 고려하세요.

참고: https://github.com/isaacs/node-lru-cache

3.2 RSC 경계에서 직렬화 최소화

영향도: HIGH (데이터 전송 크기 감소)

React Server/Client 경계는 모든 객체 속성을 문자열로 직렬화하여 HTML 응답과 후속 RSC 요청에 포함합니다. 이 직렬화된 데이터는 페이지 용량과 로드 시간에 직접 영향을 미치므로 크기가 매우 중요합니다. 클라이언트가 실제로 사용하는 필드만 전달하세요.

잘못된 예: 50개 필드 모두 직렬화

async function Page() {
  const user = await fetchUser(); // 50개 필드
  return <Profile user={user} />;
}

("use client");
function Profile({ user }: { user: User }) {
  return <div>{user.name}</div>; // 1개 필드만 사용
}

올바른 예: 1개 필드만 직렬화

async function Page() {
  const user = await fetchUser();
  return <Profile name={user.name} />;
}

("use client");
function Profile({ name }: { name: string }) {
  return <div>{name}</div>;
}

3.3 컴포넌트 합성을 통한 병렬 데이터 페칭

영향도: CRITICAL (서버 사이드 워터폴 제거)

React Server Components는 트리 내에서 순차적으로 실행됩니다. 합성을 통해 재구조화하여 데이터 페칭을 병렬화합니다.

잘못된 예: Sidebar가 Page의 fetch 완료를 기다림

export default async function Page() {
  const header = await fetchHeader();
  return (
    <div>
      <div>{header}</div>
      <Sidebar />
    </div>
  );
}

async function Sidebar() {
  const items = await fetchSidebarItems();
  return <nav>{items.map(renderItem)}</nav>;
}

올바른 예: 둘 다 동시에 fetch

async function Header() {
  const data = await fetchHeader();
  return <div>{data}</div>;
}

async function Sidebar() {
  const items = await fetchSidebarItems();
  return <nav>{items.map(renderItem)}</nav>;
}

export default function Page() {
  return (
    <div>
      <Header />
      <Sidebar />
    </div>
  );
}

children prop을 사용한 대안:

async function Layout({ children }: { children: ReactNode }) {
  const header = await fetchHeader();
  return (
    <div>
      <div>{header}</div>
      {children}
    </div>
  );
}

async function Sidebar() {
  const items = await fetchSidebarItems();
  return <nav>{items.map(renderItem)}</nav>;
}

export default function Page() {
  return (
    <Layout>
      <Sidebar />
    </Layout>
  );
}

3.4 React.cache()를 사용한 요청 내 중복 제거

영향도: MEDIUM (요청 내 중복 제거)

서버 사이드 요청 중복 제거를 위해 React.cache()를 사용합니다. 인증 및 데이터베이스 쿼리가 가장 많은 이점을 얻습니다.

사용법:

import { cache } from "react";

export const getCurrentUser = cache(async () => {
  const session = await auth();
  if (!session?.user?.id) return null;
  return await db.user.findUnique({
    where: { id: session.user.id },
  });
});

단일 요청 내에서 getCurrentUser()를 여러 번 호출해도 쿼리는 한 번만 실행됩니다.

3.5 비차단 작업에 after() 사용

영향도: MEDIUM (더 빠른 응답 시간)

Next.js의 after()를 사용하여 응답이 전송된 후 실행해야 하는 작업을 예약합니다. 이렇게 하면 로깅, analytics 및 기타 부수 효과가 응답을 차단하는 것을 방지합니다.

잘못된 예: 응답을 차단

import { logUserAction } from "@/app/utils";

export async function POST(request: Request) {
  // 뮤테이션 수행
  await updateDatabase(request);

  // 로깅이 응답을 차단
  const userAgent = request.headers.get("user-agent") || "unknown";
  await logUserAction({ userAgent });

  return new Response(JSON.stringify({ status: "success" }), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
}

올바른 예: 비차단

import { after } from "next/server";
import { headers, cookies } from "next/headers";
import { logUserAction } from "@/app/utils";

export async function POST(request: Request) {
  // 뮤테이션 수행
  await updateDatabase(request);

  // 응답 전송 후 로깅
  after(async () => {
    const userAgent = (await headers()).get("user-agent") || "unknown";
    const sessionCookie = (await cookies()).get("session-id")?.value || "anonymous";

    logUserAction({ sessionCookie, userAgent });
  });

  return new Response(JSON.stringify({ status: "success" }), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
}

응답이 즉시 전송되고 로깅은 백그라운드에서 발생합니다.

일반적인 사용 사례:

  • Analytics 추적
  • 감사 로깅
  • 알림 전송
  • 캐시 무효화
  • 정리 작업

중요 참고:

  • after()는 응답이 실패하거나 리디렉션되어도 실행됩니다
  • Server Actions, Route Handlers, Server Components에서 작동합니다

참고: https://nextjs.org/docs/app/api-reference/functions/after


4. 클라이언트 사이드 데이터 페칭

영향도: MEDIUM-HIGH

자동 중복 제거와 효율적인 데이터 페칭 패턴은 중복 네트워크 요청을 줄입니다.

4.1 전역 이벤트 리스너 중복 제거

영향도: LOW (N개 컴포넌트에 단일 리스너)

useSWRSubscription()을 사용하여 컴포넌트 인스턴스 간에 전역 이벤트 리스너를 공유합니다.

잘못된 예: N개 인스턴스 = N개 리스너

function useKeyboardShortcut(key: string, callback: () => void) {
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.metaKey && e.key === key) {
        callback();
      }
    };
    window.addEventListener("keydown", handler);
    return () => window.removeEventListener("keydown", handler);
  }, [key, callback]);
}

useKeyboardShortcut 훅을 여러 번 사용하면 각 인스턴스가 새 리스너를 등록합니다.

올바른 예: N개 인스턴스 = 1개 리스너

import useSWRSubscription from "swr/subscription";

// 키당 콜백을 추적하는 모듈 레벨 Map
const keyCallbacks = new Map<string, Set<() => void>>();

function useKeyboardShortcut(key: string, callback: () => void) {
  // 이 콜백을 Map에 등록
  useEffect(() => {
    if (!keyCallbacks.has(key)) {
      keyCallbacks.set(key, new Set());
    }
    keyCallbacks.get(key)!.add(callback);

    return () => {
      const set = keyCallbacks.get(key);
      if (set) {
        set.delete(callback);
        if (set.size === 0) {
          keyCallbacks.delete(key);
        }
      }
    };
  }, [key, callback]);

  useSWRSubscription("global-keydown", () => {
    const handler = (e: KeyboardEvent) => {
      if (e.metaKey && keyCallbacks.has(e.key)) {
        keyCallbacks.get(e.key)!.forEach((cb) => cb());
      }
    };
    window.addEventListener("keydown", handler);
    return () => window.removeEventListener("keydown", handler);
  });
}

function Profile() {
  // 여러 단축키가 같은 리스너를 공유
  useKeyboardShortcut("p", () => {
    /* ... */
  });
  useKeyboardShortcut("k", () => {
    /* ... */
  });
  // ...
}

4.2 자동 중복 제거를 위한 SWR 사용

영향도: MEDIUM-HIGH (자동 중복 제거)

SWR은 컴포넌트 인스턴스 간 요청 중복 제거, 캐싱 및 재검증을 활성화합니다.

잘못된 예: 중복 제거 없음, 각 인스턴스가 fetch

function UserList() {
  const [users, setUsers] = useState([]);
  useEffect(() => {
    fetch("/api/users")
      .then((r) => r.json())
      .then(setUsers);
  }, []);
}

올바른 예: 여러 인스턴스가 하나의 요청 공유

import useSWR from "swr";

function UserList() {
  const { data: users } = useSWR("/api/users", fetcher);
}

불변 데이터의 경우:

import { useImmutableSWR } from "@/lib/swr";

function StaticContent() {
  const { data } = useImmutableSWR("/api/config", fetcher);
}

뮤테이션의 경우:

import { useSWRMutation } from "swr/mutation";

function UpdateButton() {
  const { trigger } = useSWRMutation("/api/user", updateUser);
  return <button onClick={() => trigger()}>Update</button>;
}

참고: https://swr.vercel.app


5. 리렌더 최적화

영향도: MEDIUM

불필요한 리렌더를 줄이면 낭비되는 계산을 최소화하고 UI 반응성을 개선합니다.

5.1 사용 시점까지 상태 읽기 지연

영향도: MEDIUM (불필요한 구독 방지)

콜백 내에서만 읽는 경우 동적 상태(searchParams, localStorage)를 구독하지 마세요.

잘못된 예: 모든 searchParams 변경에 구독

function ShareButton({ chatId }: { chatId: string }) {
  const searchParams = useSearchParams();

  const handleShare = () => {
    const ref = searchParams.get("ref");
    shareChat(chatId, { ref });
  };

  return <button onClick={handleShare}>Share</button>;
}

올바른 예: 필요할 때 읽음, 구독 없음

function ShareButton({ chatId }: { chatId: string }) {
  const handleShare = () => {
    const params = new URLSearchParams(window.location.search);
    const ref = params.get("ref");
    shareChat(chatId, { ref });
  };

  return <button onClick={handleShare}>Share</button>;
}

5.2 메모이즈된 컴포넌트로 추출

영향도: MEDIUM (조기 반환 활성화)

비싼 작업을 메모이즈된 컴포넌트로 추출하여 계산 전 조기 반환을 활성화합니다.

잘못된 예: 로딩 중에도 avatar 계산

function Profile({ user, loading }: Props) {
  const avatar = useMemo(() => {
    const id = computeAvatarId(user);
    return <Avatar id={id} />;
  }, [user]);

  if (loading) return <Skeleton />;
  return <div>{avatar}</div>;
}

올바른 예: 로딩 시 계산 건너뜀

const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
  const id = useMemo(() => computeAvatarId(user), [user]);
  return <Avatar id={id} />;
});

function Profile({ user, loading }: Props) {
  if (loading) return <Skeleton />;
  return (
    <div>
      <UserAvatar user={user} />
    </div>
  );
}

참고: 프로젝트에 React Compiler가 활성화되어 있다면, memo()useMemo()를 사용한 수동 메모이제이션은 필요하지 않습니다. 컴파일러가 자동으로 리렌더를 최적화합니다.

5.3 이펙트 의존성 좁히기

영향도: LOW (이펙트 재실행 최소화)

객체 대신 원시 값을 의존성으로 지정하여 이펙트 재실행을 최소화합니다.

잘못된 예: user 필드 변경 시마다 재실행

useEffect(() => {
  console.log(user.id);
}, [user]);

올바른 예: id 변경 시에만 재실행

useEffect(() => {
  console.log(user.id);
}, [user.id]);

파생 상태의 경우 이펙트 외부에서 계산:

// 잘못된 예: width=767, 766, 765...에서 실행
useEffect(() => {
  if (width < 768) {
    enableMobileMode();
  }
}, [width]);

// 올바른 예: boolean 전환 시에만 실행
const isMobile = width < 768;
useEffect(() => {
  if (isMobile) {
    enableMobileMode();
  }
}, [isMobile]);

5.4 파생 상태 구독

영향도: MEDIUM (리렌더 빈도 감소)

리렌더 빈도를 줄이기 위해 연속적인 값 대신 파생된 boolean 상태를 구독합니다.

잘못된 예: 픽셀 변경마다 리렌더

function Sidebar() {
  const width = useWindowWidth(); // 연속적으로 업데이트
  const isMobile = width < 768;
  return <nav className={isMobile ? "mobile" : "desktop"}></nav>;
}

올바른 예: boolean 변경 시에만 리렌더

function Sidebar() {
  const isMobile = useMediaQuery("(max-width: 767px)");
  return <nav className={isMobile ? "mobile" : "desktop"}></nav>;
}

5.5 함수형 setState 업데이트 사용

영향도: MEDIUM (스테일 클로저 방지 및 불필요한 콜백 재생성 방지)

현재 상태 값을 기반으로 상태를 업데이트할 때, 상태 변수를 직접 참조하는 대신 setState의 함수형 업데이트 형태를 사용하세요.

잘못된 예: 상태가 의존성으로 필요

function TodoList() {
  const [items, setItems] = useState(initialItems);

  // 콜백이 items에 의존해야 함, items 변경마다 재생성
  const addItems = useCallback(
    (newItems: Item[]) => {
      setItems([...items, ...newItems]);
    },
    [items]
  ); // items 의존성이 재생성을 유발

  return <ItemsEditor items={items} onAdd={addItems} />;
}

올바른 예: 안정적인 콜백, 스테일 클로저 없음

function TodoList() {
  const [items, setItems] = useState(initialItems);

  // 안정적인 콜백, 재생성 안 됨
  const addItems = useCallback((newItems: Item[]) => {
    setItems((curr) => [...curr, ...newItems]);
  }, []); // 의존성 필요 없음

  return <ItemsEditor items={items} onAdd={addItems} />;
}

5.6 지연 상태 초기화 사용

영향도: MEDIUM (매 렌더마다 낭비되는 계산)

비싼 초기 값에는 useState에 함수를 전달합니다.

잘못된 예: 매 렌더마다 실행

const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items));

올바른 예: 한 번만 실행

const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items));

5.7 비긴급 업데이트에 Transitions 사용

영향도: MEDIUM (UI 반응성 유지)

빈번하고 비긴급한 상태 업데이트를 transitions로 표시하여 UI 반응성을 유지합니다.

잘못된 예: 매 스크롤마다 UI 차단

function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0);
  useEffect(() => {
    const handler = () => setScrollY(window.scrollY);
    window.addEventListener("scroll", handler, { passive: true });
    return () => window.removeEventListener("scroll", handler);
  }, []);
}

올바른 예: 비차단 업데이트

import { startTransition } from "react";

function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0);
  useEffect(() => {
    const handler = () => {
      startTransition(() => setScrollY(window.scrollY));
    };
    window.addEventListener("scroll", handler, { passive: true });
    return () => window.removeEventListener("scroll", handler);
  }, []);
}

6. 렌더링 성능

영향도: MEDIUM

렌더링 프로세스를 최적화하면 브라우저가 해야 할 작업을 줄입니다.

6.1 SVG 요소 대신 래퍼 애니메이션

영향도: LOW (하드웨어 가속 활성화)

많은 브라우저는 SVG 요소에 대한 CSS3 애니메이션의 하드웨어 가속을 지원하지 않습니다. SVG를 <div>로 감싸고 래퍼를 대신 애니메이션합니다.

잘못된 예: SVG 직접 애니메이션 - 하드웨어 가속 없음

function LoadingSpinner() {
  return (
    <svg className="animate-spin" width="24" height="24" viewBox="0 0 24 24">
      <circle cx="12" cy="12" r="10" stroke="currentColor" />
    </svg>
  );
}

올바른 예: 래퍼 div 애니메이션 - 하드웨어 가속

function LoadingSpinner() {
  return (
    <div className="animate-spin">
      <svg width="24" height="24" viewBox="0 0 24 24">
        <circle cx="12" cy="12" r="10" stroke="currentColor" />
      </svg>
    </div>
  );
}

6.2 긴 리스트를 위한 CSS content-visibility

영향도: HIGH (더 빠른 초기 렌더)

화면 밖 렌더링을 지연하기 위해 content-visibility: auto를 적용합니다.

.message-item {
  content-visibility: auto;
  contain-intrinsic-size: 0 80px;
}

1000개 메시지의 경우, 브라우저가 화면 밖 ~990개 항목의 레이아웃/페인트를 건너뜁니다(10배 더 빠른 초기 렌더).

6.3 정적 JSX 요소 호이스팅

영향도: LOW (재생성 방지)

재생성을 피하기 위해 정적 JSX를 컴포넌트 외부로 추출합니다.

잘못된 예: 매 렌더마다 요소 재생성

function LoadingSkeleton() {
  return <div className="animate-pulse h-20 bg-gray-200" />;
}

올바른 예: 같은 요소 재사용

const loadingSkeleton = <div className="animate-pulse h-20 bg-gray-200" />;

function Container() {
  return <div>{loading && loadingSkeleton}</div>;
}

6.4 SVG 정밀도 최적화

영향도: LOW (파일 크기 감소)

파일 크기를 줄이기 위해 SVG 좌표 정밀도를 줄입니다.

잘못된 예: 과도한 정밀도

<path d="M 10.293847 20.847362 L 30.938472 40.192837" />

올바른 예: 소수점 1자리

<path d="M 10.3 20.8 L 30.9 40.2" />

6.5 깜빡임 없이 Hydration 불일치 방지

영향도: MEDIUM (시각적 깜빡임 및 hydration 에러 방지)

클라이언트 사이드 스토리지(localStorage, cookies)에 의존하는 콘텐츠를 렌더링할 때, React가 hydrate하기 전에 DOM을 업데이트하는 동기 스크립트를 주입합니다.

잘못된 예: 시각적 깜빡임

function ThemeWrapper({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState("light");

  useEffect(() => {
    const stored = localStorage.getItem("theme");
    if (stored) setTheme(stored);
  }, []);

  return <div className={theme}>{children}</div>;
}

올바른 예: 인라인 스크립트로 즉시 적용

인라인 스크립트를 사용하여 요소를 표시하기 전에 DOM이 올바른 값을 갖도록 합니다. 이렇게 하면 깜빡임 없이, hydration 불일치 없이 테마가 적용됩니다.

6.6 Show/Hide에 Activity 컴포넌트 사용

영향도: MEDIUM (상태/DOM 보존)

자주 가시성이 토글되는 비싼 컴포넌트의 상태/DOM을 보존하기 위해 React의 <Activity>를 사용합니다.

import { Activity } from "react";

function Dropdown({ isOpen }: Props) {
  return (
    <Activity mode={isOpen ? "visible" : "hidden"}>
      <ExpensiveMenu />
    </Activity>
  );
}

6.7 명시적 조건부 렌더링 사용

영향도: LOW (0 또는 NaN 렌더링 방지)

조건이 0, NaN 또는 렌더링되는 다른 falsy 값일 수 있을 때 && 대신 명시적 삼항 연산자를 사용합니다.

잘못된 예: count가 0일 때 “0” 렌더링

{
  count && <span className="badge">{count}</span>;
}

올바른 예: count가 0일 때 아무것도 렌더링 안 함

{
  count > 0 ? <span className="badge">{count}</span> : null;
}

7. JavaScript 성능

영향도: LOW-MEDIUM

핫 패스에 대한 마이크로 최적화가 쌓이면 의미 있는 개선이 될 수 있습니다.

7.1 DOM CSS 변경 일괄 처리

영향도: MEDIUM (리플로우/리페인트 감소)

스타일을 한 번에 하나씩 변경하지 말고 클래스나 cssText를 통해 여러 CSS 변경을 함께 그룹화합니다.

7.2 반복 조회를 위한 인덱스 Map 빌드

영향도: LOW-MEDIUM (1M 작업을 2K 작업으로)

같은 키로 여러 번 .find()를 호출하면 Map을 사용해야 합니다.

잘못된 예 (조회당 O(n)):

const user = users.find((u) => u.id === order.userId);

올바른 예 (조회당 O(1)):

const userById = new Map(users.map((u) => [u.id, u]));
const user = userById.get(order.userId);

7.3 루프에서 속성 접근 캐싱

핫 패스에서 객체 속성 조회를 캐싱합니다.

7.4 반복 함수 호출 캐싱

렌더 중에 같은 입력으로 같은 함수가 반복적으로 호출될 때 모듈 레벨 Map을 사용하여 함수 결과를 캐싱합니다.

7.5 Storage API 호출 캐싱

localStorage, sessionStorage, document.cookie는 동기적이고 비쌉니다. 메모리에 읽기를 캐싱합니다.

7.6 여러 배열 반복 결합

여러 .filter() 또는 .map() 호출은 배열을 여러 번 반복합니다. 하나의 루프로 결합합니다.

7.7 배열 비교에서 길이 먼저 확인

비싼 작업으로 배열을 비교할 때 먼저 길이를 확인합니다. 길이가 다르면 배열은 같을 수 없습니다.

7.8 함수에서 조기 반환

결과가 결정되면 조기에 반환하여 불필요한 처리를 건너뜁니다.

7.9 RegExp 생성 호이스팅

렌더 내부에서 RegExp를 생성하지 마세요. 모듈 스코프로 호이스팅하거나 useMemo()로 메모이즈합니다.

7.10 Sort 대신 루프로 Min/Max 찾기

영향도: LOW (O(n log n) 대신 O(n))

가장 작거나 큰 요소를 찾는 데는 배열을 한 번 통과하면 됩니다. 정렬은 낭비이고 더 느립니다.

7.11 O(1) 조회를 위한 Set/Map 사용

반복되는 멤버십 확인을 위해 배열을 Set/Map으로 변환합니다.

잘못된 예 (확인당 O(n)):

allowedIds.includes(item.id);

올바른 예 (확인당 O(1)):

allowedIds.has(item.id);

7.12 불변성을 위해 sort() 대신 toSorted() 사용

영향도: MEDIUM-HIGH (React 상태에서 변경 버그 방지)

.sort()는 배열을 제자리에서 변경합니다. .toSorted()를 사용하여 변경 없이 새로운 정렬된 배열을 만듭니다.

잘못된 예: 원본 배열 변경

const sorted = users.sort((a, b) => a.name.localeCompare(b.name));

올바른 예: 새 배열 생성

const sorted = users.toSorted((a, b) => a.name.localeCompare(b.name));

8. 고급 패턴

영향도: LOW

주의 깊은 구현이 필요한 특정 케이스를 위한 고급 패턴.

8.1 Refs에 이벤트 핸들러 저장

영향도: LOW (안정적인 구독)

콜백 변경 시 재구독하지 않아야 하는 이펙트에서 사용되는 콜백을 refs에 저장합니다. useEffectEvent를 사용하면 더 깔끔한 API를 제공합니다.

8.2 안정적인 콜백 Refs를 위한 useLatest

영향도: LOW (이펙트 재실행 방지)

의존성 배열에 추가하지 않고 콜백에서 최신 값에 접근합니다.

구현:

function useLatest<T>(value: T) {
  const ref = useRef(value);
  useEffect(() => {
    ref.current = value;
  }, [value]);
  return ref;
}

참고 자료

  1. https://react.dev
  2. https://nextjs.org
  3. https://swr.vercel.app
  4. https://github.com/shuding/better-all
  5. https://github.com/isaacs/node-lru-cache
  6. https://vercel.com/blog/how-we-optimized-package-imports-in-next-js
  7. https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast

댓글