logo
Published on

Next.js는 페이지를 두 번 그린다

Authors
  • avatar
    Name
    MJ
    Twitter

Suspensive의 Docs에 버그를 issue로 제보하면서 알게 된 내용을 기반으로 정리해요.

Next.js는 왜 '두 번'이나 그릴까?

이 글에서는 Next.js가 어떤 이유로 페이지를 "두 번" 그리는 구조를 선택했는지, 그 흐름에서 RSC와 Hydration이 어떤 역할을 맡고 있는지를 풀어보려 해요.

미리 보는 핵심 포인트

  • Next.js는 한 번은 서버에서 HTML과 RSC를 생성하고, 또 한 번은 클라이언트에서 해당 데이터를 기반으로 조립합니다.
  • 이 과정을 통해 사용자는 빠른 초기 렌더링과 부드러운 페이지 전환을 동시에 경험하게 됩니다.

Next.js는 다음 두 가지 목표를 동시에 만족시키기 위해 페이지를 두 번에 나눠 그리는 방식을 채택했어요.

1. 초기 화면을 최대한 빠르게 보여주기 위해 (서버 렌더링)

사용자가 페이지에 접근하면, HTML과 RSC 데이터를 스트리밍 방식으로 서버에서 바로 보냅니다. 이렇게 하면 페이지 로딩 속도와 SEO 모두 챙길 수 있게 돼요.

2. 페이지 전환은 부드럽게, 필요한 부분만 다시 그리기 위해 (클라이언트 조립)

Next.js는 클라이언트에서 전체 페이지를 다시 요청하지 않고, 필요한 부분의 RSC 데이터만 받아서 업데이트하죠. 이때, 브라우저는 서버가 보낸 데이터(Flight)를 이용해 클라이언트 컴포넌트를 hydration 하고, 이미 받은 HTML과 조립해 인터랙션이 가능한 완성된 앱으로 구성해요.

Flight는 서버 컴포넌트를 직렬화한 데이터 트리 포맷을 말해요.

이렇게 SSR과 CSR을 혼합한 방식으로 페이지를 그리는 것이 바로 Next.js의 렌더링 구조에요.

첫 번째 그림: 서버가 먼저 HTML과 RSC를 뿌린다

이 부분은 Next.js의 app-render.tsx를 참고해서 정리했어요.

first-request-mermaid
  1. 사용자가 페이지를 요청하면, Next.js 서버가 이를 수신해요.
  2. 서버는 사용자의 요청을 수신한 뒤 분석하여 Context를 생성해요.
  3. Context는 사용자의 요청 헤더를 파싱하고, RSC_HEADER 헤더가 있는지 확인해요.
  4. 요청을 분기하여 아래 두 가지 방식으로 처리해요.
    1. RSC_HEADER가 있으면, 서버는 RSC를 반환해요.
    2. RSC_HEADER가 없으면, 서버는 HTML을 반환해요. 이때,

일반적인 요청을 추상화한 다이어그램이에요. 실제로는 더 복잡한 로직이 있지만, 개발자가 이해하기 쉽도록 간소화했어요.

Next.js는 요청 헤더를 분석하여 어떤 응답을 내려줄지 결정해요. 각 응답에 대해서 더 자세히 알아볼게요.

HTML만 보내는 게 아니라고? RSC라는 데이터도 같이 보낸다?

RSC_HEADER가 없는 요청은 웹페이지의 첫 렌더링이에요. 이때는 사용자 브라우저의 DOM에 아무 정보도 없기 때문에, 페이지의 내용, 디자인(CSS), 기능(JavaScript) 등 모든 정보가 필요하죠.

서버는 이 모든 것을 포함하는 완전한 HTML 문서를 만들어서 내려줘요. 그리고 여기에 한 가지 중요한 데이터를 추가하는데, 바로 RSC 페이로드예요. 이 RSC 페이로드는 다음 렌더링(예: 클라이언트 사이드 탐색)을 준비하거나, 현재 페이지의 초기 상태를 클라이언트 측 Next.js 앱에 전달하기 위한 숨겨진 데이터라고 생각할 수 있어요. HTML 문서 내부에 직렬화된 JSON 데이터 형태로 포함되어 함께 전송되죠. ("UI의 모습"을 HTML로, "UI의 구성 정보"는 RSC Payload로 보낸다고 생각하면 쉬워요.)

