댓글
댓글의 대댓글은 구현하지 않고 댓글만 만들도록 하겠습니다.
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
댓글