트리와 재귀를 활용한 댓글 대댓글 기능 구현

jkky98·2025년 1월 30일
0

ProjectSpring

목록 보기
9/20

활용 프레임워크

  • Spring Data JPA
  • Thymeleaf
  • BootStrap5
  • SpringBoot

엔티티 구조


@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA 기본 생성자
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 빌더와 함께 사용할 모든 필드 생성자
public class Comment extends BaseEntity {
    @Id
    @GeneratedValue
    @Column(name = "comment_id")
    private Long id;

    private String content;

    // 연관관계
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id", nullable = false)
    private Post post;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id") // Self-referencing Foreign Key
    private Comment parent;

    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
    @Builder.Default
    private List<Comment> childrens = new ArrayList<>();

자기 자신을 연관관계로 맺는 parent필드가 존재하고 이를 양방향 연관관계로 확장하여 parent가 childrens로 하여금 자식 Comment를 알 수 있도록 하는 것이 중요 포인트다. parent, childrens로 하여금 트리 구조가 완성된 것을 미리 파악할 수 있다.

thymeleaf

<div class="comments-container" th:fragment="comments">
  <h3>댓글</h3>

  <!-- Comment Form -->
  <form th:action="${currentUrl}" th:method="post" class="mb-4"
        th:object="${commentForm}" th:if="${sessionUser != null}">

    <!-- URL 정보를 숨겨진 input 필드에 담기 -->
    <input type="hidden" name="requestUrl" th:value="${currentUrl}" />

    <!-- 에러 메시지 -->
    <div class="invalid-feedback d-block" th:if="${#fields.hasErrors('content')}" th:text="${#fields.errors('content')[0]}"></div>

    <!-- content 필드 바인딩 -->
    <textarea class="form-control mb-2"
              th:field="*{content}"
              rows="3"
              placeholder="댓글을 입력하세요...">
    </textarea>

    <button type="submit" class="btn btn-primary btn-sm">댓글 작성</button>
  </form>

  <!-- 세션 사용자가 없을 경우 -->
  <div th:if="${sessionUser == null}" class="alert alert-warning">
    댓글을 작성하려면 로그인이 필요합니다. <a th:href="@{/login}" class="alert-link">로그인</a>
  </div>

  <!-- Comment List -->
  <div class="comment-list">
    <ul class="list-unstyled">
      <li th:each="comment : ${postViewDto.comments}" th:if="${comment.parentsId == null}">
        <div th:replace="~{fragments/comments :: commentBlock(${comment})}"></div>
      </li>
    </ul>
  </div>
</div>

parent가 없는 댓글 즉 Post에 직접적으로 남기는 댓글의 경우 가장 상단 댓글 폼을 이용한다. 이 댓글 폼은 로그인이 되어있지 않으면 활성화되지 않으며 비로그인시 백엔드에서도 역시 필터에 의해 댓글 업로드가 막히게 된다.

댓글과 대댓글 작성은 모두 같은 컨트롤러 메서드를 타게 되는데, 다음과 같다.

@PostMapping("/@{username}/post/{postUrl}")
    public String writeComment(
            @PathVariable("username") String username, // 포스트 주인의 username
            @PathVariable("postUrl") String postUrl, // 포스트의 url
            @SessionAttribute(SessionConst.LOGIN_USER) User sessionUser, // 코멘트 입력자
            @Validated @ModelAttribute("commentForm") CommentForm commentForm, // 코멘트 폼 데이터
            BindingResult bindingResult,
            @RequestHeader("Referer") String referer,
            Model model
    ) {

        if (bindingResult.hasErrors()) {
            PostViewDto postViewDto = postService.getPost(username, postUrl);

            model.addAttribute("postViewDto", postViewDto);
            return "postView";
        }

        // 쿼리 파라미터 제거한 referer
        referer = removeQueryStringInReferer(referer);

        commentService.saveComment(SaveCommentRequest.of(commentForm, sessionUser, postUrl, username));
        return "redirect:" + (referer != null ? referer : "/");
    }

빈 댓글을 업로드하는 등의 행동을 에러 문구 랜더링으로 하여금 막기 위해 BindingResult를 사용했으며 BindingResult사용을 위해 게시글을 GET하는 url과 Comment를 등록(POST)하는 url을 동일하게 두었다.

에러시에는 BindingResult와 함께 다시 해당 게시글 템플릿을 리턴하도록 postViewDto를 재구성해서 리턴한다.

saveComment를 수행하기 위해 commentService를 주입받아 이용하게 된다.

service코드는 따로 첨부하지 않지만 parentId를 구성하는 부분의 경우 Post에 바로 달린 댓글의 경우 parentId는 null로, 대댓글의 경우에는 대댓글의 parentId로 하여금 parent Comment를 찾아 저장하게 된다.

게시글 GET 요청 시

성공적으로 Comment가 업데이트되었을 때 게시글 url을 다시 리다이렉트 한다. 게시글 랜더링시에 Comment에 대한 데이터도 같이 가져오게 되는데 업데이트가 되었으므로 PostViewDto(게시글 템플릿에서 사용되는 데이터 전송 객체)의 필드:comments에 모든 댓글들이 논리적 트리 형태로 구성되어 딸려온다.

public static PostViewDto of(Post post) {
        return PostViewDto.builder()
                .title(post.getTitle())
                .postUrl(post.getPostUrl())
                .username(post.getUser().getUsername())
                .createByDt(post.getCreateDt())
                .content(post.getContent())
                .tags(
                        post.getPostTags().stream()
                                .map(postTag -> postTag.getTag().getName())
                                .toList()
                )
                .comments(
                        post.getComments().stream()
                                .map(CommentsDto::of)
                                .toList()
                )
                .likeCount(post.getLikes().size())
                .build();
    }

postViewDto 정적 팩토리 생성 메서드를 살펴보면 하나의 Post로 관련된 것들을 모두 끌고오게 된다. 이때 연관된 Entity를 그대로 끌고오지 않고 해당 엔티티에 대응되는 DTO를 구성하고 정적팩토리메서드를 내부에 만들어 이로 변환해서 한번에 구성하도록 빌더 및 스트림을 적극 활용했다.

우리가 주목할 부분은 .comments()부분이고 post.getComments()시에 게시글에 첫번째 레벨로 달린 Comment들을 가져온다. 그 후 CommentsDto의 of로직을 살펴보면,

public static CommentsDto of(Comment comment) {
        return CommentsDto.builder()
                .id(comment.getId())
                .createBy(comment.getCreateBy())
                .createByDt(comment.getCreateDt())
                .content(comment.getContent())
                .childrens(comment.getChildrens().stream().map(CommentsDto::of).collect(Collectors.toList()))
                .parentsId(comment.getParent() != null ? comment.getParent().getId() : null)
                .build();
    }

childrens 필드를 만들어내는 곳에서 재귀적으로 계속 자신의 of로직을 활용하는 것을 볼 수 있다.

이렇게 PostViewDto 한 객체 안에 리스트안에 리스트를 무한으로 구성할 수 있게 되어 댓글-대댓글의 모두를 트리 구조로 담아낼 수 있다.

이제 이것을 템플릿에서 잘 뽑아내어 템플릿에서 랜더링시켜야한다.

commentBlock(${comment})

<!-- 댓글 블록 템플릿 조각 -->
<th:block th:fragment="commentBlock(comment)">
  <div class="card mb-3">
    <div class="card-body">
      <div class="comment-meta d-flex justify-content-between">
        <div>
          <strong th:text="${comment.createBy}">작성자</strong>
          <span class="text-muted" th:text="${#temporals.format(comment.createByDt, 'yyyy-MM-dd HH:mm')}">작성 시간</span>
        </div>
      </div>
      <div class="comment-content mt-2" th:text="${comment.content}">
        댓글 내용
      </div>

