본문 바로가기

Study/Spring & Spring Boot

[Spring Boot] 게시판 기능 구현하기(1)

CRUD를 연습하기에 가장 좋은 기능이 게시판 구현이라고 생각한다.

DB 구축하기


게시판 테이블 생성

DB 테이블을 구축하기 위해 다음 쿼리문을 작성해준다.

show databases;
use mysql;

DROP TABLE BBS;

CREATE TABLE BBS(
    SEQ INTEGER(8) PRIMARY KEY,
    ID VARCHAR(50) NOT NULL,
    REF INTEGER(8) NOT NULL,
    STEP INTEGER(8) NOT NULL,
    DEPTH INTEGER(8) NOT NULL,
    TITLE VARCHAR(200) NOT NULL,
    CONTENT VARCHAR(4000) NOT NULL,
    WDATE DATE NOT NULL,
    DEL INTEGER(1) NOT NULL,
    READCOUNT INTEGER(8) NOT NULL
);

 

REF, STEP, DEPTH는 답글 기능을 위해 사용하는 컬럼이다. 부여되는 번호에 따라 원글-답글-답답글과 같은 형식이 부여된다.

 

 

SEQ.는 시퀀스이며 시퀀스는 글이 등록된 순서대로 1씩 증가하며 부여될 것이다.

REF는 글의 그룹 번호와 같은 역할을 한다. 다시 말해서 어느 원글에 대한 답글인지 추적해주는 기능을 한다.

STEP은 답글의 순서이다.

DEPTH는 답글의 깊이이다. 원글에 대한 답글인 경우 1이며 답글의 답글인 경우 2가 된다.

DEL은 삭제시 번호를 바꾸어 DB에는 남아있으나 화면단에서는 보이지 않게 해주는 역할을 한다.

READCOUNT는 조회수를 나타내는 컬럼이다.

외래키 설정

기존 MEMBERS 테이블의 ID를 참조하기 위해 게시판용 테이블의 ID를 외래키로 지정한다.

ALTER TABLE BBS
    ADD CONSTRAINT FK_BBS_ID FOREIGN KEY(ID)
        REFERENCES MEMBERS(ID);

시퀀스 설정

MySQL에서는 시퀀스를 생성하기 위해 별도의 테이블을 만들어서 그 테이블을 참조하여 넣어주어야 한다.

MySQL에서 시퀀스 생성하는 방법은 여기를 참조하면 된다.

샘플 정보 입력

테스트를 위해 샘플로 한개의 글을 DB에 등록해준다.

INSERT INTO BBS(SEQ, ID, REF, STEP, DEPTH, TITLE, CONTENT, WDATE, DEL, READCOUNT)
VALUES (NEXTVAL('SEQ_BBS'), 'admin',
        (SELECT IFNULL(MAX(REF)+1, 0) FROM BBS AS B),
        0, 0,
        '두번째글입니다',
        '두번째 내용입니다',
        SYSDATE(), 0, 0
);

시퀀스를 삽입할 때 NEXTVAL('SEQ_BBS')처럼 함수에 매개변수를 전달하여 삽입해준다.

REFREF의 최대값에 1을 더해가며 지정해주고 존재하지 않는 경우 0을 넣도록 한다.

DTO 작성


게시판을 구현할 때 서버단과 화면단의 계층간 데이터 교환을 위해 DTO가 필요하다.

BbsDto 작성

import lombok.Getter;

@Getter
public class BbsDto {
    private int seq;
    private String id;

    private int ref;
    private int step;
    private int depth;

    private String title;
    private String content;
    private String wdate;       // 작성일자

    private int del;            // 삭제 여부
    private int readCount;      // 조회수

    public BbsDto() {
    }

    public BbsDto(int seq, String id, int ref, int step, int depth, String title, String content, String wdate, int del, int readCount) {
        this.seq = seq;
        this.id = id;
        this.ref = ref;
        this.step = step;
        this.depth = depth;
        this.title = title;
        this.content = content;
        this.wdate = wdate;
        this.del = del;
        this.readCount = readCount;
    }

    public BbsDto(String id, String title, String content) {
        this.id = id;
        this.title = title;
        this.content = content;
    }

