배경
- SSR은 서버에서 HTML을 완성해 브라우저로 보내므로, 사용자는 JS 실행 전에도 콘텐츠를 볼 수 있다.
- 그러나, 서버는 데이터 페칭이 완전히 끝나야 HTML 생성을 시작할 수 있어 응답 지연이 발생한다.
- 또한, 페이지를 인터랙티브하게 만들려면 하이드레이션 과정이 필요한데, 이는 React가 브라우저에서 전체 컴포넌트 트리를 다시 구성하는 작업이다.
- 마지막으로, 하이드레이션은 트리 전체를 한 번에 처리하므로, 하나라도 느리면 페이지 전체의 인터랙션이 막힌다.
- 결정적으로 SSR도 CSR처럼 모든 컴포넌트의 JavaScript를 클라이언트에 전송해야 했다.
- 즉, 정적인 헤더나 텍스트 컴포넌트조차 하이드레이션을 위해 JS 번들에 포함되는 비효율이 있었다.
- 이 문제를 해결하기 위해 React 팀은 React Server Components(RSC)를 도입했다.
- RSC의 핵심 아이디어는 컴포넌트 단위로 서버/클라이언트 실행 환경을 분리하는 것이다.
- 인터랙션이 필요 없는 컴포넌트는 서버에서만 실행되고, 그 결과만 클라이언트로 전송된다.
- 따라서 Server Component는 클라이언트 JavaScript 번들에 포함되지 않아 번들 크기가 줄어드낟.
- 반면 SSR은 페이지 단위로만 서버 렌더링을 적용할 수 있었던 반면, RSC는 컴포넌트 단위로 제어가 가능하다.
Next.js의 렌더링 흐름과 RSC Payload
- Next.js에서 서버 컴포넌트는 별도 설정 없이 기본으로 활성화되어 있다.
- 서버에서 React는 서버 컴포넌트를 RSC Payload라는 특수한 바이너리 데이터 형식으로 변환한다.
- RSC Payload에는 세 가지 정보가 담겨 있다.
- 첫째, 서버 컴포넌트의 렌더링 결과물이다.
- 둘째, 클라이언트 컴포넌트가 삽입되어야 할 위치와 해당 JS 파일에 대한 참조이다.
- 셋째, 서버 컴포넌트에서 클라이언트 컴포넌트로 전달된
props데이터이다. - Next.js는 이 RSC Payload와 클라이언트 컴포넌트 JS 지침을 합쳐 최종 HTML을 생성한다.
- 클라이언트는 이 HTML을 즉시 화면에 표시하고(초기 로드), RSC Payload로 DOM을 동기화한다.
- 마지막으로 JS 지침으로 클라이언트 컴포넌트만 하이드레이션하여 페이지를 인터랙티브하게 만든다.
- 이 흐름 덕분에 서버 컴포넌트는 하이드레이션이 전혀 없어도 되고, 클라이언트 컴포넌트만 선택적으로 인터랙티브해진다.
- 서버 렌더링 전략은 Static, Dynamic, Streaming 세 가지로 구분된다.
Static Rendering
- Static Rendering은 빌드 타임에 경로를 미리 렌더링하는 방식이다.
- 렌더링 결과는 캐시되어 CDN에 배포되므로, 여러 사용자 간 렌더링 결과를 공유할 수 있다.
- 블로그 게시물이나 제품 페이지처럼 개인화가 필요 없고 내용이 정적인 경로에 적합하다.
- 데이터가 갱신되면 재검증(revalidation) 후 백그라운드에서 다시 렌더링된다.
Dynamic Rendering
-
Dynamic Rendering은 요청이 들어올 때마다 각 사용자에 맞게 경로를 새로 렌더링한다.
-
쿠키, 요청 헤더, URL 검색 파라미터처럼 요청 시에만 알 수 있는 데이터가 필요한 경우에 사용한다.
-
Next.js에서 아래 동적 API를 사용하면 해당 경로 전체가 자동으로 Dynamic Rendering으로 전환된다.
cookies() // 서버 컴포넌트에서 HTTP 요청의 쿠키를 읽을 수 있게 해주는 비동기 함수 headers() // 서버 컴포넌트에서 HTTP 요청의 헤더를 읽을 수 있게 해주는 비동기 함수 searchParams // URL 쿼리 파라미터 unstable_noStore() // 캐시 명시적 비활성화 connection() // 렌더링 과정에서 사용자 요청을 기다려야 하는지 여부를 나타낼 수 있게 해주는 비동기 함수 -
중요한 점은 동적 경로 안에서도 일부 데이터는 캐시할 수 있다는 것이다.
-
RSC Payload와 데이터 캐시가 별도로 관리되기 때문에 이것이 가능하다.
-
개발자가 Static/Dynamic을 직접 선택할 필요는 없고, Next.js가 사용된 API에 따라 자동으로 판단한다.
동적 경로 안에서도 일부 데이터는 캐시할 수 있다는 말은 무엇일까?
-
동적 경로란, 해당 페이지가 요청마다 새로 렌더링되는 경로를 말한다.
-
예를 들어
cookies()를 쓰는 순간, 그 페이지 전체는 동적 경로가 된다. -
"동적 경로 = 모든 데이터를 매 요청마다 새로 가져온다"고 착각하기 쉽다.
-
그러나 실제로는 동적 경로 안에서도 데이터마다 캐시 여부를 개별적으로 설정할 수 있다.
-
아래 예시를 보면, 같은 페이지 안에 캐시되는 데이터와 캐시되지 않는 데이터가 공존한다.
export default async function Page() { // 이 데이터는 캐시됨 — 매 요청마다 DB를 치지 않음 const products = await fetch('/api/products', { cache: 'force-cache' }); // 이 데이터는 캐시 안 됨 — 매 요청마다 새로 가져옴 const user = await fetch('/api/me', { cache: 'no-store' }); } -
즉, 동적 경로라는 것은 "렌더링 자체는 매 요청마다 한다"는 의미이지, "모든 데이터를 매번 새로 가져온다"는 의미가 아니다.
RSC Payload와 데이터 캐시가 별도로 관리된다는 말의 의미
-
이걸 이해하려면 Next.js가 두 가지를 따로 저장한다는 걸 알아야 한다.
-
하나는 RSC Payload 캐시이고, 다른 하나는 데이터 캐시이다.
-
RSC Payload 캐시는 컴포넌트 트리의 렌더링 결과물을 저장한다.
-
데이터 캐시는
fetch등으로 가져온 원본 데이터를 저장한다. -
이 둘이 분리되어 있기 때문에, 렌더링은 매번 새로 해도 데이터는 캐시된 것을 꺼내 쓸 수 있다.
[요청 발생] ↓ [렌더링 실행] ← 동적이므로 매번 실행됨 ↓ [데이터 필요] → 데이터 캐시에 있으면 꺼내 씀 (DB 안 침) → 없으면 새로 fetch -
즉 "동적 렌더링"과 "데이터를 매번 새로 가져오는 것"은 별개의 일이다.
-
RSC Plaload와 데이터가 별도로 캐싱되기 때문에, 요청 시 모든 데이터를 가져올 때 성능에 미치는 영향에 대해 신경 쓸 필요 없이 동적 렌더링을 사용해도 된다는 의미이다.
Streaming
-
기존 SSR은 전체 데이터 페칭과 렌더링이 끝나야 HTML을 전송할 수 있어 느린 데이터가 전체를 차단했다.
-
Streaming은 이 문제를 해결하기 위해 렌더링 결과를 청크 단위로 나눠 준비되는 즉시 클라이언트로 전송한다.
-
전체 페이지가 완성될 때까지 기다리지 않아도 사용자가 일부 UI를 먼저 볼 수 있다.
-
느린 데이터 페칭이 있는 일부 UI가 나머지 페이지 로딩을 막는 문제를 해결한다.
-
Next.js App Router에는 Streaming이 기본으로 내장되어 있다.
-
loading.js파일이나 ReactSuspense를 통해 어떤 부분을 스트리밍할지 세부적으로 제어할 수 있다.import { Suspense } from 'react'; import Reviews from './Reviews'; // 느린 데이터 페칭 export default function ProductPage() { return ( <div> <h1>제품 정보</h1> {/* 즉시 표시 */} <Suspense fallback={<p>리뷰 불러오는 중...</p>}> <Reviews /> {/* 준비되면 스트리밍 */} </Suspense> </div> ); } -
위 예시처럼 느린 리뷰 컴포넌트가 나머지 페이지 렌더링을 차단하지 않도록 분리할 수 있다.
렌더링 흐름
- 사용자가 특정 경로에 접속하면 Next.js 서버가 해당 페이지의 서버 컴포넌트 트리를 실행한다.
- 실행 결과는 RSC Payload라는 스트리밍 가능한 포맷으로 직렬화된다.
- 이 Payload와 함께 클라이언트 컴포넌트에 필요한 JS 번들만 선택적으로 브라우저로 전송된다.
- 브라우저는 RSC Payload를 해석해 DOM을 구성하고, 클라이언트 컴포넌트 부분만 hydration한다.
- 결과적으로 서버 컴포넌트만으로 구성된 페이지는 hydration 비용이 0에 가깝다.