본문 바로가기

FrontEnd

[번역] Rehydration(재수화)의 위험과 문제 해결하기

반응형
React의 Rehydration에 대한 놀라운 깨달음

TL; DR

서버사이드 렌더링은 두 가지 관점에서 생각해야 한다.

  • 보편적으로 먼저 만들 수 있는 것들 (상자의 포장)
  • 클라이언트 별로 달라질 수 있는 것들  (상자의 내용물과 유통기한)

 재수화시 리액트는 서버 사이드 렌더링 결과와 클라이언트에서 렌더링한 결과가 동일할 것으로 예상한다.

  • 렌더링 로직에 결과가 클라이언트 측, 서버 측에서 다를 수 있는 부분이 들어가 있다면 오류가 발생한다.
  • useHasMount와 같이 클라이언트에서만 동작 가능한 상태 로직을 포함한 훅을 사용하면 된다.
  • next13의 리액트 서버 컴포넌트, 리믹스를 사용하는 것도 하나의 방법이다.
    • next13을 이용하면 코드의 변경을 최소화 / 제거하며 CSR, SSR, ISR 등을 쉽게 지원할 수 있다.

 
저는 최근 이상한 문제에 부딛혔습니다.
개발 단계에서는 모든 것이 훌륭했지만 프로덕션 단계에서는 내 블로그 하단에서 의도하지 않은 일이 일어나고 있었습니다.

엉망이 된 UI

 

devtools의 Elements 탭을 조금 파헤쳐 원인을 밝혀냈습니다.
내 React 컴포넌트가 잘못된 지점에서 렌더링되었습니다!
개발 환경에서는, main 아래 나란히 두 컴포넌트가 렌더링 되었는데요.
<!-- In development, things are correct -->

<main>
<div class="ContentFooter">
  Last updated: <strong>Sometime</strong>
</div>
<div class="NewsletterSignup">
  <form>
    <!-- Newsletter signup form stuff -->
  </form>
</div>
</main>

운영 환경에서는, 상단 컴포넌트의 자식으로 컴포넌트가 렌더링 되고 있었습니다.

<!-- In production, things had teleported! -->
​
<main>
<div class="ContentFooter">
  Last updated: <strong>Sometime</strong>
  <div class="NewsletterSignup">
    <form>
      <!-- Newsletter signup form stuff -->
    </form>
  </div>
</div>
</main>
React의 서버 사이드 렌더링 컨텍스트에서 작동하는 방식에 대해 근본적인 오해가 있었습니다.
그리고 많은 React 개발자들이 이 오해를 공유하고 있다고 생각합니다!
그리고 그것은 꽤 심각한 결과를 초래할 수 있습니다.

문제 될 수 있는 코드들

위와 같은 렌더링 문제를 일으킬 수 있는 코드의 예는 다음과 같습니다.
문제를 발견하셨나요?
function Navigation() {
  if (typeof window === 'undefined') {
    return null;
  }
  // Pretend that this function exists,
  // and returns either a user object or `null`.
  const user = getUser();
  if (user) {
    return (
      <AuthenticatedNav
        user={user}
      />
    );
  }
  return (
    <nav>
      <a href="/login">Login</a>
    </nav>
  );
};
이 튜토리얼은 서버 측 렌더링이 작동하는 방식을 이해하는 데 도움이 되도록 장막 뒤를 엿볼 것입니다.
이 코드의 로직이 문제가 될 수 있는 이유와
다른 접근 방식이 동일한 목표를 달성할 수 있는 방법을 살펴보겠습니다.

서버 사이드 렌더링 101

문제를 이해하려면
먼저 Gatsby 및 Next.js와 같은 프레임워크가 React로 구축된 기존 클라이언트 측 앱과 어떻게 다른지 조금 파고들어야 합니다.
 
create-react-app과 같은 것과 함께 React를 사용하면 모든 렌더링이 브라우저에서 발생합니다.
응용 프로그램의 크기는 중요하지 않으며
브라우저는 여전히 다음과 같은 초기 HTML 문서를 받습니다.
<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- Maybe some stuff here -->
  </head>
  <body>
    <div id="root"></div>
    <script
      src="/static/bundle.js"
    ></script>
    <script
      src="/static/0.chunk.js"
    ></script>
    <script
      src="/static/main.chunk.js"
    ></script>
  </body>
