Syw.Frontend

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

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

1-6. ์ตœ์ ํ™” & ๋ฐฐํฌ(Vercel) + SEO/์ด๋ฏธ์ง€/์บ์‹œ

0) ๋ฐฐํฌ ์ „ ํ•„์ˆ˜ ์ ๊ฒ€

  • firebase.admin.ts ์‚ฌ์šฉ ์ค‘ โ‡’ ํ™˜๊ฒฝ๋ณ€์ˆ˜ 3์ข…(Project ID / Client Email / Private Key) ์ค€๋น„

    ์™œ Admin ํ™˜๊ฒฝ๋ณ€์ˆ˜ 3์ข…์ด ํ•„์š”ํ•˜๋‚˜์š”?

    • firebase-admin์€ ์„œ๋ฒ„ ์ „์šฉ ์ž๊ฒฉ์ฆ๋ช…์œผ๋กœ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.
      • projectId, clientEmail, privateKey๊ฐ€ ์ •ํ™•ํ•ด์•ผ verifySessionCookie, Firestore/Storage ์„œ๋ฒ„ ์•ก์„ธ์Šค๊ฐ€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.
  • ๋กœ๊ทธ์ธ ํ›„ ์„ธ์…˜์ฟ ํ‚ค API(/api/auth/session)๊ฐ€ ๋™์ž‘ํ•˜๋Š”์ง€ ๋กœ์ปฌ์—์„œ ํ™•์ธ

    ์„ธ์…˜์ฟ ํ‚ค API ์‚ฌ์ „ ์ ๊ฒ€ ํฌ์ธํŠธ

    • ๋กœ์ปฌ์—์„œ ๋กœ๊ทธ์ธ ์„ฑ๊ณต โ†’ getIdToken() โ†’ /api/auth/session์— POST ํ›„, ๋ธŒ๋ผ์šฐ์ € ์ฟ ํ‚ค์— __session์ด ์ƒ์„ฑ๋˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.

    • ๊ฐœ๋ฐœ ํ™˜๊ฒฝ(http)์—์„œ๋Š” secure: true ์ฟ ํ‚ค๊ฐ€ ์ €์žฅ๋˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

      โ†’ ๋ณดํ†ต secure: process.env.NODE_ENV === 'production'์œผ๋กœ ๋ถ„๊ธฐํ•ฉ๋‹ˆ๋‹ค.

  • next/image๋ฅผ ์“ธ ์˜ˆ์ •์ด๋ฉด Storage ๋„๋ฉ”์ธ ํ—ˆ์šฉ ์„ค์ • ํ•„์š”

    next/image ์›๊ฒฉ ๋„๋ฉ”์ธ ํ—ˆ์šฉ ์ด์œ 

    • next/image๋Š” ๋„๋ฉ”์ธ ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค.
      • Firebase Storage Web URL(firebasestorage.googleapis.com)

      • Admin SDK ์„œ๋ช… URL(storage.googleapis.com)

      • ๊ตฌ๊ธ€ ์‚ฌ์šฉ์ž ์ด๋ฏธ์ง€(.googleusercontent.com)

        ๋ฅผ ํ—ˆ์šฉํ•˜์ง€ ์•Š์œผ๋ฉด ์ด๋ฏธ์ง€๊ฐ€ ์ฐจ๋‹จ๋ฉ๋‹ˆ๋‹ค.


1) Vercel ๋ฐฐํฌ

(0) ํ”„๋กœ์ ํŠธ ์„ธํŒ…

์ง„ํ–‰์ค‘์ธ ์ €์žฅ์†Œ Vercel์— ํ”„๋กœ์ ํŠธ ๋“ฑ๋ก

(1) ํ™˜๊ฒฝ๋ณ€์ˆ˜ ๋“ฑ๋ก

Vercel โ†’ Project โ†’ Settings โ†’ Environment Variables ์— ์•„๋ž˜ ํ‚ค ์ถ”๊ฐ€(Production / Preview / Development ๋ชจ๋‘):

# Admin SDK (์„œ๋ฒ„ ์ „์šฉ, NEXT_PUBLIC ๊ธˆ์ง€)
FIREBASE_ADMIN_PROJECT_ID=your-project-id
FIREBASE_ADMIN_CLIENT_EMAIL=firebase-adminsdk-xxxx@your-project-id.iam.gserviceaccount.com
FIREBASE_ADMIN_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nABC...XYZ\n-----END PRIVATE KEY-----\n

