text/Java

springboot 대댓글 게시판 구현하기 (수정 기능x, 삭제 기능x)

hoonzii 2023. 8. 7. 15:49
반응형

대댓글 게시판 구현하기 (수정, 삭제 기능 x, 회원 존재 x)

 

프로젝트 폴더 및 파일 구성

 

대댓글을 구현하기 앞서 기본적인 게시판을 구현해야 한다.

기본 게시판 구현

  • 게시물 목록
  • 게시물 상세 보기
  • 게시물 작성하기

게시물이 작성되어 저장하기 위한 table 구성 (mysql 기준)

* ip를 넣어놓은 이유는 익명일 때는 구분을 위해 IPv4 뒤 두 자리를 넣어놓던데… 사실 필요 없을 듯하다

  • seq 값은 게시물의 pk 값
  • 게시물은 제목(title)과 내용(content)을 가지고 있고,
    그 게시물을 쓴 주체(user)에 대해 표시되어야 하기에 title, content, user가 존재
  • 작성 시간(createAt)도 표시

mysql create sql

CREATE TABLE `post` (
  `seq` bigint NOT NULL AUTO_INCREMENT,
  `title` varchar(50) COLLATE utf8mb3_unicode_ci DEFAULT NULL,
  `content` varchar(100) COLLATE utf8mb3_unicode_ci DEFAULT NULL,
  `user` varchar(20) COLLATE utf8mb3_unicode_ci DEFAULT NULL,
  `ip` varchar(50) COLLATE utf8mb3_unicode_ci DEFAULT NULL,
  `createAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`seq`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci

 

화면은 총 3개로 구성

  1. 게시물 목록 화면 - /postBoard
    1. 목록 화면에서 게시물 제목 클릭 시 상세화면으로 넘어가기
  2. 게시물 작성 화면 - /postBoard/writePost
    1. 작성 화면은 Get으로 매핑
    2. 작성 후 등록 시 Post로 매핑, 등록 완료 후 목록 화면으로 돌아가기
  3. 게시물 상세 화면 - /postBoard/postDetail? seq={post seq 값}

 

Entity, Repository, Service, Controller를 구성해 보자.

우선 Entity

테이블의 row값을 매핑할 수 있게 위에 구성한 table을 참고해 만들어준다.

@Table(name="post")
@Entity
@DynamicInsert
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
public class Post {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long seq;
    private String title;
    private String content;
    private String user;
    private String ip;

    @Column(name = "createAt")
    private LocalDateTime createAt;

    public JSONObject getPostJson() {
        JSONObject result = new JSONObject();
        result.put("seq", this.getSeq());
        result.put("title", this.getTitle());
        result.put("content", this.getContent());
        result.put("user", this.getUser());
        result.put("createAt", this.getCreateAt());
        return result;
    }
}

중간에 getPostJson 메서드는 entity를 Json으로 바꾸기 위한 메서드다.

 

Repository 선언

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
}

Service 선언

@Service
public class PostService {

    @Autowired
    private PostRepository postRepository;
// 차후 post 엔티티와 연관된 댓글을 가져오기 위한 post 엔티티 그자체 조회
    public Post getPostEntity(Long seq) {
        Optional<Post> optionalPost = postRepository.findById(seq);
        return optionalPost.orElse(null);
    }
// 게시물 목록을 보여주기 위해 모든 게시물 조회 (실무에선 이럼 안되용~)
    public JSONArray getPostAll() {
        JSONArray result = new JSONArray();
        postRepository.findAll().forEach(post -> {
            JSONObject postJson = new JSONObject();
            postJson.put("seq", post.getSeq());
            postJson.put("title", post.getTitle());
            postJson.put("content", post.getContent());
            postJson.put("createAt", post.getCreateAt());
            postJson.put("user", post.getUser());
            result.put(postJson);
        });
        return result;
    }
// 게시물 등록을 위한 메소드
    @Transactional
    public void savePost(Post post) {
        postRepository.save(post);
    }

}

위에 주석을 적어놨지만 하나씩 떼서 설명을 해보자면

게시물 목록을 보여주기 위해서는 목록을 만들기 위해

( 제목, 상세페이지로 이동하기 위한 seq, 작성시각, 작성자 ) 등의 정보가 필요하다.

*사실 목록을 만들 때 내용(content) 정보는 필요 없다.

public JSONArray getPostAll() {
    JSONArray result = new JSONArray();
    postRepository.findAll().forEach(post -> {
        JSONObject postJson = new JSONObject();
        postJson.put("seq", post.getSeq());
        postJson.put("title", post.getTitle());
        postJson.put("content", post.getContent());
        postJson.put("createAt", post.getCreateAt());
        postJson.put("user", post.getUser());
        result.put(postJson);
    });
    return result;
}

게시물 등록 시 사용하는 메서드

@Transactional
public void savePost(Post post) {
    postRepository.save(post);
}

게시물과 연관된 댓글을 가져오기 위한 게시물 자체의 entity가 필요해서

이 부분은 게시물 댓글 조회 시 다시 살펴보자.

public Post getPostEntity(Long seq) {
    Optional<Post> optionalPost = postRepository.findById(seq);
    return optionalPost.orElse(null);
}

 

Controller

  • 글목록
@Controller
public class boardController{
		@Autowired
    private PostService postService;

		@GetMapping("/")
    public String getBoardPage(Model model) {
        model.addAttribute("posts",postService.getPostAll());
        return "board";
    }
}

위에 적은 메서드 대로 “/” 접근 시 모든 게시물 정보를 조회해 목록 페이지로 반환한다.

참고로 페이지는 jsp로 구성했다.

<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%> <%@ taglib prefix="c"
uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html lang="ko">
  <head>
    <title>게시판</title>
    <script src="https://code.jquery.com/jquery-3.7.0.min.js" integrity="sha256-2Pmvv0kuTBOenSvLm6bvfBSSHrUJ+3A7x6P5Ebd07/g=" crossorigin="anonymous"></script>
  </head>
  <body>
    <ul>
        <li><a href="/postBoard">글목록</a></li>
        <li><a href="writePost">글작성</a></li>
    </ul>
    <h3>게시판 글목록</h3>
    <table id="post_table">
        <thead>
            <tr>
                <th>제목</th>
                <th>작성자</th>
                <th>작성시간</th>
            <tr>
        </thead>
        <tbody>
            <tr>
            <tr>
        </tbody>
    </table>
  </body>
  <script type="text/javascript">
    var posts = ${posts};
  </script>
  <script type="text/javascript" src="${pageContext.request.contextPath}/resources/js/board.js"></script>
</html>

 

실제로 구성된 화면은 아래와 같다.

글목록과 글작성 메뉴가 존재하고 글목록 클릭 시 현재 페이지로 이동된다.

 

글작성

@Controller
public class boardController{
	@Autowired
    private PostService postService;
		
// ...
	@GetMapping("/writePost")
    public String getWriteBoardPage() {
        return "write";
    }
}

write page

<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%> <%@ taglib prefix="c"
uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html lang="ko">
  <head>
    <title>게시판</title>
    <script src="https://code.jquery.com/jquery-3.7.0.min.js" integrity="sha256-2Pmvv0kuTBOenSvLm6bvfBSSHrUJ+3A7x6P5Ebd07/g=" crossorigin="anonymous"></script>
  </head>
  <body>
    <ul>
        <li><a href="/postBoard">글목록</a></li>
        <li><a href="writePost">글작성</a></li>
    </ul>
    <h3>게시판 글쓰기</h3>
    <section>
        <dt>제목</dt>
          <dd>
              <input
                  type="text"
                  id="title"
                  placeholder="제목 필수"
              />
          </dd>
          <dt>내용</dt>
            <dd>
                <textarea
                    id="content"
                    cols="50" rows="10"
                    placeholder="내용"
                ></textarea>
            </dd>
        <dt>작성자</dt>
          <dd>
              <input
                  type="text"
                  id="user"
                  placeholder="작성자명"
              />
          </dd>
          <dd>
            <input type="button" id="submit" value="등록"/>
          </dd>
    </section>
  </body>
  <script
  type="text/javascript" 
  src="${pageContext.request.contextPath}/resources/js/write.js"></script>
</html>

 

작성된 내용은 등록 버튼 클릭시 처리되어 post로 넘기게 된다.

구현된 화면은 아래와 같다.

작성페이지에서 post로 넘기는 함수 구현 (write.js)

$("#submit").off("click").on("click", function(){
        let data = {
          "title" : $("#title").val(),
          "content" : $("#content").val(),
          "user" : $("#user").val(),
        };
  
        $.ajax({
          method : "POST",
          url : "writePost",
          data : JSON.stringify(data),
          contentType: 'application/json',
          success : function() {
            location.href = "/postBoard";
          }
        });
      });

 

write post mapping

@Contorller
public class boardContorller {
	@Autowired
  	private PostService postService;
	...
	@PostMapping("/writePost")
	@ResponseStatus
	public ResponseEntity<Post> insertPost(@RequestBody InsertPostDto postDto, 
	HttpServletRequest request) {
	    String ip = request.getHeader("X-FORWARDED-FOR");
	    if (ip == null)
	        ip = request.getRemoteAddr();
	
	    postDto.setIp(ip);
	    
	    Post post = postDto.toEntity();
	    postService.savePost(post);
	    return ResponseEntity.status(200).build();
	}
}

메서드의 parameter로 DTO 값을 받아 entity로 변환하고 있다. DTO를 살펴보자

@Data
public class InsertPostDto {

    private String title;
    private String content;
    private String user;
    private String ip;

    public Post toEntity(){
        return Post.builder()
                .title(this.title)
                .content(this.content)
                .user(this.user)
                .ip(this.ip)
                .build();
    }
}

화면상에서 구성한 입력값을 서버로 보내기 위한 DTO로 입력값을 entity로 변환하는 toEntity 메서드를 포함하고 있다.

 

게시물 상세 화면

@Contorller
public class boardContorller {
  @Autowired
  private PostService postService;
  ...
  @GetMapping("/postDetail")
  public String getPostDetail(@RequestParam(name = "seq") Long seq, Model model) {
      Post post = postService.getPostEntity(seq);
      model.addAttribute("postDetail", post.getPostJson());
      return "detail";
  }
}

게시물 목록에서 게시물 클릭 시 /postDetail로 연결되어 넘겨받은 게시물의 id (seq)로 해당 게시물 정보를 가져온 뒤, detail 페이지를 반환한다.

상세 보기에는 댓글 등록 및 조회가 되어야 하므로 댓글 기능 추가를 설명하면서 같이 살펴보기로 하자.

 

댓글 기능 추가

기능 추가 전 어떤 기능이나 특징이 필요한지 나열해 보면

  • 댓글은 게시물에 종속되어 있다.
  • 대댓글을 무한히 달 수 있고, 대댓글은 댓글보다 안으로(?) 들여 쓰기 되어 있다.

나도 안 만들어 봤기에 다른 블로그 글을 참고했다.

게시판 무한댓글(대댓글) 드디어 해결!!!!

 

게시판 무한댓글(대댓글) 드디어 해결!!!!

🔔 22월 01월 13일 ✔ 무한 댓글 로직 다시 생각 ✔ sql문 다시 작성 ✔ 댓글의 대댓글 ✔ re댓글도 로그인해야지만 쓸수있게 ✔ 자기가 남긴 re댓글만 수정/삭제 ✔ 여행게시판 리스트 드디어!! 대

zi2eon.tistory.com

해당 블로그 주인분 께서는 REF, REF_STEP, REF_LEVEL이라는 필드 값을 두고 대댓글을 구현하셨는데
각 필드값의 의미를 보자면

 

REF - 댓글의 그룹

  • 첫 번째 댓글의 밑으로 달리는 대댓글은 첫번째 댓글 “그룹”으로 묶이며 두 번째 댓글과 구별해 주는 역할을 한다.

REF_STEP - 댓글의 순서

  • 그룹 내 댓글의 순서를 지정해 주는 역할이다.
  • 가령, 첫 번째 댓글(1)의 대댓글(1-1), (1-2)가 존재할 때, 대댓글의 대댓글(1-1-1)을 달게 되면 기존의 대댓글(1-2)은 (1-1-1) 보다 뒤로 밀려나야 한다. 이때 STEP 필드 값을 +1 해줘 순서를 보장해 준다.

REF_LEVEL - 댓글의 깊이

  • 댓글과 대댓글 간에 깊이 차이를 둬 어떤 댓글의 대댓글인지 확인할 수 있게 하는 역할

이 글을 보고 구현하던 중 무언가 잘못됐다 느낀 지점이 있었는데 바로 REF_STEP이다.

만약 대댓글이 (1-1), (1-2), (1-3), (1-4)로 구성되어 있다고 하자. (1번 댓글의 대댓글이다)

이때 (1-1)에 대댓글을 달게 되면 (1-1-1)은 STEP은 3으로 삽입되게 된다. (1, 1-1, 1-1-1의 순서로)

문제는 기존의 대댓글들은 DB에 STEP이 박혀있을 텐데, 새로운 STEP이 중간에 끼면서 그다음 순서댓글들
역시 STEP +1씩 해주어야 한다는 사실이다.

 

댓글은 하나 적는데 업데이트는 여러 개 되는 상황이 생기면서 ‘이게 맞나?’ 싶었다.
그래서 좀 더 고민해 보고 아래와 같이 구성했다.

하나씩 살펴보자면

  • seq → 댓글의 id(pk) 값이다.
  • postSeq → 댓글은 게시물에 종속되기에 1:N pk-fk 관계를 가진다.
  • REF_ → 댓글의 그룹을 표시한다. 그룹의 숫자가 작을수록 먼저 달린 댓글로 표시되고 대댓글의 그룹을 구분 짓는다.
  • REF_LEVEL → 대댓글의 깊이를 나타낸다. 0은 게시물에 달린 댓글, level > 0 일 때는 댓글 혹은 대댓글의 댓글인 셈이다.
  • user, ip, content → 작성자 정보와 작성 내용이다.
  • createAt → 작성시각
  • parent → 대댓글이 어떤 댓글에 답글을 단 건지 적는 필드이다. 게시물에 바로 다는 댓글은 부모댓글이 없기 때문에 -1로 적어 넣는다.

위 구현과 비교하면 STEP 값이 없다.

구현 로직은 다음과 같다.

게시물에 바로 댓글을 달면 REF_ = 0, REF_LEVEL = 0, parent = -1로 저장한다.

해당 댓글에 대댓글을 작성 시

  • REF_ = 0 : 댓글의 그룹은 최상위 댓글과 동일하다.
  • REF_LEVEL = 1 : 대댓글의 경우, 부모 댓글의 LEVEL에 +1 해준 값을 가진다.
  • parent = 부모 댓글의 seq : 대댓글의 경우, 부모 댓글에 seq 값을 가진다.

table을 구성해 보자. (구현하면서 중간에 바꾼 거라 REF_ORDER 필드가 남아있다.)

CREATE TABLE `comment` (
  `seq` bigint NOT NULL AUTO_INCREMENT,
  `postSeq` bigint DEFAULT NULL,
  `REF_` int DEFAULT NULL COMMENT '댓글 그룹',
  `REF_ORDER` int DEFAULT NULL COMMENT '대댓글 작성시 순서',
  `REF_LEVEL` int DEFAULT NULL COMMENT '대댓글 깊이',
  `user` varchar(20) DEFAULT NULL,
  `ip` varchar(100) DEFAULT NULL,
  `content` varchar(100) DEFAULT NULL,
  `createAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
  `parent` bigint DEFAULT '-1',
  PRIMARY KEY (`seq`),
  KEY `postSeq` (`postSeq`),
  CONSTRAINT `comment_ibfk_1` FOREIGN KEY (`postSeq`) REFERENCES `post` (`seq`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

차례대로

  1. Entity, Repository, Service를 구성하고
  2. 게시물 상세 보기 시 댓글을 같이 반환하는 메서드 추가
  3. html 화면 내 댓글 목록과 댓글 작성 부분까지

보기로 하자.

 

 

comment entity

@Table(name="comment")
@Entity
@DynamicInsert
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
public class Comment {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long seq;

    @ManyToOne(targetEntity = Post.class, cascade = CascadeType.ALL)
    @JoinColumn(name="postSeq")
    private Post post;

    @Column(name="REF_")
    private int REF;
    @Column(name="REF_ORDER")
    private int REFORDER;
    @Column(name="REF_LEVEL")
    private int REFLEVEL;

    private String user;
    private String ip;
    private String content;
    @Column(name="createAt")
    private LocalDateTime createAt;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="parent", referencedColumnName = "seq")
    private Comment parent;

    public String parentName(){ return this.parent != null ? this.parent.getUser() : ""; }
    public Long parentSeq() { return this.parent != null ? this.parent.getSeq() : -1L; }
}

entity에서 특징적인 부분만 살펴보자

게시물과 종속 관계에 있으니 post entity와 연관 관계를 표시해준다. 다대일 관계니 manyToOne

@ManyToOne(targetEntity = Post.class, cascade = CascadeType.ALL)
@JoinColumn(name="postSeq")
private Post post;

대댓글과 댓글의 관계 역시 다대일 연관 관계이므로

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="parent", referencedColumnName = "seq")
private Comment parent;

