게시판 만들기

✒️ 2025-05-28 13:14 내용 수정


실습 목표

실습 흐름

  1. DB와 Spring boot를 Mybatis로 연결한다.
  2. DB에 테이블을 추가한다. 이미 사용하던 테이블이 존재한다면 사용하던 테이블을 연결한다.
  3. DTO, Mapper 인터페이스, mapper.xml(SQL문), DAO, Service 인터페이스 및 클래스를 만든다.
  4. Service를 호출할 Controller를 만든다.
  5. 정보를 표시하고 주고 받을 HTML을 만든다.

흐름 내용

1. DB 연결 및 테이블 구성

  1. DB 연결을 위한 Mybatis 설정을 참고하여 DB 연결에 필요한 MyBatis 설정을 진행한다.

  2. Board 테이블과 Member 테이블을 생성한다.

--시퀀스
CREATE SEQUENCE SEQ_BOARD_IDX;

--테이블
CREATE TABLE BOARD(
	IDX NUMBER(3) PRIMARY KEY,  --번호
	NAME VARCHAR2(100) NOT NULL, --작성자
	SUBJECT VARCHAR2(255) NOT NULL, --게시글 이름
	CONTENT CLOB,  --게시글 내용
	PWD VARCHAR2(100),  --비밀번호
	IP VARCHAR2(100),  --IP
	REGDATE DATE,  --작성일
	READHIT NUMBER(3) DEFAULT 0, --조회수
	REF INT, --기준글번호(댓글의 메인글 번호)
	STEP INT,  --댓글순서
	DEPTH INT,  --대댓글
	DEL_INFO NUMBER(2)  --글 삭제여부
);


--시퀀스
CREATE SEQUENCE SEQ_MYUSER_IDX;

--테이블
CREATE TABLE MYUSER(
	IDX NUMBER(3) PRIMARY KEY,
	NAME VARCHAR2(100) NOT NULL,
	ID VARCHAR2(100) NOT NULL, UNIQUE,
	PWD VARCHAR2(100) NOT NULL
);

2. util 클래스

  1. 상수를 저장할 클래스와 내부 클래스를 만든다.
    • 한 페이지 당 보여줄 게시물의 수와 페이지 메뉴 수를 저장한다.
package com.example.board.common;

public class Common {
	
	public static class Board {
		
		// 한 페이지 당 보여줄 게시물 수
		public final static int BLOCKLIST = 10;
		
		// 한 화면에 보여지는 페이지 메뉴 수
		public final static int BLOCKPAGE = 5;
	}
}
  1. 페이지 메뉴를 만들 Paging 클래스를 만든다.
    • getPaging() 메소드는 URL, 현재 페이지, 전체 줄 수, 한 페이지 당 보여줄 게시물 수, 페이지 메뉴 수를 매개변수로 받는다.
    • JSP와 Spring의 페이지 처리 클래스 및 메소드도 참고.
package com.example.board.util;

public class Paging {
	
	public static String getPaging(String pageURL, int nowPage, int rowTotal, int blockList, int blockPage) {
		
		int totalPage;
		int startPage;
		int endPage;
		
		boolean isPrevPage, isNextPage;
		StringBuffer sb; // HTML에 출력할 태그
		
		isPrevPage = isNextPage = false;
		
		// 전체 페이지 = 전체 줄 수 / 한 페이지에 보여줄 게시물 수
		totalPage = rowTotal / blockList;
		
		if (rowTotal % blockList != 0 ) {
			totalPage++; // 여분을 담을 페이지까지 포함
		}
		
		if (nowPage > totalPage) {
			nowPage = totalPage; // 현재 페이지의 초과 방지
		}

		// 시작 페이지는 blockPage 단위로 증가하도록 설정
		startPage = (int)(((nowPage-1)/blockPage) * blockPage + 1);
		endPage = startPage + blockPage - 1;
		
		if (endPage > totalPage) {
			endPage = totalPage; // 마지막 페이지 초과 방지
		}
		
		if (endPage < totalPage) {
			isNextPage = true; // 다음 페이지로 넘어갈 수 있음
		}
		
		if (startPage > 1) {
			isPrevPage = true; // 이전 페이지로 넘어갈 수 있음
		}
		
		// 출력할 태그 생성
		sb = new StringBuffer();

		// i 태그는 fontawesome 사이트의 아이콘을 사용
		if(isPrevPage) {
			sb.append("<a href='"+pageURL+"?page=");
			sb.append(startPage - 1);
			sb.append("'><i class=\"fa-solid fa-arrow-left\"></i></a>");
		} else {
			sb.append("<i class=\"fa-solid fa-arrow-left\"></i>");
		}
		
		sb.append("&nbsp;");
		
		for(int i = startPage; i <= endPage; i++) {
			if(i > totalPage) break;
			if(i == nowPage) { // 선택한 페이지 강조 처리
				sb.append("<b>"+i+"</b>");
			} else {
				sb.append("<a href='"+pageURL+"?page=");
				sb.append(i);
				sb.append("'>"+i+"</a>");
			}
		}
		
		sb.append("&nbsp;");
		
		if(isNextPage) {
			sb.append("<a href='"+pageURL+"?page=");
			sb.append(endPage);
			sb.append("'><i class=\"fa-solid fa-arrow-right\"></i></a>");
		} else {
			sb.append("<i class=\"fa-solid fa-arrow-right\"></i>");
		}
		
		return sb.toString();
	}
}

