MySQL을 이용한 댓글 계층 구조 구현(댓글, 대댓글)

HanSH·2024년 12월 27일

NestJS

목록 보기
28/29

토이 프로젝트 도중 댓글 계층화가 필요하였습니다. 댓글에는 답글이 있고 그 답글에는 또 다른 답글이 있고...
계층 구조가 절실하여 구축해보았습니다!

이 블로그를 참고하였습니다


들어가기에 앞서...

  1. 단순히 parent id만 있으면 될것 같은데요?
    이게 쉽지 않습니다. 댓글 전체를 가져올건 아니잖아요? 댓글에서 페이지네이션은 거의 강제라고 봐야합니다.
    subquery를 쓰든 해서 가져와야 하는데 전부 가져온 후 skip, take를 하는 방식이라 조금 힘듭니다.
    재귀 형식도 생각했으나, 구현의 한계로 인해 포기하였습니다.

  2. 그냥 nosql 쓰면 안되나요?
    firebase realtime database는 외부에 존재하는 cloud service다보니 응답이 느립니다. 요즘 상어가 물어뜯는지 해외 회선이 좋지 않던데...
    상어 해저케이블 아그작

    mongo db는 써본적이 없고, 지금 당장 구현해야하는 기능이라 배우는데 시간이 조금 걸릴 것 같아 도입을 하지 않았습니다.

구현 요구사항

구현하고자 하는 내용은 아래와 같습니다.

  1. 댓글 삽입
    a. 댓글 삽입
    b. 대댓글 삽입
  2. 댓글 삭제
  3. 댓글 수정
  4. 댓글 조회

댓글 삽입

댓글 삽입

이 부분은 흔히 하는 댓글 삽입과 같게 넣으면 됩니다. parentId를 신경 쓸 필요 없고 댓글 계층 구조도 신경쓸 필요 없고... 참 편하죠

대댓글 삽입

이 부분은 간단해보이기만 합니다.
하지만 조회 시 댓글 - 대댓글 구조를 가져야 하기에 이를 고려해야합니다.

조회 시 아래와 같은 결과를 가지고싶습니다. 이때 각 댓글은 생성 시각이 제각각이기 때문에 id가 천차만별인 것을 볼 수 있습니다.
이를 고려하여 삽입 로직을 작성하여야 합니다.

1					id: 1
1-1					id: 10
1-1-1				id: 14
1-1-1-1				id: 22
1-1-2				id: 15
1-2					id: 13
2					id: 3
2-1					id: 17
2-1-1				id: 30
2-2					id: 21
3					id: 31
3-1					id: 34

댓글 삭제

이 부분은 2가지로 나뉩니다. 서비스 로직에 따라 다르겠지만, 토이 프로젝트에서는 대댓글이 있는 경우에는 삽입만 불가능하게 한다. 삭제한다면 내용만 삭제되게 한다 를 로직으로 삼았습니다.

  1. 대댓글이 있는 경우
  2. 대댓글이 없는 경우

각 항목을 자세히 살펴봅시다.

대댓글이 없는 경우

이 부분은 간단합니다. 댓글 자체를 삭제해버리면 됩니다.
댓글 좋아요가 있다면 해당 row까지 cascade 해버리면 되는 부분입니다.

algorithm remove(comment):
	1. remove comment
    1.1 comment like entity는 cascade 됩니다.

대댓글이 있는 경우

서비스 로직을 대댓글이 있는 경우 내용만 삭제되게 한다로 설정하였습니다. 이에 맞게 구현하여야 합니다.

algorithm remove(comment):
	1. comment와 관련된 comment like entity를 삭제합니다.
    2. comment의 내용을 ""로 설정합니다.

댓글 수정

수정할 부분은 댓글 내용 하나 뿐입니다. 신경 쓸 부분은 없습니다. 가장 간단한 부분이네요!

댓글 조회

1					id: 1
1-1					id: 10
1-1-1				id: 14
1-1-1-1				id: 22
1-1-2				id: 15
1-2					id: 13
2					id: 3
2-1					id: 17
2-1-1				id: 30
2-2					id: 21
3					id: 31
3-1					id: 34

위와 같은 형식으로 받아와야 합니다. 정렬과 pagenation을 이용하여 가져오는 방식을 고민해봅시다.

요구사항을 만족시키기 위하여...

  1. parent id 사용
  2. grouping 및 sequence 사용
    이 경우 group간 정렬이 가능하여 group1의 1~10, group2의 1~5 순서의 값을 명확한 순서로 가져올 수 있습니다.

parentId는 현재 댓글의 sequence 계산을 위해, grouping 및 sequence는 정렬을 위해 사용합니다.

이를 바탕으로 entity를 생성해봅시다.

@Entity()
export class Comment extends CommunityBaseEntity {
  // 댓글 내용
  @Column({ length: 1000 })
  content: string;

  // 부모의 id를 설정. index를 통해 쿼리 성능 향상을 꾀함
  @Index()
  @Column({ default: -1 })
  parentId: number;

