개발새발 로그

NextJS 14 app router에서 로그가 출력 되는 순서... 당연히 위에서 아래로? - SSR과 RSC와 RCC 본문

React

NextJS 14 app router에서 로그가 출력 되는 순서... 당연히 위에서 아래로? - SSR과 RSC와 RCC

이즈흐 2024. 3. 26. 23:01

Next를 사용하면서 TanStackQuery의 prefetchQuery를 사용해서 미리 서버에서 데이터를 갖고오고, 그 데이터를 getQueryData()로 가져와서 필요한 곳에 사용하는 방식을 자주 사용했다.

 

reactQuery에 캐신된 데이터를 사용함으로써 모든 컴포넌트는동일한 상태의 데이터를 참조하게 되고,

이로 인해 데이터의 일관성을 유지했다.

즉, SSOT( single source of truth :단일 진실 공급원) 원칙을 지킬 수 있었다.

 

이 로직은 특정한 상황에서만 사용하기는 했지만 아주 유용했다.

 

근데 어느 순간 내가 알던 것과 다른 문제가 생겼다.

 

나는 layout.tsx에서 page.tsxchildren으로 들어가고,

layout.tsx가 실행되고 page.tsx가 실행되는 건 줄 알 고있었다. -> 평범한 리액트 컴포넌트 처럼

 

그래서 아래와 같은 폴더 구조일 때 상위에 있는 layout.tsx에서 prefetchQuery를 실행해서 user 데이터를 캐시에 저장하고,

getQueryData를 사용해서 user데이터를 활용한 다음

하위 page.tsx하위 layout.tsx에서 getQueryData만을 이용해서 user데이터에 접근하려고 했다.

 

근데 하위 layout과 page에서 user 데이터를 못 갖고 오고 있었다.

 

그래서 console.log로 확인해보니 prefetchQuery를 실행하고 getQueryData를 한 상위 layout.tsx만 제대로 가져오고 데이터를 받고 있었다.

 

그리고 로그 출력순서가 내가 예상한 것이 아니었다.

로그는 getQueryData를 한 이후에 호출해줬다.

현재 url은 localhost:3000/home이다.

그리고 상위 layout.tsx는 그저 그룹라우팅의 layout이다.

 

자세히 보면 page.tsx가 먼저 로그가 출력되어 예상한 순서와 반대의 순서로 로그가 출력되고,

await prefetchQuery()를 한 부분인 상위 layout.tsx가 가장 나중에 console.log가 출력되었다.

 

왜 이렇게 동작하는지 알아보려고 한다.

 

왜 반대로 로그가 출력돼?

정말 이상했다.

이때까지 위에서 말했던 대로 리액트 컴포넌트처럼 동작하는 줄 알고 있었다.

 

그래서 테스트를 하기위해 새로운 next 프로젝트를 만들고 아래와 같이 만들었다.

그리고 localhost:3000/test/testgo 라는 주소로 접속해서 새로고침을 했다.

그랬더니 위에서 했던 로그와 똑같은 순서로 나왓다.

여기서 TestComponent를 만들어서 page.tsx에서 호출하게 했는데 이는 내가 예상한 순서로 나왔다.

 

그래서 한번 network 탭을 확인해봤다.

그랬더니 testgo의 자바스크립트를 먼저 파싱하고 있었다.

일단 testgo가 먼저 로드되는 이유는 알아냈다.

간단하게 보면 test/testgo를 접속했으니까 해당되는 html을 불러온 것이다.

 

그러면 page.tsx와 layout.tsx의 순서가 바뀐 이유는 무엇일까?

코드는 분명 layout.tsx의 children에서 page.tsx가 포함되는 것이다 했는데

그럼 layout.tsx와 page.tsx는 부모관계가 아닌가? 했다.

 

그래서 찾아봤더니 Next의 페이지 기반 라우팅 때문이라고 하는 것 같았다.

Next.js는 페이지를 기반으로 하는 프레임워크입니다.
여기서 페이지Next.js 앱의 진입점 역할을 합니다.
페이지 컴포넌트는 사용자가 방문하는 URL에 따라 결정되며, Next.js는 해당 페이지 컴포넌트를 렌더링하기 위해 필요한 모든 준비를 합니다.
이 과정에서 페이지 컴포넌트는 가장 먼저 실행되는 컴포넌트가 됩니다.
레이아웃 컴포넌트는 페이지의 일부분이 아닌, 페이지들을 감싸는 외부 컴포넌트의 역할을 합니다.

