React 19/18 ํต์ฌ ํ
, ์ปดํฌ๋ํธ ํจํด, Next.js 15/14 App Router ํ์ผ ๊ท์ฝ, ์๋ฒ ์ปดํฌ๋ํธ(RSC), ์๋ฒ ์ก์
, ๋ฐ์ดํฐ ํ์นญ/์บ์ฑ, Route Handlers ๋ฐ ๋ฏธ๋ค์จ์ด ์๋ฒฝ ๊ฐ์ด๋
## 1. ํ๋ก์ ํธ ์์ฑ & ์ค์น (Vite / Next.js)
React ๋จ๋
SPA ํ๋ก์ ํธ๋ Vite๋ฅผ, ํ์คํ SSR/SSG ์น ์ ํ๋ฆฌ์ผ์ด์
์ Next.js App Router๋ฅผ ์ฌ์ฉํฉ๋๋ค.
```bash
# โก [1] Vite ๊ธฐ๋ฐ React 19 + TypeScript ํ๋ก์ ํธ ์์ฑ (ํด๋ผ์ด์ธํธ SPA)
npm create vite@latest my-react-app -- --template react-ts
cd my-react-app
npm install
npm run dev
# ๐ [2] Next.js 15 / 14 ์ต์ ํ๋ก์ ํธ ์์ฑ (๊ณต์ App Router ๊ธฐ๋ณธ ์ ์ฉ)
npx create-next-app@latest my-next-app
# ๊ถ์ฅ ๋ํํ ์ค์ :
# โ Would you like to use TypeScript? โฆ Yes
# โ Would you like to use ESLint? โฆ Yes
# โ Would you like to use Tailwind CSS? โฆ Yes
# โ Would you like your code inside a `src/` directory? โฆ Yes
# โ Would you like to use App Router? (recommended) โฆ Yes
# โ Would you like to use Turbopack for `next dev`? โฆ Yes
# โ Would you like to customize the import alias (@/*)? โฆ No
# ๐ ๏ธ [3] ์์ฃผ ์ฐ๋ ํต์ฌ ํจํค์ง ์๋ผ์ด๋ ์ค์น
# ์์ด์ฝ, ์ ํธ๋ฆฌํฐ, ์ํ๊ด๋ฆฌ, ๊ฒ์ฆ ๋ผ์ด๋ธ๋ฌ๋ฆฌ
npm install lucide-react clsx tailwind-merge zustand zod
```
### App Router ๊ธฐ๋ณธ ๋๋ ํ ๋ฆฌ ๊ตฌ์กฐ (`src/app`)
```text
src/
โโโ app/
โ โโโ layout.tsx # ๋ฃจํธ ๋ ์ด์์ (HTML <html>, <body> ํฌํจ, ํ์)
โ โโโ page.tsx # ๋ฉ์ธ ํ ํ์ด์ง (/)
โ โโโ loading.tsx # ๋ก๋ฉ UI (React Suspense ๊ธฐ๋ฐ ์๋ ๋ํ)
โ โโโ error.tsx # ์๋ฌ ๋ฐ์ด๋๋ฆฌ ('use client' ํ์)
โ โโโ not-found.tsx # 404 ํ์ด์ง
โ โโโ api/
โ โ โโโ hello/route.ts # Route Handler (REST API ์๋ํฌ์ธํธ)
โ โโโ blog/
โ โโโ page.tsx # /blog ํ์ด์ง
โ โโโ [slug]/
โ โโโ page.tsx # /blog/:slug ๋์ ๋ผ์ฐํธ
```
## 2. React ํต์ฌ ํ
(Core Hooks)
ํจ์ํ ์ปดํฌ๋ํธ์์ ์ํ์ ๋ถ์ ํจ๊ณผ๋ฅผ ๋ค๋ฃจ๋ ๊ฐ์ฅ ํ์์ ์ธ ํ
๋ค์
๋๋ค.
```tsx
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
export default function CoreHooksDemo({ filterKeyword }: { filterKeyword: string }) {
// ๐ฏ [1] useState: ์ํ ์ ์ธ ๋ฐ ๋ถ๋ณ์ฑ ์
๋ฐ์ดํธ
const [count, setCount] = useState<number>(0);
const [user, setUser] = useState({ name: 'Lucky', theme: 'dark' });
// ํจ์ํ ์
๋ฐ์ดํธ (์ด์ ์ํ ๋ณด์ฅ) & ๊ฐ์ฒด ์คํ๋ ๋ ๋ถ๋ณ์ฑ ์ ์ง
const increment = () => setCount((prev) => prev + 1);
const updateTheme = (newTheme: string) => setUser((prev) => ({ ...prev, theme: newTheme }));
// ๐ [2] useEffect: ์๋ช
์ฃผ๊ธฐ ๋ฐ ์ฌ์ด๋ ์ดํํธ ๊ด๋ฆฌ
useEffect(() => {
// ๋ง์ดํธ ์ ๋๋ count ๋ณ๊ฒฝ ์ ์คํ
const timer = setInterval(() => console.log('Heartbeat'), 10000);
// ํด๋ฆฐ์
ํจ์ (์ธ๋ง์ดํธ ์ ๋๋ ๋ค์ effect ์คํ ์ง์ ๋ฆฌ์์ค ํด์ )
return () => clearInterval(timer);
}, [count]); // ์์กด์ฑ ๋ฐฐ์ด: []์ด๋ฉด ๋ง์ดํธ ์ 1ํ๋ง ์คํ
// ๐ [3] useRef: DOM ์๋ฆฌ๋จผํธ ์ง์ ์ฐธ์กฐ ๋๋ ๋ ๋๋ง์ ์ ๋ฐํ์ง ์๋ ๊ฐ ์ ์ฅ
const inputRef = useRef<HTMLInputElement>(null);
const renderCountRef = useRef<number>(0);
renderCountRef.current += 1;
const handleFocus = () => inputRef.current?.focus();
// ๐ง [4] useMemo: ๊ณ ๋น์ฉ ์ฐ์ฐ ๊ฒฐ๊ณผ๊ฐ ์บ์ฑ (๋ฉ๋ชจ์ด์ ์ด์
)
const expensiveList = useMemo(() => {
return Array.from({ length: 1000 }, (_, i) => `Item #${i}`).filter((item) =>
item.includes(filterKeyword)
);
}, [filterKeyword]);
// ๐ [5] useCallback: ์์ ์ปดํฌ๋ํธ์ ์ ๋ฌ๋๋ ์ฝ๋ฐฑ ํจ์ ์ฌ์์ฑ ๋ฐฉ์ง
const handleDelete = useCallback((id: number) => {
console.log('Delete item:', id);
}, []); // ์์กด์ฑ์ด ์์ผ๋ฉด ์ต์ด 1ํ๋ง ํจ์ ์์ฑ
return (
<div>
<input ref={inputRef} placeholder="์
๋ ฅ..." />
<button onClick={handleFocus}>ํฌ์ปค์ค ์ด๋</button>
<button onClick={increment}>ํด๋ฆญ: {count}</button>
</div>
);
}
```
## 3. React 18 & 19 ๊ณ ๊ธ ํ
(Transitions, Id, Action)
๋์์ฑ(Concurrency)๊ณผ ๋ชจ๋ ํผ ์ฒ๋ฆฌ๋ฅผ ์ํ ๊ณ ๊ธ ํ
์
๋๋ค.
```tsx
'use client';
import { useTransition, useId, useOptimistic, useActionState } from 'react';
// โก [1] useTransition: UI ๋ ๋๋ง ๋ธ๋กํน ๋ฐฉ์ง (์ฐ์ ์์๊ฐ ๋ฎ์ ์
๋ฐ์ดํธ)
export function SearchBox({ onSearch }: { onSearch: (q: string) => void }) {
const [isPending, startTransition] = useTransition();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// ํ์ดํ ์ธํ์ ์ฆ์ ๋ฐ์ํ๊ณ , ๋ฌด๊ฑฐ์ด ๊ฒ์ ๋ ๋๋ง์ ํธ๋์ง์
์ผ๋ก ์ง์ฐ ์ฒ๋ฆฌ
startTransition(() => {
onSearch(e.target.value);
});
};
return (
<div>
<input onChange={handleChange} placeholder="๊ฒ์์ด ์
๋ ฅ..." />
{isPending && <span className="spinner">๊ฒ์ ๊ฒฐ๊ณผ ๋ก๋ฉ ์ค...</span>}
</div>
);
}
// ๐ [2] useId: SSR๊ณผ ํด๋ผ์ด์ธํธ ๊ฐ ํ์ด๋๋ ์ด์
๋ถ์ผ์น ์๋ ๊ณ ์ ID ์์ฑ
export function AccessibleInput({ label }: { label: string }) {
const id = useId();
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} type="text" />
</div>
);
}
// ๐ [3] useOptimistic (React 19): ์๋ฒ ์๋ต ์ UI๋ฅผ ๋จผ์ ๊ฐฑ์ ํ๋ ๋๊ด์ ์
๋ฐ์ดํธ
export function MessageList({ messages, sendMessageAction }: any) {
const [optimisticMessages, setOptimistic] = useOptimistic(
messages,
(state: string[], newMsg: string) => [...state, `${newMsg} (์ ์ก ์ค...)`]
);
const formAction = async (formData: FormData) => {
const text = formData.get('message') as string;
setOptimistic(text); // UI ์ฆ์ ๋ฐ์
await sendMessageAction(text); // ์ค์ ์๋ฒ ์ ์ก
};
return (
<form action={formAction}>
<ul>{optimisticMessages.map((m: string, i: number) => <li key={i}>{m}</li>)}</ul>
<input name="message" required />
<button type="submit">์ ์ก</button>
</form>
);
}
```
## 4. ์ปดํฌ๋ํธ ํจํด & ์ปค์คํ
ํ
(Custom Hooks)
์ฌ์ฌ์ฉ ๊ฐ๋ฅํ UI ์ปดํฌ๋ํธ ์ค๊ณ์ ๋น์ฆ๋์ค ๋ก์ง ๋ถ๋ฆฌ ํจํด์
๋๋ค.
```tsx
import React, { useState, useEffect } from 'react';
// ๐ฆ [1] ํ์
์์ ํ Props ์ธํฐํ์ด์ค ์ ์ (React.FC ์ง์, Props ์ง์ ํ์ดํ)
interface CardProps {
title: string;
badge?: string;
children: React.ReactNode; // ์ฌ๋กฏ ํํ์ ์์ ๋
ธ๋
variant?: 'primary' | 'secondary';
onAction?: () => void;
}
export function Card({ title, badge, children, variant = 'primary', onAction }: CardProps) {
return (
<div className={`card card-${variant}`}>
<header className="flex justify-between items-center">
<h3>{title}</h3>
{badge && <span className="badge">{badge}</span>}
</header>
<div className="card-body">{children}</div>
{onAction && <button onClick={onAction}>ํ์ธ</button>}
</div>
);
}
// ๐ ๏ธ [2] ์ค์ ์ปค์คํ
ํ
: useDebounce (๊ฒ์์ฐฝ API ์์ฒญ ์ต์ ํ)
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}
// ๐พ [3] ์ค์ ์ปค์คํ
ํ
: useLocalStorage
export function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') return initialValue;
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (err) {
console.error(err);
}
};
return [storedValue, setValue] as const;
}
```
## 5. Next.js App Router ๋ผ์ฐํ
& ํ์ผ ๊ท์ฝ
ํด๋ ๊ตฌ์กฐ๊ฐ ๊ณง ์น URL ๊ฒฝ๋ก๊ฐ ๋๋ฉฐ, ํน์ ํ์ผ๋ค์ ํตํด ๋ ์ด์์๊ณผ ์ํ๋ฅผ ์บก์ํํฉ๋๋ค.
```tsx
// ๐ app/blog/[slug]/page.tsx (๋์ ๋ผ์ฐํธ ํ์ด์ง)
// Next.js 15๋ถํฐ params๋ Promise์
๋๋ค (๋น๋๊ธฐ await ์ฒ๋ฆฌ ํ์)
interface PageProps {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function BlogPostPage({ params, searchParams }: PageProps) {
const { slug } = await params;
const query = await searchParams;
return (
<article>
<h1>ํฌ์คํธ: {slug}</h1>
<p>๊ฒ์ ํํฐ: {query.tab || '๊ธฐ๋ณธ๊ฐ'}</p>
</article>
);
}
// โ ๏ธ app/error.tsx (์๋ฌ ์ฒ๋ฆฌ ๋ฐ์ด๋๋ฆฌ - ๋ฐ๋์ Client Component์ฌ์ผ ํจ)
'use client';
export function ErrorBoundary({ error, reset }: { error: Error; reset: () => void }) {
return (
<div className="error-box">
<h2>์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค!</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>๋ค์ ์๋</button>
</div>
);
}
// โณ app/loading.tsx (ํ์ด์ง ์ ํ ์ ์๋ ๋ ๋๋ง๋๋ ์ค์ผ๋ ํค UI)
export function LoadingSkeleton() {
return <div className="animate-pulse bg-gray-200 h-64 rounded-xl" />;
}
// ๐ซ app/not-found.tsx (notFound() ํจ์ ํธ์ถ ์ ๋
ธ์ถ)
import Link from 'next/link';
export default function NotFound() {
return (
<div>
<h2>ํ์ด์ง๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค (404)</h2>
<Link href="/">ํ์ผ๋ก ์ด๋</Link>
</div>
);
}
```
## 6. ์๋ฒ ์ปดํฌ๋ํธ(RSC) vs ํด๋ผ์ด์ธํธ ์ปดํฌ๋ํธ
Next.js App Router์ ๋ชจ๋ ์ปดํฌ๋ํธ๋ ๊ธฐ๋ณธ์ ์ผ๋ก **React Server Component(RSC)**์
๋๋ค.
```tsx
// ๐ [1] ์๋ฒ ์ปดํฌ๋ํธ (Server Component, ๊ธฐ๋ณธ๊ฐ)
// - ํด๋ผ์ด์ธํธ JS ๋ฒ๋ค์ ํฌํจ๋์ง ์์ (0 KB)
// - ๋ฐ์ดํฐ๋ฒ ์ด์ค(Prisma, Supabase, SQL) ๋ฐ ์๋ฒ ๋น๋ฐํค(API_KEY)์ ์ง์ ์ ๊ทผ ๊ฐ๋ฅ
// - useState, useEffect, onClick ๋ฑ ๋ธ๋ผ์ฐ์ ์ด๋ฒคํธ ์ฌ์ฉ ๋ถ๊ฐ
import { db } from '@/lib/db';
export default async function UserProfile({ userId }: { userId: string }) {
// DB ์ง์ ์กฐํ (API ์๋ํฌ์ธํธ ๊ฑฐ์น ํ์ ์์)
const user = await db.user.findUnique({ where: { id: userId } });
return (
<div>
<h2>{user?.name}</h2>
{/* ํด๋ผ์ด์ธํธ ์ปดํฌ๋ํธ๋ฅผ ์์์ผ๋ก ์ํฌํธํ์ฌ ์ฌ์ฉ */}
<FollowButton targetId={userId} />
</div>
);
}
// ๐ป [2] ํด๋ผ์ด์ธํธ ์ปดํฌ๋ํธ (Client Component)
// - ํ์ผ ์ต์๋จ์ 'use client' ๋ช
์
// - onClick, onChange ๋ฑ ์ด๋ฒคํธ ๋ฆฌ์ค๋์ useState, useEffect ์ฌ์ฉ ์ ํ์
'use client';
import { useState } from 'react';
export function FollowButton({ targetId }: { targetId: string }) {
const [isFollowing, setIsFollowing] = useState(false);
return (
<button onClick={() => setIsFollowing(!isFollowing)}>
{isFollowing ? 'ํ๋ก์' : 'ํ๋ก์ฐ'}
</button>
);
}
// ๐ก [ํต์ฌ ํจํด] Server Component๋ฅผ Client Component์ children์ผ๋ก ์ ๋ฌํ๊ธฐ
// 'use client' ์ปดํฌ๋ํธ ์์์ ์๋ฒ ์ปดํฌ๋ํธ๋ฅผ importํ๋ฉด ํด๋ผ์ด์ธํธ๋ก ๋ณํ๋๋ฏ๋ก,
// ์๋์ฒ๋ผ children ํ๋กญ์ผ๋ก ์ ๋ฌํด์ผ ์๋ฒ ๋ ๋๋ง ์ด์ ์ ์ ์งํ ์ ์์ต๋๋ค.
// <ClientModal><ServerHeavyContent /></ClientModal>
```
## 7. ๋ฐ์ดํฐ ํ์นญ, ์บ์ฑ & Revalidation
์๋ฒ ์ปดํฌ๋ํธ์์๋ `fetch` ํจ์๊ฐ ๋ธ๋ผ์ฐ์ ๊ฐ ์๋ ์๋ฒ ํ๊ฒฝ์์ ํ์ฅ๋ ์บ์ ์ต์
๊ณผ ํจ๊ป ๋์ํฉ๋๋ค.
```tsx
// ๐ฅ [1] ์ ์ ์บ์ฑ (๊ธฐ๋ณธ SSG ํจ๊ณผ, ๋น๋ ์์ ๋๋ ์ฒซ ์์ฒญ ์ ์บ์ ์ ์ง)
async function getStaticData() {
const res = await fetch('https://api.example.com/data', {
cache: 'force-cache', // ๊ธฐ๋ณธ๊ฐ
});
return res.json();
}
// ๐ [2] ๋์ ํ์นญ (SSR ํจ๊ณผ, ์์ฒญ๋ง๋ค ์๋ก ๊ฐ์ ธ์ค๊ธฐ)
async function getDynamicData() {
const res = await fetch('https://api.example.com/stock-price', {
cache: 'no-store', // ์บ์ํ์ง ์์
});
return res.json();
}
// โฑ๏ธ [3] ์๊ฐ ๊ธฐ๋ฐ ์ฌ๊ฒ์ฆ (ISR - Incremental Static Regeneration)
async function getRevalidatedData() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 }, // 60์ด๋ง๋ค ๋ฐฑ๊ทธ๋ผ์ด๋ ์ฌ๊ฒ์ฆ
});
return res.json();
}
// ๐ท๏ธ [4] ํ๊ทธ ๊ธฐ๋ฐ ์จ๋๋งจ๋ ์ฌ๊ฒ์ฆ (On-Demand Revalidation)
async function getTaggedData() {
const res = await fetch('https://api.example.com/products', {
next: { tags: ['products'] }, // ํ๊ทธ ์ง์
});
return res.json();
}
// โก [5] ์จ๋๋งจ๋ ์ฌ๊ฒ์ฆ ํธ๋ฆฌ๊ฑฐ (Server Action ๋๋ Route Handler์์ ํธ์ถ)
import { revalidateTag, revalidatePath } from 'next/cache';
export async function clearCacheAction() {
'use server';
revalidateTag('products'); // 'products' ํ๊ทธ๊ฐ ๋ฌ๋ฆฐ ๋ชจ๋ ์บ์ ์ฆ์ ๋ฌดํจํ
revalidatePath('/blog/[slug]'); // ํน์ ๊ฒฝ๋ก ์ฆ์ ์ฌ๊ฒ์ฆ
}
// ๐ [6] ์ ์ ๋์ ๊ฒฝ๋ก ์ฌ์ ์์ฑ (generateStaticParams)
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then((res) => res.json());
return posts.map((post: any) => ({ slug: post.id.toString() }));
}
```
## 8. ์๋ฒ ์ก์
(Server Actions: 'use server')
๋ณ๋์ REST API ๋ผ์ฐํธ ์์ด, ํด๋ผ์ด์ธํธ ํผ์ด๋ ๋ฒํผ์์ ์๋ฒ ํจ์๋ฅผ ์ง์ ํธ์ถํ์ฌ DB ์์ ๋ฐ ๋ณํ(Mutation)์ ์ํํฉ๋๋ค.
```tsx
// ๐ app/actions/user.ts (๋ณ๋ ํ์ผ๋ก ๋ถ๋ฆฌ ์ ํ์ผ ์๋จ์ 'use server')
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
const schema = z.object({
email: z.string().email('์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋๋๋ค.'),
username: z.string().min(2, '์ด๋ฆ์ ์ต์ 2๊ธ์ ์ด์์ด์ด์ผ ํฉ๋๋ค.'),
});
export async function createUserAction(prevState: any, formData: FormData) {
const rawData = {
email: formData.get('email'),
username: formData.get('username'),
};
// 1. ์ ํจ์ฑ ๊ฒ์ฆ
const validated = schema.safeParse(rawData);
if (!validated.success) {
return { success: false, errors: validated.error.flatten().fieldErrors };
}
// 2. DB ์์
(์: Prisma / Supabase ํธ์ถ)
// await db.user.create({ data: validated.data });
// 3. ์บ์ ๊ฐฑ์
revalidatePath('/users');
return { success: true, message: '์ฑ๊ณต์ ์ผ๋ก ๋ฑ๋ก๋์์ต๋๋ค.' };
}
// ๐ app/users/new/page.tsx (ํด๋ผ์ด์ธํธ ํผ ์ปดํฌ๋ํธ)
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { createUserAction } from '@/app/actions/user';
function SubmitButton() {
const { pending } = useFormStatus(); // ๋ถ๋ชจ <form>์ ์ ์ถ ์งํ ์ํ ๊ฐ์ง
return (
<button type="submit" disabled={pending} className="btn-primary">
{pending ? '์ ์ฅ ์ค...' : '์ฌ์ฉ์ ๋ฑ๋ก'}
</button>
);
}
export default function NewUserForm() {
const [state, formAction] = useActionState(createUserAction, null);
return (
<form action={formAction} className="space-y-4">
<div>
<input name="username" placeholder="์ด๋ฆ" required />
{state?.errors?.username && <p className="text-red-500">{state.errors.username[0]}</p>}
</div>
<div>
<input name="email" type="email" placeholder="์ด๋ฉ์ผ" required />
{state?.errors?.email && <p className="text-red-500">{state.errors.email[0]}</p>}
</div>
<SubmitButton />
{state?.success && <p className="text-green-500">{state.message}</p>}
</form>
);
}
```
## 9. Next.js ๋ด์ฅ ์ต์ ํ ์ปดํฌ๋ํธ & ๋ฉํ๋ฐ์ดํฐ
์น ์ฑ๋ฅ(Core Web Vitals)๊ณผ ๊ฒ์ ์์ง ์ต์ ํ(SEO)๋ฅผ ์ํ ๋ด์ฅ ๊ธฐ๋ฅ์
๋๋ค.
```tsx
// ๐ผ๏ธ [1] next/image: ์๋ WebP ๋ณํ, Lazy Loading, ๋ ์ด์์ ์ํํธ ๋ฐฉ์ง
import Image from 'next/image';
import Link from 'next/link';
export function Banner() {
return (
<div>
{/* LCP ์ฃผ์ ์ด๋ฏธ์ง์ผ ๊ฒฝ์ฐ priority={true} ์ ์ฉ */}
<Image
src="/hero-banner.png"
alt="๋ฉ์ธ ๋ฐฐ๋"
width={1200}
height={600}
priority
className="rounded-lg object-cover"
/>
{/* ๐ [2] next/link: ๋ทฐํฌํธ์ ๋ค์ด์ค๋ฉด ๋ฐฑ๊ทธ๋ผ์ด๋ ํ๋ฆฌํ์น(prefetch) ์๋ ์ํ */}
<Link href="/dashboard" prefetch={true} className="text-blue-500 hover:underline">
๋์๋ณด๋๋ก ์ด๋
</Link>
</div>
);
}
// ๐ [3] ์ ์ ๋ฉํ๋ฐ์ดํฐ (SEO)
export const metadata = {
title: 'Next.js ๋ง์คํฐ ๊ฐ์ด๋ | LuckyTechWorld',
description: 'App Router๋ถํฐ ์๋ฒ ์ก์
๊น์ง ๋ค๋ฃจ๋ ์ข
ํฉ ๋ ํผ๋ฐ์ค',
openGraph: {
title: 'Next.js ๋ง์คํฐ ๊ฐ์ด๋',
images: ['/og-image.png'],
},
};
// ๐ฏ [4] ๋์ ๋ฉํ๋ฐ์ดํฐ (ํฌ์คํธ ์ ๋ชฉ/์์ธ ์กฐํ ๊ธฐ๋ฐ ๋์ ์์ฑ)
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const post = await fetch(`https://api.example.com/posts/${id}`).then((r) => r.json());
return {
title: `${post.title} - ๊ธฐ์ ๋ธ๋ก๊ทธ`,
description: post.summary,
};
}
// ๐ค [5] next/font: ๊ตฌ๊ธ ํฐํธ ๋น๋ ํ์ ์์ฒด ํธ์คํ
(์ธ๋ถ ์์ฒญ ์ ๋ก)
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });
export function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko" className={inter.className}>
<body>{children}</body>
</html>
);
}
```
## 10. Route Handlers (app/api/.../route.ts)
ํด๋ผ์ด์ธํธ๋ ์ธ๋ถ ์์คํ
์์ ํธ์ถํ ์ ์๋ ์น ํ์ค ๊ธฐ๋ฐ์ REST API ์๋ํฌ์ธํธ์
๋๋ค.
```typescript
// ๐ app/api/items/route.ts
import { NextRequest, NextResponse } from 'next/server';
// ๐ข GET: ๋ชฉ๋ก ์กฐํ ๋ฐ ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ ํ์ฑ
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = searchParams.get('page') || '1';
const keyword = searchParams.get('q') || '';
const items = [{ id: 1, name: 'Item A' }, { id: 2, name: 'Item B' }];
return NextResponse.json({
success: true,
page: Number(page),
keyword,
data: items,
}, { status: 200 });
}
// ๐ต POST: JSON Body ๋ฐ์ดํฐ ์์ ๋ฐ ์๋ต
export async function POST(request: NextRequest) {
try {
const body = await request.json();
if (!body.title) {
return NextResponse.json({ error: '์ ๋ชฉ์ ํ์ ํญ๋ชฉ์
๋๋ค.' }, { status: 400 });
}
// ์ ๊ท ๋ฐ์ดํฐ DB ์ฝ์
์์...
const newItem = { id: Date.now(), title: body.title };
return NextResponse.json(newItem, { status: 201 });
} catch (err) {
return NextResponse.json({ error: '์๋ชป๋ JSON ๋ฐ๋ ์์ฒญ์
๋๋ค.' }, { status: 500 });
}
}
// ๐ app/api/items/[id]/route.ts (๋์ ๋งค๊ฐ๋ณ์ ์ฒ๋ฆฌ)
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return NextResponse.json({ message: `ID ${id} ์ญ์ ์๋ฃ` });
}
```
## 11. ๋ฏธ๋ค์จ์ด(Middleware) & ์ธ์ฆ/๋ณด์ ์ฒ๋ฆฌ
์์ฒญ์ด ์๋ฃ๋๊ธฐ ์ ์ ์คํ๋์ด ์ธ์ฆ ์ฒดํฌ, ๋ฆฌ๋๋ ์
, ํค๋ ์ฌ์์ฑ์ ์ํํฉ๋๋ค.
```typescript
// ๐ middleware.ts (src/ ๋ฐ๋ก ์๋ ๋๋ ํ๋ก์ ํธ ๋ฃจํธ์ ์์น)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('session_token')?.value;
const pathname = request.nextUrl.pathname;
// ๐ [1] ๋ณดํธ๋ ๊ด๋ฆฌ์/๋์๋ณด๋ ๊ฒฝ๋ก ์ธ์ฆ ๊ฒ์ฌ
if (pathname.startsWith('/dashboard') || pathname.startsWith('/admin')) {
if (!token) {
// ๋ฏธ์ธ์ฆ ์ ๋ก๊ทธ์ธ ํ์ด์ง๋ก ๋ฆฌ๋๋ ์
(์๋ ๊ฐ๋ ค๋ URL์ ์ฟผ๋ฆฌ์ ๋ณด์กด)
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
}
// ๐ก๏ธ [2] ๋ณด์ ํค๋ ๋ฐ ์ปค์คํ
์์ฒญ ํค๋ ์ฃผ์
(Rewrite)
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-lucky-timestamp', Date.now().toString());
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
// ์๋ต ๋ณด์ ํค๋ ์ถ๊ฐ
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
return response;
}
// โ๏ธ [3] ๋ฏธ๋ค์จ์ด๊ฐ ์คํ๋ ๊ฒฝ๋ก ํํฐ๋ง (์ ์ ํ์ผ ๋ฐ ํ๋น์ฝ ์ ์ธ ํ์)
export const config = {
matcher: [
/*
* ๋ค์์ผ๋ก ์์ํ๋ ๊ฒฝ๋ก๋ฅผ ์ ์ธํ ๋ชจ๋ ์์ฒญ ๊ฒฝ๋ก์ ๋ฏธ๋ค์จ์ด ์ ์ฉ:
* - api (API ๋ผ์ฐํธ ์ ์ธ ์)
* - _next/static (์ ์ ํ์ผ)
* - _next/image (์ด๋ฏธ์ง ์ต์ ํ ํ์ผ)
* - favicon.ico (ํ๋น์ฝ ํ์ผ)
*/
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};
```
## 12. ์ํ ๊ด๋ฆฌ (Zustand) & ํ๊ฒฝ๋ณ์ ์ค์
๊ฐ์ฅ ๊ฐ๋ณ๊ณ ๋๋ฆฌ ์ฐ์ด๋ Zustand ์ ์ญ ์ํ ๊ด๋ฆฌ์ ํ๊ฒฝ๋ณ์ ๊ท์น์
๋๋ค.
```typescript
// ๐ป [1] Zustand ์ ์ญ ์คํ ์ด ์ ์ (src/stores/useAuthStore.ts)
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
id: string;
name: string;
email: string;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
login: (userData: User) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
isAuthenticated: false,
login: (userData) => set({ user: userData, isAuthenticated: true }),
logout: () => set({ user: null, isAuthenticated: false }),
}),
{ name: 'auth-storage' } // ๋ก์ปฌ ์คํ ๋ฆฌ์ง์ ์๋ ์ ์ฅ
)
);
// ๐ [2] ์ปดํฌ๋ํธ์์ Zustand ์คํ ์ด ์ฌ์ฉ ์์
'use client';
export function UserStatus() {
const { user, isAuthenticated, logout } = useAuthStore();
if (!isAuthenticated) return <p>๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค.</p>;
return (
<div>
<p>ํ์ํฉ๋๋ค, {user?.name}๋!</p>
<button onClick={logout}>๋ก๊ทธ์์</button>
</div>
);
}
```
### ๐ ํ๊ฒฝ๋ณ์(.env) ๋ณด์ ๊ท์น
```bash
# ๐ [์๋ฒ ์ ์ฉ ํ๊ฒฝ๋ณ์]
# ๋ธ๋ผ์ฐ์ JS ๋ฒ๋ค์ ์ ๋ ํฌํจ๋์ง ์์ผ๋ฉฐ, ์๋ฒ ์ปดํฌ๋ํธ/์๋ฒ ์ก์
/Route Handler์์๋ง ์ ๊ทผ ๊ฐ๋ฅ
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
JWT_SECRET_KEY="super-secret-key-only-server-knows"
OPENAI_API_KEY="sk-..."
# ๐ [ํด๋ผ์ด์ธํธ ๊ณต๊ฐ ํ๊ฒฝ๋ณ์]
# 'NEXT_PUBLIC_' ์ ๋์ฌ๋ฅผ ๋ถ์ฌ์ผ ๋ธ๋ผ์ฐ์ ํด๋ผ์ด์ธํธ ์ปดํฌ๋ํธ('use client')์์ ์ฝ์ ์ ์์
NEXT_PUBLIC_API_URL="https://api.example.com"
NEXT_PUBLIC_GA_TRACKING_ID="G-XXXXXXXXXX"
```
์๊ฒฌ ๋ฐ ์ง๋ฌธ
0์์ง ๋ฑ๋ก๋ ์๊ฒฌ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋๊ธ์ ๋จ๊ฒจ๋ณด์ธ์!
๋๊ธ ์์
๋๊ธ ์ญ์