# ์„ธ์…˜ ์ˆ˜๋ช…(์ผ)
AUTH_SESSION_DAYS=7

# ํด๋ผ์ด์–ธํŠธ SDK
NEXT_PUBLIC_FIREBASE_API_KEY=...
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=...
NEXT_PUBLIC_FIREBASE_PROJECT_ID=...
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=...
NEXT_PUBLIC_FIREBASE_APP_ID=...
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=...

Private Key ์ค„๋ฐ”๊ฟˆ: \n ๊ทธ๋Œ€๋กœ ๋„ฃ์–ด๋„ ๋˜๊ณ , Vercel โ€œRawโ€ ํ† ๊ธ€๋กœ ์‹ค์ œ ๊ฐœํ–‰์„ ๋„ฃ์–ด๋„ ๋ฉ๋‹ˆ๋‹ค.

์ฝ”๋“œ๋Š” replace(/\\n/g, '\n')๋กœ ์ฒ˜๋ฆฌ ์ค‘์ด๋ผ ๋‘ ๋ฐฉ์‹ ๋ชจ๋‘ ํ˜ธํ™˜๋ฉ๋‹ˆ๋‹ค.

Vercel ์—์„œ๋Š” ์ €์žฅ์†Œ์—์„œ ํ™•์ธ๋˜๋Š” ํ”„๋ ˆ์ž„์›Œํฌ๋“ค์„ ์ž๋™์œผ๋กœ ์ธ์‹ํ•ฉ๋‹ˆ๋‹ค. Framework Preset์€ Next.js๋กœ ์ž๋™์œผ๋กœ ์žกํžˆ๋ฉฐ ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋“ค๋งŒ ์„ธํŒ…ํ•ด์„œ ๋ฐฐํฌํ•ด์ค๋‹ˆ๋‹ค.

๋ฐฐํฌ์‹œ ์—๋Ÿฌ๊ฐ€ ๋‚˜๋ฉด ์†Œ์Šค๋ฅผ ๊ณ ์ณ์ฃผ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๊ฑด Vercel ๋นŒ๋“œ ์ค‘ TypeScript + ESLint ๊ทœ์น™(no-explicit-any) ๋•Œ๋ฌธ์— ๋นŒ๋“œ๊ฐ€ ์‹คํŒจํ•œ ์ผ€์ด์Šค์ž…๋‹ˆ๋‹ค. ๋ณธ ๊ฐ•์ขŒ๋Š” Next.js ๋ฐ Firebase๋ฅผ ๋ฐฐ์šฐ๋Š” ๊ฒƒ์ด ๋ชฉ์ ์ด๋ฏ€๋กœ ESLint ์—๋Ÿฌ๋Š” ๋น„ํ™œ์„ฑํ™”ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

Next.js ์„ค์ •(next.config.ts)์— ์•„๋ž˜ ์ถ”๊ฐ€:

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  eslint: {
    ignoreDuringBuilds: true, // ๐Ÿš€ ESLint ๋ฌด์‹œํ•˜๊ณ  ๋นŒ๋“œ ํ†ต๊ณผ
  },
};

export default nextConfig;

ESLint๋ฅผ ๋น„ํ™œ์„ฑํ™”ํ•˜๊ณ  ๋ฐฐํฌ๋ฅผ ์‹œ๋„ํ•˜๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์—๋Ÿฌ๋กœ ๋ฐ”๋€๋‹ˆ๋‹ค.

์ด ๋ถ€๋ถ„์€ Promise๋กœ ์ „๋‹ฌ๋˜๋Š” ํƒ€์ž…์ด ๊ธฐ๋ณธ๊ฐ’์ด๋ผ์„œ ์ง€๊ธˆ์ฒ˜๋Ÿผ { params: { id: string } }๋กœ ์„ ์–ธํ•˜๋ฉด ์—๋Ÿฌ๊ฐ€ ๋‚ฉ๋‹ˆ๋‹ค. ์ด ๋ถ€๋ถ„๋งŒ ์ˆ˜์ •ํ•ด์„œ ๋‹ค์‹œ ๋ฐฐํฌ๋ฅผ ์‹œ๋„ํ•ฉ๋‹ˆ๋‹ค. params๋ฅผ Promise๋กœ ๋ฐ›๊ณ  await ํ•ฉ๋‹ˆ๋‹ค.

