본문 바로가기
성능

프론트엔드에서 네트워크 요청 줄이기

by Luke K 2023. 7. 24.

DAU 50만 정도의 순간 트래픽이 높은 서비스를 개발중이다.

그리고 외부 API를 사용하기 때문에 트래픽 제어가 쉽지 않고 요청이 많아질 경우 비용도 높아지는 문제가 있다.

또 현재 개발하고 있는 앱의 연령층은 10대, 40~50대 위주이기 때문에 데이터 비용의 부담도 유저의 이탈을 만들게되곤 한다.

그렇기에 사용자 경험을 저해하지 않는 수준의 API 요청 최소화를 생각하며 개발하고 있다.

시행착오를 겪으며 사용한 방법들과 고려해야했던 이유들을 다시 한번 검증하고 공유하기 위해 글을 정리한다.

내용을 잘 정리하기 위해 글을 두 편으로 정리하려한다.

 

Table Of Content

  • API 요청을 줄여야 할 때는 언제일까
  • 적당한 단위를 산정하여 인피니티 스크롤 적용하기
  • 정적 페이지로 만들어 관리하기

다음 글 Table Of Content

  • 브라우저 스토리지를 이용한 캐싱
  • CDN(cloudfront) 활용하기

API 요청을 줄여야 할 때는 언제일까

API 요청을 줄이려면 이에 걸맞은 코드도 필요하다.

즉 유지보수해야할 코드가 늘어난다.

또 API 요청을 줄이는 코드와의 의존성이 생겨 테스트 코드 작성도 더 어려워질 수 있다.

API 요청을 줄여야겠다고 생각했던 경우는 다음과 같다.

  • 사용자가 보지 않을 정보를 가져오는 경우
  • 데이터의 변경이 잦지 않거나 실시간성으로 변경될 필요가 없는 경우
  • 요청이 순간적으로 몰리는 페이지의 경우

위와 같은 경우에는 API 요청을 줄이는 코드의 추가를 고려해볼 필요가 있다.

하지만 유지보수 비용이 늘어나는 것은 모두가 경계할 것이다.

어떻게 하면 유지보수 비용은 낮추면서 API 요청을 줄일 수 있을까?

 

페이지가 길어진다면 적절한 단위로 인피니티 스크롤을 도입해보자.

도입에 앞서 적절한 단위를 생각하는 기준을 정해보자.

필자는 다음과 같은 기준으로 적용중이다.

  • 25:9 비율기기(갤럭시 폴드 접은 상태)에서 랜딩 화면에 보이지 않는 부분중에 스크롤 한번에 보기 어려운 영역들
  • 한 페이지에 불러올 수 있는 데이터의 크기가 nMB 이상인 경우
  • 구좌가 반복된다면 150vh에 걸리는 구좌 단위

적절한 단위를 나누었다면 사용자의 스크롤 단위에 맞추어 적용해야한다.

이 때 사용하기 좋은 API로 Intersection Observer(교집합 관찰기)가 있다. 특정 영역과 사용자 화면의 교집합을 관찰하는 API다.

물론 DOM API 에서는 clientY, clientX를 제공해주고 이를 바탕으로 직접 구현할 수 있다.

하지만 Intersection Observer를 이용해 써먹기 좋은 hook이나 함수로 만들어두면 좀 더 직관적이고 재사용하기 쉬운 형태로 표현할 수 있다.

리액트에서 useIntersectionObserverEffect 훅 만들기

import React, { RefObject, useEffect } from 'react';

interface UseIntersectionObserverEffectProps {
  root?: RefObject<Element>;
  target: RefObject<Element>;
  onIntersect: () => void;
  threshold?: number;
  rootMargin?: string;
  enabled?: boolean;
}

export default function useIntersectionObserverEffect({
  root,
  target,
  onIntersect,
  threshold = 0.5,
  rootMargin = '0px',
  enabled = true,
}: UseIntersectionObserverProps): void {
  useEffect(() => {
    if (!enabled || !target.current) return;

    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) onIntersect();
        });
      },
      {
        root: root?.current,
        rootMargin,
        threshold,
      }
    );

    observer.observe(target.current);
    
    return () => observer.unobserve(target.current);
  }, [enabled, target]);
}

코드를 살펴보면 IntersectionObserver 생성자의 들어갈 값과 useEffect 내의 콜백의 실행 여부를 결정하는 enabled를 받는다.

이 함수를 잘 이용하는 방법을 생각해보자.

1. 적당한 단위로 컴포넌트들을 묶어 배열을 만들고 각 컴포넌트는 Dynamic Import할 수 있도록 한다.

2. 다음 컴포넌트를 배열에 추가하는 함수를 만든다(onIntersect).

3. useRef의 결과를 인피니티 스크롤의 기준이 되는 요소를 만들어 넘겨주고 target에 ref를 넘겨준다(target).

4. 그리고 인피니티 스크롤의 기준이 되는 요소는 position을 absolute로 바꾸고 적절한 bottom값을 넣어준다.

5. root나 rootMargin, threshold를 지정해야할 필요는 없을 지 확인하고 넣어준다.

 

위 방법을 사용하고 컴포넌트 내부에 API 요청을 하는 코드가 있다면 그 컴포넌트가 속해있는 블럭이 호출될 때 API 요청이 일어나기 때문에 API 요청도 컴포넌트에 맞추어 개선할 수 있다.

 