entity를 화면상으로 반환하기 전 편의를 위해 해당 댓글의 부모댓글의 seq, user 이름등을 포함하기 위한 메서드를 구성

public String parentName(){ return this.parent != null ? this.parent.getUser() : ""; }
public Long parentSeq() { return this.parent != null ? this.parent.getSeq() : -1L; }

comment Repository

@Repository
public interface CommentRepository extends JpaRepository<Comment, Long> {
    public List<Comment> findByPostOrderByREFAsc(Post post);
}

Post repository와 다르게 하나 선언된 메서드가 있는데 게시물에 딸린 댓글을 가져올 때 REF(댓글 그룹) 오름차순으로 가져오는 메서드다. 먼저 달린 댓글일수록 REF 값은 작게끔 구현이 되기 때문에 조회 시 해당 메서드를 사용한다.

 

comment service

@Service
public class CommentService {

    @Autowired
    private CommentRepository commentRepository;

    @Transactional
    public void saveComment(InsertCommentDto insertCommentDto) {
        commentRepository.save(insertCommentDto.toEntity());
    }

    public JSONArray getComments(Post post) {
        JSONArray comments = new JSONArray();
        commentRepository.findByPostOrderByREFAsc(post).forEach(comment -> {
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("seq", comment.getSeq());
            jsonObject.put("REF", comment.getREF());
            jsonObject.put("REFORDER", comment.getREFORDER());
            jsonObject.put("REFLEVEL", comment.getREFLEVEL());
            jsonObject.put("user", comment.getUser());
            jsonObject.put("content", comment.getContent());
            jsonObject.put("createAt", comment.getCreateAt());
            jsonObject.put("parentName", comment.parentName());
            jsonObject.put("parentSeq", comment.parentSeq());
            comments.put(jsonObject);
        });
        return comments;
    }

