Vercel의 React Best Practices 톺아보기 [1]
Vercel이 지난 1월
React Best Practices라는 에이전트 Skills를 공개했습니다. 비록 Skills라는 포맷으로 나왔지만 React, Next.js 개발자를 위한 성능 최적화 바이블에 가깝다고 느껴졌고 하나씩 살펴보며 학습해보려고 합니다.
React Best Practices
먼저 Vercel의 React Best Practices를 알아보기 전에 소개글의 일부를 가져왔습니다.
저희는 10년 이상 축적해 온 React 및 Next.js 최적화 노하우를 AI 에이전트와 LLM에 최적화된 구조화된 저장소인 react-best-practices 에 담았습니다.
...
우리는 10년 넘게 운영 중인 코드베이스 전반에서 동일한 근본 원인을 목격해 왔습니다.
- 비동기 작업이 우연히 순차 작업으로 바뀌는 경우
- 시간이 지남에 따라 규모가 커지는 대형 고객 패키지
- 필요 이상으로 자주 다시 렌더링되는 구성 요소
이유는 간단합니다: 이것들은 미세한 최적화가 아닙니다. 대기 시간, 버벅거림, 반복 비용 등으로 나타나 모든 사용자 세션에 영향을 미칩니다.
그래서 저희는 이러한 문제점을 더 쉽게 발견하고 더 빠르게 해결할 수 있도록 React 모범 사례 프레임워크를 만들었습니다.
Vercel React Best Practices 공식 소개 문서
Vercel이 10년동안 축적해온 노하우를 공개했다니...!
대 AI 시대에서 성능/최적화 쪽을 사람이 먼저 잘 이해하지 못하면 에이전트에게도 제대로 요구하기 어렵다고 느껴졌습니다.
AI 도구 전용 학습 지침서지만 안 읽고 넘어가기가 어려웠습니다.
본격적으로 저장소안에는 8가지 섹션으로 나누어 성능 범주를 등급으로 분류해놓았습니다.
-
워터폴 처리 방식 제거 (비동기식)
영향: 심각
설명: 워터폴은 성능 저하의 가장 큰 원인입니다. 각 순차적인 대기 시간은 네트워크 지연 시간을 증가시킵니다. 워터폴을 제거하면 성능 향상 효과가 가장 큽니다. -
번들 크기 최적화 (bundle)
영향: 매우 중요
설명: 초기 번들 크기를 줄이면 상호 작용 가능 시간 및 최대 콘텐츠 표시 시간이 향상됩니다. -
서버 측 성능
영향도: 높음
설명: 서버 측 렌더링 및 데이터 가져오기를 최적화하여 서버 측 워터폴 현상을 제거하고 응답 시간을 단축합니다. -
클라이언트 측 데이터 가져오기
영향도: 중간-높음
설명: 자동 데이터 중복 제거 및 효율적인 데이터 가져오기 패턴을 통해 불필요한 네트워크 요청을 줄입니다. -
리렌더링 최적화(re-render)
영향도: 중간
설명: 불필요한 재렌더링을 줄이면 낭비되는 연산이 최소화되고 UI 응답성이 향상됩니다. -
렌더링 성능 (렌더링)
영향도: 중간
설명: 렌더링 프로세스를 최적화하면 브라우저가 수행해야 하는 작업량이 줄어듭니다. -
자바스크립트 성능 (js)
영향도: 낮음-중간
설명: 자주 실행되는 경로에 대한 미세 최적화는 의미 있는 성능 향상으로 이어질 수 있습니다. -
고급 패턴 (고급)
영향도: 낮음
설명: 특정 사례에 적용되는 고급 패턴으로, 신중한 구현이 필요합니다.
앞으로는 영향도가 높은 주제부터 차례로 짚어 가며 학습해 보려 합니다. 원문 그대로를 번역하지만 이해하기 쉽게 의역이 포함되어 있을 수 있습니다.
1. Warterfalls 제거 - 매우 중요 (CRITICAL)
워터폴은 성능 저하의 가장 큰 원인입니다. 각 순차적인 대기 시간은 네트워크 지연 시간을 증가시킵니다. 워터폴을 제거하면 성능 향상 효과가 가장 큽니다.
1.1 Check Cheap Conditions Before Async Flags (비동기 플래그보다 먼저 저비용인 동기 상태를 체크하세요.)
- 영향도: 높음 (HIGH)
분기에서 플래그나 원격 값을 await로 가져오면서, props·요청 메타데이터·이미 메모리에 있는 상태처럼 당장 동기 코드만으로 true/false를 알 수 있는 조건도 함께 쓴다면 그 조건을 먼저 체크하세요. 그렇지 않으면 플래그 && 동기조건이 애초에 성립할 수 없는데도 비동기 호출 비용만 치르게 됩니다.
이는 flag && cheapCondition 형태의 조건문을 다루는 경우에 대해 Defer Await Until Needed 규칙을 한 단계 구체화한 것입니다.
잘못된 예:
// someCondition이 false여도 getFlag()는 이미 실행된 뒤
const someFlag = await getFlag();
if (someFlag && someCondition) {
// ...
}올바른 예:
// someCondition이 false면 getFlag() 호출 자체를 생략
if (someCondition) {
const someFlag = await getFlag();
if (someFlag) {
// ...
}
}getFlag가 네트워크나 원격 설정 조회, React.cache, DB 접근 등을 수반할 때 특히 그렇습니다. 동기 조건이 맞지 않아 if 블록에 들어가지 않는 흐름에서는 getFlag를 부르지 않게 해 두면 그만큼 비용을 줄일 수 있습니다.
someCondition이 고비용이거나, 플래그에 의존하거나, 부수 효과 순서를 지켜야 하면 원래 순서를 유지하세요.
1.2 Defer Await Until Needed (await은 꼭 그 결과가 필요해지는 순간까지 미루세요)
- 영향도: 높음 (HIGH)
await는 실제로 그 결과가 쓰이는 분기 안으로 옮기세요. 그래야 그 데이터가 필요 없는 경로까지 블로킹하지 않습니다.
잘못된 예 (두 분기 모두 막힘):
async function handleRequest(userId: string, skipProcessing: boolean) {
const userData = await fetchUserData(userId);
if (skipProcessing) {
// 바로 return 하지만 이미 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);
}추가 예시 (조기 반환 최적화):
// 잘못됨: 항상 권한을 가져옴
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);
}이 최적화는 건너뛰기(스킵) 분기를 자주 타는 경우이거나, await을 뒤로 미룬 연산이 비용이 큰 경우에 특히 효과가 큽니다.
1.3 Dependency-Based Parallelization (의존 관계 병렬화)
- 영향도: 매우 중요 (CRITICAL / 2~10배 개선)
일부만 서로 의존하는 작업이면 better-all 라이브러리로 병렬을 최대화하세요. 각 작업은 가능한 한 이른 타이밍에 자동으로 시작됩니다.
better-all(shuding/better-all)은 의존 관계만 적어 두면, 각 비동기 작업을 가능한 한 빨리 이어서 시작하도록 스케줄링해 주는 유틸리티입니다.
잘못된 예 (profile이 config까지 불필요하게 기다림):
// user,config는 병렬이지만 다음 줄의 profile은 첫 Promise.all이 끝난 뒤에만 시작됨
const [user, config] = await Promise.all([fetchUser(), fetchConfig()]);
const profile = await fetchProfile(user.id); // profile은 user만 필요 (config 완료를 기다릴 필요 없음)올바른 예 (config와 profile이 병렬로 실행):
import { all } from "better-all";
// user가 준비되면 profile 시작 → config fetch와 시간이 겹칠 수 있음
const { user, config, profile } = await all({
async user() {
return fetchUser();
},
async config() {
return fetchConfig();
},
async profile() {
return fetchProfile((await this.$.user).id); // user에만 의존
},
});대안 (추가 라이브러리 없이):
프로미스를 먼저 만들고 마지막에 Promise.all()을 해도 됩니다.
const userPromise = fetchUser();
const profilePromise = userPromise.then((user) => fetchProfile(user.id)); // user 이후에만 profile 체인
const [user, config, profile] = await Promise.all([
userPromise,
fetchConfig(), // user,profile 흐름과 동시에 실행
profilePromise,
]);참고: better-all
1.4 Prevent Waterfall Chains in API Routes (API Routes에서 Warterfall 체인 막기)
- 영향도: 매우 중요 (CRITICAL / 2~10배 개선)
API 라우트/서버 액션에서는 서로 독립적인 작업이라면, 아직 await하지 않아도 프로미스는 바로 시작해 두세요.
위에서부터 await만 쓰면 앞 작업이 끝날 때까지 다음 요청이 시작조차 못 해 워터폴 상태가 됩니다.
잘못된 예 (config가 auth 끝날 때까지 기다림, data는 둘 다 기다림):
export async function GET(request: Request) {
const session = await auth();
const config = await fetchConfig(); // session과 무관한데도 auth 끝난 뒤에야 config 요청이 시작됨
const data = await fetchData(session.user.id); // data는 session만 필요한데 config까지 기다린 뒤에야 시작
return Response.json({ data, config });
}올바른 예 (auth와 config 요청을 바로 걸어 둠):
export async function GET(request: Request) {
// await 없이 호출만 → 네트워크/DB 작업은 즉시 시작 (병렬로 겹칠 준비)
const sessionPromise = auth();
const configPromise = fetchConfig();
const session = await sessionPromise; // session 값만 먼저 확보 (data에 user.id 필요)
// config는 이미 진행 중인 configPromise + 방금 시작하는 fetchData를 동시에 기다림
const [config, data] = await Promise.all([
configPromise,
fetchData(session.user.id),
]);
return Response.json({ data, config });
}의존 관계가 더 복잡하면 better-all로 병렬을 최대화할 수 있습니다(「의존 관계 병렬화」 참고).
1.5 Promise.all() for Independent Operations (독립 작업에는 Promise.all()을 사용하세요)
- 영향도: 매우 중요 (CRITICAL / 2~10배 개선)
서로 의존하지 않는 비동기 작업이면 Promise.all()로 동시에 실행하세요. 한 줄씩 await하면 앞 요청이 끝날 때까지 다음 요청이 시작도 못 해 워터폴 상태가 됩니다.
잘못된 예 (순차 실행, 사실상 요청이 세 번 줄 서서 진행):
// 각 줄이 끝나야 다음 fetch가 시작됨 → 총 대기 ≈ user + posts + comments
const user = await fetchUser();
const posts = await fetchPosts();
const comments = await fetchComments();올바른 예 (병렬 실행, 세 요청을 동시에 걸고 한꺼번에 대기):
// 세 호출을 연달아 시작하고, 끝나는 시점만 한 번에 기다림 → 총 대기 ≈ 가장 느린 하나에 가깝게
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments(),
]);1.6 Strategic Suspense Boundaries (Suspense 경계 전략)
- 영향도: 높음 (HIGH / 초기 paint 속도 빠름)
비동기 컴포넌트에서 JSX를 리턴하기 전에 데이터를 통째로 await하지 말고, Suspense 경계로 나눠 뼈대 UI를 먼저 그리세요. 데이터는 Suspense 경계 안에서 로드되면 됩니다.
잘못된 예 (데이터 때문에 래퍼 전체가 막힘):
async function Page() {
const data = await fetchData(); // 페이지 전체가 여기서 대기
return (
<div>
<div>Sidebar</div>
<div>Header</div>
<div>
<DataDisplay data={data} />
</div>
<div>Footer</div>
</div>
);
}데이터가 필요한 건 가운데 섹션 뿐인데, 레이아웃 전체가 fetch가 끝날 때까지 기다립니다.
올바른 예 (래퍼는 바로 보이고, 데이터는 스트리밍):
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뿐입니다.
대안 (프로미스를 컴포넌트끼리 공유):
function Page() {
const dataPromise = fetchData(); // 바로 요청만 시작하고 await은 하지 않음
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); // 대기 중이면 Suspense, 완료 후 data
return <div>{data.content}</div>;
}
function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise); // 같은 프로미스 → 같은 fetch 결과 재사용
return <div>{data.summary}</div>;
}참고: React use 훅
두 컴포넌트가 같은 프로미스를 쓰므로 fetch는 한 번만 발생하고, 레이아웃은 바로 그리면서 두 컴포넌트가 함께 로딩을 기다립니다.
이 패턴을 쓰지 않는 게 나은 경우
- 레이아웃 결정에 꼭 필요한 데이터 (위치·배치에 영향)
- 스크롤 없이 보이는 첫 화면에 바로 실려야 하는 SEO 핵심 본문
- 매우 가벼운 쿼리라 Suspense 오버헤드가 이득보다 클 때
- 로딩 → 콘텐츠 전환 시 레이아웃 시프트를 피하고 싶을 때
Trade-off: 초기 페인트는 빨라지지만 레이아웃 시프트 가능성은 늘어날 수 있습니다. UX 우선순위에 맞게 선택하세요.