  // 이 group을 바탕으로 어느 그룹에 속하는지 판단
  @Index()
  @Column({ default: 1 })
  groupId: number;

  // 댓글의 깊이 설정
  // 계층 구조로 주지 않아도 프론트에서 계층으로 구현 가능
  @Column({ default: 0 })
  depth: number;

  // 특정 그룹에서 몇 번째 위치에 있는지 파악
  @Column({ default: 1 })
  sequence: number;

  @Column({ default: 0 })
  childCount: number;

  @Column({ default: false })
  isDeleted: boolean;

  @ManyToOne(() => Article, article => article.comments, { onDelete: 'CASCADE' })
  @JoinColumn()
  article: Article;

  @ManyToOne(() => User, user => user.comments)
  @JoinColumn()
  user: User;

  @OneToMany(() => CommentLike, like => like.comment, { cascade: ['remove'] })
  likes: CommentLike[];
}

이를 바탕으로 조회하는 로직을 파악해보죠!

1			id: 1   group: 1  sequence: 1  depth: 0
1-1			id: 10  group: 1  sequence: 2  depth: 1
1-1-1		id: 14  group: 1  sequence: 3  depth: 2
1-1-1-1		id: 22  group: 1  sequence: 4  depth: 3
1-1-2		id: 15  group: 1  sequence: 5  depth: 2
1-2			id: 13  group: 1  sequence: 6  depth: 1
2			id: 3   group: 2  sequence: 1  depth: 0
2-1			id: 17  group: 2  sequence: 2  depth: 1
2-1-1		id: 30  group: 2  sequence: 3  depth: 2
2-2			id: 21  group: 2  sequence: 4  depth: 1
3			id: 31  group: 3  sequence: 1  depth: 0
3-1			id: 34  group: 3  sequence: 2  depth: 1

구현

댓글 작성

craete 메서드 하나를 통해 댓글과 대댓글을 처리하게 하였습니다.

  async create(user: User, createCommentDto: CreateCommentDto) {
    const articleId: number = +createCommentDto.articleId;
    delete createCommentDto.articleId;
    const parentId: number = +createCommentDto.parentId;
    delete createCommentDto.parentId;

    return parentId
      ? await this.createSubComment(user, articleId, createCommentDto.content, parentId)
      : await this.createComment(user, articleId, createCommentDto.content);
  }

root 댓글

흔히 아는 댓글 삽입에 group id만 설정하는 로직만 추가되었습니다.

  async createComment(user: User, articleId: number, content: string) {
    const article: Article = await this.articleService.findOne(articleId);
    const comment: Comment = new Comment();

    const sequence: number = 1;

    return await this.commentRepository.manager.transaction(async manager => {
      const groupId = ((await manager.maximum(Comment, 'groupId', { article: { id: articleId } })) ?? 0) + 1;

      Object.assign(comment, {
        content,
        groupId,
        sequence,
        childCount: 0,
        article: article,
        user: user,
      } as Comment);
      return await manager.save(comment);
    });
  }

대댓글

sequence 증가 로직을 빼고는 거의 같습니다!
대부분의 값을 부모 댓글의 값을 이용합니다.

주의!
코드부분에는 sequence 계산하는 부분만 작성하였습니다. sequence update 하는 부분과 save 하는 부분은 다 알것이라 생각합니다.

1			id: 1   group: 1  sequence: 1  depth: 0
1-1			id: 10  group: 1  sequence: 2  depth: 1
1-1-1		id: 14  group: 1  sequence: 3  depth: 2
1-1-1-1		id: 22  group: 1  sequence: 4  depth: 3
1-1-2		id: 15  group: 1  sequence: 5  depth: 2
1-2			id: 13  group: 1  sequence: 6  depth: 1
2			id: 3   group: 2  sequence: 1  depth: 0
2-1			id: 17  group: 2  sequence: 2  depth: 1
2-1-1		id: 30  group: 2  sequence: 3  depth: 2
2-2			id: 21  group: 2  sequence: 4  depth: 1
3			id: 31  group: 3  sequence: 1  depth: 0
3-1			id: 34  group: 3  sequence: 2  depth: 1

여기서 1-1-3을 넣을때는 값이 어떻게 될까요?
group: 1, sequence: 6, depth: 2 가 되겠죠.
그리고 1-2는 sequence: 7으로 되겠군요!

1-1-1-1-1이 들어간다면요?
1-1-2는 id: 15 group: 1 sequence: 6 depth: 2
1-2는 id: 13 group: 1 sequence: 7 depth: 1
으로 바뀌어야됩니다!

