AbortController로 네트워크 요청 최적화 하기

회고 · 2025. 3. 23.

문제점

웹 요청을 연달아 보내는 경우, 이전 요청들이 아직 처리 중인 상태에서 새로운 요청들이 계속 쌓이면서 병목 현상, 즉, 흐름이 막히는 현상이 발생할 수 있습니다. 경우에 따라, 사용자는 마지막 요청이 완료될 때까지 로딩을 기다려야 할수도 있습니다. 이는 매우 부정적인 사용자 경험으로 이어집니다. 예를 들어, 사용자는 서비스가 느리다고 느끼거나, 응답이 없다고 오해하여 페이지를 이탈할 수 있습니다.

해결 방법

1. HTTP/2 프로토콜을 사용하세요.

HTTP/2 프로토콜이란?

크롬 브라우저는 HTTP/1.1 프로토콜로 동일한 출처에 대해 동시에 6개의 TCP 연결만을 허용합니다. HTTP/2 는 브라우저에서 동시 요청에 제한이 없으므로 병목현상이 줄어듭니다. 이는 더 빠른 웹 통신을 위해 2015년에 도입되었으며 HTTP/2 나 gQUIC 같은 효율적인 프로토콜의 도입은 늘어나고 있습니다.

HTTP/2 프로토콜을 사용중인지 확인하는 방법

내 웹 사이트가 어떤 프로토콜을 사용하고 있는지 확인하려면 개발자도구 네트워크 탭을 확인하세요. 프로토콜 h2로 표시된다면 HTTP/2를 사용하고 있음을 말합니다.

HTTP/2 프로토콜을 사용하는 방법

HTTP/2같은 효율적인 프로토콜을 사용하기 위해서는 웹 브라우저와 웹서버가 모두 이를 지원해야합니다. 거의 모든 브라우저들은 이를 기본적으로 지원하므로 따로 설정할 필요가 없습니다. 대부분의 최신 웹 서버는 이를 지원하며, 따로 설정이 필요할 수 있습니다.

HTTP/2 프로토콜은 HTTPS 위에서 동작하도록 설계되었습니다. 따라서, HTTP/2 프로토콜을 사용하러면 우선 HTTPS 프로토콜을 사용해야 합니다. 프론트엔드 로컬 개발환경은 기본적으로 HTTP를 사용하므로, 로컬 개발 환경에서 HTTPS 사용하려면 따로 설정을 해주어야 합니다. (예: mkcert와 같은 도구를 활용하여 로컬 인증서를 생성할 수 있습니다.)

장점:

병렬 처리: 여러 요청을 동시에 처리하여 응답 시간을 단축합니다.

스트림 우선 순위: 요청의 우선 순위를 지정하여 중요한 리소스가 먼저 로드되도록 제어할 수 있습니다.

서버 푸시: 서버가 클라이언트 요청 없이도 리소스를 미리 전송하여 페이지 로드 속도를 높일 수 있습니다.

한계점 :
API 및 서버 지원: API 및 서버가 HTTP/2 프로토콜을 지원해야 합니다. 즉, 외부 API를 사용중이고 외부 API가 이를 지원하지 않는다면 온전한 이점을 누릴 수 없습니다.

추천 영상

[10분 테코톡] 🧃쿨라임의 HTTP/1.1, HTTP/2, 그리고 QUIC

2. 요청 수를 줄여보세요.

지연로딩을 통해 당장 필요하지 않은 리소스는 나중에 로드해보세요. 사용자가 스크롤을 내려 해당 영역에 도달했을 때 이미지를 로드하는 이미지 지연로딩은 대표적인 웹 최적화 방법입니다.

캐싱을 활용하여 반복적인 요청을 줄이고, 이미 가져온 데이터를 재사용할 수 있습니다.

데이터 병합을 통해 여러 개의 작은 요청을 하나의 큰 요청으로 병합해보세요.

3. 이전 요청을 취소해보세요.

