CSR 환경에서 SSR 속도 (절반만) 따라잡기

Park June Chul·2023년 2월 1일
4

React

목록 보기
6/7
post-thumbnail

이 글은 저도 프로덕션에서 사용해본 적이 없는 PoC 수준의 내용을 다룹니다.

브라우저의 동작 원리를 알고 계신가요?
주소창에 주소를 입력하고 엔터를 누르면 어떤일이 일어날까요?

그럼 우리의 첫번째 자바스크립트 코드는 엔터를 누르고 몇 초 후에 실행되는지도 알고 계신가요?

내 앱의 첫번째 네트워크 요청은 언제 실행될까?

이를 알아보기 위해 CRA로 앱을 생성하고 아주 간단한 리액트 코드를 작성해 보았습니다.
(이 글에서 알아보고자 하는것은 정확히 말하면 첫번째 자바스크립트 코드가 몇 초 후에 실행될까요 가 아니라 첫 번째 네트워크 요청이 몇 초 후에 실행될까요 입니다.)

const Product = ({ id }) => {
  const [data, setData] = React.useState();

  React.useEffect(() => {
    (async () => {
      setData(
        await fetch(
          `https://63da01a2b28a3148f67cef79.mockapi.io/products/${id}/`
        )
      );
    })();
  }, [id]);

  return (
    <div>
      <div>ProductName: {data?.name}</div>
      <img src={data?.avatar} style={{ width: "100px", height: "100px" }} />
    </div>
  );
};
const App = () => {
  return (
    <Product id={3} />
  );
};

이 코드를 실행하면 일반적인 CRA 앱에서 첫 번째 네트워크 요청이 언제 발생하는지를 알아볼 수 있습니다.

여기서는 0.4초 후네요.

API 응답은 약 0.2초가 걸렸습니다.

그럼 우리는 0.6초(0.4 + 0.2)후에 (그것도 아주 빠른 인터넷 환경일 때) 유저에게 컨텐츠를 보여줄 수 있게 됩니다.

물론 이부분에 대해 더 자세히 알고 계시는 분들은 0.6초도 끝이 아니라는것을 알것입니다. 왜냐면 이미지는 0.6초부터 로드를 시작하기 때문에 정말로 로드가 끝나는 시점은 더 더 길어질수도 있다는걸 말입니다.

0.6초는 느린 시간은 아니지만, 퍼포먼스는 빠르면 빠를수록 좋습니다.
심지어 이 글에서 다루는 내용은 눈으로 체감할 수 있습니다.

글의 마지막 부분에 보여드릴 예정입니다만, SSR은 얼마나 빠른지, CSR은 얼마나 느린지, 제가 이번에 소개할 SSR과 CSR의 중간 지점을 만들면 얼만큼 따라잡을 수 있는지를 영상으로 보여드립니다.

왜 0.4초를 기다리나요?

일단 분명히 해야 할 점은 모든 자바스크립트 프로젝트가 0.4초후를 소비하지 않습니다.

프로젝트의 규모, 혹은 네트워크 상황에 따라 천차만별이겠지만 제 테스트 환경은 localhost에 CRA 기본 구성이니 대부분의 경우에 이것보다 늘어날수는 있어도 줄어들기는 쉽지 않을수도 있겠네요.

이 부분을 이해하기 위해선 정말로 브라우저 주소창에 엔터를 누르면 무슨일이 일어나야 하는지 알아야 합니다.

물론 여기서는 DNS라던가 TCP라던가 이런부분은 다루지 않습니다.

  • 브라우저는 xxx.html 페이지를 로드합니다. 이 작업은 대부분의 경우에 굉장히 빠른 속도로 수행됩니다.
  • HTML 내부의 <script> 태그가 있다면 이를 로드합니다. CRA 환경이라면 아래 사진의 bundle.js 입니다. (이름은 다를 수 있습니다.)

---- 여기까지 0.16초가 걸렸습니다. ------

  • 브라우저는 index.html의 DOM 컨텐츠를 실제로 렌더합니다. (아래 사진의 파란 선)
  • 파란 선에서 빨간 선까지는 무슨 일을 하는지 정확히 모르겠습니다만, 아무래도 자바스크립트 엔진을 초기화하고 파싱하는데 걸리는 시간일 것 같습니다.

---- 여기까지 0.4초가 걸렸습니다.

  • 이후에는 자바스크립트가 실행되고 우리가 작성한 코드들이 실행됩니다.

요약하자면:

우리가 데이터를 보여주기 위해선 API콜을 수행해야 하는데, API 콜은 bundle.js 가 로드되어야 수행할 수 있습니다.
bundle.js는 index.html가 로드되어야 로드할 수 있습니다.

API 요청을 시작하기 전에도 여러단계의 waterfall이 이미 생겨버렸고 0.4초를 그냥 기다릴수 밖에 없겠네요.

bundle.js 보다 빨리 자바스크립트를 실행시킬 수 있을까요?

물론 가능합니다!

HTML에는 script 태그를 작성하고 스크립트를 작성할 수 있습니다.

<body>hi</script>
<script>console.log('hello world');</script>

index.html 에 스크립트를 직접 작성하는 방식은 옛날에는 흔한 방법이었지만 다양한 프레임워크와 번들링 도구가 발전하면서 이제는 금기시되는 일이 되었습니다. 저도 거의 몇년간 index.htmlscript 태그를 안써본 것 같네요.

우리는 잠시 과거로 돌아가서 우리의 코드를 가장 빨리 실행시킬수있는 index.html 에 코드를 적어보겠습니다.

