프론트엔드/React

React로 게시판 만들기(3)

짱뚱짱 2024. 9. 24. 14:45

https://koop.tistory.com/45

 

React로 게시판 만들기(2)

https://koop.tistory.com/44 React로 게시판 만들기(1)- 간략히 작성한 화면설계서를 참고해서 게시판을 만들 예정.   - App.jsimport './App.css';import BoardHome from './component/BoardHome';function App() { return ( );}export

koop.tistory.com

 

지난 포스팅에선 게시판을 만들기 위한 개발환경 및 DB를 세팅하였고, 본격적으로 게시판의 주요 기능인 CRUD(Create, Read, Update, Delete) 구현

 

📢 API 구축 

// src/server/server.js
// 설치한 라이브러리 변수로 받아오기
const express = require('express');
const bodyParser = require('body-parser');
const mysql = require('mysql');
const cors = require('cors');

//express 사용하기 위한 app 생성
const app = express();

//express 사용할 서버포트 설정
const PORT = process.env.PORT || 5000;

app.use(cors());
app.use(bodyParser.json());

//DB 접속
const db = mysql.createConnection({
    host : 'localhost',
    user: 'react',
    password: 'mysql',
    port:'3306',
    database:'db_react'
});

// express 접속
app.listen(PORT, ()=>{
    console.log(`server connecting on : http://localhost:${PORT}`);
});

//db 연결
db.connect((err)=>{
    if(!err){
        console.log("success");

    }else{
        console.log("fail");
    }
});

//db에서 값을 가져오는 쿼리문
app.get('/', (req,res)=>{
    res.send("React Server Connect Success!!")
});

//게시글 목록 가져오기
app.get('/list', (req, res) => {
    const page = parseInt(req.query.page) || 1; // 현재 페이지
    const limit = 8; // 페이지당 항목 수
    const offset = (page - 1) * limit; // 시작 위치
    const search = req.query.search || ''; // 검색어
    const category = req.query.category || 'title'; // 검색 카테고리

    // 검색 쿼리 동적으로 생성
    let countSql = `SELECT COUNT(*) AS totalCount FROM sk_board WHERE ${category} LIKE ?`;
    let sql = `
        SELECT b.*, COUNT(c.id) AS comment_count
        FROM sk_board b
        LEFT JOIN comments c ON b.id = c.post_id
        WHERE ${category} LIKE ?
        GROUP BY b.id
        ORDER BY b.id DESC
        LIMIT ? OFFSET ?
    `;

    db.query(countSql, [`%${search}%`], (err, countData) => {
        if (err) {
            console.log(err);
            return res.send('전송 오류');
        }

        const totalCount = countData[0].totalCount; // 총 게시글 수
        const totalPages = Math.ceil(totalCount / limit); // 전체 페이지 수

        db.query(sql, [`%${search}%`, limit, offset], (err, data) => {
            if (!err) {
                res.send({ data, totalPages }); // 데이터와 totalPages를 반환
            } else {
                console.log(err);
                res.send('전송 오류');
            }
        });
    });
});


//게시물 하나 가져오기 :id
app.get('/detail/:id', (req, res)=>{
    const id = req.params.id;
    const sql = `select * from sk_board where id=${id}`;
    db.query(sql, (err, data)=>{
        if(!err){
            res.send(data);
        }else{
            console.log(err);
            res.send('전송오류');
        }
    })
})

//게시물 삭제
app.post('/delete/:id', (req, res)=>{
    const id = req.params.id;
    const sql = `delete from sk_board where id=${id}`
    db.query(sql, (err, data)=>{
        if(!err){
            res.send(data);
        }else{
            console.log(err);
            res.send('전송오류');
        }
    })
})

//게시글 등록
app.post('/write', (req, res)=>{
    const {title, writer, contents } = req.body;

    const sql = `insert into sk_board(title, writer, contents) value (?,?,?)`;
    db.query(sql, [title, writer, contents], (err, data)=>{
        if(!err){
            // res.send("OK");
            res.sendStatus(200); //전송잘됨.
        }else{
            console.log(err);
            res.send('전송오류');
        }
    })

})

//게시글 수정
app.get('/modify/:id', (req, res)=>{
    //파라미터 가져오기
    const id = req.params.id;
    console.log(`/modify/${id}`);
    const sql = `select * from sk_board where id=${id}`;
    db.query(sql, (err, data)=>{
        if(!err){
            res.send(data);
        }else{
            console.log(err);
            res.send('전송오류');
        }
    })
})
app.post('/modify/:id', (req, res) => {
    const id = req.params.id;
    const { title, writer, contents } = req.body;
    console.log(`/modify/${id}`);
    const sql = `update sk_board set title=?, writer=?, contents=? where id=?`;
    db.query(sql, [title, writer, contents, id], (err, data) => {
        if (!err) {
            res.sendStatus(200); // 수정잘됨.
        } else {
            console.log(err);
            res.send('전송오류');
        }
    });
});

