# 개발환경
항목 | 내용 |
---|---|
Language | Java 17 |
IDE | Eclipse 2023-12 |
Spring Boot | 3.4.4 (설정파일 : properties) |
Tomcat | 10.1.18 |
DataBase | MySQL 8.0.37 (MyBatis) |
Build Tool | Maven (설정파일 : pom.xml) |
View template engine | JSP |
package com.apptest.board.dto;
import java.util.Date;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@ToString
@Builder
public class BoardDetailDto {
private int id;
private String nickname;
private String title;
private String content;
private Date createDate;
private Date deleteDate;
private String deleteYn;
}
package com.apptest.board.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@NoArgsConstructor
@Getter
@Setter
@Builder
@AllArgsConstructor
public class BoardFormDto {
private int id;
@NotBlank(message = "닉네임을 입력해주세요.")
@Size(min=2, max = 20, message = "닉네임은 최대 20글자까지 가능합니다.")
private String nickname;
@NotBlank(message = "제목을 입력해주세요.")
@Size(max = 255, message = "제목은 최대 255글자까지 가능합니다.")
private String title;
@NotBlank(message = "내용을 입력해주세요.")
private String content;
}
package com.apptest.board.dto;
import java.util.Date;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@ToString
@Builder
public class BoardListDto {
private int id;
private String nickname;
private String title;
private String content;
private Date createDate;
private Date deleteDate;
private String deleteYn;
}
package com.apptest.board.dto;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@ToString
public class Criteria {
private int page; // 요청한 페이지 번호
private int rows; // 한번에 표시할 데이터 갯수
private String sort; // 정렬방식
private String opt; // 검색옵션
private String keyword; // 검색어
private int begin; // 검색시작 범위
private int end; // 검색종료 범위
}
package com.apptest.board.dto;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;
@AllArgsConstructor
@Getter
@ToString
public class ListDto<T> {
private List<?> items;
private Pagination paging;
}
package com.apptest.board.dto;
public class Pagination {
private int rows = 10;
private int pages = 10;
private int totalRows;
private int totalPages;
private int totalBlocks;
private int currentPage;
private int currentBlock;
private int begin;
private int end;
private int beginPage;
private int endPage;
private boolean isFirst;
private boolean isLast;
public Pagination(int currentPage, int totalRows) {
this.totalRows = totalRows;
this.currentPage = currentPage;
init();
}
public Pagination(int currentPage, int totalRows, int rows) {
this.totalRows = totalRows;
this.currentPage = currentPage;
this.rows = rows;
init();
}
public Pagination(int currentPage, int totalRows, int rows, int pages) {
this.totalRows = totalRows;
this.currentPage = currentPage;
this.rows = rows;
this.pages = pages;
init();
}
private void init() {
if (totalRows > 0) {
this.totalPages = (int) Math.ceil((double) totalRows / rows);
this.totalBlocks = (int) Math.ceil((double) totalPages / pages);
this.currentBlock = (int) Math.ceil((double) currentPage / pages);
this.begin = (currentPage - 1) * rows + 1;
this.end = currentPage * rows;
this.beginPage = (currentBlock - 1) * pages + 1;
this.endPage = currentBlock * pages;
if (currentBlock == totalBlocks) {
this.endPage = this.totalPages;
}
if (currentPage == 1) {
this.isFirst = true;
}
if (currentPage == totalPages) {
this.isLast = true;
}
}
}
public int getRows() {
return rows;
}
public int getPages() {
return pages;
}
public int getTotalRows() {
return totalRows;
}
public int getTotalPages() {
return totalPages;
}
public int getTotalBlocks() {
return totalBlocks;
}
public int getCurrentPage() {
return currentPage;
}
public int getCurrentBlock() {
return currentBlock;
}
public int getBegin() {
return begin;
}
public int getEnd() {
return end;
}
public int getBeginPage() {
return beginPage;
}
public int getEndPage() {
return endPage;
}
public boolean isFirst() {
return isFirst;
}
public boolean isLast() {
return isLast;
}
@Override
public String toString() {
return "Pagination [rows=" + rows + ", pages=" + pages + ", totalRows=" + totalRows + ", totalPages="
+ totalPages + ", totalBlocks=" + totalBlocks + ", currentPage=" + currentPage + ", currentBlock="
+ currentBlock + ", begin=" + begin + ", end=" + end + ", beginPage=" + beginPage + ", endPage="
+ endPage + ", isFirst=" + isFirst + ", isLast=" + isLast + "]";
}
}
package com.apptest.board.vo;
import java.util.Date;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@ToString
@Builder
public class Board {
private int id;
private String nickname;
private String title;
private String content;
private Date createDate;
private Date deleteDate;
private String deleteYn;
}
package com.apptest.board.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import com.apptest.board.dto.BoardFormDto;
import com.apptest.board.dto.BoardDetailDto;
import com.apptest.board.dto.BoardListDto;
import com.apptest.board.dto.Criteria;
import com.apptest.board.dto.ListDto;
import com.apptest.board.service.BoardService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Controller
@RequiredArgsConstructor
@RequestMapping("/board")
public class BoardController {
private final BoardService boardService;
// 게시글 목록 조회
@GetMapping("/list")
public String getBoards(@RequestParam(name = "page", required = false, defaultValue = "1") int page,
@RequestParam(name = "rows", required = false, defaultValue = "10") int rows,
@RequestParam(name = "opt", required = false) String opt,
@RequestParam(name = "keyword", required = false) String keyword,
Model model) {
Criteria criteria = new Criteria();
criteria.setPage(page);
criteria.setRows(rows);
log.info("현재 페이지 : {}", criteria.getPage());
log.info("페이지당 게시물 수 : {}", criteria.getRows());
// 검색옵션(opt)과 검색어(keyword) 모두 null이나 빈 문자열이 아닐 때만 Map에 저장한다.
if (StringUtils.hasText(opt) && StringUtils.hasText(keyword)) {
criteria.setOpt(opt);
criteria.setKeyword(keyword);
}
ListDto<BoardListDto> boards = boardService.getBoards(criteria);
model.addAttribute("boards", boards.getItems());
model.addAttribute("paging", boards.getPaging());
model.addAttribute("criteria", criteria);
return "board/list";
}
// 게시글 등록 폼
@GetMapping("/create")
public String createBoard(Model model) {
model.addAttribute("board", new BoardFormDto());
model.addAttribute("mode", "create");
return "board/form";
}
// 게시글 등록
@PostMapping("/create")
public String createBoard(@Valid @ModelAttribute("board") BoardFormDto boardFormDto, BindingResult errors,
Model model) {
log.info("닉네임 = {}", boardFormDto.getNickname());
log.info("벨리데이션 = {}", errors.hasErrors());
if (errors.hasErrors()) {
return "board/form";
}
boardService.createBoard(boardFormDto);
return "redirect:/board/list";
}
// 게시글 상세 조회
@GetMapping("/detail/{id}")
public String getBoardDetail(@PathVariable int id, Model model) {
BoardDetailDto board = boardService.getBoardDetail(id);
model.addAttribute("board", board);
return "board/detail";
}
// 게시글 수정 폼
@GetMapping("/update/{id}")
public String updateBoard(@PathVariable int id, Model model) {
BoardDetailDto board = boardService.getBoardDetail(id);
BoardFormDto boardFormDto = BoardFormDto.builder()
.id(id)
.nickname(board.getNickname())
.title(board.getTitle())
.content(board.getContent())
.build();
model.addAttribute("board", boardFormDto);
model.addAttribute("mode", "update");
return "board/form";
}
// 게시글 수정
@PostMapping("/update/{id}")
public String updateBoard(@PathVariable int id, @Valid BoardFormDto boardFormDto, Model model) {
boardService.updateBoard(boardFormDto);
return "redirect:/board/detail/" + id;
}
// 게시글 삭제
@PostMapping("/delete/{id}")
public String deleteBoard(@PathVariable int id, RedirectAttributes redirectAttributes) {
boardService.deleteBoard(id);
redirectAttributes.addFlashAttribute("msg", "게시글이 삭제되었습니다.");
return "redirect:/board/list";
}
}
package com.apptest.board.service;
import java.util.List;
import org.springframework.stereotype.Service;
import com.apptest.board.dto.BoardFormDto;
import com.apptest.board.dto.BoardDetailDto;
import com.apptest.board.dto.BoardListDto;
import com.apptest.board.dto.Criteria;
import com.apptest.board.dto.ListDto;
import com.apptest.board.dto.Pagination;
import com.apptest.board.mapper.BoardMapper;
import com.apptest.board.vo.Board;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
@Log4j2
@Service
@RequiredArgsConstructor
public class BoardService {
private final BoardMapper boardMapper;
// 게시글 목록 조회
public ListDto<BoardListDto> getBoards(Criteria criteria) {
// 1. 총 데이터 개수 조회
int totalRows = boardMapper.getTotalRows(criteria);
// 2. 페이징 처리에 필요한 정보를 표현하는 객체 생성
Pagination pagination = new Pagination(criteria.getPage(), totalRows, criteria.getRows());
// 3. 현재 페이지번호에 해당하는 조회범위를 Criteria객체에 저장
criteria.setBegin(pagination.getBegin());
criteria.setEnd(pagination.getEnd());
List<BoardListDto> boardList = boardMapper.getBoards(criteria);
log.info("보드 리스트 : {}", boardList.size());
log.info("크리테리아 : {}, {}", criteria.getOpt(), criteria.getKeyword());
ListDto<BoardListDto> dto = new ListDto<BoardListDto>(boardList, pagination);
return dto;
}
// 게시글 등록
public void createBoard(BoardFormDto boardFormDto) {
Board board = Board.builder()
.nickname(boardFormDto.getNickname())
.title(boardFormDto.getTitle())
.content(boardFormDto.getContent())
.build();
boardMapper.createBoard(board);
}
// 게시글 상세 조회
public BoardDetailDto getBoardDetail(int id) {
return boardMapper.getBoardDetail(id);
}
// 게시글 수정
public void updateBoard(BoardFormDto boardFormDto) {
Board board = Board.builder()
.id(boardFormDto.getId())
.title(boardFormDto.getTitle())
.content(boardFormDto.getContent())
.build();
boardMapper.updateBoard(board);
}
// 게시글 삭제
public void deleteBoard(int id) {
boardMapper.deleteBoard(id);
}
}
package com.apptest.board.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import com.apptest.board.dto.BoardDetailDto;
import com.apptest.board.dto.BoardListDto;
import com.apptest.board.dto.Criteria;
import com.apptest.board.vo.Board;
@Mapper
public interface BoardMapper {
int getTotalRows(Criteria criteria);
List<BoardListDto> getBoards(Criteria criteria);
void createBoard(Board board);
BoardDetailDto getBoardDetail(int id);
void updateBoard(Board board);
void deleteBoard(int id);
}
<?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.apptest.board.mapper.BoardMapper">
<select id="getTotalRows" parameterType="com.apptest.board.dto.Criteria" resultType="int">
select
count(*)
from
board
<!-- 검색옵션이 있을 때만 아래 조회 조건 적용 -->
<where>
<if test="opt != null">
<choose>
<when test="opt == 'title' ">
title like concat('%', #{keyword},'%')
</when>
<when test="opt == 'content' ">
content like concat('%', #{keyword},'%')
</when>
</choose>
</if>
</where>
</select>
<select id="getBoards" parameterType="com.apptest.board.dto.Criteria" resultType="com.apptest.board.dto.BoardListDto">
select
b.id as id,
b.nickname as nickname,
b.title as title,
b.content as content,
b.create_date as createDate,
b.delete_date as deleteDate,
b.delete_yn as deleteYn
from
(select
row_number() over (order by id desc) as rowNum,
id,
nickname,
title,
content,
create_date,
delete_date,
delete_yn
from
board
where
delete_yn = 'N'
<if test="opt != null">
<choose>
<when test="opt == 'title' ">
and title like concat('%', #{keyword},'%')
</when>
<when test="opt == 'content' ">
and content like concat('%', #{keyword},'%')
</when>
</choose>
</if>
) as b
where
b.rowNum between #{begin} and #{end}
</select>
<insert id="createBoard" parameterType="com.apptest.board.vo.Board">
insert into board
(nickname, title, content)
values
(#{nickname}, #{title}, #{content})
</insert>
<select id="getBoardDetail" parameterType="int" resultType="com.apptest.board.dto.BoardDetailDto">
select
b.id as id,
b.nickname as nickname,
b.title as title,
b.content as content,
b.create_date as createDate,
b.delete_date as deleteDate,
b.delete_yn as deleteYn
from
board b
where
id = #{value}
and delete_yn = 'N'
</select>
<update id="updateBoard" parameterType="com.apptest.board.vo.Board">
update
board
set
title = #{title},
content = #{content}
where
id = #{id}
</update>
<update id="deleteBoard" parameterType="int">
update
board
set
delete_yn = 'Y'
where
id = #{id}
</update>
</mapper>
<%@ 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" %>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.bundle.min.js" ></script>
<title>게시판 테스트</title>
</head>
<body>
<c:if test="${not empty msg}">
<script>
alert("${msg}");
</script>
</c:if>
<div class="container">
<div class="row">
<div class="col-12 mt-3">
<h1>게시판</h1>
<form id="form-boards" method="get" action="list">
<input type="hidden" name="page">
<div class="d-flex justify-content-start mb-3">
<div class="me-2">
<select class="form-select" name="opt">
<option value="title" ${param.opt eq 'title' ? 'selected' : '' }> 제목</option>
<option value="content" ${param.opt eq 'content' ? 'selected' : '' }> 내용</option>
</select>
</div>
<div class="me-2">
<input type="text" class="form-control" name="keyword" value="${param.keyword }"/>
</div>
<div>
<button type="submit" class="btn btn-outline-primary">검색</button>
</div>
</div>
<table class="table table-bordered">
<tr>
<td>번호</td>
<td>제목</td>
<td>내용</td>
<td>작성자</td>
<td>등록일</td>
</tr>
<c:forEach var="board" items="${boards }">
<c:choose>
<c:when test="${board.deleteYn == 'Y' }">
</c:when>
<c:otherwise>
<tr>
<td>${board.id }</td>
<td>
<a href="/board/detail/${board.id }">${board.title }</a>
</td>
<td>${board.content }</td>
<td>${board.nickname }</td>
<td><fmt:formatDate value="${board.createDate }" pattern="yyyy.MM.dd HH:mm" /></td>
</tr>
</c:otherwise>
</c:choose>
</c:forEach>
</table>
<div>
<div class="col-4">
<c:if test="${paging.totalRows ne 0 }"> <!-- ne : not equal, totalRows가 0이 아닐 때 -->
<nav>
<ul class="pagination">
<li class="page-item">
<a href="list?page=${paging.currentPage - 1 }"
class="page-link ${paging.first ? 'disabled' : '' }"
onclick="changePage(${paging.currentPage - 1}, event)"><</a>
</li>
<c:forEach var="num" begin="${paging.beginPage }" end="${paging.endPage }">
<li class="page-item ${paging.currentPage eq num ? 'active' : '' }">
<a href="list?page=${num }"
class="page-link"
onclick="changePage(${num }, event)">${num }</a>
</li>
</c:forEach>
<li class="page-item">
<a href="list?page=${paging.currentPage + 1 }"
class="page-link ${paging.last ? 'disabled' : ''}"
onclick="changePage(${paging.currentPage + 1}, event)">></a>
</li>
</ul>
</nav>
</c:if>
</div>
<a class="btn btn-outline-primary mt-3 mb-5" href="create">글쓰기</a>
</div>
</form>
</div>
</div>
</div>
<script type="text/javascript">
function changePage(page, event) {
event.preventDefault();
document.querySelector("input[name=page]").value = page;
document.getElementById("form-boards").submit();
}
</script>
</body>
</html>
<%@ 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" %>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.bundle.min.js" ></script>
<title>게시글 상세</title>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-12 mt-3">
<h1>게시글 상세</h1>
<c:choose>
<c:when test="${empty board }">
<p class="mt-3">존재하지 않는 게시물입니다.</p>
<div class="d-flex justify-content-between mt-5 mb-5">
<a class="btn btn-outline-secondary me-2" href="/board/list">목록</a>
</div>
</c:when>
<c:otherwise>
<p class="mt-3">번호 : ${board.id }</p>
<p>작성자 : ${board.nickname }</p>
<p>등록일 : <fmt:formatDate value="${board.createDate }" pattern="yyyy.MM.dd HH:mm"/></p>
<p>제목 : ${board.title }</p>
<p>내용 : ${board.content }</p>
<div class="d-flex justify-content-between mt-5 mb-5">
<a class="btn btn-outline-secondary me-2" href="/board/list">목록</a>
<div class="d-flex justify-content-end">
<a class="btn btn-outline-primary me-2" href="/board/update/${board.id }">수정</a>
<form action="/board/delete/${board.id}" method="post" style="display:inline;">
<button type="submit" class="btn btn-outline-danger">삭제</button>
</form>
</div>
</div>
</c:otherwise>
</c:choose>
</div>
</div>
</div>
</body>
</html>
<%@ 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" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.bundle.min.js" ></script>
<title>게시물 등록</title>
</head>
<body>
<%@ include file="../common/navbar.jsp" %>
<div class="container">
<div class="row">
<div class="col-12 mt-3">
<h1>게시글 등록</h1>
<!-- 등록/수정 폼 분리 -->
<c:choose>
<c:when test="${mode eq 'update' }">
<c:set var="actionUrl" value="/board/update/${board.id }"/>
</c:when>
<c:otherwise>
<c:set var="actionUrl" value="/board/create"/>
</c:otherwise>
</c:choose>
<!-- Spring form 태그 사용 -->
<form:form method="post" modelAttribute="board" action="${actionUrl }">
<table class="table">
<tr>
<td><label for="nickname">닉네임</label></td>
<td>
<c:choose>
<c:when test="${mode == 'create' }">
<form:input path="nickname" cssClass="form-control" id="nickname" />
</c:when>
<c:otherwise>
<form:input readonly="true" path="nickname" cssClass="form-control" id="nickname" />
</c:otherwise>
</c:choose>
<form:errors path="nickname" cssClass="text-danger" />
</td>
</tr>
<tr>
<td><label for="title">제목</label></td>
<td>
<form:input path="title" cssClass="form-control" id="title" />
<form:errors path="title" cssClass="text-danger" />
</td>
</tr>
<tr>
<td><label for="content">내용</label></td>
<td>
<form:textarea path="content" cssClass="form-control" id="content" />
<form:errors path="content" cssClass="text-danger" />
</td>
</tr>
</table>
<div class="d-flex justify-content-between mt-3">
<a href="/board/list" class="btn btn-outline-secondary">목록</a>
<button type="submit" class="btn btn-outline-primary">저장</button>
</div>
</form:form>
</div>
</div>
</div>
</body>
</html>