본문 바로가기
공부 기록

[스프링 부트로 게시판 만들기] 11. 댓글 기능, Remember Me

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



댓글

  • 댓글의 대댓글은 구현하지 않고 댓글만 만들도록 하겠습니다.
  • 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 &nbsp;</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">작성자: &nbsp;</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">작성자: &nbsp;</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

댓글