본문 바로가기
공부 기록

[스프링 부트로 게시판 만들기] 09. 게시글 CRUD

by 매트(Mat) 2021. 7. 22.



Board 테이블 만들기

  • 게시판 정보를 담을 Board 테이블을 만들도록 하겠습니다.
  • 폴더 구조
캡처


Board 클래스

package com.azurealstn.blogproject.domain.board;

import com.azurealstn.blogproject.domain.user.User;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;


@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Entity
public class Board extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String title;

    @Lob
    private String content;

    private int count; //조회수

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "userId")
    private User user;
}
  • @Lob : 대용량 데이터를 저장할 때 사용합니다. 내용은 summernote라는 라이브러리를 이용할 것이기 때문에 @Lob으로 설정합니다.
  • @ManyToOne(fetch = FetchType.EAGER) : 게시글을 작성할 때 누가 작성했는지 알아야 하기 때문에 User 테이블과 조인해야합니다. 이 때 Java코드로 객체를 선언하게 되면 ORM문법으로 알아서 조인을 해줍니다. 즉, id값이 서로 있으니까 id값으로 foreign키를 생성하는 거죠.
  • 그리고 이 때 연관관계를 맺어줘야 하는데 게시판과 유저의 관계를 한 명의 유저가 여러 게시글을 작성할 수 있으므로 @ManyToOne을 사용합니다. @ManyToOne의 FetchType의 디폴트값이 EAGER 입니다. (EAGER 전략은 조인할 때 관련된 데이터들을 모두 가져오는 것이죠.)
  • @JoinColumn(name = "userId") : foreign키의 컬럼명 설정입니다.



JPA Auditing으로 생성시간/수정시간 자동화

  • 사실 테이블을 만들때 필요한 컬럼은 생성시간과 수정입니다. 언제 데이터가 들어갔는지 이런 시간이 중요합니다.
  • Entity 클래스마다 따로 날짜 필드를 생성하지 않아도 자동으로 생성하도록 하겠습니다.
  • domain 패키지에 BaseTimeEntity 추상클래스를 생성합니다.



BaseTimeEntity 추상클래스

package com.azurealstn.blogproject.domain;

import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    @CreatedDate //생성할 때 자동저장
    private LocalDateTime createdDate;

    @LastModifiedDate //수정할 때 자동저장
    private LocalDateTime modifiedDate;
}
  • BaseTimeEntity 클래스는 모든 Entity의 상위클래스가 되어 Entity들의 날짜 필드를 자동으로 관리합니다.
  • @MappedSuperclass : JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 날짜 필드도 칼럼으로 인식
  • 다음으로 Config 파일을 하나 생성합니다.
캡처


JpaConfig 클래스

package com.azurealstn.blogproject.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing
public class JpaConfig {
}
  • @EnableJpaAuditing : JPA Auditing 활성화
  • Board, User 클래스에 BaseTimeEntity를 상속받으면 끝납니다.
@Entity
public class User extends BaseTimeEntity { }



글쓰기 구현

  • 이제 글쓰는 기능을 구현하도록 하겠습니다.

BoardController 클래스

package com.azurealstn.blogproject.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class BoardController {

    @GetMapping("/board/save")
    public String save() {
        return "layout/board/board-save";
    }
}



  • 다음으로 board.js, board-save.html 을 만들어줍시다.
캡처

board-save.html

<!doctype html>
<html lang="en" class="h-100" xmlns:th="http://www.thymeleaf.org">

<head th:replace="layout/header :: head ('글작성')"></head>

<body class="text-center d-flex flex-column h-100">
<header th:replace="layout/header :: header"></header>

<main class="form-signin" style="max-width: 100%;">
    <div class="container border rounded flex-md-row mb-4 shadow-sm h-md-250">
        <form>
            <h1 class="h3 m-3 fw-normal">글쓰기</h1>
            <div class="form-floating m-3">
                <input type="text" class="form-control" id="title" placeholder="제목을 입력하세요." required>
                <label for="title">제목</label>
            </div>
            <div class="form-floating m-3">
                <textarea class="form-control" rows="5" id="content" style="height: 450px;"></textarea>
                <label for="content">내용</label>
            </div>

        </form>
        <button class="w-100 btn btn-lg btn-success" id="btn-save" style="max-width: 250px;">작성완료</button>

    </div>
