Springboot로 디자인이 하나도 없고 매우 간단한 게시판을 구현하는 시리즈입니다.

최종 소스는 깃헙에서 확인하실 수 있습니다.


이 글에서는 게시글 목록/상세화면 조회, 수정, 삭제하는 방법에 대해 알아보도록 하겠습니다.




지난 글에서 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입니다.

다음으로 게시글 리스트 페이지입니다.
이전 글에서 일부 구현했었는데요, <table>이 수정된 내용입니다.
src/main/resources/templates/board/list.html

<!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}">
    • thymeleaf에서 반복문을 사용하는 부분입니다.
    • 컨트롤러가 넘겨주는 변수는 ${ } 으로 받을 수 있습니다.
      • 즉, boardList는 컨트롤러가 넘겨주는 변수이며, 원소는 board 변수로 사용하여 각 속성을 사용할 수 있습니다.
  • <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로 변환하는 작업이 있습니다.


4) 테스트
이전 글에서 대부분 언급했던 내용들이라, 딱히 추가설명할 부분이 없는 것 같네요.
프로젝트를 실행해보시고, 목록이 잘 출력되는지 확인해보세요.





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와 같은 메서드를 사용합니다.


4) 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() 메서드를 같이 사용하고 있습니다.



5) 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 인터페이스에서 정의되어 있습니다.



6) 테스트

프로젝트를 실행해보시고, 상세조회, 수정, 삭제가 잘 되는지 확인해봅니다.





이상으로 게시판 CRUD를 구현해보았습니다.

다루고 싶은 내용들은 많이 있는데, 내용이 길어지면 복잡성만 늘어나서 최대한 간단하게 해보려 했습니다.


시스템 또는 UI가 복잡해지면, 더 많은 문법과 레퍼런스들을 찾아봐야 할 것입니다.

그래도 스프링부트에서 기본적인 CRUD를 구현해봤다는 것에 의의를 두시길 바라며, 진입장벽을 낮추는데 도움이 되셨기를 바랍니다!

잘못된 점 또는 더 나은 방법에 대해서는 댓글로 공유해주시면 감사하겠습니다.


