next.js로 CRUD API 서버 만들기

odada·2024년 12월 9일
1

node.js

목록 보기
7/11

Next.js 블로그 앱 만들기

Next.js를 사용하여 블로그 애플리케이션을 만들어보겠습니다. 블로그 애플리케이션은 다음과 같은 기능을 구현할 수 있습니다.

next.js 블로그 앱 만들기 github 바로가기

1. 프로젝트 개요

1.1. 프로젝트 목표

  • Next를 사용하여 블로그 앱을 만들어보자.
  • Firebase로 사용자 인증을 구현하고 로그인, 회원가입, 로그아웃 기능을 구현한다.
  • Firebase의 기본 개념을 익히고 데이터 조회, 추가, 수정, 삭제를 구현한다.

1.2. 프로젝트 환경

  • Next.js(create-next-app) 프로젝트 생성
  • Firebase (Firestore, Authentication)를 이용한 데이터 관리 및 사용자 인증
  • Firebase Firestore를 이용한 게시판 CRUD(Create, Read, Update, Delete) 구현

1.3. 프로젝트 구조

블로그 애플리케이션은 다음과 같은 구조로 구성됩니다. components 디렉토리에 레이아웃 컴포넌트와 포스트 관련 컴포넌트를 생성하고, pages 디렉토리에 메인 페이지와 포스트 페이지를 생성할 수 있습니다.
public 디렉토리에 이미지와 스타일 파일을 저장할 수 있습니다.

app-blog-firebase/
├── app/
│   ├── posts/
│   │   ├── page.js             # 게시글 목록
│   │   ├── write/
│   │   │   └── page.js         # 글쓰기 페이지
│   │   └── [id]/
│   │       ├── page.js         # 상세 페이지
│   │       └── edit/
│   │           └── page.js     # 수정 페이지
└── lib/
│   ├── firebase/
│   │   ├── firebase.js         # Firebase 연결 파일
│   │   ├── auth.jsx            # Firebase 인증 provider
│   │   └── firestore.js        # Firestore CRUD api 파일
└── components/
│   ├── layout/
│   │   └── Header.jsx          # 헤더 컴포넌트
├── .env.local                  # 환경 변수 파일

2. 프로젝트 준비

2.1. Firebase 프로젝트 생성

  • [프로젝트 만들기] 버튼을 클릭해 새 프로젝트를 만들어줍니다.

  • 프로젝트 이름을 입력하고, [계속] 버튼을 클릭합니다.

  • 현 프로젝트에서는 Google Analytics를 사용하지 않으므로 선택 해제하고, [프로젝트 만들기] 버튼을 클릭합니다.

  • 프로젝트가 생성되면, [계속] 버튼을 클릭합니다.

  • Firebase 프로젝트가 생성되면, 앱 추가 화면이 나타납니다. 웹 앱을 추가하려면, </> 아이콘을 클릭합니다.

  • 앱의 이름을 입력하고, Firebase Hosting을 설정하지 않으므로 Firebase Hosting 설정 해제합니다. [앱 등록] 버튼을 클릭합니다.

  • Firebase SDK 설정을 위한 구성 파일을 복사하여 프로젝트에 추가합니다.
// src/lib/firebase/firebase.js
// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";

// Your web app's Firebase configuration
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
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);

2.2. Firebase 연결 유틸리티 설정

- 패키지 설치

  • Firebase SDK 설정을 위한 구성 요소를 추가하기 위해 npm 패키지를 설치합니다.
npm install firebase
# or
yarn add firebase

- 환경 변수 설정

  • api key 등 민감한 정보는 환경 변수로 설정하여 보안을 유지합니다.
# .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

- Firebase 연결하기

  • Firebase SDK를 사용하여 Firebase 앱을 초기화하고, Firestore와 Authentication을 사용하기 위한 설정 파일을 작성합니다.
// src/lib/firebase/firebase.js
import { initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";
import { getFirestore } from 'firebase/firestore'

// Your web app's Firebase configuration
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
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);

// 인증관련
export const auth = getAuth(app);

// 데이터 베이스 관련
export const db = getFirestore(app)

2.3. Firebase 인증(Authentication) 사용하기

  • 회원가입, 로그인, 로그아웃 기능을 구현하기 위해 Firebase Authentication을 사용합니다.

