목록으로
JavaScript

Promise 캐싱으로 중복 API 호출을 방지하는 패턴

여러 컴포넌트가 동시에 마운트되면서 같은 토큰 API를 중복 호출하는 문제를 Promise 캐싱 패턴으로 해결한 경험을 공유합니다.

문제 상황

관리자 대시보드에서 이미지를 표시하려면 파일 토큰이 필요합니다. 이 토큰은 별도의 API를 호출해서 받아오는데, 문제는 한 페이지에 이미지를 표시하는 컴포넌트가 여러 개 있다는 점입니다.

페이지가 로드되면 컴포넌트들이 거의 동시에 마운트됩니다. 각 컴포넌트가 독립적으로 토큰 API를 호출하면, 같은 API가 3~4번 연속으로 호출됩니다.

관리자 페이지의 상품 목록 화면에서 이 문제를 발견했습니다. 상품이 리스트로 나열되는데, 각 상품마다 이미지를 가져오기 위해 파일 토큰이 필요합니다. 그런데 토큰이 아직 없는 상태에서 페이지에 진입하면, 상품 하나하나가 각자 토큰 발급 API를 호출해버립니다. 상품이 10개면 같은 API가 10번 호출되는 거였습니다.

단순 캐싱으로는 안 된다

처음 떠오르는 해결법은 토큰을 변수에 저장해두는 겁니다.

let cachedToken: string | null = null;

const getToken = async () => {
  if (cachedToken) return cachedToken;

  const token = await fetchFileToken();
  cachedToken = token;
  return token;
};

이 코드의 문제는 동시 호출을 막지 못한다는 점입니다. 컴포넌트 A, B, C가 거의 동시에 getToken()을 호출하면:

  1. A가 호출 → cachedToken이 null → API 호출 시작
  2. B가 호출 → cachedToken이 아직 null (A의 요청이 아직 안 끝남) → API 호출 시작
  3. C가 호출 → 마찬가지 → API 호출 시작

결국 3번 호출됩니다. cachedToken은 응답이 온 후에야 저장되니까요.

Promise를 캐싱한다

해결 방법은 결과가 아니라 Promise 자체를 캐싱하는 겁니다.

let cachedToken: string | null = null;
let pendingRequest: Promise<string> | null = null;

const getToken = async (): Promise<string> => {
  // 이미 토큰이 있으면 바로 반환
  if (cachedToken) return cachedToken;

  // 진행 중인 요청이 있으면 그 Promise를 반환
  if (pendingRequest) return pendingRequest;

  // 새 요청 시작, Promise를 저장
  pendingRequest = fetchFileToken()
    .then((token) => {
      cachedToken = token;
      return token;
    })
    .finally(() => {
      pendingRequest = null;
    });

  return pendingRequest;
};

이렇게 하면:

  1. A가 호출 → pendingRequest가 null → 새 API 호출, Promise 저장
  2. B가 호출 → pendingRequest가 존재 → 같은 Promise를 받아서 대기
  3. C가 호출 → 마찬가지 → 같은 Promise를 받아서 대기
  4. API 응답 도착 → A, B, C 모두 같은 토큰을 받음

API 호출은 1번만 발생합니다.

쿠키와 조합해서 사용하기

실제 코드에서는 토큰을 쿠키에도 저장해서, 페이지를 새로고침해도 토큰이 유지되게 했습니다.

import Cookies from "js-cookie";

let pendingRequest: Promise<string> | null = null;

export const getValidFileToken = async (): Promise<string | null> => {
  const existing = Cookies.get("f_token");
  if (existing) return existing;

  if (pendingRequest) return pendingRequest;

  pendingRequest = requestNewFileToken()
    .then((token) => {
      Cookies.set("f_token", token, {
        expires: new Date(Date.now() + 5 * 1000), // 5초
      });
      return token;
    })
    .catch(() => null)
    .finally(() => {
      pendingRequest = null;
    });

  return pendingRequest;
};

쿠키가 있으면 바로 사용하고, 없으면 Promise 캐싱 패턴으로 한 번만 요청합니다.

401 에러 시 자동 재시도

토큰이 만료되면 서버에서 401 응답이 옵니다. 이때 자동으로 토큰을 갱신하고 재시도하는 로직도 추가했습니다.

useEffect(() => {
  const fetchImage = async () => {
    if (!objectName) return;

    try {
      const token = await getValidFileToken();
      if (!token) return;

      const url = await getImageFromGateway(token, objectName);
      setImageUrl(url);
    } catch (error) {
      if (axios.isAxiosError(error) && error.response?.status === 401) {
        // 기존 토큰 삭제 후 재발급
        Cookies.remove("f_token");
        const newToken = await getValidFileToken();
        if (newToken) {
          const url = await getImageFromGateway(newToken, objectName);
          setImageUrl(url);
        }
      }
    }
  };

  fetchImage();
}, [objectName]);

catch 블록에서 401이면 쿠키를 삭제하고 getValidFileToken()을 다시 호출합니다. 기존 쿠키가 없으니 새로 발급받게 되고, 그 토큰으로 재시도합니다.

아래는 실제 네트워크 탭 비교입니다.

캐싱 적용 전 - file 토큰 요청이 상품 수만큼 발생

Promise 캐싱 적용 전 네트워크 탭

캐싱 적용 후 - file 토큰 요청이 1번만 발생

Promise 캐싱 적용 후 네트워크 탭

토큰 발급 요청이 4번에서 1번으로 줄었고, 이후 페이지 이동 시에는 쿠키에 토큰이 남아있어서 추가 요청 자체가 발생하지 않았습니다. 401 에러로 인한 이미지 깨짐 현상도 재시도 로직 덕분에 사라졌습니다.

정리

방식동시 호출 시
단순 변수 캐싱모든 호출이 API를 각각 요청
Promise 캐싱첫 번째 요청의 Promise를 공유

핵심은 "결과"가 아니라 "진행 중인 요청(Promise)"을 캐싱하는 겁니다. 토큰뿐 아니라 설정값 로드, 사용자 정보 조회 등 여러 곳에서 동시에 호출될 수 있는 API에 범용적으로 적용할 수 있는 패턴입니다.