    public Comment getCommentBySeq(Long seq) {
        return commentRepository.findById(seq).orElse(null);
    }
}

save… 메서드는 이름 그대로 댓글을 저장하는 함수,
get… 메서드들은 이름 그대로 조건에 맞춰 댓글(들)을 가져오는 함수다.

 

save의 경우엔 InsertCommentDto를 통해 입력받고 있다.

@PostMapping("/commentInsert")
@ResponseStatus
public ResponseEntity<Comment> uploadComment(@RequestBody InsertCommentDto commentDto, 
HttpServletRequest request){
    String ip = request.getHeader("X-FORWARDED-FOR");
    if (ip == null)
        ip = request.getRemoteAddr();
    commentDto.setIp(ip);
    System.out.println(commentDto);

    Post postEntity = postService.getPostEntity(commentDto.getPostSeq());
    commentDto.setPost(postEntity);

    // parent 존재시
    if(commentDto.getParentSeq() != null){
        Comment comment = commentService.getCommentBySeq(commentDto.getParentSeq());
        commentDto.setParent(comment);
    }

    commentService.saveComment(commentDto);
    return ResponseEntity.status(200).build();
}

입력받을 때 ip값은 빠져있기에 ip 값을 추가해 주고,
parentSeq 값이 존재한다면, 부모댓글을 조회해 해당 entity 정보를 세팅한 뒤 저장해 준다.

 

