← 글 목록

업무 사례

API 중복 호출 최적화

앱 WebView 화면의 API 호출을 확인하던 중 같은 API가 짧은 시간 안에 반복해서 호출되는 문제가 있었습니다.

각 호출부만 보면 모두 필요한 코드였습니다.

  • 화면 진입 전 preload
  • Activity preload
  • 화면이 mount될 때 실행되는 초기 조회
  • 여러 컴포넌트에서 사용하는 공통 데이터
  • 사용자 동작 이후 상태 갱신

하지만 앱 전체 흐름으로 보면 preload에서 받은 데이터를 화면 진입 직후 다시 요청하거나, 여러 컴포넌트가 동시에 같은 API를 호출하는 경우가 있었습니다.

이번 글에서는 앱 런타임에서 발생하는 중복 요청을 줄이면서 기존 UI 동작을 유지한 방법을 정리해 보려 합니다.

일반 웹과 정적 페이지는 제외하고 앱 WebView 런타임만 대상으로 진행했습니다.

🔍 중복 호출이 발생한 구조

대표적인 흐름은 다음과 같았습니다.

Entry 화면
  └─ Main 데이터 preload

Main 화면
  ├─ preload 결과 store 반영
  ├─ 컴포넌트 mount
  └─ 초기 refresh 실행

결과
  └─ 같은 API가 짧은 시간 안에 다시 호출됨

미션 화면도 비슷했습니다.

Activity preload
  ├─ 미션 정보 조회
  ├─ 미션 진행 상태 조회
  └─ 외부 미션 상태 조회

Mission 화면 mount
  ├─ 미션 정보 조회
  ├─ 미션 진행 상태 조회
  └─ 외부 미션 상태 조회

preload가 성공했더라도 화면에서는 성공 여부를 알 수 없었습니다.

데이터가 없어서 조회하는 것인지, preload가 아직 진행 중인지, 실패한 것인지 구분할 수 없으니 안전하게 다시 요청하고 있었습니다.

중복 요청 파악 방법

Network 탭에서 같은 URL이 여러 번 보이는 것만으로는 원인을 찾기 어려웠습니다.

호출부를 기준으로 다음 내용을 확인했습니다.

  • Entry에서 어떤 API를 preload하는지
  • Activity preload에서 다시 호출하는 API가 있는지
  • 화면 mount effect에서 어떤 refresh가 실행되는지
  • 같은 API를 사용하는 컴포넌트가 몇 개인지
  • 사용자 동작 이후 의도적으로 다시 조회하는 요청인지
  • 진행 중인 요청과 완료된 요청 중 어떤 것이 중복되는지

호출 목적을 구분하는 것도 중요했습니다.

제거 대상
- 동시에 실행되는 동일 GET 요청
- preload 성공 직후 실행되는 초기 조회
- 여러 컴포넌트가 각각 실행하는 동일 요청

유지 대상
- 사용자 action 이후 상태 갱신
- 날짜가 변경된 이후 콘텐츠 갱신
- 사용자 정보 수정 이후 재조회
- 실패한 요청의 재시도

호출 횟수만 줄이면 최신 상태가 필요한 UI까지 갱신되지 않을 수 있기 때문에 먼저 요청의 성격을 나눴습니다.

1. 진행 중인 요청 공유

미션 상태나 보상 상태처럼 자주 변경되는 데이터는 장시간 캐시하기 어려웠습니다.

이런 API에는 응답 캐시 대신 진행 중인 Promise만 공유했습니다.

const sharedRequests = new Map<
  string,
  Promise<unknown>
>();

function shareRequest<T>(
  key: string,
  request: () => Promise<T>
) {
  const existing =
    sharedRequests.get(key) as
      | Promise<T>
      | undefined;

  if (existing) {
    return existing;
  }

  const promise = request().finally(() => {
    if (sharedRequests.get(key) === promise) {
      sharedRequests.delete(key);
    }
  });

  sharedRequests.set(key, promise);

  return promise;
}

같은 요청이 동시에 들어오면 기존 Promise를 반환합니다.

export function fetchMissionStatus() {
  return shareRequest(
    "mission-status",
    api.fetchMissionStatus
  );
}

동작 방식은 다음과 같습니다.

첫 번째 호출
→ 실제 API 요청 실행

첫 번째 요청이 끝나기 전 두 번째 호출
→ 기존 Promise 반환

요청 완료
→ Map에서 Promise 제거

완료 이후 새로운 호출
→ 새로운 API 요청 실행

이 방식은 데이터를 장시간 저장하지 않습니다.

따라서 다음 화면 진입이나 사용자 동작 이후에는 다시 최신 상태를 조회할 수 있습니다.

실패한 요청 처리

실패한 Promise가 Map에 계속 남으면 이후 호출도 같은 실패 결과를 받게 됩니다.