3. DTO와 Mapper 인터페이스, mapper.xml 생성

  1. src/main/java 폴더의 com.example.tier처럼 group id와 artifact로 된 패키지의 하위 패키지로 dto 패키지와 mapper 패키지를 만든다.
  2. dto 패키지에 BoardDTO와 MemberDTO를 만든다.
package com.example.board.dto;

import lombok.Data;

@Data
public class BoardDTO {
	private int idx;
	private int readhit;
	private int ref;
	private int step;
	private int depth;
	private int delInfo;
	
	private String name;
	private String subject;
	private String content;
	private String pwd;
	private String ip;
	private String regdate;
}
package com.example.board.dto;

import lombok.Data;

@Data
public class MemberDTO {
	
	private int idx;
	private String name;
	private String id;
	private String pwd;
	private String email;
}
  1. mapper 패키지에 BoardMapper와 MemberMapper를 인터페이스로 만든다.
    • Mapper 클래스에는 @Mapper Annotation을 추가한다.
    • Board에서 주로 수행할 수 있는 기능은 게시글을 페이지 수만큼 조회하기, 조회수 증가, 글 작성, 답글 작성, 글을 삭제된 것처럼 처리하는 것이다.
      • 답글 작성의 경우 게시판 글들의 계층구조로 인해 step 증가 처리를 부가적으로 수행해야 한다.
package com.example.board.mapper;

import java.util.HashMap;
import java.util.List;

import org.apache.ibatis.annotations.Mapper;

import com.example.board.dto.BoardDTO;

@Mapper
public interface BoardMapper {
	
	// 페이지 게시물 조회
	public List<BoardDTO> selectList(HashMap<String, Integer> map);
	
	// 전체 게시물 수 조회
	public int getRowTotal();
	
	// 게시물 1건 조회
	public BoardDTO select(int idx);
	
	// 조회수 증가
	public int update_readhit(int idx);
	
	// 글 작성
	public int insert(BoardDTO dto);
	
	// 삭제된 것처럼 처리
	public int del_update(BoardDTO dto);
	
	// step 증가
	public int update_step(BoardDTO origin_dto);
	
	// 답글 추가
	public int reply(BoardDTO dto);
}
package com.example.board.mapper;

import org.apache.ibatis.annotations.Mapper;

import com.example.board.dto.MemberDTO;

@Mapper
public interface MemberMapper {

	// 회원 조회
	public MemberDTO select(String id);
	
	// 회원 가입
	public int join(MemberDTO dto);
}
  1. src/main/resources 폴더에 mapper 패키지를 만들고, Mapper 인터페이스와 연결할 mapper.xml(board.xml과 member.xml)의 SQL문을 작성한다.
    • query문은 JSP와 Spring에서도 진행한 게시판 만들기의 query문과 동일하다.
<?xml version="1.0" encoding="UTF-8"?>
	  
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0/EN" "https://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <typeAliases>
		<typeAlias type="com.example.board.dto.BoardDTO" alias="boardDTO"/>
		<typeAlias type="com.example.board.dto.MemberDTO" alias="MemberDTO"/>
    </typeAliases>
</configuration>
<?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.example.board.mapper.BoardMapper">
	<!-- 페이지 게시물 조회 -->
	<select id="selectList" resultType="boardDTO">
		SELECT * FROM 
		(SELECT RANK () OVER (ORDER BY REF DESC, STEP) AS NO, B.* FROM BOARD B)
		WHERE NO BETWEEN #{start} AND #{end} 
	</select>

	<!-- 전체 게시물 수 조회 -->
	<select id="getRowTotal" resultType="int">
		SELECT COUNT(*) FROM BOARD
	</select>
	
	<!-- 게시글 1건 조회 -->
	<select id="select">
		SELECT * FROM BOARD
		WHERE IDX = #{idx}
	</select>
	
	<!-- 조회수 증가 -->
	<update id="update_readhit">
		UPDATE BOARD
		SET READHIT = READHIT + 1
		WHERE IDX = #{idx}
	</update>
	
	<!-- 글 작성 -->
	<insert id="insert">
		INSERT INTO BOARD VALUES(
			SEQ_BOARD_IDX.nextVal,
			#{name},
			#{subject},
			#{content},
			#{pwd},
			#{ip},
			SYSDATE,
			0,
			SEQ_BOARD_IDX.currVal,
			0,
			0,
			0
		)
	</insert>
	
	<!-- 삭제된 것처럼 처리 -->
	<update id="del_update">
		UPDATE BOARD
		SET SUBJECT = #{subject},
			NAME = #{name},
			DEL_INFO = -1
		WHERE IDX = #{idx}
	</update>
	
	<!-- step 증가 -->
	<update id="update_step">
		UPDATE BOARD
		SET STEP = STEP + 1
		WHERE REF = #{ref} AND STEP > #{step}
	</update>
	
	<!-- 답글 추가 -->
	<insert id="reply">
		INSERT INTO BOARD VALUES(
			SEQ_BOARD_IDX.nextVal,
			#{name},
			#{subject},
			#{content},
			#{pwd},
			#{ip},
			SYSDATE,
			0,
			#{ref},
			#{step},
			#{depth},
			0
		)
	</insert>
