๐ฏ ๋ชฉํ
- Firebase Admin SDK ์ด๊ธฐํ (์๋ฒ ์ ์ฉ)
- ์ธ์ ์ฟ ํค ๋ฐฉ์ ๋ก๊ทธ์ธ(ํด๋ผ์ด์ธํธ ID ํ ํฐ โ ์๋ฒ ์ธ์ ์ฟ ํค)
- Server Actions์์ UID ๊ฒ์ฆ ํ Firestore ์กฐ์
- FormData ๊ฒ์ฆ๊ณผ ์๋ฌ ํธ๋ค๋ง ํจํด
0) ํ๊ฒฝ ๋ณ์ ์ค๋น(.env.local)
Admin SDK๋ ์๋ฒ ์ ์ฉ์ด๋ฏ๋ก โํด๋ผ์ด์ธํธ์ ๋ ธ์ถ๋๋ฉด ์ ๋ฉ๋๋คโ.
NEXT_PUBLIC_์ ๋์ฌ ์ฌ์ฉ ๊ธ์ง.
FIREBASE_ADMIN_PROJECT_ID=your-project-id
FIREBASE_ADMIN_CLIENT_EMAIL=firebase-adminsdk-xxxx@your-project-id.iam.gserviceaccount.com
# ๋ฉํฐ๋ผ์ธ ํค์ \n ์ ์ค์ ๊ฐํ์ผ๋ก ์นํํด์ ์ฐ๋ ํธ์ด ๊ฐ์ฅ ์์ ํฉ๋๋ค.
FIREBASE_ADMIN_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nABC...XYZ\n-----END PRIVATE KEY-----\n"
# ์ธ์
์ฟ ํค ์๋ช
(์ผ ๊ธฐ์ค). ์: 7์ผ
AUTH_SESSION_DAYS=7
โ ์๋น์ค ๊ณ์ ํค๋ Firebase ์ฝ์ > ํ๋ก์ ํธ ์ค์ > ์๋น์ค ๊ณ์ ์์ ๋ฐ๊ธํฉ๋๋ค.
Vercel์ ๋ฐฐํฌ ์ ์ 3๊ฐ ๊ฐ์ ํ๊ฒฝ๋ณ์๋ก ๋ฑ๋กํ์ธ์.

