본문 바로가기

개발/React

더 좋은 유저 경험을 위한 Streaming SSR

반응형

https://frontenddesign.codelly.dev/guide/architecture/Streaming-Server-Side-Rendering.html#ssr%E3%81%AE%E5%95%8F%E9%A1%8C%E7%82%B9

 

 

 

목차

 

  1. 서론
  2. Streaming SSR의 등장 배경, 기존의 CSR과 SSR 방식에 관하여
    • 웹 성능 지표 Remind
  3. Streaming SSR이란 ?
  4. Streaming SSR의 동작 방식
    • Progressive Hydration
    • Streaming SSR이 가능한 이유
  5. React 진영에서 Streaming SSR까지 오기까지의 여정
  6. 실제 구현 예시 및 이로 인한 성능 개선 지표 (미정)
  7. Streaming SSR의 전망
  8. 결론

 

 

서론

 

최근에 회사에서 CSR 기반의 Vite 프로젝트만을 진행하다가 Next.js로 이루어진 글로벌 서비스 프로젝트를 메인으로 작업하게 되어 SSR 방식을 제대로 경험하게 되었고, 그 과정에서 프론트엔드 개발자로서 사용자에게 더 좋은 경험을 주기 위한 방법들을 고민하던 중 좋은 글들을 만나게 되어 앞으로 사내 프로젝트에 시도해보고자 관련 내용부터 정리해보려고한다. 

 

 

Streaming SSR의 등장 배경, 기존의 CSR과 SSR 방식에 관하여

 

Streaming SSR이 등장한 배경을 알기 위해서는 먼저 기존에 어떤 방식의 렌더링이 존재했는지를 알아야한다. 예전의 방식까지는 언급하지 않도록 하고 요즘 많이 사용하는 방식을 기준으로 살펴보겠다.

 

 

시작하기에 앞서 각 내용에서도 설명하겠지만, 두 방식의 차이를 더 확실하게 알기 위해서는 웹 성능 지표에 대한 부분을 간단하게 짚고 넘어가면 이해하기 쉬울듯하다.

 

 

웹 성능 지표 Remind

 

 

  • TTFB (Time to First Byte): 브라우저(클라이언트)가 서버에 요청을 보낸 후 첫 번째 바이트를 받기까지의 시간이다. 즉 요청을 보낸 후 다시 응답을 받기 시작한 시간이라고 보면 된다.

  • FCP (First Contentful Paint): 페이지가 로드되기 시작한 시점부터 컨텐츠의 일부가 화면에 렌더링될 때까지의 시간이다. 요청에 대한 응답을 받기 전에는 유저가 화면에서 어떠한 컨텐츠도 볼 수 없기때문에 FCP는 중요한 지표로 여겨진다. (컨텐츠를 가지고오고있다는 로더가 보여지는 시점 역시 FCP 이다.)
  • LCP (Largest Contentful Paint): 화면 내에서 가장 큰 콘텐츠 요소가 렌더링되는 시점이다. 일반적으로는 점진적으로 데이터를 받아오는 경우 순간순간 LCP가 변경되는데, 마지막 가장 큰 컨텐츠가 그려진 시간을 기준으로 한다.

  • TTI (Time to Interactive): 페이지가 완전히 상호작용 가능한 상태가 되는 시점이다. 화면을 그리기만 해서는 상호작용이 불가능하기때문에 이 시간을 단축하는 것 역시 웹 성능 개선에 있어서 중요한 지표로 여겨진다.

 

 

 

 

간단하게 웹 성능 지표에 대해 알아보았으니 이제 본격적으로 CSR과 SSR에 대한 설명을 시작해보겠다. 실제 발전의 흐름을 보면 SSR이 먼저였지만 글의 흐름을 위해 CSR을 먼저 설명해보겠다.

 

 

