반디북
모던 리액트 Deep Dive
Ch11
준영
도은

11.1 app 디렉터리의 등장

  • Next.js 버전까지는 무언가 페이지 공통 레이아웃을 구성할 수 있는 방법이 _app이 유일
  • 그런데, _app은 루트에 하나만 존재할 수 있었기에, 서로 다른 레이아웃을 구성하기에 부족
💡 이러한 레이아웃의 한계를 극복하기 위해 나온 것이 Next.js의 app 레이아웃

11.2 리액트 서버 컴포넌트

  • 서버 컴포넌트 !== 서버 사이드 렌더링

11.2.1 기존 리액트 컴포넌트와 서버 사이드 렌더링의 한계

  • 리액트의 모든 컴포넌트는 클라이언트에서 작동, 브라우저에서 자바스크립트 코드 처리

    예를 들어, 리액트로 만들어진 페이지를 방문하면
    클라이언트는,
      - 리액트 실행에 필요한 코드를 다운로드
      - 리액트 컴포넌트 트리를 만든 다음
      - DOM을 렌더링
      - 이렇게 만들어진 DOM을 기준으로 하이드레이션 진행
      - 이후 브라우저에서는 상태를 추적
      - 이벤트 핸들러를 DOM에 추가
      - 응답에 따라 렌더링 트리를 변경하기도

🤔 하이드레이션을 진행한다는 건 어떤걸까?

https://ko.react.dev/reference/react-dom/client/hydrateRoot (opens in a new tab)

  • 보통 서버에서 렌더링된 HTML을 hydrate한다고 말하는데
  • hydrateRoot(domNode, reactNode)
    • 첫 번째 인자: 서버에서 렌더링된 DOM 엘리먼트
    • 두 번째 인자: HTML을 렌더링하기 위한 React 노드 (ex. <App />)
💡 hydration 한다는 것은
   서버에서 렌더링된 DOM을 기반으로, 리액트가 관리할 수 있는 컴포넌트 트리로 변환

🤔 hydration은 어떻게 동작하나?

  • 서버에서 렌더링된 DOM 트리를 기반으로, 컴포넌트 트리를 생성하는데
  • 커서를 유지하면서 비교를 하고, 새로 생성하는 것이 아니라 재사용해서 stateNode를 설정

🤔 stateNode는 뭘까?

  • Fiber 노드가 가지고 있는 정보 중 하나
  • 실제 컴포넌트 인스턴스 또는 DOM 노드와의 연결
  • 서버에서 렌더링된 <div>Hi<div> 요소에 대해
    • 리액트는 이 DOM 요소를 stateNode로 설정하고
    • 이를 컴포넌트 트리와 연결
    • 리액트는 stateNode를 통해 DOM 요소를 관리
    • 이후에 상태 업데이트나 이벤트 처리 등을 동적으로 가능하도록
💡 따라서, 새로운 DOM 트리를 생성하지 않고, 기존 DOM 노드를 재사용하여 stateNode 설정하는 것

😲 이런 것들까지 서버에서 이뤄질 수 있다면?

  1. 서버에서는 해당 라이브러리를 실행한 결과와 컴포넌트 렌더링 결과물만 클라이언트에게 제공
    • 클라이언트가 다운로드 및 실행 X
  2. 클라이언트에서 직접 백엔드에 접근해 원하는 데이터 가져오기
    • 백엔드에 접근할 수 있는 단계가 하나 줄어든 것
  3. 연쇄적으로 발생하는 클라이언트와 서버의 요청을 서버에서 처리
    • 데이터를 불러오고 컴포넌트를 렌더링하는 것이 모두 서버에서
    • 클라이언트에서 서버로 요청함으로써 발생하는 지연 감소
    • 클라이언트에서 반복적으로 요청을 수행할 필요도 X

11.2.2 서버 컴포넌트란?

💡 서버와 클라이언트 모두에서 컴포넌트를 렌더링할 수 있는 기법

  - 서버에서 할 수 있는 일은 서버가 처리하게 두고
  - 서버가 할 수 없는 나머지 작업은 클라이언트인 브라우저에서 수행

  • 위는 리액트 컴포넌트 트리를 도식화한 것
  • 모든 컴포넌트는 서버 컴포넌트가 될 수도 있고 클라이언트 컴포넌트가 될 수도 있다.
  • 위와 같은 구조가 가능한 것은 children으로 자주 사용되는 ReactNode에 달려 있다.