//조회수
app.post('/views/:id', (req, res) => {
    const id = req.params.id;
    const sql = `update sk_board set views = views + 1 where id =${id}`;
    console.log(`/views/${id}`);
    db.query(sql, (err, data)=>{
        if(!err){
            res.send(data);
        }else{
            console.log(err);
            res.send('전송오류');
        }
    })
});

//댓글
app.get('/comments/:postId', (req, res) => {
    const postId = req.params.postId;
    const sql = 'SELECT * FROM comments WHERE post_id = ? ORDER BY created_at DESC';
    db.query(sql, [postId], (err, data) => {
        if (!err) {
            res.send(data);
        } else {
            console.log(err);
            res.send('전송오류');
        }
    });
});
app.post('/comments', (req, res) => {
    const { postId, user, content } = req.body;
    const sql = 'INSERT INTO comments (post_id, user, content) VALUES (?, ?, ?)';
    db.query(sql, [postId, user, content], (err, data) => {
        if (!err) {
            res.sendStatus(200); // 댓글 등록 성공
        } else {
            console.log(err);
            res.send('전송오류');
        }
    });
});
app.delete('/comments/:id', (req, res) => {
    const id = req.params.id;
    const sql = 'DELETE FROM comments WHERE id = ?';
    db.query(sql, [id], (err, data) => {
        if (!err) {
            res.sendStatus(200); // 댓글 삭제 성공
        } else {
            console.log(err);
            res.send('전송오류');
        }
    });
});

🔷 Express를 사용해 서버를 생성하고, MySQL DB에 연결.
게시판 기능:

  👉🏻 게시글 목록 조회: 페이지네이션, 검색기능, 게시글 목록
  👉🏻 게시글 상세 조회
  👉🏻 게시글 작성
  👉🏻 게시글 수정
  👉🏻 게시글 삭제
  👉🏻 조회수 업데이트
  👉🏻 댓글 기능: 조회, 작성, 삭제

 

 

📢 라우팅 설정

// src/components/BoardHome.jsx
import React from 'react';
import { BrowserRouter, Route, Routes } from "react-router-dom";
import BoardList from './BoardList';
import BoardDetail from './BoardDetail';
import BoardWrite from './BoardWrite';
import BoardModify from './BoardModify';
import shibainuImage from './shibainu.png';

const BoardHome = () => {
    return (
        <div className='boardHome'>
            <h1>
                <span>SK React Board</span>
                <img src={shibainuImage} />
            </h1>
            <hr />
            <BrowserRouter>
                <Routes>
                    <Route path='/' element={<BoardList />} />
                    <Route path='/list' element={<BoardList />} />
                    <Route path='/detail/:id' element={<BoardDetail />} />
                    <Route path='/write' element={<BoardWrite />} />
                    <Route path='/modify/:id' element={<BoardModify />} />                 
                </Routes>
            </BrowserRouter>
        </div>
    );
};

export default BoardHome;

🔷 게시판 기본 구조를 설정함

 

 

📢 게시글 목록

// src/components/BoardList.jsx
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';