그래서 성공 여부와 관계없이 요청이 끝나면 Map에서 제거했습니다.

const promise = request().finally(() => {
  sharedRequests.delete(key);
});

이후 호출에서는 정상적으로 다시 시도할 수 있습니다.

2. preload 상태 구분

기존에는 preload Promise가 없거나 실패한 경우와 정상적으로 완료된 경우를 구분하지 않았습니다.

다음 세 가지 상태로 분리했습니다.

type PreloadStatus =
  | "absent"
  | "succeeded"
  | "failed";

preload 결과를 확인하는 함수는 다음과 같습니다.

async function resolvePreloadStatus(
  preload?: Promise<unknown>
): Promise<PreloadStatus> {
  if (!preload) {
    return "absent";
  }

  try {
    await preload;
    return "succeeded";
  } catch {
    return "failed";
  }
}

화면의 초기 조회 조건도 preload 상태를 기준으로 변경했습니다.

function shouldRunInitialRefresh(
  status: PreloadStatus
) {
  return status !== "succeeded";
}

동작은 다음과 같습니다.

preload 성공
→ store의 데이터 사용
→ 화면 초기 중복 조회 생략

preload 없음
→ 기존 초기 조회 실행

preload 실패
→ 기존 초기 조회 실행

성공한 경우에만 요청을 생략하고 나머지는 기존 흐름을 유지했습니다.

preload 최적화 때문에 화면이 빈 상태로 남는 것을 방지하기 위한 처리였습니다.

3. preload 결과를 먼저 화면에 반영

preload 성공 여부만 확인하고 화면에서 store 데이터를 사용하지 않으면 UI가 비어 있을 수 있습니다.

따라서 화면 초기화 순서도 함께 확인했습니다.

1. preload 완료 대기
2. preload 결과를 store에 반영
3. 화면에서 store 데이터 사용
4. preload 성공 시 초기 refresh 생략

특히 미션 화면에서는 미션 종류마다 store가 달라 순서가 중요했습니다.

  • 일반 미션
  • 이벤트 미션
  • 외부 참여 미션
  • 외부 방문 미션
  • 오늘의 뽑기 상태

각 데이터가 preload에 포함돼 있는지 확인한 뒤 해당 데이터만 초기 조회를 생략했습니다.

4. 콘텐츠 API 런타임 캐시

매번 최신 상태가 필요한 미션 API와 달리 일간 콘텐츠는 같은 날 응답이 자주 바뀌지 않았습니다.

여러 컴포넌트가 같은 콘텐츠를 사용하고 있어 화면 이동이나 재렌더링 과정에서 동일 API가 반복될 수 있었습니다.

이 API에는 앱 메모리 기반 런타임 캐시를 적용했습니다.

type RuntimeCacheEntry = {
  value: unknown;
  expiresAt: number;
};

const responseCache =
  new Map<string, RuntimeCacheEntry>();

const requestsInFlight =
  new Map<string, Promise<unknown>>();

조회 순서는 다음과 같습니다.

1. 유효한 응답 캐시 확인
2. 진행 중인 동일 요청 확인
3. 둘 다 없으면 실제 API 호출
4. 성공 응답만 캐시에 저장
function loadRuntimeResponse<T>({
  key,
  request,
  cacheUntil,
}: {
  key: string;
  request: () => Promise<T>;
  cacheUntil?: number;
}) {
  const cached = responseCache.get(key);

  if (cached) {
    if (Date.now() < cached.expiresAt) {
      return Promise.resolve(
        cached.value as T
      );
    }

    responseCache.delete(key);
  }

  const existing =
    requestsInFlight.get(key) as
      | Promise<T>
      | undefined;

  if (existing) {
    return existing;
  }

  const promise = request()
    .then(response => {
      if (cacheUntil !== undefined) {
        responseCache.set(key, {
          value: response,
          expiresAt: cacheUntil,
        });
      }

      return response;
    })
    .finally(() => {
      requestsInFlight.delete(key);
    });

  requestsInFlight.set(key, promise);

  return promise;
}

5. 일간 콘텐츠는 자정까지 캐시

일간 콘텐츠는 날짜가 바뀌면 새로운 응답이 필요합니다.

고정된 24시간 TTL을 사용하면 오후에 조회한 데이터가 다음 날 오후까지 유지될 수 있습니다.

그래서 캐시 만료 시간을 다음 로컬 자정으로 설정했습니다.

function getNextLocalMidnight() {
  const next = new Date();

  next.setHours(24, 0, 0, 0);

  return next.getTime();
}
function loadDailyContent<T>(
  key: string,
  request: () => Promise<T>
) {
  return loadRuntimeResponse({
    key,
    request,
    cacheUntil: getNextLocalMidnight(),
  });
}

