Files
2nd/10_Wiki/Topics/Coding/Backend_Server_Components_Pattern.md
T
2026-05-09 22:47:42 +09:00

9.6 KiB

id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
id title category status source_trust_level verification_status created_at updated_at tags tech_stack applied_in aliases
backend-server-components-pattern Server Components / Server Actions / TanStack Start Coding draft B conceptual 2026-05-09 2026-05-09
backend
server-components
fullstack
vibe-coding
language applicable_to
TS / React
Backend
Frontend
RSC
server actions
TanStack Start
fullstack TS
server functions
isomorphic

Server Components / Server Functions

Frontend / backend 경계가 흐려짐. RSC (React Server Components), Next App Router, TanStack Start, Remix, Astro. Server function = REST endpoint 의 typed alternative.

📖 핵심 개념

  • RSC: 서버 render, 0 client JS.
  • Server function: 'use server' — type-safe RPC.
  • 'use client': 인터랙션 component.
  • Streaming: Suspense + 점진 hydration.

💻 코드 패턴

Next.js App Router (RSC)

// app/users/page.tsx — server component (default)
async function UsersPage() {
  const users = await db.user.findMany();
  
  return (
    <div>
      <h1>Users</h1>
      <UserList users={users} />
    </div>
  );
}

// app/users/UserList.tsx — server
function UserList({ users }: { users: User[] }) {
  return (
    <ul>
      {users.map(u => <li key={u.id}>{u.email}</li>)}
    </ul>
  );
}

// app/users/UserSearch.tsx — client
'use client';
import { useState } from 'react';

export function UserSearch() {
  const [q, setQ] = useState('');
  return <input value={q} onChange={(e) => setQ(e.target.value)} />;
}

→ Server component 가 default. 인터랙션만 'use client'.

Server Action (mutation)

// app/users/actions.ts
'use server';

import { db } from '@/db';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';

const CreateUser = z.object({
  email: z.string().email(),
  name: z.string().min(1),
});

export async function createUser(formData: FormData) {
  const data = CreateUser.parse(Object.fromEntries(formData));
  await db.user.create({ data });
  revalidatePath('/users');
}
// Form
import { createUser } from './actions';

<form action={createUser}>
  <input name="email" type="email" />
  <input name="name" />
  <button>Create</button>
</form>

→ JS 없어도 form submit OK + 활성 시 SPA-like.

useActionState + useFormStatus

'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? '...' : 'Save'}</button>;
}

function Form() {
  const [state, formAction] = useActionState(createUser, { error: null });
  return (
    <form action={formAction}>
      <input name="email" />
      {state.error && <p>{state.error}</p>}
      <SubmitButton />
    </form>
  );
}

TanStack Start (modern)

// routes/users.tsx
import { createFileRoute } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/start';

const fetchUsers = createServerFn('GET', async () => {
  return db.user.findMany();
});

export const Route = createFileRoute('/users')({
  loader: () => fetchUsers(),
  component: UsersPage,
});

function UsersPage() {
  const users = Route.useLoaderData();
  return <ul>{users.map(u => <li key={u.id}>{u.email}</li>)}</ul>;
}

→ Type-safe server function — RPC.

Mutation (TanStack Start)

const createUser = createServerFn('POST', async (input: { email: string; name: string }) => {
  return db.user.create({ data: input });
});

// Component
async function handleSubmit(formData: FormData) {
  await createUser({
    email: formData.get('email') as string,
    name: formData.get('name') as string,
  });
}

Remix

// app/routes/users.tsx
import { json, type LoaderFunctionArgs, type ActionFunctionArgs } from '@remix-run/node';
import { useLoaderData, Form } from '@remix-run/react';

export async function loader() {
  return json(await db.user.findMany());
}

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  await db.user.create({
    data: {
      email: formData.get('email') as string,
      name: formData.get('name') as string,
    },
  });
  return json({ ok: true });
}

export default function Users() {
  const users = useLoaderData<typeof loader>();
  return (
    <>
      <Form method="post">...</Form>
      <ul>{users.map(u => <li key={u.id}>{u.email}</li>)}</ul>
    </>
  );
}

Streaming (Suspense)

import { Suspense } from 'react';

async function SlowPanel() {
  const data = await fetch('https://slow-api.com').then(r => r.json());
  return <div>{data}</div>;
}

export default function Page() {
  return (
    <>
      <h1>Title</h1>  {/* 즉시 보임 */}
      <Suspense fallback={<Skeleton />}>
        <SlowPanel />  {/* 도착 시 stream */}
      </Suspense>
    </>
  );
}

→ Fast TTFB + 점진 reveal.

Cache (Next 15)