    public void setSeq(int seq) {
        this.seq = seq;
    }

    public void setId(String id) {
        this.id = id;
    }

    public void setRef(int ref) {
        this.ref = ref;
    }

    public void setStep(int step) {
        this.step = step;
    }

    public void setDepth(int depth) {
        this.depth = depth;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public void setContent(String content) {
        this.content = content;
    }

    public void setWdate(String wdate) {
        this.wdate = wdate;
    }

    public void setDel(int del) {
        this.del = del;
    }

    public void setReadCount(int readCount) {
        this.readCount = readCount;
    }

    @Override
    public String toString() {
        return "seq=" + seq +
                ", id='" + id + '\'' +
                ", ref=" + ref +
                ", step=" + step +
                ", depth=" + depth +
                ", title='" + title + '\'' +
                ", content='" + content + '\'' +
                ", wdate='" + wdate + '\'' +
                ", del=" + del +
                ", readCount=" + readCount;
    }
}

BbsParam 작성

검색 기능을 사용하기 위해 검색구분과 검색어를 받아 처리해줄 BbsParam을 작성한다.

import lombok.Getter;

@Getter
public class BbsParam {

    private String choice;
    private String search;

    public BbsParam() {
    }

    public BbsParam(String choice, String search) {
        this.choice = choice;   // 제목, 내용, 작성자 중 한 개 항목
        this.search = search;   // 검색어
    }

    public void setChoice(String choice) {
        this.choice = choice;
    }

    public void setSearch(String search) {
        this.search = search;
    }

    @Override
    public String toString() {
        return "BbsParam{" +
                "choice='" + choice + '\'' +
                ", search='" + search + '\'' +
                '}';
    }
}

Mapper - bbs.xml 구성


마이바티스를 사용하기 위해 매퍼를 구성한다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.hwangduil.springbootbackend.dao.BbsDao">

    <!-- 게시판 목록에 뿌려주기 -->
    <select id="getBbsList" resultType="com.hwangduil.springbootbackend.dto.BbsDto">
        SELECT SEQ, ID, REF, STEP, DEPTH, TITLE, CONTENT, WDATE, DEL, READCOUNT FROM BBS
        ORDER BY REF DESC, STEP ASC
    </select>

