실제 커머스 프론트엔드의 성능 개선을 바탕으로 썼다. 조직과 저장소를 식별할 수 있는 정보는 걷어내고 요청 흐름과 판단만 남겼다.

앞서 쓴 동시에 처리한다는 것에서 효과가 확인된 실험으로 짧게 언급했던 프리페치의 구조와 한계를 기술적으로 펼친 글이다.

다음 화면이 느린데 지연의 대부분이 API 응답 시간이라면 가장 좋은 해결책은 API를 빠르게 만드는 것이다. 하지만 프론트엔드가 당장 서버 구현을 바꿀 수 없거나, 서버 개선 일정이 멀리 있는 경우도 있다.

그럴 때 네트워크 시간을 없앨 수는 없어도 사용자가 기다리기 시작하기 전에 요청을 시작할 수는 있다.

이번에는 장바구니에서 주문 화면으로 이동하는 사이에 다음 화면의 데이터를 미리 요청했다. 호출 횟수는 늘리지 않고 호출 시점만 앞당겼다. 결과적으로 합법적인 꼼수에 가까웠다.

TL;DR

  • 프리페치는 느린 API를 빠르게 만들지 않는다. 네트워크 대기를 다른 작업과 겹친다.
  • 앞 화면과 다음 화면이 같은 QueryClientqueryKey를 사용하고, queryFn이 같은 데이터를 조회해야 캐시를 안전하게 재사용할 수 있다.
  • staleTime을 맞추지 않으면 다음 화면에서 즉시 다시 요청해 호출이 두 번 나갈 수 있다.
  • 이동 가능성이 높고, 조회에 부작용이 없고, 요청 입력이 이미 확정된 시점에만 유효하다.
  • 이동을 막지 않는 프리페치라면 실패 시 다음 화면의 기존 조회가 자연스럽게 다시 시도할 수 있어야 한다.
  • API 응답 시간을 줄이는 서버 개선, 응답 크기 축소, SSR을 대신하는 해법은 아니다.

기존 흐름: 화면이 열린 뒤에야 요청을 시작했다

원래 흐름은 자연스러웠다.

장바구니에서 결제하기 클릭
  -> 주문 가능 여부 확인
  -> 주문 화면으로 이동
  -> 주문 화면 JavaScript 실행
  -> order-form API 요청 시작
  -> 응답 대기
  -> 화면에 결제 요약 표시

문제는 네트워크 요청이 너무 늦게 시작된다는 점이었다. 사용자는 이미 결제하기를 눌렀지만, 라우트 전환과 다음 화면의 JavaScript가 준비된 뒤에야 가장 오래 걸리는 요청이 출발했다.

API 처리 시간을 당장 줄일 수 없다면, 이 순서를 조금 겹쳐볼 수 있다.

장바구니에서 결제하기 클릭
  -> 주문 가능 여부 확인
  -> order-form prefetch 시작 ─────────┐
  -> 주문 화면으로 이동                │ 네트워크 진행
  -> 주문 화면 JavaScript 실행          │
  -> 같은 queryKey로 캐시 사용 <────────┘
  -> 화면에 결제 요약 표시

총 작업량은 거의 같다. 달라진 것은 API 대기를 라우트 전환과 JavaScript 실행 뒤에 직렬로 두지 않고, 그 시간과 겹친 점이다.

구현: 같은 쿼리 옵션을 양쪽에서 사용한다

TanStack Query를 사용한다면 핵심은 프리페치 호출 자체보다 다음 화면과 완전히 같은 쿼리 정의를 공유하는 것이다.

import { queryOptions, useQuery, useQueryClient } from '@tanstack/react-query';

const orderFormQueryOptions = (checkoutId: string) =>
  queryOptions({
    queryKey: ['order-form', checkoutId],
    queryFn: () => fetchOrderForm(checkoutId),
    staleTime: 10_000,
  });

장바구니에서는 이동에 필요한 검증이 끝나고 요청 입력이 확정된 직후 프리페치를 시작한다.

const queryClient = useQueryClient();

const handleCheckout = async () => {
  const result = await prepareCheckout();

  if (!result.ok) {
    return;
  }

  void queryClient.prefetchQuery(orderFormQueryOptions(result.checkoutId));
  router.push(`/checkout?id=${result.checkoutId}`);
};

