일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
- CSS
- 파이썬프로그래밍기초
- 파이썬
- Cookie
- 방송대컴퓨터과학과
- 코딩테스트준비
- nestjs
- 중간이들
- 개발자취업
- 자격증
- HTML
- 방송대
- JavaScript
- 코딩테스트
- Python
- node.js
- 꿀단집
- 엘리스sw트랙
- 유노코딩
- Git
- SQL
- 데이터베이스시스템
- redis
- 코드잇
- 99클럽
- 프로그래머스
- 항해99
- aws
- mongoDB
- TiL
- Today
- Total
배꼽파지 않도록 잘 개발해요
Mongoose N+1 문제 해결로 8배 성능 향상 본문
공개 여행 피드 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배 이상의 성능 향상을 얻을 수 있었다.
겉으로 보기엔 효율적으로 보이는 코드라도 실제 쿼리 레벨에서는 예상치 못한 비효율이 숨어 있을 수 있다.
그래서 앞으로는 쿼리를 작성할 때 단순히 동작 여부만 확인하는 게 아니라, 쿼리 횟수와 실행 시간을 정량적으로 체크하는 습관을 반드시 들여야겠다고 느꼈다.
'BackEnd > Database' 카테고리의 다른 글
N + 1 쿼리 MySQL로 직접 눈으로 확인해보자 (0) | 2025.08.26 |
---|---|
stateful과 stateless (0) | 2024.12.29 |
[Redis] Redis 클라이언트는 기본적으로 로컬 Redis 서버에 연결됨 (0) | 2024.09.10 |
[Redis] Redis Cloud와 Redis Insignt 차이점 및 사용해보기 (0) | 2024.09.10 |
[MongoDB] Mongoose Populate 이해 - find의 결과를 populate가 필터링 하지 않음 (0) | 2024.08.31 |