- Firebase Authentication 활성화

  • Firebase Console에서 Authentication 메뉴로 이동합니다.

  • [시작하기] 버튼을 클릭하여 Firebase Authentication을 활성화합니다.

  • Firebase Authentication이 활성화되면, [로그인 방법] 탭으로 이동합니다.

  • [google] 로그인 방법을 활성화합니다.

  • Firebase Authentication 설정이 완료되었습니다.

- Firebase 인증 설정

  • Provider를 사용하여 로그인, 회원가입, 로그아웃 기능을 구현하고
  • 전역 상태로 관리할 수 있도록 AuthProvider 컴포넌트를 만들어보겠습니다.

Firebase 공식 문서

// src/lib/firebase/auth.js
"use client"

import { createContext, useContext, useEffect, useState } from 'react';
import { auth } from './firebase';
import { 
  signInWithEmailAndPassword, 
  createUserWithEmailAndPassword, 
  signOut, 
  onAuthStateChanged,
  GoogleAuthProvider,
  signInWithPopup
} from 'firebase/auth';

// AuthContext 컨텍스트 생성
const AuthContext = createContext({
  user: null,
  login: async () => {},
  register: async () => {},
  logout: async () => {},
  loginWithGoogle: async () => {}
});

// AuthProvider 컴포넌트 정의
export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);

  // 사용자 상태 업데이트
  useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, setUser);
    return () => unsubscribe();
  }, []);

  // 로그인 함수
  const login = async (email, password) => {
    await signInWithEmailAndPassword(auth, email, password);
  };

  // 회원가입 함수
  const register = async (email, password) => {
    await createUserWithEmailAndPassword(auth, email, password);
  };

  // 로그아웃 함수
  const logout = async () => {
    await signOut(auth);
  };

  // Google 로그인 함수
  const loginWithGoogle = async () => {
    const provider = new GoogleAuthProvider();
    try {
      await signInWithPopup(auth, provider);
    } catch (error) {
      console.error('Google 로그인 실패:', error);
    }
  };

  return (
    <AuthContext.Provider value={{
      user,
      login,
      register,
      logout,
      loginWithGoogle
    }}>
      {children}
    </AuthContext.Provider>
  );
};

// 사용자 정의 훅
export const useAuth = () => useContext(AuthContext);

Provider 사용하기

  • AuthProvider 컴포넌트를 사용하여 전역 상태로 사용자 인증을 관리합니다.
// app/layout.js
import localFont from "next/font/local";
import "./globals.css";
import Header from "@/components/layout/Header";
import { AuthProvider } from "@/lib/firebase/auth";

(...)

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <AuthProvider> // AuthProvider로 감싸기
          <Header />
          {children}
        </AuthProvider>
      </body>
    </html>
  );
}

- 로그인 및 회원가입 기능 포함된 헤더

이제 로그인 및 회원가입 기능이 포함된 헤더를 만들어보겠습니다.

// components/Layout/Header.tsx
'use client'

import Link from 'next/link'
import { useAuth } from '@/lib/firebase/auth'

const Header = () => {
    const { user, loginWithGoogle, logout } = useAuth()

    return (
        <header className="flex justify-between items-center p-4">
            <h1>
                <Link href="/">logo</Link>
            </h1>

            <nav>
                <ul className="flex space-x-4">
                    <li>
                        <Link href="/about">About</Link>
                    </li>
                    <li>
                        <Link href="/post">Post</Link>
                    </li>
                </ul>
            </nav>

            <div className="space-x-4">
                {user ? (
                    <button onClick={logout} className="p-2 bg-red-500 rounded">
                        로그아웃
                    </button>
                ) : (
                    <>
                        <button onClick={loginWithGoogle} className="p-2 bg-blue-500 rounded">
                            Google로 로그인
                        </button>
                        <Link href="/login">
                            <button className="p-2 bg-green-500 rounded">로그인</button>
                        </Link>
                        <Link href="/signup">
                            <button className="p-2 bg-yellow-500 rounded">회원가입</button>
                        </Link>
                    </>
                )}
            </div>
        </header>
    )
}

export default Header

- 로그인 페이지