여기서는 프리페치를 await하지 않는다. 데이터가 다 올 때까지 현재 화면에서 기다리게 만들면 대기 장소만 바뀐다. 요청을 시작한 뒤 바로 이동해 라우트 전환과 네트워크를 겹친다.

다음 화면은 같은 옵션을 사용한다.

const { data, isPending } = useQuery(orderFormQueryOptions(checkoutId));

프리페치가 먼저 끝났다면 캐시 데이터를 바로 사용한다. 아직 진행 중이라면 다음 화면은 같은 쿼리의 완료를 기다린다. 프리페치가 실패했더라도 prefetchQuery는 일반적으로 오류를 던져 이동을 막지 않고, 다음 화면의 useQuery가 기존 오류 처리와 재시도 흐름을 담당할 수 있다.

호출 횟수를 그대로 유지하려면

프리페치를 넣었다고 자동으로 호출이 한 번만 나가는 것은 아니다. 다음 조건이 어긋나면 앞 화면과 다음 화면이 서로 다른 요청으로 인식될 수 있다.

1. queryKey가 완전히 같아야 한다

// prefetch
['order-form', checkoutId]

// page: 문자열과 숫자가 다르면 다른 키다
['order-form', Number(checkoutId)]

키의 값과 타입, 포함된 매개변수까지 같아야 한다. 그래서 쿼리 옵션 팩토리를 하나 만들고 양쪽에서 함께 쓰는 편이 안전하다.

2. 같은 QueryClient를 사용해야 한다

화면 전환 중 QueryClient가 새로 만들어지면 앞 화면에 채운 캐시가 사라진다. 앱 루트에서 안정적으로 유지되는 동일한 인스턴스인지 확인해야 한다.

3. staleTime을 양쪽에서 맞춰야 한다

TanStack Query는 기본적으로 캐시된 데이터를 즉시 stale 상태로 본다. 프리페치가 끝났더라도 다음 화면에서 데이터를 stale 상태로 판단하면 백그라운드 재요청이 발생할 수 있다.

prefetchQuery에만 staleTime을 주는 것으로는 부족하다. 공식 문서도 prefetchQuery에 지정한 staleTime은 해당 프리페치에만 적용되므로 useQuery에도 데이터 신선도 정책을 설정해야 한다고 설명한다. 공용 쿼리 옵션을 사용한 이유다.

4. 요청 입력이 확정된 뒤 시작해야 한다

사용자가 배송지나 상품 구성을 바꿀 수 있는 상태에서 미리 요청하면 다음 화면이 다른 입력으로 다시 조회한다. 이 경우 중복은 버그가 아니라 서로 다른 데이터 요청이다.

이번 패턴에서는 주문 가능 여부 확인이 끝나고 식별자가 확정된 뒤, 실제 화면 이동 직전에만 프리페치했다.

왜 클릭보다 hover에서 하지 않았나

프리페치 예제는 링크의 onMouseEnter에서 자주 시작한다. 문서나 상세 페이지처럼 조회 비용이 작고 이동 가능성이 높은 링크에는 좋은 방식이다.

하지만 주문 화면 데이터는 상대적으로 무겁고 사용자 상태에 의존한다. 모바일에는 hover도 없다. 버튼 근처에 마우스를 올렸다는 이유만으로 요청하면 사용하지 않을 데이터를 가져오거나, 아직 확정되지 않은 입력으로 캐시를 만들 수 있다.

그래서 클릭 직후에도 바로 요청하지 않았다. 앞 화면의 필수 검증이 성공한 뒤, 이제 실제로 이동할 가능성이 매우 높은 순간에 시작했다.

프리페치 시점은 빠를수록 좋은 것이 아니다. 낭비 요청이 거의 없으면서도 다음 화면보다 조금이라도 앞설 수 있는 지점을 찾아야 한다.

실패해도 이동은 막지 않는다

이 최적화의 중요한 조건은 기존 경로가 그대로 남는 것이다.

prefetch 성공 -> 다음 화면이 캐시 사용
prefetch 진행 중 -> 다음 화면이 같은 조회 완료 대기
prefetch 실패 -> 다음 화면의 기존 조회와 오류 처리 사용