동작은 다음과 같습니다.

23:59 콘텐츠 조회
→ 응답 캐시

00:00 날짜 변경
→ 기존 캐시 만료

00:01 다시 조회
→ 새로운 API 요청

6. 사용자별 캐시 키 분리

같은 콘텐츠라도 사용자 정보에 따라 응답이 달라질 수 있습니다.

단순히 엔드포인트만 키로 사용하면 이전 사용자 응답을 다른 사용자에게 보여줄 가능성이 있습니다.

function getUserCacheKey(
  userKey: string,
  contentKey: string
) {
  return `${userKey}:${contentKey}`;
}

매개변수가 있는 API는 그 값도 캐시 키에 포함했습니다.

function fetchCategoryContent(
  category: string
) {
  return loadDailyContent(
    `daily:category:${category}`,
    () => api.fetchCategoryContent(category)
  );
}

예를 들어 CATEGORY_ACATEGORY_B는 서로 다른 캐시를 사용합니다.

daily:category:CATEGORY_A
daily:category:CATEGORY_B

7. 기본값으로 API를 먼저 호출하던 문제

사용자 정보가 아직 없는데 UI 기본값을 사용해 콘텐츠 API를 먼저 호출하는 흐름도 있었습니다.

const category =
  userCategory ?? "CATEGORY_A";

이렇게 처리하면 사용자 정보 조회가 끝나기 전에 기본 category API가 호출됩니다.

이후 실제 category가 확인되면 다시 API를 요청합니다.

사용자 정보 조회 전
→ CATEGORY_A 콘텐츠 요청

사용자 정보 조회 완료
→ 실제 CATEGORY_B 확인
→ CATEGORY_B 콘텐츠 요청

첫 번째 요청은 UI에서도 사용되지 않을 가능성이 높습니다.

그래서 사용자 정보가 없으면 콘텐츠 요청 자체를 실행하지 않도록 변경했습니다.

const enabled =
  hasUserInfo &&
  Boolean(userCategory);
useCategoryContent({
  category: userCategory,
  enabled,
});

동작은 다음과 같이 바뀝니다.

사용자 정보 없음
→ 콘텐츠 API 호출 안 함

사용자 정보 확인
→ 실제 category로 API 호출

8. 사용자 정보 수정 시 캐시 무효화

응답 캐시를 적용하면 사용자 정보가 변경됐을 때 기존 콘텐츠가 남을 수 있습니다.

예를 들어 사용자 정보 수정으로 category가 바뀌었는데 이전 응답을 계속 사용하면 UI가 갱신되지 않습니다.

정보 저장이 완료되면 사용자 기반 콘텐츠 캐시를 무효화했습니다.

async function saveUserInfo(
  input: UserInfoInput
) {
  const response =
    await api.saveUserInfo(input);

  invalidateUserContentCache();

  return response;
}
function invalidateUserContentCache() {
  invalidateRuntimeRequests(key =>
    key.startsWith("user-content:")
  );
}

정보 수정 이후에는 새로운 사용자 정보와 매개변수를 기준으로 다시 요청합니다.

기존 CATEGORY_A 콘텐츠 캐시
→ 사용자 정보 수정
→ 사용자 콘텐츠 캐시 제거
→ CATEGORY_B 콘텐츠 재조회

9. 변경 가능한 상태는 응답 캐시하지 않기

모든 GET API에 같은 캐시 정책을 적용하지 않았습니다.

보상 상태, 광고 참여 상태, 아이템 사용 여부처럼 사용자 동작으로 즉시 변경되는 데이터는 응답을 오래 보관하면 화면에 이전 상태가 남을 수 있습니다.

이런 API는 진행 중인 요청만 공유했습니다.

동시에 발생한 동일 요청
→ 하나의 Promise 공유

요청 완료 이후 다시 호출
→ 새로운 API 요청

사용자 동작이 성공하면 관련된 상태 키를 무효화했습니다.

async function claimReward() {
  const response =
    await api.claimReward();

  invalidateMutableState(
    "reward-status"
  );

  return response;
}

캐시 여부는 HTTP 메서드가 아니라 데이터가 언제 변경되는지를 기준으로 정했습니다.

일간 콘텐츠
→ 자정까지 응답 캐시

공지사항
→ 동시에 진행되는 요청만 공유

미션·보상 상태
→ 동시에 진행되는 요청만 공유

사용자 정보 기반 콘텐츠
→ 응답 캐시 + 정보 변경 시 무효화

10. Snackbar 동작 유지

API 호출을 줄이는 과정에서 Snackbar 동작 순서도 같이 확인했습니다.

기존 흐름은 다음과 같았습니다.

Snackbar 표시
→ 사용자가 action 클릭
→ Snackbar 닫기
→ 화면 이동 또는 미션 처리

