유랑하는 나그네의 갱생 기록

だけど素敵な明日を願っている -HANABI, Mr.children-

Study/Java

[JSP] 게시판 만들기

Madirony 2025. 9. 10. 10:13
반응형

Intro

Y2K

2000년대 감성을 좋아하는 편.

게시판 만들 때마다 이때로 회귀하는 기분입니다.

잘 보면 2002년에 jsp를 쓰고 있어.. 저 때는 스프링도 나오기 전이라 jsp가 최신 기술이었겠죠.

 

 

사전작업

생각해 보니, 인덱스를 개념으로만 숙지하고 있었지 프로젝트에 적용을 제대로 못해본 거 같아서 이번에는 인덱스도 걸어보도록 합시다.

INSERT, DELETE, UPDATE가 자주 일어나지 않는 컬럼에 적용하면 됩니다. 생성일자(created_at)같은 것들 말입니다.

posts
mysql> CREATE TABLE posts (
    -> id BIGINT PRIMARY KEY AUTO_INCREMENT,
    -> title      VARCHAR(200) NOT NULL,
    -> content    TEXT         NOT NULL,
    -> user_id    BIGINT       NOT NULL,
    -> views      INT          NOT NULL DEFAULT 0,
    -> created_at TIMESTAMP    DEFAULT CURRENT_TIMESTAMP,
    -> updated_at TIMESTAMP    NULL
    -> );
Query OK, 0 rows affected (0.105 sec)

mysql> CREATE INDEX idx_posts_created ON posts(created_at DESC);
Query OK, 0 rows affected (0.107 sec)

 

comments
mysql> CREATE TABLE comments (
    -> id BIGINT PRIMARY KEY AUTO_INCREMENT,
    -> post_id   BIGINT NOT NULL,
    -> user_id   BIGINT NOT NULL,
    -> content   TEXT   NOT NULL,
    -> is_deleted TINYINT(1) NOT NULL DEFAULT 0,
    -> created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    -> );
Query OK, 0 rows affected, 1 warning (0.030 sec)

mysql> CREATE INDEX idx_comments_post_created ON comments(post_id, created_at DESC);
Query OK, 0 rows affected (0.015 sec)

 

Dummy Data
mysql> INSERT INTO posts(title, content, user_id) VALUES ('테스트 제목', '테스트 본문', 4);
Query OK, 1 row affected (0.048 sec)

mysql> select * from posts;
+----+------------------+------------------+---------+-------+---------------------+------------+
| id | title            | content          | user_id | views | created_at          | updated_at |
+----+------------------+------------------+---------+-------+---------------------+------------+
|  1 | 테스트 제목      | 테스트 본문      |       4 |     0 | 2025-09-09 19:33:50 | NULL       |
+----+------------------+------------------+---------+-------+---------------------+------------+
1 row in set (0.003 sec)

테스트를 위해 게시글 더미 데이터도 하나 넣어보죠.

저번 포스트에서 유저 3을 넣어놨기도 하고, FK를 걸지도 않았으니 대충 넣어둡시다.

 

참조 무결성(데이터 정합성 제약 조건 중 하나)을 위해서라면 FK가 있어야 할 텐데,

성능 저하 이슈 및 관리가 번거로워 현업에서는 잘 사용하지 않는다고 합니다.

 

 


본론

Board List

제일 먼저 게시판 목록을 구현해 보도록 합시다.

더미 데이터로 페이지네이션까지 테스트를 해볼 겁니다.

 

Post.java
package com.madirony.board;

import java.util.Date;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class Post {
	private Long id;
	private String title;
	private String content;
	private Long userId;
	private String authorName;
	private Integer views;
	private Date createdAt;
	private Date updatedAt;
}

 

PostMapper.java
package com.madirony.board;

import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.Param;

public interface PostMapper {
	List<Map<String,Object>> findPage(@Param("limit") int limit,
          @Param("offset") int offset);

	int countPosts();
	
	int insert(Post post);
	Post findById(@Param("id") long id);
	int increaseViews(@Param("id") long id);
	
	int deleteByIdAndUserId(@Param("id") long id, @Param("userId") long userId);

	int updateByIdAndUserId(@Param("id") long id,
            @Param("userId") long userId,
            @Param("title") String title,
            @Param("content") String content);
}

