Node.js API 성능 최적화: 160초를 5초로 줄인 여정

Seong Hyeon Kim·2025년 11월 27일

개인공부

목록 보기
26/26

Node.js API 성능 최적화: 160초를 5초로 줄인 여정 🚀

📌 들어가며

최근 여러 회사의 지점별 연도별 판매 실적을 집계하는 API를 개발하면서 심각한 성능 문제에 직면했습니다. 100개 회사, 500개 지점, 5년치 데이터를 조회하는데 무려 160초가 걸렸죠.

이 글에서는 이 API를 5초로 단축시킨 과정과, 그 과정에서 배운 핵심 개념들을 공유하고자 합니다.


📊 성능 개선 타임라인

단계처리 시간개선율주요 변경사항
최초~160초-N+1 쿼리, 순차 처리
1차 최적화~40초75% ↓벌크 조회, 인메모리 캐싱
2차 최적화~17초89% ↓완전 병렬화, 통합 조회
3차 시도~19초❌ 역효과청크 처리 오버헤드
최종 최적화~5초97% ↓Aggregate 직접 사용

🎓 핵심 개념 이해하기

최적화 과정을 설명하기 전에, 사용된 핵심 기술들을 먼저 이해해봅시다.

1. 🔄 N+1 쿼리 문제

데이터베이스 조회에서 발생하는 가장 흔한 성능 문제입니다.

// ❌ 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번만! ✨

비유:

  • ❌ N+1: 편의점에서 물건을 하나씩 사러 100번 왕복
  • ✅ 벌크: 편의점에서 물건 100개를 한 번에 구매

2. 🚀 병렬 처리 (Parallel Processing)

여러 작업을 동시에 실행하여 시간을 단축하는 기법입니다.

// ❌ 순차 처리 (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초 (가장 오래 걸리는 작업 기준)

비유:

  • 순차: 빨래 → 기다림 → 설거지 → 기다림 → 청소 (총 3시간)
  • 병렬: 세탁기 돌리면서 + 식기세척기 돌리면서 + 청소하기 (총 1시간)

주의사항:

// ✅ 병렬 가능: 서로 독립적인 작업
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 필요

3. 📦 청크 처리 (Chunking)

큰 데이터를 작은 조각으로 나눠서 처리하는 기법입니다.

// 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대로 10톤 짐을 한 번에 (과적 위험)
  • 청크: 트럭 10대로 1톤씩 나눠서 (안전)
  • 청크 + 병렬: 트럭 10대가 동시에 출발 (안전 + 빠름)

4. 🗄️ MongoDB Aggregate vs Find

Find (일반 조회)

// 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로 계산
결과

Aggregate (집계)

// 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, 결과만!)
앱 서버 (집계된 결과만 받음)
결과

성능 차이 비유:

  • Find: 마트에서 사과 10,000개를 집으로 가져와서 집에서 무게를 하나하나 재서 합산
  • Aggregate: 마트 계산대에서 사과 10,000개의 총 무게를 재서 결과만 알려줌

Aggregate의 강력한 기능

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개만
  }
]);

5. 💾 캐싱 (Caching)

계산한 결과를 메모리에 저장해두고 재사용하는 기법입니다.

// 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 (캐시)

비유:

  • 캐시 없음: 계산기를 매번 찾아서 계산
  • 캐시 사용: 계산 결과를 노트에 적어두고, 같은 계산은 노트만 확인

🔧 단계별 최적화 과정

🔴 최초 상태 (~160초)

코드

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. N+1 쿼리 폭발: 회사×지점×상품×연도만큼 DB 조회 반복
  2. 순차 처리: 모든 계산이 순차적으로 실행
  3. 앱에서 계산: DB에서 가져온 데이터를 앱에서 계산
  4. 중복 계산: 같은 데이터를 여러 번 계산

실행 흐름:

회사 1 → 지점 1 → 상품들 → 2020년 → 2021년 → ...
       → 지점 2 → 상품들 → 2020년 → 2021년 → ...
회사 2 → 지점 1 → ...

🟡 1차 최적화 (~40초, 75% 개선)

