배꼽파지 않도록 잘 개발해요

Mongoose N+1 문제 해결로 8배 성능 향상 본문

BackEnd/Database

Mongoose N+1 문제 해결로 8배 성능 향상

꼽파 2025. 8. 26. 20:45

공개 여행 피드 API 흐름과 구조

현재 내가 만든 서비스에서는 GET /our-trip API를 호출하면 다음과 같은 흐름으로 실행된다.

GET /our-trip 
 → Controller 
   → fetchOurFeeds() 
     → getPaginatedFeeds() 
       → extractFeeds()

 

즉, 컨트롤러는 서비스 계층(OurTripService)의 fetchOurFeeds() 메서드를 호출하고,

다른 모듈의 서비스 계층(FeedService)의 메서드(getPaginatedFeeds)를 호출,

해당 메서드는 같은 모듈의 헬퍼 클래스(feed-extractor)의 메서드(extractFeeds)를 호출하는 구조이다.

 

공개 여행 피드를 페이지네이션으로 조회하고, 클라이언트에 필요한 데이터만 추출해서 제공하고 있다.

DB 조회와 데이터 가공을 분리해서 관심사를 명확히 했다.

 

fetchOurFeeds: DB 조회 + 데이터 가공 분리

/**
 * 모든 공개 게시물을 조회 (페이지네이션)
 *
 * @param {number} pageNumber - 페이지 번호 (기본값: 1)
 * @param {string} [userId] - 사용자 ID (로그인 시 개인화된 피드를 제공하기 위함)
 */
async fetchOurFeeds(pageNumber: number = 1, userId?: string) {
  const pageSize = 9;
  const criteria = {
    isPublic: true,
    $or: [{ deletedAt: null }, { deletedAt: { $exists: false } }],
  };

  // 1) DB에서 페이지네이션된 게시물 조회
  const paginatedResult = await this.feedService.getPaginatedFeeds(
    pageNumber,
    pageSize,
    criteria,
  );

  // 2) 클라이언트에서 필요한 데이터만 추출
  const extractedFeeds = await this.feedExtractor.extractFeeds(
    paginatedResult.feeds.data,
    userId || null,
  );

  paginatedResult.feeds.data = extractedFeeds;
  return paginatedResult;
}

 

여기서 핵심은 관심사 분리이다.

  • getPaginatedFeeds() : DB에서 필요한 게시물을 빠르게 조회
  • extractFeeds() : 조회된 게시물에서 클라이언트에 필요한 데이터만 가공

이렇게 역할을 나누면 코드 유지보수성이 높아지고, 확장하기도 훨씬 수월해진다.

 

getPaginatedFeeds: MongoDB Aggregation으로 페이지네이션

async getPaginatedFeeds(pageNumber = 1, pageSize = 9, criteria: any = {}, sort: any = {}) {
  const skip = (pageNumber - 1) * pageSize;
  const pipeline: any[] = [{ $match: criteria }];

  if (Object.keys(sort).length) {
    pipeline.push({ $sort: sort });
  }

  pipeline.push({
    $facet: {
      metadata: [
        { $match: { $and: [{ travelPlan: { $ne: null } }, { $or: [{ deletedAt: null }, { deletedAt: { $exists: false } }] }] } },
        { $count: 'totalCount' },
      ],
      data: [{ $skip: skip }, { $limit: pageSize }],
    },
  });

  const result = await this.feedModel.aggregate(pipeline);
  const totalCount = result[0].metadata.length > 0 ? result[0].metadata[0].totalCount : 0;

  return {
    success: true,
    feeds: {
      metadata: { totalCount, pageNumber, pageSize },
      data: result[0].data,
    },
  };
}

 

MongoDB의 Aggregation + $facet을 활용해 한 번의 쿼리로

  • 전체 게시물 개수(totalCount)
  • 페이지 데이터(data)

를 동시에 가져온다.

맨 처음에는 aggregation을 사용하지 않았는데, mongodb 커뮤니티에서 성능상 이점이 더 좋다고 해서 저걸로 작성하였다.

 

extractFeeds: 필요한 데이터만 추출

DB에서 가져온 게시물은 정보가 많습니다. 하지만 클라이언트가 실제로 화면에 렌더링하는 데이터는 제한적이다.

그래서 FeedExtractor가 다음과 같은 역할을 한다:

  • TravelPlan 정보 join
  • Thumbnail 이미지 추출
  • 스크랩 여부 확인
  • 최소한의 데이터 구조로 응답 변환

extractFeeds 메서드에서 N + 1 문제 발견

 