// ClientComponent.jsx
'use client';
// ❌ 이렇게 클라이언트 컴포넌트에서 서버 컴포넌트를 불러오는 것은 불가능하다.
import ServerComponent from './ServerComponent.server';
export default function ClientComponent() {
  return (
    <div>
      <ServerComponent />
    </div>
  );
}
'use client'
// ClientComponent.jsx
export default function ClientComponent({children}) {
  return  (
    <div>
      <h1>클라이언트 컴포넌트</h1>
      {children}
    </div>
  )
}
 
// ServerComponent.jsx
export default function ServerComponent() {
  return <span>서버 컴포넌트</span>
}
 
// ParentServerComponent.jsx
// 이 컴포넌트는 서버 컴포넌트일 수도, 클라이언트 컴포넌트일 수도 있다.
// 따라서 두 군데 모두에서 사용할 수 있다.
import ClientComponent from './ClientComponent'
import ServerComponent from './ServerComponent'
export default function ParentServerComponent() {
  return (
    <ClientComponent>
      <ServerComponent/>
    </ClientComponent>
  )
}
  • 서버 컴포넌트와 클라이언트 컴포넌트가 있으면 동시에 두 군데에서 모두 사용할 수 있는 공용 컴포넌트가 있다는 것

😲 세 컴포넌트의 차이와 제약사항

  1. 서버 컴포넌트

    • 요청이 오면 그 순간 서버에서 딱 한 번 실행되는 것
    • 상태를 가질 수 없음
    • 리액트에서 상태를 가질 수 있는 hook 사용 불가능
    • 렌더링 생명주기도 사용 불가능
    • 한번 렌더링이 되면 그걸로 끝
    • 서버에서 제공할 수 있는 기능만 사용하는 훅이라면 충분히 사용 가능
    • 브라우저에서 실행되지 않으므로, DOM API를 쓰거나 window, document에 접근 불가능
    • 다른 서버 컴포넌트를 렌더링하거나 div, span, p 같은 요소 렌더링 가능. 혹은 클라이언트 컴포넌트 렌더링 가능
  2. 클라이언트 컴포넌트

    • 브라우저 환경에서만 실행되므로, 서버 컴포넌트를 불러오거나 서버 전용 훅이나 유틸리티 사용 불가능
    • 서버 컴포넌트가 클라이언트 컴포넌트를 렌더링하는데,
      • 클라이언트 컴포넌트가 children으로 서버 컴포넌트를 갖는 구조는 가능
      • 클라이언트 입장에서 봤을 때, 서버 컴포넌트는 이미 서버에서 만들어진 트리를 가지고 있을 것이고,
      • 클라이언트 컴포넌트는 이미 서버에서 만들어진 그 트리를 삽입해서 보여주기만 하기 때문
      • 따라서, 서버 컴포넌트와 클라이언트 컴포넌트를 중첩해서 갖는 위와 같은 구조를 설계하는 것이 가능
  3. 공용 컴포넌트(shared components)

    • 서버와 클라이언트 모두에서 사용 가능
    • 공통으로 사용할 수 있는 만큼, 당연히 서버 컴포넌트와 클라이언트 컴포넌트의 모든 제약을 받는 컴포넌트가 된다.

😲 리액트는 어떻게 서버 컴포넌트인지, 클라이언트 컴포넌트인지, 공용 컴포넌트인지 판단할까?

  • 리액트는 모든 것을 다 공용 컴포넌트로 판단
💡 즉, 리액트는 모든 컴포넌트를 다 서버에서 실행 가능한 것으로 분류
  • 대신 클라이언트 컴포넌트라는 것을 명시적으로 선언하려면 "use client" 작성

11.2.3 서버 사이드 렌더링과 서버 컴포넌트의 차이

  • 서버 사이드 렌더링
    • 응답받은 페이지 전체를 HTML로 렌더링하는 과정을 서버에서 수행한 후, 그 결과를 클라이언트에 내려준다.
    • 클라이언트에서 하이드레이션 과정을 거쳐 서버의 결과물을 확인하고 이벤트를 붙이는 등의 작업을 수행

11.2.4 서버 컴포넌트는 어떻게 작동하는가?