app/posts/[id]/page.tsx

// ์ƒ๋žต...

export default async function PostDetail({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  const snap = await adminDb.collection('posts').doc(id).get();
  
	// ์ƒ๋žต...
}

์œ„ ์ฝ”๋“œ์™€ ๊ฐ™์ด ๋ณ€๊ฒฝํ•˜๊ณ  ๋ฉ”์ธ ๋ธŒ๋žœ์น˜์— Push ํ•˜๋ฉด ์ž๋™์œผ๋กœ Vercel์— ๋ฐฐํฌ๊ฐ€ ์ด๋ฃจ์–ด์ง€๋ฉฐ ๋ฐฐํฌ ์„ฑ๊ณต์‹œ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ํ™”๋ฉด์„ ํ™•์ธํ•˜์‹ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

nextjs-firebase-flame.vercel.app

์œ„ ๋ฐฐํฌ URL์€ ์ž์œ ๋กญ๊ฒŒ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๋ถ€๋ถ„์€ ์ถ”ํ›„์— ๋‹ค๋ฃจ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.


2) ์ด๋ฏธ์ง€ ์ตœ์ ํ™” (next/image + Storage ํ˜ธํ™˜)

(1) app/posts/new/page.tsx Server Action ๊ธฐ๋ฐ˜์œผ๋กœ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ๊ตฌํ˜„

ํ™˜๊ฒฝ๋ณ€์ˆ˜๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”(๋กœ์ปฌ .env.local + Vercel ํ”„๋กœ์ ํŠธ ์„ค์ • ๋‘˜ ๋‹ค):

FIREBASE_STORAGE_BUCKET=your-project-id.appspot.com

Firebase ์ฝ˜์†” โ†’ ํ”„๋กœ์ ํŠธ ์„ค์ • โ†’ ์ผ๋ฐ˜ โ†’ ์Šคํ† ๋ฆฌ์ง€ ๋ฒ„ํ‚ท ์ด๋ฆ„ ํ™•์ธ

๋ณดํ†ต ํ”„๋กœ์ ํŠธID.appspot.com ํ˜•ํƒœ์ž…๋‹ˆ๋‹ค.

lib\firebase.admin.ts

import { cert, getApps, initializeApp } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
import { getFirestore } from 'firebase-admin/firestore';
import { getStorage } from 'firebase-admin/storage';

const STORAGE_BUCKET =
  process.env.FIREBASE_STORAGE_BUCKET ?? // ์„œ๋ฒ„์šฉ ๊ถŒ์žฅ
  process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET ?? // ์ž„์‹œ fallback
  '';

if (!STORAGE_BUCKET || STORAGE_BUCKET.startsWith('gs://')) {
  // ์„œ๋ฒ„ ์ฝ˜์†”์—์„œ ๋ฐ”๋กœ ๋ณด์ด๊ฒŒ ๊ฒฝ๊ณ 
  console.warn('[firebase.admin] Invalid STORAGE_BUCKET:', STORAGE_BUCKET);
}

const adminApp = getApps().length
  ? getApps()[0]!
  : 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'
        )!,
      }),
      // โœ… ๊ธฐ๋ณธ ๋ฒ„ํ‚ท์„ ๋ฐ˜๋“œ์‹œ ์ง€์ •
      storageBucket: STORAGE_BUCKET || undefined,
    });

export const adminAuth = getAuth(adminApp);
export const adminDb = getFirestore(adminApp);
export const adminStorage = getStorage(adminApp);

app\actions\postActions.ts

'use server';

import { adminAuth, adminDb, adminStorage } 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;
}

const BUCKET =
  process.env.FIREBASE_STORAGE_BUCKET ??
  process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET!;

