🗓️ 2024. 03. 31
⏱️ 22

Node.js Promise로 외부 서비스 호출 최적화하기

일괄처리와 캐싱을 한번에 해결하기

개요

사이드 프로젝트로 스트리밍 미터라는 서비스를 운영하고 있습니다.
스트리밍 미터는 유튜브, 스포티파이에 게시된 영상과 음악에 얼마나 많은 시간이 누적됐는지 알 수 있는 서비스입니다.

서비스의 핵심이 데이터인만큼 외부에서 데이터를 불러오는 부분이 제일 중요한데요,
외부 서비스를 여러 번 호출할 때, 불필요한 요청을 최적화했던 경험을 소개드리려 합니다.

읽어보시면 좋아요

  • 외부 서비스에서 데이터를 조회하고 계신 분들(스크래핑, 크롤링, 타사 API 호출 등)

서비스 구조

스트리밍 미터는 사용자에게 검색 결과를 제공하기 위해 유튜브와 스포티파이의 검색 API를 이용하고 있습니다.
그리고 누적된 시간을 계산하기 위해 웹스크래핑으로 재생시간과 조회수(재생횟수)를 가져옵니다.

웹스크래핑을 이용하는 이유는, API로 재생시간과 조회수를 제공하는 유튜브와 달리 스포티파이는 재생시간과 인기도를 제공하기 때문입니다. 스포티파이의 인기도는 구체적인 재생횟수가 아니라 1~100의 수치로 매겨지는 점수이므로 서비스에 필요한 데이터가 아닙니다. 필요한 데이터가 API로 제공되지 않는다면 데이터를 얻기 위해 웹스크래핑을 활용할 수 있습니다.
(물론 서비스 운영에 해를 끼치지 않아야 합니다.)

검색 API를 이용해 사용자에게 검색 결과 제공검색 API를 이용해 사용자에게 검색 결과 제공
스크래핑으로 웹에서 제공되는 재생횟수 가져오기스크래핑으로 웹에서 제공되는 재생횟수 가져오기

해결해야 할 문제들

이처럼 외부 API 호출이나 스크래핑에 의존하는 구조에서는 어떤 문제와 마주하게 될까요?

API 호출 수 제한

대개 외부 API는 무료인 경우 시간당 호출 횟수가 정해져있고, 유료인 경우 호출 수에 비례해 요금이 책정됩니다.
할당량이 정해져있거나 한정된 예산 안에서 제한적으로 사용해야 하므로 외부 API를 호출할 땐 그 횟수를 가능한 최소화해야 합니다.

유튜브 검색 API의 일일 할당량은 기본적으로 100건유튜브 검색 API의 일일 할당량은 기본적으로 100건
스포티파이 역시 30초간의 호출 수를 기반으로 API 요청을 조절스포티파이 역시 30초간의 호출 수를 기반으로 API 요청을 조절

소요 시간

운영중인 서비스가 외부 서비스에 의존하는 만큼 외부 서비스에 의한 지연 시간도 신경써야 합니다.
직접 작성한 코드가 아무리 빠르더라도 외부 서비스의 응답이 늦는다면 사용자의 체감은 그만큼 늘어지게 됩니다.

사용자의 요청이 외부 서비스에 의존하면 그에 따라 전체 응답 시간이 늘어난다사용자의 요청이 외부 서비스에 의존하면 그에 따라 전체 응답 시간이 늘어난다

캐싱으로 해결하기

예상하시다시피 이런 문제들은 캐싱으로 비교적 쉽게 해결할 수 있습니다.
동일한 요청이 이미 처리된 경우엔 캐싱된 값을 반환해주면 됩니다.

첫 번째 요청이 캐싱된 '이후' 들어오는 동일한 요청은 지연 시간과 호출 횟수를 줄일 수 있다첫 번째 요청이 캐싱된 '이후' 들어오는 동일한 요청은 지연 시간과 호출 횟수를 줄일 수 있다