지금은 빠른 구현이 목적이므로 임시로 Map을 사용했습니다. 하지만 타입 안정성 등의 문제가 생길 수 있으니 DTO(Data Transfer Object)를 사용하는 것을 권장합니다.

 

PostMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.madirony.board.PostMapper">

  <select id="findPage" resultType="map">
    SELECT
      p.id,
      p.title,
      p.user_id AS userId,
      u.name    AS authorName,
      p.views,
      p.created_at AS createdAt,
      (p.created_at >= NOW() - INTERVAL 3 DAY) AS isNew,
      (SELECT COUNT(*) FROM comments c
         WHERE c.post_id = p.id AND c.is_deleted = 0) AS commentCount
    FROM posts p
    LEFT JOIN users u ON u.id = p.user_id
    ORDER BY p.id DESC
    LIMIT #{limit} OFFSET #{offset}
  </select>

  <select id="countPosts" resultType="int">
    SELECT COUNT(*) FROM posts
  </select>

  <insert id="insert" parameterType="com.madirony.board.Post"
          useGeneratedKeys="true" keyProperty="id">
    INSERT INTO posts(title, content, user_id)
    VALUES(#{title}, #{content}, #{userId})
  </insert>

  <select id="findById" parameterType="long" resultType="com.madirony.board.Post">
  SELECT p.id, p.title, p.content,
         p.user_id AS userId,
         u.name    AS authorName,
         p.views, p.created_at AS createdAt, p.updated_at AS updatedAt
  FROM posts p
  LEFT JOIN users u ON u.id = p.user_id
  WHERE p.id = #{id}
  </select>

  <update id="increaseViews" parameterType="long">
    UPDATE posts SET views = views + 1 WHERE id = #{id}
  </update>
  
  <delete id="deleteByIdAndUserId">
    DELETE FROM posts WHERE id = #{id} AND user_id = #{userId}
  </delete>
  
	<update id="updateByIdAndUserId">
	  UPDATE posts
	    SET title = #{title},
	        content = #{content},
	        updated_at = NOW()
	  WHERE id = #{id} AND user_id = #{userId}
	</update>
	  
</mapper>

늘 하던 대로 쿼리문을 작성하면 되겠습니다.

그런데 조회수 부분에서 궁금한 점이 생겼습니다. "저 쿼리문은 동시성 문제에 대해서 안전할까?"

 

결론적으로 말하자면 안전합니다. DB가 원자적으로 처리하기 때문이죠.

 

InnoDB는 해당 행을 행 단위 잠금(row lock)으로 보호하고(exclusive lock), views = views + 1 같은 누적 연산은 경쟁해도 합산이 유지됩니다. 왜냐하면 각 SQL 문장은 하나의 트랜잭션으로 실행되어 원자성(Atomicity)을 갖기 때문입니다.

 

+ 2025.09.15)

그런데, 트랜잭션 처리 코드를 작성하지 않았는데요?

 

What

 

"여기서는 MyBatis openSession(true)(auto-commit)로 각 UPDATE가 자체 트랜잭션으로 커밋된다. 따라서 views = views + 1 같은 단일 문장 누적 연산은 InnoDB가 행 락 + 문장 원자성으로 안전하게 처리한다. 여러 문장을 묶어야 할 때만 수동 트랜잭션(openSession(false) + commit)이 필요하다."

 

일단 결론부터 말씀드리자면 가만히 놔둬도 OK 입니다. JDBC가 auto-commit을 Default로 들고 있기 때문이에요.

일단 아래 서블릿에 적은 코드를 일부 발췌하겠습니다.

 