브라우저는 이 HTML 문서를 받아서 페이지의 초기 내용들을 빠르게 화면에 보여줘요. 동시에 HTML에 포함된 스크립트들이 로드되고 실행되면서, 서버에서 미리 만들어진 HTML 콘텐츠 위에 React 앱이 동작할 수 있도록 하이드레이션(Hydration) 과정을 진행합니다.

더 자세히 살펴보자면, 이렇게 동작해요.

  • 브라우저가 서버에서 전달된 HTML 문서를 받아서 페이지의 초기 내용들을 빠르게 화면에 보여줘요.
  • 렌더링 중 <script src="/_next/static/...">를 만나면 JS 번들을 로드해요.
  • 이 때, react-dom/client.hydrateRoot() 가 실행되게 돼요.
  • 이 시점부터 클라이언트가 서버에서 전달된 RSC Payload를 요청하여 받은 뒤, 서버에서 만들어둔 UI 상태를 클라이언트에서도 복원해요.

사용자는 이런 구조 덕분에 빠르게 페이지를 조회하고, 필요한 부분부터 상호작용할 수 있게 되는거에요.

hydration 이전에 상호작용을 시작하면 어떻게 될까요?

Selective Hydration으로 해결할 수 있어요. 이 방식은 클라이언트에서 필요한 부분 먼저 하이드레이션을 진행하는 방식이에요. Next.js는 스트리밍으로 hydration을 진행하기 때문에 클라이언트에서 필요한 부분만 하이드레이션을 진행할 수 있어요. 이렇게 하면 사용자는 빠르게 페이지를 조회하고, 필요한 부분부터 상호작용할 수 있게 되는거에요.

두 번째 그림: 브라우저에서 다시 조립되는 페이지

사용자가 웹사이트에 접속해서 페이지를 탐색하고 있는 상황을 떠올려 보세요. next/link를 클릭하거나 router.push() 같은 함수로 페이지를 이동할 때가 이에 해당해요. 이 시점에는 브라우저에 이미 Next.js 앱이 실행 중이고, 페이지의 기본적인 HTML과 자바스크립트가 로드되어 있는 상태예요.

이럴 때에는 전체 페이지를 다시 요청하는 것이 아니라, 필요한 부분의 RSC 데이터만 받아서 렌더링하는게 효율적인 방법이에요.

그래서 클라이언트는 서버에 HTTP GET 요청을 보내는데, 이때 요청 헤더에 RSC_HEADER를 포함해요. 이 헤더는 서버에게 RSC 페이로드(Flight Data)만 필요하다는 신호를 보냅니다.

서버는 generateDynamicRSCPayload 함수를 호출하여, 요청된 새 라우트와 현재 클라이언트 라우터 상태(Next-Router-State-Tree 헤더)를 비교해요.

이 비교를 통해 변경된 부분만 효율적으로 식별하고, 해당 부분에 대한 React 서버 컴포넌트를 다시 렌더링해요. 예를 들어, article 레이아웃은 그대로 유지하고 아티클 내용만 바뀌는 경우, 서버는 아티클 내용에 해당하는 컴포넌트만 렌더링해서 보낼 준비를 해요.

이 데이터를 받은 클라이언트는 이제 이 데이터를 이용해서 페이지를 그리게 돼요. 이 과정을 통해 사용자는 페이지가 깜빡이거나 전체가 다시 로드되는 느낌 없이, 부드럽고 빠르게 새로운 페이지 내용으로 전환되는 경험을 하게 될 수 있는거에요. (CSR의 사용자 경험을 Next.js에서도 줄 수 있는 비법이에요.)

RSC는 Hydration일까? 둘은 같은 듯 달랐다