일반적으로 레이아웃은 여러 페이지에서 공통적으로 사용되는 UI 부분(예: 헤더, 네비게이션 바, 푸터 등)을 정의합니다.
이 레이아웃 컴포넌트는 페이지 컴포넌트 내부에서 호출되거나, 또는 특정 페이지 렌더링 메커니즘을 통해 자동으로 적용됩니다.

Next.js에서 페이지 컴포넌트(TestPage)가 먼저 실행되는 이유는, 페이지 로직이 가장 먼저 처리되어야 하기 때문입니다.
페이지 컴포넌트 내에서 console.log("나는 하위 page.tsx")가 실행되고, 그 다음에 페이지를 감싸는 레이아웃 컴포넌트(TestLayout)의 로직이 실행됩니다.

이 경우, 레이아웃 컴포넌트는 페이지 컴포넌트의 children으로 취급되어, 페이지 컴포넌트 로직 이후에 실행되는 것입니다.
출력 로그 순서가 "나는 하위 page.tsx", "나는 하위 layout.tsx" 순서로 나타나는 것은, Next.js가 페이지 컴포넌트를 먼저 처리하고, 그 다음에 레이아웃 컴포넌트를 처리하기 때문입니다.
이는 Next.js의 페이지 기반 라우팅 및 렌더링 시스템의 특징으로, React의 기본 컴포넌트 실행 순서와는 구별되는 동작 방식입니다.

 

 

그러면 반대로 하위 testgo의 page.tsx"use client"를 넣어봤다.

네트워크 탭에서 불러오는 순서는 똑같았지만 로그는 위와 같이 다르게 찍혔다.

클라이언트 컴포넌트는 가장 나중에 호출된다.

이게 서버컴포넌트클라이언트 컴포넌트의 차이인데 
여기서 궁금한 점이 생겼다

 

SSR과 RSC(React Server Component)는 다른건가?

이를 알기 위해서는 RSC와 RCC를 알아야한다.

 

RSC의 동작 방식

RSC의 특징을 이해하고 RSC와 RCC를 적재적소에 배치하기 위해서는 실제로 RSC가 어떻게 렌더링되는지 이해할 필요가 있다.

아래와 같이 RSC와 RCC를 적절하게 혼합하여 구성한 스크린이 있다고 가정해 보자.

 

사용자는 해당 페이지를 띄우기 위해 서버로 요청을 날린다. 그러면 서버는 이때부터 컴포넌트 트리를 root부터 실행하며 직렬화된 json형태로 재구성하기 시작한다.

 

직렬화란

여기서 직렬화(serialization)에 대한 이해가 필요하기 때문에 잠깐 짚고 넘어가겠다.

직렬화란 데이터 스토리지 문맥에서 데이터 구조나 오브젝트 상태를 동일하거나 다른 컴퓨터 환경에 저장(이를테면 파일이나 메모리 버퍼에서, 또는 네트워크 연결 링크 간 전송)하고 나중에 재구성할 수 있는 포맷으로 변환하는 과정

 

쉽게 말해서 특정 개체를 다른 컴퓨터 환경으로 전송하고 재구성할 수 있는 형태로 바꾸는 과정이라고 할 수 있다.

 

주의할 점은 모든 객체를 직렬화 할 수 없다는 것이다.

대표적으로 function이 직렬화가 불가능한 객체다.

function이 실행코드와 실행컨텍스트를 모두 포함하는 개념이기 때문인데

함수는 자신이 선언된 스코프에 대한 참조를 유지하고, 그 시점의 외부 변수에 대한 참조를 기억하고 있다.

JS의 클로저가 바로 이 현상이다.

 

그래서 함수의 실행컨텍스트, 스코프, 클로저까지는 직렬화 할 수 없기 때문에 function은 직렬화가 불가능한 객체로 분류된다.

<div style={{backgroundColor:'green'}}>hello world</div> //JSX 코드는 createElement의 syntax sugar다.

> React.createElement(div,{style:{backgroundColor:'green'}},"hello world")

> {
  $$typeof: Symbol(react.element),
  type: "div",
  props: { style:{backgroundColor:"green"}, children:"hello world" },
  ...
} //이런 형태로 모든 컴포넌트를 순차적으로 실행한다.

그래서 직렬화 과정은 모든 서버 컴포넌트를 실행하면서 json 객체 형태의 트리로 재구성할 때 까지 진행되는데 RCC일 경우 건너뛰게 된다.

하지만 건너뛴다면 실제 컴포넌트 트리와 괴리가 생기기 때문에 RCC는 직접 해석하지 않고, "이곳은 RCC입니다" 라는 placeholder를 대신 배치한다고 한다.