아래는 삽질했던 내용입니다.

  async createSubComment(user: User, articleId: number, content: string, parentId: number) {
    await this.commentRepository.manager.transaction(async manager => {
      const parent: Comment = await manager.findOneByOrFail(Comment, { id: parentId });
      
      // 1. 매우 간단한 로직입니다.
      //    하지만 문제가 있습니다. 아래의 계층에서 1-2를 넣는다면 어떻게 될까요?
      //    1-2의 sequence는 3이 됩니다...
      //
      //    value   sequence  childCount  parentId
      //    1		  1			1          -1
      //    1-1       2 	    1          1
      //    1-1-1     3			1          2
      //    1-1-1-1   4			0          3
      const sequence: number = parent.sequence + parent.childCount + 1;
        
      // 2. 해결책 모색
      //    자식 중 가장 큰 sequence 값을 가져온다! <- 이 경우도 마찬가지입니다.
      //    컬럼이 하나 줄어들긴 했네요! 그걸로 일단 만족합시다.
      //    추가로, 아래의 경우에는 1에서는 발생하지 않았던 문제가 발생합니다.
      // 
      //    1-2를 넣는 경우 sequence가 3이 됩니다.
      //    value   sequence  parentId
      //    1-1       2        1
      //    1-1-1     3        2
      const maxSequence = await manager.maximum(Comment, 'sequence', { parentId });
      const sequence = maxSequence ? maxSequence + 1 : parent.sequence + 1;
      
      // 3. recursive 하게 작성해보자
      //    depth가 증가하면 N+1이 발생하지만... 다른 방법이 없습니다.
      //    새로운 code block으로 이동합시다!
      ...
    });
  }

해결 방법

async getMaxSequence(manager: EntityManager, parentId: number): Promise<number> {
  if (!parentId) return 0;

  const result = await manager.findOne(Comment, {
    where: { parentId: parentId },
    order: { id: 'DESC' },
  });

  if (!result) return 0;

  const childMaxSequence = await this.getMaxSequence(manager, result.id);
  return Math.max(result.sequence, childMaxSequence);
}

async getMaxSequenceByCTE(manager: EntityManager, parentId: number): Promise<number> {
  const result = await manager.query(
    `
    WITH RECURSIVE comments_tree AS (
        SELECT id, sequence, parentId 
        FROM comment
        WHERE parentId = $1
        
        UNION ALL
        
        SELECT c.id, c.sequence, c.parentId
        FROM comment c
        JOIN comments_tree ct ON c.parentId = ct.id
    )
    SELECT MAX(sequence) as max_sequence 
    FROM comments_tree
`,
    [parentId],
  );
  return result[0]?.max_sequence || 0;
}

async createSubComment(user: User, articleId: number, content: string, parentId: number) {
  await this.commentRepository.manager.transaction(async manager => {
    // 1. 재귀 호출을 통한 max sequence 검색
    //    N+1 문제 및 db에 여러 번 connection 해야하여 속도가 느리다
    const maxSequence = await this.getMaxSequence(manager, parentId);
    const sequence = Math.max(maxSequence, parent.sequence) + 1;
    
    // 2. CTE를 통한 max sequence 검색
    //    한 번의 쿼리를 보내기 때문에 성능이 우수하지만 분석하기가 어렵다
    const maxSequence = await this.getMaxSequenceByCTE(manager, parentId);
    const sequence = Math.max(maxSequence, parent.sequence) + 1;
  });
}

삭제

로직이 간단하여 하나로 합쳤습니다.
다른 댓글의 parent id가 삭제하려는 댓글의 id와 같은게 있다면 삭제하지 않고 값을 지우는 방향으로 작성하였습니다.

    await this.commentRepository.manager.transaction(async manager => {
      const counts: number = await manager.countBy(Comment, { parentId: comment.id });
      if (counts === 0) {
        await manager.remove(comment);
      } else {
        comment.isDeleted = true;
        comment.content = '삭제된 댓글입니다';
        comment.user = null;
        await manager.save(comment);
      }
    });

수정

다른걸 신경쓸 필요가 없습니다. content와 updatedAt만 수정하면 됩니다.

    Object.assign(comment, {
      ...updateCommentDto,
      updatedAt: new Date(),
    } as Comment);
    return await this.commentRepository.save(comment);

조회

조금 긴데 뜯어보면 간단합니다.
group id를 기준으로 먼저 오름차순 정렬하고 sequence를 기준으로 오름차순 정렬 합니다.
이후 pagenation을 이용하여 값을 가져옵니다.

  async pagenatedComments(articleId: number, page: number, take: number) {
    const comments: object[] = await this.commentRepository
      .createQueryBuilder('c')
      .select([
        'c.id as id',
        'c.content as content',
        'c.depth as depth',
        'c.createdAt as createdAt',
        'c.updatedAt as updatedAt',
      ])
      .where('c.articleId = :articleId', { articleId })
      .groupBy('c.id')
      .orderBy('c.groupId', 'ASC')
      .addOrderBy('c.sequence', 'ASC')
      .skip((page - 1) * take)
      .take(take)
      .getRawMany();

    return comments.map(comment => new CommentDetailDto(comment));
  }

후기

다른 좋은 방법이 있을 것이라 생각됩니다. 더 효율적인 방법이 있을 수 있겠죠.
하지만 일단 구현을 해야하니 도입을 하였습니다.
로직을 짜고 보니 조금 좋지 않은 부분이 보이긴 하네요...

profile
저는 말하는 싹 난 감자입니다

0개의 댓글