React.js + Next.js + 게시판 생성

By | 2025년 6월 18일
Table of Contents

React.js + Next.js + 게시판 생성

간단한 게시판을 만들어 봅니다.
인공지능이 작성한 코드지만 정상작동 확인되었습니다.

프로젝트 구조

# 프로젝트 구조
simple-board/
├── package.json
├── .env.local
├── lib/
│   └── db.js
├── pages/
│   ├── index.js (게시글 목록)
│   ├── post/
│   │   ├── [id].js (게시글 상세)
│   │   └── write.js (게시글 작성)
│   └── api/
│       └── posts/
│           ├── index.js (목록 조회, 작성)
│           └── [id].js (상세 조회, 수정, 삭제)
├── components/
│   ├── Layout.js
│   └── PostForm.js
└── styles/
    └── globals.css

프로젝트 생성 및 설정

mkdir simple-board
cd simple-board

npm init -y
npm install next react@18 react-dom@18 mysql2
CREATE DATABASE db_board;

USE db_board;

CREATE TABLE posts (
  id INT AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  content TEXT NOT NULL,
  author VARCHAR(100) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

package.json

{
  "name": "simple-board",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "keywords": [],
  "author": "",
  "description": "",
  "dependencies": {
    "mysql2": "^3.14.1",
    "next": "^15.3.3",
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  }
}

.env.local

DB_HOST=localhost
DB_USER=your_username
DB_PASSWORD=your_password
DB_NAME=db_board

lib/db.js

import mysql from 'mysql2/promise';

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0,
  charset: 'utf8mb4'  // 한글 지원
});

export default pool;

components/Layout.js

import Link from 'next/link';

export default function Layout({ children }) {
  return (
    <div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
      <header style={{ borderBottom: '1px solid #eee', paddingBottom: '20px', marginBottom: '20px' }}>
        <h1 style={{ margin: 0 }}>
          <Link href="/" style={{ textDecoration: 'none', color: '#333' }}>
            간단한 게시판
          </Link>
        </h1>
        <nav style={{ marginTop: '10px' }}>
          <Link href="/" style={{ marginRight: '20px', textDecoration: 'none', color: '#666' }}>
            목록
          </Link>
          <Link href="/post/write" style={{ textDecoration: 'none', color: '#666' }}>
            글쓰기
          </Link>
        </nav>
      </header>
      <main>{children}</main>
    </div>
  );
}

components/PostForm.js

import { useState } from 'react';
import { useRouter } from 'next/router';

export default function PostForm({ post = null, isEdit = false }) {
  const [title, setTitle] = useState(post?.title || '');
  const [content, setContent] = useState(post?.content || '');
  const [author, setAuthor] = useState(post?.author || '');
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  const handleSubmit = async (e) => {
    e.preventDefault();

    if (!title.trim() || !content.trim() || !author.trim()) {
      alert('모든 필드를 입력해주세요.');
      return;
    }

    setLoading(true);

    try {
      const url = isEdit ? `/api/posts/${post.id}` : '/api/posts';
      const method = isEdit ? 'PUT' : 'POST';

      const response = await fetch(url, {
        method,
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ title, content, author }),
      });

      if (response.ok) {
        router.push('/');
      } else {
        alert('오류가 발생했습니다.');
      }
    } catch (error) {
      console.error('Error:', error);
      alert('오류가 발생했습니다.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
      <div>
        <label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>제목</label>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          style={{ 
            width: '100%', 
            padding: '10px', 
            border: '1px solid #ddd', 
            borderRadius: '4px',
            fontSize: '16px'
          }}
          disabled={loading}
        />
      </div>

      <div>
        <label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>작성자</label>
        <input
          type="text"
          value={author}
          onChange={(e) => setAuthor(e.target.value)}
          style={{ 
            width: '100%', 
            padding: '10px', 
            border: '1px solid #ddd', 
            borderRadius: '4px',
            fontSize: '16px'
          }}
          disabled={loading || isEdit}
        />
      </div>

      <div>
        <label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>내용</label>
        <textarea
          value={content}
          onChange={(e) => setContent(e.target.value)}
          rows="10"
          style={{ 
            width: '100%', 
            padding: '10px', 
            border: '1px solid #ddd', 
            borderRadius: '4px',
            fontSize: '16px',
            resize: 'vertical'
          }}
          disabled={loading}
        />
      </div>

      <div style={{ display: 'flex', gap: '10px' }}>
        <button
          type="submit"
          disabled={loading}
          style={{ 
            padding: '12px 24px', 
            backgroundColor: '#007bff', 
            color: 'white', 
            border: 'none', 
            borderRadius: '4px', 
            cursor: loading ? 'not-allowed' : 'pointer',
            fontSize: '16px'
          }}
        >
          {loading ? '처리중...' : (isEdit ? '수정' : '작성')}
        </button>

        <button
          type="button"
          onClick={() => router.back()}
          style={{ 
            padding: '12px 24px', 
            backgroundColor: '#6c757d', 
            color: 'white', 
            border: 'none', 
            borderRadius: '4px', 
            cursor: 'pointer',
            fontSize: '16px'
          }}
        >
          취소
        </button>
      </div>
    </form>
  );
}