</html>
페이지는 기본적으로 비어 있지만 몇 가지 JS 스크립트가 포함되어 있습니다.
브라우저가 해당 스크립트를 다운로드하고 구문 분석하면
React는 페이지의 모양에 대한 그림을 만들고 많은 DOM 노드를 주입하여 그렇게 만듭니다.
모든 렌더링이 클라이언트(사용자의 브라우저)에서 발생하므로 이를 클라이언트측 렌더링이라고 합니다.
이 모든 작업에는 시간이 걸리며,
브라우저와 React가 마법을 사용하는 동안 사용자는 빈 흰색 화면을 보고 있어야 합니다.
유저에게 좋은 경험이 아닙니다.
똑똑한 사람들은 우리가 서버에서 렌더링을 할 수 있다면 사용자에게 완전한 형식의 HTML 문서를 보낼 수 있다는 것을 깨달았습니다.
이러면 브라우저가 JS를 다운로드, 구문 분석 및 실행하는 동안 볼 것이 있습니다.
이를 서버 사이드 렌더링(SSR)이라고 합니다.
 
서버 측 렌더링은 성능 향상이 될 수 있지만 문제는 작업이 여전히 주문형(on demand)으로 수행되어야 한다는 것입니다.
your-website.com을 요청할 때 React는 React 컴포넌트를 HTML로 변환해야 하며
유저는 기다리는 동안 여전히 빈 화면을 응시하게 됩니다.
사용자 컴퓨터가 아닌 서버에서 작업이 수행되고 있을 뿐입니다.
 
 
이것이 바로 Gatsby가 하는 일입니다(특정 설정에선 Next.js도요).
Yarn 빌드를 실행하면 사이트의 모든 경로에 대해 1개의 HTML 문서가 생성됩니다.
모든 페이지, 모든 블로그 게시물, 모든 스토어 아이템 - 각각에 대한 HTML 파일이 생성되어 즉시 제공될 수 있습니다.


그런데 이 모든게 서버 사이드 렌더링인가요?

불행하게도, 해당 용어는 실제로 다양한 의미로 사용됩니다.
기술적으로 Gatsby가 하는 일은 서버 사이드 렌더링입니다.
전통적인 서버 측 렌더링과 동일한 ReactDOMServer API를 사용하여 Node.js를 사용하여 React 앱을 렌더링하기 때문입니다.
 
하지만 제 생각에는 개념적으로 다릅니다.
"서버 사이드 렌더링"은 요청에 대한 응답으로 라이브 프로덕션 서버에서 실시간으로 발생하는 반면
이 컴파일 타임 렌더링은 빌드 프로세스의 일부로 훨씬 더 일찍 발생합니다.

어떤 사람들은 SSG라고 부르기 시작했습니다.
사람에 따라 "Static Site Generation" 또는 "Server-Side Generated"라고 합니다.

 


클라이언트의 코드

오늘날 우리가 개발하는 앱은 상호작용이 많고 역동적입니다.
사용자는 HTML과 CSS만으로는 달성할 수 없는 경험에 익숙해져 있습니다!
따라서 우리는 여전히 클라이언트 측 JS를 실행해야 합니다.
 
클라이언트 측 JS에는 컴파일 타임에 생성하는 데 사용되는 것과 동일한 React 코드가 포함되어 있습니다.
그것은 사용자의 장치에서 실행되며. UI를 만듭니다.
그런 다음 이를 document에 존재하는 HTML과 비교합니다.
이것은 rehydration(재수화)로 알려진 프로세스입니다.
 
rehydration은 render와 같은 것이 아닙니다.
일반적인 렌더링에서 프롭이나 상태가 변경되면 React는 차이점을 조정하고 DOM을 업데이트할 준비가 됩니다.
rehydration에서 React는 DOM이 변경되지 않는다고 가정합니다.
기존 DOM을 사용하려고 할 뿐입니다.

동적인 Section

코드로 다시 돌아갑시다.

const Navigation = () => {
  if (typeof window === 'undefined') {
    return null;
  }
  // Pretend that this function exists,
  // and returns either a user object or `null`.
  const user = getUser();
  if (user) {
    return (
      <AuthenticatedNav
        user={user}
      />
    );
  }
  return (
    <nav>
      <a href="/login">Login</a>
    </nav>
  );
};
 