Node.js에선 Map으로 캐싱을 간단하게 구현할 수 있습니다.

// Map을 이용한 간단한 캐싱
type Provider = "youtube" | "spotify";
type CacheKey = `${Provider}:${string}`;
interface SearchResult { ... }; // 검색 결과 타입

const cacheMap = new Map<CacheKey, SearchResult>();

const getCacheKey = (provider: Provider, term: string): CacheKey => {
    return `${provider}:${term}`;
}

const search = async (provider: Provider, term: string) => {
    const key = getCacheKey(provider, term);
    const cached = cacheMap.get(key);

    // 캐싱된 값이 있으면 외부 API를 호출하지 않고 캐싱된 값을 반환한다
    if(cached) {
        return cached;
    }

    // 서비스 제공 업체에 따른 검색 API 구현체를 가져온다
    const provider = providers.get(provider);

    // 캐싱된 값이 없으면 외부 API를 호출하고 값을 캐싱한다
    const result = await provider.search(term);
    cacheMap.set(key, result);

    return result;
}

자, 이제 문제가 말끔히 해결됐을까요?

캐싱되기 전에 요청이 몰리면?

만약 첫 번째 요청이 채 캐싱되기 전에 동일한 요청이 몰린다면 어떻게 될까요?

캐싱이 끝나기 전에 동일한 요청이 연달아 들어온 경우캐싱이 끝나기 전에 동일한 요청이 연달아 들어온 경우

위 상황처럼 사용자가 서버에게 검색 요청을 보내면 응답하기까지 2초가 걸린다고 가정해보겠습니다.

사용자 A, B, C는 모두 '침착맨'이라는 키워드로 검색을 요청합니다.

  1. 사용자 A는 오후 3시 0분 1초에 요청을 보내고 오후 3시 0분 3초에 응답을 받습니다.
  2. 사용자 B는 사용자 A의 응답 결과가 캐싱되기 전인 오후 3시 0분 2초에 요청을 보냈기 때문에 동일한 키워드이지만 다시 한번 외부 서비스의 검색 API를 호출합니다.
  3. 사용자 C는 오후 3시 0분 3초가 지나서 요청을 보냅니다. 이때는 '침착맨' 키워드의 검색 결과가 이미 캐싱되어있기 때문에 곧바로 응답을 받습니다.

최종적으로 사용자 A와 B로 인해 총 2회의 외부 API 호출이 발생합니다.

만약 사용자 B같은 케이스가 B 한 명이 아닌 n명이 있다면 어떻게 될까요?
캐싱되기 전이라면 동일한 요청임에도 불구하고 n+1만큼 외부 API를 호출하게 될 것입니다.

실제로 스트리밍 미터에서는 아이돌 신규 뮤직비디오가 공개됐을 때 똑같은 문제가 발생했었습니다.
그 날은 조회수 추이를 파악하기 위한 팬들의 트래픽이 몰려 API 할당량을 모두 사용해 다른 검색이 불가능했고, 과도한 스크래핑으로 인해 메모리 사용량에도 문제가 발생했었습니다.

이 글에서 해결하고자 하는 문제가 바로 이런 상황입니다.
짧은 시간 동안 몰려오는 동일한 요청은 일괄처리로, 그 이후에 들어오는 요청은 캐싱으로 처리하면 해결할 수 있습니다.

캐싱되기 이전의 요청들은 일괄처리(Batch Processing)캐싱되기 이전의 요청들은 일괄처리(Batch Processing)

이제 이 글의 부제에 걸맞게 Promise의 특성을 이용해 일괄처리캐싱을 한번에 해결해보겠습니다.

Promise로 일괄처리 및 캐싱

Promise의 특성