1. Client Side Rendering(CSR)

 

 

 먼저 가장 많이 사용하는 방식 중 하나인 Client Side Rendering(CSR) 이다. CSR은 이름 그대로 클라이언트 사이드에서 렌더링을 수행하기 위한 방식으로 서버로부터 기본적인 HTML을 받아오고 프로젝트 전체에 사용하는 JS 번들파일을 받아와 이를 활용해 렌더링하는 방식이다. 이러한 방식은 최초에 전체에서 사용하는 JS파일 번들을 가지고 와서 사용하기 때문에 이후부터는 클라이언트 측에서 라우팅과 렌더링을 수행하여 화면전환이 매끄럽게 느껴진다. 하지만 전체 JS파일을 가지고 오는 것은 당연하게도 파일의 크기가 크기때문에 사용자의 네트워크 상황에 따라 컨텐츠를 마주하는 시간이 상당히 길어질 수 있다. 그럼에도 불구하고 CSR은 클라이언트 사이드에서 JS를 통해 컨텐츠를 렌더링하기때문에 그려진 후에는 빠르게 상호작용이 가능하기 때문에 비교적 빠른 TTI(Time To Interaction)를 가진다. 

 

 이러한 한계를 극복하기 위해 Lazy Loading과 Code Splitting을 사용하여 초기 로딩 시 불러오는 JS 번들파일의 크기를 줄여 FCP 및 TTI를 단축하는 방법도 자주 사용한다. 이는 실제로 Code Splitting을 통해 나눠둔 번들파일을 CloudFront와 같은 곳에 캐싱해두어 최초 진입 이후에는 더 빠르게 응답받아 지면을 그려줄 수 있도록 한발 더 최적화하는 것이 좋다.

 

 또한 많이 알려진 문제 중 하나인 SEO 최적화 이슈가 존재한다. 이는 실제로 서버에서는 특정 경로에 대한 요청이 들어오면 해당 경로에 존재하는 정적 파일을 반환해주려고 하는데 이때 CSR로 구현된 프로젝트의 경우 JS로 가상의 라우팅을 만들어냈을 뿐 실제 해당 경로에 정적 파일이 있는 것은 아니기때문에 해당 경로의 정적 파일을 찾지 못해 Fallback 라우트로 Root에 있는 index.html을 반환하게 되는 것이다. 그로 인해 어떤 경로로 진입하더라도 동일한 Root HTML파일을 반환받아 해당 지면에 맞는 커스텀 메타태그를 보여줄 수 없어 SEO 최적화가 불가능한 것이다.

 

 React 프로젝트를 사용하고있다면 메타태그를 변경해주는 React-Helmet 라이브러리도 들어본적이 있겠지만, 이 역시도 JS실행 이후 메타태그를 변경해주기때문에 SEO 최적화에 영향을 줄 수는 없다. 이를 해결하기 위해서는 서버사이드 렌더링이 가능한 Next.js를 사용하거나 인프라의 도움을 받아 실제 서버에서 해당 경로의 요청을 인터셉트하여 메타태그를 변경하여 반환해주는 방법도 존재한다. 실제로 필자의 회사에서는 Vite로 이루어진 프로젝트를 운영하고있지만 공유하기 시에 특정 케이스에서 알맞는 메타태그를 보여주어야하는 이슈가 있어 중간에 Express 서버와 DOM을 조작할 수 있는 Cheerio 라는 라이브러리를 활용하여 이를 해결했다. 혹시 이미 거대한 CSR프로젝트를 운영하고있어 마이그레이션이 어려운 상태임에도 불구하고 부득이하게 SEO 최적화가 필요하다면 이러한 방식을 사용해보는 것도 추천한다.

 

 

