[나만무] Jungle-Board 기본 CRUD 구현하기

2025. 6. 17. 13:32·크래프톤 정글/Equipped in 정글(나만무)

Jungle-Board 기본 CRUD 구현하기 (by Next.js)

강의를 마저 듣고 게시판을 만들어볼까 하는 마음도 있었지만, 당장 배운 내용들을 적용해서 눈에 보이는 결과물을 빨리 만들어보고 싶다는 생각이 들어 기본 CRUD가 구현된 게시판을 먼저 만들어보게 되었습니다. 추후 로그인, 회원가입 등이 완성되면 이를 통해 본인만 수정, 삭제하게 만든다던가 하는 기능들을 추가하게 될 것 같습니다.

 

 

게시판 글 목록 페이지

글 목록 페이지는 전형적인 서버 컴포넌트(Server Component)로 구현했습니다.

브라우저에서 보여지는 게시글 목록 페이지

page.js 코드

import Link from 'next/link';
import styles from './page.module.css';
import formatDate from '@/util/util';
import { headers } from 'next/headers'

export default async function Board() {
  const host = headers().get('host')
  const protocol = process.env.NODE_ENV === 'development' ? 'http' : 'https'
  const result = await fetch(`${protocol}://${host}/api/post`, { method: 'GET', });
  const posts = await result.json();

  return (
    <div className={styles.content}>
      <h3 style={{ paddingLeft: '20px' }}>그냥게시판</h3>
      <div className={styles.community_header}>
        <form action="/searchList.cboard" className={styles.searchForm}>
          <select name="searchType" className={styles.searchSelect}>
            <option value="">검색 유형</option>
            <option value="title">제목</option>
            <option value="writer">작성자</option>
          </select> <input type="text" name="searchInput" className={styles.searchInput}
            autoComplete="off" />
          <button className={styles.searchBtn}>
            <i className="fa-solid fa-magnifying-glass" style={{ color: '#111' }}></i>
          </button>
        </form>
      </div>
      <div className={styles.board_list} >
        {
          posts.map((post, i) => {
            return (
              <div className={styles.board_item} key={i}>
                <Link href={`/detail/${post._id}`} className={styles.title}>
                  {post.title}
                </Link>
                <div className={styles.writer}>{post.writer}</div>
                <div className={styles.date}>{formatDate(post.date)}</div>
                <div className={styles.view}>
                  <i className="fa-regular fa-eye" style={{ color: "#000000" }}></i> {post.view}</div>
              </div>
            )
          })
        }
      </div>
      <div className={styles.buttonBox}>
        <Link href={'/write'} style={{ color: 'white', textDecoration: 'none' }}>
          <button className={styles.writeBtn}>글쓰기</button>
        </Link>
      </div>
    </div>
  );
}

page.module.css 코드

.content {
  position: relative;
  margin: 63px auto 0 auto;
  width: 1300px;
  min-height: 500px;
}

.community_header {
  position: relative;
  margin-top: 23px;
  width: 1300px;
  height: 58px;
  border-bottom: 1px solid #a3a3a7;
}

.community_header .searchForm {
  display: flex;
  justify-content: end;
  align-items: center;
}

.community_header .searchInput {
  padding: 0;
  margin: 0;
  width: 200px;
  height: 42px;
  border: none;
  border: 1px solid #5a5a64;
  border-right: 0;
  font-size: 14px;
  line-height: 53px;
  text-indent: 20px;
}

.community_header .searchInput:focus {
  outline: none;
}

.community_header .searchBtn {
  height: 42px;
  background: #fff;
  border: 1px solid #5a5a64;
  border-left: 0;
}

.searchSelect {
  position: relative;
  margin: 0;
  width: 120px;
  height: 42px;
  z-index: 99;
  font-size: 14px;
  font-weight: 400;
  color: #6a6e76;
  z-index: 1;
  padding: 0 0 0 13px;
}

