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์ ํ๋ก์ ํธ ๋ฑ๋ก

.png?alt=media&token=da309844-5567-4d2b-943c-4f9b18785dcb)
(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')๋ก ์ฒ๋ฆฌ ์ค์ด๋ผ ๋ ๋ฐฉ์ ๋ชจ๋ ํธํ๋ฉ๋๋ค.
.png?alt=media&token=0ce8f90c-e09b-47b5-b1a7-e84b49b12537)
Vercel ์์๋ ์ ์ฅ์์์ ํ์ธ๋๋ ํ๋ ์์ํฌ๋ค์ ์๋์ผ๋ก ์ธ์ํฉ๋๋ค. Framework Preset์ Next.js๋ก ์๋์ผ๋ก ์กํ๋ฉฐ ํ๊ฒฝ ๋ณ์๋ค๋ง ์ธํ ํด์ ๋ฐฐํฌํด์ค๋๋ค.
.png?alt=media&token=5b8495b4-f6be-420d-b6e6-b06d42626482)
๋ฐฐํฌ์ ์๋ฌ๊ฐ ๋๋ฉด ์์ค๋ฅผ ๊ณ ์ณ์ฃผ์ด์ผ ํฉ๋๋ค.
์ด๊ฑด 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๋ฅผ ๋นํ์ฑํํ๊ณ ๋ฐฐํฌ๋ฅผ ์๋ํ๋ฉด ๋ค์๊ณผ ๊ฐ์ ์๋ฌ๋ก ๋ฐ๋๋๋ค.
.png?alt=media&token=411444aa-efda-4fe2-907e-a6eae3869291)
์ด ๋ถ๋ถ์ 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์ ๋ฐฐํฌ๊ฐ ์ด๋ฃจ์ด์ง๋ฉฐ ๋ฐฐํฌ ์ฑ๊ณต์ ๋ค์๊ณผ ๊ฐ์ ํ๋ฉด์ ํ์ธํ์ค ์ ์์ต๋๋ค.
.png?alt=media&token=ad9010d7-c4f5-45e9-93c7-b2687c9166bd)
.png?alt=media&token=33f3f40d-a600-4be9-8b4b-9bf3eb84dd32)
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 ์ค์ ํ๊ณ ๋ก์ปฌ ์๋ฒ์์ ๋น๋๋ฅผ ์คํํ๋ฉด ๋ค์๊ณผ ๊ฐ์ ๋ธ๋ผ์ฐ์ ์ฐฝ์ด ๋จ๋ ๊ฒ์ ํ์ธํ ์ ์์ต๋๋ค.
.png?alt=media&token=31de5522-ff1e-49c5-a9ec-cf99f104c7b1)
.png?alt=media&token=703dce90-4ffc-4ac0-8d1f-177aa611de2b)
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์ค์ ์๋ฃ - ๋ชฉ๋กยท์์ธ ํ์ด์ง ์บ์ ์ ์ฑ
๊ฒฐ์ (
dynamicorrevalidate) - ๋ฐฐํฌ ํ
/login โ /posts/new โ /posts/[id]์ ์ -
/meํ์ด์ง์์ UID ํ์ธ OK
์ค์ต์ ์ฅ์
GitHub - heroyooi/nextjs-firebase at ch1_6
๐ฌ ๋๊ธ
โป ๋ก๊ทธ์ธ ํ ๋๊ธ์ ์์ฑํ ์ ์์ต๋๋ค.