Springboot로 디자인이 하나도 없고 매우 간단한 게시판을 구현하는 시리즈입니다.
최종 소스는 깃헙에서 확인하실 수 있습니다.
- [SpringBoot] 게시판 (1) - 준비작업
- [SpringBoot] 게시판 (2) - 게시글 추가하기
- [SpringBoot] 게시판 (3) - 게시글 조회,수정,삭제
- [SpringBoot] 게시판 (4) - 검색과 페이징
- [SpringBoot] 게시판 (5) - EC2에 배포하기
이 글에서는 게시글 목록/상세화면 조회, 수정, 삭제하는 방법에 대해 알아보도록 하겠습니다.
지난 글에서 CRUD 공통으로 사용하는 domain, dto, (Controller, Service 일부) 패키지를 구현했었습니다.
그래서 게시글 목록, 수정, 삭제 처리를 구현하기 위해서는 퍼블리싱, 컨트롤러, 서비스 부분만 구현해주면 됩니다.
1. 게시글 목록
1) 퍼블리싱
src/main/resources/static/css/board.css
table {
border-collapse: collapse;
}
table, th, td {
border: 1px solid black;
}
- 게시글 목록의 테이블을 꾸며주는 css입니다.
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Title</title>
<link rel="stylesheet" th:href="@{/css/board.css}">
</head>
<body>
<!-- HEADER -->
<div th:insert="common/header.html" id="header"></div>
<a th:href="@{/post}">글쓰기</a>
<table>
<thead>
<tr>
<th class="one wide">번호</th>
<th class="ten wide">글제목</th>
<th class="two wide">작성자</th>
<th class="three wide">작성일</th>
</tr>
</thead>
<tbody>
<!-- CONTENTS !-->
<tr th:each="board : ${boardList}">
<td>
<span th:text="${board.id}"></span>
</td>
<td>
<a th:href="@{'/post/' + ${board.id}}">
<span th:text="${board.title}"></span>
</a>
</td>
<td>
<span th:text="${board.writer}"></span>
</td>
<td>
<span th:text="${#temporals.format(board.createdDate, 'yyyy-MM-dd HH:mm')}"></span>
</td>
</tr>
</tbody>
</table>
<!-- FOOTER -->
<div th:insert="common/footer.html" id="footer"></div>
</body>
</html>
-
<link rel="stylesheet" th:href="@{/css/board.css}">
- 앞에서 작성한 board.css를 불러오는 코드입니다.
- css, js, img 같은 정적자원들을 src/main/resources/static 경로에 저장하면 스프링부트가 인식을 하게 됩니다.
-
<tr th:each="board : ${boardList}">
- boardList는 컨트롤러가 넘겨주는 변수이며, 원소는 board 변수로 사용하여 각 속성을 사용할 수 있습니다.
- thymeleaf에서 반복문을 사용하는 부분입니다.
- 컨트롤러가 넘겨주는 변수는 ${ } 으로 받을 수 있습니다.
-
<span th:text="${board.id}"></span>
- 변수 값을 escape 처리하여, 태그의 텍스트로 사용합니다.
-
<a th:href="@{'/post/' + ${board.id}}">
- 글 제목을 누르면 상세페이지로 이동하기 위해 path variable을 사용했습니다.
-
<span th:text="${#temporals.format(board.createdDate, 'yyyy-MM-dd HH:mm')}"></span>
- #temporals.format() 메서드를 사용하여 날짜를 포맷팅하는 방법입니다.
- createdDate는 LocalDateTime 타입이기 때문에 java.util.Date를 사용하는 #dates.format()를 사용하지 않습니다.
2) Controller
src/main/java/com/victolee/board/controller/BoardController.java
package com.victolee.board.controller;
import com.victolee.board.dto.BoardDto;
import com.victolee.board.service.BoardService;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.List;
@Controller
@AllArgsConstructor
public class BoardController {
private BoardService boardService;
/* 게시글 목록 */
@GetMapping("/")
public String list(Model model) {
List<BoardDto> boardList = boardService.getBoardlist();
model.addAttribute("boardList", boardList);
return "board/list.html";
}
...
}
-
public String list(Model model) { }
- Model 객체를 통해 View에 데이터를 전달합니다.
3) Service
src/main/java/com/victolee/board/service/BoardService.java
package com.victolee.board.service;
import com.victolee.board.domain.entity.BoardEntity;
import com.victolee.board.domain.repository.BoardRepository;
import com.victolee.board.dto.BoardDto;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.util.ArrayList;
import java.util.List;
@AllArgsConstructor
@Service
public class BoardService {
private BoardRepository boardRepository;
@Transactional
public List<BoardDto> getBoardlist() {
List<BoardEntity> boardEntities = boardRepository.findAll();
List<BoardDto> boardDtoList = new ArrayList<>();
for ( BoardEntity boardEntity : boardEntities) {
BoardDto boardDTO = BoardDto.builder()
.id(boardEntity.getId())
.title(boardEntity.getTitle())
.content(boardEntity.getContent())
.writer(boardEntity.getWriter())
.createdDate(boardEntity.getCreatedDate())
.build();
boardDtoList.add(boardDTO);
}
return boardDtoList;
}
...
}
- Controller와 Service간에 데이터 전달은 dto 객체로 하기 위해, Repository에서 가져온 Entity를 반복문을 통해 dto로 변환하는 작업이 있습니다.
2. 게시글 상세조회 / 수정 / 삭제
다음으로 게시글 제목을 클릭하면 이동되는 상세페이지와 수정, 삭제 한꺼번에 구현 해보도록 하겠습니다.
1) 게시글 상세조회 페이지 - js
src/main/resources/static/js/board.js
console.log(boardDto);
- boardDto를 콘솔로 찍어보는 스크립트입니다.
- 컨트롤러에서 넘겨준 Java 변수를 JS에서 어떻게 사용할 수 있는지 알아보기 위한 예제입니다.
2) 게시글 상세조회 페이지 + 수정/삭제 버튼
src/main/resources/templates/board/detail.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h2 th:text="${boardDto.title}"></h2>
<p th:inline="text">작성일 : [[${#temporals.format(boardDto.createdDate, 'yyyy-MM-dd HH:mm')}]]</p>
<p th:text="${boardDto.content}"></p>
<!-- 수정/삭제 -->
<div>
<a th:href="@{'/post/edit/' + ${boardDto.id}}">
<button>수정</button>
</a>
<form id="delete-form" th:action="@{'/post/' + ${boardDto.id}}" method="post">
<input type="hidden" name="_method" value="delete"/>
<button id="delete-btn">삭제</button>
</form>
</div>
<!-- 변수 셋팅 -->
<script th:inline="javascript">
/*<![CDATA[*/
var boardDto = /*[[${boardDto}]]*/ "";
/*]]>*/
</script>
<!-- script -->
<script th:inline="javascript" th:src="@{/js/board.js}"></script>
</body>
</html>
-
<p th:inline="text">작성일 : [[${#temporals.format(boardDto.createdDate, 'yyyy-MM-dd HH:mm')}]]</p>
- th:text를 사용하면, 태그 사이에 작성한 내용은 사라지고 th:text 값으로 덮어씌어집니다.
- 이를 해결하기 위해 th:inline 를 사용하며, 변수는 [[ ${ } ]] 으로 표기합니다.
-
<input type="hidden" name="_method" value="delete"/>
- RESTful API 작성을 위해 hiddenHttpMethodFilter를 이용한 것입니다. ( 참고 )
- 그러면 form 태그의 method는 post이지만, 실제로는 컨트롤러에서 delete로 매핑이됩니다.
-
/*<![CDATA[*/ ~~~ /*]]>*/
- JS에서 Java 변수를 사용하기 위한 방식입니다. ( 참고 )
- 위에서 boardDto를 콘솔로 출력하는 스크립트를 작성하였으므로 게시글 상세 페이지에 접근 시, 개발자도구 콘솔창에서 확인할 수 있습니다.
3) 게시글 수정 페이지
src/main/resources/templates/board/update.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form th:action="@{'/post/edit/' + ${boardDto.id}}" method="post">
<input type="hidden" name="_method" value="put"/>
<input type="hidden" name="id" th:value="${boardDto.id}"/>
제목 : <input type="text" name="title" th:value="${boardDto.title}"> <br>
작성자 : <input type="text" name="writer" th:value="${boardDto.writer}"> <br>
<textarea name="content" th:text="${boardDto.content}"></textarea><br>
<input type="submit" value="수정">
</form>
</body>
</html>
-
<input type="hidden" name="_method" value="put"/>
- 마찬가지로 Restful API 작성을 위한 것으로, 컨트롤러에서 put 메서드로 매핑이됩니다.
-
<input type="hidden" name="id" th:value="${boardDto.id}"/>
- hidden 타입을 게시글 id 값을 넘겨준 이유는 JPA(BoardRepository.save())에서 insert와 update를 같은 메서드로 사용하기 때문입니다.
- 즉, 같은 메서드를 호출하는데 id 값이 없다면 insert가 되는 것이고, id 값이 이미 존재한다면 update가 되도록 동작됩니다. 따라서 Service에서 update를 위한 메서드는 없고, insert와 같은 메서드를 사용합니다.
스프링부트 2.2 부터는 put, delete 사용을 위해 HiddenMethodFilter 설정이 별도로 필요합니다.
spring:
...
mvc:
hiddenmethod:
filter:
enabled: true
...
...
5) Controller
src/main/java/com/victolee/board/controller/BoardController.java
package com.victolee.board.controller;
import com.victolee.board.dto.BoardDto;
import com.victolee.board.service.BoardService;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Controller
@AllArgsConstructor
public class BoardController {
private BoardService boardService;
...
@GetMapping("/post/{no}")
public String detail(@PathVariable("no") Long no, Model model) {
BoardDto boardDTO = boardService.getPost(no);
model.addAttribute("boardDto", boardDTO);
return "board/detail.html";
}
@GetMapping("/post/edit/{no}")
public String edit(@PathVariable("no") Long no, Model model) {
BoardDto boardDTO = boardService.getPost(no);
model.addAttribute("boardDto", boardDTO);
return "board/update.html";
}
@PutMapping("/post/edit/{no}")
public String update(BoardDto boardDTO) {
boardService.savePost(boardDTO);
return "redirect:/";
}
@DeleteMapping("/post/{no}")
public String delete(@PathVariable("no") Long no) {
boardService.deletePost(no);
return "redirect:/";
}
...
}
- 위에서부터 차례대로 "게시글 상세조회 페이지", "게시글 수정 페이지", "게시글 수정", "게시글 삭제" 입니다.
-
@GetMapping("/post/{no}") @PathVariable("no") Long no
- 유동적으로 변하는 Path Variable을 처리하는 방법입니다.
- URL 매핑하는 부분에서 {변수} 처리를 해주면, 메서드 파라미터로 @PathVariable("변수") 이렇게 받을 수 있습니다.
- update()
- 게시글 추가에서 사용하는 boardService.savePost() 메서드를 같이 사용하고 있습니다.
6) Service
src/main/java/com/victolee/board/service/BoardService.java
package com.victolee.board.service;
import com.victolee.board.domain.entity.BoardEntity;
import com.victolee.board.domain.repository.BoardRepository;
import com.victolee.board.dto.BoardDto;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@AllArgsConstructor
@Service
public class BoardService {
private BoardRepository boardRepository;
...
@Transactional
public BoardDto getPost(Long id) {
Optional<BoardEntity> boardEntityWrapper = boardRepository.findById(id);
BoardEntity boardEntity = boardEntityWrapper.get();
BoardDto boardDTO = BoardDto.builder()
.id(boardEntity.getId())
.title(boardEntity.getTitle())
.content(boardEntity.getContent())
.writer(boardEntity.getWriter())
.createdDate(boardEntity.getCreatedDate())
.build();
return boardDTO;
}
@Transactional
public Long savePost(BoardDto boardDto) {
return boardRepository.save(boardDto.toEntity()).getId();
}
@Transactional
public void deletePost(Long id) {
boardRepository.deleteById(id);
}
}
- findById()
- PK 값을 where 조건으로 하여, 데이터를 가져오기 위한 메서드이며, JpaRepository 인터페이스에서 정의되어 있습니다.
- 반환 값은 Optional 타입인데, 엔티티를 쏙 빼오려면 boardEntityWrapper.get(); 이렇게 get() 메서드를 사용해서 가져옵니다.
- deleteById()
- PK 값을 where 조건으로 하여, 데이터를 삭제하기 위한 메서드이며, JpaRepository 인터페이스에서 정의되어 있습니다.
7) 테스트
프로젝트를 실행해보시고, 상세조회, 수정, 삭제가 잘 되는지 확인해봅니다.
이상으로 게시판 CRUD를 구현해보았습니다.
다루고 싶은 내용들은 많이 있는데, 내용이 길어지면 복잡성만 늘어나서 최대한 간단하게 해보려 했습니다.
시스템 또는 UI가 복잡해지면, 더 많은 문법과 레퍼런스들을 찾아봐야 할 것입니다.
그래도 스프링부트에서 기본적인 CRUD를 구현해봤다는 것에 의의를 두시길 바라며, 진입장벽을 낮추는데 도움이 되셨기를 바랍니다!
잘못된 점 또는 더 나은 방법에 대해서는 댓글로 공유해주시면 감사하겠습니다.