insertCommentDto는 아래와 같다.

@Data
public class InsertCommentDto {

    private String user;
    private String content;
    private String ip;
    private int ref;
    private int refOrder;
    private int refLevel;
    private Long postSeq;
    private Post post;
    private Long parentSeq;
    private Comment parent;

    public Comment toEntity() {
        return Comment.builder()
                .user(this.user)
                .content(this.content)
                .ip(this.ip)
                .REF(this.ref)
                .REFORDER(this.refOrder)
                .REFLEVEL(this.refLevel)
                .post(this.post)
                .parent(this.parent)
                .build();
    }
}

위에서 설명하다 만 부분이 있다.

바로 게시물 상세 보기에서 댓글을 포함해 반환해야 한다고 말했는데

@GetMapping("/postDetail")
    public String getPostDetail(@RequestParam(name = "seq") Long seq, Model model) {
        Post post = postService.getPostEntity(seq);
        JSONArray comments = commentService.getComments(post); // 추가된 부분

        model.addAttribute("postDetail", post.getPostJson()); 
        model.addAttribute("comments", comments); // 추가된 부분
        return "detail";
    }

게시물 entity를 이용해 연관관계인 comment를 조회한다. 조회된 Comment 정보를 댓글로 반환한다.

구현 다하고 보니 골치 아픈 부분이 있었는데 가령 아래와 같은 데이터가 있을 때

