React Server API 알아보기 (feat. edge 환경)

2024. 5. 19. 16:08카테고리 없음

RenderToString

react-dom/server 모듈에서 제공하는 renderToString 메서드는 React 트리를 HTML 문자열로 렌더링한다. 자세히 알아보기 전에 간단한 API 사용법은 다음과 같다.

// renderToString(reactNode, options?) 
// reactNode는 HTML로 렌더하려는 리액트 노드를 의미한다.
// options는 서버 렌더에서 사용될 옵션들을 의미한다. 
// 옵션 중 identifierPrefix의 경우 hydrateRoot에 사용될 prefix와 같아야 한다.

import { renderToString } from 'react-dom/server';

const html = renderToString(<App />);

 

위 예제에서 html 에는 App이 렌더된 HTML string이 저장이 되게 된다. 이 때 생성된 HTML string에 data-react* 어트리뷰트가 생성이 된다. 리액트는 클라이언트 사이드에서 hydrate를 할 때 이 어트리뷰트를 사용하게 된다.

 

말로만 보면 잘 이해가 가지 않기에 리액트 딥다이브의 예제를 참고해보자. 

 

//// server.ts
      
const result = await fetchTodo()

const rootElement = createElement(
   'div',
   { id: 'root' },
   createElement(App, { todos: result }),
)
      
// rootElement를 HTML string으로 바꾼다. (서버에서 렌더링을 한다.)
const renderResult = renderToString(rootElement)
      
// html의 경우 실제 html 파일이다. 단순히 서버에서 렌더링된 HTML string으로 replace 한다.
const htmlResult = html.replace('__placeholder__', renderResult)

res.setHeader('Content-Type', 'text/html')
res.write(htmlResult)
res.end()
      
      
      
      
      
//// index.ts (client)
      
const result = await fetchTodo()

const app = <App todos={result} />
const el = document.getElementById('root')

// hydrate를 시킨다.
hydrate(app, el)

 

코드가 예상 외로 굉장히 간단하다. 말로 풀어 설명하면

1) 서버에서 rendertToString을 통해 HTML string을 만든다. 이 때 만들어진 HTML은 당연히 리액트가 없는 단순 마크업이다.

2) 클라이언트에 html 파일이 전해지게 되고, 클라이언트에서는 hydrate를 통해 서버에서 렌더링된 HTML을 리액트가 사용할 수 있게 해준다.

 

하지만 위 예제의 경우 약간의 개선점이 보이는데 fetch가 현재 두번 일어나고 있다. 서버사이드에서, 클라이언트에서 모두 한번씩 데이터를 페치하고 있다.

 

이는 불필요하다. Next의 경우 __NEXT_DATA__에 json 형태로 서버에서 페치한 데이터를 클라이언트에게 전해주게 되고, 클라이언트에서는 전역객체 window에 이를 저장하여 사용한다. 

 

renderToStaticMarkup

사실 renderToStaticMarkup은 renderToString과 별반 다르지 않다. 하지만 가장 주된 차이점이 있다면 data-react* 어트리뷰트가 생성되지 않는다. 

 

즉, rendertToString은 ssr을 하기 위해 사용되므로 hydrate를 위한 data-react* 어트리뷰트가 필요하지만, renderToStaticMarkup은 ssg를 위해 사용되므로 hydrate를 위한 추가적인 attribute가 필요하지 않다.

 

hydrate가 불가능하기 때문에 인터렉티브가 없는 순수한 정적 사이트의 경우, React를 사용하여 마크업을 생성하지만 JS 번들에 React를 포함시키고 싶지 않을 때 사용 가능하다.

 

 

 

renderToNodeStream

먼저 노드의 Stream의 개념은 뭘까? 제로초님의 노드 강의에서 간략히 설명해주고 있는데, 버퍼처럼 파일을 한꺼번에 메모리에 읽는 것이 아닌, 데이터 청크를 하나씩 읽어서 처리한다. 이 때 메모리를 버퍼보다 훨씬 적게 소모하는 장점이 있다. 비단 메모리 뿐만 아니라 한번에 모든 데이터를 받아와 한번에 처리하는 것보다 데이터 청크를 바로바로 처리할 수 있다는 장점도 있다.

 

renderToString의 경우 하나의 커다란 HTML을 생성하고, HTML을 한번에 서버로 전송한다. 

 

하지만 React 16에 소개된 renderToNodeStream은 리액트 트리를 Node.js의 Readable Stream으로 렌더한다. API는 renderToString과 동일하지만, 반환하는 값이 renderToString은 HTML string 이었던 반면 renderToNodeStream은 Node.js의 Readable Stream을 반환한다.

 

res.setHeader('Content-Type', 'text/html')
res.write(indexFront)

const result = await fetchTodo()
const rootElement = createElement(
  'div',
  { id: 'root' },
  createElement(App, { todos: result }),
)

const stream = renderToNodeStream(rootElement)
stream.pipe(res, { end: false })
stream.on('end', () => {
  res.write(indexEnd)
  res.end()
})

 