<script>
prefetchStorage = {};
  
const prefetch = (url) => {
  const task = async () => {
    const json = await (await fetch(url)).json();
    return json;
  };
  prefetchStorage[url] = task();
};
  
const sp = new URLSearchParams(window.location.search);
const id = sp.get("product_id");
  
if (id) {
prefetch(`https://63da01a2b28a3148f67cef79.mockapi.io/products/${id}/`);
}
</script>

리액트가 초기화되지 않아도, 혹은 react-router-dom을 쓰지 않아도 이미 window.location 에는 주소를 식별할 수 있는 기능이 있습니다.

이를 이용해 해당 주소에서 어떤 API를 사용하게 될지 미리 예측하고 prefetch 하는 코드를 하드코딩할 수 있습니다.

const myfetch = async (url) => {
  if (prefetchStorage[url]) {
    console.log("cache hit: " + url);
    return await prefetchStorage[url];
  } else {
    const json = await (await fetch(url)).json();
    return json;
  }
};

앱 내부의 fetch 함수는 이렇게 한번 래핑했습니다. 실제 네트워크 요청을 수행하기 이전에 prefetch pool 에 데이터가 있으면 해당 Promise를 대신 반환합니다.

정말 줄어들었을까요?

(before)

(after)

이제 우리 앱은 bundle.js 가 로딩되기보다 전에도 API 요청을 수행합니다!
그리고 해당 요청을 react에서 그대로 이어와서 처리할 수 있게 되었습니다.

index.html 보다 빨리 실행할 수 있나요?

이것 또한 가능합니다.. 만 이 글의 주제에서 벗어납니다.
그래도 어떤 방식이 있는지 설명해보도록 하겠습니다.

  • SSG는 무려 요청이 들어오기도 전에 자바스크립트를 실행합니다. 베스트 케이스라면 유저는 그냥 CDN에서 단일 html 파일을 다운받는 정도의 기다림 시간만 필요할 수 있겠네요.
  • (SSG가 아니더라도)SSR은 이 부분에서 구조상 더 나은 속도를 제공합니다.

CSR과 SSR의 비교

ping 100ms의 LTE 환경이라고 가정하겠습니다.

(50ms) 라고 적힌 부분은 모바일 기기에서 혹은 모바일 기기로의 단방향 전송
(10ms) 라고 적힌 부분은 서버와 서버간의 유선 환경에서의 단방향 전송을 의미합니다.

  • CSR: GET /index.html (50ms) -> RESPONSE /index.html (50ms) -> GET /bundle.js (50ms) -> RESPONSE /bundle.js (50ms) -> GET /products/3 (50ms) -> RESPONSE /products/3 (50ms) -> GET /imgs/product3.png (50ms) -> RESPONSE /imgs/products3.png (50ms) = 400ms

  • SSR: GET /idnex.html (50ms) -> GET /products/3 (10ms) -> RESPONSE /products/3 (10ms) -> RESPONSE /index.html (50ms) -> GET /imgs/product3.png (50ms) -> RESPONSE /imgs/products3.png (50ms) = 220ms

여기서 우리는 두가지 차이점을 발견할 수 있습니다.

첫번째는 bundle.js 를 아예 로드하지 않아도 페이지를 그릴 수 있다는 것이고 (여기서 100ms가 줄어듭니다)
두번째는 API 요청이 서버와 서버간에 이루어지기 때문에 안정적이고 빠른 유선랜을 통해 일어난다는 점 입니다. (50ms vs 10ms) 여기서 80ms가 줄어듭니다.

그리고 더 더 빠르게 하려는 움직임들이 있습니다.

이 두 라이브러리는 속도를 위해 아주 근본적인 부분부터 다시 고려하여 작성된 프레임워크들입니다.

저도 사용해 본 적이 없어서 위 2개에 대해 자세히 적지는 못합니다만, 관심이 있으시면 읽어보셔도 좋을 것 같습니다.

그래서 뭐가 얼마나 빠른가요?

마지막으로 제가 간단한 테스트 코드를 작성해 실행해본 결과를 올려드리겠습니다.

위에서부터 차례대로

  • SSR(SSG)
    • 엄밀히 말하면 SSG는 아닙니다. 맨 첫번째줄은 index.html하드코딩된 div와 img입니다. 다르게 말하면 이건 SSG가 해주는 일을 손으로 직접 한 것 이라고 볼 수도 있습니다.
    • 제대로 된 SSG와 제가 CDN에 디플로이한 index.html 은 인프라적 차이, 그리고 파이프라인상 차이가 있을 수 있습니다만 이 글의 주제는 TTFB(Time to First Byte)가 아닌걸 감안하고 봐주세요.
  • prefetched CSR
    • 이 글에서 적은 기법을 사용해 index.html에서 로드를 시작한 경우 입니다.
  • CSR
    • 일반적인 useEffect에서 fetch 하는 코드입니다.

알아야 할 점

이 방법은 굉장히 실험적이며, 개선해야 할 부분이 많습니다. (예를들어 prefetch가 실패하면 어쩔건지)

저는 이 글을 통해 실험적인 prefetch 가 아니라 CSR과 SSR의 근본적 속도 차이는 어디서 나오는가를 알아가실 수 있으면 좋을 것 같습니다.
(뭐가 어떻게 돌아가는지를 아는것이 빠르게 만드는 첫걸음입니다)

profile
다른 곳에서 볼 수 없는 이상한 주제를 다룹니다. https://pjc0247.github.io/new-home

0개의 댓글