    <!-- 글 등록 시 -->
    <insert id="insertBbs" parameterType="com.hwangduil.springbootbackend.dto.BbsDto">
        INSERT INTO BBS (SEQ, ID, REF, STEP, DEPTH, TITLE, CONTENT, WDATE, DEL, READCOUNT)
        VALUES (NEXTVAL('SEQ_BBS'), #{id}, (SELECT IFNULL(MAX(REF)+1, 0) FROM BBS AS B),0, 0, #{title}, #{content}, SYSDATE(), 0, 0)
    </insert>

    <!-- 게시글 상세보기 페이지 -->
    <select id="getBbsDetail" parameterType="Integer" resultType="com.hwangduil.springbootbackend.dto.BbsDto">
        SELECT * FROM BBS
        WHERE SEQ = #{seq}
    </select>

    <!-- 조회수 업데이트 -->
    <update id="readcount" parameterType="Integer">
        UPDATE BBS
        SET READCOUNT = READCOUNT+1
        WHERE SEQ=#{seq}
    </update>

    <!-- 검색기능 사용 시 -->
    <select id="getBbsListSearch" parameterType="com.hwangduil.springbootbackend.dto.BbsParam" resultType="com.hwangduil.springbootbackend.dto.BbsDto">
        SELECT SEQ, ID, REF, STEP, DEPTH, TITLE, CONTENT, WDATE, DEL, READCOUNT FROM BBS
        WHERE 1 = 1
        <if test="choice != null and choice != '' and search != null and search !=''">
            <if test="choice == 'title'">
                AND TITLE LIKE CONCAT('%', #{search}, '%')
            </if>
            <if test="choice == 'content'">
                AND CONTENT LIKE CONCAT('%', #{search}, '%')
            </if>
            <if test="choice == 'writer'">
                AND ID=${search}
            </if>
        </if>
        ORDER BY REF DESC, STEP ASC
    </select>

</mapper>

DAO 작성


BbsDao 작성

데이터베이스 접근을 위해 DAO가 필요하다.

import com.hwangduil.springbootbackend.dto.BbsDto;
import com.hwangduil.springbootbackend.dto.BbsParam;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;

import java.util.List;

@Mapper
@Repository
public interface BbsDao {

    List<BbsDto> getBbsList();        // 게시판 목록에 뿌려줄 정보를 List로 반환한다.
    void insertBbs(BbsDto dto);        // 게시판에 글을 추가한다.
    BbsDto getBbsDetail(int seq);    // 상세 글 보기에 보여줄 정보를 BbsDto 형태로 반환한다. 이 때 글 번호(sequence)를 참조한다.

    List<BbsDto> getBbsListSearch(BbsParam param);    // 검색 결과를 리스트로 반환한다.

}

Service 작성


DAO를 통해 DB에서 가져온 정보를 알맞게 가공하여 컨트롤러까지 가져다 줄 것이다.

BbsService 작성

import com.hwangduil.springbootbackend.dao.BbsDao;
import com.hwangduil.springbootbackend.dto.BbsDto;
import com.hwangduil.springbootbackend.dto.BbsParam;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@Transactional
@RequiredArgsConstructor
public class BbsService {

    private final BbsDao dao;

    public List<BbsDto> getBbsList() {
        return dao.getBbsList();
    }

    public void insertBbs(BbsDto dto) {
        dao.insertBbs(dto);
    }

    public BbsDto getBbsDetail(int seq) {
        return dao.getBbsDetail(seq);
    }

    public List<BbsDto> getBbsListSearch(BbsParam param) {
        return dao.getBbsListSearch(param);
    }

}

Controller 작성


BbsController

URL 패턴을 참조하여 서비스 로직을 실행시켜 값을 전달해준다.

import com.hwangduil.springbootbackend.dto.BbsDto;
import com.hwangduil.springbootbackend.dto.BbsParam;
import com.hwangduil.springbootbackend.service.BbsService;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequiredArgsConstructor
public class BbsController {

    public final Logger logger = LoggerFactory.getLogger(BbsController.class);
    private final BbsService service;

    @GetMapping("/getBbsList")
    public List<BbsDto> getBbsList() {
        logger.info("BbsController getBbsList");
        return service.getBbsList();
    }

    @GetMapping("/insertBbs")
    public void insertBbs(BbsDto dto) {
        logger.info("BbsController insertBbs() ");
        service.insertBbs(dto);
    }

    @GetMapping("/getBbsDetail")
    public BbsDto getBbsDetail(int seq) {
        logger.info("BbsController getBbsDetail()");
        return service.getBbsDetail(seq);
    }

    @GetMapping("getBbsListSearch")
    public List<BbsDto> getBbsListSearch(BbsParam param) {
        logger.info("BbsController getBbsListSearch");
        return service.getBbsListSearch(param);
    }

}

여기에서 반환되는 데이터를 화면단에서 사용할 것이다.

화면단 구성하기


게시판 메인 화면 구성

테이블을 통해 정보를 뿌려줄 것이며 <tbody>에 서버로 부터 받아온 정보를 파싱하여 삽입해줄 것이다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel='stylesheet' href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
    <title>Boards</title>
    <style>
        h1 {
            font-size: 25px;
            text-align: center;
            margin: 30px;
        }

        .search-form {
            margin-left: auto;
            margin-right: auto; 
            margin-top: 3px; 
            margin-bottom: 3px
        }
    </style>
</head>
<body>

    <div id="app" class="container">

        <h1>자유게시판</h1>

        <table class="table table-striped table-hover">
            <thead>
                <th>번호</th>
                <th>제목</th>
                <th>작성자</th>
                <th>seq</th>
            </thead>
            <tbody id="tbody">
                <!-- List로 받아온 내용이 들어감 -->
            </tbody>
        </table>

        <div align="right">
            <a href="boardwrite.html">글쓰기</a>
        </div>

        <table class="search-form">
            <tr>
                <td>검색</td>
                <td style="padding-left: 5px;">
                    <select name="choice" id="_choice" class="form-select">
                        <option value="" selected>선택</option>
                        <option value="title">제목</option>
                        <option value="content">내용</option>
                        <option value="writer">작성자</option>
                    </select>
                </td>
                <td style="padding-left: 5px;">
                    <input type="text" name="search" id="_search" class="form-control" />
                </td>
                <td style="padding-left: 5px;">
                    <button type="button" id="btnSearch" class="btn btn-primary">검색</button>
                </td>
            </tr>
        </table>

    </div>

sessionStorage를 통해 getItem으로 로그인 페이지에서 저장한 세션정보를 불러온다.
이 정보를 JSON 형태로 파싱하여 출력해보면 객체 형태로 나오는 것을 알 수 있다.

이후 AJAX 처리를 거치는데 getBbsList라는 패턴의 컨트롤러를 거쳐 컨트롤러에서 반환되는 값을 for...each 루프로 <tbody>안에 추가해준다.

    <script>
        let str = sessionStorage.getItem("login");
        let json = JSON.parse(str);
        // alert(json.name);

        $(document).ready(function() {

            $.ajax({
                url: "http://localhost:3000/getBbsList",
                type: "GET",
                success: function(list) {
                    // alert('success');
                    // alert(list);
                    // alert(JSON.stringify(list));

                    $.each(list, function(index, item) {  // index = 배열의 index
                        let str = `<tr>
                                        <th>${index+1}</th>
                                        <td><a href="board-detail.html?seq=${item.seq}">${item.title}</a></td>
                                        <td>${item.id}</td>
                                        <td>${item.seq}</td>
                                    </tr>`;
                        $("#tbody").append(str);

                    })

                },
                error: function() {
                    alert('error');
                }
            });

검색버튼 클릭 시 <tbody>의 내부를 모두 없애주고, getBbsListSearch 패턴의 컨트롤러에 데이터를 전달하여 반환된 값을 <tbody>에 다시 뿌려준다.

이 때 board-detail.html로 넘겨서 보여주는데, 이 때 seq를 참조하여 해당 글을 디테일 페이지에서 보여준다.

때문에 글 제목을 눌렀을 때 글 번호를 파라미터로 전달하여 DB로 부터 가져오라는 요청을 보낼 것이다.

            $("#btnSearch").click(function() {

                $("#tbody").text("");

                let choice = $("#_choice").val();
                let search = $("#_search").val();

                $.ajax({
                    url: "http://localhost:3000/getBbsListSearch",
                    type: "GET",
                    data: { choice: choice, search: search },
                    success: function(list) {
                        // console.log(list);
                        $.each(list, function(index, list) {  // index = 배열의 index
                        let str = `<tr>
                                        <th>${index+1}</th>
                                        <td><a href="board-detail.html?seq=${list.seq}">${list.title}</a></td>
                                        <td>${list.id}</td>
                                        <td>${list.seq}</td>
                                    </tr>`;
                        $("#tbody").append(str);

                    })
                    },
                    error: function() {
                        console.log("error");
                    }
                })
            })
        });
    </script>

</body>
</html>

게시판 글 쓰기 화면 구성

화면의 구성은 마찬가지로 테이블을 기반으로 한다. 작성버튼을 클릭했을 때 모든 정보를 가지고 서버로 가서 DB에 저장한다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel='stylesheet' href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
    <title>글쓰기</title>
    <style>
        .center {
            margin: 0 auto;
        }
        h1 {
            font-size: 25px;
            margin: 30px;
            text-align: center;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>새 글 작성</h1>
        <table class="table">
            <tr>
                <td>작성자</td>
                <td><input type="text" name="" id="id" class="col form-control" readonly></td>
            </tr>
            <tr>
                <td>제목</td>
                <td><input type="text" name="" id="title" class="col form-control"></td>
            </tr>
            <tr>
                <td>내용</td>
                <td><textarea name="" id="content" cols="30" rows="10" class="col form-control"></textarea></td>
            </tr>
        </table>


        <div class="d-grid gap-2 d-md-flex justify-content-md-end">
            <button type="button" id="insertBtn" class="btn btn-primary">작성</button>
        </div>

    </div>

로그인 세션 정보를 가져와서 작성자가 들어가는 곳에 파싱한 정보 중 id 값을 넣어준다.

버튼 클릭 시 insertBbs 패턴의 컨트롤러에 id, title, content를 전달해준다.

정상 처리 되었다면 게시판 메인으로 넘어간다.

    <script>
        $(document).ready(function() {
            let str = sessionStorage.getItem("login");
            let parse = JSON.parse(str);
            $("#id").val(parse.id);
            $("#insertBtn").click(function() {
                $.ajax({
                    url: "http://localhost:3000/insertBbs",
                    type: "GET",
                    data: { 
                        id: $("#id").val(), 
                        title: $("#title").val(), 
                        content: $("#content").val()
                    },
                    success: function() {
                        alert("등록되었습니다!");
                        location.href="boards.html";
                    },
                    error: function() {
                        alert("에러가 발생하였습니다.");
                    }
                });
            });
        });

    </script>
</body>
</html>

상세 글 보기 페이지 구성

화면단에는 작성자, 작성일, 조회수, 글의 내용을 보여준다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel='stylesheet' href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
    <title>상세 글 보기</title>

    <style>
        h1 {
            font-size: 25px;
            margin: 30px;
            text-align: center;
        }
        .btn-grp {
            float: right;
        }
    </style>
</head>
<body>
    <div class="container">

        <h1>상세 글 보기</h1>

        <table class="table table-striped">
            <tr>
                <td>제목</td>
                <td><span id="title"></span></td>
            </tr>
            <tr>
                <td>작성자</td>
                <td><span id="name"></span></td>
            </tr>
            <tr>
                <td>작성일</td>
                <td><span id="wdate"></span></td>
            </tr>
            <tr>
                <td>조회수</td>
                <td><span id="readCount"></span></td>
            </tr>
            <tr>
                <td>내용</td>
                <td>
                    <textarea name="" id="content" cols="100%" rows="10" readonly></textarea>
                </td>
            </tr>
        </table>

        <div class="d-grid gap-2 btn-grp">
            <button type="button" id="bbsanswer" class="btn btn-primary">답글</button>
            <button type="button" id="bbsupdate" class="btn btn-warning">수정</button>
            <button type="button" id="bbsdelete" class="btn btn-danger">삭제</button>
        </div>

    </div>

URL.searchParams를 통해 주소로 전달되는 매개변수를 가져올 수 있다.

가령 https://example.com/?name=Jonathan%20Smith&age=18라는 링크가 있다고 가정할 때,

let params = (new URL(document.location)).searchParams;
let name = params.get('name');                                 // "Jonathan Smith".
let age = parseInt(params.get('age'));                         // 18

다음과 같이 매개변수의 이름을 가지고 값을 가져올 수 있다.

여기서는 URL을 통해 seq로 전달되는 숫자를 가져오는 것이다.

getBbsDetail 패턴의 컨트롤러에 seq를 전달해준다. 이 때 반환되는 데이터를 각각의 태그에 값으로 삽입해준다.

로그인 정보를 sessionStorage로부터 받아와서 전달받은 글의 정보에 들어있는 아이디와 로그인 정보의 아이디가 다르다면 수정, 삭제 버튼을 비활성화한다.

    <script>

        // $(document).ready(function() {
            const url = new URL(location.href);
            const urlParams = url.searchParams;
            let seq = urlParams.get("seq");
            $.ajax({
                url: "http://localhost:3000/getBbsDetail",
                type: "GET",
                data: { seq: seq },
                success: function(data) {
                    // console.log("success");
                    // console.log(data);
                    // console.log(JSON.stringify(data));
                    $("#title").text(data.title);
                    $("#name").text(data.id);
                    $("#wdate").text(data.wdate);
                    $("#readCount").text(data.readCount);
                    $("#content").val(data.content);

                    // 로그인 정보와 일치하는 작성자만 수정, 삭제 하게 하기
                    let login = JSON.parse(sessionStorage.getItem("login"));
                    if(data.id !== login.id) {
                        $("#bbsupdate").hide();
                        $("#bbsdelete").hide();
                    }

                },
                error: function() {
                    console.log("error");
                }
            });
        // });
    </script>
</body>
</html>