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으로 접속하여 게시판을 확인할 수 있습니다.