[
    {"REFLEVEL":0,"REF":0,"parentName":"","parentSeq":-1,"REFORDER":0,"user":"익명4c587c","seq":34,"content":"첫번째 댓글","createAt":"2023-08-02T10:32:28"},
    {"REFLEVEL":1,"REF":0,"parentName":"익명4c587c","parentSeq":34,"REFORDER":0,"user":"익명5b982c","seq":35,"content":"대댓글?","createAt":"2023-08-02T10:32:35"},
    {"REFLEVEL":0,"REF":2,"parentName":"","parentSeq":-1,"REFORDER":0,"user":"익명9a1444","seq":36,"content":"두번째 댓글","createAt":"2023-08-02T10:33:12"},
    {"REFLEVEL":2,"REF":0,"parentName":"익명5b982c","parentSeq":35,"REFORDER":0,"user":"익명5b37af","seq":37,"content":"대대댓글","createAt":"2023-08-02T10:33:19"},
    {"REFLEVEL":2,"REF":0,"parentName":"익명5b982c","parentSeq":35,"REFORDER":0,"user":"익명f654ad","seq":38,"content":"대대댓글2","createAt":"2023-08-02T10:34:10"},
    {"REFLEVEL":3,"REF":0,"parentName":"익명5b37af","parentSeq":37,"REFORDER":0,"user":"익명d3c426","seq":39,"content":"대대대댓글","createAt":"2023-08-02T10:34:37"},
]