</mapper>
<?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.example.board.mapper.MemberMapper">
	<!-- 회원 조회 -->
	<select id="select">
		SELECT IDX, NAME, ID, PWD, EMAIL FROM MEMBER WHERE id = #{id}
	</select>

	<!-- 회원가입 -->
	<insert id="join">
		INSERT INTO MEMBER VALUES(
			SEQ_MEMBER_IDX.nextVal,
			#{name},
			#{id},
			#{pwd},
			#{email}
		)
	</insert>
</mapper>

4. DAO

  1. 이제 src/main/java의 하위 dao패키지를 만들어 Mapper 인터페이스를 사용할 BoardDAO와 MemberDAO를 만든다.
    • DAO 클래스에는 @Repository Annotation을 추가한다.
    • DAO는 Mapper 인터페이스를 생성자 주입하고, Mapper 인터페이스의 메소드를 호출한다.
package com.example.board.dao;

import java.util.HashMap;
import java.util.List;

import org.springframework.stereotype.Repository;

import com.example.board.dto.BoardDTO;
import com.example.board.mapper.BoardMapper;

import lombok.RequiredArgsConstructor;

@Repository
@RequiredArgsConstructor
public class BoardDAO {

	private final BoardMapper boardMapper;
	
	// 페이지별 게시물 조회
	public List<BoardDTO> selectList(HashMap<String, Integer> map) {
		return boardMapper.selectList(map);
	}
	
	// 전체 게시글 수 조회
	public int getRowTotal() {
		return boardMapper.getRowTotal();
	}
	
	// 게시글 1건 조회
	public BoardDTO select(int idx) {
		return boardMapper.select(idx);
	}
	
	// 조회수 증가
	public int update_readhit(int idx) {
		return boardMapper.update_readhit(idx);
	}
	
	// 새 글 작성
	public int insert(BoardDTO dto) {
		return boardMapper.insert(dto);
	}
	
	// 삭제된 것처럼 처리
	public int del_update(BoardDTO origin_dto) {
		return boardMapper.del_update(origin_dto);
	}
	
	// step 증가
	public int update_step(BoardDTO dto) {
		return boardMapper.update_step(dto);
	}
	
	// 답글 추가
	public int reply(BoardDTO dto) {
		return boardMapper.reply(dto);
	}
}
package com.example.board.dao;

import org.springframework.stereotype.Repository;

import com.example.board.dto.MemberDTO;
import com.example.board.mapper.MemberMapper;

import lombok.RequiredArgsConstructor;

@Repository
@RequiredArgsConstructor
public class MemberDAO {
	
	private final MemberMapper memberMapper;
	
	// 회원 조회
	public MemberDTO select(String id) {
		return memberMapper.select(id);
	}
	
	// 회원 가입
	public int join(MemberDTO dto) {
		return memberMapper.join(dto);
	}
}

5. Controller

  1. src/main/java의 하위 패키지로 controller 패키지를 만들고, BoardController와 MemberController 클래스를 만든다.
    • Controller 클래스는 @Controller Annotation과 @RequestMapping("/상위경로") Annotation(선택사항)을 추가한다.
package com.example.board.controller;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.view.RedirectView;

import com.example.board.common.Common;
import com.example.board.common.Common.Board;
import com.example.board.dao.BoardDAO;
import com.example.board.dao.MemberDAO;
import com.example.board.dto.BoardDTO;
import com.example.board.dto.MemberDTO;
import com.example.board.util.Paging;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;

@Controller
@RequiredArgsConstructor
@RequestMapping("/board/*")
public class BoardController {

	// DAO를 바로 주입 받아서 사용했다
	private final BoardDAO boardDAO;
	private final MemberDAO memberDAO;

	// HttpServlertRequest를 필드 주입받아 사용
	@Autowired
	private HttpServletRequest request;