.searchSelect:focus {
  outline: none;
}

.board_list {
  min-height: 400px;
}

.board_list .board_item {
  display: flex;
  align-items: center;
  padding: 17px 0 16px 0;
  border-bottom: 1px solid #eeedf2;
}

.board_list .board_item .title {
  flex: 5;
  padding-left: 40px;
  color: black;
  text-decoration: none;
}

.board_list .board_item .writer {
  flex: 1;
}

.board_list .board_item .date {
  flex: 1;
  padding-left: 20px;
}

.board_list .board_item .view {
  flex: 1;
  padding-left: 20px;
}

.buttonBox {
  margin-top: 30px;
  text-align: right;
}

.buttonBox .writeBtn {
  width: 160px;
  height: 45px;
  background: #3392ff;
  color: white;
  border: 1px solid #1a6ed8;
  font-size: 13px;
  font-weight: bold;
}

 

 

게시글 작성 페이지

게시글 작성 페이지의 경우, form 태그를 통해 처리 후 redirect를 통해 글 목록 페이지로 이동시켰는데, 이 경우 무조건 페이지 이동을 해야 하고, 사용자의 입력에 대한 예외 처리가 어렵다는 문제가 있었습니다.

 

이에 유저 컴포넌트로 전환한 뒤, Ajax를 이용해 처리 후 useRouter를 통해 글 목록 페이지로 이동시키게 바꾸게 되었습니다. 게시글 작성 페이지의 경우 DB에서 가져올 데이터도 없고, SEO와도 관련 없으므로 유저 인터렉션이 강한 유저 컴포넌트가 적절하다고 판단했습니다.

브라우저에서 보이는 글 작성 페이지

page.js 코드

'use client'

import { useState } from 'react';
import styles from './page.module.css';
import { useRouter } from 'next/navigation';

export default function Write() {
    const [title, setTitle] = useState('');
    const [content, setContent] = useState('');
    const writer = 'tbxjvkdldj';    // 나중에 jwt 도입 후 변경

    const router = useRouter();

    const handleSubmit = async () => {
        if (title.trim() == '') {
            alert('제목을 입력해주세요!');
            return;
        } else if (content.trim() == '') {
            alert('내용을 입력해주세요!');
            return;
        }

        const resp = await fetch('/api/post', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                title: title,
                content: content,
                writer: writer
            }),
        });
        const json = await resp.json();

        if (json.success) {
            router.push('/');
            setTimeout(() => {
                location.reload();
            }, 100)
        } else {
            alert('작성 실패: ' + json.message);
        }
    }

    return (
        <div className={styles.content}>
            <h3 style={{ paddingLeft: '20px' }}>{writer}님의 글</h3>
            <div className={styles.title}>
                <input type="text" placeholder="제목을 입력해 주세요 (최대 30자까지 입력 가능)"
                    maxLength="30" onChange={(e) => setTitle(e.target.value)} />
            </div>
            <div className={styles.editorBox}>
                <textarea className={styles.editor} placeholder='내용을 입력해주세요'
                    onChange={(e) => setContent(e.target.value)}></textarea>
            </div>
            <div className={styles.btnBox}>
                <button type="button" className={styles.cancelPostButton} onClick={() => {
                    let isCancel = confirm('작성을 취소하시겠습니까?');

                    if (isCancel) {
                        router.back();
                    }
                }}>취소</button>
                <button className={styles.writePostButton} onClick={handleSubmit}>등록</button>
            </div>
        </div>
    )
}

page.module.css 코드

.content {
  position: relative;
  margin: 63px auto 0 auto;
  width: 1300px;
  min-height: 500px;
}

.title {
  border-top: 1px solid #eeedf2;
  margin-top: 23px;
  width: 1300px;
  height: 80px;
}

.title input {
  width: 100%;
  height: 80px;
  line-height: 70px;
  border: none;
  font-size: 16px;
  text-indent: 20px;
  border-bottom: 1px solid #eeedf2;
}

