Dandy Now!
  • [Node.js] 대량 API 요청의 효율적인 처리: p-limit을 활용한 동시성 제한
    2025년 05월 01일 20시 58분 35초에 업로드 된 글입니다.
    작성자: DandyNow
    728x90
    반응형

    Node.js에서 대량 API 요청의 효율적인 처리: p-limit을 활용한 동시성 제한

    Node.js 애플리케이션에서 다수의 API 요청을 처리할 때 발생할 수 있는 문제점과 이를 해결하기 위한 최적화 방법이 본 글의 주제이다. 대량의 동시 요청은 서버에 과부하를 일으키고 자원을 비효율적으로 사용하게 하는 원인이다. 이러한 문제의 효과적인 해결책을 살펴보도록 하겠다.

    1. 문제: 대량의 순차적 API 요청

    다음과 같은 코드는 많은 항목에 대해 API 요청을 순차적으로 수행하는 예시이다:

    const request = require('request');
    
    async function fetchDataForAllItems(items) {
      const results = [];
    
      for (const item of items) {
        const data = await fetchDataForItem(item.id);
        results.push(data);
      }
    
      return results;
    }
    
    function fetchDataForItem(itemId) {
      return new Promise((resolve, reject) => {
        request(`https://api.example.com/items/${itemId}`, (error, response, body) => {
          if (error) {
            return reject(error);
          }
          try {
            const data = JSON.parse(body);
            resolve(data);
          } catch (e) {
            reject(e);
          }
        });
      });
    }
    
    // 100개 항목에 대한 데이터 조회
    const items = Array.from({ length: 100 }, (_, i) => ({ id: i + 1 }));
    fetchDataForAllItems(items).then(console.log);

    이 코드의 문제점은 다음과 같다:

    1. 모든 요청이 순차적으로 처리되어 총 실행 시간이 길어지는 점
    2. 대량의 항목이 있을 경우 비효율적인 점
    3. 오류 처리가 제한적인 점
    4. API 제공자 측 서버에 과부하를 줄 위험성이 있는 점

    2. 개선 방법

    2-1. Promise.all을 사용한 병렬 처리

    가장 간단한 최적화는 모든 요청을 병렬로 처리하는 것이다:

    async function fetchDataForAllItemsParallel(items) {
      const promises = items.map(item => fetchDataForItem(item.id));
      return Promise.all(promises);
    }

    하지만 이 방법은 항목 수가 많을 경우 다음과 같은 문제를 일으킬 수 있다:

    1. 서버에 과부하 발생
    2. 네트워크 및 시스템 리소스 고갈
    3. API 제공자의 요청 제한 초과

    2-2. p-limit 모듈을 사용한 동시성 제한

    p-limit은 Promise 기반 작업의 동시성을 제한하는데 최적화된 인기 있는 npm 모듈이다. 이 모듈을 사용하면 커스텀 리미터를 구현하는 복잡성 없이 간단하게 동시 실행 수를 제한할 수 있다.

    먼저 p-limit 모듈을 설치해야 한다:

    npm i p-limit@3.1.0

    p-limit을 사용한 동시성 제한 구현은 다음과 같다:

    const pLimit = require('p-limit');
    const request = require('request');
    
    function fetchDataForItem(itemId) {
      return new Promise((resolve, reject) => {
        request(`https://api.example.com/items/${itemId}`, (error, response, body) => {
          if (error) {
            return reject(error);
          }
          try {
            const data = JSON.parse(body);
            resolve(data);
          } catch (e) {
            reject(e);
          }
        });
      });
    }
    
    async function fetchDataForAllItemsLimited(items) {
      // 최대 10개의 동시 요청으로 제한
      const limit = pLimit(10);
    
      // 각 항목에 대한 Promise를 생성하되, p-limit으로 동시성 제한
      const promises = items.map(item => 
        limit(() => fetchDataForItem(item.id))
      );
    
      // 모든 Promise가 완료될 때까지 대기
      return Promise.all(promises);
    }
    
    // 사용 예시
    const items = Array.from({ length: 100 }, (_, i) => ({ id: i + 1 }));
    fetchDataForAllItemsLimited(items)
      .then(results => console.log(`처리된 항목 수: ${results.length}`))
      .catch(error => console.error('오류 발생:', error));

    이 방식의 장점은 다음과 같다:

    1. 코드가 간결하고 이해하기 쉽다
    2. 널리 사용되는 검증된 라이브러리를 활용한다
    3. 커스텀 구현의 잠재적 버그를 방지한다
    4. 동시성 제한 기능 외에도 추가 기능을 제공한다

    2-3. 배치 처리 및 기타 최적화와 p-limit 결합

    p-limit을 사용하면서 추가 최적화를 적용한 종합적인 솔루션은 다음과 같다:

    const request = require('request');
    const pLimit = require('p-limit');
    
    // Promise 래핑된 request
    function requestPromise(options) {
      return new Promise((resolve, reject) => {
        request(options, (error, response, body) => {
          if (error) return reject(error);
          if (!response) return reject(new Error('No response'));
          if (response.statusCode < 200 || response.statusCode >= 300) {
            return reject(new Error(`HTTP Error: ${response.statusCode}`));
          }
    
          try {
            const data = JSON.parse(body);
            resolve(data);
          } catch (e) {
            reject(new Error(`Parse error: ${e.message}`));
          }
        });
      });
    }
    
    async function processItems(items, concurrency = 10, batchSize = 50) {
      // p-limit 인스턴스 생성
      const limit = pLimit(concurrency);
      const results = [];
    
      // 타임아웃 설정
      const timeoutMs = 5000;
    
      // 재시도 함수
      const fetchWithRetry = async (itemId, retries = 3) => {
        try {
          return await limit(() => 
            requestPromise({
              url: `https://api.example.com/items/${itemId}`,
              timeout: timeoutMs
            })
          );
        } catch (error) {
          if (retries > 0) {
            const delay = 1000 * (4 - retries); // 재시도 전 지연 시간
            await new Promise(r => setTimeout(r, delay));
            return fetchWithRetry(itemId, retries - 1);
          }
          throw error;
        }
      };
    
      // 배치 처리
      for (let i = 0; i < items.length; i += batchSize) {
        const batch = items.slice(i, i + batchSize);
    
        // 각 배치 병렬 처리
        const batchPromises = batch.map(item => 
          fetchWithRetry(item.id)
            .then(data => ({ success: true, data, itemId: item.id }))
            .catch(error => ({ success: false, error: error.message, itemId: item.id }))
        );
    
        const batchResults = await Promise.all(batchPromises);
        results.push(...batchResults);
    
        // 배치 간 지연 (선택적)
        if (i + batchSize < items.length) {
          await new Promise(r => setTimeout(r, 500));
        }
      }
    
      return {
        successful: results.filter(r => r.success),
        failed: results.filter(r => !r.success)
      };
    }
    
    // 사용 예시
    async function main() {
      const items = Array.from({ length: 200 }, (_, i) => ({ id: i + 1 }));
    
      console.time('API 요청 처리');
      const result = await processItems(items, 10, 50);
      console.timeEnd('API 요청 처리');
    
      console.log(`성공: ${result.successful.length}, 실패: ${result.failed.length}`);
    
      if (result.failed.length > 0) {
        console.log('실패한 항목:', result.failed.map(f => f.itemId));
      }
    }
    
    main().catch(console.error);

    3. 주요 개선 요소 및 기법

    1. p-limit을 활용한 동시성 제한

      • 검증된 라이브러리로 동시에 실행되는 요청 수 제한
      • 간결한 API로 쉽게 구현 가능
      • 과도한 리소스 사용과 서버 부하 방지
    2. 배치 처리

      • 전체 항목을 작은 배치로 나누어 처리
      • 메모리 사용량 제어 및 진행 상황 관리가 용이한 장점
      • 중간 결과를 확인할 수 있는 이점
    3. 재시도 메커니즘

      • 일시적인 오류에 대응하는 지수 백오프 재시도 전략
      • 네트워크 불안정성에 대한 견고성 향상 효과
      • 성공률을 높이는 핵심 요소
    4. 타임아웃 설정

      • 응답 없는 요청이 리소스를 계속 점유하는 것을 방지하는 안전장치
      • 전체 작업 시간 예측 가능성 향상에 기여
      • 좀비 요청 방지 효과
    5. 에러 처리 및 로깅

      • 성공 및 실패 결과 구분으로 명확한 결과 분석 가능
      • 오류 원인 추적 및 분석이 용이한 구조
      • 결과 보고서 생성이 간편한 이점

    4. p-limit 활용의 추가 이점

    p-limit 라이브러리는 다음과 같은 추가 이점을 제공한다:

    1. 큐 관리 자동화

      • 내부적으로 대기 큐를 최적으로 관리하는 기능
      • 메모리 누수 방지 메커니즘 내장
    2. 간단한 API

      • 복잡한 동시성 제어를 단 몇 줄의 코드로 구현 가능
      • 유지보수가 쉽고 코드 가독성이 높은 장점
    3. 성능 최적화

      • 내부적으로 최적화된 알고리즘 사용
      • 오버헤드가 적은 효율적인 구현
    4. 메타 정보 접근

      • 활성 Promise 수, 대기 중인 작업 수 등 정보 접근 가능
      • 동적 모니터링과 조정이 가능한 이점

    5. 결론

    대량의 API 요청을 효율적으로 처리하기 위해서는 단순히 순차적 또는 완전 병렬 처리보다 더 세련된 접근 방식이 필요하다. p-limit과 같은 라이브러리를 활용한 동시성 제한, 배치 처리, 재시도 전략 등의 기법을 조합하면 시스템 리소스를 효율적으로 사용하면서도 전체 성능을 크게 향상시킬 수 있다.

    특히 대규모 프로덕션 환경에서는 다음과 같은 추가 고려사항이 중요하다:

    • 모니터링 및 로깅: 성능 지표 수집 및 병목 지점 식별이 필수 요소이다
    • 캐싱: 중복 요청 제거 및 응답 시간 단축의 핵심 전략이다
    • 서킷 브레이커 패턴: 장애가 검출된 서비스에 대한 요청 차단 메커니즘이다
    • 부하 테스트: 최적의 동시성 한계 설정을 위한 테스트는 필수적이다

    이러한 최적화 기법을 적용하면 더 안정적이고 효율적인 Node.js 애플리케이션을 구축할 수 있다. p-limit과 같은 검증된 라이브러리의 활용은 개발 시간을 단축하고 코드 품질을 향상시키는 현명한 선택이다.

    728x90
    반응형
    댓글