본문 바로가기
IT/react

[react] ssr 프로젝트 만들기 (5) - stream으로 html 그리기

by 내일은교양왕 2024. 7. 8.

html 문서를 string으로 한번에 그렸더라면, 이번엔 stream으로 그려보자

API응답이 항상 빠르고 메모리가 허락하는 한 stream보다 string이 훨씬 더 빠르다.

단, API 응답이 늦어진다면 string으로 전달할 경우 API 응답을 기다린 후 그릴 수 있어서 최대한 빠르게 SSR를 주려면 stream이 더 나은 방식이다. stream으로 어떻게 구현했는지 알아보자.

 

정답부터 보고 싶으면 소스 코드를 확인하자

https://github.com/insidedw/react-webpack5-ssr/commit/c2766bcad9bd58ed34842ce2333dd3da839685c7

 

integrate renderToPipeableStream · insidedw/react-webpack5-ssr@c2766bc

insidedw committed Jul 7, 2024

github.com

https://github.com/insidedw/react-webpack5-ssr/commit/161b6bab7c777ecd3437d8db95a4820b59815b3e

 

streaming QueryClient · insidedw/react-webpack5-ssr@161b6ba

insidedw committed Jul 7, 2024

github.com


 

react-query 설치

stream을 사용한다는건 suspense를 사용한다는 것인데, 응답을 늦게 주고 suspesnse가 잘 동작한다는걸 확인하려면 react-query를 설치해보자. 추후 필요한 라이브러리이기도 하다.

npm i @tanstack/react-query

 

routes.js 생성

QueryClient 객체를 생성하는 파일이다.

서버에서 만들 때와 브라우저에서 만들 때를 구분한다.

서버에서 만들 때는 항상 새로운 객체를 생성하고 브라우저에서는 한번 만들어 놓은거 계속 사용한다.

(브라우저에서 왜 싱글턴을 사용해야하는지 확인이 필요해 보인다.)

import { isServer, QueryClient } from '@tanstack/react-query'

function makeQueryClient() {
  return new QueryClient()
}

let browserQueryClient = undefined