현재 빨간색 표시를 한 부분을 보면,

feed 배열을 순회하면서 각 feed에 대해 TravelPlan를 조회하고, scrap 상태 확인을 개별적으로 수행하고 있다.


그래서 불필요하게 쿼리를 호출하는 느낌이 들어, 실제 정량 측정을 통해 확인해보기로 했다.


N+1 문제 확인하기

정말 간단한 방법으로 문제를 확인했다.

1. 쿼리 로그 활성화

if (process.env.NODE_ENV === 'development') {
  mongoose.set('debug', true); // 모든 쿼리 출력
}

실행되는 MongoDB 쿼리를 확인해서 “몇 번 호출되는지” 로그로 직접 체크할 수 있다.
(N+1 문제일 경우 findOne 같은 쿼리가 피드 수만큼 반복 호출됨)

 

2. 실행 시간을 측정한다.

performance.now()로 구간별 시간을 찍어 페이지네이션 / 데이터 추출 / 전체 실행 시간을 따로 확인한다.

 

3. 로그를 확인한다.

  • Mongoose debug 로그 → 쿼리 호출 횟수 확인
  • console.log 출력 → 실행 시간(ms) 확인

 

로그 출력 결과:

  • Feed 목록 조회: 1번
  • Scrap.findOne: 9번 (피드 개수만큼)

단 9개의 피드만 불러와도 총 10번 쿼리 + 387ms가 소요되었다.
데이터가 많아지면 심각한 성능 문제가 될 수밖에 없다.

참고로 우리 서비스에서는 페이지네이션을 기본 9개씩 진행하기로 하였기 때문에, 9개 단위로 나눈다.

 


해결: 개별 조회 → 일괄 조회

travelPlan 쪽에는 문제가 없었고, Scrap.findOne에 문제가 있었다.

findOne을 여러 번 호출하지 말고, $in 연산자로 한 번에 긁어오도록 하였다.

 

기존 방식 (N+1 발생)

Scrap 스키마에서

feedId = A → findOne()
feedId = B → findOne()
feedId = C → findOne()
...

Feed 9개 → Scrap.findOne 9번

 

  • 이렇게 피드마다 개별적으로 쿼리를 실행해서, 9개의 피드면 findOne이 9번 호출된다.
  • 결과적으로 1(피드 목록) + 9(Scrap 조회) = 10쿼리 → N+1 문제

 

개선 방식 (일괄 조회)

 

feedId IN [A, B, C, ...] → find()

Feed 9개 → Scrap.find({ feedId: { $in: [...] } }) 1번
  • feedId 배열을 $in 연산자로 한 번에 조건 검색해서 결과를 몽땅 가져온다.
  • 가져온 결과를 Set이나 Map으로 바꿔놓으면 O(1)로 각 피드에 매핑한다.
  • 최종적으로 1(피드 목록) + 1(스크랩 일괄 조회) = 2쿼리로 끝

 

개선 후 코드 

async getScrappedFeedIds(feedIds: string[], userId: string): Promise<Set<string>> {
  const scraps = await this.scrapModel.find({
    feedId: { $in: feedIds },
    userId,
  }).select('feedId');

  return new Set(scraps.map(s => s.feedId.toString()));
}

async extractFeeds(feeds: FeedDocument[], userId?: string) {
  const feedIds = feeds.map(f => f._id.toString());
  const scrappedSet = await this.feedScrapService.getScrappedFeedIds(feedIds, userId);

  return feeds.map(feed => ({
    ...feed,
    isScrapped: scrappedSet.has(feed._id.toString()),
  }));
}

 

결과: 8배 성능 향상

항목 개선 전 개선 후 향상률
쿼리 수 10개 1개 90% 감소
피드 추출 시간 333.62ms 0.50ms 99.8% ↓
전체 응답 시간 387.96ms 46.96ms 87.9% ↓

 

N+1 문제는 초반에는 잘 드러나지 않지만, 데이터가 쌓일수록 성능에 치명적인 영향을 준다.
이번 사례처럼 단순히 개별 조회를 일괄 조회로 바꾸는 것만으로도 8배 이상의 성능 향상을 얻을 수 있었다.

 

겉으로 보기엔 효율적으로 보이는 코드라도 실제 쿼리 레벨에서는 예상치 못한 비효율이 숨어 있을 수 있다.
그래서 앞으로는 쿼리를 작성할 때 단순히 동작 여부만 확인하는 게 아니라, 쿼리 횟수와 실행 시간을 정량적으로 체크하는 습관을 반드시 들여야겠다고 느꼈다.

728x90