# React 모범 사례
**버전 0.1.0**
Vercel 엔지니어링
2026년 1월
> **참고:**
> 이 문서는 주로 에이전트와 LLM이 Vercel에서 React 및 Next.js 코드베이스를
> 유지보수, 생성 또는 리팩토링할 때 따라야 할 지침입니다. 사람도 유용하게
> 사용할 수 있지만, 여기의 지침은 AI 지원 워크플로우에 의한 자동화와
> 일관성을 위해 최적화되어 있습니다.
---
## 요약
React 및 Next.js 애플리케이션을 위한 포괄적인 성능 최적화 가이드로, AI 에이전트와 LLM을 위해 설계되었습니다. 8개 카테고리에 걸쳐 40개 이상의 규칙을 포함하며, 중요도(워터폴 제거, 번들 크기 감소 등 치명적)부터 점진적(고급 패턴)까지 우선순위가 지정되어 있습니다. 각 규칙에는 상세한 설명, 잘못된 구현과 올바른 구현을 비교하는 실제 예제, 자동화된 리팩토링 및 코드 생성을 안내하는 구체적인 영향 지표가 포함되어 있습니다.
---
## 목차
1. [워터폴 제거](#1-워터폴-제거) — **치명적**
- 1.1 [필요할 때까지 Await 지연](#11-필요할-때까지-await-지연)
- 1.2 [의존성 기반 병렬화](#12-의존성-기반-병렬화)
- 1.3 [API 라우트에서 워터폴 체인 방지](#13-api-라우트에서-워터폴-체인-방지)
- 1.4 [독립적인 작업을 위한 Promise.all()](#14-독립적인-작업을-위한-promiseall)
- 1.5 [전략적 Suspense 경계](#15-전략적-suspense-경계)
2. [번들 크기 최적화](#2-번들-크기-최적화) — **치명적**
- 2.1 [배럴 파일 임포트 피하기](#21-배럴-파일-임포트-피하기)
- 2.2 [조건부 모듈 로딩](#22-조건부-모듈-로딩)
- 2.3 [중요하지 않은 서드파티 라이브러리 지연 로드](#23-중요하지-않은-서드파티-라이브러리-지연-로드)
- 2.4 [무거운 컴포넌트를 위한 동적 임포트](#24-무거운-컴포넌트를-위한-동적-임포트)
- 2.5 [사용자 의도에 기반한 프리로드](#25-사용자-의도에-기반한-프리로드)
3. [서버 사이드 성능](#3-서버-사이드-성능) — **높음**
- 3.1 [요청 간 LRU 캐싱](#31-요청-간-lru-캐싱)
- 3.2 [RSC 경계에서 직렬화 최소화](#32-rsc-경계에서-직렬화-최소화)
- 3.3 [컴포넌트 합성을 통한 병렬 데이터 페칭](#33-컴포넌트-합성을-통한-병렬-데이터-페칭)
- 3.4 [React.cache()를 사용한 요청별 중복 제거](#34-reactcache를-사용한-요청별-중복-제거)
- 3.5 [비차단 작업을 위한 after() 사용](#35-비차단-작업을-위한-after-사용)
4. [클라이언트 사이드 데이터 페칭](#4-클라이언트-사이드-데이터-페칭) — **중간-높음**
- 4.1 [글로벌 이벤트 리스너 중복 제거](#41-글로벌-이벤트-리스너-중복-제거)
- 4.2 [자동 중복 제거를 위한 SWR 사용](#42-자동-중복-제거를-위한-swr-사용)
5. [재렌더링 최적화](#5-재렌더링-최적화) — **중간**
- 5.1 [사용 시점까지 상태 읽기 지연](#51-사용-시점까지-상태-읽기-지연)
- 5.2 [메모이제이션된 컴포넌트로 추출](#52-메모이제이션된-컴포넌트로-추출)
- 5.3 [이펙트 의존성 좁히기](#53-이펙트-의존성-좁히기)
- 5.4 [파생 상태 구독](#54-파생-상태-구독)
- 5.5 [함수형 setState 업데이트 사용](#55-함수형-setstate-업데이트-사용)
- 5.6 [지연 상태 초기화 사용](#56-지연-상태-초기화-사용)
- 5.7 [긴급하지 않은 업데이트에 트랜지션 사용](#57-긴급하지-않은-업데이트에-트랜지션-사용)
6. [렌더링 성능](#6-렌더링-성능) — **중간**
- 6.1 [SVG 요소 대신 래퍼 애니메이션](#61-svg-요소-대신-래퍼-애니메이션)
- 6.2 [긴 리스트를 위한 CSS content-visibility](#62-긴-리스트를-위한-css-content-visibility)
- 6.3 [정적 JSX 요소 호이스팅](#63-정적-jsx-요소-호이스팅)
- 6.4 [SVG 정밀도 최적화](#64-svg-정밀도-최적화)
- 6.5 [깜빡임 없이 하이드레이션 불일치 방지](#65-깜빡임-없이-하이드레이션-불일치-방지)
- 6.6 [표시/숨김을 위한 Activity 컴포넌트 사용](#66-표시숨김을-위한-activity-컴포넌트-사용)
- 6.7 [명시적 조건부 렌더링 사용](#67-명시적-조건부-렌더링-사용)
7. [JavaScript 성능](#7-javascript-성능) — **낮음-중간**
- 7.1 [DOM CSS 변경 일괄 처리](#71-dom-css-변경-일괄-처리)
- 7.2 [반복 조회를 위한 인덱스 맵 구축](#72-반복-조회를-위한-인덱스-맵-구축)
- 7.3 [루프에서 속성 접근 캐시](#73-루프에서-속성-접근-캐시)
- 7.4 [반복 함수 호출 캐시](#74-반복-함수-호출-캐시)
- 7.5 [스토리지 API 호출 캐시](#75-스토리지-api-호출-캐시)
- 7.6 [여러 배열 순회 결합](#76-여러-배열-순회-결합)
- 7.7 [배열 비교 시 길이 먼저 확인](#77-배열-비교-시-길이-먼저-확인)
- 7.8 [함수에서 조기 반환](#78-함수에서-조기-반환)
- 7.9 [RegExp 생성 호이스팅](#79-regexp-생성-호이스팅)
- 7.10 [정렬 대신 루프로 최소/최대 찾기](#710-정렬-대신-루프로-최소최대-찾기)
- 7.11 [O(1) 조회를 위한 Set/Map 사용](#711-o1-조회를-위한-setmap-사용)
- 7.12 [불변성을 위해 sort() 대신 toSorted() 사용](#712-불변성을-위해-sort-대신-tosorted-사용)
8. [고급 패턴](#8-고급-패턴) — **낮음**
- 8.1 [이벤트 핸들러를 Ref에 저장](#81-이벤트-핸들러를-ref에-저장)
- 8.2 [안정적인 콜백 Ref를 위한 useLatest](#82-안정적인-콜백-ref를-위한-uselatest)
---
## 1. 워터폴 제거
**영향: 치명적**
워터폴은 성능 저하의 가장 큰 원인입니다. 각각의 순차적 await는 전체 네트워크 지연 시간을 추가합니다. 이를 제거하면 가장 큰 성능 향상을 얻을 수 있습니다.
### 1.1 필요할 때까지 Await 지연
**영향: 높음 (사용되지 않는 코드 경로 차단 방지)**
`await` 작업을 실제로 필요한 분기 내부로 이동하여 필요하지 않은 코드 경로를 차단하지 않도록 합니다.
**잘못된 예: 두 분기 모두 차단**
```typescript
async function handleRequest(userId: string, skipProcessing: boolean) {
const userData = await fetchUserData(userId);
if (skipProcessing) {
// 즉시 반환하지만 여전히 userData를 기다림
return { skipped: true };
}
// 이 분기만 userData를 사용
return processUserData(userData);
}
```
**올바른 예: 필요할 때만 차단**
```typescript
async function handleRequest(userId: string, skipProcessing: boolean) {
if (skipProcessing) {
// 기다림 없이 즉시 반환
return { skipped: true };
}
// 필요할 때만 페치
const userData = await fetchUserData(userId);
return processUserData(userData);
}
```
**또 다른 예: 조기 반환 최적화**
```typescript
// 잘못된 예: 항상 권한을 페치
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);
}
// 올바른 예: 필요할 때만 페치
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 의존성 기반 병렬화
**영향: 치명적 (2-10배 개선)**
부분적인 의존성이 있는 작업의 경우, `better-all`을 사용하여 병렬성을 최대화합니다. 각 작업을 가능한 가장 빠른 시점에 자동으로 시작합니다.
**잘못된 예: profile이 config를 불필요하게 기다림**
```typescript
const [user, config] = await Promise.all([fetchUser(), fetchConfig()]);
const profile = await fetchProfile(user.id);
```
**올바른 예: config와 profile이 병렬로 실행**
```typescript
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](https://github.com/shuding/better-all)
### 1.3 API 라우트에서 워터폴 체인 방지
**영향: 치명적 (2-10배 개선)**
API 라우트와 Server Actions에서 독립적인 작업을 즉시 시작합니다. 아직 await하지 않더라도요.
**잘못된 예: config가 auth를 기다리고, data가 둘 다 기다림**
```typescript
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가 즉시 시작**
```typescript
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()
**영향: 치명적 (2-10배 개선)**
비동기 작업에 상호 의존성이 없을 때, `Promise.all()`을 사용하여 동시에 실행합니다.
**잘못된 예: 순차 실행, 3번의 라운드 트립**
```typescript
const user = await fetchUser();
const posts = await fetchPosts();
const comments = await fetchComments();
```
**올바른 예: 병렬 실행, 1번의 라운드 트립**
```typescript
const [user, posts, comments] = await Promise.all([fetchUser(), fetchPosts(), fetchComments()]);
```
### 1.5 전략적 Suspense 경계
**영향: 높음 (더 빠른 초기 페인트)**
비동기 컴포넌트에서 JSX를 반환하기 전에 데이터를 await하는 대신, Suspense 경계를 사용하여 데이터가 로드되는 동안 래퍼 UI를 더 빨리 표시합니다.
**잘못된 예: 래퍼가 데이터 페칭에 의해 차단됨**
```tsx
async function Page() {
const data = await fetchData(); // 전체 페이지 차단
return (
Sidebar
Header
Footer
);
}
```
전체 레이아웃이 데이터를 기다리지만 중간 섹션만 데이터가 필요합니다.
**올바른 예: 래퍼가 즉시 표시되고, 데이터가 스트리밍됨**
```tsx
function Page() {
return (
Sidebar
Header
}>
Footer
);
}
async function DataDisplay() {
const data = await fetchData(); // 이 컴포넌트만 차단
return
{data.content}
;
}
```
Sidebar, Header, Footer가 즉시 렌더링됩니다. DataDisplay만 데이터를 기다립니다.
**대안: 컴포넌트 간 프로미스 공유**
```tsx
function Page() {
// 페치를 즉시 시작하지만 await하지 않음
const dataPromise = fetchData();
return (
Sidebar
Header
}>
Footer
);
}
function DataDisplay({ dataPromise }: { dataPromise: Promise }) {
const data = use(dataPromise); // 프로미스 언랩
return
{data.content}
;
}
function DataSummary({ dataPromise }: { dataPromise: Promise }) {
const data = use(dataPromise); // 같은 프로미스 재사용
return
{data.summary}
;
}
```
두 컴포넌트가 같은 프로미스를 공유하므로 페치는 한 번만 발생합니다. 레이아웃이 즉시 렌더링되고 두 컴포넌트가 함께 기다립니다.
**이 패턴을 사용하지 말아야 할 때:**
- 레이아웃 결정에 필요한 중요 데이터 (위치 지정에 영향)
- 폴드 위의 SEO 중요 콘텐츠
- Suspense 오버헤드가 가치 없는 작고 빠른 쿼리
- 레이아웃 시프트를 피하고 싶을 때 (로딩 → 콘텐츠 점프)
**트레이드오프:** 더 빠른 초기 페인트 vs 잠재적 레이아웃 시프트. UX 우선순위에 따라 선택하세요.
---
## 2. 번들 크기 최적화
**영향: 치명적**
초기 번들 크기를 줄이면 상호작용 시간(TTI)과 최대 콘텐츠풀 페인트(LCP)가 개선됩니다.
### 2.1 배럴 파일 임포트 피하기
**영향: 치명적 (200-800ms 임포트 비용, 느린 빌드)**
사용하지 않는 수천 개의 모듈을 로드하지 않도록 배럴 파일 대신 소스 파일에서 직접 임포트합니다. **배럴 파일**은 여러 모듈을 재내보내는 진입점입니다 (예: `export * from './module'`을 수행하는 `index.js`).
인기 있는 아이콘 및 컴포넌트 라이브러리는 진입 파일에 **최대 10,000개의 재내보내기**가 있을 수 있습니다. 많은 React 패키지의 경우, **임포트하는 데만 200-800ms가 걸리며**, 이는 개발 속도와 프로덕션 콜드 스타트 모두에 영향을 미칩니다.
**트리 쉐이킹이 도움이 되지 않는 이유:** 라이브러리가 외부(번들되지 않음)로 표시되면 번들러가 최적화할 수 없습니다. 트리 쉐이킹을 활성화하기 위해 번들하면 전체 모듈 그래프를 분석하느라 빌드가 상당히 느려집니다.
**잘못된 예: 전체 라이브러리 임포트**
```tsx
// 1,583개 모듈 로드, 개발 시 ~2.8초 추가
// 런타임 비용: 매 콜드 스타트마다 200-800ms
import { Button, TextField } from "@mui/material";
import { Check, Menu, X } from "lucide-react";
// 2,225개 모듈 로드, 개발 시 ~4.2초 추가
```
**올바른 예: 필요한 것만 임포트**
```tsx
// 3개 모듈만 로드 (~2KB vs ~1MB)
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Check from "lucide-react/dist/esm/icons/check";
import Menu from "lucide-react/dist/esm/icons/menu";
import X from "lucide-react/dist/esm/icons/x";
// 사용하는 것만 로드
```
**대안: Next.js 13.5+**
```js
// 그러면 편리한 배럴 임포트를 유지할 수 있습니다:
import { Check, Menu, X } from "lucide-react";
// next.config.js - optimizePackageImports 사용
module.exports = {
experimental: {
optimizePackageImports: ["lucide-react", "@mui/material"],
},
};
// 빌드 시 자동으로 직접 임포트로 변환됨
```
직접 임포트는 15-70% 더 빠른 개발 부트, 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](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
### 2.2 조건부 모듈 로딩
**영향: 높음 (필요할 때만 큰 데이터 로드)**
기능이 활성화될 때만 큰 데이터나 모듈을 로드합니다.
**예: 애니메이션 프레임 지연 로드**
```tsx
function AnimationPlayer({ enabled }: { enabled: boolean }) {
const [frames, setFrames] = useState(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 ;
return ;
}
```
`typeof window !== 'undefined'` 체크는 이 모듈이 SSR용으로 번들되는 것을 방지하여 서버 번들 크기와 빌드 속도를 최적화합니다.
### 2.3 중요하지 않은 서드파티 라이브러리 지연 로드
**영향: 중간 (하이드레이션 후 로드)**
분석, 로깅, 오류 추적은 사용자 상호작용을 차단하지 않습니다. 하이드레이션 후에 로드합니다.
**잘못된 예: 초기 번들 차단**
```tsx
import { Analytics } from "@vercel/analytics/react";
export default function RootLayout({ children }) {
return (
{children}
);
}
```
**올바른 예: 하이드레이션 후 로드**
```tsx
import dynamic from "next/dynamic";
const Analytics = dynamic(() => import("@vercel/analytics/react").then((m) => m.Analytics), {
ssr: false,
});
export default function RootLayout({ children }) {
return (
{children}
);
}
```
### 2.4 무거운 컴포넌트를 위한 동적 임포트
**영향: 치명적 (TTI와 LCP에 직접 영향)**
초기 렌더링에 필요하지 않은 큰 컴포넌트를 지연 로드하려면 `next/dynamic`을 사용합니다.
**잘못된 예: Monaco가 메인 청크와 함께 번들됨 ~300KB**
```tsx
import { MonacoEditor } from "./monaco-editor";
function CodePanel({ code }: { code: string }) {
return ;
}
```
**올바른 예: Monaco가 필요 시 로드**
```tsx
import dynamic from "next/dynamic";
const MonacoEditor = dynamic(() => import("./monaco-editor").then((m) => m.MonacoEditor), {
ssr: false,
});
function CodePanel({ code }: { code: string }) {
return ;
}
```
### 2.5 사용자 의도에 기반한 프리로드
**영향: 중간 (체감 지연 감소)**
체감 지연을 줄이기 위해 무거운 번들을 필요하기 전에 프리로드합니다.
**예: 호버/포커스 시 프리로드**
```tsx
function EditorButton({ onClick }: { onClick: () => void }) {
const preload = () => {
if (typeof window !== "undefined") {
void import("./monaco-editor");
}
};
return (
);
}
```
**예: 기능 플래그가 활성화되면 프리로드**
```tsx
function FlagsProvider({ children, flags }: Props) {
useEffect(() => {
if (flags.editorEnabled && typeof window !== "undefined") {
void import("./monaco-editor").then((mod) => mod.init());
}
}, [flags.editorEnabled]);
return {children};
}
```
`typeof window !== 'undefined'` 체크는 프리로드된 모듈이 SSR용으로 번들되는 것을 방지하여 서버 번들 크기와 빌드 속도를 최적화합니다.
---
## 3. 서버 사이드 성능
**영향: 높음**
서버 사이드 렌더링과 데이터 페칭을 최적화하면 서버 사이드 워터폴을 제거하고 응답 시간을 줄입니다.
### 3.1 요청 간 LRU 캐싱
**영향: 높음 (요청 간 캐시)**
`React.cache()`는 하나의 요청 내에서만 작동합니다. 순차적 요청 간에 공유되는 데이터의 경우 (사용자가 버튼 A를 클릭한 다음 버튼 B를 클릭), LRU 캐시를 사용합니다.
**구현:**
```typescript
import { LRUCache } from "lru-cache";
const cache = new LRUCache({
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](https://vercel.com/docs/fluid-compute) 사용 시:** 여러 동시 요청이 같은 함수 인스턴스와 캐시를 공유할 수 있으므로 LRU 캐싱이 특히 효과적입니다. 이는 Redis와 같은 외부 스토리지 없이도 캐시가 요청 간에 지속된다는 것을 의미합니다.
**전통적인 서버리스에서:** 각 호출이 격리되어 실행되므로 프로세스 간 캐싱을 위해 Redis를 고려하세요.
참고: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
### 3.2 RSC 경계에서 직렬화 최소화
**영향: 높음 (데이터 전송 크기 감소)**
React Server/Client 경계는 모든 객체 속성을 문자열로 직렬화하여 HTML 응답과 후속 RSC 요청에 포함합니다. 이 직렬화된 데이터는 페이지 가중치와 로드 시간에 직접 영향을 미치므로 **크기가 매우 중요합니다**. 클라이언트가 실제로 사용하는 필드만 전달합니다.
**잘못된 예: 50개 필드 모두 직렬화**
```tsx
async function Page() {
const user = await fetchUser(); // 50개 필드
return ;
}
("use client");
function Profile({ user }: { user: User }) {
return
{user.name}
; // 1개 필드 사용
}
```
**올바른 예: 1개 필드만 직렬화**
```tsx
async function Page() {
const user = await fetchUser();
return ;
}
("use client");
function Profile({ name }: { name: string }) {
return
{name}
;
}
```
### 3.3 컴포넌트 합성을 통한 병렬 데이터 페칭
**영향: 치명적 (서버 사이드 워터폴 제거)**
React Server Components는 트리 내에서 순차적으로 실행됩니다. 데이터 페칭을 병렬화하려면 합성으로 재구성합니다.
**잘못된 예: Sidebar가 Page의 페치 완료를 기다림**
```tsx
export default async function Page() {
const header = await fetchHeader();
return (
{header}
);
}
async function Sidebar() {
const items = await fetchSidebarItems();
return ;
}
```
**올바른 예: 둘 다 동시에 페치**
```tsx
async function Header() {
const data = await fetchHeader();
return