      <!-- 대댓글 작성 버튼 -->
      <div class="comment-actions mt-3">
        <a class="btn btn-outline-primary btn-sm" data-bs-toggle="collapse"
           th:href="'#replyForm-' + ${comment.id}"
           role="button"
           aria-expanded="false"
           th:attr="aria-controls='replyForm-' + ${comment.id}">
          대댓글 달기
        </a>
      </div>

      <!-- 대댓글 작성 폼 -->
      <div class="collapse mt-3" th:id="'replyForm-' + ${comment.id}">
        <form th:action="${currentUrl}" method="post">
          <input type="hidden" name="parentsId" th:value="${comment.id}" />
          <textarea class="form-control mb-2" name="content" rows="3" placeholder="답글을 입력하세요..."></textarea>
          <button type="submit" class="btn btn-primary btn-sm">등록</button>
        </form>
      </div>

      <!-- 대댓글 리스트 (재귀적 호출) -->
      <ul th:if="${not #lists.isEmpty(comment.childrens)}" class="list-unstyled ms-4 mt-3">
        <li th:each="child : ${comment.childrens}">
          <div th:replace="~{fragments/comments :: commentBlock(${child})}"></div>
        </li>
      </ul>
    </div>
  </div>
</th:block>

모든 댓글 작성 폼은 독립적으로 존재해야 하므로, th:id를 활용하여 동적으로 ID를 할당하였다. th:block을 이용해 댓글 카드를 생성하고, 해당 카드 내부에서 또 다른 댓글 카드를 호출하는 구조로 구현하였다. 다만, 대댓글(childrens)이 존재하는 경우에만 commentBlock(child)을 호출하여 재귀적으로 댓글 계층을 불러오도록 설계하였다.

DFS를 활용할 때, 재귀적 접근 방식이 자주 사용되는데, 이 방식에서는 자식 댓글을 먼저 호출한 후, 해당 자식이 또 다른 자식을 가질 경우 이를 우선적으로 처리하기 때문에, 댓글이 추가될 때 계층 구조와 순서를 자연스럽게 유지할 수 있다. 이를 통해 댓글이 깊어져도 계층적 구조를 정확히 표현할 수 있도록 구현하였다.

profile
자바집사의 거북이 수련법

0개의 댓글

관련 채용 정보