.title input:focus {
  outline: none;
}

.editorBox {
  padding-top: 10px;
}

.editor {
  width: 100%;
  min-height: 500px;
  font-size: 18px;
  padding: 20px 0 0 20px;
  border-color: #e3e1e1;
  resize: none;
}

.editor:focus {
  outline: none;
}

.btnBox {
  margin-top: 10px;
  display: flex;
  justify-content: center;
}

.btnBox .cancelPostButton {
  width: 160px;
  height: 45px;
  background: gray;
  color: #fff;
  border: 1px solid #888;
  font-size: 13px;
  font-weight: bold;
  margin-right: 10px;
}

.btnBox .writePostButton {
  width: 160px;
  height: 45px;
  background: #3392ff;
  color: white;
  border: 1px solid #1a6ed8;
  font-size: 13px;
  font-weight: bold;
}

 

 

게시글 상세페이지

상세페이지 같은 경우에도 사용자 경험 개선을 위해 서버 컴포넌트로 만들어 데이터를 받아와서 바로 보여주게끔 만들었습니다. 다만 삭제 버튼 같은 경우 삭제 처리 이후 useRouter를 사용해 게시글 목록으로 보내주기 위해 유저 컴포넌트로 만들게 되었습니다.

브라우저에서 보이는 게시글 상세페이지

page.js 코드

import styles from './page.module.css';
import formatDate from '@/util/util';
import Link from 'next/link';
import DeleteButton from './DeleteButton';
import { headers } from 'next/headers'

export default async function Detail(props) {
    const resolvedParams = await props.params
    let _id = resolvedParams.id;

    const host = headers().get('host')
    const protocol = process.env.NODE_ENV === 'development' ? 'http' : 'https'
    const resp = await fetch(`${protocol}://${host}/api/post/${_id}`, {
        method: 'GET',
        cache: 'no-store',
    });
    const result = await resp.json();

    return (
        <div className={styles.content}>
            <h3 style={{ paddingLeft: '20px' }}>그냥게시판</h3>
            <div className={styles.headerBox}>
                <div className={styles.titleBox}>{result.title}</div>
            </div>
            <div className={styles.infoBox}>
                <div className={styles.leftInfo}><i className="fa-solid fa-seedling" style={{ color: "#000000", marginRight: '10px' }}></i> {result.writer}</div>
                <div className={styles.rightInfo}>
                    <div className={styles.dateBox}>작성일 {formatDate(result.date)}</div>
                    <div className={styles.viewBox}>조회수 {result.view}</div>
                </div>
            </div>
            <div className={styles.contentBox}>{result.content}</div>
            <div className={styles.buttonBox}>
                <div className={styles.leftButtonBox}>
                    <Link href={'/'} style={{ color: 'white', textDecoration: 'none' }}>
                        <button type="button" className={styles.goListBtn}>목록</button>
                    </Link>
                </div>
                <div className={styles.rightButtonBox}>
                    <DeleteButton id={_id} />
                    <Link href={`/edit/${_id}`} style={{ color: 'white', textDecoration: 'none' }}>
                        <button type="button" className={styles.corBtn}>수정</button>
                    </Link>
                </div>
            </div>
        </div>
    )
}

DeleteButton.js 코드

'use client';

import { useRouter } from "next/navigation";
import styles from './page.module.css';

export default function DeleteButton(props) {
    const router = useRouter();

    return (
        <button className={styles.delBtn}
            type="button"
            onClick={async () => {
                const isDelete = confirm('정말 이 글을 삭제하시겠습니까?');
                if (isDelete) {
                    const resp = await fetch(`/api/post/${props.id}`, { method: 'DELETE' });
                    const json = await resp.json();

                    if (json.success) {
                        alert('게시글이 삭제되었습니다.');
                        router.push('/');
                        setTimeout(() => {
                            location.reload();
                        }, 100);
                    } else {
                        alert('삭제 실패!');
                    }
                }
            }}
        >삭제</button>
    );
}