댓글의 순서는 아래와 같아야 한다. (가시성을 위해 몇몇 필드 제거)

{"REFLEVEL":0,"REF":0,"parentSeq":-1,"seq":34,"createAt":"2023-08-02T10:32:28"}
	{"REFLEVEL":1,"REF":0,"parentSeq":34,"seq":35,"createAt":"2023-08-02T10:32:35"}
		{"REFLEVEL":2,"REF":0,"parentSeq":35,"seq":37,"createAt":"2023-08-02T10:33:19"}
			{"REFLEVEL":3,"REF":0,"parentSeq":37,"seq":39,"createAt":"2023-08-02T10:34:37"}
		{"REFLEVEL":2,"REF":0,"parentSeq":35"seq":38,"createAt":"2023-08-02T10:34:10"}
{"REFLEVEL":0,"REF":2,"parentSeq":-1,"seq":36,"createAt":"2023-08-02T10:33:12"}

만들면서 헷갈린 부분이 바로 여기 있다.

REF 순으로 정렬까지는 ok, 이전에 STEP 필드값은 update가 너무 많이 일어나기 때문에 사용하지 않는다고 했다.

 

그럼 첫 번째 댓글 그룹 중에서 REF_STEP 값 없이 어떻게 정렬해야 할까?

시간 순으로 한다고 하면 이전 댓글(1-1)의 대댓글(1-1-1)이 (1-2)보다 나중에 달렸을 때 정렬 시