최적화 과정에서 동작이 먼저 실행되면 다음 화면에서도 이전 Snackbar가 남아 있을 수 있습니다.

그래서 기존 순서를 유지했습니다.

function handleSnackbarAction() {
  dismissSnackbar();
  runMissionAction();
}

API 호출 횟수만 확인한 것이 아니라 해당 응답을 사용하는 UI의 기존 동작도 같이 확인했습니다.

🧪 테스트

중복 요청은 실제 요청 횟수를 기준으로 테스트했습니다.

동시에 호출하면 한 번만 요청

const deferred = createDeferred();

apiMock.mockReturnValue(
  deferred.promise
);

const first = fetchMissionStatus();
const overlapping =
  fetchMissionStatus();

expect(apiMock).toHaveBeenCalledTimes(1);

deferred.resolve({
  missions: [],
});

await Promise.all([
  first,
  overlapping,
]);

완료 후에는 새로운 요청 허용

await fetchMissionStatus();
await fetchMissionStatus();

expect(apiMock).toHaveBeenCalledTimes(2);

진행 중인 요청만 공유하고 완료된 응답은 장기 캐시하지 않는지 확인했습니다.

실패 후 재시도

await expect(
  fetchMissionStatus()
).rejects.toThrow();

await expect(
  fetchMissionStatus()
).resolves.toEqual({
  missions: [],
});

실패한 Promise가 Map에 남지 않는지 확인했습니다.

매개변수별 캐시 분리

await fetchCategoryContent("CATEGORY_A");
await fetchCategoryContent("CATEGORY_B");
await fetchCategoryContent("CATEGORY_A");

expect(fetchMock).toHaveBeenCalledTimes(2);

자정 이후 재조회

setSystemTime("2026-08-04 23:59");

await fetchDailyContent();

setSystemTime("2026-08-05 00:01");

await fetchDailyContent();

expect(fetchMock).toHaveBeenCalledTimes(2);

사용자 정보 변경 이후 재조회

await fetchDailyContent();
await saveUserInfo(nextUserInfo);
await fetchDailyContent();

expect(contentRequestCount).toBe(2);

적용 결과

이번 최적화에서 적용한 기준은 다음과 같습니다.

동시에 겹치는 요청
→ 진행 중인 Promise 공유

preload 성공 직후 초기 조회
→ 생략

preload 실패 또는 없음
→ 기존 초기 조회 유지

같은 날 유지되는 콘텐츠
→ 다음 자정까지 메모리 캐시

사용자 action으로 변경되는 상태
→ 진행 중인 요청만 공유

사용자 정보 변경
→ 관련 콘텐츠 캐시 무효화

실패한 요청
→ 캐시하지 않고 다음 호출에서 재시도

데이터가 바뀌는 시점과 응답을 사용하는 UI에 맞춰 정책을 나눴습니다. 동시에 들어오는 요청은 공유하고, 완료된 응답은 재사용해도 되는 경우에만 캐시했습니다.

추가 검증: 호출 수와 데이터 최신성

앞에서 정리한 동작이 실제 호출 수에도 반영되는지 확인했습니다.

변경 전후 커밋의 소스를 같은 Node 환경에 각각 격리하고, 동일한 입력과 API 테스트 대역을 사용해 비교했습니다. 아래 수치는 각 getter를 정해진 순서로 호출한 로컬 테스트 결과입니다. 실제 서비스 전체 트래픽을 측정한 것은 아닙니다.

시나리오이전이후
변경 가능한 상태의 동일 getter를 완료 전 두 번 호출하위 API 2회하위 API 1회
위 두 호출이 완료된 뒤 한 번 더 조회누적 3회누적 2회
같은 운세 콘텐츠를 동시에 조회한 뒤 다시 조회fetch 3회fetch 1회
띠 콘텐츠를 A → B → A 순서로 조회fetch 3회fetch 2회

보상·미션처럼 변경 가능한 상태는 진행 중인 Promise만 공유했습니다. 요청이 끝난 뒤에는 다시 최신 상태를 조회할 수 있어야 했기 때문에, 콘텐츠 응답 캐시와는 다른 정책을 적용했습니다.

일간 콘텐츠는 같은 날의 응답을 재사용하되, 날짜가 바뀐 뒤 처음 조회하면 새로운 fetch가 한 번 실행되는 것도 확인했습니다.

이번 테스트에서는 중복 호출이 줄어드는지와 필요한 갱신이 그대로 실행되는지를 함께 확인했습니다. 인증 서버 왕복, 광고 SDK 트래픽, 전체 화면 준비 시간이나 실제 서버 부하 감소율은 측정하지 않았습니다.

표의 각 행은 별도 시나리오이므로 호출 수를 합산하지 않았습니다.