우선 잘 알고 계실 Promise의 특성을 상기해보겠습니다.

  1. Promise는 비동기 작업의 결과를 담고 있는 객체이며, 아래 4가지 상태로 표현됩니다.
    작업이 완료되지 않으면 대기(pending)
    작업이 성공하면 이행(fulfilled)
    작업이 실패하면 거부(rejected)
    작업이 이행 또는 거부되면 결정(settled)
  2. Promise Chain으로 작업 결과를 쉽게 전달할 수 있습니다.
    이때 then 리스너는 한 Promise 객체에 여러 번 등록할 수 있습니다.
  3. then 리스너는 Promise가 이미 수행된 후에 등록되어도 동작하며, 항상 비동기적으로 동작합니다.

1번은 많이 익숙하실 테니 2번과 3번을 조금 더 살펴보겠습니다.

우선 2번입니다. 한 Promise 객체에 then() 리스너를 여러 개 등록하는 예제입니다.
stream을 다뤄보신 분께는 분기 패턴으로 익숙하시겠지만, Promise에서 자주 보이는 패턴은 아닙니다.
(Promise Chaining과는 엄연히 다른 개념입니다!)

const asyncHello = async () => {
  // 2초 뒤에 문자열 Hello를 반환한다
  return new Promise<string>((resolve) => {
    setTimeout(() => {
      resolve('Hello');
    }, 2000);
  });
};

const hello = asyncHello();
hello.then((res) => console.log(`${res} World`));
hello.then((res) => console.log(`${res} Node.js`));
hello.then((res) => console.log(`${res} Rust`));
# 코드를 실행하면 2초 뒤에 결과가 동시에 출력된다
Hello World
Hello Node.js
Hello Rust

다음은 3번입니다. 이미 수행된 Promise를 나중에 등록하는 예제입니다.

// 작업 수행에 2초가 걸리는 함수
const async2sec = async () => {
  return new Promise<void>((resolve) => {
    setTimeout(() => resolve('Hello'), 2000);
  });
};

// 작업 수행에 4초가 걸리는 함수
const async4sec = async () => {
  return new Promise<void>((resolve) => {
    setTimeout(resolve, 4000);
  });
};

// 우선 async2sec를 먼저 호출합니다
console.time('task1');
const task = async2sec();
task.then(() => {
  console.timeEnd('task1');
});

console.log(task); // task를 확인합니다

// async2sec가 이미 완료됐을 3초 뒤에 수행할 작업입니다
setTimeout(() => {
  console.time('task2');

  console.log(task); // task를 확인합니다
  // task가 완료된 후에 async4sec을 수행합니다
  task.then(async4sec).then(() => {
    console.timeEnd('task2');
  });
}, 3000);
Promise { <pending> }
task1: 2.003s
Promise { 'Hello' }
task2: 4.000s

보시다시피 task2의 수행시간은 4초입니다.
3번 특성을 모르고 코드를 읽는다면 async2sec를 수행하는 데 2초, async4sec을 수행하는 데 4초가 걸리기 때문에 총 6초가 걸릴 것으로 예상할 수 있습니다. 하지만 async2sec를 다시 호출하여 새로운 Promise 객체를 생성한 것이 아니라 이미 이행된 Promise 객체를 이용하고 있기 때문에 async4sec을 수행하는 시간만 듭니다.

활용해보기

방금 살펴본 Promise 특성들을 활용해서 예제를 작성해보겠습니다.
클라이언트 <-> 운영 서버 <-> 외부 API 서버의 구조입니다.

먼저 외부 API 서버입니다.
요청이 들어온 시각을 콘솔에 출력하고, 요청받은 데이터를 2초 뒤에 반환합니다.

// 특정 채널에 업로드된 영상 수를 조회할 수 있는 외부 API 서버
import express from 'express';

// 편의를 위해 json 객체를 사용합니다.
const db: Record<string, { videoCount: number }> = {
  '침착맨': {
    videoCount: 30,
  },
  '침착맨 플러스': {
    videoCount: 47,
  },
};