</main>

<footer th:replace="layout/footer :: footer"></footer>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
        crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"
        integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
<script th:src="@{/js/board.js}"></script>
</body>

</html>






board.js

'use strict';

let index = {
    init: function () {
        $("#btn-save").on("click", () => {
            this.save();
        });
    },

    save: function () {
        let data = {
            title: $("#title").val(),
            content: $("#content").val(),
        }

        $.ajax({
            type: "POST",
            url: "/api/v1/board",
            data: JSON.stringify(data),
            contentType: "application/json; charset=utf-8",
            dataType: "json"
        }).done(function (res) {
            alert("글작성이 완료되었습니다.");
            location.href = "/";
        }).fail(function (err) {
            alert(JSON.stringify(err));
        });
    }
}
index.init();
  • data에는 title과 content를 받습니다.



  • 그리고 API를 만듭니다.
  • 클래스는 BoardApiController, BoardService, BoardRepository, 데이터를 보낼 BoardSaveReqeustDto를 생성합니다.

BoardApiController 클래스

package com.azurealstn.blogproject.controller.api;

import com.azurealstn.blogproject.config.auth.PrincipalDetail;
import com.azurealstn.blogproject.dto.board.BoardSaveRequestDto;
import com.azurealstn.blogproject.service.BoardService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
public class BoardApiController {

    private final BoardService boardService;

    /**
     * 글작성 API
     */
    @PostMapping("/api/v1/board")
    public Long save(@RequestBody BoardSaveRequestDto boardSaveRequestDto, @AuthenticationPrincipal PrincipalDetail principalDetail) {
        return boardService.save(boardSaveRequestDto, principalDetail.getUser());
    }
}
  • @PostMapping이므로 @RequestBody를 꼭붙여주어야 합니다.
  • 어떤 사용자가 게시글을 작성하는지 알기 위해 @AuthenticationPrincipal 정보를 파라미터로 받습니다.



BoardSaveReqeustDto 클래스

package com.azurealstn.blogproject.dto.board;

import com.azurealstn.blogproject.domain.board.Board;
import com.azurealstn.blogproject.domain.user.User;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class BoardSaveRequestDto {

    private String title;
    private String content;
    private int count;
    private User user;

    public Board toEntity() {
        return Board.builder()
                .title(title)
                .content(content)
                .count(0)
                .user(user)
                .build();
    }

    public void setUser(User user) {
        this.user = user;
    }
}



BoardRepository 클래스

package com.azurealstn.blogproject.domain.board;

import org.springframework.data.jpa.repository.JpaRepository;

public interface BoardRepository extends JpaRepository<Board, Long> {
}



BoardService 클래스

package com.azurealstn.blogproject.service;

import com.azurealstn.blogproject.domain.board.BoardRepository;
import com.azurealstn.blogproject.domain.user.User;
import com.azurealstn.blogproject.dto.board.BoardSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
public class BoardService {

    private final BoardRepository boardRepository;

    @Transactional
    public Long save(BoardSaveRequestDto boardSaveRequestDto, User user) {
        boardSaveRequestDto.setUser(user);
        return boardRepository.save(boardSaveRequestDto.toEntity()).getId();
    }
}



글 목록

  • 이제 게시글 작성했던 걸 리스트로 가져오겠습니다.

indexController 클래스

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final BoardService boardService;

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("boards", boardService.findAll());
        return "index";
    }
}
  • View에 뿌려줄 모델을 파라미터로 받아서 키값을 boards라고 합시다.



BoardService 클래스

@RequiredArgsConstructor
@Service
public class BoardService {

    private final BoardRepository boardRepository;

    /**
     * 글목록 로직
     */
    public List<Board> findAll() {
        return boardRepository.findAll();
    }
}
  • JPA의 findAll() 메소드를 사용하면 테이블의 raw 데이터를 모두 조회해서 가져옵니다.