/** ๊ธ€ ์ƒ์„ฑ (๋‹จ์ผ ํŒŒ๋ผ๋ฏธํ„ฐ) */
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';
    const file = formData.get('file') as File | null; // โ† input name="file"

    if (!title) throw new Error('์ œ๋ชฉ์„ ์ž…๋ ฅํ•˜์„ธ์š”.');

    const ref = await adminDb.collection('posts').add({
      uid,
      title,
      content,
      isPublic,
      thumbUrl: null,
      thumbPath: null,
      createdAt: new Date(),
      updatedAt: new Date(),
    });

    let thumbUrl: string | null = null;
    let thumbPath: string | null = null;

    if (file && file.size > 0) {
      const fileName = `${Date.now()}-${file.name}`;
      thumbPath = `users/${uid}/posts/${ref.id}/${fileName}`;

      // File โ†’ Buffer
      const buffer = Buffer.from(await file.arrayBuffer());

      // ์—…๋กœ๋“œ
      const bucket = adminStorage.bucket(BUCKET); // ๊ธฐ๋ณธ ๋ฒ„ํ‚ท
      const gcsFile = bucket.file(thumbPath);
      await gcsFile.save(buffer, {
        contentType: file.type || 'application/octet-stream',
        resumable: false,
        public: false, // ๊ธฐ๋ณธ ๋น„๊ณต๊ฐœ
        metadata: { cacheControl: 'public, max-age=31536000, immutable' },
      });

      // ์‚ฌ์ธ๋“œ URL ์ƒ์„ฑ(์ฝ๊ธฐ์šฉ) โ€” ํ•„์š”์‹œ ๋งŒ๋ฃŒ ์—ฐ์žฅ
      const [signedUrl] = await gcsFile.getSignedUrl({
        action: 'read',
        expires: Date.now() + 1000 * 60 * 60 * 24 * 365, // 1๋…„
      });
      thumbUrl = signedUrl;

      await ref.update({ thumbUrl, thumbPath, updatedAt: new Date() });
    }

    return { ok: true, id: ref.id };
  } catch (e: any) {
    console.error(e);
    return { ok: false, message: e?.message ?? 'CREATE_FAILED' };
  }
}

// ์ƒ๋žต...

app/posts/new/page.tsx

import { createPostAction } from '@/app/actions/postActions';
import { redirect } from 'next/navigation';

export const dynamic = 'force-dynamic';
export const runtime = 'nodejs'; // โœ… ์ถ”๊ฐ€

export default function NewPostPage() {
  // ์ƒ๋žต...

  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>
        <input type='file' name='file' accept='image/*' />{/* โœ… ํŒŒ์ผ ์—…๋กœ๋“œ ํ•„๋“œ */}
        <button type='submit' style={{ padding: '10px 12px', borderRadius: 8 }}>
          ๋“ฑ๋ก
        </button>
      </form>
    </main>
  );
}

src\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: Promise<{ id: string }>;
}) {
  const { id } = await params;

  // ์ƒ๋žต...

  return (
    <main style={{ maxWidth: 720, margin: '40px auto', padding: 16 }}>
      <h1>{post.title}</h1>
      {post.thumbUrl && (
        <p>
          <img src={post.thumbUrl} alt='' />
        </p>
      )}
      {post.content && <p style={{ whiteSpace: 'pre-wrap' }}>{post.content}</p>}

      {/* ์ƒ๋žต */}
    </main>
  );
}

(1) next.config.ts ๋„๋ฉ”์ธ ํ—ˆ์šฉ

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  eslint: { ignoreDuringBuilds: true },
  images: {
    remotePatterns: [
      // Firebase Storage (Web SDK ์ผ๋ฐ˜ URL)
			{ protocol: 'https', hostname: 'firebasestorage.googleapis.com' },
      // Googleusercontent
      { protocol: 'https', hostname: '**.googleusercontent.com' },
      // Admin SDK Signed URL
      { protocol: 'https', hostname: 'storage.googleapis.com' },
    ],
  },
};

export default nextConfig;

(2) ์ƒ์„ธ/๋ชฉ๋ก ์ด๋ฏธ์ง€ ๊ต์ฒด

import Image from 'next/image';

// ์˜ˆ์‹œ
{post.thumbUrl && (
  <Image src={post.thumbUrl}
    alt=""
    width={720}
    height={420}
    style={{ width: '100%', height: 'auto', borderRadius: 12 }}
    priority
  />
)}
  • ์ƒ์„ธํ•œ ๋‚ด์šฉ์€ ํ•ด๋‹น ๊ฐ•์ขŒ ์ €์žฅ์†Œ ๋ธŒ๋žœ์น˜์—์„œ ํ™•์ธํ•˜์„ธ์š”.

