$ yh.log
[톺아보기] Vercel의 react-best-practices #5 - Server-Side Optimization

[톺아보기] Vercel의 react-best-practices #5 - Server-Side Optimization

reactnext.jsserver-componentsserver-actionscachingauthentication

작성자 : 오예환 | 작성일 : 2026-01-29 | 수정일 : 2026-01-29 | 조회수 :

titleimpactimpactDescriptiontags
Server Actions를 API Routes처럼 인증하기CRITICALserver mutations에 대한 무단 접근 방지server, server-actions, authentication, security, authorization

Server Actions를 API Routes처럼 인증하기

Impact: CRITICAL (server mutations에 대한 무단 접근 방지)

Server Actions ("use server"가 있는 functions)는 API routes와 마찬가지로 public endpoints로 노출됩니다. 항상 각 Server Action 내부에서 authentication과 authorization을 검증하세요—Server Actions는 직접 호출될 수 있으므로 middleware, layout guards, page-level 체크에만 의존하지 마세요.

Next.js 문서는 명시적으로 다음과 같이 말합니다: "Server Actions를 public-facing API endpoints와 동일한 보안 고려사항으로 다루고, 사용자가 mutation을 수행할 권한이 있는지 검증하세요."

Incorrect (authentication 체크 없음):

"use server";
 
export async function deleteUser(userId: string) {
  // 누구나 호출할 수 있음! auth 체크 없음
  await db.user.delete({ where: { id: userId } });
  return { success: true };
}

Correct (action 내부에서 authentication):

"use server";
 
import { verifySession } from "@/lib/auth";
import { unauthorized } from "@/lib/errors";
 
export async function deleteUser(userId: string) {
  // 항상 action 내부에서 auth 체크
  const session = await verifySession();
 
  if (!session) {
    throw unauthorized("Must be logged in");
  }
 
  // authorization도 체크
  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 };
}

input validation과 함께:

"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) {
  // 먼저 input 검증
  const validated = updateProfileSchema.parse(data);
 
  // 그 다음 authenticate
  const session = await verifySession();
  if (!session) {
    throw new Error("Unauthorized");
  }
 
  // 그 다음 authorize
  if (session.user.id !== validated.userId) {
    throw new Error("Can only update own profile");
  }
 
  // 마지막으로 mutation 수행
  await db.user.update({
    where: { id: validated.userId },
    data: {
      name: validated.name,
      email: validated.email,
    },
  });
 
  return { success: true };
}

Reference: Next.js Authentication Guide



titleimpactimpactDescriptiontags
Component Composition으로 Parallel Data FetchingCRITICALserver-side waterfalls 제거server, rsc, parallel-fetching, composition

Component Composition으로 Parallel Data Fetching

React Server Components는 tree 내에서 순차적으로 실행됩니다. composition으로 재구성하여 data fetching을 병렬화하세요.

Incorrect (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>;
}

Correct (둘 다 동시에 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을 사용한 대안:

import { ReactNode } from "react";
 
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>
  );
}


titleimpactimpactDescriptiontags
Cross-Request LRU CachingHIGH요청 간 캐시server, cache, lru, cross-request

Cross-Request LRU Caching

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

Implementation:

import { LRUCache } from "lru-cache";
 
// 타입 정의
type User = {
  id: string;
  name: string;
  email: string;
};
 
const cache = new LRUCache<string, User>({
  max: 1000,
  ttl: 5 * 60 * 1000, // 5분
});
 
export async function getUser(id: string): Promise<User | null> {
  const cached = cache.get(id);
  if (cached) return cached;
 
  const user = await db.user.findUnique({ where: { id } });
  if (user) {
    cache.set(id, user);
  }
  return user;
}
 
// Request 1: DB query, 결과 캐시됨
// Request 2: cache hit, DB query 없음

순차적인 사용자 actions이 몇 초 내에 동일한 데이터가 필요한 여러 endpoints를 호출할 때 사용하세요.

Vercel의 Fluid Compute와 함께: 여러 concurrent requests가 동일한 function instance와 cache를 공유할 수 있어 LRU caching이 특히 효과적입니다. 이는 Redis 같은 외부 storage 없이도 cache가 requests 간에 유지된다는 것을 의미합니다.

전통적인 serverless에서: 각 invocation이 격리되어 실행되므로, cross-process caching을 위해 Redis를 고려하세요.

Reference: node-lru-cache GitHub



titleimpactimpactDescriptiontags
RSC Boundaries에서 Serialization 최소화HIGHdata transfer size 감소server, rsc, serialization, props

RSC Boundaries에서 Serialization 최소화

React Server/Client boundary는 모든 object properties를 strings로 serialize하여 HTML response와 이후 RSC requests에 embed합니다. 이 serialized data는 page weight와 load time에 직접적인 영향을 미치므로 size가 매우 중요합니다. client가 실제로 사용하는 fields만 전달하세요.

Incorrect (50개 fields 모두 serialize):

// Server Component
async function Page() {
  const user = await fetchUser(); // 50개 fields
  return <Profile user={user} />;
}
 
// Client Component
("use client");
type User = {
  name: string;
  // ... 49개 추가 필드
};
 
function Profile({ user }: { user: User }) {
  return <div>{user.name}</div>; // 1개 field만 사용
}

Correct (1개 field만 serialize):

// Server Component
async function Page() {
  const user = await fetchUser();
  return <Profile name={user.name} />;
}
 
// Client Component
("use client");
function Profile({ name }: { name: string }) {
  return <div>{name}</div>;
}


titleimpactimpactDescriptiontags
Non-Blocking 작업에 after() 사용MEDIUM더 빠른 response timesserver, async, logging, analytics, side-effects

Non-Blocking 작업에 after() 사용