express()
  .get('/', (req, res) => {
    const now = new Date().toISOString();
    console.log(`API 호출 발생 ${now}`);

    // 업로드된 영상 수를 조회할 채널 이름
    const term = req.query.term as string;

    const result = db[term];
    setTimeout(() => {
      res.json({
        result,
      });
    }, 2000);
  })
  .listen(6002, () => {
    console.log('Server Listening on 6002');
  });

다음은 운영 서버입니다.
요청이 들어오면 외부 API를 호출하고, 반환받은 값을 그대로 사용자에게 전달합니다.

// 사용자의 요청을 받아 외부 API를 호출하는 운영 서버
import express from 'express';
import axios from 'axios';

interface ApiResult {
  result: { videoCount: number };
}

const callApi = async (term: string) => {
  const { data } = await axios.get<ApiResult>('http://localhost:6002', {
    params: {
      term,
    },
  });

  return data;
};

express()
  .get('/', async (req, res) => {
    const term = req.query.term as string;
    const data = await callApi(term)

    return res.json(data);
  })
  .listen(6001, () => {
    console.log('Server listening on 6001');
  });

마지막으로 테스트를 위한 클라이언트 코드입니다. 운영 서버에게 10번의 요청을 연달아 보냅니다.

// '침착맨' 키워드로 검색을 10번 요청하는 테스트용 코드
import axios from 'axios';

const request = async () => {
  const { data } = await axios.get<{ result: { videoCount: number } }>(
    'http://localhost:6001', 
    {
        params: {
            term: '침착맨',
        },
    }
  );

  return data.result.videoCount;
};

(async () => {
  await Promise.all(Array(10).fill(null).map(request));
})();

클라이언트 코드를 실행해보면 다음과 같이 외부 API 서버에 10번의 API 호출이 발생했음을 알 수 있습니다.

# 외부 API 서버 콘솔
API 호출 발생 2024-03-30T15:12:07.677Z
API 호출 발생 2024-03-30T15:12:07.677Z
API 호출 발생 2024-03-30T15:12:07.677Z
API 호출 발생 2024-03-30T15:12:07.677Z
API 호출 발생 2024-03-30T15:12:07.678Z
API 호출 발생 2024-03-30T15:12:07.678Z
API 호출 발생 2024-03-30T15:12:07.678Z
API 호출 발생 2024-03-30T15:12:07.679Z
API 호출 발생 2024-03-30T15:12:07.679Z
API 호출 발생 2024-03-30T15:12:07.679Z

Promise의 특성을 적용하기 전에 먼저 언급했던 캐싱부터 적용해보겠습니다.

외부 API를 호출하는 코드를 getData 함수로 감쌌습니다.
getData 함수 내부에서는 Map을 통해 캐싱 여부를 확인한 뒤 데이터를 반환합니다.

// 운영 서버

// ...

const cacheMap = new Map<string, ApiResult>();
const getData = async (term: string) => {
  const cached = cacheMap.get(term);
  if (cached) {
    return cached;
  }

  const data = await callApi(term);

  cacheMap.set(term, data);
  return data;
};

express()
  .get('/', async (req, res) => {
    const term = req.query.term as string;
    const data = await getData(term);

    return res.json(data);
  })

// ...

클라이언트 코드를 실행해보면 결과가 그대로일 것입니다.
만약 2초 간격으로 요청을 10번 보냈다면 외부 API 서버에는 한 번의 요청만 출력이 됐겠지만,
앞서 보여드린 상황처럼 첫 번째 요청이 캐싱되기 전에 나머지 9번의 요청이 연달아 도착하기 때문입니다.

우리가 원하는 상황은 외부 API 서버에 단 하나의 요청만 도착하는 것입니다.
이제 Promise의 특성을 활용해 일괄처리까지 적용해보겠습니다. 코드가 아주 조금 수정됩니다.

// 운영 서버

// ...

const cacheMap = new Map<string, Promise<ApiResult>>();
const getData = async (term: string) => {
  const cached = cacheMap.get(term);
  if (cached) {
    return cached;
  }

  const data = callApi(term);

  cacheMap.set(term, data);
  return data;
};

// ...

