[MindDiary] 이슈 6. 게시글 목록 조회 시 일대다 관계의 컬렉션 데이터를 가져오는 문제

Dayeon myeong·2021년 9월 29일
0

Mind Diary

목록 보기
6/10

게시글 목록 조회 API 시 게시글 정보 뿐 아니라 게시글에 등록된 여러 태그들과 여러 미디어자료(사진 등)을 같이 가져와야 합니다. 이러한 일대다 관계의 모든 데이터를 한번에 가져오면서 중복된 데이터가 생기고, 쿼리 결과 row 수가 늘어납니다. 결국 응답 결과 데이터가 늘어나는 문제가 발생합니다.

테이블 관계로 살펴 보자면

  • 게시글 : 미디어 = 1 : N
  • 게시글 : 태그 = 1 : N
//Post.class
public class Post {

  ...
  private List<PostMedia> postMedias = new ArrayList<>(); //여러 미디어 자료
  private List<PostTag> postTags = new ArrayList<>(); // 여러 태그들

위처럼 일대다 관계를 가지고 있으며, 데이터는 List 컬렉션에 담기게 됩니다

문제가 되는 mybatis 코드

//post.xml
<select id="findHotPosts" parameterType="com.mindDiary.mindDiary.entity.PageCriteria" resultMap="post">
    SELECT
      p.id AS 'post_id',
      p.user_id AS 'post_user_id',
      u.nickname AS 'post_writer',
      p.created_at AS 'post_created_at',
      p.title AS 'post_title',
      p.content AS 'post_content',
      p.visit_count AS 'post_visit_count',
      p.like_count AS 'post_like_count',
      p.hate_count AS 'post_hate_count',
      p.reply_count AS 'post_reply_count',
      pm.id AS 'post_media_id',
      pm.type AS 'post_media_type',
      pm.url AS 'post_media_url',
      t.id AS 'tag_id',
      t.name AS 'tag_name'
    FROM (SELECT * FROM post ORDER BY visit_count DESC LIMIT #{pageStart},#{perPageNum}) AS p
    LEFT OUTER JOIN post_media pm ON pm.post_id = p.id
    LEFT OUTER JOIN post_tag pt ON pt.post_id = p.id
    LEFT OUTER JOIN tag t ON t.id = pt.tag_id
    LEFT OUTER JOIN user u ON u.id = p.user_id
  </select>

<resultMap id="post" type="com.mindDiary.mindDiary.entity.Post">
    <id property="id" column = "post_id"/>
    <result property="userId" column = "post_user_id"/>
    <result property="writer" column="post_writer"/>
    <result property="createdAt" column="post_created_at"/>
    <result property="title" column="post_title"/>
    <result property="content" column="post_content"/>
    <result property="visitCount" column="post_visit_count"/>
    <result property="likeCount" column="post_like_count"/>
    <result property="hateCount" column="post_hate_count"/>
    <result property="replyCount" column="post_reply_count"/>
    <collection property="postMedias" resultMap="postMedia"/>
    <collection property="tags" resultMap="tag"/>
  </resultMap>

  <resultMap id="postMedia" type="com.mindDiary.mindDiary.entity.PostMedia">
    <id property="id" column="post_media_id"/>
    <result property="type" column="post_media_type"/>
    <result property="url" column="post_media_url"/>
  </resultMap>

  <resultMap id="tag" type="com.mindDiary.mindDiary.entity.Tag">
    <id property="id" column="tag_id"/>
    <result property="name" column="tag_name"/>
  </resultMap>

mybatis 의 collection 태그를 이용하여 모든 데이터를 한번의 쿼리 호출로 다 가져옵니다.

mybatis에서는 collection이나 association 태그를 사용할 때 select 속성을 사용하는 경우, N+1문제가 발생할 수 있어서 collection을 마치 resultMap과 같은 방식으로 사용하면 N+1문제 없이 한번의 쿼리로 연관 데이터를 조회할 수 있습니다.

실제로 위 쿼리문을 수행한 결과입니다. 게시글의 id가 42번인 게시글을 살펴보면 태그와 미디어 데이터가 늘어날수록 불필요한 중복 데이터가 생기며, row의 수가 증가합니다.

만약 하나의 게시글을 조회하는 경우에는 위와 같은 쿼리로 한번에 데이터를 받는 것이 괜찮을 수 있지만, 여러 게시글 목록을 조회하는 경우에는 문제가 될 수 있다고 생각합니다.

문제 해결 과정

데이터양이 늘어나는 주요한 원인은 게시글과 일대다 관계를 가지는 태그와 미디어입니다.

따라서 태그와 미디어 데이터를 따로 가져오는 로직을 작성했습니다.

  • 게시글 리스트만 가져오기 : postRepository.findHotPosts(pageCriteria)
  • 게시글의 관련 태그를 모두 가져오기 : findPostMediaMap(postIds)
  • 게시글의 관련 미디어 자료를 모두 가져오기 : findPostTagMap(postIds)
//postServiceImpl.class
List<Post> posts = postRepository.findHotPosts(pageCriteria);

//post.xml
  <select id="findHotPosts" parameterType="com.mindDiary.mindDiary.entity.PageCriteria" resultMap="post">
    SELECT
      p.id AS 'post_id',
      p.user_id AS 'post_user_id',
      u.nickname AS 'post_writer',
      p.created_at AS 'post_created_at',
      p.title AS 'post_title',
      p.content AS 'post_content',
      p.visit_count AS 'post_visit_count',
      p.like_count AS 'post_like_count',
      p.hate_count AS 'post_hate_count',
      p.reply_count AS 'post_reply_count'
    FROM post p
    INNER JOIN user u ON u.id = p.user_id
    ORDER BY p.id DESC LIMIT #{pageStart},#{perPageNum}
  </select>

먼저 게시글 리스트를 가져옵니다. 이 때 다대일 관계에 있는 user를 join해도 row의 수는 증가하지 않고, 중복데이터는 발생하지 않기 때문에 user와의 join을 사용해도 괜찮습니다.

이후 모든 게시글의 id를 리스트로 묶어 태그 조회와 미디어 조회시에 활용합니다.

//postServiceImpl.class
 private List<Integer> toPostIds(List<Post> posts) {
    return posts.stream()
        .map(p -> p.getId())
        .collect(Collectors.toList());
  }

두번째로, 게시글 미디어 관련 조회입니다. 미디어 관련 조회는 모든 게시글 id List를 findPostMediaMap에 전달하여 <postId, PostMedia List > 형태의 Map을 반환합니다.
Map으로 반환하는 이유는 이후 Post 엔티티에 PostMedia List를 넣어줄 때 게시글의 id만으로 바로 값을 가져올 수 있기 때문입니다.

//postServiceImpl.class
Map<Integer, List<PostMedia>> postMediaMap = findPostMediaMap(postIds);
//postServiceImpl.class
  private Map<Integer, List<PostMedia>> findPostMediaMap(List<Integer> postIds) {
    return postMediaService
        .findAllByPostIds(postIds)
        .stream()
        .collect(Collectors.groupingBy(PostMedia::getPostId));
  }
//postMedia.xml
 <select id = "findAllByPostIds" resultType="com.mindDiary.mindDiary.entity.PostMedia">
    SELECT *
    FROM post_media
    WHERE post_id IN
    <foreach item="item" index="index" collection="list" open="(" separator="," close=")">
      #{item}
    </foreach>
  </select>

위와 같은 쿼리문으로 postId 리스트가 IN 절에 한번에 들어가서 중복되는 데이터 없이 미디어 관련 데이터를 가져올 수 있습니다.

그런데 만약 IN 절에 들어가는 데이터 크기가 큰 경우가 발생할 수 있습니다.
따라서 mybatis 설정에 배치 사이즈를 설정하여 요청 데이터가 한번에 100개씩만 들어가도록 설정했습니다.

//mybatis-config.xml
<configuration>
  <settings>
    <setting name="defaultFetchSize" value="100"/>
  </settings>
</configuration>

배치 사이즈를 100으로 설정하여 쿼리에 들어가는 데이터 개수를 조절할 수 있습니다.만약 200개의 데이터를 조회한다면 100개씩 데이터가 쪼개져서 2번 쿼리가 수행됩니다.

//100개 수행
SELECT *
    FROM post_media
    WHERE post_id IN (? , ? ,,, 100개)

//나머지 100개 수행
SELECT *
    FROM post_media
    WHERE post_id IN (? , ? ,,, 100개)

위와 같은 로직으로 동일하게 태그 관련 코드도 작성했습니다.

리팩토링 후 코드

//PostServiceImpl.class
  @Override
  public List<Post> readHotPosts(int pageNumber) {

    PageCriteria pageCriteria = new PageCriteria(pageNumber);
    List<Post> posts = postRepository.findHotPosts(pageCriteria);
    List<Integer> postIds = toPostIds(posts);

    Map<Integer, List<PostMedia>> postMediaMap = findPostMediaMap(postIds);
    Map<Integer, List<PostTag>> postTagMap = findPostTagMap(postIds);

    return posts.stream()
        .map(post -> Post.create(
            post,
            postMediaMap.get(post.getId()),
            postTagMap.get(post.getId())))
        .collect(Collectors.toList());
  }
  
  
  private Map<Integer, List<PostTag>> findPostTagMap(List<Integer> postIds) {
    return postTagService
        .findAllByPostIds(postIds)
        .stream()
        .collect(Collectors.groupingBy(PostTag::getPostId));
  }


  private Map<Integer, List<PostMedia>> findPostMediaMap(List<Integer> postIds) {
    return postMediaService
        .findAllByPostIds(postIds)
        .stream()
        .collect(Collectors.groupingBy(PostMedia::getPostId));
  }
  
  private List<Integer> toPostIds(List<Post> posts) {
    return posts.stream()
        .map(p -> p.getId())
        .collect(Collectors.toList());
  }

이러한 리팩토링을 통해 게시글 조회, 태그 조회, 미디어 관련 조회 3번의 쿼리 호출이 발생합니다. 쿼리 호출수는 1회에서 3회로 늘었지만 중복 데이터가 발생하지 않게 되면서 DB 응답 데이터 크기가 줄어들게 됩니다.

profile
부족함을 당당히 마주하는 용기

0개의 댓글