9.6 KiB
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 |
|
|
|
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.
tRPC (related)
// 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 빠름.