try (SqlSession s = MyBatisUtil.openSession(true)) {

 

이 부분입니다. 만약에 여러 문장을 한꺼번에 실행시키고 싶다면, false 옵션을 통해 수동으로 트랜잭션을 처리하면 되겠습니다.

s.commit()!

 

mybatis-config.xml
<package name="com.madirony.board"/>

 

BoardListServlet.java
package com.madirony.web;

import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;
import javax.servlet.ServletException;
import java.io.IOException;
import java.util.*;
import org.apache.ibatis.session.SqlSession;
import com.madirony.config.MyBatisUtil;
import com.madirony.board.PostMapper;

@WebServlet("/board/list")
public class BoardListServlet extends HttpServlet {
	@Override
	protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
		int page = parseIntOr(req.getParameter("page"), 1);
	    int size = parseIntOr(req.getParameter("size"), 10);
	    if (size != 10 && size != 20) size = 10;
	    if (page < 1) page = 1;
	    
	    try (SqlSession s = MyBatisUtil.openSession(true)) {
	        PostMapper m = s.getMapper(PostMapper.class);
	        int total = m.countPosts();
	        int totalPages = (int)Math.ceil(total / (double)size);
	        if (totalPages < 1) totalPages = 1;
	        if (page > totalPages) page = totalPages;

	        int offset = (page - 1) * size;
	        List<Map<String,Object>> posts = m.findPage(size, offset);

	        req.setAttribute("posts", posts);
	        req.setAttribute("page", page);
	        req.setAttribute("size", size);
	        req.setAttribute("total", total);
	        req.setAttribute("totalPages", totalPages);
	        req.getRequestDispatcher("/WEB-INF/views/board/list.jsp").forward(req, resp);
	    }
	}
	
	
	private int parseIntOr(String s, int def) {
	    try { return Integer.parseInt(s); }
	    catch (Exception e) { return def; }
	}
}

페이지네이션은 10, 20 아이템 단위로 선택하도록 구현해 봅시다.

무한 스크롤 형태였다면 커서 기반 페이지네이션을 썼겠지만, 일반적인 게시판은 오프셋 기반 페이지네이션을 많이 사용하는 편입니다.

 

list.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%>

<c:set var="ctx" value="${pageContext.request.contextPath}"/>

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Board List Page</title>
</head>
<body>

	<%@ include file="/header.jsp" %>
	
	<h2>게시판</h2>
	<p>총 글 수: ${total}</p>
	
	<form method="get" action="${ctx}/board/list" style="margin-bottom:8px">
	  <label>페이지 크기:
	    <select name="size" onchange="this.form.submit()">
	      <option value="10" ${size==10?'selected':''}>10개</option>
	      <option value="20" ${size==20?'selected':''}>20개</option>
	    </select>
	  </label>
	  <input type="hidden" name="page" value="1"/>
	</form>
	
	<c:if test="${not empty sessionScope.loginUser}">
	  <div style="text-align:right; margin:8px 0;">
	    <a href="${ctx}/board/write">
	      <button type="button">글쓰기</button>
	    </a>
	  </div>
	</c:if>
	
	<table border="1" cellspacing="0" cellpadding="6" width="100%">
	  <tr>
	    <th style="width:60px;">ID</th>
	    <th>제목</th>
	    <th style="width:120px;">작성자</th>
	    <th style="width:160px;">작성일시</th>
	    <th style="width:80px;">댓글</th>
	    <th style="width:80px;">조회</th>
	  </tr>
	  <c:forEach var="row" items="${posts}">
	    <tr>
	      <td>${row.id}</td>
	      <td>
	        <a href="${ctx}/board/detail?id=${row.id}">
	          <c:out value="${row.title}"/>
	        </a>
	        <c:if test="${row.isNew == 1}">
	          <span style="color:red;">[New]</span>
	        </c:if>
	      </td>
	      <td>${row.authorName}</td>
	      <td><fmt:formatDate value="${row.createdAt}" pattern="yyyy-MM-dd HH:mm"/></td>
	      <td>${row.commentCount}</td>
	      <td>${row.views}</td>
	    </tr>
	  </c:forEach>
	</table>
	
	<div style="margin-top:8px;">
	  <c:if test="${page > 1}">
	    <a href="${ctx}/board/list?page=${page-1}&size=${size}">이전</a>
	  </c:if>
	  <span> ${page} / ${totalPages} </span>
	  <c:if test="${page < totalPages}">
	    <a href="${ctx}/board/list?page=${page+1}&size=${size}">다음</a>
	  </c:if>
	</div>
	
	<%@ include file="/footer.jsp" %>

</body>
</html>

 

 

 

Pagination

페이지네이션 적용에 성공했습니다!

나머지 detail page라던가, 글쓰기, 댓글, 조회수 업데이트 같은 것도 추가로 구현합시다.

글을 작성하면서 위에 있는 코드들도 수정을 좀 하긴 했는데, 일단 게시글 관련 나머지 코드들도 첨부하겠습니다.

 

 

 

BoardDetail

Read

BoardDetailServlet.java
package com.madirony.web;