// app/(auth)/login/page.jsx
"use client"

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '@/lib/firebase/auth'

const Login = () => {
    const { login, loginWithGoogle } = useAuth()
    const router = useRouter()
    const [email, setEmail] = useState('')
    const [password, setPassword] = useState('')
    const [error, setError] = useState('')

    const handleLogin = async (event) => {
        event.preventDefault()
        try {
            await login(email, password)
            router.push('/')
        } catch (err) {
            setError('로그인 실패: 잘못된 이메일 또는 비밀번호입니다.')
        }
    }

    return (
        <div className='container mx-auto'>
            <h2>로그인</h2>
            {error && <p>{error}</p>}
            <form onSubmit={handleLogin}>
                <label>
                    이메일:
                    <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
                </label>
                <label>
                    비밀번호:
                    <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
                </label>
                <button type="submit">로그인</button>
            </form>
            <button onClick={loginWithGoogle}>Google로 로그인</button>
        </div>
    )
}

export default Login

- 회원가입 페이지

// app/(auth)/signup/page.jsx
"use client";

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/firebase/auth';
import { signInWithPopup } from 'firebase/auth';

const Signup = () => {
    const { register } = useAuth();
    const router = useRouter();
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [error, setError] = useState('');

    // 이메일 회원가입 처리
    const handleRegister = async (event) => {
        event.preventDefault();
        try {
            await register(email, password);
            router.push('/');
        } catch (err) {
            setError('회원가입 실패: 이메일 형식이 올바르지 않거나 비밀번호가 너무 짧습니다.');
        }
    };

    // Google 로그인 처리
    const handleGoogleLogin = async () => {
        try {
            const result = await signInWithPopup(auth, googleProvider);
            console.log('Google 로그인 성공:', result.user); // 로그인 성공 시 사용자 정보
            router.push('/'); // 로그인 성공 시 메인 페이지로 이동
        } catch (err) {
            console.error('Google 로그인 실패:', err);
            setError('Google 로그인 실패: 다시 시도해주세요.');
        }
    };

    return (
        <div className='container mx-auto'>
            <h2>회원가입</h2>
            {error && <p style={{ color: 'red' }}>{error}</p>}
            <form onSubmit={handleRegister}>
                <label>
                    이메일:
                    <input
                        type="email"
                        value={email}
                        onChange={(e) => setEmail(e.target.value)}
                        required
                    />
                </label>
                <label>
                    비밀번호:
                    <input
                        type="password"
                        value={password}
                        onChange={(e) => setPassword(e.target.value)}
                        required
                    />
                </label>
                <button type="submit">회원가입</button>
            </form>
            <hr />
            <button onClick={handleGoogleLogin}>Google로 로그인</button> {/* Google 로그인 버튼 */}
        </div>
    );
};

export default Signup;

3. Firebase Firestore(데이터 베이스) 연결

firebase firestore를 사용하여 데이터베이스를 생성하고, 데이터를 추가, 조회, 수정, 삭제하는 방법을 알아보겠습니다.

3.1. Firestore 데이터베이스 생성

  • Firebase Console에서 Firestore 메뉴로 이동합니다.
  • [데이터베이스 만들기] 버튼을 클릭합니다.
  • [시작하기] 버튼을 클릭합니다.
  • [보안 규칙] 설정을 테스트 모드로 설정합니다.
  • [다음] 버튼을 클릭합니다.
  • [데이터베이스 만들기] 버튼을 클릭합니다.

3.2. Firestore 데이터베이스 설정

  • Firestore 데이터베이스를 설정합니다.
// src/lib/firebase/firestore.js
import { db } from './firebase'
import { collection, getDocs, addDoc, doc, updateDoc, deleteDoc } from 'firebase/firestore'

// 추가 (Create)
export async function addDocument(collectionName, data) {
    try {
        const docRef = await addDoc(collection(db, collectionName), data)
        console.log('Document written with ID:', docRef.id)
    } catch (e) {
        console.error('Error adding document:', e)
    }
}

// 조회 (Read)
export async function getAllDocuments(collectionName) {
    const querySnapshot = await getDocs(collection(db, collectionName))
    querySnapshot.forEach((doc) => {
        console.log(`${doc.id} =>`, doc.data())
    })
}

