대형 Document 분리와 비지니스 로직 개선 중 발생한 성능이슈 : [MongoDB 성능 리팩토링]

MongoDB의 오해와 해결

진행 중인 프로젝트에서는 RDB와 NoSQL을 혼합하여 사용하고 있습니다.

그 중 게시판(QnA) 기능은 MongoDB를 활용하고 있는데, 처음에는 NoSQL은 자유로운 스키마 구조 덕분에 하나의 Document에 다양한 정보를 다 넣어도 문제없다라는 생각에 MongoDB를 선택했습니다.

 

이런 이유로 게시글 본문뿐만 아니라 사용자 정보, 통계 정보까지 하나의 Document에 모두 담기 시작했습니다.

하지만 아래와 같은 초기 구조처럼 필드가 계속 늘어나고, 관련 없는 데이터까지 한 문서에 들어가다 보니 점점 데이터 구조가 난잡해지고, 도메인 간 책임이 전혀 분리되지 않는 문제가 발생했습니다.

@Getter
@Document(collection = "qna")
public class BoardDocument extends BaseTimeDocument {
    @Id
    private String id;
    @Field(name = "users_id")
    private Long userId;
    private String nickname;
    private String profileImg;
    private String title;
    private String content;
    private String language;
    private String parentId;
    @Field(name = "like_count")
    private Long likeCount;
    @Field(name = "view_count")
    private Long viewCount;
    @Field(name = "answer_count")
    private Long answerCount;
    @Field(name = "reply_count")
    private Long replyCount;
    private boolean isBlind;
    private boolean isSelected;
}

 

이 구조는 처음엔 하나의 도큐먼트만 조회하여 편리했지만 다음과 같은 문제가 있었습니다.

  • 데이터의 관심사 분리가 전혀 없다.
  • 통계 필드만 수정하고 싶어도 전체 문서 업데이트를 해야된다.
  • 조회 시 필요 없는 데이터까지 함께 조회가 되어 성능 저하가 발생한다.

이러한 구조적 문제를 해결하기 위해 다음과 같이 BoardDocument는 게시글 본문 전용, BoardStatsDocument는 통계 전용 BoardViewDocument는 방문 기록 전용으로 역할을 명확히 나누게 되었습니다.

 

@Getter
@Document(collection = "qna")
public class BoardDocument extends BaseTimeDocument {
    @Id
    private String id;
    @Field(name = "users_id")
    private Long userId;
    private String content;
    private String language;
    private String title; // 질문인 경우에만 존재
    private String parentId; // null이면 질문, 값이 있으면 답변(해당 질문의 ID)
    private boolean isBlind;
    private boolean isSelected;
}

@Getter
@Document(collection = "qna_stats")
public class BoardStatsDocument {
    @Id
    private String boardId;
    private Long likeCount;
    private Long viewCount;
    private Long answerCount;
    private Long replyCount;
    private LocalDateTime lastUpdated;
}

@Getter
@Document(collection = "qna_view")
@CompoundIndexes({
        @CompoundIndex(name = "qna_visitor_date_idx",
                def = "{'qnaId': 1, 'visitorId': 1, 'viewDate': 1}",
                unique = true)
})
public class BoardViewDocument extends BaseTimeDocument {
    @Id
    private String id;
    private String qnaId;
    private String visitorId;
    private LocalDate viewDate;
    }

 

Document 분리 후 문제

도큐먼트를 분리한 후 모든 비지니스 로직을 수정해야 했습니다.

밑에 수정 전, 후 코드를 비교하겠습니다.

   // 수정 전
    @Override
    public CustomPageDto<QnaList> findAll(Pageable pageable) {
        Page<QnaList> qnaListPage = boardRepository.findAll(pageable).map(
                QnaList::from
        );

        return CustomPageDto.of(qnaListPage);
    }
    
    // 수정 후
        @Override
    public CustomPageDto<QnaList> findAll(Pageable pageable) {
        Page<Board> boardPage = boardRepository.findAll(pageable);

        Set<String> boardIds = boardPage.getContent().stream()
                .map(Board::getId)
                .collect(Collectors.toSet());

        List<BoardStats> statsList = boardStatsService.getStatsByBoardIds(boardIds);

        Map<String, BoardStats> statsMap = statsList.stream()
                .collect(Collectors.toMap(
                        BoardStats::getBoardId,
                        boardStats -> boardStats)
                );

        Set<Long> userIds = boardPage.getContent().stream()
                .map(Board::getUserId)
                .collect(Collectors.toSet());

        Map<Long, UserProfileCache> profileMap = userFinder.getAll(userIds);

        Page<QnaList> qnaListPage = boardPage.map(board -> {
            BoardStats stats = statsMap.get(board.getId());
            return QnaList.from(board, stats,
                    profileMap.get(board.getUserId()).nickname()
            );
        });

        return CustomPageDto.of(qnaListPage);
    }

 