import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;
import javax.servlet.ServletException;
import java.io.IOException;
import java.util.Collections;
import java.util.List;

import org.apache.ibatis.session.SqlSession;
import com.madirony.config.MyBatisUtil;
import com.madirony.board.Comment;
import com.madirony.board.CommentMapper;
import com.madirony.board.Post;
import com.madirony.board.PostMapper;

@WebServlet("/board/detail")
public class BoardDetailServlet extends HttpServlet {
	@Override protected void doGet(HttpServletRequest req, HttpServletResponse resp)
		    throws ServletException, IOException {

	  long id;
	  try { id = Long.parseLong(req.getParameter("id")); }
	  catch (Exception e) { resp.sendError(400, "id 필요"); return; }
	
	  try (SqlSession s = MyBatisUtil.openSession(true)) {
      	PostMapper pm = s.getMapper(PostMapper.class);
	    pm.increaseViews(id);
	    Post post = pm.findById(id);
	    if (post == null) { resp.sendError(404); return; }
	
	    CommentMapper cm = s.getMapper(CommentMapper.class);
	    List<Comment> comments = cm.findAllByPost(id);
	    int totalComments = comments.size();     
	
	    req.setAttribute("post", post);
	    req.setAttribute("comments", comments);
	    req.setAttribute("totalComments", totalComments);
	    req.getRequestDispatcher("/WEB-INF/views/board/detail.jsp").forward(req, resp);
	  }
	}
}

 

 

detail.jsp
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%>
<c:set var="ctx" value="${pageContext.request.contextPath}"/>
<%@ include file="/header.jsp" %>

<h2><c:out value="${post.title}"/></h2>
<p>
  글번호: ${post.id} · 작성자: <c:out value="${post.authorName}"/> · 조회수: ${post.views}
  · 작성일: <fmt:formatDate value="${post.createdAt}" pattern="yyyy-MM-dd HH:mm"/>
</p>

<pre style="white-space:pre-wrap;"><c:out value="${post.content}"/></pre>

<c:if test="${not empty sessionScope.loginUser && sessionScope.loginUser.id == post.userId}">
  <form method="get" action="${ctx}/board/edit" style="display:inline;">
    <input type="hidden" name="id" value="${post.id}"/>
    <button type="submit">수정</button>
  </form>
  <form method="post" action="${ctx}/board/delete" style="display:inline;"
        onsubmit="return confirm('삭제할까요?');">
    <input type="hidden" name="id" value="${post.id}"/>
    <button type="submit">삭제</button>
  </form>
</c:if>

<hr/>

<h3>댓글 (${totalComments})</h3>

<c:forEach var="cmt" items="${comments}">
  <div style="padding:6px 0; border-bottom:1px solid #eee;">
    <c:choose>
      <c:when test="${cmt.isDeleted == 1}">
        삭제된 댓글입니다.
      </c:when>
      <c:otherwise>
        <strong><c:out value="${empty cmt.authorName ? '탈퇴/미상' : cmt.authorName}"/></strong>
        <small style="color:#888; margin-left:8px;">
          <fmt:formatDate value="${cmt.createdAt}" pattern="yyyy-MM-dd HH:mm"/>
        </small>
        <div><c:out value="${cmt.content}"/></div>
        <c:if test="${not empty sessionScope.loginUser && sessionScope.loginUser.id == cmt.userId}">
          <form method="post" action="${ctx}/comment/delete" style="display:inline;margin-top:4px;"
                onsubmit="return confirm('댓글을 삭제할까요?');">
            <input type="hidden" name="id" value="${cmt.id}"/>
            <input type="hidden" name="postId" value="${post.id}"/>
            <button type="submit">삭제</button>
          </form>
        </c:if>
      </c:otherwise>
    </c:choose>
  </div>
</c:forEach>

<c:if test="${not empty sessionScope.loginUser}">
  <form method="post" action="${ctx}/comment/add" style="margin-top:12px;">
    <input type="hidden" name="postId" value="${post.id}"/>
    <textarea name="content" required style="width:100%;height:90px;"></textarea>
    <button type="submit">댓글 등록</button>
  </form>
</c:if>


<p><a href="${ctx}/board/list">목록</a></p>
<%@ include file="/footer.jsp" %>

댓글은 CRUD 나머지 기능을 구현하고 마저 작성하겠습니다.

 

 

Create