const BoardList = () => {
    const [boardList, setBoardList] = useState([]);
    const [currentPage, setCurrentPage] = useState(1);
    const [totalPages, setTotalPages] = useState(1);
    const [pageGroup, setPageGroup] = useState(0);
    const [searchTerm, setSearchTerm] = useState('');
    const [searchCategory, setSearchCategory] = useState('title'); 
    const [loading, setLoading] = useState(true);

    const location = useLocation();

    const getQueryParams = () => {
        const params = new URLSearchParams(location.search);
        return {
            page: parseInt(params.get('page')) || 1,
            search: params.get('search') || '',
            category: params.get('category') || 'title' 
        };
    };

    const getBoardData = async (page, search = '', category = 'title') => {
        setLoading(true);
        try {
            const response = await axios(`/list?page=${page}&search=${search}&category=${category}`);
            setBoardList(response.data.data);
            setTotalPages(response.data.totalPages);
        } catch (error) {
            console.log(error);
        } finally {
            setLoading(false);
        }
    };

    useEffect(() => {
        const { page, search, category } = getQueryParams();
        setCurrentPage(page);
        setSearchTerm(search);
        setSearchCategory(category);
        getBoardData(page, search, category);
    }, [location.search]);

    const handleSearch = () => {
        setCurrentPage(1);
        getBoardData(1, searchTerm, searchCategory);
    };

    const navigate = useNavigate();

    const trClick = async (id) => {
        try {
            await axios.post(`/views/${id}`);
            navigate(`/detail/${id}`);
        } catch (error) {
            console.log(error);
        }
    };

    const handlePageChange = (newPage) => {
        setCurrentPage(newPage);
        navigate(`?page=${newPage}&search=${searchTerm}&category=${searchCategory}`);
    };

    const getPageButtons = () => {
        const buttons = [];
        const startPage = pageGroup * 10 + 1;
        const endPage = Math.min(startPage + 9, totalPages);

        for (let i = startPage; i <= endPage; i++) {
            buttons.push(
                <button
                    key={i}
                    onClick={() => handlePageChange(i)}
                    disabled={currentPage === i}
                >
                    {i}
                </button>
            );
        }

        return buttons;
    };

    if (loading) {
        return <div>Loading...</div>;
    }

    return (
        <div className='boardList'>
            <h2>📢 상호 존중하고 배려하며, 비방 금지</h2>
            <div className='searchs'>
                <select value={searchCategory} onChange={(e) => setSearchCategory(e.target.value)}>
                    <option value="title">제목</option>
                    <option value="contents">내용</option>
                    <option value="writer">작성자</option>
                </select>
                <input
                    type="text"
                    placeholder="검색어 입력..."
                    value={searchTerm}
                    onChange={(e) => setSearchTerm(e.target.value)}
                />
                <button onClick={handleSearch}>검색</button>
            </div>
            <table>
                <thead>
                    <tr>
                        <th>번호</th>
                        <th>제목</th>
                        <th>작성자</th>
                        <th>작성일</th>
                        <th>조회</th>
                    </tr>
                </thead>
                <tbody>
                    {boardList.length > 0 ? (
                        boardList.map(l => (
                            <tr key={l.id}>
                                <td>{l.id}</td>
                                <td className='title' onClick={() => trClick(l.id)}>
                                    {l.title}
                                    <strong>
                                        {l.comment_count > 0 && ` [${l.comment_count}]`}
                                    </strong>
                                </td>
                                <td>{l.writer}</td>
                                <td>{l.reg_date.substring(0, l.reg_date.indexOf("T"))}</td>
                                <td>{l.views}</td>
                            </tr>
                        ))
                    ) : (
                        <tr>
                            <td colSpan="5" style={{ textAlign: 'center' }}>게시글이 없습니다.</td>
                        </tr>
                    )}
                </tbody>
            </table>
            <div className='pagination'>
                {pageGroup > 0 && <button onClick={() => setPageGroup(prev => Math.max(prev - 1, 0))}>이전</button>}
                {getPageButtons()}
                {pageGroup < Math.ceil(totalPages / 10) - 1 && <button onClick={() => setPageGroup(prev => prev + 1)}>다음</button>}
            </div>
            <div className='listBot'>
                <Link to={`/write`}><button>글쓰기</button></Link>
            </div>
        </div>
    );
};

export default BoardList;

🔷 게시글 목록 페이지 컴포넌트

  👉🏻 게시글 목록, 현재 페이지, 총 페이지 수, 검색어 및 카테고리 등의 상태 관리

  👉🏻 Axios를 사용, 서버에서 데이터를 비동기로 가져옴.

  👉🏻 페이지, 검색어, 카테고리를 URL 쿼리 처리

  👉🏻 검색어와 카테고리에 따라 게시글 목록 필터링

  👉🏻 페이지 번호 버튼 생성, 페이지 변경 가능

  👉🏻 제목 클릭 시 상세 페이지 이동

  👉🏻 글쓰기 버튼 제공

 

 

📢 게시글 상세보기

