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
댓글