연달아 요청하는 경우, 이전 요청에 대한 응답이 필요한다면 이전 요청을 취소하여 성능을 개선할 수 있습니다. AbortController로 웹 요청을 취소 할 수 있습니다. AbortController가 처음이라면 가이드라이브 데모를 확인해보세요.

React에서는 useEffect의 클린업 함수를 이용하면 새 요청이 있을 때 이전 요청을 다음과 같이 취소할 수 있습니다.

function ProductDetails({ productId }) {
  useEffect(() => {
    const controller = new AbortController();
    const { signal } = controller;

    const fetchProductData = async () => {
      try {
        const response = await fetch(`/api/products/${productId}`, { signal });
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const productData = await response.json();
        // productData를 사용하여 상태를 업데이트하거나 UI를 렌더링합니다.
        console.log("Fetched product data:", productData);
      } catch (error) {
        if (error.name === 'AbortError') {
          console.log('Fetch aborted');
        } else {
          console.error("Error fetching product data:", error);
        }
      }
    };

    fetchProductData();

    return () => {
      controller.abort(); // 컴포넌트가 언마운트되거나 productId가 변경되면 요청을 취소합니다.
      console.log("Cleanup: Abort fetch");
    };
  }, [productId]); // productId가 변경될 때마다 useEffect가 다시 실행됩니다.

  return (
    <div>
      {/* 제품 상세 정보를 렌더링합니다. */}
    </div>
  );
}
  1. 위 제품 상세보기 컴포넌트는 제품 ID를 상속받아, 제품 데이터를 요청하여 제품 상세 정보를 표시합니다.
  2. 제품 ID가 변경되면 해당 제품 ID로 재요청합니다.
  3. 제품 데이터 요청이 끝나지 않은 상태에서 새 요청을 해야하거나, 컴포넌트가 언마운트 되면 제품 데이터 요청을 취소합니다.

실제 상황에서 문제 해결하기

전월세 실거래가 공공데이터를 활용한 지도 서비스(대방)를 만들면서, 여러 요청이 쌓이면서 로딩 시간이 매우 길어지는 치명적인 문제를 직면했습니다. 지도를 움직여 지역이 바뀔 때마다, 그 지역의 데이터를 요청하는데, 데이터 패칭에 1000m ~ 2000ms 정도 소요되었습니다. 이때, 기존 데이터 패칭이 완료되기 전에 새로운 지역으로 움직이면 새 데이터 패칭을 기다려야 했습니다. 이것이 반복되면, 요청이 계속 쌓여서 로딩이 매우 길어지는 치명적인 이슈가 발생했습니다. 이를 해결하기 위해 새 요청이 있기 전에 기존 요청을 취소할 필요를 느꼈습니다. React-Query를 사용중이였기 때문에 이에 맞게 코드를 작성했습니다.

// useSise.ts
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
...

const useSise = () => {

    const queryClient = useQueryClient(); // 쿼리 클라이언트 가져오기

    ... // 쿼리 키 가져오는 부분 (리덕스나 서치파람 등에서 쿼리 키를 가져옵니다)

    // 새 요청이 발생할 때 이전 요청 취소
    // useEffect의 클린업 함수 부분은 의존성 배열이 변경되기 이전에 실행되어, 변경 이전의 값을 참고합니다.
    // exact: true 옵션을 사용하지 않으면(기본 설정), queryKey 배열과 부분적으로 일치하는 모든 쿼리가 취소됩니다.
    // exact: true 옵션을 사용한다면 정확히 이전 쿼리만 취소합니다.
    useEffect(() => {
        return () => {
            queryClient.cancelQueries({
                queryKey: [category, regionCode, slectedYYYYMMM, activeFilters],
                exact: true 
            });
        };
    }, [category, regionCode, queryClient, slectedYYYYMMM, activeFilters]);

        // useQuery는 쿼리 키 변경시마다 실행되어 해당 키에 해당 하는 데이터를 가져오고 캐시합니다.
    const { data, isPending, isError, error } = useQuery({
        queryKey: [category, regionCode, slectedYYYYMMM, activeFilters],
        queryFn: async ({ signal }) => {
                    // 패칭 로직
                // signal 객체는 AbortController와 연결되어 요청 취소 시 사용됩니다.
            // 예: fetch API 사용 시 signal 객체를 fetch 함수의 옵션으로 전달하여 요청 취소 가능
        },
    });

    return { data, isPending, isError, error };
};