index.html 클래스

<!doctype html>
<html lang="en" class="h-100" xmlns:th="http://www.thymeleaf.org">

<head th:replace="layout/header :: head ('블로그')"></head>

<body class="d-flex flex-column h-100">
<header th:replace="layout/header :: header"></header>

<!-- Begin page content -->
<main class="flex-shrink-0">
    <div class="container">
        <div class="p-2"></div>
        <div th:each="board : ${boards}" class="row g-0 border rounded overflow-hidden flex-md-row mb-4 shadow-sm h-md-250 position-relative">
            <div class="col p-4 d-flex flex-column position-static">
                <a href="#" class="a-title">
                    <h3 class="mb-0 title" style="padding-bottom: 10px;" th:text="${board.title}"></h3>
                </a>
                <div class="card-text mb-auto" th:text="${board.content}">
                </div>
                <div class="mb-1 text-muted" style="padding-top: 15px;" th:text="${#temporals.format(board.createdDate, 'yyyy-MM-dd')}"></div>
            </div>
        </div>
    </div>
</main>

<footer th:replace="layout/footer :: footer"></footer>

<!-- Option 1: Bootstrap Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
</body>

</html>
  • indexController에서 Model에 담은 데이터는 th:each="board : ${boards}" 반복문을 이용해서 View에 뿌릴 수 있습니다. 사용할 때는 th:text="${board.title}"
  • 또한 날짜를 출력할 때는 th:text="${#temporals.format(board.createdDate, 'yyyy-MM-dd')}" 해서 날짜 포맷을 할 수 있습니다.



글 상세

  • 이번엔 게시글의 제목을 클릭했을 때 글 상세보기를 구현할 것입니다.

index.html

<!-- Begin page content -->
<main class="flex-shrink-0">
    <div class="container">
        <div class="p-2"></div>
        <div th:each="board : ${boards}" class="row g-0 border rounded overflow-hidden flex-md-row mb-4 shadow-sm h-md-250 position-relative">
            <div class="col p-4 d-flex flex-column position-static">
                <a th:href="@{/board/{id}(id=${board.id})}" class="a-title">
                    <h3 class="mb-0 title" style="padding-bottom: 10px;" th:text="${board.title}"></h3>
                </a>
                <div class="card-text mb-auto" th:text="${board.content}">
                </div>
                <div class="mb-1 text-muted" style="padding-top: 15px;" th:text="${#temporals.format(board.createdDate, 'yyyy-MM-dd')}"></div>
            </div>
        </div>
    </div>
</main>
  • a 태그에 th:href="@{/board/{id}(id=${board.id})}"를 추가해주세요.



BoardController 클래스

  • 상세보기 페이지 Controller를 작성합니다.
@RequiredArgsConstructor
@Controller
public class BoardController {

    private final BoardService boardService;

    ...

    @GetMapping("/board/{id}")
    public String detail(@PathVariable Long id, Model model) {
        model.addAttribute("board", boardService.detail(id));
        return "layout/board/board-detail";
    }
}
  • 주소 뒤에 {id} 이렇게 id를 받을 때는 @PathVariable을 사용하면 주소의 id로 받습니다.



BoardService 클래스

@RequiredArgsConstructor
@Service
public class BoardService {

    private final BoardRepository boardRepository;

    ...

    /**
     * 글상세 로직
     */
    @Transactional(readOnly = true)
    public Board detail(Long id) {
        return boardRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 id가 없습니다. id=" + id));
    }
}



board-detail.html

  • 이제 화면을 구현하겠습니다.
<!doctype html>
<html lang="en" class="h-100" xmlns:th="http://www.thymeleaf.org">

<head th:replace="layout/header :: head ('글상세')"></head>

<body class="text-center d-flex flex-column h-100">
<header th:replace="layout/header :: header"></header>