// 수정 (Update)
export async function updateDocument(collectionName, docId, newData) {
    const docRef = doc(db, collectionName, docId)
    try {
        await updateDoc(docRef, newData)
        console.log('Document updated')
    } catch (e) {
        console.error('Error updating document:', e)
    }
}

// 삭제 (Delete)
export async function deleteDocument(collectionName, docId) {
    const docRef = doc(db, collectionName, docId)
    try {
        await deleteDoc(docRef)
        console.log('Document deleted')
    } catch (e) {
        console.error('Error deleting document:', e)
    }
}

4. Firestore 프론트엔드 연결

  • Firestore 데이터베이스를 사용하여 데이터를 추가, 조회, 수정, 삭제할 수 있는 기능을 구현합니다.

4.1. Firestore 데이터 조회

데이터를 조회하고 수정, 삭제 버튼을 추가하여 데이터를 수정하거나 삭제할 수 있습니다.

// src/app/social/page.jsx
"use client"

import React, { useState, useEffect } from 'react'
import { collection, getDocs, query, orderBy } from 'firebase/firestore'
import SocialEditPage from './[id]/edit/page'
import { db } from '@/lib/firebase/firebase'
import { deleteDocument } from '@/lib/firebase/firestore'
import Link from 'next/link'

/**
 * 게시글 목록 페이지
 * - 게시글 목록 조회
 * - 게시글 수정/삭제 기능
 * - 게시글 상세 페이지 링크
 */
const SocialPage = () => {
    // 게시글 목록 상태
    const [posts, setPosts] = useState([])
    // 현재 수정 중인 게시글 ID
    const [editingPostId, setEditingPostId] = useState(null)

    /**
     * Firestore에서 게시글 목록을 가져오는 함수
     * - 제목 기준 오름차순 정렬
     * - 제목과 내용만 가져옴
     */
    const fetchPosts = async () => {
        try {
            const postsQuery = query(
                collection(db, 'posts'), 
                orderBy('title')
            )
            const querySnapshot = await getDocs(postsQuery)
            const postsData = querySnapshot.docs.map((doc) => ({
                id: doc.id,
                title: doc.data().title,
                content: doc.data().content,
            }))
            setPosts(postsData)
        } catch (error) {
            console.error('게시글 목록 조회 실패:', error)
            alert('게시글 목록을 불러오는데 실패했습니다.')
        }
    }

    // 컴포넌트 마운트 시 게시글 목록 조회
    useEffect(() => {
        fetchPosts()
    }, [])

    /**
     * 게시글 삭제 처리 함수
     * @param {string} id - 삭제할 게시글 ID
     */
    const handleDelete = async (id) => {
        if (window.confirm('정말 삭제하시겠습니까?')) {
            try {
                await deleteDocument('posts', id)
                fetchPosts() // 목록 새로고침
            } catch (error) {
                console.error('게시글 삭제 실패:', error)
                alert('게시글 삭제에 실패했습니다.')
            }
        }
    }

    /**
     * 게시글 수정 완료 처리 함수
     * - 수정 모드 종료
     * - 목록 새로고침
     */
    const handleUpdate = () => {
        setEditingPostId(null)
        fetchPosts()
    }

    return (
        <div className="max-w-4xl mx-auto p-4">
            <div className="flex justify-between items-center mb-6">
                <h2 className="text-2xl font-bold">게시글 목록</h2>
                <Link 
                    href="/social/write"
                    className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
                >
                    글쓰기
                </Link>
            </div>

            {editingPostId ? (
                // 수정 모드
                <SocialEditPage
                    id={editingPostId}
                    initialTitle={posts.find((post) => post.id === editingPostId)?.title ?? ''}
                    initialContent={posts.find((post) => post.id === editingPostId)?.content ?? ''}
                    onCancel={() => setEditingPostId(null)}
                    onUpdate={handleUpdate}
                />
            ) : (
                // 목록 모드
                <>
                    {posts.length > 0 ? (
                        <ul className="space-y-4">
                            {posts.map((post) => (
                                <li 
                                    key={post.id}
                                    className="border rounded-lg p-4 hover:shadow-md transition-shadow"
                                >
                                    <Link 
                                        href={`/social/${post.id}`}
                                        className="block"
                                    >
                                        <h3 className="text-xl font-semibold mb-2">{post.title}</h3>
                                        <p className="text-gray-600 mb-4 line-clamp-2">{post.content}</p>
                                    </Link>
                                </li>
                            ))}
                        </ul>
                    ) : (
                        <p className="text-center text-gray-500 py-8">
                            게시글이 없습니다.
                        </p>
                    )}
                </>
            )}
        </div>
    )
}