	// 페이지별 게시글 목록 조회
	@GetMapping(value={"/", "board_list"})
	public String list(Model model, @RequestParam(required=false, defaultValue="1") int page) {
		// 페이지 설정 -> 조회 시 해당 범위의 게시물을 가져옴
		int start = (page - 1) * Common.Board.BLOCKLIST + 1;
		int end = start + Common.Board.BLOCKLIST -1;
		
		HashMap<String, Integer> map = new HashMap<>();
		map.put("start", start);
		map.put("end", end);

		// 전체 게시글 수 조회
		int rowTotal = boardDAO.getRowTotal();
		
		// 페이지 메뉴 생성(태그)
		String pageMenu = Paging.getPaging("board_list", page, rowTotal, Board.BLOCKLIST, Board.BLOCKPAGE);
		
		// 페이지별 게시물 조회
		List<BoardDTO> list = boardDAO.selectList(map);
		
		// 조회수 관련 session 정보 제거 -> 한 게시글에서 F5로 조회수 증가 방지용
		request.getSession().removeAttribute("show");
		
		// 데이터 포워딩
		model.addAttribute("list", list);
		model.addAttribute("pageMenu", pageMenu);
		
		return "/board/board_list";
	}

	// 게시글 상세 보기
	@GetMapping("view")
	public String view(Model model, @RequestParam(defaultValue="1") int page, int idx) {
		
		// 게시물 1건 조회
		BoardDTO dto = boardDAO.select(idx);
		
		// 조회수 1회 업로드용 세션 속성 추가
		String show = (String)request.getSession().getAttribute("show");
		
		if(show == null) { // 조회수 속성이 없을 경우 조회수 올리고 속성 추가
			int res = boardDAO.update_readhit(idx);
			request.getSession().setAttribute("show", "0");
		}

		// 데이터 포워딩
		model.addAttribute("dto", dto);
		
		return "board/board_view";
	}

	// 글 작성 페이지로 이동
	@GetMapping("insert_form")
	public String insert_form(Model model, @RequestParam(required=false, defaultValue="1") int page) {
		// 미리 form에 dto 전달해서 넣어놓기
		model.addAttribute("dto", new BoardDTO());
		return "board/insert_form";
	}

	// 글 작성
	@PostMapping("insert")
	public RedirectView insert(BoardDTO dto) { // form으로부터 온 데이터는 자동으로 dto에 담김
		// ip 등록
		String ip = request.getRemoteAddr();
		dto.setIp(ip);
		
		// 글 추가
		int res = boardDAO.insert(dto);
		
		return new RedirectView("/board/board_list");

	}

	// 글 삭제된 것처럼 처리
	@PostMapping("del")
	@ResponseBody // ajax를 위한 결과 전송
	public String del(@RequestBody String body) { // POST 요청 시 RequestBody에 데이터가 담겨온다
		
		// ObjectMapper : Java 객체와 JSON 데이터 간의 변환을 수행할 때 사용한다
		// JSON을 읽고 쓰는 함수 제공하고, Java 객체를 JSON으로 직렬화하거나, JSON을 Java객체로 역직렬화 해준다.
		ObjectMapper om = new ObjectMapper(); 
		
		// 변환시킬 JSON 데이터를 저장할 Map
		Map<String, String> data = null;
		
		try {
			// JSON 문자열이나 데이터를 Java 객체로 변환
			// JSON의 정보를 Map에 저장
			data = om.readValue(body, new TypeReference<Map<String, String>>() {});
		} catch (Exception e) {
		}
		
		// Map에서 idx를 가져온다
		int intIdx = Integer.parseInt(data.get("idx"));

		// 삭제 처리할 dto를 조회
		BoardDTO dto = boardDAO.select(intIdx);
		
		dto.setSubject("deleted"); // 삭제 처리
		dto.setName("unknown"); // 삭제 처리

		// DB에 삭제 처리
		int res = boardDAO.del_update(dto);
		
		if(res > 0) {
			return "{\"param\":\"success\"}";
		}
		
		return "{\"param\":\"fail\"}";
	}

	// 답변 작성 페이지로 이동
	@GetMapping("reply_form")
	public String reply_form(Model model, @RequestParam(required=false, defaultValue="1") int page, int idx) {
		// form에 미리 dto 넣어두기
		model.addAttribute("dto", new BoardDTO());
		
		return "board/reply_form";
	}

	// 답변 작성
	@PostMapping("reply/{idx}") // 이번엔 @PathVariable을 사용해봤다
	public RedirectView reply(BoardDTO dto, @PathVariable("idx") int idx, @RequestParam(defaultValue="1") int page) {
		// ip 설정
		dto.setIp(request.getRemoteAddr());
		
		// 원본 게시글 정보 조회
		BoardDTO origin_dto = boardDAO.select(idx);
		
		// 기준글에 step 이상값은 step = step + 1 처리
		int res = boardDAO.update_step(origin_dto);
		
		// 답글의 ref, depth, step 설정
		dto.setRef(origin_dto.getRef());
		dto.setStep(origin_dto.getStep()+1);
		dto.setDepth(origin_dto.getDepth()+1);

		// 답글 작성
		int res2 = boardDAO.reply(dto);
		
		return new RedirectView("/board/board_list?page="+page);
	}