// src/components/BoardDetail.jsx
import React, { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import axios from 'axios';
import Comment from './Comment';

const BoardDetail = () => {
    const { id } = useParams();
    const [board, setBoard] = useState(null);
    
    const getBoard = async () => {
        try {
            const res = await axios(`/detail/${id}`);
            setBoard(res.data[0]);
        } catch (error) {
            console.log(error);
        }
    };

    useEffect(() => {
        getBoard();
    }, []);

    const onDelete = async () => {
        if (window.confirm('정말 삭제 하시겠습니까?')) {
            try {
                await axios.post(`/delete/${id}`);
                window.location.href = "/list";
            } catch (e) {
                console.log(e);
            }
        }
    };

    if (board != null) {
        return (
            <div className='boardDetail'>
                <h2>No.{board.id} / 게시글 상세</h2>
                <div className='contents'>
                    <h3>{board.title}</h3>
                    <div className='writer'>
                        <span>{board.writer} </span>
                        <span>[{board.reg_date.substring(0, board.reg_date.indexOf("T"))}]</span>
                        <span>조회:{board.views}</span>
                    </div>
                    <div className='con'>{board.contents}</div>
                </div>
                <div className='btns'>
                    <Link to={`/modify/${board.id}`}><button>수정</button></Link>
                    <button onClick={onDelete}>삭제</button>
                    <Link to={`/`}><button>목록</button></Link>
                </div>
                <Comment postId={board.id} />
            </div>
        );
    }

    return <div>Loading...</div>; // 로딩 상태 표시
};

export default BoardDetail;

🔷 게시글 상세보기 컴포넌트

  👉🏻 제목, 작성자, 작성일, 조회수, 내용 등의 상세 정보 표시.

  👉🏻 useParams를 통해 URL에서 게시글 ID를 받아와 Axios로 데이터를 서버에서 가져옴

  👉🏻 게시글 데이터를 저장하여 상태관리(useState)

  👉🏻 삭제 버튼 누를 시, 확인 메시지 띄우고 삭제 진행. => 게시글 목록으로 리다이렉트
  👉🏻 게시글 수정, 목록 버튼
  👉🏻 Comment 컴포넌트로 해당 게시글에 대한 댓글 표시

 

 

📢 댓글

// src/components/Comment.jsx
import axios from 'axios';
import React, { useEffect, useState } from 'react';

const Comment = ({ postId }) => {
    const [comments, setComments] = useState([]);
    const [newComment, setNewComment] = useState({ user: '', content: '' });

    useEffect(() => {
        fetchComments();
    }, []);

    const fetchComments = async () => {
        const response = await axios.get(`/comments/${postId}`);
        setComments(response.data);
    };

    const handleCommentSubmit = async (e) => {
        e.preventDefault();
        await axios.post('/comments', { postId, ...newComment });
        setNewComment({ user: '', content: '' });
        fetchComments();
    };

    const handleDeleteComment = async (commentId) => {
        if (window.confirm('이 댓글을 삭제하시겠습니까?')) {
            await axios.delete(`/comments/${commentId}`);
            fetchComments(); // 삭제 후 댓글 목록 새로고침
        }
    };

    return (
        <div className='comment'>
            <form onSubmit={handleCommentSubmit}>
                <input
                    className='cmt-writer'
                    type="text"
                    placeholder="작성자.."
                    value={newComment.user}
                    onChange={(e) => setNewComment({ ...newComment, user: e.target.value })}
                    required
                />
                <input
                    className='cmt-contents'
                    type='text'
                    placeholder="댓글 내용.."
                    value={newComment.content}
                    onChange={(e) => setNewComment({ ...newComment, content: e.target.value })}
                    required
                />
                <button className='com-submit'>댓글등록</button>
            </form>
            <ul>
                {comments.map(comment => (
                    <li key={comment.id}>
                        <span>{comment.user}</span><br /><strong>{comment.content} </strong>
                        <button onClick={() => handleDeleteComment(comment.id)}>X</button>
                    </li>
                ))}
            </ul>
        </div>
    );
};

export default Comment;

🔷 댓글 컴포넌트

  👉🏻 useEffect를 사용하여 컴포넌트가 처음 렌더링될 때 fetchComments 함수 호출하여, 댓글을 서버에서 가져옴.

  👉🏻 handleCommentSubmit 함수로 새로운 댓글을 서버에 POST 요청으로 전송, 입력 필드 초기화, 댓글 목록 갱신.

  👉🏻 X버튼 클릭하여 handleDeleteComment 함수 호출, 해당 댓글 삭제 후 댓글 목록 갱신.

 

 

📢 게시글 작성

// src/components/BoardWrite.jsx
import React, { useState } from 'react';
import axios from 'axios';
import { Link } from 'react-router-dom';

const BoardWrite = () => {

    const [board, setBoard] = useState({
       title: '',
       writer: '',
       contents: '' 
    });  

    const { title, writer, contents } = board;

    const onChange = (e) =>{
        const { name, value } = e.target;
        setBoard({
            ...board,
            [name]:value
        });
    };

    const onReset =()=>{
        setBoard({
            ...board,
            title: '',
            writer: '',
            contents: ''
        });
    };

    const onCreate = async ()=>{
        if(title === ''){
            alert('제목을 입력해주세요.');
            return;
        }
        if(writer === ''){
            alert('작성자를 입력해주세요.');
            return;
        }
        if(contents === ''){
            alert('내용을 입력해주세요.');
            return;
        }
        if(window.confirm('등록 하시겠습니까?')){
            try {
                await axios.post('/write', board);
                window.location.href ="/list";
            } catch (error) {
                console.log(error);
            }
        }
    }


    return (
        <div className='boardWrite'>
            <h2>게시글 작성</h2>
            <form action="" className='contents'>
                <div className='wrtCon'>
                    <input type="text" name='title'  value={title} placeholder='Title...' className='input-field' onChange={onChange} />
                    <input type="text" name='writer' value={writer} placeholder='Writer...' className='input-field' onChange={onChange} />
                    <textarea name='contents' value={contents} placeholder='Contents...' className='textarea-field' onChange={onChange} />
                </div>
                <div className='btns'>
                    <button onClick={onCreate} type='button'>제출</button>
                    <button onClick={onReset}>초기화</button>
                    <Link to={`/`}><button>목록</button></Link>
                </div>
            </form>
        </div>
    );
};

export default BoardWrite;

🔷 게시글 작성 컴포넌트

  👉🏻 사용하여 제목(title), 작성자(writer), 내용(contents)을 포함하는 board 객체의 상태

  👉🏻 onChange로 입력 필드에서 변경된 값을 받아 board 상태를 업데이트. (각 필드는 name 속성을 통해 어떤 값을 업데이트할지 결정)
  👉🏻 onReset 함수는 입력 필드를 초기화
  👉🏻 onCreate 함수는 제목, 작성자, 내용이 모두 입력되었는지 확인한 후, 확인창을 띄우고 사용자가 승인하면 axios를 통해 서버에 게시글 데이터를 POST 요청으로 전송. 전송 후에는 목록 페이지로 리다이렉트

 

 

📢 게시글 수정

// src/components/BoardModify.jsx
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';

const BoardModify = () => {

    const { id } = useParams();
    
    const [ mod, setMod ] = useState(null);

    const onChange = (e)=>{
        const {name, value} = e.target;
        setMod({
            ...mod,
            [name]:value
        });
    };

    const getBoard = async()=>{
        try {
            const res = await axios(`/modify/${id}`);
            setMod(res.data[0]);
        } catch (e) {
            console.log(e);
        }
    }

    useEffect(()=>{
        getBoard();
    },[]);

    const onUpdate = async()=>{
        if(mod.title === ''){
            alert('title is null!!');
            return;
        }
        if(mod.writer === ''){
            alert('writer is null!!!');
            return;
        }
        if(mod.contents === ''){
            alert('contents is null!!!');
            return;
        }
        if(window.confirm('수정 하시겠습니까?')){
            try {
                const res = await axios.post(`/modify/${id}`, mod);
                console.log(res);
                window.location.href =`/detail/${id}`;
            } catch (error) {
                console.log(error);
            }
        }
    }

    if(mod !==null){
        return (
            <div className='boardModify'>
                <h2>게시글 수정</h2>
                <form action="" className='contents'>
                    <div className='wrtCon'>
                        <input type="text" name='title'  value={mod.title} placeholder='Title...' className='input-field' onChange={onChange} />
                        <input type="text" name='writer' value={mod.writer} placeholder='Writer...' className='input-field' onChange={onChange} />
                        <textarea name='contents' value={mod.contents} placeholder='Content...' className='textarea-field' onChange={onChange} />
                    </div>
                    <div className='btns'>
                        <button type='button' onClick={onUpdate}>제출</button>
                        <Link to={`/`}><button>목록</button></Link>
                    </div>
                </form>
            </div>
        );
    }
};

export default BoardModify;

🔷 게시글 수정 컴포넌트

  👉🏻 수정할 게시글(mod)의 상태를 관리

  👉🏻 useParams를 통해 URL에서 게시글 ID 추출.
  👉🏻 getBoard 함수로 서버에 요청을 보내 게시글 정보를 가져와 상태 업데이트. useEffect로 컴포넌트가 마운트될 때 이 함수를 호출.

  👉🏻 onChange 함수는 입력 필드에서 변경된 값을 받아 mod 상태를 업데이트. name 속성을 통해 어떤 값을 업데이트할지 결정.
  👉🏻 onUpdate 함수는 제목, 작성자, 내용이 모두 입력되었는지 확인창을 띄우고 사용자가 승인하면 axios를 통해 수정된 데이터를 서버에 POST 요청으로 전송. 수정 후에는 상세 페이지로 리다이렉트.

 

 

📢 결과