
최근 여러 회사의 지점별 연도별 판매 실적을 집계하는 API를 개발하면서 심각한 성능 문제에 직면했습니다. 100개 회사, 500개 지점, 5년치 데이터를 조회하는데 무려 160초가 걸렸죠.
이 글에서는 이 API를 5초로 단축시킨 과정과, 그 과정에서 배운 핵심 개념들을 공유하고자 합니다.
| 단계 | 처리 시간 | 개선율 | 주요 변경사항 |
|---|---|---|---|
| 최초 | ~160초 | - | N+1 쿼리, 순차 처리 |
| 1차 최적화 | ~40초 | 75% ↓ | 벌크 조회, 인메모리 캐싱 |
| 2차 최적화 | ~17초 | 89% ↓ | 완전 병렬화, 통합 조회 |
| 3차 시도 | ~19초 | ❌ 역효과 | 청크 처리 오버헤드 |
| 최종 최적화 | ~5초 | 97% ↓ | Aggregate 직접 사용 |
최적화 과정을 설명하기 전에, 사용된 핵심 기술들을 먼저 이해해봅시다.
데이터베이스 조회에서 발생하는 가장 흔한 성능 문제입니다.
// ❌ N+1 쿼리 (매우 느림)
const companies = await Company.find(); // 1번 조회
for (const company of companies) { // 회사가 100개라면
const branches = await Branch.find({
companyId: company._id
}); // 100번 조회!
for (const branch of branches) { // 지점이 500개라면
const products = await Product.find({
branchId: branch._id
}); // 500번 조회!
}
}
// 총 DB 조회: 1 + 100 + 500 = 601번 😱
// ✅ 벌크 조회 (매우 빠름)
const companies = await Company.find(); // 1번
const companyIds = companies.map(c => c._id);
const allBranches = await Branch.find({
companyId: { $in: companyIds } // 한 번에 조회!
});
const branchIds = allBranches.map(b => b._id);
const allProducts = await Product.find({
branchId: { $<in: branchIds } // 한 번에 조회!
});
// 총 DB 조회: 3번만! ✨
비유:
여러 작업을 동시에 실행하여 시간을 단축하는 기법입니다.
// ❌ 순차 처리 (Sequential)
const sales2020 = await calculateSales(2020); // 5초
const sales2021 = await calculateSales(2021); // 5초
const sales2022 = await calculateSales(2022); // 5초
const sales2023 = await calculateSales(2023); // 5초
const sales2024 = await calculateSales(2024); // 5초
// 총 시간: 25초 (5초 × 5년)
// ✅ 병렬 처리 (Parallel)
const allSales = await Promise.all([
calculateSales(2020), // 동시에
calculateSales(2021), // 동시에
calculateSales(2022), // 동시에
calculateSales(2023), // 동시에
calculateSales(2024) // 동시에
]);
// 총 시간: 5초 (가장 오래 걸리는 작업 기준)
비유:
주의사항:
// ✅ 병렬 가능: 서로 독립적인 작업
await Promise.all([
fetchUserProfile(userId),
fetchOrderHistory(userId),
fetchWishlist(userId)
]);
// ❌ 병렬 불가: 순서가 중요한 작업
const user = await createUser(userData); // 먼저 실행
const order = await createOrder(user.id); // user.id 필요
const payment = await processPayment(order); // order 필요
큰 데이터를 작은 조각으로 나눠서 처리하는 기법입니다.
// 10,000개의 상품을 처리해야 할 때
// ❌ 한 번에 처리 (메모리 부족 위험)
const result = await processAllProducts(10000개);
// ✅ 청크로 나눠서 처리
const CHUNK_SIZE = 1000;
const productChunks = [
products.slice(0, 1000),
products.slice(1000, 2000),
products.slice(2000, 3000),
// ...
];
for (const chunk of productChunks) {
await processProducts(chunk); // 1000개씩 처리
}
청크 + 병렬 조합:
// 🚀 청크를 병렬로 처리 (가장 효율적)
await Promise.all(
productChunks.map(chunk => processProducts(chunk))
);
// 여러 청크를 동시에 처리!
비유:
// 1. DB에서 모든 데이터를 가져옴
const sales = await Sale.find({
year: 2024,
productId: { $in: productIds }
});
// 2. 앱 서버에서 계산
let totalRevenue = 0;
for (const sale of sales) { // 10,000개 순회
totalRevenue += sale.amount;
}
console.log(totalRevenue);
데이터 흐름:
DB (10,000개 documents)
↓ 네트워크 전송 (수 MB)
앱 서버 (메모리에 10,000개 로드)
↓ JavaScript로 계산
결과
// DB에서 직접 계산해서 결과만 가져옴
const result = await Sale.aggregate([
{
$match: {
year: 2024,
productId: { $in: productIds }
}
},
{
$group: {
_id: '$productId',
totalRevenue: { $sum: '$amount' } // DB가 직접 합산!
}
}
]);
console.log(result); // 집계된 결과만
데이터 흐름:
DB (10,000개 documents를 내부에서 처리)
↓ 네트워크 전송 (수 KB, 결과만!)
앱 서버 (집계된 결과만 받음)
결과
성능 차이 비유:
await Sale.aggregate([
// 1단계: 필터링 (WHERE)
{
$match: {
year: 2024,
amount: { $gt: 0 }
}
},
// 2단계: 그룹화 및 집계 (GROUP BY)
{
$group: {
_id: {
branchId: '$branchId',
month: '$month'
},
totalRevenue: { $sum: '$amount' }, // 합계
avgRevenue: { $avg: '$amount' }, // 평균
maxRevenue: { $max: '$amount' }, // 최대값
orderCount: { $sum: 1 } // 개수
}
},
// 3단계: 정렬 (ORDER BY)
{
$sort: { totalRevenue: -1 }
},
// 4단계: 제한 (LIMIT)
{
$limit: 10 // 상위 10개만
}
]);
계산한 결과를 메모리에 저장해두고 재사용하는 기법입니다.
// Map = 빠른 Key-Value 저장소
const cache = new Map();
async function getSalesTotal(branchId, year, month) {
const key = `${branchId}_${year}_${month}`;
// 1. 캐시에 있으면 바로 반환 (초고속)
if (cache.has(key)) {
return cache.get(key); // 0.001ms
}
// 2. 없으면 DB에서 조회
const result = await Sale.aggregate([...]); // 50ms
// 3. 캐시에 저장
cache.set(key, result);
return result;
}
// 같은 데이터를 10번 요청하면?
// 첫 번째: 50ms (DB 조회)
// 나머지 9번: 0.001ms (캐시)
비유:
async function getSalesReport(selectedCompanies) {
const report = [];
// 회사별 순회
for (const companyData of selectedCompanies) {
const company = await Company.findById(companyData.companyId); // ❌ N+1
// 지점별 순회
for (const branchData of companyData.branches) {
const branch = await Branch.findById(branchData.branchId); // ❌ N+1
// 상품 조회
const products = await Product.find({
branchId: branch._id
}); // ❌ N+1
// 연도별 순회
for (const year of [2020, 2021, 2022, 2023, 2024]) {
// 상품별 판매 집계
for (const product of products) {
const sales = await Sale.find({
productId: product._id,
year: year
}); // ❌ N+1
// 앱에서 계산
let total = 0;
for (const sale of sales) {
total += sale.amount;
}
}
}
}
}
return report;
}
실행 흐름:
회사 1 → 지점 1 → 상품들 → 2020년 → 2021년 → ...
→ 지점 2 → 상품들 → 2020년 → 2021년 → ...
회사 2 → 지점 1 → ...
1. 모든 데이터를 사전에 벌크 조회
async function getSalesReport(selectedCompanies) {
// ✅ 모든 ID 수집
const companyIds = selectedCompanies.map(c => c.companyId);
const branchIds = selectedCompanies.flatMap(c =>
c.branches.map(b => b.branchId)
);
// ✅ 한 번에 조회 (N+1 제거)
const [allCompanies, allBranches, allProducts] = await Promise.all([
Company.find({ _id: { $in: companyIds } }).lean(),
Branch.find({ _id: { $in: branchIds } }).lean(),
Product.find({ branchId: { $in: branchIds } }).lean()
]);
console.log('✅ 총 DB 조회: 3번만!');
}
2. Map 구조로 빠른 조회
// ✅ O(1) 조회를 위한 Map 생성
const companyMap = new Map(
allCompanies.map(c => [c._id.toString(), c])
);
const branchMap = new Map(
allBranches.map(b => [b._id.toString(), b])
);
const branchesByCompany = new Map();
allBranches.forEach(branch => {
const companyId = branch.companyId.toString();
if (!branchesByCompany.has(companyId)) {
branchesByCompany.set(companyId, []);
}
branchesByCompany.get(companyId).push(branch);
});
// 조회 시
const company = companyMap.get(companyId); // 배열 탐색 O(n) → Map O(1)
3. 인메모리 캐싱
// ✅ 계산 결과 캐싱
const salesCache = new Map(); // key: `${productId}_${year}_${month}`
// 연도별 집계
for (const year of years) {
const yearSales = await calculateYearSales(allProductIds, year);
// 캐시에 저장
yearSales.forEach(sale => {
const key = `${sale.productId}_${year}_${sale.month}`;
salesCache.set(key, sale.totalAmount);
});
}
// 나중에 빠르게 조회
const cached = salesCache.get(`${productId}_2024_3`); // 초고속
1. 모든 연도의 판매 데이터를 한 번에 조회
// ❌ 변경 전: 연도별로 5번 조회
for (const year of years) {
const sales = await Sale.find({ year }); // 5번 호출
}
// ✅ 변경 후: 한 번에 조회
const allYears = [2020, 2021, 2022, 2023, 2024];
const allSales = await Sale.find({
productId: { $in: productIds },
year: { $in: allYears } // 5년치를 한 번에!
}).lean();
2. 연도별 계산 완전 병렬화
// ❌ 변경 전: 순차 처리 (25초)
for (const year of years) {
await calculateYearSales(year); // 5초 × 5년 = 25초
}
// ✅ 변경 후: 병렬 처리 (5초)
await Promise.all(
years.map(async (year) => {
return calculateYearSales(year); // 동시에 실행!
})
);
// 가장 느린 작업 기준: 5초
3. 복잡한 계산도 병렬화
// ✅ 회사×연도 조합을 모두 병렬로
const tasks = selectedCompanies.flatMap(company =>
years.map(year =>
calculateCompanySales(company.id, year)
)
);
await Promise.all(tasks); // 100개 회사 × 5년 = 500개 작업을 동시에!
// 우리 코드
const allSales = await Sale.find({ year: { $in: years } }); // 조회 1
// calculateYearSales 함수 내부
async function calculateYearSales(year) {
const sales = await Sale.find({ year }); // 조회 2 ❌ 중복!
// 계산...
}
→ 중복 조회 발생!
// 청크로 나눠서 처리
const CHUNK_SIZE = 2000;
const productChunks = [];
for (let i = 0; i < productIds.length; i += CHUNK_SIZE) {
productChunks.push(productIds.slice(i, i + CHUNK_SIZE));
}
// 각 청크별로 함수 호출
for (const chunk of productChunks) {
await calculateSales(chunk, year); // 청크마다 호출
}
함수 호출 오버헤드:
calculateSales(chunk1)
→ 함수 진입 → 연결 생성 → 쿼리 파싱 → 실행
calculateSales(chunk2)
→ 함수 진입 → 연결 생성 → 쿼리 파싱 → 실행
...
이미 내부에서 배치 처리 중:
async function calculateSales(productIds, year) {
// 내부에서 이미 50개씩 배치 처리
for (let i = 0; i < productIds.length; i += 50) {
const batch = productIds.slice(i, i + 50);
// 처리...
}
}
→ 청크 처리가 2중으로 발생!
이미 최적화된 함수를 청크로 나누면 오히려 오버헤드 증가!
문제의 근본 원인:
// 우리 메인 코드
const allSales = await Sale.find({
year: { $in: [2020, 2021, 2022, 2023, 2024] }
}); // 조회 1
// 호출하는 함수 내부
async function calculateBulkSales(productIds, year) {
const sales = await Sale.find({
productId: { $in: productIds },
year: year
}); // 조회 2 ❌ 중복!
// 앱에서 계산
let total = 0;
for (const sale of sales) {
total += sale.amount;
}
return total;
}
2가지 문제:
1. DB를 중복 조회
2. 앱에서 계산 (네트워크 트래픽 과다)
async function getSalesReport(selectedCompanies) {
// 1. 기본 데이터 벌크 조회
const [allCompanies, allBranches, allProducts] = await Promise.all([
Company.find({ _id: { $in: companyIds } }).lean(),
Branch.find({ _id: { $in: branchIds } }).lean(),
Product.find({ branchId: { $in: branchIds } }).lean()
]);
// 2. Map 구조 생성
const productMap = new Map(allProducts.map(p => [p._id.toString(), p]));
// 3. 캐싱 준비
const salesCache = new Map();
// 4. ✨ Aggregate로 직접 집계 (핵심!)
const allYears = [2020, 2021, 2022, 2023, 2024];
const productIds = allProducts.map(p => p._id);
// 연도별 병렬 Aggregate
const aggregateResults = await Promise.all(
allYears.map(async (year) => {
// DB에서 직접 합산
const result = await Sale.aggregate([
{
$match: {
productId: { $in: productIds },
year: year,
month: { $in: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] }
}
},
{
$group: {
_id: {
productId: '$productId',
year: '$year',
month: '$month'
},
// DB가 직접 계산!
totalAmount: { $sum: '$amount' },
orderCount: { $sum: 1 },
avgAmount: { $avg: '$amount' }
}
}
]).allowDiskUse(false); // 메모리 내 고속 처리
return { year, data: result };
})
);
// 5. 결과를 캐시에 저장
let cachedCount = 0;
aggregateResults.forEach(({ year, data }) => {
data.forEach(record => {
const productId = record._id.productId.toString();
const month = record._id.month;
const key = `${productId}_${year}_${month}`;
salesCache.set(key, {
totalAmount: record.totalAmount,
orderCount: record.orderCount,
avgAmount: record.avgAmount
});
cachedCount++;
});
});
console.log(`✅ 캐시에 ${cachedCount}개 데이터 저장 완료`);
// 6. 캐시에서 초고속 조회하며 계층 구조 생성
const hierarchicalReport = allCompanies.map(company => {
const companyBranches = allBranches.filter(b =>
b.companyId.toString() === company._id.toString()
);
return {
companyId: company._id,
companyName: company.name,
branches: companyBranches.map(branch => {
const branchProducts = allProducts.filter(p =>
p.branchId.toString() === branch._id.toString()
);
let branchTotal = 0;
branchProducts.forEach(product => {
allYears.forEach(year => {
[1,2,3,4,5,6,7,8,9,10,11,12].forEach(month => {
const cached = salesCache.get(
`${product._id}_${year}_${month}`
);
if (cached) {
branchTotal += cached.totalAmount;
}
});
});
});
return {
branchId: branch._id,
branchName: branch.name,
totalSales: branchTotal
};
})
};
});
return hierarchicalReport;
}
1. 중복 조회 완전 제거
변경 전:
우리 조회 (1번) + 함수 내부 조회 (5번) = 6번
변경 후:
Aggregate 직접 실행 (5번, 병렬)
함수 호출 없음 = 5번
2. DB 레벨 계산
변경 전:
DB → 앱 (10,000개 데이터, 5MB) → JavaScript 계산
변경 후:
DB (내부에서 집계) → 앱 (결과만, 5KB)
3. 네트워크 트래픽 대폭 감소
변경 전: 10,000개 documents × 5년 = 50,000개 전송
변경 후: 집계 결과 약 6,000개만 전송 (상품×월 조합)
4. 완전 병렬 실행
Promise.all([
year2020.aggregate(), // 동시
year2021.aggregate(), // 동시
year2022.aggregate(), // 동시
year2023.aggregate(), // 동시
year2024.aggregate() // 동시
])
회사 1
└─ DB 조회 (회사 정보)
└─ 지점 1
└─ DB 조회 (지점 정보)
└─ DB 조회 (상품들)
└─ 상품 1
└─ DB 조회 (2020년 판매)
└─ DB 조회 (2021년 판매)
└─ ...
└─ 상품 2
└─ ...
└─ 지점 2
└─ ...
회사 2
└─ ...
[회사, 지점, 상품 한번에 조회]
연도 2020 → 계산
연도 2021 → 계산
연도 2022 → 계산
...
[회사, 지점, 상품, 판매 데이터 한번에 조회]
Promise.all([
연도 2020 계산,
연도 2021 계산,
연도 2022 계산,
...
]) 병렬 실행
[회사, 지점, 상품 조회]
Promise.all([
연도 2020 Aggregate (DB가 직접 계산),
연도 2021 Aggregate (DB가 직접 계산),
연도 2022 Aggregate (DB가 직접 계산),
...
]) 완전 병렬 실행
// ❌ 절대 금지
for (const item of items) {
await DB.find({ relatedId: item.id });
}
// ✅ 항상 이렇게
const ids = items.map(i => i.id);
await DB.find({ relatedId: { $in: ids } });
// ✅ 병렬 가능: 서로 무관
Promise.all([
fetchWeather(),
fetchNews(),
fetchStocks()
]);
// ❌ 병렬 불가: 순서 의존
const user = await createUser();
const profile = await createProfile(user.id);
// ❌ 앱에서 계산 (느림)
const data = await DB.find();
const sum = data.reduce((a, b) => a + b.value, 0);
// ✅ DB에서 계산 (빠름)
const result = await DB.aggregate([
{ $group: { _id: null, sum: { $sum: '$value' } } }
]);
// 함수가 이미 최적화되어 있다면
// 불필요한 래퍼나 청크 처리는 오버헤드만 증가
console.time('작업명');
// 작업 수행
console.timeEnd('작업명');
// 병목 지점을 찾아서 집중 개선!
// 자주 조회하는 필드 조합에 인덱스
db.sales.createIndex({
productId: 1,
year: 1,
month: 1
});
// 조회 속도 10~100배 차이!
const cached = await redis.get(`sales:report:${reportId}`);
if (cached) return JSON.parse(cached);
const result = await generateReport();
await redis.setex(`sales:report:${reportId}`, 600, JSON.stringify(result));
// 600초(10분) 캐싱
// ❌ 너무 큼: 메모리 부족
await processAll(100000개);
// ❌ 너무 작음: 오버헤드
for (let i = 0; i < 100000; i++) {
await process(items[i]);
}
// ✅ 적절한 크기
const BATCH_SIZE = 1000;
for (let i = 0; i < items.length; i += BATCH_SIZE) {
await processBatch(items.slice(i, i + BATCH_SIZE));
}
160초 걸리던 API를 5초로 줄이는 과정은 단순히 "빠르게 만들기"가 아니라, 근본적인 문제를 찾아 해결하는 과정이었습니다.
핵심 포인트:
1. N+1 쿼리 제거 → 벌크 조회
2. 순차 처리 → 병렬 처리
3. 앱 계산 → DB 계산 (Aggregate)
4. 중복 조회 → 직접 최적화
이 글이 성능 최적화로 고민하시는 분들께 도움이 되길 바랍니다! 🚀
참고 자료: