Intro

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)
그런데, 트랜잭션 처리 코드를 작성하지 않았는데요?

"여기서는 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>


페이지네이션 적용에 성공했습니다!
나머지 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







여기까지가 간단한 JSP 게시판 만들기였습니다. 좀 더 나아가서 디테일을 추가할 수도 있고 그렇겠지만, JSP를 빠르게 익히면서 실전에 적용하려면 이 정도까지만 따라와도 충분하다고 생각해요. 에디터 오픈소스도 많이 있으니 그런 것을 가져와서 적용해도 되고. 그러면 이미지 첨부도 끌어와서 쓸 수도 있겠죠! S3 안 쓰고도 구현했었던 기억이 있네요.
따로 Github에 올려둘까 생각하긴 했지만, 복사 가능한 블로그에 전부 첨부해 뒀으니 딱히 그럴 필요는 없을 것 같습니다.
아무튼 MyBatis + JSP 게시판 시리즈는 아마 여기까지 연재할 듯.
참고로 지금 프로젝트의 구조는 경량화된 Model 2 정도라고 생각하면 됩니다. (Model 1은 JSP에 로직을 다 넣어버림)
Service 레이어는 생략한 형태라서, 이후 진심 모드일 때 Spring Boot로 제대로 구성하겠습니다.
P.S.
속성으로 단기간동안 휘뚜루 마뚜루 만든 건 이번이 처음입니다. 사전 지식이 없었다면 더 오래걸렸겠지만 원하는 목표 시간에 끝낼 수 있어서 다행이네요. 처음 게시판 만들 때는 일주일 정도 걸렸던 거 같은데 ㅋㅋ..
혹시나 누락된 부분이나 잘못된 부분이 있다면 댓글 부탁드리겠습니다. 빠른 피드백 반영하겠습니다.
'Study > Java' 카테고리의 다른 글
| [JSP] 세션 기반 로그인과 회원가입을 만들어보자 (0) | 2025.09.09 |
|---|---|
| [JDBC] JDBC 연결 테스트와 MyBatis 설정 해보기 (0) | 2025.09.09 |
| [JSP] JSP와 친해지기 (0) | 2025.09.09 |
| [JSP] Java/JSP 프로젝트를 위한 eclipse 환경 설정 (0) | 2025.09.08 |
| [Java] HashMap의 데이터 관리?! Linked List와 Red-Black Tree의 전환 과정! (7) | 2024.12.01 |