코딩과 결혼합니다

Aggregation으로 수많은 쿼리를 한방 쿼리로. 본문

2세/Nest.js

Aggregation으로 수많은 쿼리를 한방 쿼리로.

코딩러버 2024. 9. 2. 16:38
728x90

아주 복잡하고 보기 싫은 내 코드.. ㅎㅎㅎ

  /**
   * 게시물 전체 조회 + 페이징
   */
  async getPosts(
    page: number = 1,
    limit: number = 10,
    category: Category
  ): Promise<ResponsePostsDto> {
    //카테고리 값 검증
    this.validateCategory(category);

    const currentPage = page || 1;
    const pageSize = limit || 10;
    const skip = (currentPage - 1) * pageSize;

    const query = { category };

    const posts = await this.postRepository.findWithPagination(
      query,
      skip,
      pageSize
    );

    // 게시물 DTO 변환
    const responsePosts = await Promise.all(
      posts.map(async (post) => {
        const empathyCount = await this.postEmpathyService.getEmpathyCount(
          post._id.toString()
        );

        const images = category === "review" ? post.images : [post.images[0]];
        const profileImg =
          post.memberId?.profileImg || process.env.DEFAULT_PROFILE_IMAGE_URL;

        // 댓글 수 조회
        const comments = await this.commentService.findCommentsByPost(post);
        const totalCommentCount = comments.reduce(
          (total, comment) => total + (comment.commentCount || 0),
          0
        );

        return new postinfo(
          post._id.toString(),
          post.nickname,
          post.title,
          post.text,
          images,
          post.createdAt,
          post.ip,
          profileImg,
          empathyCount,
          totalCommentCount
        );
      })
    );

    return new ResponsePostsDto(
      SMESSAGES.RETRIEVED_SUCCESSFULLY,
      category,
      responsePosts
    );
  }

 

전체 조회를 하면서 각 포스트의 공감 수와, 총 댓글 수를 함께 보여주며 문제가 생겼다.

물론 프런트에서 정보들을 조합할 수도 있고, 방법은 여러 가지였지만

프런트 작업하시는 분도 나와 같은 아직은 귀엽고 부족한 인턴이기에 다른 일에 허덕이고 계셔서 😂

일단 기간을 맞추고 이후에 상의를 통해 더 좋은 방향을 찾아보기로 하였다.

 

일단 상세조회를 하면 그 포스트에 딸린 댓글들이 나오고,

그 댓글들에서 더 보기를 누르면 sub댓글들이 나오는 형식이다. (댓글, 대댓글 스키마 분리)

 

총 댓글 수를 셀 때마다 일일이 댓글, sub 댓글을 세어볼 수는 없는 것이기에 sub댓글이 등록될 때마다 

  async updateCommentCount(commentId: string): Promise<void> {
    await this.commentModel.findByIdAndUpdate(commentId, {
      $inc: { commentCount: 1 }
    }).exec();
  }

이렇게 commentCount가 1씩 올라가게 하였다.

그렇게 post에서는 각 comment의 commentCount들을 더해서 총 댓글 수를 구하는 것이다.

 

  • 포스트 전체조회
  • 각 포스트에 달린 댓글을 조회
  • 그 댓글들의 commentCount 조회
  • commentCount를 모두 더해줌
  • totalCommentCount 반환

이런 순서인데,,, 그렇다,, 너무 별로다. 별로인걸 알지만 일단 고!!!!!!!!!해보았다.


전체 조회를 할 때마다 저렇게 하나하나 쿼리가 나가고 있다. 

postMan으로 응답까지 걸리는 시간을 확인해 보았을 때에는 223ms 


나는 이전에 jpa를 공부했을 때 배웠던 fetch join을 떠올렸다. 

바로 한 방쿼리로 데이터를 가져오는 것.

 

nest.js도 그런 기능이 있지 않을까? 싶었는데, 있었다. 바로 Aggregation 

 

  /**
   * 게시물 전체 조회 + 페이징
   */
  async getPosts(
    page: number = 1,
    limit: number = 10,
    category: Category
  ): Promise<ResponsePostsDto> {
    //카테고리 값 검증
    this.validateCategory(category);

    const currentPage = page || 1;
    const pageSize = limit || 10;
    const skip = (currentPage - 1) * pageSize;

    const query = { category };

    const posts = await this.postRepository.findWithAggregation(
      query,
      skip,
      pageSize
    );

    const responsePosts = posts.map((post) => new postinfo(
      post._id.toString(),
      post.nickname,
      post.title,
      post.text,
      post.images,
      post.createdAt,
      post.ip,
      post.profileImg,
      post.empathyCount,
      post.commentCount
    ));

    return new ResponsePostsDto(
      SMESSAGES.RETRIEVED_SUCCESSFULLY,
      category,
      responsePosts
    );
  }

위는 서비스 코드 아래는 repository 코드이다.

async findWithAggregation(
        query: any,
        skip: number,
        limit: number
      ): Promise<any[]> {
        return this.postModel.aggregate([
          { $match: query }, // 쿼리로 필터링
          { $skip: skip }, // 페이징 처리
          { $limit: limit }, // 페이지 크기
          {
            $lookup: {
              from: 'comments', // comments 컬렉션과 조인
              localField: '_id', // posts의 _id 필드
              foreignField: 'post', // comments의 post 필드
              as: 'comments' // 결과를 comments 배열로
            }
          },
          {
            $lookup: {
              from: 'empathies', // empathies 컬렉션과 조인
              localField: '_id', // posts의 _id 필드
              foreignField: 'postId', // empathies의 postId 필드
              as: 'empathies' // 결과를 empathies 배열로
            }
          },
          {
            $addFields: {
              commentCount: { $size: '$comments' }, // 댓글 수 계산
              empathyCount: { $size: '$empathies' } // 공감 수 계산
            }
          },
          {
            $project: {
              title: 1,
              text: 1,
              commentCount: 1,
              empathyCount: 1,
              images: 1,
              createdAt: 1,
              ip: 1,
              profileImg: { $ifNull: ['$memberId.profileImg', process.env.DEFAULT_PROFILE_IMAGE_URL] } // 프로필 이미지
            }
          }
        ]).exec();
      }

코드가 좀 길지만... 

이렇게 한 방의 쿼리로 데이터들을 가져왔다.

시간도  223ms에서 40ms로 줄일 수 있었다.


Aggregation Pipeline

데이터베이스에서 필요한 데이터를 한 번의 쿼리로 집계할 수 있다.

애플리케이션이 여러 번의 쿼리를 실행할 필요가 없으므로 서버의 부담이 줄어든다.

 

Aggregation을 사용하여 댓글 수와 공감 수 같은 집계 데이터를 서버에서 직접 계산하여 프런트엔드에서 별도로 추가 요청을 보내지 않아도 되고, 데이터의 일관성을 유지한다.


캐싱, 프런트에서 데이터 조합 그리고 스키마를 변경하는 등 여러 방법이 있었다. 일단은 이렇게 최적화를 해보았으나, 프로젝트의 규모나 상황에 따라서 프런트분과 상의를 통해 가장 최선의 방법이 있다면 그 방법을 채택해야겠다.