{
  $$typeof: Symbol(react.element),
  type: {                                       // type이 위 서버컴포넌트와 다르다.
    $$typeof: Symbol(react.module.reference),
    name: "default", //export default를 의미
    filename: "./src/ClientComponent.js" //파일 경로
  },
  props: { children: "some children" },
}

위에서 말했듯이 RCC는 함수이므로 직렬화 할 수 없다.

따라서 함수를 직접 참조하는 것이 아니라 “module reference” 라고 하는 새로운 타입을 적용하고, 해당 컴포넌트의 경로를 명시함으로써 직렬화를 우회하고 있다.

 

이러한 직렬화 작업을 마친 후 생성된 JSON Tree를 도식화하면 다음과 같은 형태를 띠고 있다.

이제 이렇게 도출된 결과물을 Stream 형태로 클라이언트가 전달받게 되고, 함께 다운로드한 js bundle을 참조하여,

module reference 타입이 등장할 때마다 RCC를 렌더링해서 빈 공간을 채워놓은 뒤,

DOM에 반영하면 실제 화면에 스크린이 보여지게 되는 것이다.

 

그러면 여기서 궁금한 것은 SSR과 RSC와 RCC는 무엇인가였다.

 

특히 SSR과 RSC의 차이가 무엇인지 헷갈렸다.

 

 RSC와 SSR은 서버에서 처리한다는 공통점 외에는 각각 해결하고자하는 목표도 다르고, 일어나는 시점과 최종 산출물도 다른 완전히 별개의 개념이다.

 

따라서 반드시 둘 중 하나를 선택할 필요도 없고 필요에 따라 RSC와 SSR을 함께 사용하면 큰 시너지를 낼 수도 있다.

우리가 작성한 소스코드가 브라우저에 보여지기 위해서는 우선 컴포넌트가 실행되어 데이터가 해석되어야하고, 그 해석된 데이터가 다시 html로 변환하는 과정을 거쳐야한다. RSC전자에 해당하는 단계에 관여하고, SSR후자에 관여한다.

 

SSR (Server-Side Rendering)

  • 정의 : SSR은 클라이언트로 보내기 전에 서버에서 HTML을 생성하는 방식입니다. 사용자가 웹 페이지에 접근할 때, 서버는 해당 페이지의 초기 상태를 포함한 완성된 HTML을 생성하고 이를 클라이언트에 전송합니다. 이 방식은 초기 페이지 로딩 시간을 단축시키고, 검색 엔진 최적화(SEO)에 유리하다는 장점이 있습니다.
  • 동작 방식: 사용자가 웹 페이지에 접근하면, 서버는 리액트 컴포넌트(또는 다른 프레임워크의 컴포넌트)를 HTML로 렌더링하고, 이를 클라이언트에 전송합니다. 클라이언트는 받은 HTML을 즉시 렌더링하여 사용자에게 보여줍니다.

서버 컴포넌트 (React Server Components)

  • 정의: React 18에서 소개된 새로운 기능으로, 서버 컴포넌트는 서버에서만 실행되고 클라이언트로 전송되지 않는 리액트 컴포넌트입니다. 이러한 컴포넌트는 서버에서 데이터를 가져오고, UI를 구성하는 데 사용되며, 최종적으로 HTML로 변환되어 클라이언트에 전송됩니다. 서버 컴포넌트는 클라이언트 사이드 번들 크기를 줄이고, 성능을 향상시킬 수 있습니다.
  • 동작 방식: 서버 컴포넌트는 서버에서 실행되어 필요한 데이터를 처리하고 UI를 구성합니다. 이렇게 해서 만들어진 UI는 HTML 형태로 클라이언트에 전송되며, 클라이언트에서는 추가적인 자바스크립트 실행 없이 이 HTML을 렌더링합니다. 클라이언트에서 실행되는 자바스크립트의 양이 줄어들어 성능이 향상됩니다.

여기서 궁금할 수 있는 점이 있는데

RSC와 SSR는 공존할 수 있다고 했는데, RSC를 쓰든 RCC를 쓰든 SSR을 적용하면 클라이언트가 받는 최종 산출물은 html로 동일한거 아닌가?

그러면 SSR을 채택했을 때 RSC의 이점은 어디에 있는걸까?

 

이를 위해서는 CSRSSR의 간단한 개념을 알아야 한다고 한다.

 

Client Side Rendering은 말 그대로 클라이언트에서 컴포넌트를 렌더링하는 것을 의미한다.

서버에서 빈 html과 js bundle을 다운로드 받고,

이 js 소스코드를 클라이언트에서 해석해서 처음부터 그려나가게 된다.

때문에 초기 로딩속도가 느리지만, 스크린간 이동이나 인터렉션에 강점이 있다.

 