3) SEO: metadata / OG / robots / sitemap

(1) ์ „์—ญ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (app/layout.tsx)

export const metadata = {
  metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'),
  title: {
    default: 'Next.js + Firebase ์‹ค์ „ ์•ฑ',
    template: '%s | Next.js + Firebase',
  },
  description: 'Next.js(App Router)์™€ Firebase๋กœ ๋กœ๊ทธ์ธ/CRUD/์ด๋ฏธ์ง€ ์—…๋กœ๋“œ๊นŒ์ง€ ๊ตฌํ˜„ํ•˜๋Š” ์‹ค์ „ ํ”„๋กœ์ ํŠธ',
  openGraph: {
    type: 'website',
    title: 'Next.js + Firebase ์‹ค์ „ ์•ฑ',
    description: '๋กœ๊ทธ์ธ/CRUD/Storage',
    url: '/',
    images: [{ url: '/og.png', width: 1200, height: 630 }],
  },
};

๋ฐฐํฌ ํ™˜๊ฒฝ์—์„œ NEXT_PUBLIC_SITE_URL(์˜ˆ: https://nextjs-firebase.vercel.app)์„ ๋„ฃ์–ด๋‘๋ฉด ์ •๊ทœํ™”๋œ URL์ด ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค. ์ผ๋‹จ ์‹ค์Šต ์ž๋ฃŒ๋Š” NEXT_PUBLIC_SITE_URL ๊ฐ’์œผ๋กœ Vercel์—์„œ ์ œ๊ณตํ•ด์ค€ https://nextjs-firebase-flame.vercel.app๋ฅผ ๋„ฃ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

(2) ํŽ˜์ด์ง€ ๊ฐœ๋ณ„ ๋ฉ”ํƒ€ (app/posts/[id]/page.tsx)

SSR์—์„œ ๋ฌธ์„œ ํƒ€์ดํ‹€/์š”์•ฝ์„ ๋ฐ˜์˜ํ•˜๋ ค๋ฉด:

export async function generateMetadata({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const snap = await adminDb.collection('posts').doc(id).get();
  if (!snap.exists) return { title: '๊ฒŒ์‹œ๊ธ€ ์—†์Œ' };
  const data = snap.data() as any;
  return {
    title: data.title,
    description: (data.content ?? '').slice(0, 100),
  };
}

(3) robots & sitemap

app/robots.ts

import type { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  const base = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
  return {
    rules: [{ userAgent: '*', allow: '/' }],
    sitemap: `${base}/sitemap.xml`,
  };
}

app/sitemap.ts

import type { MetadataRoute } from 'next';
import { adminDb } from '@/lib/firebase.admin';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const base = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';

  // ๊ณต๊ฐœ ๊ธ€๋งŒ
  const snap = await adminDb.collection('posts').where('isPublic', '==', true).get();

  const posts = snap.docs.map(d => ({
    url: `${base}/posts/${d.id}`,
    lastModified: (d.get('updatedAt')?.toDate?.() as Date) || new Date(),
    changeFrequency: 'weekly' as const,
    priority: 0.7,
  }));

  return [
    { url: base, lastModified: new Date() },
    { url: `${base}/posts`, lastModified: new Date() },
    ...posts,
  ];
}

4) ๋ Œ๋”๋ง/์บ์‹œ ์ „๋žต (App Router)

๋น ๋ฅด๊ฒŒ ์ •๋ฆฌ

  • ์ž์ฃผ ๋ณ€ํ•˜๋Š” ๋ฐ์ดํ„ฐ: ํŽ˜์ด์ง€ ์ƒ๋‹จ์—

    export const dynamic = 'force-dynamic'; // ์„œ๋ฒ„๊ฐ€ ๋งค ์š”์ฒญ ์ƒˆ๋กœ ๊ทธ๋ฆผ
    
  • ์ •์  + ์ฃผ๊ธฐ์  ๊ฐฑ์‹ (ISR):

    export const revalidate = 60; // ์ดˆ ๋‹จ์œ„
    
  • ์„œ๋ฒ„์—์„œ ๋ฐ์ดํ„ฐ ๊ฐฑ์‹  ํ›„ ๋ฆฌ์ŠคํŠธ ๊ฐฑ์‹ :

    ์•ก์…˜์—์„œ revalidatePath('/posts') ํ˜น์€ revalidateTag('posts') ์‚ฌ์šฉ.

