logo

DowanKim

2. Firebase 설정하는 법

2025년 10월 5일

포트폴리오 사이트

이제 블로그 기능을 구현하고자 합니다.

보통 포트폴리오 사이트는 정적페이지로 배포하며, 게시글은 md 파일을 만들며 커밋하고 배포하는 식으로 하면 쉽게 구현할 수 있고, 서버리스라 문제가 될 요소들도 없을 것입니다.

하지만 저는 파이어베이스를 연결하여 제 아이디 비밀번호로만 admin페이지 접근이 가능하게 하고 이를 기반으로 실제 블로그 사이트들처럼 글들을 관리하고 작성할 수 있는 시스템을 만들고자 합니다.


Next.js 포트폴리오에 Firebase 연결하기: 백엔드 구축과 동적 렌더링 전환

1. Firebase 연결 배경

포트폴리오를 Next.js로 전환한 후, 제가 생각하는 블로그 기능을 추가하기 위해 백엔드가 필요했습니다. 별도 서버 구축 대신 Firebase를 선택한 이유:

  • 빠른 개발: 서버 구축 없이 바로 시작
  • 실시간 데이터베이스: Firestore로 콘텐츠 관리
  • 파일 저장소: Storage로 이미지 관리
  • 인증 시스템: Authentication으로 관리자 인증
  • 무료 티어: 초기 비용 부담 적음
  • 백엔드 구현 실력이 없음(가장 중요;;)

2. Firebase 프로젝트 설정

2.1 Firebase 프로젝트 생성

  1. Firebase Console 접속
  2. "프로젝트 추가" 클릭
  3. 프로젝트 이름 입력 (예: dowankim-portfolio)
  4. Google Analytics 설정 (선택사항입니다)
  5. 프로젝트 생성 완료

2.2 웹 앱 추가

  1. Firebase 프로젝트 대시보드에서 "웹" 아이콘 클릭
  2. 앱 닉네임 입력
  3. Firebase Hosting 설정 (선택사항, 이 프로젝트에서는 사용 안 함)
  4. Firebase SDK 설정 정보 복사

복사된 설정 정보 예시:

const firebaseConfig = { apiKey: "AIzaSy...", authDomain: "dowankim-portfolio.firebaseapp.com", projectId: "dowankim-portfolio", storageBucket: "dowankim-portfolio.appspot.com", messagingSenderId: "123456789", appId: "1:123456789:web:abcdef" }

2.3 Firebase 서비스 활성화

Firestore Database

  1. 왼쪽 메뉴에서 "Firestore Database" 선택
  2. "데이터베이스 만들기" 클릭
  3. 프로덕션 모드 선택 (보안 규칙은 나중에 설정)
  4. 위치 선택 (예: asia-northeast3 - 서울)

Storage

  1. 왼쪽 메뉴에서 "Storage" 선택
  2. "시작하기" 클릭
  3. 보안 규칙은 나중에 설정
  4. 위치 선택 (Firestore와 동일하게)

Authentication

  1. 왼쪽 메뉴에서 "Authentication" 선택
  2. "시작하기" 클릭
  3. "이메일/비밀번호" 로그인 방법 활성화
  4. 관리자 계정 생성 (내가 쓸 아이디 비번 추가해놓기)

3. Next.js 프로젝트에 Firebase 설치

3.1 Firebase SDK 설치

npm install firebase

3.2 프로젝트 구조 생성

lib/
├── firebase.ts    # Firebase 초기화
├── auth.ts        # 인증 관련 함수
└── blog.ts        # 블로그 관련 Firestore 함수

types/
└── blog.ts        # 타입 정의

4. Firebase 초기화 설정

4.1 환경 변수 설정

프로젝트 루트에 .env.local 파일 생성:

NEXT_PUBLIC_FIREBASE_API_KEY=your_api_key NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your_auth_domain NEXT_PUBLIC_FIREBASE_PROJECT_ID=your_project_id NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your_storage_bucket NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id NEXT_PUBLIC_FIREBASE_APP_ID=your_app_id