	// 로그인 페이지로 이동
	@GetMapping("login_form")
	public String login_form(@ModelAttribute("dto") MemberDTO dto) { // model에 "dto"라는 key의 value를 MemberDTO dto로 설정
		
		return "board/login_form";
	}
	
	// 로그인
	@GetMapping("login")
	@ResponseBody
	public String login(String id, String pwd) {
		// id 확인용
		System.out.println("id : " + id + " | pwd : " + pwd);

		// DB에 id 조회
		MemberDTO dto = memberDAO.select(id);

		if(dto == null || !dto.getPwd().equals(pwd)) { // id가 없거나 비밀번호가 일치하지 않을 때
			return "{\"result\":\"no\"}";
		}

		// 로그인 처리로 세션에 id를 저장
		request.getSession().setAttribute("id", dto);
		
		return "{\"result\":\"yes\"}";
	}

	// 로그아웃
	@GetMapping("logout")
	public RedirectView logout() {
		// 세션에서 id를 제거한다.
		request.getSession().removeAttribute("id");
		return new RedirectView("/board/board_list");
	}

	// 회원가입 페이지로 이동
	@GetMapping("join_form")
	public String join_form(@ModelAttribute("dto") MemberDTO dto) { // Annotation을 사용하면 더 간결해진다.
		return "board/join_form";
	}

	// 아이디 중복 검사
	@GetMapping("id_check")
	@ResponseBody
	public String id_check(String id) {
		// 전달 받은 id가 DB에 존재하는지 조회
		MemberDTO dto = memberDAO.select(id);
		
		if(dto != null) {
			return "{\"result\":\"no\"}";
		}

		return "{\"result\":\"yes\"}";
	}

	// 회원가입
	@PostMapping("join")
	public RedirectView join(MemberDTO dto) {
		int res = memberDAO.join(dto);
		
		if (res > 0) {
			return new RedirectView("/board/login_form");
		}
		
		return new RedirectView("/board/join_form");
	}
}
 //post 형식으로 사용 시
@PostMapping("login") // Mapping을 Post로 변경
@ResponseBody public String login(@RequestBody String body) { 
	// Java <-> JSON 변환 객체
	ObjectMapper om = new ObjectMapper();
	// 데이터를 저장할 Map 인스턴스
	Map<String, String> data = null;

	try { 
		// JSON 데이터를 Map에 저장
		data = om.readValue(body, new TypeReference<Map<String, String>>() {});
	} catch (Exception e) { }

	// id와 pwd를 변수에 저장
	String id = data.get("id"); 
	String pwd = data.get("pwd");

	// 해당 id가 DB에 존재하는지 조회
	MemberDTO dto = memberDAO.select(id);
	
	if(dto == null || !dto.getPwd().equals(pwd)) { // id가 없거나 비밀번호가 틀렸을 때
		return "[\"result\":\"no\"]";
	}
	// 로그인 처리로 세션에 id 저장
	request.getSession().setAttribute("id", dto);
	
	return "[\"result\":\"yes\"]"; 
} 

6. HTML, CSS, JS

  1. 이제 src/main/resources/templates 패키지 하위에 product 폴더와 order 폴더를 만들고, 정보를 주고받을 HTML을 만든다.
<!-- board_list.html -->
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
	<script src="https://kit.fontawesome.com/75c3a9ae5d.js" crossorigin="anonymous"></script>
	<link rel="stylesheet" th:href="@{/board.css}">