결론부터 말하면 RSC(React Server Components)는 Hydration이 아니에요. 하지만 둘은 Next.js 앱이 상호작용 가능한 웹 페이지로 거듭나는 과정에서 서로 긴밀하게 연결돼있어요.

Hydration은 서버에서 미리 렌더링된 HTML에 클라이언트 측 JavaScript를 '부착'해서 페이지를 동적으로 만드는 과정이에요.

사용자가 버튼을 클릭하면 반응하게 만들고, 입력창에 타이핑할 수 있게 하는 등의 상호작용을 가능하게 하죠. react-dom/client.hydrateRoot() 함수가 바로 이 역할을 해요.

반면 RSC는 클라이언트 측 JavaScript 번들로 전송되지 않고, 서버에서만 렌더링되는 React 컴포넌트예요. RSC는 HTML의 일부로 서버에서 미리 그려지거나, 클라이언트 측 라우팅 시 데이터 페이로드(Flight Data) 형태로 전송돼요. 중요한 건 이 데이터 자체가 브라우저에서 직접 실행되는 JavaScript 코드가 아니라는 점이에요. RSC는 UI를 어떻게 그릴지 설명하는 데이터에 가까워요.(Server Driven UI와 같은 느낌?) 서버에서 이 설명서를 만들어서 보내주면, 클라이언트 측 React 앱이 이 설명서를 보고 UI를 어떻게 구성해야 할지 아는 거죠.

그럼 둘은 어떻게 함께 작동할까요? 서버에서 RSC가 HTML이나 Flight Data 형태로 UI 설명서를 보내면, 클라이언트 측에서는 이 설명서를 기반으로 React 트리를 복원하고, 그 위에 클라이언트 컴포넌트들을 Hydration하는 거예요. 즉, RSC가 UI의 내용과 구조를 결정하고, Hydration이 그 구조 위에 상호작용성을 부여한다고 볼 수 있습니다.

서버 컴포넌트는 왜 클라이언트에서 동작하지 않을까?

서버 컴포넌트(RSC)가 클라이언트에서 직접 동작하지 않는 이유는 그들의 본질적인 목적과 설계 방식 때문이에요.

서버 전용 환경

RSC는 파일 시스템 접근, 데이터베이스 쿼리, API 키 사용 등 서버 환경에서만 가능한 작업을 수행하도록 설계되었어요. 이런 작업들은 보안상 민감하거나, 클라이언트 브라우저에서는 실행될 수 없는 것들이죠. 만약 RSC가 클라이언트에서 동작한다면, 이런 서버 전용 로직이 브라우저에 노출되거나 오류를 일으킬 수 있어요.

번들 크기 최적화

RSC의 가장 큰 장점 중 하나는 클라이언트 JavaScript 번들 크기를 줄이는 것이에요. RSC는 서버에서만 실행되고 그 결과(HTML 또는 Flight Data)만 클라이언트로 보내지기 때문에, RSC 코드 자체가 클라이언트 번들에 포함될 필요가 없어요. 덕분에 사용자가 다운로드해야 하는 JavaScript 양이 줄어들어 페이지 로드 속도가 빨라지죠. 만약 RSC가 클라이언트에서 동작해야 한다면, 그 코드가 모두 클라이언트 번들에 포함되어야 하므로 번들 크기 절감이라는 이점을 잃게 돼요.

데이터 직렬화

RSC는 서버에서 렌더링된 후, 그 결과가 직렬화(serialize)되어 클라이언트로 전송돼요. 이 직렬화된 데이터는 HTML이나 특별한 포맷(Flight Data)으로 변환된 'UI 정보'이지, 브라우저에서 직접 실행할 수 있는 '코드'가 아니에요. 클라이언트 React는 이 직렬화된 데이터를 파싱해서 UI를 구성하는 데 사용합니다.

요약하자면, 서버 컴포넌트는 서버의 자원을 효율적으로 활용하고, 클라이언트의 부담을 줄이며, 웹 애플리케이션의 성능을 최적화하기 위해 서버 전용으로 설계되었습니다.