(1-1) → (1-2) → (1-1-1)로 정렬되어 대댓글이 잘못 달린 것처럼 보일 것이다.

 

seq 값 역시 시간 순과 마찬가지로 증가만 하기 때문에 정렬에 사용할 수 없고,

parentSeq (부모댓글의 seq)로 정렬하면 시간 순 정렬과 마찬가지로 부모댓글의 seq가 작은 순으로 정렬된다.

 

그래서 내가 쓴 트릭은 이렇다.

한 가지 가정이 들어간다.
(게시물에 댓글은 많이 달리지 않을 거다… 한 200개 정도로?)

 

dfs를 사용하는 거다.

우선 parentSeq가 -1인 댓글을 순회하며 찾는다. (게시물에 달린 댓글 = c)

해당 댓글들에 parentSeq가 위 댓글들의 seq인 댓글을 순회하면서 찾는다. (c에 대댓글 = c’)

대댓글의 대댓글을 재귀의 형태로 찾아 내려간다.

위 로직을 구현하면 아래와 같다.

댓글의 순서는 (REF_, parentSeq, seq) 값으로 결정

[(0, -1, 1), (0, 1, 2), (0, 1, 3), (0, 2, 5), (1, -1, 4)] 일때

1. parentSeq = -1 인 댓글을 찾는다. 게시물에 직접 달린 댓글이므로
[ (0, -1, 1), (1, -1, 4) ]

2. 해당 댓글들의 seq를 parentSeq로 가진 댓글을 찾는다. 
먼저 (0, -1, 1) 의 대댓글은 
[ (0, 1, 2), (0, 1, 3) ] 이렇게 두개다.

3. 2번에서 찾은 댓글의 대댓글을 찾는다.
(0, 1, 2) 의 대댓글은 
[ (0, 2, 5) ] 이다. 해당 댓글의 대댓글은 없으므로 3번 과정이 종료.
해당 댓글을 반환한다.

4. 3번 과정에서 반환한 값 [ (0, 2, 5) ]을 부모댓글 뒤에 넣어준다. 
[ (0, 1, 2), (0, 2, 5), (0, 1, 3) ]

5. 2번 과정에서 반환한 값 [ (0, 1, 2), (0, 2, 5), (0, 1, 3) ]을 부모댓글 뒤에 넣어준다.
[ (0, -1, 1), (0, 1, 2), (0, 2, 5), (0, 1, 3), (1, -1, 4) ]

정렬된 순서대로 화면에 보여준다.
(0, -1, 1)
	(0, 1, 2)
		(0, 2, 5)
	(0, 1, 3)
(1, -1, 4)

서버에서는 게시물에 딸린 댓글을 댓글 그룹순으로 전송해 준다. 정렬 없이!

전송된 댓글과 대댓글은 위 로직(dfs) 의 형태로 재정렬 된다.