핵심 개선사항

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`);  // 초고속

개선 효과

  • DB 조회: 수천 번 → 3번
  • 조회 속도: O(n) → O(1)
  • 중복 계산 방지

남은 문제

  • 여전히 연도별 순차 처리
  • 함수 내부에서 또 다른 DB 조회 발생

🟠 2차 최적화 (~17초, 89% 개선)

핵심 개선사항

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개 작업을 동시에!

개선 효과

  • DB 조회: 연도별 5번 → 1번
  • 처리 시간: 순차 25초 → 병렬 5초

남은 문제

// 우리 코드
const allSales = await Sale.find({ year: { $in: years } });  // 조회 1

// calculateYearSales 함수 내부
async function calculateYearSales(year) {
  const sales = await Sale.find({ year });  // 조회 2 ❌ 중복!
  // 계산...
}

중복 조회 발생!


🔵 3차 시도 (~19초, ❌ 역효과)

시도한 방법

// 청크로 나눠서 처리
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중으로 발생!

교훈

이미 최적화된 함수를 청크로 나누면 오히려 오버헤드 증가!


🟢 최종 최적화 (~5초, 97% 개선) ⭐

핵심 발견

문제의 근본 원인:

// 우리 메인 코드
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. 앱에서 계산 (네트워크 트래픽 과다)

해결책: MongoDB Aggregate 직접 사용

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()   // 동시
])

📈 최종 비교 정리

처리 흐름 비교

최초 (160초)

회사 1
  └─ DB 조회 (회사 정보)
  └─ 지점 1
      └─ DB 조회 (지점 정보)
      └─ DB 조회 (상품들)
      └─ 상품 1
          └─ DB 조회 (2020년 판매)
          └─ DB 조회 (2021년 판매)
          └─ ...
      └─ 상품 2
          └─ ...
  └─ 지점 2
      └─ ...
회사 2
  └─ ...

1차 최적화 (40초)

[회사, 지점, 상품 한번에 조회]
연도 2020 → 계산
연도 2021 → 계산
연도 2022 → 계산
...

2차 최적화 (17초)

[회사, 지점, 상품, 판매 데이터 한번에 조회]
Promise.all([
  연도 2020 계산,
  연도 2021 계산,
  연도 2022 계산,
  ...
]) 병렬 실행

최종 (5초)

[회사, 지점, 상품 조회]
Promise.all([
  연도 2020 Aggregate (DB가 직접 계산),
  연도 2021 Aggregate (DB가 직접 계산),
  연도 2022 Aggregate (DB가 직접 계산),
  ...
]) 완전 병렬 실행

💡 핵심 교훈

1. N+1 쿼리는 무조건 제거하라

// ❌ 절대 금지
for (const item of items) {
  await DB.find({ relatedId: item.id });
}

// ✅ 항상 이렇게
const ids = items.map(i => i.id);
await DB.find({ relatedId: { $in: ids } });

2. 독립적인 작업은 병렬로

// ✅ 병렬 가능: 서로 무관
Promise.all([
  fetchWeather(),
  fetchNews(),
  fetchStocks()
]);

// ❌ 병렬 불가: 순서 의존
const user = await createUser();
const profile = await createProfile(user.id);

3. DB의 힘을 활용하라

// ❌ 앱에서 계산 (느림)
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' } } }
]);

4. 과도한 추상화 주의

// 함수가 이미 최적화되어 있다면
// 불필요한 래퍼나 청크 처리는 오버헤드만 증가

5. 항상 측정하라

console.time('작업명');
// 작업 수행
console.timeEnd('작업명');
// 병목 지점을 찾아서 집중 개선!

🎯 추가 최적화 팁

1. MongoDB 인덱스 추가

// 자주 조회하는 필드 조합에 인덱스
db.sales.createIndex({ 
  productId: 1, 
  year: 1, 
  month: 1 
});
// 조회 속도 10~100배 차이!

2. Redis 캐싱 (선택사항)

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분) 캐싱

3. 적절한 배치 크기

// ❌ 너무 큼: 메모리 부족
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. 중복 조회 → 직접 최적화

이 글이 성능 최적화로 고민하시는 분들께 도움이 되길 바랍니다! 🚀


참고 자료:

profile
삽질도 100번 하면 요령이 생긴다. 부족한 건 경험으로 채우는 개발자

0개의 댓글