
FE 개발자를 위한 안드로이드 후려치기 #2 자바스크립트 인터페이스 만들기
[소소한 개발 일지] Next.js의 SSR와 RSC에 대해서 알아보자
2025-02-20
Explanation
오늘은 지난 포스팅에 이어서 Next.js의 서버 사이드 렌더링(SSR)과 리액트 서버 컴포넌트(RSC)가 어떻게 동작하는지 조금 더 자세히 알아보는 시간을 가져보려 합니다!
이번 글은 좀.. 뭐랄까.. (다른 글들도 다 비슷하지만..)
개인적으로 공부하며 생각의 흐름대로 작성해서 좀 더 뒤죽박죽 일 수 있습니다.
이전에 Next.js의 버전별로 정리한 적이 있어서, 이번 글에서는 Next.js의 기능적인 부분보다 동작에 대해서 알아보려 합니다! (이전글: https://falsy.me/next-js의-13-14-15-버전의-업데이트-내역-살펴보기/)
이번에 포스팅 하면서 인터넷에서 정보를 찾던 중 발견한 블로그가 있는데요. 글 하나 하나가 엄청 좋았어요. (이 글을 읽는 것보다.. 아래 링크의 글을 참고하시는 게 100만 배 유익…)
블로그: https://mycodings.fly.dev
포스트: https://mycodings.fly.dev/blog/2024-01-28-complete-understanding-nextjs-ssr-and-react-rsc
가장 먼저, Next.js는 v12까지 Pages Router를 사용하였고 v13부터 App Router가 추가되어 사용을 권장하고 있는데요. 이 둘은 여러가지 차이가 있지만 대표적으로 App Rotuer는 React Server Components(RSC), 서버 액션(Server Actions), 스트리밍 및 Suspense 기능을 지원합니다.
반대로 생각해보면, React 18가 출시되고 이때 React Server Components(RSC)라는 새로운 개념이 도입되었고 Next.js는 RSC를 포함한 React 18의 새로운 기능들을 활용하기 위해 App Router를 도입하였습니다.
RSC라는 개념이 도입되기 전까지(React 18 이전, Next 13 이전) Next.js는 모든 페이지에 서버 사이드 렌더링(SSR)을 하고 getServerSideProps, getStaticProps를 사용해서 데이터를 미리 받아오는 방식을 사용했습니다.
하지만 RSC 개념이 도입 된 후 Next.js에서도 서버 사이드에서 렌더링되는 ‘서버 컴포넌트’와 클라이언트 사이드에서 렌더링되는 ‘클라이언트 컴포넌트’로 나누어 사용할 수 있게 되었습니다.
여기서 SSR과 RSC가 헷갈릴 수 있는데요, SSR은 서버 사이드에서 렌더링을 하고 최종적으로 HTML을 클라이언트(브라우저)에 전송하는 것을 말하며, RSC는 서버 사이드와 클라이언트 사이드의 렌더링 방식을 컴포넌트 단위로 나누워 최적화하는 방식을 이야기합니다.
RSC는 서버에서만 실행되는 컴포넌트이기 때문에 서버 컴포넌트의 자바스크립트가 클라이언트에 번들에 포함되지 않기 때문에 클라이언트 측 번들 크기가 줄어들고 Next.js에서는 클라이언트에게 React의 Virtual DOM 정보를 JSON 형식으로 직렬화한(Flight) 데이터를 전달하며 SSR보다 최적화된 성능을 제공합니다.
정확하게는 SSR은 서버에서 HTML 만들어서 클라이언트에 전송하고 클라이언트는 바로 출력하기 때문에 초기 페이지가 보여지는 속도는 RSC보다 SSR이 빠르지만, SSR는 이후에 RSC보다 상대적으로 큰 번들을 로드해야 하고 하이드레이션 과정이 추가로 필요합니다.
(그리고 앞서 이야기한 것처럼 RSC는 SSR와 대립되는 관계는 아니고 RSC도 SSR 방식으로 사용할 수 있지만, Next.js는 SSR 방식이 아닌 JSON 직렬화(Flight) 방식을 사용합니다.)
그리고 데이터 페칭에서 있어서 클라이언트가 아닌 서버에서 직접 데이터를 가져오기 때문에 보안이 향상됩니다.(클라이언트에는 해당 로직이 포함되지 않음) 그리고 React의 Suspense와 Streaming을 함께 사용해서 선택적으로 UI 일부를 먼저 렌더링할 수도 있습니다.
추가로 (저는 사용해본적 없지만..) fs나 DB 쿼리 등을 서버 컴포넌트에서 직접 실행할 수도 있습니다.
렌더링이라는 단어는 웹에서 굉장히 많이 사용되는 단어인데요. 혼란을 줄이기 위해 보편적으로 사용하는 렌더링은 브라우저가 DOM과 CSSOM을 가지고 화면을 그리는 그리는 것을 브라우저의 렌더링이라고 합니다. 그리고 React에서는 조금 다른데요. React에서 말하는 렌더링은 jsx 형태의 값을 가지고 Virtual DOM을 만들고 DOM을 어떻게 업데이트 할지 정하는 과정?을 말합니다.
그리고 여기서 앞서 이야기한 방식들마다 동작의 차이가 있는데요. 우선 SSR의 경우에는 서버에서 컴포넌트를 렌더링 해서 HTML로 만들고 이 HTML과 클라이언트에서 동작하게 할 스크립트를 브라우저에 전송하고 브라우저는 이 HTML을 가지고 DOM 을 만들어서 화면을 출력한 후 함께 전송받은 스크립트를 실행해서 하이드레이션하는 과정을 거치게 됩니다.
다음으로 Next.js의 RSC 방식은 서버 컴포넌트와 클라이언트 컴포넌트를 구분해서 동작하는데, 서버 컴포넌트는 JSON과 비슷한 형태의 React Flight라는 포맷으로 직렬화된 데이터를 HTTP 스트림으로 전송합니다. 이때 서버 컴포넌트는 서버에서만 실행되기 때문에 서버 컴포넌트 로직은 클라이언트에 전송되지 않습니다. 그리고 클라이언트 컴포넌트는 브라우저에서 동작하는 React 컴포넌트로 CSR와 유사하게 Webpack과 같은 번들을 브라우저에 전송합니다.
그러면 브라우저는 서버에서 전송한 서버 컴포넌트의 직렬화 된 데이터(Flight 데이터)와 클라이언트 컴포넌트의 자바스크립트 번들 데이터를 합쳐 최종 UI를 구축하고 이후에 하이드레이션을 수행합니다.
Pages Router에서 ‘getStaticProps’를 사용한 SSG에서 빌드 시점의 HTML을 만들고(SSR) App Router에서는 fetch의 ‘{ cache: “force-cache” }’ 속성을 사용해서 빌드 시점의 JSON 직렬화 데이터(Flight)를 정적으로 생성합니다.(RSC)
1 2 3 4 5 6 7 8 |
export default async function Page() { const res = await fetch("https://api.falsy.com/posts/1", { cache: "force-cache" }) const data = await res.json() return <div>{data.title}</div> } |
Dynamic SSG 역시 ‘getStaticPaths’ 대신 ‘generateStaticParams’와 fetch의 ‘{ cache: “force-cache” }’ 속성을 사용해서 구현할 수 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
export async function generateStaticParams() { const res = await fetch("https://api.falsy.com/posts") const posts = await res.json() return posts.map(post => ({ id: post.id })) } export default async function Page({ params }) { const res = await fetch(`https://api.falsy.com/posts/${params.id}`, { cache: "force-cache" }) const data = await res.json() return <div>{data.title}</div> } |
ISR 역시 Pages Router에서 ‘revalidate’를 사용했던 것처럼 fetch에서 ‘{ next: { revalidate: … } }’를 사용해서 구현할 수 있습니다.
1 2 3 4 5 6 7 8 |
export default async function Page() { const res = await fetch("https://api.falsy.com/posts/1", { next: { revalidate: 10 } }) const data = await res.json() return <div>{data.title}</div> } |
끝으로 간략하게, Next.js에서는 layout.js(레이아웃)나 page.js(페이지)에서 fetchCache 설정을 통해 전체 캐싱(해당 하위 경로 포함) 정책을 설정할 수 있는데요. 아래와 같은 옵션을 설정할 수 있습니다.
https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#fetchcache
1 2 3 4 5 |
// layout.tsx | page.tsx | route.ts export const fetchCache = "auto" // 'auto' | 'default-cache' | 'only-cache' // 'force-cache' | 'force-no-store' | 'default-no-store' | 'only-no-store' |
– ‘auto’
동적 API 요청 전에 실행된 fetch()는 기본적으로 캐싱, 동적 API 요청 후에 실행된 fetch는 캐싱되지 않음.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
export default async function Page() { // fetch 요청은 기본적으로 캐싱 const fetch1 = await fetch("https://api.falsy.com/posts/1") // 동적 API 요청 const fetch2 = await fetch("https://api.falsy.com/posts/2", { cache: "no-store" }) // 동적 API 요청 이후 캐싱되지 않음 const fetch3 = await fetch("https://api.falsy.com/posts/3") ... } |
– ‘default-cache’
모든 fetch() 요청을 정적 데이터로 캐싱(force-cache), 하지만 비활성화할 수 있음(no-store)
– ‘only-cache’
모든 fetch() 요청을 캐싱, 비활성화 불가능.
– ‘force-cache’
모든 fetch() 요청에 어떤 캐시 옵션을 설정하든 강제로 캐싱(force-cache)
– ‘force-no-store’
모든 fetch() 요청이 캐싱 없이 동작(‘no-store’), 캐싱 설정(force-cache)도 무시
– ‘default-no-store’
모든 fetch() 요청이 정적 데이터로 캐싱되지 않음(no-store), 하지만 캐싱할 수 있음(force-cache)
– ‘only-no-store’
모든 fetch() 요청이 캐싱 없이 동작(‘no-store’), 캐싱 설정(force-cache)을 하면 오류 발생.
Advertisement
광고 수익금은 소년소녀가정에 기부됩니다.