주의사항:

  • NEXT_PUBLIC_ 접두사 필수 (클라이언트에서 접근 가능)
  • .env.local.gitignore에 포함되어 있어야 함
  • 실제 값은 Firebase Console에서 복사한 값으로 대체

4.2 Firebase 초기화 파일 생성

lib/firebase.ts:

import { initializeApp, getApps, FirebaseApp } from 'firebase/app' import { getAuth, Auth } from 'firebase/auth' import { getFirestore, Firestore } from 'firebase/firestore' import { getStorage, FirebaseStorage } from 'firebase/storage' const firebaseConfig = { apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, } // Firebase 초기화 (이미 초기화되어 있으면 재초기화 방지) let app: FirebaseApp if (getApps().length === 0) { app = initializeApp(firebaseConfig) } else { app = getApps()[0] } // 서비스 인스턴스 생성 export const auth: Auth = getAuth(app) export const db: Firestore = getFirestore(app) export const storage: FirebaseStorage = getStorage(app) export default app

핵심 포인트:

  1. 환경 변수 사용: process.env.NEXT_PUBLIC_*로 설정값 읽기
  2. 중복 초기화 방지: getApps()로 이미 초기화된 앱이 있으면 재사용
    • 개발 환경에서 Hot Module Replacement 시 에러 방지
    • 프로덕션에서도 안정적으로 작동

4.3 타입 정의

types/blog.ts:

import { Timestamp } from 'firebase/firestore' export interface BlogPost { id?: string title: string content: string // 마크다운 텍스트 images: string[] // Storage URL 배열 createdAt: Timestamp | Date updatedAt: Timestamp | Date author: string // 사용자 UID tags?: string[] published: boolean slug?: string // URL 친화적인 제목 } export interface Project { id?: string tag: string // 태그 이름 (프로젝트 이름) description: string // 프로젝트 소개글 createdAt: Timestamp | Date updatedAt: Timestamp | Date }

5. 기본 Firestore 함수 구현

5.1 블로그 포스트 관련 함수

lib/blog.ts의 기본 구조:

import { collection, doc, getDocs, getDoc, addDoc, updateDoc, deleteDoc, query, orderBy, where, Timestamp } from 'firebase/firestore' import { ref, uploadBytes, getDownloadURL } from 'firebase/storage' import { db, storage } from './firebase' import { BlogPost, Project } from '@/types/blog' const POSTS_COLLECTION = 'posts' const PROJECTS_COLLECTION = 'projects' // 모든 포스트 가져오기 (공개된 것만) export const getPublishedPosts = async (): Promise<BlogPost[]> => { const q = query( collection(db, POSTS_COLLECTION), where('published', '==', true), orderBy('createdAt', 'desc') ) const snapshot = await getDocs(q) return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data(), createdAt: doc.data().createdAt.toDate(), updatedAt: doc.data().updatedAt.toDate(), })) as BlogPost[] } // 모든 포스트 가져오기 (관리자용) export const getAllPosts = async (): Promise<BlogPost[]> => { try { const q = query( collection(db, POSTS_COLLECTION), orderBy('createdAt', 'desc') ) const snapshot = await getDocs(q) return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data(), createdAt: doc.data().createdAt.toDate(), updatedAt: doc.data().updatedAt.toDate(), })) as BlogPost[] } catch (error) { console.error('포스트 가져오기 실패:', error) return [] } } // 단일 포스트 가져오기 export const getPost = async (id: string): Promise<BlogPost | null> => { const docRef = doc(db, POSTS_COLLECTION, id) const docSnap = await getDoc(docRef) if (!docSnap.exists()) { return null } return { id: docSnap.id, ...docSnap.data(), createdAt: docSnap.data().createdAt.toDate(), updatedAt: docSnap.data().updatedAt.toDate(), } as BlogPost } // 이미지 업로드 export const uploadImage = async (file: File, postId: string): Promise<string> => { const fileName = `${postId}/${Date.now()}_${file.name}` const storageRef = ref(storage, `blog-images/${fileName}`) await uploadBytes(storageRef, file) return await getDownloadURL(storageRef) } // 포스트 생성 export const createPost = async ( postData: Omit<BlogPost, 'id' | 'createdAt' | 'updatedAt'> ): Promise<string> => { const now = Timestamp.now() const docRef = await addDoc(collection(db, POSTS_COLLECTION), { ...postData, createdAt: now, updatedAt: now, }) return docRef.id }

5.2 인증 관련 함수

lib/auth.ts:

import { signInWithEmailAndPassword, signOut, User, onAuthStateChanged } from 'firebase/auth' import { auth } from './firebase' type AuthResult = { user: User | null error: string | null } const getErrorMessage = (error: unknown): string => { if (error instanceof Error) { return error.message } return '알 수 없는 오류가 발생했습니다.' } export const login = async (email: string, password: string): Promise<AuthResult> => { try { const userCredential = await signInWithEmailAndPassword(auth, email, password) return { user: userCredential.user, error: null } } catch (error: unknown) { return { user: null, error: getErrorMessage(error) } } } export const logout = async (): Promise<{ error: string | null }> => { try { await signOut(auth) return { error: null } } catch (error: unknown) { return { error: getErrorMessage(error) } } } export const getCurrentUser = (): Promise<User | null> => { return new Promise((resolve) => { const unsubscribe = onAuthStateChanged(auth, (user) => { unsubscribe() resolve(user) }) }) }

6. Firebase 보안 규칙 설정

6.1 Firestore 보안 규칙

Firebase Console → Firestore Database → 규칙:

rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { // posts 컬렉션 규칙 match /posts/{postId} { // 공개된 포스트는 모든 사용자가 읽기 가능 allow read: if resource.data.published == true; // 인증된 사용자는 모든 포스트 읽기 가능 (어드민용) allow read: if request.auth != null; // 인증된 사용자만 쓰기 가능 allow write: if request.auth != null; } // projects 컬렉션 규칙 match /projects/{projectId} { // 모든 사용자가 읽기 가능 allow read: if true; // 인증된 사용자만 쓰기 가능 allow write: if request.auth != null; } } }

6.2 Storage 보안 규칙

Firebase Console → Storage → 규칙:

rules_version = '2'; service firebase.storage { match /b/{bucket}/o { // blog-images 경로의 이미지 match /blog-images/{allPaths=**} { // 모든 사용자가 읽기 가능 allow read: if true; // 인증된 사용자만 쓰기 가능 allow write: if request.auth != null; } } }

7. 정적 사이트에서 동적 페이지로 전환

7.1 문제 상황

Firebase를 연결한 후, 블로그 기능을 추가하려 했지만 문제가 발생했습니다:

  • Next.js가 기본적으로 정적 사이트 생성(SSG) 모드로 빌드됨
  • Firebase 데이터는 런타임에 가져와야 하는데, 빌드 시점에 모든 페이지를 생성하려고 시도
  • 동적 라우트(/blog/[tag], /blog/[tag]/[slug])가 제대로 작동하지 않음

7.2 원인 분석

next.config.jsoutput: 'export' 설정이 있으면:

  • 모든 페이지를 정적 HTML 파일로 생성
  • 서버 사이드 렌더링 불가
  • 동적 데이터를 빌드 시점에만 가져올 수 있음

기존 설정 (문제):

const nextConfig = { output: 'export', // ← 이 설정이 문제였음 reactStrictMode: true, }

7.3 해결 방법

1단계: next.config.js 수정

next.config.js:

/** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, images: { unoptimized: true, }, // output: 'export' 제거 ← 이 줄 삭제! } module.exports = nextConfig

변경 사항:

  • output: 'export' 제거 → 서버 사이드 렌더링 활성화
  • images.unoptimized: true 유지 (GitHub Pages 호환을 위해)

2단계: 동적 라우트에서 generateStaticParams 제거

기존 방식 (문제):

// app/blog/[tag]/page.tsx export async function generateStaticParams() { // 빌드 시점에 모든 태그의 페이지를 생성하려고 시도 const tags = await getAllTags() return tags.map(tag => ({ tag })) } export default async function TagPage({ params }: PageProps) { // ... }

새로운 방식 (해결):

// app/blog/[tag]/page.tsx export default async function TagPage({ params }: PageProps) { const decodedTag = decodeURIComponent(params.tag) const project = await getProjectByTag(decodedTag) const posts = await getPostsByTag(decodedTag) // 런타임에 Firebase에서 데이터 가져오기 // ... }

변경 사항:

  • generateStaticParams() 완전히 제거
  • 모든 페이지가 런타임에 동적으로 렌더링됨
  • 요청 시점에 Firebase에서 최신 데이터를 가져옴

3단계: 배포 플랫폼 변경 (GitHub Pages → Vercel)

GitHub Pages는 정적 사이트만 지원하므로, 서버 사이드 렌더링을 지원하는 Vercel로 전환:

  1. Vercel 프로젝트 생성

    • Vercel에 로그인
    • GitHub 저장소 연결
    • Framework Preset: Next.js 선택
  2. 환경 변수 설정

    • Vercel 프로젝트 설정 → Environment Variables
    • Firebase 환경 변수 추가:
      NEXT_PUBLIC_FIREBASE_API_KEY=...
      NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=...
      NEXT_PUBLIC_FIREBASE_PROJECT_ID=...
      NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=...
      NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=...
      NEXT_PUBLIC_FIREBASE_APP_ID=...
      
  3. vercel.json 설정

vercel.json:

{ "buildCommand": "npm run build", "devCommand": "npm run dev", "installCommand": "npm install", "framework": "nextjs", "regions": ["icn1"] }

7.4 전환 후 장점

  1. 동적 데이터 반영

    • 새로운 블로그 글이 즉시 반영됨 (재배포 불필요)
    • Firebase에서 실시간으로 데이터 가져오기
  2. 동적 라우트 지원

    • 빌드 타임에 모든 경로를 생성할 필요 없음
    • 런타임에 동적으로 라우트 처리
  3. 성능 최적화

    • 필요한 페이지만 렌더링
    • 자동 스케일링 지원 (Vercel)
  4. 개발 경험 개선

    • 로컬과 배포 환경의 동작이 일치
    • Hot Module Replacement 정상 작동

7.5 주의사항(기억하기)

  1. 환경 변수 설정 필수

    • Vercel 프로젝트 설정에서 Firebase 환경 변수 추가 필수
    • 환경 변수가 없으면 Firebase 초기화 실패
  2. Firestore 인덱스 설정

    • 복합 쿼리(where + orderBy) 사용 시 인덱스 필요
    • Firebase Console에서 인덱스 생성 필요
  3. 보안 규칙 확인

    • Firestore와 Storage 보안 규칙이 올바르게 설정되어 있는지 확인
    • 테스트 모드로 설정하면 모든 사용자가 읽기/쓰기 가능 (위험)

8. 마무리

Firebase를 Next.js 프로젝트에 연결하고, 정적 사이트에서 동적 페이지로 전환했습니다:

  1. Firebase 프로젝트 생성 및 서비스 활성화
  2. Firebase SDK 설치 및 초기화
  3. 타입 정의 및 기본 함수 구현
  4. 보안 규칙 설정
  5. 정적 사이트에서 동적 렌더링으로 전환
  6. Vercel 배포로 서버 사이드 렌더링 지원

서버 구현을 다음과 같이 설정할 수 있었습니다. 사실 잘 되는지 확실하지 않습니다.. 블로그 페이지와 관리자 페이지 , 기능을 구현해 봐야 이것이 제대로 되는지 확인할 수 있을 것 같습니다.