위 예제를 살펴보면 HTML의 앞부분을 보낸 후에 컴포넌트를 스트림 방식으로 전달하고 컴포넌트가 모두 전달되면 HTML의 마지막 부분을 보낸다.

 

renderToString은 한번에 HTML string을 만들어 삽입하고 한번에 완성된 파일을 보냈다면, renderToNodeStream은 리액트 트리를 스트리밍하여 보내준다. 따라서 좀 더 빠르게 클라이언트 측에서는 화면을 그릴 수 있다.

 

단, 주의 깊게 살펴봐야 할 점은 완성된 HTML을 스트리밍 해주긴 하지만 데이터를 모두 받아올 때까지 대기해야 했다.

또한 리액트 18버전부터는 모든 출력을 버퍼링을 하므로 실제로 스트리밍의 이점을 제공하지 않는다.

 

renderToStaticNodeStream

 

renderToStaticMarkup과 유사하게 hydrate가 불가능한 정적 HTML을 보내준다. 하지만 renderToString, renderToNodeStream의 차이와 동일하게 HTML을 스트리밍하여 보내준다.

 

 

renderToPipeableStream

앞서 말했듯이 renderToNodeStream 리액트18버전 이전에는 데이터를 기다려야 한다는 단점이 있었고, 18 버전 이후에는 아예 HTML이 스트리밍이 되지 않았다.

 

하지만 React 18에서는 Suspense의 새로운 아키텍처에서 설명하고 있듯이, 기존 리액트 SSR의

 

1) 서버에서 전체 앱에 대한 데이터를 가져오고

2) 서버에서 전체 앱에 대한 HTML을 응답으로 전송하고

3) 클라이언트에서 전체 앱에 대한 JS 코드를 로드하고

4) 클라이언트에서 전체 앱을 하이드레이션

 

4단계가 이전 단계가 완료된 후에야 다음 단계를 수행할 수 있던 단점을 보완했다. 즉 Suspense와 React.lazy가 SSR에서도 동작하도록 하여 기존의 waterfall 형태로부터 비롯된 단점을 보완했다. 

 

마찬가지로 React 18 부터는 클라이언트 사이드에서 hydrate대신 hydrateRoot를 통해 selective Hydration이 가능해졌다. 상세한 내용은 위 링크에 매우 잘 설명되어있다.

 

참고로, renderToPipeableStream은 Node.js에 특화된 API다. 따라서 반환값이 Node.js의 Stream이다.

 

 

renderToReadableStream

 

renderToPipeableStream이 노드 환경이라면 renderToReadableStream은 엣지 환경을 위한 API다. 크게 다른 점은 없고 반환 값이 Readable Web Stream이라는 점 정도가 다르다. 

 

 

보너스 

 

그럼 Edge Runtime이 뭘까?

 

Edge Runtime을 이해하기 이전에 CDN부터 이해해보자. CDN은 사용자와 서버 사이의 물리적 거리를 줄이기 위해 생겨났다. 예를 들어 한국에 서버가 존재할 때 해외에서 한국 서버에 접근을 하면 물리적 거리로 인해 데이터 전송에 시간이 오래 걸리게 된다. 이는 UX에 좋지 않은 영향을 끼친다.

 

하지만 CDN을 사용하게 되면 이런 문제를 해결할 수 있다. CDN은 세계 여러 곳에 위치한 서버의 네트워크 망이다. 이 CDN에 미리 정적 자원들을 캐시를 해놓으면 사용자는 굳이 origin으로 요청을 보내지 않아도 데이터를 빠르게 받아볼 수 있다.

 

하지만 CDN의 경우 정적 자원만 캐싱을 해놓을 수 있다는 단점이 존재한다. 동적인 기능을 수행해야 한다면 CDN은 활용할 수 없고 다시금 origin에 요청을 보내야한다.

 

이런 문제를 해결하기 위해 Edge가 등장했다. 작은 코드를 CDN에서 실행하기 위해 등장한 것이 바로 Edge라고 할 수 있다. 이제 CDN에서도 동적인 코드 실행이 가능해졌기에 정적 자원 뿐만 아니라 SSR이나 사용자 인증 등의 기능을 수행할 수 있게 되었다. 

 

하지만 Edge의 경우 cold start를 최소화 하기 위해 제한된 웹 API만을 제공한다. Node의 API를 사용할 수 없다. 이게 바로 renderToReadableStream이 따로 존재하는 이유다.

 

참고로 Next의 Page router의 경우 Middleware는 엣지 런타임만을 지원한다. 또 이 여기를 보면 아직까진 Middleware에서만 엣지 런타임을 추천하고 있으니 아직까지 크게 고려할 상황은 아닌듯하다.

 

 

마치며  

 

항상 궁금했던 리액트의 client API와 server API에 대해 간략히 학습해보았다. 마법처럼 느껴졌던 React를 활용한 SSR(,SSG)에 대한 궁금증이 조금은 해결된 느낌이다.