pages/api/posts/index.js

import pool from '../../../lib/db';

export default async function handler(req, res) {
  if (req.method === 'GET') {
    // 게시글 목록 조회
    try {
      const [rows] = await pool.execute(
        'SELECT id, title, author, created_at FROM posts ORDER BY created_at DESC'
      );
      res.status(200).json(rows);
    } catch (error) {
      console.error('Database error:', error);
      res.status(500).json({ error: '데이터베이스 오류가 발생했습니다.' });
    }
  } else if (req.method === 'POST') {
    // 게시글 작성
    const { title, content, author } = req.body;

    if (!title || !content || !author) {
      return res.status(400).json({ error: '모든 필드를 입력해주세요.' });
    }

    try {
      const [result] = await pool.execute(
        'INSERT INTO posts (title, content, author) VALUES (?, ?, ?)',
        [title, content, author]
      );

      res.status(201).json({ 
        id: result.insertId,
        message: '게시글이 작성되었습니다.' 
      });
    } catch (error) {
      console.error('Database error:', error);
      res.status(500).json({ error: '데이터베이스 오류가 발생했습니다.' });
    }
  } else {
    res.setHeader('Allow', ['GET', 'POST']);
    res.status(405).json({ error: `Method ${req.method} Not Allowed` });
  }
}

pages/api/posts/[id].js

import pool from '../../../lib/db';

export default async function handler(req, res) {
  const { id } = req.query;
  const postId = parseInt(id);

  if (isNaN(postId)) {
    return res.status(400).json({ error: '잘못된 게시글 ID입니다.' });
  }

  if (req.method === 'GET') {
    // 게시글 상세 조회
    try {
      const [rows] = await pool.execute(
        'SELECT * FROM posts WHERE id = ?',
        [postId]
      );

      if (rows.length === 0) {
        return res.status(404).json({ error: '게시글을 찾을 수 없습니다.' });
      }

      res.status(200).json(rows[0]);
    } catch (error) {
      console.error('Database error:', error);
      res.status(500).json({ error: '데이터베이스 오류가 발생했습니다.' });
    }
  } else if (req.method === 'PUT') {
    // 게시글 수정
    const { title, content, author } = req.body;

    if (!title || !content || !author) {
      return res.status(400).json({ error: '모든 필드를 입력해주세요.' });
    }

    try {
      const [result] = await pool.execute(
        'UPDATE posts SET title = ?, content = ?, author = ? WHERE id = ?',
        [title, content, author, postId]
      );

      if (result.affectedRows === 0) {
        return res.status(404).json({ error: '게시글을 찾을 수 없습니다.' });
      }

      res.status(200).json({ message: '게시글이 수정되었습니다.' });
    } catch (error) {
      console.error('Database error:', error);
      res.status(500).json({ error: '데이터베이스 오류가 발생했습니다.' });
    }
  } else if (req.method === 'DELETE') {
    // 게시글 삭제
    try {
      const [result] = await pool.execute(
        'DELETE FROM posts WHERE id = ?',
        [postId]
      );

      if (result.affectedRows === 0) {
        return res.status(404).json({ error: '게시글을 찾을 수 없습니다.' });
      }

      res.status(200).json({ message: '게시글이 삭제되었습니다.' });
    } catch (error) {
      console.error('Database error:', error);
      res.status(500).json({ error: '데이터베이스 오류가 발생했습니다.' });
    }
  } else {
    res.setHeader('Allow', ['GET', 'PUT', 'DELETE']);
    res.status(405).json({ error: `Method ${req.method} Not Allowed` });
  }
}