page.module.css 코드

.content {
  position: relative;
  margin: 63px auto 0 auto;
  width: 1300px;
  min-height: 500px;
}

.headerBox {
  position: relative;
  margin-top: 23px;
  width: 1300px;
  border: 1px solid #e0e2ec;
}

.titleBox {
  display: flex;
  padding-left: 30px;
  height: 139px;
  color: #36393f;
  font-size: 22px;
  line-height: 34px;
  align-items: center;
}

.infoBox {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 59px;
  color: #6a6e76;
  font-size: 14px;
  font-weight: 500;
  border: 1px solid #e0e2ec;
  border-top: 0;
}

.leftInfo {
  flex: 4;
  padding-left: 30px;
}

.rightInfo {
  display: flex;
  flex: 1;
}

.rightInfo .dateBox {
  flex: 1;
}

.rightInfo .viewBox {
  flex: 1;
  text-align: center;
}

.contentBox {
  padding: 45px 0 150px 30px;
  color: #36393f;
  font-size: 16px;
  line-height: 30px;
  font-weight: 400;
  min-height: 500px;
  overflow-y: auto;
  border-bottom: 1px solid #e0e2ec;
}

.buttonBox {
  margin-top: 10px;
}

.buttonBox .leftButtonBox {
  float: left;
}

.buttonBox .rightButtonBox {
  float: right;
}

.goListBtn {
  width: 120px;
  height: 45px;
  background: #444;
  color: #fff;
  border: 1px solid #555;
  font-size: 13px;
  font-weight: bold;
  margin-right: 10px;
}

.delBtn {
  width: 120px;
  height: 45px;
  background: rgb(242, 51, 51);
  color: #fff;
  border: 1px solid #f90000;
  font-size: 13px;
  font-weight: bold;
  margin-right: 10px;
}

.corBtn {
  width: 120px;
  height: 45px;
  background: #3392ff;
  color: #fff;
  border: 1px solid #1a6ed8;
  font-size: 13px;
  font-weight: bold;
  margin-right: 10px;
}

 

 

게시글 수정페이지

브라우저에서 보이는 게시글 수정페이지

글 수정페이지는 원래 유저 컴포넌트로 만든 뒤, useEffect에서 해당 원본 게시글 정보를 서버에 요청에 받아온 뒤 채우는 방식으로 구현했었는데요. 이 경우 빈 HTML이 먼저 보이고 그 다음에 내용이 채워지는 문제가 있었습니다.

 

이를 Next.js의 장점인 하이브리드 방식으로 바꿔서, 원본 게시글 정보는 서버 컴포넌트에서 받아온 뒤, 이를 실제 페이지(유저 컴포넌트)에 props로 전달해 바로 띄울 수 있게끔 했습니다. 이는 전형적인 CSR 구조적 한계를 가진 기본 React에서는 할 수 없는, Next.js 만의 SSR + CSR 하이브리드 구조라고 할 수 있습니다.

 

page.js (서버 컴포넌트) 코드

import Edit from './Edit';
import { connectDB } from '@/util/database';
import { ObjectId } from 'mongodb';

export default async function EditPost(props) {
    const resolvedParams = await props.params
    let _id = resolvedParams.id;

    const db = (await connectDB).db('board')
    let post = await db.collection('post').findOne({ _id: new ObjectId(String(_id)) });
    post = { ...post, _id: String(_id) }

    return (
        <Edit post={post} />
    )
}

Edit.js (하위 유저 컴포넌트) 코드

'use client'
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import styles from './page.module.css';