Server Side Rendering서버에서 컴포넌트를 해석하여 최종 결과물인 html 파일을 내려주는 것을 의미한다.

CSR과는 반대로 초기 로딩속도가 빠르지만, 페이지를 이동할때마다 새로운 html을 요청해서 받는 시간이 필요하고, 현재 화면에서도 작은 변경사항이 발생하면 처음부터 html을 다시 로드해야하기 때문에 스크린간 이동이나 인터렉션에 약점이 있다.

 

사실 next js에서 우리가 사용하는 SSR은 전통적인 의미의 SSR은 아니다. 

SSR과 CSR의 장점만을 취하기 위해 일종의 절충점을 찾은 형태라고 할 수 있다.

즉, 초기 로딩속도가 느리다는 CSR의 단점을 보완하기 위해 초기 로딩시에는 html파일을 SSR을 통해 빠르게 받아오고, 이와 병렬적으로 js번들도 함께 가져와서 미리 받아온 html과 병합하는 hydration과정을 거치는 것이다.

그 결과 빠른 로딩에 강점이 있는 SSR과 인터렉션에 강점이 있는 CSR의 장점모두 취할 수 있게 된다.

 

즉 Next js의 SSR 뿐만 아니라 CSR의 특징도 많이 가지고 있으므로 RSC를 함께 사용했을 때 그 이점이 더욱 크게 극대화될 수 있다.

 

조금 더 깊이 가보고 싶어졌다.

그래서 RSC를 쓰면 왜 좋은건데?

RSC의 장점을 찾아 보았다.

Zero Bundle Size

RSC는 서버에서 이미 모두 실행된 후 직렬화된 JSON 형태로 전달되기 때문에 어떠한 bundle도 필요하지 않다.

즉, RSC의 컴포넌트 소스파일 뿐만아니라, RSC에서만 사용하는 외부 라이브러리의 경우에도 번들에 포함될 필요가 없기 때문에 번들사이즈를 획기적으로 감량할 수 있다.

이러한 부분이  Next의 TTI(Time To Interactive) 개선에 크게 기여한다.

 Next에서 SSR을 사용한다고 하더라도 초기 로딩속도에 이점이 있을 뿐 CSR과 동일한 사이즈의 js 번들을 다운받아야하기 때문에 TTI는 여전히 CSR 대비 큰 메리트가 없었기 때문이다.

하지만 RSC를 도입하면 다운받아야하는 번들 사이즈가 줄어들게 되므로, TTI에 개선에 기여할 수 있다

 

No More getServerSideProps / getStaticProps (app directory)

기존 next에서는 getServerSideProps / getStaticProps라는 함수를 이용해서 서버에 접근했었다.

때문에, Data fetch등을 수행할때는 반드시 getServerSideProps(or getStaticProps)함수를 page 최상단에서 수행하고, 이를 page에 prop으로 넘겨서 사용했어야 했다.

 

하지만 이 과정은 순수 React와는 괴리가 있어 처음 next를 사용하는 사람들에게 낯설 뿐만 아니라, 무조건 최상단에서 fetch 후 page에 prop으로 넘겨줄 수밖에 없는 구조 때문에, 실제 data를 사용하는 하위 컴포넌트의 depth까지 props drilling이 불가피했다.

 

반면 RSC는 그 자체가 서버에서 렌더링되므로, 컴포넌트 내부에서 Data Fetch를 실행해도 무방하다.

즉, data가 필요한 컴포넌트에서 직접 data fetch가 가능해졌고, next14의 app directory에서는 기본적으로 모든 컴포넌트가 RSC이기 때문에 더이상 getServerSideProps / getStaticProps는 불필요한 함수가 되었다.

 

Automatic Code Splitting

본래 code splitting을 하기 위해서는 React.Lazydynamic import를 사용했어야했다.

import dynamic from 'next/dynamic'
            
const DynamicComponent = dynamic(() => import('../components/hello'))

하지만 RSC에서 RCC를 import 하는 케이스에서는 자동적으로 RCC를 dynamic import가 적용된다.

이 장점은 어떻게 보면 굉장히 당연한 사실인데, 서버에서 RSC가 렌더링될 때 RCC는 실행되지 않기 때문에 굳이 RCC를 즉시 import 할 필요가 없기 때문이다.

 

Progressive Rendering

위에서 살펴봤듯, next13부터는 컴포넌트가 서버에서 한차례 렌더링 되며, 그 결과물로 직렬화된 JSON이 생성된다고 했다. 그리고 client는 그 결과물을 스트림의 형태로 수신한다.

예시 코드를 봐보자