BoardWriteServlet.java
package com.madirony.web;

import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;
import javax.servlet.ServletException;
import java.io.IOException;

import org.apache.ibatis.session.SqlSession;
import com.madirony.config.MyBatisUtil;
import com.madirony.board.Post;
import com.madirony.board.PostMapper;
import com.madirony.user.User;

@WebServlet("/board/write")
public class BoardWriteServlet extends HttpServlet {

  @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp)
      throws ServletException, IOException {
    req.getRequestDispatcher("/WEB-INF/views/board/write.jsp").forward(req, resp);
  }

  @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp)
      throws ServletException, IOException {
    User loginUser = (User) req.getSession().getAttribute("loginUser");
    if (loginUser == null) { resp.sendRedirect(req.getContextPath() + "/login"); return; }

    String title = req.getParameter("title");
    String content = req.getParameter("content");
    if (title == null || title.isBlank() || content == null || content.isBlank()) {
      req.setAttribute("error", "제목/본문은 필수입니다.");
      req.getRequestDispatcher("/WEB-INF/views/board/write.jsp").forward(req, resp);
      return;
    }

    Post p = new Post();
    p.setTitle(title.trim());
    p.setContent(content);
    p.setUserId(loginUser.getId());

    try (SqlSession s = MyBatisUtil.openSession(true)) {
      PostMapper m = s.getMapper(PostMapper.class);
      m.insert(p);
      resp.sendRedirect(req.getContextPath() + "/board/detail?id=" + p.getId());
    }
  }
}

 

 

write.jsp
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<c:set var="ctx" value="${pageContext.request.contextPath}"/>
<%@ include file="/header.jsp" %>

<h2>글쓰기</h2>
<c:if test="${not empty error}">
  <p style="color:red;"><c:out value="${error}"/></p>
</c:if>

<form method="post" action="${ctx}/board/write">
  <div><input type="text" name="title" placeholder="제목" required style="width:100%;" value="${param.title}"></div>
  <div><textarea name="content" placeholder="본문" required style="width:100%; height:220px;">${param.content}</textarea></div>
  <button type="submit">등록</button>
  <a href="${ctx}/board/list">목록</a>
</form>

<%@ include file="/footer.jsp" %>

 

 

Update

BoardEditServlet.java
package com.madirony.web;

import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;
import javax.servlet.ServletException;
import java.io.IOException;

import org.apache.ibatis.session.SqlSession;
import com.madirony.config.MyBatisUtil;
import com.madirony.board.Post;
import com.madirony.board.PostMapper;
import com.madirony.user.User;

@WebServlet("/board/edit")
public class BoardEditServlet extends HttpServlet {

  @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp)
      throws ServletException, IOException {
    long id;
    try { id = Long.parseLong(req.getParameter("id")); }
    catch (Exception e) { resp.sendError(400, "id 필요"); return; }

    try (SqlSession s = MyBatisUtil.openSession(true)) {
      PostMapper m = s.getMapper(PostMapper.class);
      Post post = m.findById(id);
      if (post == null) { resp.sendError(404); return; }

      User login = (User) req.getSession().getAttribute("loginUser");
      if (login == null || !login.getId().equals(post.getUserId())) {
        resp.sendError(403, "수정 권한 없음");
        return;
      }

      req.setAttribute("post", post);
      req.getRequestDispatcher("/WEB-INF/views/board/edit.jsp").forward(req, resp);
    }
  }

  @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp)
      throws ServletException, IOException {
    User login = (User) req.getSession().getAttribute("loginUser");
    if (login == null) { resp.sendRedirect(req.getContextPath()+"/login"); return; }

    long id = Long.parseLong(req.getParameter("id"));
    String title = req.getParameter("title");
    String content = req.getParameter("content");
    if (title == null || title.isBlank() || content == null || content.isBlank()) {
      req.setAttribute("error", "제목/본문은 필수입니다.");
      req.setAttribute("postId", id);
      req.getRequestDispatcher("/WEB-INF/views/board/edit.jsp").forward(req, resp);
      return;
    }

    try (SqlSession s = MyBatisUtil.openSession(true)) {
      PostMapper m = s.getMapper(PostMapper.class);
      int affected = m.updateByIdAndUserId(id, login.getId(), title.trim(), content);
      if (affected == 1) {
        resp.sendRedirect(req.getContextPath()+"/board/detail?id="+id);
      } else {
        resp.sendError(403, "수정 권한이 없거나 글이 없습니다.");
      }
    }
  }
}

 

 