export default function EditPage({ post }) {
    const [title, setTitle] = useState(post.title);
    const [content, setContent] = useState(post.content);
    const writer = 'tbxjvkdldj'; // 추후 JWT로 교체

    const router = useRouter();

    const handleSubmit = async () => {
        if (title.trim() == '') {
            alert('제목을 입력해주세요!');
            return;
        }
        if (content.trim() == '') {
            alert('내용을 입력해주세요!');
            return;
        }

        try {
            const resp = await fetch(`/api/post/${post._id}`, {
                method: 'PUT',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    _id: post._id,
                    title: title,
                    content: content,
                }),
            });

            const json = await resp.json();

            if (json.success) {
                alert('게시글을 수정했습니다.');
                router.push(`/detail/${post._id}`);
            } else {
                alert('수정 실패: ' + json.message);
            }
        } catch (err) {
            alert('에러 발생: ' + err.message);
        }
    };

    return (
        <div className={styles.content}>
            <h3 style={{ paddingLeft: '20px' }}>게시글 수정하기</h3>
            <div className={styles.title}>
                <input
                    type="text"
                    maxLength="30"
                    value={title}
                    onChange={(e) => setTitle(e.target.value)}
                    placeholder="제목을 입력해 주세요 (최대 30자까지 입력 가능)"
                />
            </div>
            <div className={styles.editorBox}>
                <textarea
                    className={styles.editor}
                    value={content}
                    onChange={(e) => setContent(e.target.value)}
                    placeholder="내용을 입력해주세요"
                />
            </div>
            <div className={styles.btnBox}>
                <button type="button" className={styles.cancelPostButton} onClick={() => {
                    let isCancel = confirm('수정을 취소하시겠습니까?');

                    if (isCancel) {
                        router.back();
                    }
                }}>
                    수정취소
                </button>
                <button className={styles.writePostButton} onClick={handleSubmit}>
                    수정완료
                </button>
            </div>
        </div>
    );
}

page.module.css 코드

.content {
  position: relative;
  margin: 63px auto 0 auto;
  width: 1300px;
  min-height: 500px;
}

.title {
  border-top: 1px solid #eeedf2;
  margin-top: 23px;
  width: 1300px;
  height: 80px;
}

.title input {
  width: 100%;
  height: 80px;
  line-height: 70px;
  border: none;
  font-size: 16px;
  text-indent: 20px;
  border-bottom: 1px solid #eeedf2;
}

.title input:focus {
  outline: none;
}

.editorBox {
  padding-top: 10px;
}

.editor {
  width: 100%;
  min-height: 500px;
  font-size: 18px;
  padding: 20px 0 0 20px;
  border-color: #e3e1e1;
  resize: none;
}

.editor:focus {
  outline: none;
}

.btnBox {
  margin-top: 10px;
  display: flex;
  justify-content: center;
}

.btnBox .cancelPostButton {
  width: 160px;
  height: 45px;
  background: gray;
  color: #fff;
  border: 1px solid #888;
  font-size: 13px;
  font-weight: bold;
  margin-right: 10px;
}

.btnBox .writePostButton {
  width: 160px;
  height: 45px;
  background: #3392ff;
  color: white;
  border: 1px solid #1a6ed8;
  font-size: 13px;
  font-weight: bold;
}

 

 

API 구현하기

API의 경우, 게시글 ID가 필요한 작업과 필요하지 않은 작업을 나누었습니다. '/api/post'에서는 게시글 ID가 필요하지 않는 작업인 DB에서 모든 게시글 조회, DB에 새 글 추가 기능을 구현하였습니다.

 

그리고 '/api/post/[id]'에서는 게시글 ID가 필요한 작업, 즉 단일 게시글 조회, 게시글 수정, 게시글 삭제 기능을 구현하였습니다.

 

post.js (게시글 ID 불필요) 코드 

import { connectDB } from '@/util/database';