// Tweets.server.js
import { fetch } from 'react-fetch' // React's Suspense-aware fetch()
import Tweet from './Tweet.client'
export default function Tweets() {
  const tweets = fetch(`/tweets`).json()
  return (
    <ul>
      {tweets.slice(0, 2).map((tweet) => (
        <li>
          <Tweet tweet={tweet} />
        </li>
      ))}
    </ul>
  )
}

// Tweet.client.js
export default function Tweet({ tweet }) {
  return <div onClick={() => alert(`Written by ${tweet.username}`)}>{tweet.body}</div>
}

// OuterServerComponent.server.js
export default function OuterServerComponent() {
  return (
    <ClientComponent>
      <ServerComponent />
      <Suspense fallback={'Loading tweets...'}>
        <Tweets />
      </Suspense>
    </ClientComponent>
  )
}
M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
S2:"react.suspense"
J0:["$","@1",null,{"children":[["$","span",null,{"children":"Hello from server land"}],["$","$2",null,{"fallback":"Loading tweets...","children":"@3"}]]}]
M4:{"id":"./src/Tweet.client.js","chunks":["client8"],"name":""}
J3:["$","ul",null,{"children":[["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}],["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}]]}]

위 문자열은 클라이언트가 수신하는 스트림의 한 예시를 나타낸 것이다.

여기서 짚고 넘어갈 부분은 데이터가 ‘스트림’ 형태로 전달된다는 사실이다.

즉, 스크린의 모든 화면정보를 수신할 때까지 기다릴 필요 없이클라이언트는 먼저 수신된 부분부터 반영하기 시작하여 화면에 띄워줄 수 있게 된다.

 


위 스트림 문자열을 보면 S2 지점에 suspense가 서술되어 있다.

그리고 J0를 보면 뒤쪽에 children으로 “@3”이 참조되어 있는 것을 볼 수 있다.

하지만 스트림의 어디를 봐도 “@3”에 대한 정의는 나와있지 않다.

이는,아직 data fetch가 완료되지 않았기 때문에 fallback이 보여지는 상황이기 때문에

"@3"를 placehoder로 사용하고 있기 때문이다.

 

만약 data fetch가 완료되면 “@3”이 “J3”로 대체되고, “J3”는 참조하고 있던 “M4”에 해당하는 client componentdata를 넘겨주면서 화면에 보여지게 된다.

 

따라서 RSC를 React.Suspense와 함께 사용한다면 모든 데이터를 기다릴 필요 없이 먼저 그릴 수 있는 부분을 반영하여 뷰를 로드한 뒤,

data fetch가 완료되면 그 결과가 즉각적으로 스트림에 반영됨을 알 수 있다.

컴포넌트 단위 refetch

전통적인 SSR의 경우 완성된 html파일을 내려주기 때문에 작은 변경사항이 발생하더라도 전체 페이지를 전부 새로 그려서 받아와야 했다.

하지만 직전에 설명했듯이 RSC는 그 최종 결과물이 html이 아니라 직렬화된 스트림 형태로 데이터를 받아오기 때문에,

클라이언트에서 스트림을 해석하여 vitualDOM을 형성하고, Reconciliation을 통해 뷰를 갱신하는 과정을 거치게 된다. 즉, 화면에 변경사항이 생겨서 서버에서 새로운 정보를 받아와야하는 상황이 생기더라도,

새로운 스크린으로 갈아끼우는 것이 아니라 기존 화면의 state등 context를 유지한채로 변경된 사항만 선택적으로 반영할 수 있게 된다.

 

 

 

Next의 페이지 기반 라우팅 부터 SSR과 RSC, RCC를 알아보게 됐다.

어떻게 보면 관련이 없어 보이지만

페이지 기반 라우팅에서 "use client"로 바꿨을 때 생기는 변화에 대해서 궁금해졌었다.

이를 통해서 조금 더 next의 동작방식과 SSR, RSC, RCC에 대해서 알게 된 것 같다.

 

스트리밍과 Suspense 얘기가 나왔는데

이는 리액트를 더 배우면서 Suspense에 대해 정확히 알고 다시 배워야할 것 같다.

 

 

출처

https://velog.io/@2ast/React-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8React-Server-Component%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EC%B0%B0

 

Next) 서버 컴포넌트(React Server Component)에 대한 고찰

이번에 회사에서 신규 웹 프로젝트를 진행하기로 결정했는데, 정말 뜬금 없게도 앱 개발자인 내가 이 프로젝트를 리드하게 되었다. 사실 억지로 떠맡게 된 것은 아니고, 새로운 웹 기술 스택을

velog.io

 

728x90
반응형
LIST