<div class="d-flex" style="position: absolute; left: 20px; top: 70px;"><h2 style="margin-right: 10px;">글 번호:</h2><h2 th:text="${board.id}" id="id"></h2></div>
<div class="d-flex" style="position: absolute; left: 20px; top: 110px;"><h2 style="margin-right: 10px;">작성자:</h2><h2 th:text="${board.user.username}"></h2></div>
<main class="form-signin" style="max-width: 100%;">
    <div class="container border rounded flex-md-row mb-4 shadow-sm h-md-250">
        <h1 class="h3 m-3 fw-normal">글상세</h1>
        <hr/>
        <div class="form-floating m-3">
            <h3 th:text="${board.title}" style="margin-bottom: 50px;"></h3>
        </div>
        <div class="form-floating m-3">
            <p th:text="${board.content}"></p>
        </div>


    </div>
    <div th:if="${board.user.id == #authentication.principal.id}">
        <a th:href="@{/board/{id}/update(id=${board.id})}" class="btn btn-warning" id="btn-update">수정</a>
        <button class="btn btn-danger" id="btn-delete">삭제</button>
    </div>
    <button class="btn btn-secondary" onclick="history.back()" style="position: absolute; top:556px; left:82px;">뒤로</button>
</main>

<footer th:replace="layout/footer :: footer"></footer>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
        crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"
        integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
<script th:src="@{/js/board.js}"></script>
</body>

</html>
  • 삭제 버튼과 수정 버튼은 글쓴이만 볼 수 있게 해야줘야 합니다.
  • thymeleaf 조건문을 쓰면 해결됩니다. th:if="${board.user.id == #authentication.principal.id}"
  • board에 저장되어있는 userId와 현재 로그인 되어있는 id와 비교하면 끝입니다.
  • 수정하기는 수정하기 페이지가 따로 있기 때문에 a 태그를 걸어주었습니다.

style.css

.card-text {
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
    text-overflow: ellipsis;
    word-break: break-all;
}

.a-title {
    text-decoration: none;
    color: #4e423b;
}

.a-title:hover {
    color: #15751b;
    text-decoration: underline;
}

p {
    word-break: break-all;
    text-align: left;
}

h3 {
    word-break: break-all;
}



글 삭제

  • 이제 게시판을 삭제해보도록 하겠습니다.

board.js

'use strict';

let index = {
    init: function () {
        ...
        $("#btn-delete").on("click", () => {
            this.deleteById();
        });
    },

       ...

    deleteById: function () {
        let id = $("#id").text();

        $.ajax({
            type: "DELETE",
            url: "/api/v1/board/" + id,
            dataType: "json"
        }).done(function (res) {
            alert("글삭제가 완료되었습니다.");
            location.href = "/";
        }).fail(function (err) {
            alert(JSON.stringify(err));
        });
    }
}
index.init();
  • 삭제같은 경우는 data가 필요가없고 id만 필요합니다.
  • 그래서 board-detail.html에서 글 번호로 id값을 받았습니다.
  • 여기서는 let id = $("#id").text();에서 뒤에 val()가 아니라 text()라는 점을 주의하면 됩니다. input 태그가 아니기 때문이죠.



BoardApiController 클래스

@RequiredArgsConstructor
@RestController
public class BoardApiController {

    private final BoardService boardService;
    ...

    /**
     * 글삭제 API
     */
    @DeleteMapping("/api/v1/board/{id}")
    public Long deleteById(@PathVariable Long id) {
        boardService.deleteById(id);
        return id;
    }
}
  • id값을 주소에 받기 위해 @PathVariable을 썼습니다.



BoardService 클래스

@RequiredArgsConstructor
@Service
public class BoardService {

    private final BoardRepository boardRepository;
    ...

    /**
     * 글삭제 로직
     */
    @Transactional
    public void deleteById(Long id) {
        boardRepository.deleteById(id);
    }

}
  • JpaRepository의 deleteById는 void타입입니다.



글 수정

  • 이번에는 글 수정을 해보도록 하겠습니다.

BoardController 클래스

@RequiredArgsConstructor
@Controller
public class BoardController {

    private final BoardService boardService;
    ...

    @GetMapping("/board/{id}/update")
    public String update(@PathVariable Long id, Model model) {
        model.addAttribute("board", boardService.detail(id));
        return "layout/board/board-update";
    }
}
  • 글 수정 페이지 역시 id값이 필요하므로 주소 id를 받구요. Model에 데이터를 담습니다.