export default async function board_post_handler(request, response) {
    const db = (await connectDB).db('board')

    if (request.method == 'GET') {
        const result = await db.collection('post').find().toArray();
        return response.status(200).json(result);
    }

    if (request.method == 'POST') {

        const body = request.body

        let document = {
            title: body.title,
            content: body.content,
            writer: body.writer,
            date: new Date(),
            view: 0
        }

        const result = await db.collection('post').insertOne(document)

        if (!result.acknowledged || !result.insertedId) {
            return response.status(500).json({ success: false, message: 'DB 오류!' });
        }

        return response.status(200).json({ success: true, message: '글 작성 완료!' });
    }
}

post/[id].js (게시글 ID 필요) 코드

import { connectDB } from '@/util/database';
import { ObjectId } from 'mongodb';

export default async function board_handler(request, response) {
    if (request.method == 'GET') {
        const _id = request.query.id;

        const db = (await connectDB).db('board')
        let result = await db.collection('post').findOne({ _id: new ObjectId(String(_id)) });

        return response.status(200).json(result);
    }

    if (request.method == 'PUT') {
        const body = request.body

        let edited_document = {
            title: body.title,
            content: body.content,
        }

        const db = (await connectDB).db('board')
        const result = await db.collection('post')
            .updateOne({ _id: new ObjectId(String(body._id)) }, { $set: edited_document })

        if (!result.acknowledged || result.matchedCount == 0) {
            return response.status(500).json({ success: false, message: 'DB 오류!' });
        }
        return response.status(200).json({ success: true, message: '글 수정 완료!' });
    }

    if (request.method == 'DELETE') {
        const db = (await connectDB).db('board')
        const result = await db.collection('post').deleteOne({ _id: new ObjectId(String(request.query.id)) });

        console.log(result);
        if (!result.acknowledged || result.deletedCount == 0) {
            return response.status(500).json({ success: false, message: '삭제 실패' });
        }
        return response.status(200).json({ success: true, message: '삭제 완료' });
    }
}

 

저작자표시 비영리 변경금지 (새창열림)

'크래프톤 정글 > Equipped in 정글(나만무)' 카테고리의 다른 글

[나만무] Jungle-Board 로그인/회원가입 기능 구현 및 게시판 권한 반영하기  (0) 2025.06.18
[나만무] 06.17 TIL - Next.js 학습 by 코딩애플  (0) 2025.06.17
[나만무] 06.16 TIL - Next.js 학습 by 코딩애플  (0) 2025.06.16
[나만무] 06.15 TIL - Next.js 학습 by 코딩애플  (0) 2025.06.15
[나만무] 06.14 TIL - Next.js 학습 by 코딩애플  (0) 2025.06.14
'크래프톤 정글/Equipped in 정글(나만무)' 카테고리의 다른 글
  • [나만무] Jungle-Board 로그인/회원가입 기능 구현 및 게시판 권한 반영하기
  • [나만무] 06.17 TIL - Next.js 학습 by 코딩애플
  • [나만무] 06.16 TIL - Next.js 학습 by 코딩애플
  • [나만무] 06.15 TIL - Next.js 학습 by 코딩애플
그냥사람_
그냥사람_
IT 관련 포스팅을 합니다. 크래프톤 정글 8기 정경호
  • 그냥사람_
    그냥코딩
    그냥사람_
  • 전체
    오늘
    어제
    • 글 전체보기 N
      • 크래프톤 정글 N
        • 로드 투 정글(입학시험)
        • CS기초(키워드, 개념정리)
        • 컴퓨터구조(CSAPP)
        • Code 정글(C언어)
        • Equipped in 정글(나만무) N
        • 마이 정글(WIL, 에세이) N
      • 자료구조&알고리즘
        • 자료구조
        • 알고리즘
      • 일상
  • 블로그 메뉴

    • 홈
  • 링크

    • Github
  • hELLO· Designed By정상우.v4.10.3
그냥사람_
[나만무] Jungle-Board 기본 CRUD 구현하기
상단으로

티스토리툴바