프리페치 실패를 이유로 주문 화면 이동을 막으면 성능 최적화가 새로운 장애 지점이 된다. 미리 가져오기는 성공하면 빠르고, 실패하면 원래 속도로 돌아가는 부가 경로여야 한다.

초기 배포에서는 기능 플래그도 함께 사용했다. 운영 환경에서 중복 호출이나 데이터 불일치가 보이면 즉시 기존 호출 시점으로 돌아가기 위해서다.

이런 전환용 플래그는 오래 유지할 기능이 아니라 검증이 끝나면 제거할 임시 경로다. 플래그를 넣는 위치와 제거 조건은 전환용 피처 플래그, 지우기 쉽게 설계하기에서 정리한 원칙을 그대로 따랐다.

측정 결과

프로덕션 빌드에 모바일 수준의 CPU 제한을 적용한 로컬 측정에서는 다음 화면의 주요 데이터가 표시되는 시점이 약 1.7초에서 1.5초로 줄었다. 별도 QA 환경의 데스크톱 측정에서는 결제 요약 값이 채워지는 시점이 약 0.3초 앞당겨졌다.

절대적인 수치보다 중요한 것은 개선 구간을 설명할 수 있었다는 점이다.

  • API 응답 시간 자체는 거의 같았다.
  • API 호출 수도 한 번으로 같았다.
  • 요청 시작 시점만 앞으로 이동했다.
  • 앞당긴 시간만큼 다음 화면의 대기 구간이 줄었다.

느린 모바일 WebView에서는 라우트 전환과 JavaScript 실행 시간이 더 길기 때문에 네트워크와 겹칠 여지가 더 크다. 다만 이것은 기대일 뿐이므로 실기기 측정이나 운영 RUM, 동일 조건의 A/B로 다시 확인해야 한다.

이 꼼수가 맞지 않는 경우

다음 조건에서는 프리페치가 오히려 비용을 늘릴 수 있다.

  • 사용자가 다음 화면으로 이동할 확률이 낮다.
  • 요청이 조회가 아니라 상태를 변경하는 부작용을 가진다.
  • API 비용이 높거나 호출 제한이 엄격하다.
  • 앞 화면에서는 인증, 배송지, 선택 항목 같은 입력이 아직 확정되지 않는다.
  • 화면마다 QueryClientqueryKey 정책이 다르다.
  • 아주 최신 데이터가 중요해 짧은 시간의 캐시도 허용하기 어렵다.
  • 백그라운드 요청이 모바일 데이터와 배터리에 주는 비용이 더 크다.

특히 결제 실행, 쿠폰 사용, 재고 예약 같은 mutationprefetch처럼 다루면 안 된다. 이 패턴은 여러 번 실행돼도 상태를 바꾸지 않는 조회 요청에만 사용해야 한다.

결국 API를 고쳐야 한다

prefetch는 지연 시간을 제거하지 않는다. 사용자의 대기 구간 밖으로 일부를 옮길 뿐이다.

근본적인 선택지는 여전히 남아 있다.

  • API 내부 처리 시간 단축
  • 응답 크기 축소
  • 다음 화면에 꼭 필요한 데이터와 나중에 필요한 데이터 분리
  • 서버 렌더링 또는 서버 측 프리페치
  • BFF에서 여러 요청을 병렬화하거나 집계
  • 캐시 정책과 데이터 의존성 재설계

앞 화면에서 프리페치할 여유조차 없거나 API가 더 느려지면 효과는 사라진다. 그래서 이 방법은 서버 개선을 취소할 이유가 아니라, 서버 개선 전까지 사용자 대기를 줄이는 임시 전술에 가깝다.

마치며

느린 API를 만났을 때 프론트엔드가 할 수 있는 일은 제한적이다. 렌더링을 아무리 줄여도 네트워크 응답이 대부분을 차지한다면 목표 시간에 도달하지 못한다.

그래도 요청이 출발하는 위치는 바꿀 수 있다. 사용자가 다음 화면으로 이동할 것이 거의 확실하고, 입력이 확정됐으며, 같은 캐시를 재사용할 수 있다면 호출을 조금 먼저 시작해 대기 시간을 겹칠 수 있다.

API를 빠르게 만든 것은 아니다. 기다리기 시작하기 전에 API를 출발시켰을 뿐이다. 그래서 효과가 있고, 동시에 꼼수다.

참고 자료