์˜ˆ) ๊ธ€ ์ƒ์„ฑ ํ›„ ๋ชฉ๋ก ๊ฐฑ์‹ :

import { revalidatePath } from 'next/cache';

export async function createPostAction(formData: FormData) {
  // ...์ƒ์„ฑ ๋กœ์ง
  revalidatePath('/posts'); // ๋ชฉ๋ก ํŽ˜์ด์ง€ ์บ์‹œ ๋ฌดํšจํ™”
  return { ok: true, id: docRef.id };
}

ํด๋ผ์ด์–ธํŠธ fetch์—” next: { revalidate: n } ๋˜๋Š” cache: 'no-store'๋ฅผ ์ง€์ •ํ•  ์ˆ˜ ์žˆ์ง€๋งŒ, Firestore SDK๋ฅผ ์ง์ ‘ ์“ฐ๋Š” ๊ฒฝ์šฐ์—” ํ•ด๋‹น ์˜ต์…˜์ด ์ ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ด๋Ÿด ๋• ์„œ๋ฒ„์—์„œ ์ง์ ‘ ์ƒˆ๋กœ ๊ทธ๋ฆฌ๊ฑฐ๋‚˜(revalidate/dynamic), ํด๋ผ์ด์–ธํŠธ onSnapshot์œผ๋กœ ์‹ค์‹œ๊ฐ„ ์œ ์ง€๊ฐ€ ๊ฐ€์žฅ ๊ฐ„๋‹จํ•ฉ๋‹ˆ๋‹ค.


5) ํฐํŠธ/์ด๋ฏธ์ง€/๋ฒˆ๋“ค ์ตœ์ ํ™”

(1) ์›นํฐํŠธ (next/font)

app/layout.tsx

import { Noto_Sans_KR } from 'next/font/google';
const noto = Noto_Sans_KR({ subsets: ['latin'], weight: ['400','500','700'], display: 'swap' });

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body className={noto.className}>{children}</body>
    </html>
  );
}

(2) ๋ฒˆ๋“ค ํฌ๊ธฐ ์ ๊ฒ€(์„ ํƒ)

  • ํ•ด๋‹น ๊ณผ์ •์€ ์ƒ๋žตํ•ด๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.
npm i -D @next/bundle-analyzer

next.config.ts

import type { NextConfig } from 'next';
import bundleAnalyzer from '@next/bundle-analyzer';

const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
});

const nextConfig: NextConfig = {
  eslint: { ignoreDuringBuilds: true },
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'firebasestorage.googleapis.com' },
      { protocol: 'https', hostname: '**.googleusercontent.com' },
      { protocol: 'https', hostname: 'storage.googleapis.com' },
    ],
  },
};

export default withBundleAnalyzer(nextConfig);

.env.local

ANALYZE=true

ํ„ฐ๋ฏธ๋„ ์‹คํ–‰:

npm run build

ANALYZE=true ์„ค์ •ํ•˜๊ณ  ๋กœ์ปฌ ์„œ๋ฒ„์—์„œ ๋นŒ๋“œ๋ฅผ ์‹คํ–‰ํ•˜๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋ธŒ๋ผ์šฐ์ € ์ฐฝ์ด ๋œจ๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

1๏ธโƒฃ nodejs.html - ์„œ๋ฒ„ ์‚ฌ์ด๋“œ(Server Components + Server Actions ๋ฒˆ๋“ค)

์ด๊ฑด Next.js ์„œ๋ฒ„ ๋Ÿฐํƒ€์ž„์—์„œ ๋™์ž‘ํ•˜๋Š” ์ฝ”๋“œ (SSR, Server Action, Metadata, Firebase Admin SDK ๋“ฑ) ๋ฅผ ๋ถ„์„ํ•œ ๊ฒฐ๊ณผ์ž…๋‹ˆ๋‹ค.