위에 만든 훅은 onIntersect를 인자로 받기 때문에 react-query의 useInfiniteQuery등의 API 요청을 단위별로 나누어할 때도 재사용 가능하다. 

 

useInfiniteQuery 공식 예제를 살펴보자

const {
  fetchNextPage,
  fetchPreviousPage,
  hasNextPage,
  hasPreviousPage,
  isFetchingNextPage,
  isFetchingPreviousPage,
  ...result
} = useInfiniteQuery({
  queryKey,
  queryFn: ({ pageParam = 1 }) => fetchPage(pageParam),
  ...options,
  getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
})

useInfiniteQuery 함수는 결과로 fetchNextPage라는 다음 페이지를 호출하는 함수와 hasNextPage라는 boolean값을 준다.

이를 각각 onIntersect와 enabled에 넣어 위와 같은 방식으로 해결할 수 있다.

 

정적 파일들로 관리하면 API 요청을 줄일 수 있다.

Next.js가 소개하는 Static Generation

정적 파일은 일반적으로 브라우저에서 렌더링되는 js, html, css, image 같은 파일들을 말한다.

정적 파일들을 만들어 API 요청을 줄이는 것은 데이터가 자주 바뀌지 않는 페이지에 주로 사용할 수 있다.

정적 파일들로 API 요청을 줄이기 위해서 빌드 시간에 완성된 HTML을 만드는 방식을 주로 사용할 수 있다. 

Next.js의 getStaticProps를 이용하면 좀 더 편하게 사전 렌더링을 이용할 수 있다.

왜냐면 Next.js의 getStaticProps가 제공해주는 revalidate이라는 옵션이 있다.

revalidate 10분(60 * 10)으로 정해준다면 프론트 서버는 10분마다 새로운 document(HTML)을 제작한다.

이 옵션을 사용한다면 사용자가 50만명이 들어올 때 정적페이지가 아니라면 50만번 API 요청을 보낼 것을 10분에 한번씩만 보내도록 만들 수 있다.
적절하게 사용하면 많은 비용을 아낄 수 있다. 

 

예제 코드를 살펴보자.

export async function getStaticProps() {
  const res = await fetch('https://api.example.com/posts')
  const posts = await res.json()

  return {
    props: {
      posts,
    },
    revalidate: 60 * 10,  // 10분 후에 페이지를 재생성합니다.
  }
}

이런식으로 사용할 수 있다.

getStaticProps를 이용한 page와 이용하지 않은 페이지의 document를 비교해보자.

const Page_ArticleNoIndex: NextPage = () => {
  const { data, isLoading } = useQueryFn<{ title: string; content: string }>(
    API_BOARDS_EACH_ARTICLE('206651', '235047')
  );

  if (isLoading) return <LottieColorfulLoading />;
  
  return (
    <>
      <NavigationBarDefault title={data.title} />
      <div
        style={{ padding: '0px 12px' }}
        dangerouslySetInnerHTML={{ __html: data.content }}
      />
    </>
  );
};

이러한 간단한 게시글 페이지가 있다.

데이터는 없는 HTML

정적 페이지로 바꾸지 않으면 정적이지 않은 데이터들은 HTML로 오는 것이 아닌 JS를 통해 클라이언트 사이드에서 렌더링하게 된다.

정적 페이지로 바꿔보자

페이지 코드는 다음과 같다.

const Page_ArticleNoIndex: NextPage<TProps> = ({ title, content }) => {
  const { isFallback } = useRouter();
  if (isFallback) {
    return <LottieColorfulLoading />;
  }
  return (
    <>
      <NavigationBarDefault title={title} />
      <div
        style={{ padding: '0px 12px' }}
        dangerouslySetInnerHTML={{ __html: content }}
      />
    </>
  );
};

데이터가 미리 그려져있는 HTML

 

 

한번 더 정리해보자.

1. getStaticProps를 Pages 내부에 파일에서 적용한다.

2. 페이지를 빌드한다.

3. 빌드할 때 getStaticProps를 적용한 페이지는 모든 API 응답을 적절한 text로 바꾸어 완성된 HTML을 만든다.

4. 사용자가 해당 페이지에 접속하면 이미 모든 텍스트가 적용된 HTML을 받기 때문에 API 서버에는 재요청하지 않는다.

5. 특정 시간마다 갱신이 필요할 수도 있는 정보라면 revalidate 옵션을 활용하면 특정 시간이 지날 때마다 다시 빌드된다.

 

마무리

필요이상의 요청으로 서버에 부담을 준다면 서버 안정화에도 문제가 생길 뿐더러, 비용 문제를 겪게 된다.

하지만 요청을 줄이는 코드도 적절하게 추가하여 유지보수 비용을 낮추는 것도 중요하다.

인피니티 스크롤을 적용하여 API 요청을 사용자의 스크롤 위치에 따라 적절히 분산하면 필요한만큼만 요청할 수 있다.

그리고 이를 위해 Intersection Observer API를 사용하고 이를 hook을 만들어 사용한다면 재사용하기 쉽다.

또 정적 페이지를 만들어 서빙함으로써 API 요청을 정말 많이 줄일 수 있다.

이를 위해 Next.js의 getStaticProps 사용을 고민할 수 있고 revalidate 옵션을 통해 자동 갱신도 가능하다.

 

 

댓글