Next.js의 after()를 사용하여 response가 전송된 후 실행되어야 할 작업을 예약하세요. 이렇게 하면 logging, analytics 및 기타 side effects가 response를 block하는 것을 방지합니다.

Incorrect (response를 block함):

import { logUserAction } from "@/app/utils";
 
export async function POST(request: Request) {
  // mutation 수행
  await updateDatabase(request);
 
  // Logging이 response를 block함
  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" },
  });
}

Correct (non-blocking):

import { after } from "next/server";
import { headers, cookies } from "next/headers";
import { logUserAction } from "@/app/utils";
 
export async function POST(request: Request) {
  // mutation 수행
  await updateDatabase(request);
 
  // response 전송 후 log
  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" },
  });
}

Response는 즉시 전송되고 logging은 백그라운드에서 실행됩니다.

일반적인 use cases:

  • Analytics tracking
  • Audit logging
  • 알림 전송
  • Cache invalidation
  • Cleanup tasks

중요 사항:

  • after()는 response가 실패하거나 redirect되어도 실행됩니다
  • Server Actions, Route Handlers, Server Components에서 동작합니다

Reference: Next.js after() documentation



titleimpactimpactDescriptiontags
React.cache()로 Per-Request 중복 제거MEDIUMrequest 내 중복 제거server, cache, react-cache, deduplication

React.cache()로 Per-Request 중복 제거

server-side request 중복 제거를 위해 React.cache()를 사용하세요. Authentication과 database queries가 가장 큰 이점을 얻습니다.

Usage:

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 },
  });
});

단일 request 내에서 getCurrentUser()에 대한 여러 호출은 query를 한 번만 실행합니다.

inline objects를 arguments로 사용하지 마세요:

React.cache()는 cache hits를 결정하기 위해 shallow equality (Object.is)를 사용합니다. Inline objects는 매 호출마다 새로운 references를 생성하여 cache hits를 방지합니다.

Incorrect (항상 cache miss):

const getUser = cache(async (params: { uid: number }) => {
  return await db.user.findUnique({ where: { id: params.uid } });
});
 
// 매 호출마다 새 object 생성, cache hit 안 됨
getUser({ uid: 1 });
getUser({ uid: 1 }); // Cache miss, query 다시 실행

Correct (cache hit):

const getUser = cache(async (uid: number) => {
  return await db.user.findUnique({ where: { id: uid } });
});
 
// Primitive args는 value equality 사용
getUser(1);
getUser(1); // Cache hit, 캐시된 결과 반환

objects를 반드시 전달해야 한다면, 동일한 reference를 전달하세요:

const params = { uid: 1 };
getUser(params); // Query 실행
getUser(params); // Cache hit (동일한 reference)

Next.js-Specific Note:

Next.js에서 fetch API는 자동으로 request memoization이 확장됩니다. 동일한 URL과 options를 가진 requests는 단일 request 내에서 자동으로 중복 제거되므로, fetch 호출에는 React.cache()가 필요하지 않습니다. 그러나 React.cache()는 다른 async tasks에 여전히 필수적입니다:

  • Database queries (Prisma, Drizzle 등)
  • Heavy computations
  • Authentication checks
  • File system operations
  • fetch가 아닌 모든 async 작업

component tree 전체에서 이러한 operations를 중복 제거하기 위해 React.cache()를 사용하세요.

Reference: React.cache documentation



titleimpactimpactDescriptiontags
RSC Props에서 중복 Serialization 피하기LOW중복 serialization을 피해 network payload 감소server, rsc, serialization, props, client-components

RSC Props에서 중복 Serialization 피하기

Impact: LOW (중복 serialization을 피해 network payload 감소)

RSC→client serialization은 값이 아닌 object reference로 중복 제거합니다. 같은 reference = 한 번 serialized; 새로운 reference = 다시 serialized. 변환(.toSorted(), .filter(), .map())은 server가 아닌 client에서 하세요.

Incorrect (array 중복):

// RSC: 6개 strings 전송 (2 arrays × 3 items)
<ClientList usernames={usernames} usernamesOrdered={usernames.toSorted()} />

Correct (3개 strings 전송):

// RSC: 한 번만 전송
<ClientList usernames={usernames} />;
 
// Client: 거기서 변환
("use client");
import { useMemo } from "react";
 
function ClientList({ usernames }: { usernames: string[] }) {
  const sorted = useMemo(() => [...usernames].sort(), [usernames]);
  return (
    <ul>
      {sorted.map(name => (
        <li key={name}>{name}</li>
      ))}
    </ul>
  );
}

Nested deduplication 동작:

Deduplication은 재귀적으로 동작합니다. 영향은 data type에 따라 다릅니다:

  • string[], number[], boolean[]: HIGH impact - array + 모든 primitives 완전 중복
  • object[]: LOW impact - array는 중복되지만, nested objects는 reference로 중복 제거됨
// string[] - 모든 것 중복
usernames={['a','b']} sorted={usernames.toSorted()} // 4개 strings 전송
 
// object[] - array 구조만 중복
users={[{id:1},{id:2}]} sorted={users.toSorted()} // 2 arrays + 2 unique objects 전송 (4개 아님)

Deduplication을 깨는 operations (새로운 references 생성):

  • Arrays: .toSorted(), .filter(), .map(), .slice(), [...arr]
  • Objects: {...obj}, Object.assign(), structuredClone(), JSON.parse(JSON.stringify())

더 많은 예시:

// ❌ Bad
<C users={users} active={users.filter(u => u.active)} />
<C product={product} productName={product.name} />
 
// ✅ Good
<C users={users} />
<C product={product} />
// filtering/destructuring은 client에서

Exception: 변환이 비용이 많이 들거나 client가 원본이 필요 없을 때는 derived data를 전달하세요.