</head>
<body>
	<div class="container">
		<table>
			<tr>
				<td colspan="5" align="right">
					<th:block th:if="${session.id == null}">
						<input type="button" value="로그인" name="login_form">
						<input type="button" value="회원가입" name="join">
					</th:block>
					<th:block th:unless="${session.id == null}">
						<input type="button" value="로그아웃" name="logout">
					</th:block>
				</td>
			</tr>
			<tr>
				<th>번호</th>
				<th>제목</th>
				<th>작성자</th>
				<th>작성일</th>
				<th>조회수</th>
			</tr>
			<th:block th:each="dto:${list}">
				<tr th:object="${dto}">
					<td align="center" th:text="*{idx}"></td>
					
					<td>
						<!-- 게시글 제목 표시 -->
						<!-- 답글일 경우엔 들여쓰기와 기호를 추가 -->
						<th:block th:if="*{depth > 0}">
							<!-- #numbers 유틸리티 객체를 사용해서 1부터 depth까지 1씩 증가하는 sequence를 만들어
								th:each에 적용하여 for문의 기능을 구현한다.
								${#numbers.sequence(1, dto.depth)}으로 작성해도 작동한다.
							-->
							<span th:each="depth : *{#numbers.sequence(1, depth)}">&nbsp;</span>
						</th:block>
						
						<th:block th:if="*{depth != 0}">ㄴ</th:block>
						
						<th:block th:if="*{delInfo != -1}">
							<!-- a태그 사용 시 mapping을 잡아주려면 th:href를 사용해 잡아줘야 한다. -->
							<a th:href="@{/board/view(idx=*{idx}, page=${param.page})}" th:text="*{subject}"></a>
						</th:block>
						<!-- 삭제된 글이라면 클릭 불가 -->
						<th:block th:if="*{delInfo == -1}">
							<font color="gray" th:text="*{subject}"></font>
						</th:block>
					</td>
					
					<!-- 게시글 작성자 표시도 삭제 여부에 따라 다르게 표시함 -->
					<td th:if="*{delInfo != -1}" th:text="*{name}"></td>
					<td th:unless="*{delInfo != -1}" th:text="unknown"></td>
					
					<!-- 게시글 작성일 표시도 삭제 여부에 따라 다르게 표시함 -->
					<!-- #strings 유틸리티 객체의 substring 메소드를 사용해서 
						date 객체의 시간 부분을 제거하고 날짜만 표시
					-->
					<td th:if="*{delInfo != -1}" th:text="*{#strings.substring(regdate, 0, 10)}">
					<td th:unless="*{delInfo != -1}" th:text="unknwon">
					<td th:text="*{readhit}"></td>
				</tr>
			</th:block>
			<tr>
				<td colspan="5" align="center">
					<!-- Paging 클래스에서 getPaging() 메소드를 사용해서 
						만든 페이지 메뉴를 id="pageMenu"인 div에 추가 
					-->
					<div id="pageMenu"></div>
				</td>
			</tr>
			<tr>
				<td colspan="5" align="right">
					<!-- button에 onclick을 property로 바로 적용 시 
						thymeleaf 표현식을 사용해서 location.href를 작성할 수 있다.
						문자열로 표시해야 하기 때문에 ${}도 같이 사용하려면 "|" 기호를 사용해야 한다.
					-->
					<button type="button" th:onclick="|location.href='@{/board/insert_form(page=${param.page})}'|">📝 글 작성</button>
				</td>
			</tr>
		</table>
	</div>

	<!-- jquery 사용 -->
	<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
	<script th:inline="javascript">
		
		// controller에서 포워딩한 데이터 가져오기
		let pageMenu = [[${pageMenu}]];
		
		// 로그인 액션 추가
		let $loginFormButton = $("input[name='login_form']");
		
		$loginFormButton.on("click", function() {
			window.location.href = '/board/login_form';
		})
		
		// 회원가입 액션 추가
		let $joinFormButton = $("input[name='join']");
		
		$joinFormButton.on("click", function() {
			window.location.href = '/board/join_form';
		})
		
		// 로그아웃 액션 추가
		let $logoutButton = $("input[name='logout']");
		
		$logoutButton.on("click", function() {
			window.location.href = '/board/logout';
		})
		
		// id=pageMenu인 div에 넣기
		$("#pageMenu").html(pageMenu);
		
	</script>
</body>
</html>
<!-- board_view.html -->
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" th:href="@{/board.css}">
</head>
<body>
	<div class="container">
		<table th:object="${dto}">	
			<caption>::게시글 상세보기</caption>
			<tr>
				<th>제목</th>
				<td th:text="*{subject}"></td>
			</tr>
			<tr>
				<th>작성자</th>
				<td th:text="*{name}"></td>
			</tr>
			<tr>
				<th>작성일</th>
				<td th:text="*{regdate}"></td>
			</tr>
			<tr>
				<th>ip</th>
				<td th:text="*{ip}"></td>
			</tr>
			<tr>
				<th>내용</th>
				<td width="500px" height="200px" th:text="*{content}"></td>
			</tr>
			<tr>
				<th>비밀번호</th>
				<td><input type="password" id="c_pwd"></td>
			</tr>
			<tr>
				<td colspan="2" align="right">
					<!-- button에 onclick을 property로 바로 적용 시 
						thymeleaf 표현식을 사용해서 location.href를 작성할 수 있다.
						문자열로 표시해야 하기 때문에 ${}도 같이 사용하려면 "|" 기호를 사용해야 한다.
					-->
					<button type="button" th:onclick="|location.href='@{/board/board_list(page=${param.page})}'|" th:text="뒤로가기"></button>
					
					<!-- 답글 -->
					<th:block th:if="*{depth lt 1}">
						<!-- window키+"."으로 작성한 이모티콘은 th:value에 작성시 에러가 발생했다. -->
						<button type="button" id="reply-btn">✏답글</button>
					</th:block>
					
				 	<button type="button" id="del-btn">글 삭제</button>
				</td>
			</tr>
		</table>
	</div>
	
	<!-- jquery 사용 -->
	<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
	<script th:inline="javascript">
		// inline 작성 시 세미콜론을 쓰면 '' 내용으로 인식함
		let idx=  /*[[${dto.idx}]]*/''
		let pwd =  /*[[${dto.pwd}]]*/''
		let page =  /*[[${param.page}]]*/''
		
		$("#del-btn").on("click", function() {
			if(!confirm("정말 삭제하시겠습니까?")) {
				return;
			}
			
			let $c_pwd = $("input[type='password']").val(); 
			
			if($c_pwd != pwd) { // 비밀번호가 일치할 때만 삭제 진행
				alert("비밀번호가 일치하지 않습니다");
				return;
			}

			// ajax
			$.ajax({
				url : "/board/del",
				type : "POST",
				data : JSON.stringify({'idx':idx}), // POST로 요청 시 data는 JSON.stringigy()로 전송해야 한다.
				dataType : "json",
				contentType : "application/json; charset=utf-8",
				success: function(data) {
					if(data["param"]) { // @ResponseBody의 내용으로 결과 확인
						alert("성공적으로 삭제했습니다");
						window.location.href="/board/board_list?page="+page;
					}
				}
			});
		});
		
		$("#reply-btn").on("click", function() {
			location.href = "/board/reply_form?idx="+idx+"&page="+page;
		});

	</script>
</body>
</html>
<!-- insert_form.html -->
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
	<link rel="stylesheet" th:href="@{/board.css}">
</head>
<body>
	<div class="container">
		<!-- form을 submit하는 경우라면 form에 action, method 지정 시 자동으로 데이터를 담아 전송해준다. -->
		<form th:action="@{/board/insert}" name="f" th:object="${dto}" method="post">
			<input type="hidden" name="page" th:value="${param.page}">
			<table>
				<caption>:: 새 글 작성 ::</caption>
				<tr>
					<th>제목</th>
					<td><input th:field="*{subject}"></td>
				</tr>
				<tr>
					<th>작성자</th>
					<td><input th:field="*{name}"></td>
				</tr>
				<tr>
					<th>내용</th>
					<td><textarea th:field="*{content}" rows="10" cols="50" style="resize:none;"></textarea></td>
				</tr>
				<tr>
					<th>비밀번호</th>
					<td><input th:field="*{pwd}" type="password"></td>
				</tr>
				<tr>
					<td colspan="2" align="right">
						<!-- input 태그의 type="submit"을 사용해도 상관없다. -->
						<button type="button" id="send_check">📝 글 작성</button>
						<button type="button" th:onclick="|location.href='@{/board/board_list(page=${param.page})}'|">취소</button>
					</td>
				</tr>
			</table>
		</form>
	</div>
	
	<!-- jquery 사용 -->
	<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
	<script th:inline="javascript">
		
		const $sendBtn = $("#send_check");
	
		$sendBtn.on("click", function() {
			$("form[name='f']").submit(); // form 제출
		});
		
	</script>
</body>
</html>
<!-- reply_form.html -->
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
	<link rel="stylesheet" th:href="@{/board.css}">
</head>
<body>
	<div class="container">
		<!-- 게시글 작성과 동일 -->
		<form th:action="@{/board/reply/{idx}(idx=${param.idx})}" name="f" th:object="${dto}" method="post">
			<input type="hidden" name="page" th:value="${param.page}">
			<table>
				<caption>:: 답글 작성 ::</caption>
				<tr>
					<th>제목</th>
					<td><input th:field="*{subject}"></td>
				</tr>
				<tr>
					<th>작성자</th>
					<td><input th:field="*{name}"></td>
				</tr>
				<tr>
					<th>내용</th>
					<td><textarea th:field="*{content}" rows="10" cols="50" style="resize:none;"></textarea></td>
				</tr>
				<tr>
					<th>비밀번호</th>
					<td><input th:field="*{pwd}" type="password"></td>
				</tr>
				<tr>
					<td colspan="2" align="right">
						<button type="button" id="send_check">📝 글 작성</button>
						<button type="button" th:onclick="|location.href='@{/board/view(idx=${param.idx}, page=${param.page})}'|">취소</button>
					</td>
				</tr>
			</table>
		</form>
	</div>
	
	<!-- jquery 사용 -->
	<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
	<script th:inline="javascript">
		
		const $sendBtn = $("#send_check");
		
		$sendBtn.on("click", function() {
			$("form[name='f']").submit();
		});
		
	</script>
</body>
</html>
<!-- login_form.html -->
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
	<link rel="stylesheet" th:href="@{/board.css}">
</head>
<body>
	<div class="container login">
		<!-- method를 get으로 할 때와 post 할 때 ajax로 보내는 data 처리가 약간 다르다 -->
		<form th:action="@{/board/login}" th:object="${dto}" method="GET">
			<table>
				<caption>::로그인::</caption>
				<tr>
					<th>아이디</th>
					<td><input th:field="*{id}"></td>
				</tr>
				<tr>
					<th>비밀번호</th>
					<td><input th:field="*{pwd}" type="password"></td>
				</tr>
				<tr>
					<td colspan="2" align="center">
						<button type="button" name="login">로그인</button>
						<button type="button" th:onclick="|location.href='@{/board/board_list}'|">뒤로가기</button>
					</td>
				</tr>
			</table>
		</form>
	</div>

	<!-- jquery 사용 -->
	<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
	<!-- js파일 참고 -->
	<script th:src="@{/login.js}"></script>
</body>
</html>
// name이 login인 버튼
const $loginBtn = $("button[name='login']");

$loginBtn.on("click", function() { // click 액션 추가

	// input 태그의 경우 onclick action 외부에서 미리 요소를 가져오면
	// 가져올 때의 값을 그대로 인식하므로
	// 값이 변경될 때 확인하는 식으로 동작하려면 function 내에서 요소를 가져와서 변수에 저장해야 함
	let $id = $("input[name='id']").val();
	let $pwd = $("input[name='pwd']").val();

	// 유효성 검사
	if ($id == '') {
		alert("아이디를 입력해주세요");
		return;
	}
	
	if ($pwd == '') {
		alert("비밀번호를 입력해주세요");
		return;
	}

	// ajax
	$.ajax({
		url : "/board/login",
		type : "GET",
		data : {'id':$id, 'pwd':$pwd},
		// post 형식이라면 JSON.stringify({'id':$id, 'pwd':$pwd})로 전송
		// get 형식이라면 객체 형식으로 바로 전달 가능
		dataType : "json",
		contentType : "application/json; charset=utf-8",
		success : function(data) {
			if(data["result"] == "no") {
				alert("아이디나 비밀번호를 다시 확인해주세요");
				return;
			} else {
				alert("로그인에 성공했습니다");
				window.location.href = "/board/board_list";
			}
		}
	});
});
<!-- join_form -->
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
	<link rel="stylesheet" th:href="@{/board.css}">
</head>
<body>
	<div class="container">
		<form th:action="@{/board/join}" name="f" th:object="${dto}" method="POST">
			<table>
				<caption>::회원가입::</caption>
				<tr>
					<th>아이디</th>
					<td>
						<input th:field="*{id}">
						<input type="button" id="id-check" value="중복체크">
					</td>
				</tr>
				<tr>
					<th>이름</th>
					<td><input th:field="*{name}"></td>
				</tr>
				<tr>
					<th>이메일</th>
					<td><input th:field="*{email}"></td>
				</tr>
				<tr>
					<th>비밀번호</th>
					<td><input th:field="*{pwd}" type="password"></td>
				</tr>
				<tr>
					<td colspan="2" align="center">
						<button type="button" name="join">회원가입</button>
						<button type="button" th:onclick="|location.href='@{/board/board_list}'|">취소</button>
					</td>
				</tr>
			</table>
		</form>
	</div>

	<!-- jquery 사용 -->
	<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
	<script th:src="@{/join.js}"></script>
</body>
</html>
const $joinBtn = $("button[name='join']");
const $checkBtn = $("input#id-check");
let $form = $("form[name='f']");
let idCheck = false;

// id 입력이 바뀌면 중복체크 안한 것으로 변경
$("input[name='id']").on("change", function() {
	idCheck = false;
});

// 중복체크
$checkBtn.on("click", function() {
	let $id = $("input[name='id']").val();
	
	if($id == '') {
		alert("아이디를 입력해주세요");
		return;
	}
	
	$.ajax({
		url : "/board/id_check",
		type : "GET",
		data : {'id' : $id},
		dataType : 'json',
		contetnType : "application/json; charset=utf-8",
		success : function(data) {
			if(data['result'] == 'no') {
				alert("중복된 아이디입니다.");
				return;
			} else {
				alert("사용 가능한 아이디입니다.");
				idCheck = true;
			}
		}
	})
});


$joinBtn.on("click", function() {
	if(!idCheck) {
		alert("아이디 중복 체크를 해주세요");
		return;
	}
	
	if($("input[name='name']").val() == '') {
		alert("이름를 입력하세요");
		return;
	}
	
	if($("input[name='email']").val() == '') {
		alert("이메일을 입력하세요");
		return;
	}
	
	if($("input[name='pwd']").val() == '') {
		alert("비밀번호를 입력하세요");
		return;
	}	
	
	$form.submit();
})
  1. 간단한 CSS도 만들어서 적용한다.
    • HTML에 CSS를 적용할 때 CSS 파일은 src/main/resources/static에 만들고, <link rel="stylesheet" th:href="@{/filename.css}">으로, thymeleaf 표현식으로 경로를 줘야 한다.
@charset "UTF-8";

*{margin:0; padding:0; box-sizing: border-box;}
ul, ol, li{list-style: none;}
a{text-decoration: none;}

.container{
	margin-top:30px;
	width:100%;
	display:flex; justify-content:center;
}

table{
	width:600px;
	border:1px solid black; border-collapse:collapse;
}
tr, th, td{border:1px solid black;}

button{width:80px; height:30px;}

.container.login table{ width: 250px; height: 120px;}
.container.login table input{ width: 100%; }

spring boot board 1.png

spring boot board 2.png
spring boot board 3.png

spring boot board 4.png

spring boot board 5.png
spring boot board 6.png

spring boot board 7.png

spring boot board 8.png
spring boot board 9.png

spring boot board 10.png

spring boot board 11.png

spring boot board 12.png

spring boot board 13.png
spring boot board 14.png