pages/index.js

import Link from 'next/link';
import Layout from '../components/Layout';
import pool from '../lib/db';

export default function Home({ posts }) {
  const formatDate = (dateString) => {
    const date = new Date(dateString);
    return date.toLocaleDateString('ko-KR', {
      year: 'numeric',
      month: '2-digit',
      day: '2-digit',
      hour: '2-digit',
      minute: '2-digit'
    });
  };

  return (
    <Layout>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
        <h2 style={{ margin: 0 }}>게시글 목록</h2>
        <Link 
          href="/post/write"
          style={{ 
            padding: '10px 20px', 
            backgroundColor: '#007bff', 
            color: 'white', 
            textDecoration: 'none', 
            borderRadius: '4px',
            fontSize: '14px'
          }}
        >
          글쓰기
        </Link>
      </div>

      {posts.length === 0 ? (
        <div style={{ 
          textAlign: 'center', 
          padding: '50px', 
          color: '#666',
          border: '1px solid #eee',
          borderRadius: '4px'
        }}>
          <p>등록된 게시글이 없습니다.</p>
          <Link href="/post/write" style={{ color: '#007bff', textDecoration: 'none' }}>
            첫 번째 글을 작성해보세요!
          </Link>
        </div>
      ) : (
        <div style={{ border: '1px solid #ddd', borderRadius: '4px' }}>
          {posts.map((post, index) => (
            <div 
              key={post.id} 
              style={{ 
                padding: '15px 20px', 
                borderBottom: index < posts.length - 1 ? '1px solid #eee' : 'none',
                display: 'flex',
                justifyContent: 'space-between',
                alignItems: 'center'
              }}
            >
              <div style={{ flex: 1 }}>
                <Link 
                  href={`/post/${post.id}`}
                  style={{ 
                    textDecoration: 'none', 
                    color: '#333',
                    fontSize: '16px',
                    fontWeight: '500'
                  }}
                >
                  {post.title}
                </Link>
                <div style={{ 
                  marginTop: '5px', 
                  fontSize: '14px', 
                  color: '#666' 
                }}>
                  {post.author} • {formatDate(post.created_at)}
                </div>
              </div>
            </div>
          ))}
        </div>
      )}
    </Layout>
  );
}

// 서버사이드 렌더링으로 게시글 목록을 가져옴
export async function getServerSideProps() {
  try {
    const [rows] = await pool.execute(
      'SELECT id, title, author, created_at FROM posts ORDER BY created_at DESC'
    );

    // Date 객체를 문자열로 변환 (JSON 직렬화를 위해)
    const posts = rows.map(post => ({
      ...post,
      created_at: post.created_at.toISOString()
    }));

    return {
      props: {
        posts
      }
    };
  } catch (error) {
    console.error('Database error:', error);
    return {
      props: {
        posts: []
      }
    };
  }
}

pages/post/[id].js

import { useState } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Layout from '../../components/Layout';
import PostForm from '../../components/PostForm';
import pool from '../../lib/db';