이 컴포넌트는 세 가지 가능한 결과를 갖도록 설계되었습니다.
 
  • 사용자가 로그인한 경우 <AuthenticatedNav/>를 렌더링합니다.
  • 사용자가 로그인하지 않은 경우 <UnauthenticatedNav/>를 렌더링합니다.
  • 사용자가 로그인했는지 여부를 알 수 없으면 아무 것도 렌더링하지 않습니다.

슈뢰딩거의 사용자

무시무시한 사고 실험에서(macabre thought experiment), 오스트리아의 물리학자 Erwin Schrödinger는 상황을 설명합니다.
한 시간 안에 방출될 확률이 50%인 독소가 들어 있는 상자에 고양이를 넣었습니다.
1시간 후 고양이가 살아있을 확률과 죽었을 확률은 같습니다.
하지만 상자를 열어 확인하기 전에는 고양이가 살아있는 동시에 죽은 것으로 생각할 수 있습니다*.
 

우리 앱도 비슷한 곤경에 직면해 있습니다.
사용자가 사이트에 있는 처음 몇 분 동안은 로그인 여부를 알 수 없습니다.

이는 HTML 파일이 컴파일 타임에 빌드되기 때문입니다.
모든 각 사용자는 로그인 여부에 관계없이 해당 HTML의 동일한 사본을 받습니다.
JS 번들이 파싱되고 실행되면 사용자의 상태를 반영하도록 UI를 업데이트할 수 있지만
그렇게 되기까지는 상당한 시간차가 있습니다.
 
SSG의 요점은 앱을 다운로드, 구문 분석 및 재수화하는 동안 동안
사용자에게 볼 수 있는 무언가를 제공하는 것입니다.
이 작업은 느린 네트워크/장치에서 긴 프로세스가 될 수 있기 때문입니다.
 
따라서 많은 앱은 기본적으로 "로그아웃" 상태를 표시하게 됩니다.
이로 인해 이전에 경험한 깜박임이 발생합니다.
 

예시 : Guardian 뉴스 웹사이트는 당신 계정 정보를 표시하기 전에 '로그인' 링크를 표시합니다.

 
 

 

예시 : Airbnb 웹사이트도 로그아웃된 사용자의 네비게이션 바를 디폴트로 보여주는 실수를 했네요

 

나는 이 문제를 재현하는 미니 Gatsby 앱을 만들었습니다.

3G 속도에서는 잘못된 상태가 꽤 오랫동안 표시됩니다!

직접 해보세요! fake-login에 대한 "로그인" 링크를 클릭하여 로그인하고 다시 클릭하여 로그아웃합니다.
 

멋지지만 완벽하지 않은 해결 방법