.png?alt=media&token=b61cf64e-f696-4070-a3be-87586b9ba5bc)
ํค ์์ฑ์ ํ๋ฉด json ํ์ผ์ ๋ค์ด๋ก๋ํ๊ฒ ๋ฉ๋๋ค. ๊ทธ ํ์ผ์ ์ด์ด์ ํ๊ฒฝ๋ณ์๋ฅผ ์ฑ์์ค๋๋ค.
.png?alt=media&token=5d761422-142a-4aed-9347-b4f8c7db56f6)
1) Admin SDK ์ด๊ธฐํ
ํ์ด์ด๋ฒ ์ด์ค ์ด๋๋ฏผ ์ค์น
npm i firebase-admin
lib/firebase.admin.ts
import { cert, getApps, initializeApp, App } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
import { getFirestore } from 'firebase-admin/firestore';
let adminApp: App;
if (!getApps().length) {
adminApp = initializeApp({
credential: cert({
projectId: process.env.FIREBASE_ADMIN_PROJECT_ID,
clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_ADMIN_PRIVATE_KEY?.replace(/\\n/g, '\n'),
}),
});
} else {
adminApp = getApps()[0]!;
}
export const adminAuth = getAuth(adminApp);
export const adminDb = getFirestore(adminApp);
์ญํ
- ์๋ฒ ๋ฐํ์์์๋ง ๋์ํ๋ Admin SDK ์ธ์คํด์ค(Auth/Firestore)๋ฅผ ์ฑ๊ธํค์ผ๋ก ์ด๊ธฐํํฉ๋๋ค.
๋์ ์๋ฆฌ
getApps().length๋ก ์ค๋ณต ์ด๊ธฐํ ๋ฐฉ์ง(๊ฐ๋ฐ HMR/ํ ์คํธ ํ๊ฒฝ์์ ์ค์).credential: cert({...})๋ก ์๋น์ค ๊ณ์ ์๊ฒฉ ์ฆ๋ช ์ฃผ์ .- ๋ด๋ณด๋ธ ๊ฐ์ฒด:
adminAuthโ ์ธ์ ์ฟ ํค ๊ฒ์ฆ/์์ฑ ๋ฑ ์ธ์ฆ ์๋ฒ ์์adminDbโ ์๋ฒ ์ ๋ขฐ ์ปจํ ์คํธ์์ Firestore ์ฝ๊ธฐ/์ฐ๊ธฐ
์ฃผ์
- ํด๋ผ์ด์ธํธ์์ import ๊ธ์ง. (App Router์์๋ ์๋ฒ ํ์ผ์๋ง ์ฌ์ฉ)
2) ์ธ์ ์ฟ ํค API (๋ก๊ทธ์ธ/๋ก๊ทธ์์)
ํด๋ผ์ด์ธํธ์์ Firebase Auth๋ก ๋ก๊ทธ์ธํ ๋ค, ID ํ ํฐ์ ๋ฐ์ ์๋ฒ์ ์ ๋ฌ โ ์๋ฒ๋ ์ธ์ ์ฟ ํค๋ฅผ ๊ตฝ์ต๋๋ค.
์ด ์ฟ ํค๋ Server Action์์ ์๋์ผ๋ก ์ ๋ฌ๋๋ฏ๋ก ๋ณ๋ ํค๋ ์์ด ์ธ์ฆ์ด ๊ฐ๋ฅํฉ๋๋ค.
app/api/auth/session/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { adminAuth } from '@/lib/firebase.admin';
export async function POST(req: NextRequest) {
// body: { idToken: string }
const { idToken } = await req.json();
if (!idToken)
return NextResponse.json(
{ ok: false, message: 'NO_TOKEN' },
{ status: 400 }
);
const expiresIn =
Number(process.env.AUTH_SESSION_DAYS ?? 7) * 24 * 60 * 60 * 1000;
try {
const sessionCookie = await adminAuth.createSessionCookie(idToken, {
expiresIn,
});
const res = NextResponse.json({ ok: true });
// httpOnly + secure ์ฟ ํค ์ค์
res.cookies.set({
name: '__session', // Firebase ํธํ ๋ค์ด๋ฐ
value: sessionCookie,
httpOnly: true,
secure: true,
path: '/',
maxAge: Math.floor(expiresIn / 1000),
sameSite: 'lax',
});
return res;
} catch (e) {
return NextResponse.json(
{ ok: false, message: 'CREATE_SESSION_FAILED' },
{ status: 401 }
);
}
}
export async function DELETE() {
// ์ฟ ํค ์ญ์
const res = NextResponse.json({ ok: true });
res.cookies.set({
name: '__session',
value: '',
path: '/',
maxAge: 0,
});
return res;
}
๋ฌด์์ ํ๋์?
ํด๋ผ์ด์ธํธ(๋ธ๋ผ์ฐ์ )์์ ๋ฐ์ ID ํ ํฐ์ ์๋ฒ๋ก ๋ณด๋ด๋ฉด
โ ์๋ฒ๊ฐ
createSessionCookie๋ก HTTP-Only ์ธ์ ์ฟ ํค๋ฅผ ๊ตฝ์ต๋๋ค.๋ก๊ทธ์์์ ํด๋น ์ฟ ํค๋ฅผ ์ญ์ ํฉ๋๋ค.
๋ณด์ ์์ฑ
httpOnly: trueโ JS๋ก ์ฟ ํค ์ ๊ทผ ๋ถ๊ฐ(XSS ์ ํญ).secure: trueโ HTTPS์์๋ง ์ ์ก(๋ฐฐํฌ ํ๊ฒฝ ๊ถ์ฅ, ๋ก์ปฌ http์์๋ ์๋ต ๊ฐ๋ฅ).sameSite: 'lax'โ CSRF ๊ธฐ๋ณธ ์ํ.
์๋ช
AUTH_SESSION_DAYS๋ก ๋ง๋ฃ ์๊ฐ ์ ์ด. ๋ง๋ฃ ํ์ ๋ค์ ๋ก๊ทธ์ธํด์ผ ํฉ๋๋ค.
์ค๋ฅ ์ฒ๋ฆฌ
- ID ํ ํฐ ๋๋ฝ/์ ํจํ์ง ์์ผ๋ฉด 400/401๋ก ์คํจ ๋ฐํ โ ํด๋ผ์ด์ธํธ๋ ์ฌ๋ก๊ทธ์ธ ์ ๋.
ํ
๋ณด์ ์๊ตฌ๊ฐ ๋๋ค๋ฉด CSRF ํ ํฐ์ ์ถ๊ฐ(์: ํค๋
X-CSRF-Token)ํ๊ณ , ์์ฒญ ํค๋๋ฅผ ๊ฒ์ฆํ์ธ์.
ํด๋ผ์ด์ธํธ: ๋ก๊ทธ์ธ ์งํ ์ธ์ ์ค์
app/login/page.tsx / app/signup/page.tsx์์ ๋ก๊ทธ์ธ ์ฑ๊ณต ํ ์๋ ํธ์ถ ์ถ๊ฐ:
import { getIdToken } from 'firebase/auth';
// ๋ก๊ทธ์ธ/ํ์๊ฐ์
์ฑ๊ณต ์งํ
const idToken = await getIdToken(auth.currentUser!, true);
await fetch('/api/auth/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idToken }),
});
- ์ต์ข ์ ์ฉ ์์ค๋ ์ ์ฅ์ ๋ธ๋์น๋ฅผ ์ฐธ๊ณ ํ์ธ์.
ํ๋ฆ
- Firebase Auth๋ก ๋ก๊ทธ์ธ ์๋ฃ
getIdToken(currentUser, /*forceRefresh=*/true)๋ก ์ต์ ID ํ ํฐ ๋ฐ๊ธ/api/auth/session์ POST โ ์๋ฒ๊ฐ ์ธ์ ์ฟ ํค ๋ฐ๊ธ- ์ดํ Server Action/SSR์์ ์๋ ์ธ์ฆ๋จ(์ฟ ํค ๊ธฐ๋ฐ)
๋ก๊ทธ์์ ์ ์ธ์ ์ญ์
HeaderAuth.tsx์ ๋ก๊ทธ์์ ๋ฒํผ์์:
// ๋ก๊ทธ์์ ํธ๋ค๋ฌ (์๋ฒ ์ธ์
+ ํด๋ผ์ด์ธํธ ๋์ ๋ก๊ทธ์์)
const handleLogout = async () => {
try {
// 1๏ธโฃ ์๋ฒ ์ธ์
์ฟ ํค ์ญ์
await fetch('/api/auth/session', { method: 'DELETE' });
// 2๏ธโฃ ํด๋ผ์ด์ธํธ Firebase Auth ๋ก๊ทธ์์
await signOut(auth);
// 3๏ธโฃ ์๋ก๊ณ ์นจ (์ํ ๋ฐ์)
window.location.href = '/';
} catch (e) {
console.error('๋ก๊ทธ์์ ์คํจ:', e);
}
};
<button
onClick={handleLogout}
style={{
padding: '6px 10px',
border: '1px solid #ddd',
borderRadius: 8,
background: 'white',
}}
>
๋ก๊ทธ์์
</button>
์ด๋ ๊ฒ ํ๋ฉด ํด๋ผ์ด์ธํธ/์๋ฒ ๋ชจ๋ ๋์ผํ ์ธ์ฆ ์ํ๋ฅผ ๊ณต์ ํฉ๋๋ค.
๋ก๊ทธ์์
/api/auth/sessionDELETE๋ก ์ฟ ํค ์ญ์ auth.signOut()์ผ๋ก ํด๋ผ์ด์ธํธ ์ธ์ ์ข ๋ฃ- ์๋ก๊ณ ์นจ์ผ๋ก UI ๋๊ธฐํ
3) Server Actions: ์์ ํ CRUD ๊ตฌํ
(1) ์ก์ ๋ชจ๋ ์์ฑ
app/actions/postActions.ts
'use server';
import { adminAuth, adminDb } from '@/lib/firebase.admin';
import { cookies } from 'next/headers';
async function requireUid() {
const cookieStore = await cookies(); // Next 15: ๋ฐ๋์ await
const session = cookieStore.get('__session')?.value;
if (!session) throw new Error('UNAUTHORIZED');
const decoded = await adminAuth.verifySessionCookie(session, true);
return decoded.uid;
}
/** ๊ธ ์์ฑ (๋จ์ผ ํ๋ผ๋ฏธํฐ) */
export async function createPostAction(formData: FormData) {
try {
const uid = await requireUid();
const title = (formData.get('title') as string)?.trim();
const content = (formData.get('content') as string)?.trim() || '';
const isPublic = formData.get('isPublic') === 'on';
if (!title) throw new Error('์ ๋ชฉ์ ์
๋ ฅํ์ธ์.');
const docRef = await adminDb.collection('posts').add({
uid,
title,
content,
isPublic,
thumbUrl: null,
thumbPath: null,
createdAt: new Date(),
updatedAt: new Date(),
});
return { ok: true, id: docRef.id };
} catch (e: any) {
return { ok: false, message: e?.message ?? '๊ธ ์์ฑ ์คํจ' };
}
}
/** ๊ธ ์์ */
export async function updatePostAction(formData: FormData) {
try {
const uid = await requireUid();
const id = (formData.get('id') as string)?.trim();
const title = (formData.get('title') as string)?.trim();
const content = (formData.get('content') as string)?.trim() || '';
const isPublic = formData.get('isPublic') === 'on';
if (!id) throw new Error('ID ๋๋ฝ');
if (!title) throw new Error('์ ๋ชฉ์ ์
๋ ฅํ์ธ์.');
const ref = adminDb.collection('posts').doc(id);
const snap = await ref.get();
if (!snap.exists) throw new Error('NOT_FOUND');
if (snap.get('uid') !== uid) throw new Error('FORBIDDEN');
await ref.update({ title, content, isPublic, updatedAt: new Date() });
return { ok: true };
} catch (e: any) {
return { ok: false, message: e?.message ?? '๊ธ ์์ ์คํจ' };
}
}
/** ๊ธ ์ญ์ */
export async function deletePostAction(formData: FormData) {
try {
const uid = await requireUid();
const id = (formData.get('id') as string)?.trim();
if (!id) throw new Error('ID ๋๋ฝ');
const ref = adminDb.collection('posts').doc(id);
const snap = await ref.get();
if (!snap.exists) throw new Error('NOT_FOUND');
if (snap.get('uid') !== uid) throw new Error('FORBIDDEN');
await ref.delete();
return { ok: true };
} catch (e: any) {
return { ok: false, message: e?.message ?? '๊ธ ์ญ์ ์คํจ' };
}
}
ํต์ฌ ์์ด๋์ด
- Server Actions์์๋ง Firestore๋ฅผ ์กฐ์ํ๊ณ , ํญ์ ์ธ์ ์ฟ ํค๋ก ์ฌ์ฉ์ UID๋ฅผ ๊ฒ์ฆํฉ๋๋ค.
requireUid() ๋์
cookies()์์__session์ฟ ํค ์ถ์ถadminAuth.verifySessionCookie(session, true)๋ก ์ ํจ์ฑ/๋ง๋ฃ ํ์ธ- ๊ฒ์ฆ ์ฑ๊ณต ์
uid๋ฐํ, ์คํจ ์ ์๋ฌ(UNAUTHORIZED)
๊ฐ ์ก์ ์ ๊ณตํต ๋ณด์ ํจํด
- ์๋ฒ์์ FormData ์ฌ๊ฒ์ฆ(ํ์๊ฐ/๊ธธ์ด/ํ์) โ ์ ๋ขฐํ ์ ์๋ ์ ๋ ฅ ๋ฐฉ์ด
- ๋ฌธ์ ์์ /์ญ์ ์:
- ๋์ ๋ฌธ์ ์กฐํ โ ์กด์ฌ ์ฌ๋ถ ํ์ธ
- ๋ฌธ์์
uid์ ํ์ฌuid๊ฐ ์ผ์นํ๋์ง ๊ฒ์ฌ(์์ ์๋ง ํ์ฉ)
๋ฐํ๊ฐ ํจํด
- ํญ์
{ ok: boolean, message?: string, id?: string }ํํ๋ก ์๋ฌ/์ฑ๊ณต ๊ตฌ๋ถ - ํด๋ผ์ด์ธํธ๋ ํด๋น ๊ฐ์ ๋ฐ์ UI ํผ๋๋ฐฑ/๋ฆฌ๋ค์ด๋ ํธ ์ฒ๋ฆฌ
ํ
Next 15 ๊ธฐ์ค
cookies()๋ await ํ์ํฉ๋๋ค(์ฌ์ฉํ์ ์ฝ๋์ฒ๋ผ). ๋ฒ์ ๋ณ ์ฐจ์ด๋ฅผ ์ธ์งํ์ธ์.
4) Form์์ Server Actions ํธ์ถํ๊ธฐ
(1) ์ ๊ธ ์์ฑ ํ์ด์ง (์๋ฒ์ก์ ๋ฒ์ )
app/posts/new/page.tsx
import { createPostAction } from '@/app/actions/postActions';
import { redirect } from 'next/navigation';
export const dynamic = 'force-dynamic';
export default function NewPostPage() {
// โ
๋จ์ผ ํ๋ผ๋ฏธํฐ(ํผ๋ฐ์ดํฐ)๋ง ๋ฐ๋ ์๋ฒ ํจ์
async function handleAction(formData: FormData) {
'use server';
const res = await createPostAction(formData);
if (!res.ok) {
console.error(res.message);
return;
}
redirect(`/posts/${res.id}`);
}
return (
<main style={{ maxWidth: 720, margin: '40px auto', padding: 16 }}>
<h1>์ ๊ธ ์์ฑ (Server Action)</h1>
<form action={handleAction} style={{ display: 'grid', gap: 12 }}>
<input
name='title'
placeholder='์ ๋ชฉ'
style={{ padding: 10, border: '1px solid #ddd', borderRadius: 8 }}
/>
<textarea
name='content'
placeholder='๋ด์ฉ(์ ํ)'
rows={8}
style={{ padding: 10, border: '1px solid #ddd', borderRadius: 8 }}
/>
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type='checkbox' name='isPublic' defaultChecked />
๊ณต๊ฐ๊ธ๋ก ๋ฑ๋ก
</label>
<button type='submit' style={{ padding: '10px 12px', borderRadius: 8 }}>
๋ฑ๋ก
</button>
</form>
</main>
);
}
์ค์: ์ด ํ์ด์ง๋ก ์ค๊ธฐ ์ ์ /api/auth/session์ด ์ค์ ๋์ด ์์ด์ผ ํฉ๋๋ค.
(๋ก๊ทธ์ธ ํ ์ธ์ ์ฟ ํค๋ฅผ ์ธํ ํ๋ ๊ณผ์ )
ํ๋ฆ
<form action={handleAction}>์ ์๋ฒ ์ธ๋ผ์ธ ํจ์๋ฅผ ์ฐ๊ฒฐ- ์๋ฒ์์
createPostAction(formData)ํธ์ถ โ ์ฑ๊ณต ์redirect('/posts/{id}')
์ ์ข์์?
- ๋คํธ์ํฌ/๋ณด์ ๋จ์ํ: ๋ณ๋ fetch/ํค๋ ์์ด ์ฟ ํค๋ก ์ธ์ฆ ์ ํ
- ์ฝ๋ ๋ถ๊ธฐ ์ต์ํ: ํผ ์ ์ถ = ์๋ฒ ์คํ = ๋ฆฌ๋ค์ด๋ ํธ
(2) ์์ธ ํ์ด์ง์์ ์์ /์ญ์ (์๋ฒ์ก์ ๋ฒ์ )
app/posts/[id]/page.tsx (ํต์ฌ๋ง ๋ฐ์ท)
import { adminDb } from '@/lib/firebase.admin';
import { updatePostAction, deletePostAction } from '@/app/actions/postActions';
import { redirect } from 'next/navigation';
export const dynamic = 'force-dynamic';
export default async function PostDetail({
params,
}: {
params: { id: string };
}) {
const snap = await adminDb.collection('posts').doc(params.id).get();
if (!snap.exists) return <main>์กด์ฌํ์ง ์๋ ๊ธ์
๋๋ค.</main>;
const post = { id: snap.id, ...(snap.data() as any) };
async function onUpdate(formData: FormData) {
'use server';
formData.set('id', post.id);
const res = await updatePostAction(formData);
if (!res.ok) {
console.error(res.message);
return;
}
// ํ์ํ๋ฉด revalidatePath('/posts') ๋ฑ ์ถ๊ฐ
}
async function onDelete(formData: FormData) {
'use server';
formData.set('id', post.id);
const res = await deletePostAction(formData);
if (!res.ok) {
console.error(res.message);
return;
}
redirect('/posts');
}
return (
<main style={{ maxWidth: 720, margin: '40px auto', padding: 16 }}>
<h1>{post.title}</h1>
{post.content && <p style={{ whiteSpace: 'pre-wrap' }}>{post.content}</p>}
<form
action={onUpdate}
style={{ display: 'grid', gap: 8, marginTop: 24 }}
>
<input name='title' defaultValue={post.title} />
<textarea name='content' defaultValue={post.content} rows={6} />
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type='checkbox'
name='isPublic'
defaultChecked={post.isPublic}
/>
๊ณต๊ฐ๊ธ
</label>
<button type='submit'>์์ </button>
</form>
<form action={onDelete} style={{ marginTop: 12 }}>
<button
type='submit'
style={{ border: '1px solid #f33', color: '#f33' }}
>
์ญ์
</button>
</form>
</main>
);
}
ํ๋ฆ
- ์๋ฒ ์ปดํฌ๋ํธ์์ ๋ฌธ์ ์ค๋
์ท
adminDb๋ก SSR ์์ ์ ์ป์ - ์์ /์ญ์
<form action={onUpdate|onDelete}>๋ก ๊ฐ๊ฐ ์๋ฒ์ก์ ํธ์ถ - ์์ ์ ์ฑ๊ณต ํ **์ฌ๊ฒ์ฆ(revalidatePath)**์ด๋ ๋๊ด์ ์ ๋ฐ์ดํธ ํจํด์ ๋ถ์ด๋ฉด UX ์ ๊ทธ๋ ์ด๋
- ์ญ์ ๋ ์ฑ๊ณต ์
redirect('/posts')
ํ
๋ชฉ๋ก ์บ์๋ฅผ ์ฐ๋ ๊ฒฝ์ฐ, ์์ /์ญ์ ํ
revalidatePath('/posts')๋ฅผ ํธ์ถํด SSR ์บ์ ๊ฐฑ์ ์ ๋ณด์ฅํ์ธ์.
5) ํผ ์ ํจ์ฑ & ์๋ฌ ํ์(๊ฐ๋จ ํจํด)
Server Action์ ๊ฐ์ฒด๋ฅผ ๋ฐํํ์ฌ ํด๋ผ์ด์ธํธ์์ ์๋ฌ๋ฅผ ํ์ํ ์ ์์ต๋๋ค.
์ ์์์ฒ๋ผ { error: string }์ ๋ฆฌํดํ๊ณ , ํด๋ผ์ด์ธํธ์์ ์กฐ๊ฑด๋ถ๋ก ๋ณด์ฌ์ฃผ๋ฉด ๋ฉ๋๋ค.
์ข ๋ ์ ๊ตํ๊ฒ ํ๋ ค๋ฉด ํ๋๋ณ ์๋ฌ๋ฅผ { fieldErrors: { title?: string, content?: string } }๋ก ๋ด๋ ค ์ฃผ์ธ์.
์์ฐํ๊ฒฝ์์ zod๋ก ์คํค๋ง ๊ฒ์ฆ์ ๋์ ํ๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.
6) ์ฒดํฌ๋ฆฌ์คํธ
- ๋ก๊ทธ์ธ/ํ์๊ฐ์
์ฑ๊ณต ์งํ
/api/auth/session์ POST ํธ์ถ๋ก ์ธ์ ์ฟ ํค ์ค์ -
HeaderAuth๋ก๊ทธ์์ ์/api/auth/sessionDELETE +auth.signOut() -
createPostAction / updatePostAction / deletePostAction์์ UID ๊ฒ์ฆ ์ฑ๊ณต - ํด๋ผ์ด์ธํธ์์ form action์ผ๋ก Server Action ํธ์ถ ์ ์ ์ ๋์
๐ฌ ๋๊ธ
โป ๋ก๊ทธ์ธ ํ ๋๊ธ์ ์์ฑํ ์ ์์ต๋๋ค.