export const getQueryClient = () => {
  if (isServer) {
    // Server: always make a new query client
    return makeQueryClient()
  } else {
    // Browser: make a new query client if we don't already have one
    // This is very important, so we don't re-make a new client if React
    // suspends during the initial render. This may not be needed if we
    // have a suspense boundary BELOW the creation of the query client
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}

 

 

client.js 파일 수정

hydrate를 해야하는데.. 기존 메소드는 deprecated된 function이다. stream을 쓰는 만큼 권장하는 function을 사용하자.

hydrate -> hydrateRoot로 변경

 

서버에서 prefetch한 값을 client에게 알려줘야 중복 호출을 안한다.

알려주는 방법은 window 객체를 이용해서 전달한다.

 

그 외 변경사항 설명은 생략한다.

import React from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './components/App'
import { BrowserRouter } from 'react-router-dom'
import { HydrationBoundary, QueryClientProvider } from '@tanstack/react-query'
import { getQueryClient } from './routes'
const queryClient = getQueryClient()

const dehydratedState = window.__REACT_QUERY_STATE__ ?? {}

hydrateRoot(
  document.getElementById('root'),
  <QueryClientProvider client={queryClient}>
    <HydrationBoundary state={dehydratedState}>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </HydrationBoundary>
  </QueryClientProvider>,
)

 

src/components/queries.js 생성

fetch할 함수를 생성한다.

suspense가 잘 동작하는지 확인하기 위해 2초 지연을 주었다.

(추후 위치를 옮겨야 겠다. 왜 여기있지?)

export const customFetch = async () => {
  const r = new Promise((resolve, reject) =>
    setTimeout(
      () =>
        resolve({
          id: 'new jeans',
          src: 'https://pbs.twimg.com/media/F04xYoVaYAAmchT.jpg:large',
        }),
      2000,
    ),
  )
  return await r
}

 

src/components/Images.js 생성

suspesneQuery를 사용하는 컴포넌트를 생성한다.

import React from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { customFetch } from './queries'
export const Images = () => {
  const { data } = useSuspenseQuery({ queryKey: ['image', '1'], queryFn: () => customFetch() })

  return (
    <div>
      <div className={'artist'}>{data.id}</div>
      <img src={data.src} alt={data.id} />
    </div>
  )
}

 

App.css 수정

Images 컴포넌트에서 사용되는 스타일을 추가하자

h1 {
  color: dodgerblue;
}

.artist {
  text-transform: uppercase;
  font-weight: bold;
  font-size: 20px;
}

img {
  width: 480px;
}

 

App.js 수정

Images 컴포넌트와 suspense 컴포넌트를 추가한다.

import React, { Suspense } from 'react'
import './App.css'
import { Link, Route, Routes } from 'react-router-dom'
import { Images } from './Images'
const App = () => {
  return (
    <Routes>
      <Route
        path={'/'}
        element={
          <div>
            <h1>Hello, SSR!</h1>
            <Link to={'/about'}>About</Link>
            <Suspense fallback={<h3>loading...</h3>}>
              <Images />
            </Suspense>
          </div>
        }
      />
      <Route
        path={'/about'}
        element={
          <div>
            <h1>SSR is sever side rendering</h1>
            <Link to={'/'}>Home</Link>
          </div>
        }
      />
    </Routes>
  )
}

export default App

 

server.js 수정

변경사항이 가장 많은 파일이다.

 

html 기본 틀

html 상수에 넣는다. 

상수안에 변수는 STREAM과 INITIAL_STATE이다.

추후 STREAM 변수 기준으로 앞뒤를 짜른다.

앞에는 css파일을 주입 시키고 뒤에는 script 파일과 prefetch한 값을 주입시킨다.

 

queryClient 상수

서버에서도 prefetch를하기 위해 queryClient가 필요하다.

getQueryClient() 함수를 통해 얻는다.

prefetch 할때 주의할 점은 `await`를 추가하면 안된다.

추가하게 되면 string으로 넘겨주는 꼴이 된다. API 응답이 올때까지 기다린다.

 

elementToReadable 함수

react 컴포넌트를 Readable Stream으로 변환하는 함수이다.

`renderToPipeableStream`은 writable stream으로 변환하는데 response 객체에 바로 쓰이면 그대로 사용할 수 있지만, html 상수 앞뒤 짜른 부분도 stream으로 보내야하기 때문에 writable stream을 PassThrough trasnform stream으로 이용해 Readable stream으로 변환한다.  각 스트림을 순서대로 서빙하기 위해서 generator function을 이용하는데 그때 비동기로 처리하기 위해 Promise로 한번 감싼다.

 

streamHTML 함수

위에서 언급한 generator function이다. 아까 html 상수 앞뒤 짜른 값이 가공되어 이 함수로 들어간다. 

처음에는 head부터 바로 서빙하고 그다음 react node 마지막에 footer가 스트림 된다.

footer가 마지막에 서빙되는데 페이지가 잘 나오는게 신기하다. 추가 조사가 필요하다.

generator function이 궁금하면 이 링크 [JS] 제너레이터 함수 (generator function)를 확인해봐라

 

Readable.from

streamHTML을 통해서 한개씩 얻은 데이터를 모아둔다. 

yield를 통해서 API응답이 종료될 때까지 계속 기다리다 결국 끝까지 받는다. 

 

`ssrStream.pipe(res)`

Readable.from 정보를 가지고 있는 상수가 ssrStream인데, yield로 얻어진 데이터를 res로 넘긴다. 

 

import path from 'path'
import fs from 'fs'
import React from 'react'
import express from 'express'
import { renderToPipeableStream } from 'react-dom/server'
import App from './components/App'
import { StaticRouter } from 'react-router-dom/server'
import { getQueryClient } from './routes'
import { dehydrate, QueryClientProvider } from '@tanstack/react-query'
import { customFetch } from './components/queries'
import { PassThrough, Readable } from 'stream'

const app = express()
app.use(express.static('dist'))

const html = `
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>React SSR</title>
</head>
<body>
<div id="root">{{STREAM}}</div>
</body>
<script>window.__REACT_QUERY_STATE__={{INITIAL_STATE}};</script>
</html>
`

const manifest = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../dist/manifest.json'), 'utf8'))
const scripts = Object.keys(manifest)
  .filter((key) => key.endsWith('.js'))
  .map((key) => `<script defer src="${manifest[key]}"></script>`)
  .join('\n')

const styles = Object.keys(manifest)
  .filter((key) => key.endsWith('.css'))
  .map((key) => `<link rel="stylesheet" type="text/css" href="${manifest[key]}">`)
  .join('\n')

const [head, footer] = html.split('{{STREAM}}')
const refinedHead = head.replace('</head>', `${styles}</head>`)
const refinedFooter = footer.replace('</body>', `${scripts}</body>`)

function elementToReadable(element) {
  /**
   * pipe 함수가 WritableStream으로 데이터를 전달해주는 함수
   * react component만 스트림으로 한다면 바로 pipe(res) 하면 되는데,
   * 그 외 html markup도 stream으로 보내야 하므로, transfrom stream을 이용해서 데이터 그대로 읽기 스트림으로 전달한다
   * @type {module:stream.internal.PassThrough}
   */
  const duplex = new PassThrough()

  return new Promise((resolve, reject) => {
    /**
     *  HTML을 제공된 쓰기 가능한 Node.js 스트림으로 출력
     */
    const { pipe, abort } = renderToPipeableStream(element, {
      onShellReady() {
        console.log('[Streaming SSR] onShellReady')
        resolve(pipe(duplex))
      },
      onShellError(error) {
        abort()
        reject(error)
      },
      onError: (error) => {
        duplex.emit('onError', error)
      },
    })
  })
}
async function* streamHTML(head, body, footer, queryClient) {
  yield head
  console.log('[Streaming SSR] head rendered')
  let i = 0
  for await (const chunk of body) {
    yield chunk
    i++
    console.log(`[Streaming SSR] chunk ${i} rendered`)
  }

  const dehydratedState = dehydrate(queryClient)
  const footerWithQueryState = footer.replace('{{INITIAL_STATE}}', `${JSON.stringify(dehydratedState)}`)
  yield footerWithQueryState
  console.log('[Streaming SSR] footer rendered')
}

app.get('*', async (req, res) => {
  res.setHeader('Content-Type', 'text/html')
  res.status(200)

  const queryClient = getQueryClient()

  queryClient.prefetchQuery({ queryKey: ['image', '1'], queryFn: () => customFetch() })

  const vnode = (
    <QueryClientProvider client={queryClient}>
      <StaticRouter location={req.url}>
        <App />
      </StaticRouter>
    </QueryClientProvider>
  )

  try {
    const ssrStream = Readable.from(streamHTML(refinedHead, await elementToReadable(vnode), refinedFooter, queryClient))
    ssrStream.on('error', (error) => {
      console.log(`ssrStream!`, error.message)
    })
    ssrStream.on('close', () => {
      queryClient.clear()
      console.log(`queryClient clear!`)
    })

    ssrStream.pipe(res)
  } catch (e) {
    console.log('error', e)
  }
})

app.listen(3000, () => {
  console.log('Server is listening on port 3000')
})

 

테스트

stream, suspense가 잘 작동하고 있다는걸 볼 수 있다.

그렇지만 더 확인해봐야 할건 suspense가 렌더링 되고 있을 때 html 응답이 html 일부분밖에 없는데 UI가 깨지지 않고 잘나온다는게 이상하다. 이유를 꼭 찾아보자

 

suspense시 html 

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>React SSR</title>
<link rel="stylesheet" type="text/css" href="/main.1d973b210f9cb388daa2.css"></head>
<body>
<div id="root"><div><h1>Hello, SSR!</h1>
<a href="/about">About</a><!--$?--><template id="B:0"></template>
<h3>loading...</h3><!--/$--></div>

 

결과