이 글은 Vercel에서 작성한 React Best Practice와 소스 코드를 보며 톺아보는 글입니다.
AI에게 "이 코드를 React Best Practice에 맞게 리팩토링해줘"라고 요청할 수도 있지만,
왜 그 코드가 더 나은지, 어떤 원리로 성능이 개선되는지 이해하는 것이 중요합니다.
이 시리즈에서는 Vercel이 정의한 8가지 카테고리를 하나씩 깊이 있게 살펴봅니다.
각 Best Practice의 이론적 배경, 실제 성능 차이, 적용 시점을 중심으로 분석합니다.
이 글은 sections에서 소개하는 8가지 섹션 중 3번째 섹션인 Server-Side Performance를 다룹니다.
Vercel React Best Practices 구조
Vercel은 React 성능 최적화를 Impact 수준에 따라 8개 섹션으로 분류했습니다:
- Eliminating Waterfalls (CRITICAL)
- Bundle Size Optimization (CRITICAL)
- Server-Side Performance (HIGH)
- Client-Side Data Fetching (MEDIUM-HIGH)
- Re-render Optimization (MEDIUM)
- Rendering Performance (MEDIUM)
- JavaScript Performance (LOW-MEDIUM)
- Advanced Patterns (LOW)
3. Server-Side Performance: 서버에서의 최적화
서버 사이드 성능이 중요한 이유
React Server Components와 Server Actions의 등장으로 서버 사이드 로직이 React 앱의 핵심이 되었습니다. 서버에서의 최적화는:
- 서버 사이드 Waterfall 제거: 병렬 데이터 페칭으로 응답 시간 단축
- 응답 시간 감소: 불필요한 작업 제거 및 캐싱 활용
- 네트워크 페이로드 최소화: 클라이언트로 전송되는 데이터 크기 감소
- 보안 강화: Server Actions의 적절한 인증/인가
3-1. 컴포넌트 구성을 통한 병렬 데이터 페칭 (CRITICAL)
Impact: CRITICAL (서버 사이드 Waterfall 제거)
React Server Components는 트리 내에서 순차적으로 실행됩니다. 컴포넌트 구성을 재구조화하여 데이터 페칭을 병렬화하세요.
문제 코드: Sidebar가 Page의 fetch 완료를 기다림
export default async function Page() {
const header = await fetchHeader() // 100ms
return (
<div>
<div>{header}</div>
<Sidebar /> {/* header 완료 후에야 시작 */}
</div>
)
}
async function Sidebar() {
const items = await fetchSidebarItems() // 100ms
return <nav>{items.map(renderItem)}</nav>
}
// 총 200ms (순차 실행)
실행 흐름:
Page컴포넌트 실행 시작fetchHeader()완료 대기 (100ms)- JSX 반환,
<Sidebar />렌더링 시작 fetchSidebarItems()완료 대기 (100ms)- 총 200ms
개선 코드: 동시에 fetch 시작
async function Header() {
const data = await fetchHeader() // 100ms
return <div>{data}</div>
}
async function Sidebar() {
const items = await fetchSidebarItems() // 100ms (동시 실행)
return <nav>{items.map(renderItem)}</nav>
}
export default function Page() {
return (
<div>
<Header />
<Sidebar />
</div>
)
}
// 총 100ms (병렬 실행)
실행 흐름:
Page컴포넌트 실행 (동기)<Header />와<Sidebar />동시에 렌더링 시작- 두 fetch가 병렬로 실행
- 가장 느린 것(100ms) 기준으로 완료
핵심 원리: React의 컴포넌트 실행 순서
// ❌ 순차 실행
async function Parent() {
const data1 = await fetch1() // 여기서 차단
return <Child /> // fetch1 후에야 Child 시작
}
// ✅ 병렬 실행
async function Child1() {
const data1 = await fetch1()
return <div>{data1}</div>
}
function Parent() {
return <Child1 /> // 즉시 Child1 렌더링 시작
}
children prop를 사용한 대안
async function Header() {
const data = await fetchHeader()
return <div>{data}</div>
}
async function Sidebar() {
const items = await fetchSidebarItems()
return <nav>{items.map(renderItem)}</nav>
}
function Layout({ children }: { children: ReactNode }) {
return (
<div>
<Header />
{children}
</div>
)
}
export default function Page() {
return (
<Layout>
<Sidebar />
</Layout>
)
}
<Header />와 <Sidebar />가 병렬로 실행되며, Layout은 동기 컴포넌트로 차단 없이 즉시 반환됩니다.
3-2. Server Actions를 API Routes처럼 인증하기 (CRITICAL)
Impact: CRITICAL (서버 변경에 대한 무단 접근 방지)
Server Actions("use server" 함수)는 API Routes처럼 공개 엔드포인트로 노출됩니다. 항상 각 Server Action 내부에서 인증과 인가를 검증하세요.
왜 중요한가?
Next.js 공식 문서는 명시합니다:
"Server Actions를 공개 API 엔드포인트와 동일한 보안 고려 사항으로 다루세요. 사용자가 변경을 수행할 권한이 있는지 확인하세요."
- Middleware는 우회 가능
- Layout guards는 UI만 보호
- Server Actions는 직접 호출 가능
문제 코드: 인증 체크 없음
'use server'
export async function deleteUser(userId: string) {
// 누구나 호출 가능! 인증 체크 없음
await db.user.delete({ where: { id: userId } })
return { success: true }
}
위험성:
- 악의적 사용자가 개발자 도구에서 직접 호출 가능
- CSRF 토큰만으로는 불충분 (Next.js가 자동 처리하지만 인증은 아님)
- 다른 사용자의 데이터 삭제 가능
개선 코드: Action 내부에서 인증
'use server'
import { verifySession } from '@/lib/auth'
import { unauthorized } from '@/lib/errors'
export async function deleteUser(userId: string) {
// 항상 action 내부에서 인증 체크
const session = await verifySession()
if (!session) {
throw unauthorized('Must be logged in')
}
// 인가도 체크
if (session.user.role !== 'admin' && session.user.id !== userId) {
throw unauthorized('Cannot delete other users')
}
await db.user.delete({ where: { id: userId } })
return { success: true }
}
입력 검증 추가
'use server'
import { verifySession } from '@/lib/auth'
import { z } from 'zod'
const updateProfileSchema = z.object({
userId: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email()
})
export async function updateProfile(data: unknown) {
// 1. 입력 검증
const validated = updateProfileSchema.parse(data)
// 2. 인증
const session = await verifySession()
if (!session) {
throw new Error('Unauthorized')
}
// 3. 인가
if (session.user.id !== validated.userId) {
throw new Error('Can only update own profile')
}
// 4. 변경 수행
await db.user.update({
where: { id: validated.userId },
data: {
name: validated.name,
email: validated.email
}
})
return { success: true }
}
보안 체크리스트
- 모든 Server Action에 인증 체크
- 인가 로직 (권한 확인)
- 입력 검증 (Zod, Yup 등)
- Rate limiting (선택)
- 에러 메시지 주의 (정보 노출 방지)
참고: Next.js Authentication Guide
3-3. RSC 경계에서 직렬화 최소화 (HIGH)
Impact: 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개 필드만 사용
}
문제점:
- 서버 → 클라이언트로 50개 필드 모두 전송
- HTML에 JSON으로 임베드됨
- 네트워크 페이로드 불필요하게 증가
- 파싱 시간 증가
개선 코드: 1개 필드만 직렬화
async function Page() {
const user = await fetchUser()
return <Profile name={user.name} />
}
'use client'
function Profile({ name }: { name: string }) {
return <div>{name}</div>
}
실전 예시: 복잡한 객체
// ❌ 나쁨: 전체 product 객체 전달 (100개 필드)
<ProductCard product={product} />
'use client'
function ProductCard({ product }) {
return (
<div>
<h2>{product.name}</h2>
<p>{product.price}</p>
</div>
)
}
// ✅ 좋음: 필요한 필드만 전달
<ProductCard
name={product.name}
price={product.price}
/>
'use client'
function ProductCard({ name, price }) {
return (
<div>
<h2>{name}</h2>
<p>{price}</p>
</div>
)
}
언제 전체 객체를 전달하나?
- 클라이언트에서 대부분의 필드를 사용할 때
- 타입 안전성이 중요할 때
- Props가 너무 많아질 때 (10개 이상)
측정하기
// 직렬화 크기 확인
console.log(JSON.stringify(data).length)
일반적으로 10KB 이상의 props는 의심해봐야 합니다.
3-4. 교차 요청 LRU 캐싱 (HIGH)
Impact: 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와의 시너지
Vercel의 Fluid Compute에서는 LRU 캐싱이 특히 효과적입니다:
- 여러 동시 요청이 같은 함수 인스턴스와 캐시를 공유
- Redis 같은 외부 스토리지 없이 캐시가 요청 간 유지
- 인스턴스가 유지되는 동안 캐시 히트율 극대화
전통적인 Serverless 환경
각 호출이 격리되어 실행되므로, 프로세스 간 캐싱을 위해 Redis를 고려하세요.
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.REDIS_URL,
token: process.env.REDIS_TOKEN
})
export async function getUser(id: string) {
// Redis에서 캐시 확인
const cached = await redis.get(`user:${id}`)
if (cached) return cached
const user = await db.user.findUnique({ where: { id } })
await redis.setex(`user:${id}`, 300, user) // 5분 TTL
return user
}
LRU vs React.cache() 비교
| 특징 | React.cache() | LRU Cache |
|---|---|---|
| 범위 | 단일 요청 | 여러 요청 |
| 지속 시간 | 요청 완료까지 | TTL 기반 |
| 메모리 | 자동 관리 | max 설정 필요 |
| 사용 사례 | 컴포넌트 트리 중복 제거 | 반복 요청 최적화 |
참고: node-lru-cache
3-5. React.cache()로 요청당 중복 제거 (MEDIUM)
Impact: 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()를 여러 번 호출해도 쿼리는 한 번만 실행됩니다.
실전 예시: 여러 컴포넌트에서 사용
// layout.tsx
async function UserMenu() {
const user = await getCurrentUser() // DB 쿼리 실행
return <div>{user?.name}</div>
}
// page.tsx
async function ProfilePage() {
const user = await getCurrentUser() // 캐시 히트, 쿼리 없음
return <div>{user?.email}</div>
}
// sidebar.tsx
async function UserAvatar() {
const user = await getCurrentUser() // 캐시 히트, 쿼리 없음
return <img src={user?.avatar} />
}
세 컴포넌트 모두 getCurrentUser()를 호출하지만, DB 쿼리는 한 번만 실행됩니다.
중요: 인라인 객체 인자 피하기
React.cache()는 얕은 동등성(Object.is)을 사용하여 캐시 히트를 결정합니다. 인라인 객체는 매번 새 참조를 생성하여 캐시 히트를 방지합니다.
문제 코드: 항상 캐시 미스
const getUser = cache(async (params: { uid: number }) => {
return await db.user.findUnique({ where: { id: params.uid } })
})
// 매 호출마다 새 객체 생성, 캐시 히트 없음
getUser({ uid: 1 })
getUser({ uid: 1 }) // 캐시 미스, 쿼리 재실행
개선 코드: 원시 타입 인자 사용
const getUser = cache(async (uid: number) => {
return await db.user.findUnique({ where: { id: uid } })
})
// 원시 타입은 값 동등성 사용
getUser(1)
getUser(1) // 캐시 히트, 캐시된 결과 반환
객체를 전달해야 한다면
같은 참조를 전달하세요:
const params = { uid: 1 }
getUser(params) // 쿼리 실행
getUser(params) // 캐시 히트 (같은 참조)
Next.js의 자동 fetch 메모이제이션
Next.js에서는 fetch API가 자동으로 요청 메모이제이션으로 확장됩니다. 동일한 URL과 옵션을 가진 요청은 단일 요청 내에서 자동으로 중복 제거되므로, fetch 호출에는 React.cache()가 필요하지 않습니다.
// ✅ 자동으로 중복 제거됨
async function Component1() {
const data = await fetch('/api/data')
}
async function Component2() {
const data = await fetch('/api/data') // 같은 요청, 중복 제거됨
}
React.cache()가 여전히 필요한 경우
- 데이터베이스 쿼리 (Prisma, Drizzle 등)
- 무거운 연산
- 인증 체크
- 파일 시스템 작업
- fetch가 아닌 모든 비동기 작업
참고: React.cache 문서
3-6. 논블로킹 작업에 after() 사용 (MEDIUM)
Impact: MEDIUM (더 빠른 응답 시간)
Next.js의 after()를 사용하여 응답이 전송된 후 실행될 작업을 예약하세요. 로깅, 분석, 기타 부수 효과가 응답을 차단하지 않도록 방지합니다.
문제 코드: 응답 차단
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 }) // 100ms
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' }
})
}
// 응답 즉시 전송, 로깅은 백그라운드에서
일반적인 사용 사례
- 분석 추적: 사용자 행동 기록
- 감사 로깅: 보안 이벤트 기록
- 알림 전송: 이메일, 푸시 알림
- 캐시 무효화: 관련 캐시 업데이트
- 정리 작업: 임시 파일 삭제
Server Actions에서도 사용 가능
'use server'
import { after } from 'next/server'
export async function createPost(data: FormData) {
const post = await db.post.create({ data })
after(async () => {
// 알림 전송 (응답 차단하지 않음)
await sendNotification(post.authorId, 'New post created')
})
return { success: true, postId: post.id }
}
중요한 특징
after()는 응답이 실패하거나 리다이렉트되어도 실행됨- Server Actions, Route Handlers, Server Components에서 작동
- 에러는 자동으로 로깅되지만 응답에 영향 없음
주의사항
// ❌ 중요한 작업에는 사용하지 마세요
after(async () => {
await chargeCustomer() // 실패해도 알 수 없음!
})
// ✅ 중요하지 않은 작업만
after(async () => {
await analytics.track('purchase') // 실패해도 괜찮음
})
3-7. RSC Props에서 중복 직렬화 방지 (LOW)
Impact: LOW (중복 직렬화 방지로 네트워크 페이로드 감소)
RSC→클라이언트 직렬화는 객체 참조로 중복 제거하며, 값이 아닙니다. 같은 참조 = 한 번 직렬화, 새 참조 = 다시 직렬화. 변환(.toSorted(), .filter(), .map())은 서버가 아닌 클라이언트에서 수행하세요.
문제 코드: 배열 중복
// RSC: 6개 문자열 전송 (2개 배열 × 3개 항목)
<ClientList
usernames={usernames}
usernamesOrdered={usernames.toSorted()}
/>
toSorted()는 새 배열을 생성하므로, 원본 배열과 정렬된 배열 모두 직렬화됩니다.
개선 코드: 클라이언트에서 변환
// RSC: 한 번만 전송
<ClientList usernames={usernames} />
// 클라이언트: 여기서 변환
'use client'
const sorted = useMemo(() => [...usernames].sort(), [usernames])
중첩 중복 제거 동작
중복 제거는 재귀적으로 작동합니다. 영향은 데이터 타입에 따라 다릅니다:
string[],number[],boolean[]: HIGH impact - 배열 + 모든 원시값 완전 중복object[]: LOW impact - 배열 구조만 중복, 중첩된 객체는 참조로 중복 제거
// string[] - 모든 것 중복
usernames={['a','b']}
sorted={usernames.toSorted()}
// 4개 문자열 전송
// object[] - 배열 구조만 중복
users={[{id:1},{id:2}]}
sorted={users.toSorted()}
// 2개 배열 + 2개 고유 객체 전송 (4개 아님)
중복 제거를 깨는 작업
새 참조를 생성하는 작업들:
- 배열:
.toSorted(),.filter(),.map(),.slice(),[...arr] - 객체:
{...obj},Object.assign(),structuredClone(),JSON.parse(JSON.stringify())
더 많은 예시
// ❌ 나쁨
<C users={users} active={users.filter(u => u.active)} />
<C product={product} productName={product.name} />
// ✅ 좋음
<C users={users} />
<C product={product} />
// 클라이언트에서 필터링/구조 분해
예외: 변환이 비싸거나 클라이언트가 원본 필요 없을 때
// 클라이언트가 필터링된 결과만 필요하고 원본은 필요 없음
<C activeUsers={users.filter(u => u.active)} />
// 변환이 CPU 집약적 (서버에서 수행)
<C processedData={heavyComputation(rawData)} />
실전 팁
직렬화 크기 비교:
// 개발 환경에서 측정
console.log('Original:', JSON.stringify(users).length)
console.log('Filtered:', JSON.stringify(users.filter(...)).length)
일반적으로 1KB 이상 차이나면 최적화 고려하세요.
섹션 3 정리: Server-Side Performance의 핵심
- 병렬 데이터 페칭: 컴포넌트 구성으로 서버 Waterfall 제거
- Server Actions 인증: 모든 action 내부에서 인증/인가 체크
- 직렬화 최소화: 클라이언트가 사용하는 필드만 전달
- LRU 캐싱: 연속된 요청 간 데이터 공유
- React.cache(): 단일 요청 내 중복 제거
- after() 사용: 로깅/분석을 논블로킹으로 처리
- 중복 직렬화 방지: 변환은 클라이언트에서
Vercel이 이 섹션을 HIGH로 분류한 이유는 서버 사이드 최적화가 응답 시간과 보안에 직접적인 영향을 미치기 때문입니다. 특히 병렬 페칭과 Server Actions 인증은 필수적으로 적용해야 합니다.
서버 최적화 체크리스트
- 컴포넌트 구성으로 병렬 페칭 적용
- 모든 Server Actions에 인증 로직 추가
- RSC Props 크기 측정 및 최적화
- 반복 쿼리에 LRU 캐시 적용
- 인증 함수에 React.cache() 적용
- 로깅/분석을 after()로 이동
- 불필요한 배열 변환 제거
다음 편 예고
다음 글에서는 Client-Side Data Fetching (MEDIUM-HIGH)을 다룹니다. 클라이언트에서의 효율적인 데이터 페칭과 자동 중복 제거 패턴을 살펴봅니다.