export default useSiseWithReactQuery;

//App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            staleTime: Infinity,
            gcTime: Infinity, 
        },
    },
});

const App = () => {
    return (
            ...    
          <QueryClientProvider client={queryClient}>
            ...   
        </QueryClientProvider>
            ...
    );
}

export default App;

또한,React Query의 캐시 기능을 적극 활용하여 성능을 개선했습니다. staleTimegcTimeInfinity로 설정함으로써, 이전에 불러온 데이터는 항상 캐시에 저장되어, 사용자가 동일한 데이터를 요청할 때 즉시 제공될 수 있도록 했습니다. 이는 전월세 실거래가 데이터가 실시간성이 중요하지 않은 데이터라는 것을 고려한 설정입니다.

고려했지만 적용하지 않은 방법

연속적인 데이터 요청에 의한 병목 현상 이슈를 파악하고, 처음에는 요청 횟수를 직접적으로 혹은 간접적으로 제한하는 여러 방안을 떠올렸습니다.

  • 간접적인 방식: 사용자가 지도를 일정 수준 이상 확대했을 때에만 상세한 전월세 실거래가 데이터를 불러오도록 하는 방식을 생각했습니다. 지도가 확대된 상태에서는 새로운 지역으로 이동하는 것이 불편하여 요청 횟수를 간접적으로 제한할 수 있습니다.
  • 직접적인 방식:
    1. 데이터를 불러오는 중에는 지도 이동을 일시적으로 제한하여, 새로운 요청이 발생하는 것을 방지하는 방법을 떠올렸습니다.
    2. 또한, 요청 트리거 간에 일정 시간 간격을 두는(디바운싱) 방법을 떠올렸습니다. **** 예를 들어, 1~2초에 한 번만 요청을 보내도록 제한하여 연속적인 요청으로 인한 병목 현상을 완화할 수 있을 것으로 예상했습니다.

그러나 이러한 방식들은 사용자 경험 측면에서 이전 요청을 취소하는 방식에 비해 열위에 있으므로 도입하지 않았습니다.

느낀점

데이터 패칭 과정을 정확히 이해하고자 브라우저 개발자 도구의 콘솔 탭과 네트워트 탭을 적극적으로 활용한 것이 문제를 감지하고 원인을 파악하는데 큰 도움을 주었습니다. 내가 작성한 코드가 실제로 어떻게 작동하는지 이해하고자 열의를 가지고 도구를 사용한 것이 도움이 되었습니다.

문제 해결을 위한 코드를 작성하기 위해, 문제를 크게 다음과 같은 세가지 단계로 나누어 접근했고 이와 관련된 내용들을 차례로 학습했습니다.

  1. AbortController를 통한 웹 요청 취소하기
  2. React Query 환경에서 요청을 취소하기
  3. 매 요청마다 이전 요청을 취소하도록 useEffect 훅 활용하기

이러한 일련의 과정을 통해, 복잡해 보이는 문제도 작은 단위로 나누어 해결할 수 있다는 것을 다시 한번 느꼈습니다. 또한, 새로운 기술을 학습할 때는 공식 문서, 블로그, 검색, 생성형 AI 등 다양한 자료를 교차 검증하며, 공식문서에 자세히 나와있지 않는 내용과 부정확한 블로그 글들을 보완하며 비판적으로 학습하는 것이 중요하다는 것을 느꼈습니다. 또한, 코드 작성 이후와 문제 해결 이후에도, 학습한 내용들을 검증하고 정리하여 유지보수가 가능하도록 하였습니다.