'use cache';

async function getUsers() {
  'use cache';
  return db.user.findMany();
}

// 또는 fetch 의 cache option
fetch(url, { next: { revalidate: 60, tags: ['users'] } });

// Invalidate
revalidateTag('users');
revalidatePath('/users');

'use client' boundary

// Server component
import { ClientCounter } from './counter';  // imports client

async function Page() {
  const data = await fetchData();
  return (
    <div>
      <h1>{data.title}</h1>
      <ClientCounter initial={data.count} />
    </div>
  );
}

// Client component
'use client';
import { useState } from 'react';

export function ClientCounter({ initial }: { initial: number }) {
  const [count, setCount] = useState(initial);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

→ Server data → client component prop. Serializable 만.

Server function pitfalls

1. Public endpoint — Auth 매번 검사 필요.
2. Input validate (Zod / Valibot).
3. Rate limit.
4. Error handling — exception → user-facing message.
5. Logging — PII 제외.
'use server';

export async function deletePost(postId: string) {
  const user = await getUser();
  if (!user) throw new Error('Unauthorized');
  
  const post = await db.post.findUnique({ where: { id: postId } });
  if (!post) throw new Error('Not found');
  if (post.userId !== user.id && !user.isAdmin) {
    throw new Error('Forbidden');
  }
  
  await db.post.delete({ where: { id: postId } });
  revalidatePath('/posts');
}

vs REST API

REST:
+ Standard
+ Multi-client (web / mobile / 3rd party)
+ Cache 표준 (HTTP)
- Type drift (server / client)

Server functions:
+ Type-safe end-to-end
+ Less boilerplate
+ Co-located with UI
- Single-app (web only)
- Cache 어려움 (POST)
- 다른 client (mobile) X

→ Web only / fullstack TS = server functions. Multi-client / public API = REST.

// Server
const appRouter = router({
  users: {
    list: publicProcedure.query(() => db.user.findMany()),
    create: publicProcedure
      .input(z.object({ email: z.string().email() }))
      .mutation(({ input }) => db.user.create({ data: input })),
  },
});

// Client
const trpc = createTRPCReact<AppRouter>();
const users = trpc.users.list.useQuery();
trpc.users.create.useMutation();

→ Type-safe + framework agnostic.

Caching strategy

Static (build-time):
  Generate at build → CDN.

ISR (incremental):
  Revalidate every N seconds.

SSR (per-request):
  Always fresh.

Client-only:
  No server.

Server actions:
  Mutation → revalidate.

Hydration

1. Server render HTML
2. Client receives HTML (visible immediately)
3. Client downloads JS
4. React hydrates (event listeners attach)

→ JS 가 작아야 빠른 hydration.

Streaming SSR

Old:  Server 가 모든 거 render → send.
New:  HTML 가 stream — first paint 빠름 + Suspense 가 점진.

Server-only (security)

import 'server-only';

export const apiKey = process.env.API_KEY!;

// Client component 가 import 시도 = build error.

→ Secret 누설 방지.

Astro (SSG / SSR / RSC-like)

---
// Server only — build / request time
const users = await db.user.findMany();
---

<html>
  <body>
    <h1>Users</h1>
    {users.map(u => <li>{u.email}</li>)}
    
    <!-- Island (client) -->
    <SearchBox client:load />
  </body>
</html>

→ Static-first + 작은 JS.

Phoenix LiveView (Elixir)

# Server 가 HTML diff push
defmodule MyAppWeb.UserLive do
  use MyAppWeb, :live_view
  
  def mount(_params, _session, socket) do
    {:ok, assign(socket, users: list_users())}
  end
  
  def handle_event("search", %{"q" => q}, socket) do
    {:noreply, assign(socket, users: search_users(q))}
  end
end

→ Server-driven + WebSocket.

🤔 의사결정 기준

상황 추천
Next.js + fullstack TS App Router + Server Actions
Type-safe + flexibility TanStack Start
Old Remix users Remix
Mostly static + 작은 island Astro
Multi-client REST + tRPC
Real-time / chat LiveView / Hotwire

안티패턴

  • Server action auth 무 검사: public endpoint.
  • Input validate 없음: 위험.
  • 모두 'use client': bundle 폭발.
  • Server-only secret 누설: import 'server-only'.
  • Server / client component 혼동: build error.
  • Cache 안 — 매 request DB: latency.
  • Rate limit 없음: DoS.

🤖 LLM 활용 힌트

  • Server component default + 'use client' 작게.
  • Server action = form action.
  • Validate (Zod) + auth + rate limit.
  • Streaming + Suspense = TTFB 빠름.

🔗 관련 문서