어디가 바뀌었는지 눈치채셨나요?

const cacheMap = new Map<string, Promise<ApiResult>>();
const getData = async (term: string) => {
    // ...
    const data = callApi(term);
    // ...
}

바뀐 곳은 단 2곳뿐입니다.

  1. cacheMap의 Value 타입이 ApiResult에서 Promise<ApiResult>으로 바뀌었습니다.
  2. getData 함수에서 callApi 함수를 호출할 때 await 키워드를 사용하지 않습니다.

1번의 경우 타입만 변경된 것이니 런타임 동작에 영향을 미치지 않습니다.
실질적으로 런타임에 영향을 미치는 변경점은 2번인데요, await 키워드를 제거함으로써 Promise의 이행된 값이 아닌 Promise 객체를 반환하게 됐습니다.

다시 클라이언트 코드를 실행해보면 외부 API 서버에 단 하나의 요청만 도착했음을 알 수 있습니다.

# 외부 API 서버 콘솔
API 요청 발생 2024-03-30T15:51:10.517Z

바뀐 동작을 시각화해보면 다음과 같습니다.

cacheMap에 있는 하나의 Promise 객체에 여러 리스너가 등록된 모습cacheMap에 있는 하나의 Promise 객체에 여러 리스너가 등록된 모습

Promise의 2번 특성에서 설명드렸듯 하나의 Promise 객체에는 여러 리스너를 등록할 수 있습니다.
위 그림처럼 여러 사용자가 서버로 같은 요청을 연달아 보냈을 때, 서버는 요청에 따른 리스너를 수행합니다.
현재 4명의 사용자가 요청을 보냈으니 사용자에게 응답하기 위해 리스너는 총 4번 수행될 것입니다.

운영 서버 예제에서 작성한 리스너에서는 getData 함수를 호출하는데, getData 함수는 cacheMap에 이미 만들어진 Promise 객체가 있다면 해당 객체를 반환합니다. 현재 cacheMap에 있는 Promise 객체는 await 키워드를 사용하지 않아 대기(Pending) 상태입니다. 때문에 작업이 완료되는 대로 Promise 객체에 등록된 리스너들이 일괄적으로 수행될 것입니다. 즉, 하나의 비동기 작업에 연관된(chain) 여러 작업들이 일괄처리되는 것입니다!

그럼 캐싱은 어떻게 처리되는 걸까요?
3번 특성의 예제에서 살펴봤듯 Promise 객체는 수행이 끝나면 대기(Pending) 상태에서 이행(Fulfilled) 상태로 바뀝니다. cacheMap에 있는 Promise<{ pending }>가 작업이 이행되는 순간 Promise<{ result: { videoCount: 30} }>로 바뀌는 것입니다. 이미 이행된 작업이기 결과값을 그대로 사용하면 됩니다.

요컨대 Promise의 상태에 따라 일괄처리가 되기도, 캐싱된 값을 반환한다고도 말할 수 있습니다.

결론

Promise만으로 외부 서비스 호출을 최적화해보았습니다.
복잡한 코드나 라이브러리를 사용하지 않았음에도 그 변화는 꽤나 극적입니다.
외부 API를 덜 호출하여 비용을 아꼈고, 일괄처리캐싱 덕에 사용자가 원하는 작업을 더 빨리 수행할 수 있게 됐습니다.

물론 아직도 많은 과제들이 남아있습니다. 캐시 무효화, 멀티 프로세스 환경 등등 실제 환경에선 더 다뤄야할 문제들이 많습니다.
하지만 인프라나 정책 없이 언어의 스펙을 충분히 이해하는 것만으로도 극적인 개선을 이뤄낼 수 있음을 확인할 수 있었습니다.

언젠가 고민에 맞닥뜨렸을 때 새로운 도구를 살피는 것도 좋지만, 때론 손에 쥔 도구를 더 정교히 써보는 건 어떨까요?

참고

돌아가기
© 2024 VERYCOSY.