아래는 서버 사이드 렌더링이 수행되지 않는 코드이다.

app.get(
  '/',
  handleErrors(async function (_req, res) {
    await waitForWebpack();
    const html = readFileSync(path.resolve(__dirname, '../build/index.html'), 'utf8');
  }),
  res.send(html),
);
  • waitForWebpack은 단순히 개발 환경에서 웹팩이 빌드 경로에 index.html을 만들 때까지 기다리는 코드일 뿐
  • 사용자가 최초로 들어왔을 때 수행하는 작업은 오로지 index.html을 제공하는 것

😲 순서를 정리해보자

  1. 서버가 렌더링 요청을 받는다.

    • 서버가 렌더링 과정을 수행해야 하므로
    • 리액트 서버 컴포넌트를 사용하는 모든 페이지는 항상 서버에서 시작된다.
    • 즉, 루트에 있는 컴포넌트는 항상 서버 컴포넌트
  2. 서버는 받는 요청에 따라 **컴포넌트를 JSON으로 직렬화(serialize)**한다.

    • 이때 서버에서 렌더링할 수 있는 것은 직렬화해서 보내고
    • 클라이언트 컴포넌트로 표시된 부분은 해당 공간을 플레이스홀더 형식으로 비워두고 나타낸다.
    • 브라우저는 이 후에 이 결과물을 받아서 다시 역직렬화한 다음 렌더링을 수행
    • M으로 시작하는 줄은 클라이언트 컴포넌트를 의미
      • 클라이언트 번들에서 해당 함수를 렌더링하기 위해 필요한 정보가 chunk에 담겨 있는지 ✔️참조를 전달
    • S는 Suspense를 의미
    • J는 서버에서 렌더링된 서버 컴포넌트
      • 렌더링에 필요한 모든 element, classNAme, props, children 정보 등이 들어가 있다.
  3. 브라우저가 리액트 컴포넌트 트리를 구성

    • 브라우저가 서버로 스트리밍으로 JSON 결과물을 받았다면
    • 이 구문을 다시 파싱한 결과물을 바탕으로 트리를 재구성해 컴포넌트를 만들어 나간다.
    • M1과 같은 클라이언트 컴포넌트를 받았다면, 클라이언트에서 렌더링을 진행할 것
    • 서버에서 만ㄷ늘어진 결과물을 받았다면, 이 정보를 기반으로 리액트 트리를 그대로 만들 것이다.
    • 최종적으로 이 트리를 렌더링해 브라우저의 DOM에 커밋

😲 서버 컴포넌트 작동 방식의 특별한 점

  • 먼저 서버에서 클라이언트로 정보를 보낼 때
    • 스트리밍 형태로 보냄으로써
    • 클라이언트가 줄 단위로 JSON을 읽고 컴포넌트를 렌더링할 수 있어
    • 브라우저에서는 되도록 빨리 사용자에게 결과물을 보여줄 수 있다는 장점
  • 컴포넌트들이 하나의 번들러 작업에 포함 X
    • 각 컴포넌트별로 번들링이 별개도 되어 있어
    • 필요에 따라 컴포넌트를 지연해서 받거나 따로 받는 등의 작업이 가능해짐
  • 서버 사이드 렌더링과 다르게 결과물이 HTML이 아닌 JSON 형태로 보내진 것
    • 클라이언트의 최종 목표는 리액트 컴포넌트 트리를
    • 서버 컴포넌트와 클라이언트 컴포넌트의 두 가지로 조화롭게 구성하는 것
    • 이는 단순히 HTML을 그리는 작업 이상의 일이 필요로 한다.
    • 따라서, HTML 대신 단순한 리액트 컴포넌트 구조를 JSON을 받아서 리액트 컾모넌트 트리의 구성을 최대한 빠르게 할 수 있도록 도와준다.

11.4 웹팩의 대항마, 터보팩의 등장(beta)

  • SWC는 Next.js를 만든 Vercel에서 제공하는 도구로
  • Next.js 12에서 안정화가 완료되어 공식적으로 사용할 것을 권장
  • 많은 프로젝트에서 바벨을 대신해 사용하고 있다.
  • Next.js 13에서는 터보팩(turbopack)을 출시
  • 터포팩은 웹팩 대비 최대 700배, Vite 대비 최대 10배 빠르다고 한다.

Reference