export default SocialPage

4.2. Firestore 데이터 추가

// src/app/social/write/page.jsx
"use client"

import { addDocument } from '@/lib/firebase/firestore'
import React, { useState } from 'react'

const SocialWritePage = () => {
    const [title, setTitle] = useState('')
    const [content, setContent] = useState('')

    // 폼 제출 시 Firestore에 데이터를 추가하는 함수 호출
    const handleSubmit = async (event) => {
        event.preventDefault()
        // 입력된 데이터 유효성 검사
        if (title.trim() === '' || content.trim() === '') {
            alert('제목과 내용을 입력하세요!')
            return
        }

        // Firestore에 문서 추가
        try {
            await addDocument('posts', { title, content })
            alert('새 게시물이 추가되었습니다!')
            setTitle('')
            setContent('')
        } catch (error) {
            console.error('게시물 추가 중 오류 발생:', error)
            alert('게시물 추가에 실패했습니다.')
        }
    }

    return (
        <div>
            <h1>게시물 추가하기</h1>
            <form onSubmit={handleSubmit}>
                <div>
                    <label htmlFor="title">제목:</label>
                    <input id="title" type="text" value={title} onChange={(e) => setTitle(e.target.value)} />
                </div>
                <div>
                    <label htmlFor="content">내용:</label>
                    <textarea id="content" value={content} onChange={(e) => setContent(e.target.value)} />
                </div>
                <button type="submit">추가하기</button>
            </form>
        </div>
    )
}

export default SocialWritePage

4.3. Firestore 특정 데이터 조회

// src/app/social/[id]/page.jsx
import { doc, getDoc } from 'firebase/firestore'
import { db } from '@/lib/firebase/firebase'
import Link from 'next/link'
import { notFound } from 'next/navigation'

async function SocialDetailPage({ params }) {
  // Fetch data directly in the component
  const docRef = doc(db, 'posts', params.id)
  const docSnap = await getDoc(docRef)

  if (!docSnap.exists()) {
    notFound()
  }

  const post = docSnap.data()

  return (
    <div className="p-4">
      <h2 className="text-2xl font-bold mb-4">{post.title}</h2>
      <p className="mb-4">{post.content}</p>
      <Link 
        href="/social"
        className="text-blue-500 hover:text-blue-700"
      >
        Back to list
      </Link>
    </div>
  )
}

// Generate static params (replaces getStaticPaths)
export async function generateStaticParams() {
  // If you want to statically generate some paths at build time
  // you can fetch the IDs here and return them
  return []
}

export default SocialDetailPage

4.4. Firestore 특정 데이터 수정

// src/app/social/[id]/edit/page.jsx
import { updateDocument } from '@/lib/firebase/firestore'
import React, { useState, useEffect } from 'react'

const SocialEditPage = ({ id, initialTitle, initialContent, onCancel, onUpdate }) => {
    const [title, setTitle] = useState(initialTitle)
    const [content, setContent] = useState(initialContent)

    const handleUpdate = async (event) => {
        event.preventDefault()
        await updateDocument('posts', id, { title, content })
        onUpdate() // 성공 시 부모 컴포넌트에 알리기 위해 사용
    }

    return (
        <div>
            <h2>Edit Post</h2>
            <form onSubmit={handleUpdate}>
                <div>
                    <label htmlFor="title">Title:</label>
                    <input id="title" type="text" value={title} onChange={(e) => setTitle(e.target.value)} />
                </div>
                <div>
                    <label htmlFor="content">Content:</label>
                    <textarea id="content" value={content} onChange={(e) => setContent(e.target.value)} />
                </div>
                <button type="submit">Update</button>
                <button type="button" onClick={onCancel}>
                    Cancel
                </button>
            </form>
        </div>
    )
}

export default SocialEditPage

0개의 댓글