๐Ÿ” ์ฃผ์š” ๊ด€์ฐฐ ํฌ์ธํŠธ

๊ตฌ๋ถ„ ์„ค๋ช…
390.js (971KB) ์„œ๋ฒ„์šฉ ์ฃผ์š” ์—”ํŠธ๋ฆฌ. Firebase Admin SDK์™€ gRPC ๊ด€๋ จ ์ฝ”๋“œ๊ฐ€ ๋Œ€๋ถ€๋ถ„ ์ฐจ์ง€ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.
@grpc / protobufs / firebase-admin Firebase Admin SDK ๋‚ด๋ถ€ ์˜์กด์„ฑ(gRPC ๊ธฐ๋ฐ˜ ํ†ต์‹  ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ). ์ด ๋ถ€๋ถ„์ด ์ „์ฒด ์„œ๋ฒ„ ๋ฒˆ๋“ค์˜ ์ ˆ๋ฐ˜ ์ด์ƒ์„ ์ฐจ์ง€ํ•ฉ๋‹ˆ๋‹ค.
auth/dist, firestore/dist Firebase Admin SDK์˜ ํ•˜์œ„ ๋ชจ๋“ˆ. ์ธ์ฆ/์Šคํ† ๋ฆฌ์ง€/DB ๊ด€๋ จ ์ฝ”๋“œ.
next/dist/server Next.js ์„œ๋ฒ„ ๋ Œ๋”๋ง ๋กœ์ง. (์ด๊ฑด ํ•„์ˆ˜์ด๋ฏ€๋กœ ์ œ๊ฑฐ ๋ถˆ๊ฐ€)
app/posts/new, app/posts/[id] ์„œ๋ฒ„ ์•ก์…˜(createPostAction, updatePostAction) ๋“ฑ์—์„œ ์ฐธ์กฐ๋˜๋Š” ์ฝ”๋“œ.
์ด ํฌ๊ธฐ ์•ฝ 1.5MB ์ˆ˜์ค€์œผ๋กœ, ๋Œ€๋ถ€๋ถ„ firebase-admin ๊ด€๋ จ ์˜์กด์„ฑ์ž…๋‹ˆ๋‹ค.

2๏ธโƒฃ client.html - ํด๋ผ์ด์–ธํŠธ ๋ฒˆ๋“ค(Client Components)

์ด๊ฑด ๋ธŒ๋ผ์šฐ์ €๋กœ ์ „์†ก๋˜๋Š” ์ฝ”๋“œ ๋ถ„์„ ๊ฒฐ๊ณผ๋กœ, ์‚ฌ์šฉ์ž๊ฐ€ ์‹ค์ œ ๋‹ค์šด๋กœ๋“œ๋ฐ›๋Š” JS ๋ฆฌ์†Œ์Šค๋ฅผ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค.

๐Ÿ” ์ฃผ์š” ๊ด€์ฐฐ ํฌ์ธํŠธ

๊ตฌ๋ถ„ ์„ค๋ช…
react-dom-client.production.js (~170KB) ๋ฆฌ์•กํŠธ ๋ Œ๋”๋Ÿฌ (ํ•„์ˆ˜). ๊ฑฐ์˜ ํ•ญ์ƒ ๊ฐ€์žฅ ํผ.
next/dist/client/router.js (~120KB) Next.js ํด๋ผ์ด์–ธํŠธ ๋ผ์šฐํŒ… ๋กœ์ง. ํ•„์ˆ˜.
firebase (auth, firestore, storage) ์•ฝ 100KB ์ •๋„. ํด๋ผ์ด์–ธํŠธ์—์„œ Firebase SDK๋ฅผ ์ง์ ‘ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ๋•Œ๋ฌธ.
๊ฐ ํŽ˜์ด์ง€(entry modules) /posts/new, /login, /signup, /dashboard ๋“ฑ ํŽ˜์ด์ง€๋ณ„ ์ฒญํฌ ๋ถ„๋ฆฌ ํ™•์ธ๋จ.
์ „์ฒด ํด๋ผ์ด์–ธํŠธ ๋ฒˆ๋“ค ์•ฝ 1MB ์ „ํ›„ (gzip ์‹œ 300~400KB ์ •๋„). ๋ชจ๋ฐ”์ผ ํ™˜๊ฒฝ์—์„œ๋„ ๋ฌด๋‚œํ•œ ์ˆ˜์ค€.

