댓글
댓글의 대댓글은 구현하지 않고 댓글만 만들도록 하겠습니다.
domain 패키지에 reply 패키지를 만들어 Reply 엔티티를 생성합니다.
Reply 클래스
package com.azurealstn.blogproject.domain.reply;
import com.azurealstn.blogproject.domain.BaseTimeEntity;
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;
import javax.persistence.*;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Entity
public class Reply extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 500)
private String content;
@ManyToOne
@JoinColumn(name = "boardId")
private Board board;
@ManyToOne
@JoinColumn(name = "userId")
private User user;
}
댓글을 누가 작성했는지와 어느 게시글에 작성했는지 알아야 하기 때문에 연관관계가 필요합니다.
Board : Reply : User -> 1 : N : 1
한 게시글에 여러 개의 댓글과 한 유저가 여러 개의 댓글을 달 수 있습니다.
board-detail.html
댓글 디자인은 자유입니다.
main 태그 맨 아래에다 댓글을 만들어줍니다.
<main class="form-signin" style="max-width: 100%;">
...
<div class="card mb-2 mt-5">
<div class="card-header bg-light">
<i class="fa fa-comment fa"></i> 댓글
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="list-group-item">
<textarea class="form-control" id="exampleFormControlTextarea1" rows="1"></textarea>
<button type="button" class="btn btn-dark mt-3">등록</button>
</li>
</ul>
</div>
</div>
<br/>
<div class="card">
<div class="card-header">댓글</div>
<ul id="reply--box" class="list-group">
<li id="reply--1" class="list-group-item d-flex justify-content-between">
<div>댓글 내용입니다.!</div>
<div class="d-flex">
<div class="text-monospace">작성자: ssarlength </div>
<button class="badge btn-warning">수정</button><span> | </span>
<button class="badge btn-danger">삭제</button>
</div>
</li>
</ul>
</div>
</main>
ReplyRepository 클래스
package com.azurealstn.blogproject.domain.reply;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ReplyRepository extends JpaRepository<Reply, Long> {
}
무한참조
먼저 Board 엔티티 클래스를 수정하겠습니다.
Board 클래스
@OneToMany(mappedBy = "board", fetch = FetchType.EAGER)
private List<Reply> replyList;
이 칼럼을 추가하겠습니다.
이러면 Board 테이블에 댓글리스트를 추가하겠다는 건데 DB에는 하나의 raw 데이터에 하나의 값만 들어갈 수 있습니다. 만약 여러 개의 데이터가 들어간다면 원자성이 깨집니다. 그래서 replyList는 DB에 FK로 생성되면 안되기 때문에 mappedBy
를 사용합니다.
mppedBy : 연관관계의 주인이 아니므로 DB의 FK가 아니다 라는 뜻입니다.
@OneToMany의 디폴트 fetch는 Lazy 입니다. 이것을 Eager 로 변경합니다.
board-detail.html
<div class="card">
<div class="card-header">댓글</div>
<ul id="reply--box" class="list-group" th:each="reply : ${replyList}">
<li id="reply--1" class="list-group-item d-flex justify-content-between">
<div th:text="${reply.content}"></div>
<div class="d-flex">
<span class="text-monospace mr-1">작성자: </span><div class="text-monospace mr-1" th:text="${reply.user.username}"></div>
<button class="badge btn-warning">수정</button><span> | </span>
<button class="badge btn-danger">삭제</button>
</div>
</li>
</ul>
</div>
board-detail.html에서 데이터를 넣어줍니다.
Controller에서 model에 데이터를 따로 담아주지 않았는데도 사용할 수 있는 이유는 Board를 조회할 때 Reply도 같이 조회하게 됩니다. 그래서 따로 model에 담지 않아도 reply를 사용할 수 있습니다.
하지만 문제가 있습니다.
무한참조 발생
Board를 조회할 때 Reply를 조회하게 되고 Reply를 조회하면 Board, User를 조회하게 됩니다.
여기서 또 Board 조회하고 또 Reply를 조회하게 되고.... (무한 반복)
해결하려면 Board 조회하고 Reply를 조회하고 다시 Board를 조회안하게 되면 됩니다.
@JsonIgnoreProperties({"board"}) 를 추가하면 해결이 됩니다.
public class Board extends BaseTimeEntity {
...
@OrderBy("id desc")
@JsonIgnoreProperties({"board"})
@OneToMany(mappedBy = "board", fetch = FetchType.EAGER)
private List<Reply> replyList;
}
@OrderBy("id desc") : 댓글 작성시 최근 순으로 볼 수 있도록 설정
댓글 작성
어느 게시글에 댓글을 작성했는지 알기 위해 hidden값을 넣어주어야 합니다.
등록 버튼에 id값도 줍니다.
board-detail.html
<main class="form-signin" style="max-width: 100%;">
...
<div class="card mb-2 mt-5">
<div class="card-header bg-light">
<i class="fa fa-comment fa"></i> 댓글
</div>
<form>
<div class="card-body">
<input type="hidden" id="boardId" th:value="${board.id}">
<ul class="list-group list-group-flush">
<li class="list-group-item">
<textarea class="form-control" id="reply-content" rows="1"></textarea>
<button id="reply-btn-save" type="button" class="btn btn-dark mt-3">등록</button>
</li>
</ul>
</div>
</form>
</div>
<br/>
<div class="card">
<div class="card-header">댓글</div>
<ul id="reply--box" class="list-group" th:each="reply : ${board.replyList}">
<li th:id="'reply--' + ${reply.id}" class="list-group-item d-flex justify-content-between">
<div th:text="${reply.content}"></div>
<div class="d-flex">
<span class="text-monospace mr-1">작성자: </span><div class="text-monospace mr-1" th:text="${reply.user.username}"></div>
<button class="badge btn-warning">수정</button><span> | </span>
<button class="badge btn-danger">삭제</button>
</div>
</li>
</ul>
</div>
</main>
reply.js를 만들고 <script th:src="@{/js/reply.js}"></script>
경로도 추가합니다.
reply.js
'use strict';
let replyIndex = {
init: function () {
$("#reply-btn-save").on("click", () => {
this.replySave();
});
},
replySave: function () {
let data = {
content: $("#reply-content").val(),
}
let boardId = $("#boardId").val();
console.log(data);
console.log(boardId);
$.ajax({
type: "POST",
url: `/api/v1/board/${boardId}/reply`,
data: JSON.stringify(data),
contentType: "application/json; charset=utf-8",
dataType: "text"
}).done(function (res) {
alert("댓글작성이 완료되었습니다.");
location.href = `/board/${boardId}`;
}).fail(function (err) {
alert(JSON.stringify(err));
});
},
}
replyIndex.init();
ReplyApiController 클래스
@RequiredArgsConstructor
@RestController
public class ReplyApiController {
private final ReplyService replyService;
@PostMapping("/api/v1/board/{boardId}/reply")
public void save(@PathVariable Long boardId,
@RequestBody Reply reply,
@AuthenticationPrincipal PrincipalDetail principalDetail) {
replyService.replySave(boardId, reply, principalDetail.getUser());
}
}
User 정보는 @AuthenticationPrincipal, boardId는 @PathVariable 통해서, Reply는 JSON으로 보내줍니다.
ReplyService 클래스
@RequiredArgsConstructor
@Service
public class ReplyService {
private final ReplyRepository replyRepository;
private final BoardRepository boardRepository;
@Transactional
public void replySave(Long boardId, Reply reply, User user) {
Board board = boardRepository.findById(boardId).orElseThrow(() -> new IllegalArgumentException("해당 boardId가 없습니다. id=" + boardId));
reply.save(board, user);
replyRepository.save(reply);
}
}
댓글을 저장할 때는 Board의 Id 값을 가져와야 합니다. 그래서 Board를 영속화시켜서 Board와 User를 저장합니다.
Reply 클래스
...
public void save(Board board, User user) {
this.board = board;
this.user = user;
}
의문점..
제가 구현할 때 처음에 좀 실수를 해서 여러 에러 발생에 멘탈이 조금 털렸는데요.. ㅋㅋ 그 와중에 ajax 통신할 때 parseerror 가 나더라구요.
dataType이 서버의 dataType과 일치하지 않아서 나는 에러라는데 저는 분명히 @RequestBody로 보냈는데 왜 json으로는 parseerror가 나는지 의문이 들더군요..
결국엔 "text" 로 고쳐서 해결은 했습니다.. (흠..)
게시글 삭제 에러
이 상태에서 게시글을 삭제하는데 에러가 발생합니다.
왜냐하면 댓글에 외래키로 잡혀서 있어서 삭제가 안되는데 옵션을 주면 됩니다.
Board 클래스
@OrderBy("id desc")
@JsonIgnoreProperties({"board"})
@OneToMany(mappedBy = "board", fetch = FetchType.EAGER, cascade = CascadeType.REMOVE)
private List<Reply> replyList;
댓글 삭제
board-detail.html
<button th:onclick="|replyIndex.replyDelete('${board.id}', '${reply.id}')|" class="badge btn-danger" style="margin-left: 10px;">삭제</button>
삭제 버튼에 onclick()
메소드를 추가합니다. 문법은 thymeleaf 공식 홈페이지에서 참고 가능하며 구글에 검색해도 많이 나와요!
reply.js
replyDelete: function (boardId, replyId) {
$.ajax({
type: "DELETE",
url: `/api/v1/board/${boardId}/reply/${replyId}`,
dataType: "text"
}).done(function (res) {
alert("댓글삭제가 완료되었습니다.");
location.href = `/board/${boardId}`;
}).fail(function (err) {
alert(JSON.stringify(err));
});
},
ReplyApiController 클래스
@RequiredArgsConstructor
@RestController
public class ReplyApiController {
private final ReplyService replyService;
...
@DeleteMapping("/api/v1/board/{boardId}/reply/{replyId}")
public void delete(@PathVariable Long replyId) {
replyService.replyDelete(replyId);
}
}
ReplyService 클래스
@RequiredArgsConstructor
@Service
public class ReplyService {
private final ReplyRepository replyRepository;
private final BoardRepository boardRepository;
...
@Transactional
public void replyDelete(Long replyId) {
replyRepository.deleteById(replyId);
}
}
그리고 삭제 버튼은 로그인한 사람과 그 로그인한 사람이 작성한 댓글 id와 같아야 삭제 버튼이 나오도록 설정합니다.
board-detail.html
<main>
<div class="card">
<div class="card-header">댓글</div>
<ul id="reply--box" class="list-group" th:each="reply : ${board.replyList}">
<li th:id="'reply--' + ${reply.id}" class="list-group-item d-flex justify-content-between">
<div th:text="${reply.content}"></div>
<div class="d-flex" >
<span class="text-monospace">작성자: </span><div class="text-monospace" th:text="${reply.user.username}"></div>
<span th:if="${reply.user.id == #authentication.principal.id}">
<button th:onclick="|replyIndex.replyDelete('${board.id}', '${reply.id}')|" class="badge btn-danger" style="margin-left: 10px;">삭제</button>
</span>
</div>
</li>
</ul>
</div>
</main>
Remember Me
Remember Me 구현은 엄청 간단합니다.
SecurityConfig에서 이 코드만 추가해주면 되요!
SecurityConfig 클래스
@Override
protected void configure(HttpSecurity http) throws Exception {
...
http
.rememberMe().tokenValiditySeconds(60 * 60 * 7)
.userDetailsService(principalDetailService);
}
tokenValiditySeconds : 쿠키를 얼마나 유지할 것인지 계산합니다. (7일 설정)
그 다음에 User 정보를 넣어주면 됩니다. principalDetailService
user-login.html
<main class="form-signin">
<div class="container border rounded flex-md-row mb-4 shadow-sm h-md-250">
<form action="/auth/user/login" method="post">
...
<div class="checkbox mb-3">
<input type="checkbox" name="remember-me" id="rememberMe">
<label for="rememberMe" aria-describedby="rememberMeHelp">로그인 유지</label>
</div>
<button class="w-100 btn btn-lg btn-success" id="btn-login">로그인</button>
</form>
</div>
</main>
다음으로
댓글과 Remember Me 기능까지 구현해보았습니다.
다음은 소셜로그인인데 사실.. 소셜로그인을 포스팅을 할지 말지 고민이 됩니다. 왜냐하면 이미 영상에 강의가 다 나와있고, 저도 이 강의를 보고 따라만드는 입장이라.. 이 부분은 생기는 이슈들만 따로 포스팅을 해보도록 하겠습니다.
References
댓글