Syw.Frontend

๐Ÿš€ Next.js + Firebase๋กœ ๋งŒ๋“œ๋Š” ํ’€์Šคํƒ ์›น์•ฑ ์‹ค์ „ ํ”„๋กœ์ ํŠธ

1๋‹จ๊ณ„. Next.js + Firebase ์‹ค์ „ ์•ฑ

1-5. Server Actions๋กœ ์•ˆ์ „ํ•œ CRUD

๐ŸŽฏ ๋ชฉํ‘œ

  1. Firebase Admin SDK ์ดˆ๊ธฐํ™” (์„œ๋ฒ„ ์ „์šฉ)
  2. ์„ธ์…˜์ฟ ํ‚ค ๋ฐฉ์‹ ๋กœ๊ทธ์ธ(ํด๋ผ์ด์–ธํŠธ ID ํ† ํฐ โ†’ ์„œ๋ฒ„ ์„ธ์…˜ ์ฟ ํ‚ค)
  3. Server Actions์—์„œ UID ๊ฒ€์ฆ ํ›„ Firestore ์กฐ์ž‘
  4. 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๊ฐœ ๊ฐ’์„ ํ™˜๊ฒฝ๋ณ€์ˆ˜๋กœ ๋“ฑ๋กํ•˜์„ธ์š”.

ํ‚ค ์ƒ์„ฑ์„ ํ•˜๋ฉด json ํŒŒ์ผ์„ ๋‹ค์šด๋กœ๋“œํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๊ทธ ํŒŒ์ผ์„ ์—ด์–ด์„œ ํ™˜๊ฒฝ๋ณ€์ˆ˜๋ฅผ ์ฑ„์›Œ์ค๋‹ˆ๋‹ค.


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 }),
});
  • ์ตœ์ข… ์ ์šฉ ์†Œ์Šค๋Š” ์ €์žฅ์†Œ ๋ธŒ๋žœ์น˜๋ฅผ ์ฐธ๊ณ ํ•˜์„ธ์š”.

ํ๋ฆ„

  1. Firebase Auth๋กœ ๋กœ๊ทธ์ธ ์™„๋ฃŒ
  2. getIdToken(currentUser, /*forceRefresh=*/true)๋กœ ์ตœ์‹  ID ํ† ํฐ ๋ฐœ๊ธ‰
  3. /api/auth/session์— POST โ†’ ์„œ๋ฒ„๊ฐ€ ์„ธ์…˜์ฟ ํ‚ค ๋ฐœ๊ธ‰
  4. ์ดํ›„ 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>

์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ํด๋ผ์ด์–ธํŠธ/์„œ๋ฒ„ ๋ชจ๋‘ ๋™์ผํ•œ ์ธ์ฆ ์ƒํƒœ๋ฅผ ๊ณต์œ ํ•ฉ๋‹ˆ๋‹ค.

๋กœ๊ทธ์•„์›ƒ

  1. /api/auth/session DELETE๋กœ ์ฟ ํ‚ค ์‚ญ์ œ
  2. auth.signOut()์œผ๋กœ ํด๋ผ์ด์–ธํŠธ ์„ธ์…˜ ์ข…๋ฃŒ
  3. ์ƒˆ๋กœ๊ณ ์นจ์œผ๋กœ 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() ๋™์ž‘

  1. cookies()์—์„œ __session ์ฟ ํ‚ค ์ถ”์ถœ
  2. adminAuth.verifySessionCookie(session, true)๋กœ ์œ ํšจ์„ฑ/๋งŒ๋ฃŒ ํ™•์ธ
  3. ๊ฒ€์ฆ ์„ฑ๊ณต ์‹œ 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/session DELETE + auth.signOut()
  • createPostAction / updatePostAction / deletePostAction์—์„œ UID ๊ฒ€์ฆ ์„ฑ๊ณต
  • ํด๋ผ์ด์–ธํŠธ์—์„œ form action์œผ๋กœ Server Action ํ˜ธ์ถœ ์‹œ ์ •์ƒ ๋™์ž‘

์‹ค์Šต์ €์žฅ์†Œ

GitHub - heroyooi/nextjs-firebase at ch1_5

๐Ÿ’ฌ ๋Œ“๊ธ€

    โ€ป ๋กœ๊ทธ์ธ ํ›„ ๋Œ“๊ธ€์„ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.