๐Ÿ‘‰ ์š”์•ฝ

  • @firebase/auth, @firebase/firestore ๊ฐ€ ํ•ฉ์ณ์„œ ์•ฝ 100~150KB ์ •๋„ ์ฐจ์ง€ (Firebase SDK ํŠน์„ฑ์ƒ ๋ถˆ๊ฐ€ํ”ผ)
  • Next.js ๋ผ์šฐํ„ฐ ๋ฐ React Core ๊ฐ€ ์•ฝ 300KB
  • ๋‚˜๋จธ์ง€๋Š” ํŽ˜์ด์ง€๋ณ„ entry module ๋ถ„๋ฆฌ๋กœ ์ž˜ ๋‚˜๋‰˜์–ด ์žˆ์Œ
ํ•ญ๋ชฉ ์„œ๋ฒ„(nodejs.html) ํด๋ผ์ด์–ธํŠธ(client.html)
์ฃผ์š” ์šฉ๋„ SSR, Server Actions, Firebase Admin CSR, Firebase Client SDK, UI
๊ฐ€์žฅ ํฐ ๋ชจ๋“ˆ @grpc, firebase-admin, protobufjs react-dom, next/router, firebase/*
์ด ์šฉ๋Ÿ‰(Parsed ๊ธฐ์ค€) ์•ฝ 1.5MB ์•ฝ 1.0MB
๋ณ‘๋ชฉ ์ง€์  Firebase Admin SDK Firebase SDK + React Router
๊ฐœ์„  ๋ฐฉํ–ฅ Dynamic import / Node only import Tree-shaking ์œ ์ง€, dynamic import

6) ๋ฐฐํฌ ํ›„ ํ—ฌ์Šค์ฒดํฌ ํŽ˜์ด์ง€(์„ ํƒ)

์„œ๋ฒ„์—์„œ ์„ธ์…˜ UID๊ฐ€ ์ž˜ ์ฝํžˆ๋Š”์ง€ ํ™•์ธ:

app/me/page.tsx

import { cookies } from 'next/headers';
import { adminAuth } from '@/lib/firebase.admin';

export default async function MePage() {
  const cookieStore = await cookies();
  const session = cookieStore.get('__session')?.value;

  if (!session)
    return <main style={{ padding: 16 }}>๋กœ๊ทธ์ธ ์„ธ์…˜์ด ์—†์Šต๋‹ˆ๋‹ค.</main>;

  try {
    const decoded = await adminAuth.verifySessionCookie(session, true);
    return <main style={{ padding: 16 }}>ํ˜„์žฌ UID: {decoded.uid}</main>;
  } catch {
    return <main style={{ padding: 16 }}>์„ธ์…˜์ด ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.</main>;
  }
}

์ฒดํฌ๋ฆฌ์ŠคํŠธ (ํ•œ ๋ฒˆ์— ์ ๊ฒ€)

  • Vercel ํ™˜๊ฒฝ๋ณ€์ˆ˜ 3์ข…(Admin) + ํด๋ผ ๊ณต๊ฐœ ํ‚ค๋“ค ๋ฐ˜์˜
  • /api/auth/session์—์„œ secure = prod ์ „์šฉ ์ ์šฉ
  • next.config.js์— Storage ์›๊ฒฉ ์ด๋ฏธ์ง€ ๋„๋ฉ”์ธ ํ—ˆ์šฉ
  • metadata/robots/sitemap ์„ค์ • ์™„๋ฃŒ
  • ๋ชฉ๋กยท์ƒ์„ธ ํŽ˜์ด์ง€ ์บ์‹œ ์ •์ฑ… ๊ฒฐ์ •(dynamic or revalidate)
  • ๋ฐฐํฌ ํ›„ /login โ†’ /posts/new โ†’ /posts/[id] ์ •์ƒ
  • /me ํŽ˜์ด์ง€์—์„œ UID ํ™•์ธ OK

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

GitHub - heroyooi/nextjs-firebase at ch1_6

์‹ค์Šต ๋ฐฐํฌ URL

Next.js + Firebase ์‹ค์ „ ์•ฑ

๐Ÿ’ฌ ๋Œ“๊ธ€

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