Next.js의 렌더링은 왜 이렇게 복잡해졌을까?

CSR, SSR, 그리고 RSC까지… 이 구조가 필요한 이유

이 복잡한 구조는 웹 렌더링의 다양한 방식들이 가진 장점은 취하고 단점은 보완하기 위해 진화해온거예요.

CSR (Client-Side Rendering)

  • 장점: 페이지 전환이 빠르고 부드러워요. 이미 다운로드된 JavaScript를 사용해서 필요한 부분만 업데이트하니까요.
  • 단점: 첫 로딩 시 빈 화면이 오래 보이거나, JavaScript 번들 크기가 커질 수 있어요. 검색 엔진 최적화(SEO)에도 불리할 수 있고요.

SSR (Server-Side Rendering)

  • 장점: 서버에서 HTML을 미리 만들어서 보내주니 첫 로딩이 빠르고, 검색 엔진이 페이지 내용을 쉽게 파악할 수 있어 SEO에 유리해요.
  • 단점: 서버 부하가 커질 수 있고, 페이지 전환 시 전체 페이지를 다시 받아야 하므로 CSR보다 느리거나 화면이 깜빡일 수 있어요.

RSC (React Server Components)

  • 등장 배경: SSR과 CSR의 장점을 모두 취하면서 단점을 보완하기 위해 등장했어요. 특히 '서버에서만 실행되는 컴포넌트'라는 개념으로 클라이언트 번들 크기를 획기적으로 줄이고, 서버의 풍부한 데이터 접근성을 활용할 수 있게 했죠.

  • RSC의 역할: RSC는 SSR처럼 빠른 초기 HTML 렌더링에 기여하고, CSR처럼 페이지 전환 시 필요한 데이터(Flight Data)만 전송하여 부드러운 업데이트를 가능하게 합니다. 즉, SSR과 CSR 사이의 간극을 메우는 다리 역할을 한다고 볼 수 있어요.

Next.js는 이 세 가지 렌더링 방식을 영리하게 결합해서 사용자가 느끼는 웹 성능을 극대화한 거예요. 첫 방문 시에는 SSR과 RSC를 활용해 빠르게 보여주고, 이후 사이트 내에서 이동할 때는 RSC와 CSR을 활용해 부드럽게 전환하는 거죠.

정리하며: Next.js는 왜 이렇게 복잡할까요?

Next.js의 렌더링 구조는 분명 복잡하지만, 이는 개발자가 성능과 사용자 경험을 위해 일일이 수동으로 최적화하던 많은 부분을 프레임워크가 추상화하고 자동화돼 있어 개발자가 구현에 집중하고 최적화에 신경쓸 필요가 없어졌다는 것을 의미해요.

우리는 어디까지 이해하고 써야 할까?

모든 개발자가 Next.js의 모든 렌더링 메커니즘을 모든 범위까지 완벽하게 이해할 필요는 없어요.

  1. 서버와 클라이언트의 역할 구분하여 어떤 코드가 서버에서 실행되고 어떤 코드가 클라이언트에서 실행되는지('use client', 'use server') 명확히 아는 것이 중요해요.

  2. RSC가 HTML에 포함되거나 별도의 페이로드로 전송되어 클라이언트 React에 전달되는 방식을 이해하면, 데이터 로딩과 UI 업데이트의 원리를 파악할 수 있어요.

  3. HTML이 단순히 그림이 아니라 상호작용하는 웹 앱이 되려면 Hydration이 필수적이라는 것을 아는 것이 중요해요.

Next.js는 이 복잡성을 추상화하여 개발자가 더 쉽게 고성능 웹 앱을 만들 수 있도록 돕는 도구예요. 우리가 내부 엔진의 모든 나사를 알 필요는 없지만, 자동차의 작동 원리를 이해해야 효율적으로 운전할 수 있듯이, **Next.js **의 렌더링 원리를 이해하는 것은 더욱 견고하고 최적화된 애플리케이션을 개발하는 데 큰 도움이 될 거예요.