export default function PostDetail({ post }) {
  const [isEditing, setIsEditing] = useState(false);
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  if (!post) {
    return (
      <Layout>
        <div style={{ textAlign: 'center', padding: '50px' }}>
          <h2>게시글을 찾을 수 없습니다</h2>
          <Link href="/" style={{ color: '#007bff', textDecoration: 'none' }}>
            목록으로 돌아가기
          </Link>
        </div>
      </Layout>
    );
  }

  const formatDate = (dateString) => {
    const date = new Date(dateString);
    return date.toLocaleDateString('ko-KR', {
      year: 'numeric',
      month: '2-digit',
      day: '2-digit',
      hour: '2-digit',
      minute: '2-digit'
    });
  };

  const handleDelete = async () => {
    if (!confirm('정말 삭제하시겠습니까?')) {
      return;
    }

    setLoading(true);

    try {
      const response = await fetch(`/api/posts/${post.id}`, {
        method: 'DELETE',
      });

      if (response.ok) {
        alert('게시글이 삭제되었습니다.');
        router.push('/');
      } else {
        alert('삭제 중 오류가 발생했습니다.');
      }
    } catch (error) {
      console.error('Error:', error);
      alert('삭제 중 오류가 발생했습니다.');
    } finally {
      setLoading(false);
    }
  };

  if (isEditing) {
    return (
      <Layout>
        <h2>게시글 수정</h2>
        <PostForm post={post} isEdit={true} />
      </Layout>
    );
  }

  return (
    <Layout>
      <div style={{ marginBottom: '20px' }}>
        <Link href="/" style={{ color: '#007bff', textDecoration: 'none' }}>
          ← 목록으로
        </Link>
      </div>

      <article style={{ border: '1px solid #ddd', borderRadius: '4px', padding: '20px' }}>
        <header style={{ borderBottom: '1px solid #eee', paddingBottom: '15px', marginBottom: '20px' }}>
          <h1 style={{ margin: '0 0 10px 0', fontSize: '24px', color: '#333' }}>
            {post.title}
          </h1>
          <div style={{ fontSize: '14px', color: '#666' }}>
            <span>{post.author}</span>
            <span style={{ margin: '0 10px' }}>•</span>
            <span>작성일: {formatDate(post.created_at)}</span>
            {post.updated_at !== post.created_at && (
              <>
                <span style={{ margin: '0 10px' }}>•</span>
                <span>수정일: {formatDate(post.updated_at)}</span>
              </>
            )}
          </div>
        </header>

        <div style={{ 
          lineHeight: '1.6', 
          fontSize: '16px', 
          whiteSpace: 'pre-wrap',
          minHeight: '200px'
        }}>
          {post.content}
        </div>
      </article>

      <div style={{ 
        marginTop: '20px', 
        display: 'flex', 
        gap: '10px',
        justifyContent: 'flex-end'
      }}>
        <button
          onClick={() => setIsEditing(true)}
          style={{ 
            padding: '10px 20px', 
            backgroundColor: '#28a745', 
            color: 'white', 
            border: 'none', 
            borderRadius: '4px', 
            cursor: 'pointer',
            fontSize: '14px'
          }}
        >
          수정
        </button>

        <button
          onClick={handleDelete}
          disabled={loading}
          style={{ 
            padding: '10px 20px', 
            backgroundColor: '#dc3545', 
            color: 'white', 
            border: 'none', 
            borderRadius: '4px', 
            cursor: loading ? 'not-allowed' : 'pointer',
            fontSize: '14px'
          }}
        >
          {loading ? '삭제중...' : '삭제'}
        </button>
      </div>
    </Layout>
  );
}

export async function getServerSideProps({ params }) {
  const { id } = params;
  const postId = parseInt(id);

  if (isNaN(postId)) {
    return {
      notFound: true
    };
  }

  try {
    const [rows] = await pool.execute(
      'SELECT * FROM posts WHERE id = ?',
      [postId]
    );

    if (rows.length === 0) {
      return {
        notFound: true
      };
    }

    const post = {
      ...rows[0],
      created_at: rows[0].created_at.toISOString(),
      updated_at: rows[0].updated_at.toISOString()
    };

    return {
      props: {
        post
      }
    };
  } catch (error) {
    console.error('Database error:', error);
    return {
      notFound: true
    };
  }
}

pages/post/write.js

import Link from 'next/link';
import Layout from '../../components/Layout';
import PostForm from '../../components/PostForm';

export default function WritePost() {
  return (
    <Layout>
      <div style={{ marginBottom: '20px' }}>
        <Link href="/" style={{ color: '#007bff', textDecoration: 'none' }}>
          ← 목록으로
        </Link>
      </div>

      <h2 style={{ marginBottom: '20px' }}>새 게시글 작성</h2>

      <PostForm />
    </Layout>
  );
}

styles/globals.css

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html,
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
  line-height: 1.6;
  color: #333;
  background-color: #f8f9fa;
}

a {
  color: inherit;
  text-decoration: none;
}

button {
  font-family: inherit;
}

input,
textarea {
  font-family: inherit;
  outline: none;
}

input:focus,
textarea:focus {
  border-color: #007bff;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

button:hover:not(:disabled) {
  opacity: 0.9;
  transform: translateY(-1px);
  transition: all 0.2s ease;
}

button:disabled {
  opacity: 0.6;
}

pages/_app.js

import '../styles/globals.css';

export default function App({ Component, pageProps }) {
  return <Component {...pageProps} />;
}

실행

npm run dev

브라우저에서 http://localhost:3000으로 접속하여 게시판을 확인할 수 있습니다.

답글 남기기