위 코드에서 우리는 처음 몇 줄에서 이 문제를 해결하려고 시도합니다.
const Navigation = () => {
  if (typeof window === 'undefined') {
    return null;
  }
 
아이디어는 건전합니다. 초기 컴파일 타임 빌드는 서버 런타임인 Node.js에서 발생합니다.
창이 존재하는지 확인하여 서버에서 렌더링하고 있는지 여부를 감지합니다.
서버 환경인 경우 렌더링을 일찍 중단할 수 있습니다.
문제는 이렇게 함으로써 우리가 규칙을 어기고 있다는 것입니다. 😬

재수화 != 렌더

React 앱이 재수화되면 DOM 구조가 일치한다고 가정합니다.

React 앱이 클라이언트에서 처음으로 실행될 때
  • 모든 컴포넌트를 마운트하여 DOM이 어떻게 보여야 하는지에 대한 멘탈 모델을 구축합니다.
  • 그런 다음 이미 페이지에 있는 DOM 노드와 해당 DOM을 비교합니다.
일반적인 업데이트 중에 수행하는 "차이점 찾기" 게임을 하는 것이 아니라
향후 업데이트가 올바르게 처리될 수 있도록 두 DOM을 일원화하려 합니다.
 
우리가 서버 측 렌더링에 있는지 여부에 따라 다른 것을 렌더링함으로써 시스템을 해킹하고 있습니다.
우리는 서버에서 뭔가를 렌더링하고 있지만
클라이언트에서 다른 것을 기대하도록 React에 지시합니다.
 
 

컴파일 타임에 만들어진 HTML

<!-- The initial HTML
     generated at compile-time -->
	​
<header>
  <h1>Your Site</h1>
</header>​

 

 

리액트가 렌더링 후 기대하는 것

<!-- What React expects
     after rehydration -->
	​
<header>
  <h1>Your Site</h1>
  <nav>
    <a href="/login">Login</a>
  </nav>
</header>
놀랍게도 React는 때때로 이 상황을 처리할 수 있습니다.
하지만 당신은 불장난을 하고 있습니다.
재수화 프로세스는 불일치를 포착하고 수정하는 것이 아니라 해당 작업을 ⚡️빠르게⚡️하도록 최적화되어 있습니다.

 

Gatsby의 경우

React 팀은 rehydration 불일치가 펑키한 문제로 이어질 수 있다는 것을 알고 있으며,

콘솔 메시지로 불일치를 강조 표시했습니다.

예상되는 돔 구조와 다르면 에러 표시
  • 불행히도 Gatsby는 프로덕션용 빌드 시 서버 사이드 렌더링 API만 사용합니다.
  • 일반적으로 React 경고는 개발 중에만 실행되기 때문에 이러한 경고는 Gatsby로 빌드할 때 표시되지 않습니다 😱
이것은 트레이드 오프 입니다.
dev에서 서버 사이드 렌더링을 선택 해제함으로써 Gatsby는 피드백 루프를 최적화하고 있습니다.
변경 사항을 빠르게 확인할 수 있다는 것은 매우 중요합니다. Gatsby는 정확성보다 속도를 우선시합니다.
그러나 이것은 중요한 문제입니다.
an open issue에서 사람들은 변화를 옹호하고 있으며 앞으로 우리는 수화 경고를 볼 수 있을수도 있습니다.
 
하지만 그때까지는 Gatsby로 개발할 때 이를 염두에 두는 것이 특히 중요합니다!

해결방법

문제를 방지하려면 재수화된 앱이 원본 HTML과 일치하는지 확인해야 합니다.
그러면 "동적" 데이터를 어떻게 관리합니까?
해결책은 다음과 같습니다.
function Navigation() {
  const [hasMounted, setHasMounted] = React.useState(false);
  React.useEffect(() => {
    setHasMounted(true);
  }, []);
  if (!hasMounted) {
    return null;
  }
  const user = getUser();
  if (user) {
    return (
      <AuthenticatedNav
        user={user}
      />
    );
  }
  return (
    <nav>
      <a href="/login">Login</a>
    </nav>
  );
};
우리는 hasMounted 상태를 false로 초기화합니다.
false면 "실제" 콘텐츠를 렌더링하지 않습니다.
useEffect 호출 내에서 hasMounted를 true로 설정하여 즉시 다시 렌더링을 트리거합니다.
이 값이 true이면 "실제" 콘텐츠가 렌더링됩니다.

 

이전 솔루션과의 차이점은 다음과 같습니다.
  • useEffect는 구성 요소가 마운트된 후에만 실행됩니다.
  • 리하이드레이션 중에 React 앱이 document의 DOM을 채택할 때, useEffect가 아직 호출되지 않은 상태입니다.
    • React가 기대한 것과 동일합니다.
    • 이전에는 플래그 때문에 달랐습니다

 

컴파일 타임에 생성된 HTML

<!-- The initial HTML
     generated at compile-time -->
	​
<header>
  <h1>Your Site</h1>
</header>

리액트가 재수화 시 예상하는 HTML

<!-- What React expects
     after rehydration -->
	​
<header>
  <h1>Your Site</h1>
</header>
이 비교 직후 우리는 다시 렌더링을 트리거하고 이를 통해 React가 적절한 조정을 수행할 수 있습니다.
리액트는 렌더링할 새 콘텐츠(인증된 메뉴 또는 로그인 링크)가 있음을 확인하고,
이에 따라 DOM을 업데이트합니다.

 

솔루션의 동작 모습은 다음과 같습니다.
  • 처음에는 아무것도 보여주지 않습니다.
  • 로그인 되었으면 유저 정보를 보여줍니다.
  • 로그인 된 상태가 아니면 로그인 버튼을 보여줍니다.
초기 렌더링엔 빈 UI가 표시됩니다. 마운트 후 다시 렌더링하면 실제 상태로 업데이트됩니다.

Two-pass rendering

시리얼의 유통기한이 시리얼 박스와 동시에 인쇄되지 않는다는 것을 아시나요?
사실 나중에 스탬프로 표시됩니다.
시리얼 박스의 유통기한은 박스와 동시에 인쇄되지 않습니다.

 

여기에는 이유가 있습니다. 시리얼 박스 인쇄는 2단계 프로세스입니다.

먼저, 모든 "보편적인" 항목이 인쇄됩니다.

  • 로고, 요정 그림, 시리얼의 질감을 보여주기 위해 확대된 사진, 스마트 워치의 무작위 사진.
  • 이러한 것들은 정적이기 때문에 한 번에 수백만 장을 미리 대량 생산하고 인쇄할 수 있습니다.
하지만 만료 날짜는 그렇게 할 수는 없습니다. 동적인 항목은 나중에 인쇄합니다.
  • 박스 제조업체는 유통기한을 모릅니다.
    • 그 상자를 채울 시리얼은 아마도 아직 존재하지도 않을 것입니다!
    • 그래서 대신 빈 파란색 사각형을 인쇄합니다.
  • 훨씬 후 시리얼이 생산되어 상자에 주입된 후에 흰색 유통 기한을 찍고, 배송을 위해 포장할 수 있습니다.

2-패스 렌더링도 똑같습니다.

  • 첫 번째 패스는 컴파일 타임에 정적이 아닌 모든 정적 콘텐츠를 생성하고 동적 콘텐츠의 구멍을 남겨두는 작업입니다.
  • 두 번째 패스는 React 앱이 클라이언트 상태에 따라 달라지는 모든 동적 콘텐츠를 해당 구멍에 채우는 작업입니다.

성능 영향

2패스 렌더링의 단점은 상호 작용 시간이 지연될 수 있다는 것입니다.
마운트 직후 렌더링을 강제하는 것은 일반적으로 눈살을 찌푸리게 합니다.
즉, 대부분의 응용 프로그램에서 이것은 큰 차이를 만들지 않아야 합니다.
일반적으로 동적 콘텐츠의 양은 상대적으로 적고 신속하게 조정할 수 있습니다.
앱의 상당 부분이 동적인 경우 사전 렌더링의 많은 이점을 놓치게 되지만 이것은 피할 수 없는 일입니다.
동적 섹션은 정의에 따라 미리 생성할 수 없습니다.
항상 그렇듯 성능에 대한 우려가 있는 경우 직접 몇 가지 실험을 수행하는 것이 가장 좋습니다.

추상화

이 블로그에서 나는 몇 가지 렌더링 결정을 두 번째 패스로 연기해야 ​​했고
같은 로직을 반복해서 작성하는 데 지쳤습니다.
이 작업을 추상화하기 위해 <ClientOnly> 컴포넌트를 만들었습니다.
function ClientOnly({ children, ...delegated }) {
  const [hasMounted, setHasMounted] = React.useState(false);
  React.useEffect(() => {
    setHasMounted(true);
  }, []);
  if (!hasMounted) {
    return null;
  }
  return (
    <div {...delegated}>
      {children}
    </div>
  );
}
이제 렌더링하려는 요소를 다음과 같이 감쌀 수 있습니다.
<ClientOnly>
  <Navigation />
</ClientOnly>
커스텀 훅을 사용할 수도 있습니다.
function useHasMounted() {
  const [hasMounted, setHasMounted] = React.useState(false);
  React.useEffect(() => {
    setHasMounted(true);
  }, []);
  return hasMounted;
}
function Navigation() {
  const hasMounted = useHasMounted();
  if (!hasMounted) {
    return null;
  }
  const user = getUser();
  if (user) {
    return (
      <AuthenticatedNav
        user={user}
      />
    );
  }
  return (
    <nav>
      <a href="/login">Login</a>
    </nav>
  );
};
이 기술로 내 렌더링 문제를 해결할 수 있었습니다. 
주 : Remix나 next13을 이용해 동적 컨텐츠를 Suspense로 감싸는 방법도 있음!

위 추상화는 깔끔하지만 이 자습서에서 가장 중요한 부분이 아닙니다.
 

중요한 부분은 2 패스 렌더링의 멘탈 모델입니다.

Gatsby/Next 앱을 작업할 때 2패스 렌더링의 관점에서 생각하는 것이 정말 도움이 된다는 것을 알게 되었습니다.

렌더링의 첫 번째 패스는 컴파일 시간에 훨씬 앞서 발생하며 페이지의 기초를 만들어, 모든 사용자에게 보편적인 모든 것을 채웁니다.

렌더링의 두 번째 패스는 그 다음 훨씬 나중에 사람마다 다른 상태를 이용해 빈 구멍을 채웁니다.

 

 

 



 

 

반응형