댓글 펼치기 👇
  1. 파오리 2019.11.25 23:04

    정말 많은 도움이 되었습니다. 감사합니다!!

  2. 신입 2020.01.26 13:24

    HiddenHttpMethodFilter 안먹히시는 분들 (어제 2시간정도 삽질했네요 ㅠ.ㅠ)
    @SpringBootApplication 클래스에 HiddenHttpMethodFilter @bean으로 등록해주셔야 합니다.
    감사합니다.

    • Favicon of https://victorydntmd.tistory.com victolee 우르르응 2020.01.27 12:36 신고

      안녕하세요

      저는 이슈가 없어서, 원인을 확인해보았는데요.

      Gradle이 추가한 External Libraries를 보시면,
      org.springframework.web.filter.HiddenHttpMethodFilter/
      spring-boot-autoconfigure-2.1.8.RELEASE-sources.jar!/org/springframework/boot//autoconfigure/web/servlet/WebMvcAutoConfiguration.java

      해당 파일에 hiddenHttpMethodFilter() 메서드가 있는데, 이 메서드가 OrderedHiddenHttpMethodFilter 타입을 반환하거든요.
      근데 이 녀석이 org.springframework.web.filter.HiddenHttpMethodFilter 를 상속받고 있습니다.

      즉, @SpringBootApplication 어노테이션을 달아주면 자동설정이 되므로, 패키지명에서 알 수 있듯이 HiddenHttpMethodFilter가 자동으로 추가될 것으로 보이는데...
      설정에 차이가 있나 보네요 ㅎㅎ;
      ( 참고 : https://howtodoinjava.com/spring-boot2/springbootapplication-auto-configuration/ )

      이슈 공유해주셔서 감사합니다~

    • 택길이지 2020.07.16 14:32

      HiddenHttpMethodFilter 안먹는 분들

      BoardApplication.java 파일에서

      public static void main(String[] args) {
      SpringApplication.run(BoardApplication.class, args);
      }

      아래에

      /**
      * HiddenHttpMethodFilter
      */
      @Bean
      public HiddenHttpMethodFilter hiddenHttpMethodFilter(){
      HiddenHttpMethodFilter filter = new HiddenHttpMethodFilter();
      return filter;
      }

      위 내용을 붙여 넣으시면 됩니다

  3. 돌튀김 2020.02.21 07:19

    잘따라하고있었는데... 이 오류는 어떤걸 얘기하는건지 몰라 애먹고 있습니다.

    ===== 오류 페이지=====

    Whitelabel Error Page
    This application has no configured error view, so you are seeing this as a fallback.

    Thu Feb 20 16:12:49 CST 2020
    [baa9844c] There was an unexpected error (type=Method Not Allowed, status=405).
    Request method 'POST' not supported
    org.springframework.web.server.MethodNotAllowedException: 405 METHOD_NOT_ALLOWED "Request method 'POST' not supported"
    at org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping.handleNoMatch(RequestMappingInfoHandlerMapping.java:164)
    Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
    Error has been observed at the following site(s):
    |_ checkpoint ⇢ HTTP POST "/post/3" [ExceptionHandlingWebHandler]


    이 오류는 delete이나 edit을 할때 생기는 오류입니다....
    코드상에 문제 같진않고, 디비 업뎃에서 생기는 오류같은데... 혹시 아시면 도움이 많이 될것같아요. 감사합니다.

    • Favicon of https://victorydntmd.tistory.com victolee 우르르응 2020.02.21 12:39 신고

      음.. 컨트롤러에서 RequestMapping이 안되는 것 같은데, 혹시 webflux 의존성이 추가되어 있나요??
      추가되어 있다면 webflux는 제거 후, spring-boot-starter-web을 추가하셔서 해보시길 바랍니다.

  4. 돌튀김 2020.02.27 03:24

    에고.. 찾았어요... 제 프로젝트에선 mapping 설정이 빅토리님께서 하신것처럼 하면 오류가 생기더라구요...
    말씀하신대로 mapping이 잘 못잡힌듯 해서, GetMapping이랑 DeleteMapping 둘 모두를 PostMapping 하니깐 됬어요...
    아직 어노테이션이 뭔지를 몰라서 왜 그렇게 해야하는지는 모르지만...;; 일단 버그는 골케해서 고쳤습니다.
    감사합니다. :)

    • Favicon of https://victorydntmd.tistory.com victolee 우르르응 2020.02.27 08:02 신고

      update, delete할 때 form의 method가 post이므로 말씀 주신 방법으로 해결은 됐겠지만, 근본적인 해결은 아닌듯 싶습니다.

      PutMapping, DeleteMapping이 매핑되려면
      hiddenHttpMethodFilter가 필요한데, 이게 spring-boot-starter-web 의존성에 포함되어 있거든요..

      해당 내용이 중요하다면 중요할 수 있는데, 일단은 그냥 넘어가셔도 될것 같네요 ㅎㅎ;

  5. 초보 2020.03.17 13:36

    안녕하세요 다른 것들은 다 작동하는데 Delete만 아예 작동을 하지 않습니다.
    어떻게 해야 할까요..?

  6. Favicon of https://mrxx.tistory.com h0ch1 2020.04.07 16:56 신고

    저도 위에분처럼 수정할때 오류가났었는데
    컨트롤러에 GetMapping위에
    @RequestMapping(value = "/post/edit/{no}", method = {RequestMethod.POST})
    PutMapping위에
    @RequestMapping(value = "/post/edit/{no}", method = {RequestMethod.GET})
    각각추가해주었더니 잘되더라구요

    근데 왜 이런오류가 생기는지, 왜해결되었는디 잘모르겠네요 ㅠㅠ

  7. 푸린이 2020.04.10 20:01

    저의 경우에는 application.properties 에 spring.mvc.hiddenmethod.filter.enabled=true 를 추가하여 해결 하였습니다.

    • Favicon of https://victorydntmd.tistory.com victolee 우르르응 2020.04.19 14:31 신고

      @SpringBootApplication 어노테이션을 달아주면 HiddenHttpMethodFilter가 자동설정 되는줄 알았는데...
      설정파일에서 명시를 해줘야 하나보네요ㅎㅎ;

      저는 추가를 안해줘도 됐는데 뭐가 문제인지... ㅋㅋㅋ
      아무튼 해결 방법 공유해주셔서 정말 감사합니다!!

    • dd 2020.07.08 15:02

      저도 해당방법으로 해결했습니다~

      아마 스프링부트의 버전때문아닌가 싶네요 2.3에서 작업했습니다

  8. ㅇㅇ 2020.04.30 21:54

    정말 감사해요.. 뒤늦게 시작했는데 최고로 도움 많이됐어요 ^^

  9. Favicon of https://xiyo.tistory.com 엽기토끼이요 2020.05.04 00:12 신고

    BoardDto boardDTO = BoardDto.builder()

    문제는 아닌데 그냥.... DTO를 대문자로 쓰셨네요.

  10. goodjob 2020.10.21 17:47

    스프링부트 배우는데 도움이 많이 됐어요!!!!
    그런데
    put delete 매핑이 spring.mvc.hiddenmethod.filter.enabled=true 이걸 설정해줘야 되나요??!?
    너무 궁금해요 ...

    • Favicon of https://victorydntmd.tistory.com victolee 우르르응 2020.11.14 14:25 신고

      어떤 분은spring.mvc.hiddenmethod.filter.enabled=true 설정을 추가해야 하고, 저같은 경우는 필요가 없었습니다.
      개발하는 환경이 전부 다르다보니, 에러가 발생하면 필요에따라 추가해주시면 될듯합니다!

  11. 프로야근맨 2020.11.25 00:00

    좋은 예제 감사드립니다...

    따라하다가 게시물 수정 컨트롤러를 못 찾아가는 현상이 발생하여 해결한 방법 공유 드립니다!

    [update.html]
    .....
    <form th:action="@{'/post/edit/' + ${boardDto.id}}" method="post">
    <input type="hidden" name="_method" value="put"/>
    .....

    수정 버튼 클릭 시, PutMapping 컨트롤러를 찾지 못하는 경우 아래 설정을 추가하면 잘됩니다..!

    [application.yml]
    spring:
    mvc:
    hiddenmethod:
    filter:
    enabled: true

    [application.properties]
    spring.mvc.hiddenmethod.filter.enabled=true