수정 전, 후의 가장 큰 차이점은 비지니스 로직이 너무 길어졌다는 것입니다.

 

하나의 도큐먼트에서 관리하던 때와 달리 분리를 하면서 N + 1 문제를 방지해야 했으며, 또한 getStatsByBoardIds의 경우 id 하나가 아닌 전체 id 값을 db에서 찾아오는 과정이 발생하기 때문에 성능 저하 이슈 가능성이 존재합니다.

 

Aggregation 적용

Document를 분리한 이후, 비즈니스 로직이 복잡해지고 데이터 조회량이 늘어나면서, 단순한 게시판 목록 조회에서도 SQL의 join과 비슷한 기능이 필요해졌습니다.

이를 해결하기 위해 MongoDB의 Aggregation 기능을 도입했고, 특히 lookup 메서드를 활용하여 Board와 BoardStats를 단일 쿼리로 함께 조회하도록 리팩토링했습니다.

더보기

MongoDB의 lookup은 RDBMS의 join과는 다르게, 네트워크 비용과 메모리 사용량이 생각보다 큽니다.

특히 컬렉션 크기가 크거나, lookup 대상 문서 수가 많아질수록 성능에 미치는 영향이 커지기 때문에 게시판 목록 조회처럼 한 번에 여러 개의 데이터를 가져오는 경우에는 lookup을 이용해 단일 쿼리로 조회하는 것이 효과적일 수 있습니다.

반면, 상세 조회처럼 단일 게시글만 조회하는 경우에는 굳이 lookup을 사용하지 않고, 본문(Board)과 통계(BoardStats)를 각각 조회하는 것이 더 효율적인 선택이 될 수 있습니다.

    // 애플리케이션 계층
    @Override
    public CustomPageDto<QnaList> findAll(Pageable pageable) {
        Page<QnaFindAllAggregation> qnaFindAllAggregation = boardRepository.findAll(pageable);

        Set<Long> userIds = qnaFindAllAggregation.stream()
                .map(QnaFindAllAggregation::userId)
                .collect(Collectors.toSet());

        Map<Long, UserProfileCache> profileMap = userProfileCache.getAll(userIds);

        Page<QnaList> qnaListPage = qnaFindAllAggregation.map(
                findAllQuestions ->
                        QnaList.from(findAllQuestions,
                        profileMap.get(findAllQuestions.userId()).nickname())
        );

        return CustomPageDto.of(qnaListPage);
    }
    
    // 인프라스트럭쳐 계층
    public Page<QnaFindAllAggregation> findRootQuestions(Pageable pageable) {
        Criteria rootQuestionCriteria = Criteria.where("parentId").exists(false)
                .and("isBlind").is(false);

        Aggregation aggregation = Aggregation.newAggregation(
                match(rootQuestionCriteria),
                sort(pageable.getSort()),
                skip((long) pageable.getPageNumber() * pageable.getPageSize()),
                limit(pageable.getPageSize()),
                lookup("boardStats", "_id", "boardId", "stats"),
                unwind("stats", true),
                project()
                        .and("_id").as("qnaId")
                        .and("language").as("language")
                        .and("title").as("title")
                        .and("content").as("content")
                        .and("userId").as("userId")
                        .and("createDate").as("createDate")
                        .and("stats.viewCount").as("viewCount")
                        .and("stats.answerCount").as("answerCount")
        );

        AggregationResults<QnaFindAllAggregation> results = mongoTemplate.aggregate(
                aggregation, "board", QnaFindAllAggregation.class);

        Query countQuery = new Query(rootQuestionCriteria);
        long total = mongoTemplate.count(countQuery, "board");

        return PageableExecutionUtils.getPage(
                results.getMappedResults(),
                pageable,
                () -> total
        );
    }

 

위 코드에서 볼 수 있듯이 Aggregation 도입 전과 후의 비지니스 로직에 길이가 꽤 있는 것을 볼 수 있습니다.

또한 게시글 작성자에 대한 정보는 캐싱하여 조회 성능을 향상시켰고 게시판 통계 또한 캐싱을 도입하면 매번 lookup을 통해 데이터를 가져오지 않기 때문에 큰 성능향상이 있을 것을 기대할 수 있습니다.