let comments =
[
    {"REFLEVEL":0,"REF":0,"parentName":"","parentSeq":-1,"REFORDER":0,"user":"익명4c587c","seq":34,"content":"첫번째 댓글","createAt":"2023-08-02T10:32:28"},
    {"REFLEVEL":1,"REF":0,"parentName":"익명4c587c","parentSeq":34,"REFORDER":0,"user":"익명5b982c","seq":35,"content":"대댓글?","createAt":"2023-08-02T10:32:35"},
    {"REFLEVEL":0,"REF":2,"parentName":"","parentSeq":-1,"REFORDER":0,"user":"익명9a1444","seq":36,"content":"두번째 댓글","createAt":"2023-08-02T10:33:12"},
    {"REFLEVEL":2,"REF":0,"parentName":"익명5b982c","parentSeq":35,"REFORDER":0,"user":"익명5b37af","seq":37,"content":"대대댓글","createAt":"2023-08-02T10:33:19"},
    {"REFLEVEL":2,"REF":0,"parentName":"익명5b982c","parentSeq":35,"REFORDER":0,"user":"익명f654ad","seq":38,"content":"대대댓글2","createAt":"2023-08-02T10:34:10"},
    {"REFLEVEL":3,"REF":0,"parentName":"익명5b37af","parentSeq":37,"REFORDER":0,"user":"익명d3c426","seq":39,"content":"대대대댓글","createAt":"2023-08-02T10:34:37"},
]

let parents = [];
comments.forEach((comment) => {
  if(comment.parentSeq == -1) parents.push(comment);
});

function dfs(orgComment, comments) {
  let result = [orgComment];
  for(let i = 0; i < comments.length; i++) {
    let comment = comments[i];
    if(orgComment.seq == comment.parentSeq) {
      // result.push(comment);
      let children = dfs(comment, comments.slice(i, comments.length));
      result = result.concat(children);
    }
  }
  return result;
}

let result = [];
for(let comment of parents) {
  let children = dfs(comment, comments);
  result = result.concat(children);
}

화면을 그리는 html 은 아래와 같다.

<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%> <%@ taglib prefix="c"
uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html lang="ko">
  <head>
    <title>게시판</title>
    <script src="https://code.jquery.com/jquery-3.7.0.min.js" integrity="sha256-2Pmvv0kuTBOenSvLm6bvfBSSHrUJ+3A7x6P5Ebd07/g=" crossorigin="anonymous"></script>
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
    <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/twbs-pagination/1.4.2/jquery.twbsPagination.min.js"></script>
  </head>
  <body>
    <header>
        <ul>
            <li><a href="/postBoard">글목록</a></li>
            <li><a href="writePost">글작성</a></li>
        </ul>
    </header>
    <div class="cont">
        <h3>게시글</h3>
        <section width="500px" height="500px">
            <dt>제목</dt>
              <dd>
                  <div id="title">
                  </div>
              </dd>
              <dt>내용</dt>
                <dd>
                    <div id="content">
                    </div>
                </dd>
            <dt>작성자</dt>
              <dd>
                <div id="user">
                </div>
              </dd>
            <dt>작성시간</dt>
              <dd>
                <div id="createAt">
                </div>
              </dd>
        </section>
        <section>
            <h6>댓글 등록</h6>
            <dt>작성자</dt>
            <dd><input id="commentUser"/></dd>
            <dt>댓글</dt>
            <dd><input id="commentContent"/></dd>
            <dd><input type="button" id="commentSubmit" value="댓글등록"/></dd>
        </section>
        <section>
            <h6>댓글 목록</h6>
            <div id="commentList">
            </div>
            <div id="pagination-div" ></div>
        </section>
    </div>
  </body>
    <script type="text/javascript">
        var postDetail = ${postDetail};
        var comments = ${comments};
    </script>
  <script
  type="text/javascript"
  src="${pageContext.request.contextPath}/resources/js/detail.js"></script>
</html>

구현화면은 아래와 같다.

게시물의 댓글을 다는 부분과 댓글 별로 대댓 작성 버튼이 달려있고, 대댓 작성 버튼을 누를 경우,

대댓글 작성 부분이 등장해 대댓글을 작성할 수 있게 해 준다.
댓글의 깊이가 같은 레벨일 때는 시간 오름차순으로 정렬해 보여주게 된다.

 

모든 코드는 아래의 깃허브에

https://github.com/hoonzinope/SprintBootStudy/tree/main/demoBoard

 

반응형