edit.jsp
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<c:set var="ctx" value="${pageContext.request.contextPath}"/>
<%@ include file="/header.jsp" %>

<h2>글 수정</h2>
<c:if test="${not empty error}">
  <p style="color:red;"><c:out value="${error}"/></p>
</c:if>

<form method="post" action="${ctx}/board/edit">
  <input type="hidden" name="id" value="${post.id}"/>
  <div><input type="text" name="title" required style="width:100%;" value="${post.title}"></div>
  <div><textarea name="content" required style="width:100%; height:220px;">${post.content}</textarea></div>
  <button type="submit">저장</button>
  <a href="${ctx}/board/detail?id=${post.id}">취소</a>
</form>

<%@ include file="/footer.jsp" %>

 

 

Delete

BoardDeleteServlet.java
package com.madirony.web;

import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;
import javax.servlet.ServletException;
import java.io.IOException;

import org.apache.ibatis.session.SqlSession;
import com.madirony.config.MyBatisUtil;
import com.madirony.board.PostMapper;
import com.madirony.board.CommentMapper;
import com.madirony.user.User;

@WebServlet("/board/delete")
public class BoardDeleteServlet extends HttpServlet {
  @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp)
      throws ServletException, IOException {

    User login = (User) req.getSession().getAttribute("loginUser");
    if (login == null) { resp.sendRedirect(req.getContextPath()+"/login"); return; }

    long id;
    try { id = Long.parseLong(req.getParameter("id")); }
    catch (Exception e) { resp.sendError(400, "id 필요"); return; }

    try (SqlSession s = MyBatisUtil.openSession(false)) {
      CommentMapper cm = s.getMapper(CommentMapper.class);
      PostMapper pm = s.getMapper(PostMapper.class);

      cm.deleteByPostId(id);
      int affected = pm.deleteByIdAndUserId(id, login.getId());
      if (affected == 1) {
        s.commit();
        resp.sendRedirect(req.getContextPath()+"/board/list");
      } else {
        s.rollback();
        resp.sendError(403, "삭제 권한이 없습니다.");
      }
    }
  }
}

여기까지 따라왔으면 게시글 CRUD는 완성되었을 것입니다.

마지막으로 댓글 기능까지 구현해 보고 테스트해봅시다. 댓글도 게시글 구현과 크게 다르지 않아요.

 

 

Comment

Comment.java
package com.madirony.board;

import java.util.Date;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class Comment {
  private Long id;
  private Long postId;
  private Long userId;
  private String authorName;
  private String content;
  private Integer isDeleted;
  private Date createdAt;
}

 

 

CommentMapper.java
package com.madirony.board;
import java.util.List;
import org.apache.ibatis.annotations.Param;

public interface CommentMapper {
  List<Comment> findAllByPost(@Param("postId") long postId);
  int countByPost(@Param("postId") long postId);
  int insert(Comment c);
  int softDelete(@Param("id") long id, @Param("userId") long userId);
  
  int deleteByPostId(@Param("postId") long postId);
}

 

 

CommentMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.madirony.board.CommentMapper">

	<select id="findAllByPost" resultType="com.madirony.board.Comment">
	  SELECT
	    c.id, c.post_id AS postId, c.user_id AS userId,
	    c.content, c.is_deleted AS isDeleted, c.created_at AS createdAt,
	    u.name AS authorName
	  FROM comments c
	  LEFT JOIN users u ON u.id = c.user_id
	  WHERE c.post_id = #{postId}
	  ORDER BY c.id DESC
	</select>

	<select id="countByPost" resultType="int">
	  SELECT COUNT(*) FROM comments WHERE post_id = #{postId} AND is_deleted=0
	</select>
	
	<insert id="insert" parameterType="com.madirony.board.Comment" useGeneratedKeys="true" keyProperty="id">
	  INSERT INTO comments(post_id, user_id, content)
	  VALUES(#{postId}, #{userId}, #{content})
	</insert>
	
	<update id="softDelete">
	  UPDATE comments SET is_deleted=1 WHERE id=#{id} AND user_id=#{userId}
	</update>

	<delete id="deleteByPostId">
	  DELETE FROM comments WHERE post_id = #{postId}
	</delete>

</mapper>

 

 

CommentAddServlet.java
package com.madirony.web;

import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;
import javax.servlet.ServletException;
import java.io.IOException;

import org.apache.ibatis.session.SqlSession;
import com.madirony.config.MyBatisUtil;
import com.madirony.board.Comment;
import com.madirony.board.CommentMapper;
import com.madirony.user.User;

@WebServlet("/comment/add")
public class CommentAddServlet extends HttpServlet {
  @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp)
      throws ServletException, IOException {

    User login = (User) req.getSession().getAttribute("loginUser");
    if (login == null) { resp.sendRedirect(req.getContextPath()+"/login"); return; }

    long postId = Long.parseLong(req.getParameter("postId"));
    String content = req.getParameter("content");
    if (content == null || content.isBlank()) {
      resp.sendRedirect(req.getContextPath()+"/board/detail?id="+postId);
      return;
    }

    Comment c = new Comment();
    c.setPostId(postId);
    c.setUserId(login.getId());
    c.setContent(content);

    try (SqlSession s = MyBatisUtil.openSession(true)) {
      s.getMapper(CommentMapper.class).insert(c);
    }
    resp.sendRedirect(req.getContextPath()+"/board/detail?id="+postId);
  }
}

 

 

CommentDeleteServlet.java
package com.madirony.web;

import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;
import java.io.IOException;

import org.apache.ibatis.session.SqlSession;
import com.madirony.config.MyBatisUtil;
import com.madirony.board.CommentMapper;
import com.madirony.user.User;

@WebServlet("/comment/delete")
public class CommentDeleteServlet extends HttpServlet {
  @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    User login = (User) req.getSession().getAttribute("loginUser");
    if (login == null) { resp.sendRedirect(req.getContextPath()+"/login"); return; }

    long id = Long.parseLong(req.getParameter("id"));
    long postId = Long.parseLong(req.getParameter("postId"));

    try (SqlSession s = MyBatisUtil.openSession(true)) {
      int updated = s.getMapper(CommentMapper.class).softDelete(id, login.getId());
    }
    resp.sendRedirect(req.getContextPath()+"/board/detail?id="+postId);
  }
}

Comment의 경우에는 삭제 시, Soft Delete 하도록 구현했습니다.

 

소프트 딜리트를 하면 데이터 복구도 쉽고, 데이터 무결성을 유지할 수 있는 장점이 있지만, 데이터베이스의 크기가 커지고 쿼리도 복잡해지는 단점이 있습니다. 그래서 Hard Delete도 정책에 맞춰 병행해서 적용하기도 합니다.

 

 

 

Test

Create / Read
Update / Delete
Board List
Comments
Posts DB

여기까지가 간단한 JSP 게시판 만들기였습니다. 좀 더 나아가서 디테일을 추가할 수도 있고 그렇겠지만, JSP를 빠르게 익히면서 실전에 적용하려면 이 정도까지만 따라와도 충분하다고 생각해요. 에디터 오픈소스도 많이 있으니 그런 것을 가져와서 적용해도 되고. 그러면 이미지 첨부도 끌어와서 쓸 수도 있겠죠! S3 안 쓰고도 구현했었던 기억이 있네요.

 

따로 Github에 올려둘까 생각하긴 했지만, 복사 가능한 블로그에 전부 첨부해 뒀으니 딱히 그럴 필요는 없을 것 같습니다.

아무튼 MyBatis + JSP 게시판 시리즈는 아마 여기까지 연재할 듯.

 

참고로 지금 프로젝트의 구조는 경량화된 Model 2 정도라고 생각하면 됩니다. (Model 1은 JSP에 로직을 다 넣어버림)

Service 레이어는 생략한 형태라서, 이후 진심 모드일 때 Spring Boot로 제대로 구성하겠습니다.


P.S.

속성으로 단기간동안 휘뚜루 마뚜루 만든 건 이번이 처음입니다. 사전 지식이 없었다면 더 오래걸렸겠지만 원하는 목표 시간에 끝낼 수 있어서 다행이네요. 처음 게시판 만들 때는 일주일 정도 걸렸던 거 같은데 ㅋㅋ..

 

혹시나 누락된 부분이나 잘못된 부분이 있다면 댓글 부탁드리겠습니다. 빠른 피드백 반영하겠습니다.

반응형
TOP