실제 플로우는 위와 같다. (https://www.patterns.dev/react/client-side-rendering)

 

 

 

2. Server Side Rendering (SSR)

 

 위에서도 언급했지만 이 역시도 이름 그대로 서버 사이드에서 렌더링을 수행하기 위한 방식이다. 이 방식은 화면에 그려주는데에 필요한 서버요청까지 모두 실행시켜 완성된 HTML을 만들고 이를 클라이언트에 넘겨주고 클라이언트에서는 DOM에 JS를 연결해주는 즉, 이벤트핸들러와 상태를 주입해주는 동작만 담당하는 방식이다.

 

 잠깐 여담으로 이야기하자면 여기서 DOM에서 동작해야하는 JS를 주입해주는 것을 Hydration(수화) 이라고 한다. 일반적으로 DOM은 Tree 구조를 가지고있어 DOM Tree라고 부르는데 마른 나무에 수분을 공급하여 더 생기있는 나무를 만든다는 의미에서 Hydration이라는 단어를 사용했다.

 

이러한 Server Side Rendering 은 CSR에서처럼 프로젝트 전반적으로 사용하는 JS번들을 한번에 불러오지 않기때문에 최초 초기로딩 속도에 있어서는 CSR보다 빠르지만 기본적으로 처음 화면을 마주하는 속도는 CSR보다 느리다.(코드 스플리팅이 되어있지 않은 CSR 기준이다.) 이는 당연하지만 기본적인 껍데기 HTML만 반환해주는 CSR과 다르게 완성된 형태의 HTML을 만들어 내려주기때문에 처음 화면이 보이는 속도인 FCP(First Contentful Paint)가 느릴 수 밖에 없는 것이다. 다만 처음에 화면이 보여짐과 동시에 제공하고자하는 지면 내에서 가장 큰 컨텐츠도 바로 보이기때문에 웹 성능 지표 중 하나인 LCP(Largest Contentful Paint) 역시도 FCP와 거의 동일선상에 있다.

 

 또한 SSR은 위에서 언급했다시피 서버 API 수행 및 완성된 HTML을 받은 이후에도 JS를 받아 완성된 HTML에 이벤트핸들러와 상태를 주입해주는 과정이 필요하다. 따라서 유저가 실제로 지면과 상호작용이 가능한 시점은 이러한 Hydration 과정이 완료된 후이기때문에 보다 느린 TTI를 가진다.

 

 

 

FCP 이후 Hydration이 필요해 TTI까지는 시간이 필요하다 (https://patterns-dev-kr.github.io/rendering-patterns/progressive-hydration/)

 

 

위에서 지나가면서 각각의 장단점을 이야기했지만 SSR의 관점에서 정리해보자면 SSR은 CSR에서 아쉬웠던 포인트들을 보완하는 형태를 보이지만 모든 기술이 Trade-off가 있듯이 여전히 아쉬운 포인트가 존재한다.

 

1. 느린 TTFB(Time To First Byte) : SSR의 근본적인 구조를 보았을 때, API 서버에서 데이터를 받아오고 이를 바탕으로 HTML을 생성한 뒤 브라우저로 전송하기때문에 클라이언트에서 서버로 요청을 보낸 뒤 다시 받아오는 시점이 더 늦을 수 밖에 없다.

2. 느린 TTI (Time To Interaction) : CSR과 다르게 SSR은 서버에서 API 서버에 데이터 요청 및 HTML을 만들어서 내려주고, 이후에 클라이언트에서 내려받은 HTML에 이벤트 핸들러 및 상태를 주입해주는 Hydration 과정을 거치기때문에 실제로 인터렉션이 가능한 TTI 시점은 비교적 늦어져 좋지 않은 경험을 줄 수 있다.

 

이러한 문제들을 개선하기 위해 Streaming SSR 방식이 나타나게 되었다.

 

 

 

 

Streaming SSR 이란?

 

 

Streaming SSR이란 이름에서도 알 수 있지만 SSR 방식의 개선형태로 최초 클라이언트 요청 이후 서버에서 API 서버에 요청을 보내고, 이에 대해 응답을 받아 HTML을 완성하는 과정에서 이를 Stream 형태로 반환하여 HTML을 모두 완성하기 전부터 클라이언트 측에 HTML에 대한 정보를 내려주기 시작하는 방식이다. 

 

이렇게 될 경우 기존에 완성된 HTML을 클라이언트가 전달받는 TTFB가 단축되고, 스트림을 통해 HTML이 내려오기때문에 먼저 의미있게 완성된 HTML부터 화면에 보여지기 시작하여 유저가 더 빠르게 화면을 볼 수 있게 된다.

 

하지만 위에서 이야기한 두가지 한계점 중 TTFB를 개선했지만 여전히 HTML 스트림을 모두 내려받은 후에 Hydration을 수행하기때문에 아직 Hydration으로 인한 느린 TTI는 개선하지 못했다. 이를 개선하기 위해서는 한가지 방식이 더 필요한데 이것은 Progressive Hydration 이다.

 

 

Progressive Hydration

 

 

Progressive Hydration 역시 이름에서와 같이 점진적으로 Hydration(수화)을 진행하는 것이다. 이 방식이 같이 적용된다면 위에서 언급한 바와 같이 HTML을 Stream 형태로 내려주고 이에 대해 의미있는 HTML 단위가 만들어졌을 때, 해당 HTML에서 사용하는 JS를 같이 내려주어 완성된 HTML부터 먼저 Hydration을 수행하도록 한다. 이렇게 된다면 모든 HTML이 완성되기 전에 부분적으로 완성된 HTML에 대해 상호작용이 가능해져 느린 TTI를 개선할 수 있게 된다.

 

 

일반적으로 React에서의 Streaming SSR은 Progressive Hydration을 내장한 표현으로 보인다.(https://patterns-dev-kr.github.io/rendering-patterns/streaming-server-side-rendering)

 

 

 

 

 

그렇다면 어떻게 이런 Streaming 방식이 가능할 수 있었던 것일까 ?

 

 

Streaming SSR이 가능한 이유

 

위에서 언급한 바와 같이 Streaming 방식이 가능한 이유는 먼저 HTTP 에 있다.

 

HTTP/1.1 에서는 이러한 청크 전송 인코딩 방식(https://developer.mozilla.org/ko/docs/Web/HTTP/Headers/Transfer-Encoding)이 존재한다. 이것은 Response HeaderTransfer-Encodingchunked 로 전송하면 되는데 일반적으로는 대용량 파일을 전송하거나 실시간 데이터 스트리밍에 사용하기도 한다. 이를 활용했기때문에 별도의 socket과 같은 방식을 사용하지 않고도 지속적으로 데이터를 스트림형태로 받을 수 있는 것이다.

 

하지만 당연하게도 HTTP에서 지원하는 것만으로는 불가능하다.

 

이는 결국 보내주는 서버에서도 이렇게 Stream 형태로 보낼 수 있어야하는데, 이러한 방식이 가능하도록 Node.js에서 Stream API를 만들었다. (https://nodejs.org/api/stream.html

 

해당 API를 활용하면 데이터를 작은 청크로 처리하여 ReadableStream 형태로 내려줄 수 있게 되는 것이다.

 

결론적으로는 이러한 기술의 발전이 결합되어 Streaming SSR 방식을 사용할 수 있게 되었다. (물론 프론트엔드 프레임워크나 브라우저의 발전도 있지만 이런 부분들은 다음 글에서 다룰 예정이기에 제외했다.)

 

 

 

마무리

 

긴 호흡으로 내용을 전달하기보다는 글을 나누어 전달하는 것이 더 효과적일 것으로 생각되어 이번 글은 위의 내용 중 1-4 까지의 내용만 다루고 나머지 내용은 다음 글에서 마저 다루겠다.

 

다음 글에서는 이러한 렌더링 방식을 React에서는 어떻게 구현했고, 이를 활용해 기존의 React 프로젝트에서의 형태를 어떻게 개선할 수 있는지 알아보도록 하겠다.

반응형