board-update.html

<!doctype html>
<html lang="en" class="h-100" xmlns:th="http://www.thymeleaf.org">

<head th:replace="layout/header :: head ('글수정')"></head>

<body class="text-center d-flex flex-column h-100">
<header th:replace="layout/header :: header"></header>

<main class="form-signin" style="max-width: 100%;">
    <div class="container border rounded flex-md-row mb-4 shadow-sm h-md-250">
        <form>
            <h1 class="h3 m-3 fw-normal">글수정</h1>
            <input type="hidden" id="id" th:value="${board.id}">
            <div class="form-floating m-3">
                <input type="text" th:value="${board.title}" class="form-control" id="title" placeholder="제목을 입력하세요." required>
                <label for="title">제목</label>
            </div>
            <div class="form-floating m-3">
                <textarea class="form-control" th:text="${board.content}" rows="5" id="content" style="height: 450px;"></textarea>
                <label for="content">내용</label>
            </div>

        </form>
        <button class="w-100 btn btn-lg btn-success" id="btn-update" style="max-width: 250px;">수정완료</button>

    </div>
</main>

<footer th:replace="layout/footer :: footer"></footer>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
        crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"
        integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
<script th:src="@{/js/board.js}"></script>
</body>

</html>
  • 글 수정 페이지입니다.
  • input 태그에 hidden값으로 id를 받았습니다.



board.js

'use strict';

let index = {
    init: function () {
        ...
        $("#btn-update").on("click", () => {
            this.update();
        });
    },
    ...

    update: function () {
        let id = $("#id").val();

        let data = {
            title: $("#title").val(),
            content: $("#content").val()
        }

        $.ajax({
            type: "PUT",
            url: "/api/v1/board/" + id,
            data: JSON.stringify(data),
            contentType: "application/json; charset=utf-8",
            dataType: "json"
        }).done(function (res) {
            alert("글수정이 완료되었습니다.");
            location.href = "/";
        }).fail(function (err) {
            alert(JSON.stringify(err));
        });
    },
}
index.init();



BoardApiController 클래스

@RequiredArgsConstructor
@RestController
public class BoardApiController {

    private final BoardService boardService;

    ...
    /**
     * 글수정 API
     */
    @PutMapping("/api/v1/board/{id}")
    public Long update(@PathVariable Long id, @RequestBody BoardUpdateRequestDto boardUpdateRequestDto) {
        return boardService.update(id, boardService);
    }
}
  • update할 데이터를 따로 dto로 클래스로 만들어줍니다.



Board 클래스

    ...
    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }
  • update 메소드를 추가합니다.
  • JPA에서 udpate를 진행할 때는 영속성 컨텍스트에 있는 값과 비교를 해서 변경된 값이 있으면 그 변경된 값만 update 시켜줍니다. 이것을 변경감지라 하여 더치체킹이라 부릅니다.
  • 즉, Entity 객체의 값만 변경시켜주면 더티체킹이 일어납니다. (Update 쿼리문을 날릴 필요가 없습니다!!)



BoardService 클래스

@RequiredArgsConstructor
@Service
public class BoardService {

    private final BoardRepository boardRepository;
    ...

    /**
     * 글수정 로직
     */
    @Transactional
    public Long update(Long id, BoardUpdateRequestDto boardUpdateRequestDto) {
        Board board = boardRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 id가 없습니다. id=" + id));
        board.update(boardUpdateRequestDto.getTitle(), boardUpdateRequestDto.getContent());
        return id;
    }

}
  • 먼저 boardRepository.findById(id)로 찾아서 Board를 영속화시킵니다. 그러면 영속성 컨텍스트에 Board 객체가 담아집니다.
  • 그리고 나서 Board의 값을 변경시키면 Service가 종료되는 시점에 트랜잭션이 종료되고 더티체킹이 일어납니다.






다음으로

  • 게시판 CRUD는 여기서 마무리하겠습니다.
  • 다음에는 페이징과 검색을 구현해보겠습니다.






References

댓글