React Server Component
RSC(React Server Component)는 서버에서만 실행되는 컴포넌트를 말한다. 즉, 리액트 컴포넌트 내에서 바로 데이터베이스 쿼리를 작성하는 것과 같은 작업을 할 수 있다.
RSC는 다시 렌더링되지 않는다. 따라서 사이드 이펙트를 고려하지 않아도 된다.
결론적으로 말하면, RSC는 리액트에서 데이터를 효율적으로 가져오기 위해 고안된 메커니즘이다. RSC의 등장을 이해하기 위해서 현대 웹 애플리케이션의 기존 렌더링 패러다임을 알아보자.
CSR
CSR(Client Side Rendering)은 웹 개발 패러다임에 큰 변화를 가져온 모델이다. 특히 SPA(Single Page Application)의 근간을 이룬다. 이 방식에서 브라우저는 서버로부터 최소한의 HTML과 대규모의 자바스크립트 번들을 전달받는다.
이후 모든 렌더링, 데이터 페칭, 라우팅 및 비즈니스 로직 실행이 전적으로 클라이언트에서 이루어진다.
CSR의 가장 큰 장점은 초기 로딩이 완료된 후의 사용자 경험이다. 페이지 전체를 새로고침하지 않고도 동적으로 UI를 업데이트할 수 있어 매우 유동적이고 앱과 같은 상호작용을 제공한다. 이 때문에 상호작용이 많은 애플리케이션에 이상적이다.
하지만 CSR은 명백한 단점을 가지고 있는데, 초기 로딩 시 사용자는 자바스크립트 번들이 다운로드되고 파싱되어 실행될 때까지 빈 화면이나 로딩 스피너를 보게 된다. 이는 사용자가 의미 있는 콘텐츠를 처음 보게 되는 시간(First Contentful Paint, FCP)과 LCP(Largest Contentful Paint)를 지연시켜 초기 사용자 경험을 저해한다. 또한, 검색 엔진 크롤러가 초기의 빈 HTML을 보게 되므로 SEO에 불리하다.
SSR
SSR(Server Side Rendering)은 초기 렌더링을 서버에서 수행하는 방식이다. 이 방식에서 브라우저는 서버로부터 완전한 HTML을 전달받는다. 그 결과 FCP가 매우 빠르며 SEO에도 유리하다.
그러나 SSR에도 고유한 문제가 존재한다. 바로 Hydration 과정이다. 서버에서 생성된 정적 HTML은 상호작용이 불가능하다. 이를 상호작용 가능하게 만들기 위해, 클라이언트는 SSR에 사용된 것과 동일한 자바스크립트 번들을 다운로드하고, 다시 한번 렌더링 로직을 실행하여 가상 DOM을 생성한 뒤, 이를 기존의 정적 HTML에 연결하고 이벤트 리스너를 부착해야 한다. 이 과정은 페이지가 시각적으로는 준비된 것처럼 보이지만 실제로는 상호작용이 불가능하게 만들 수 있다. 이는 FID(First Input Delay), INP(Interaction to Next Paint)와 같은 지표를 좋지 않게 만들 수 있다.
즉, 서버와 클라이언트에서 동일한 렌더링 작업을 두 번 수행하는 비효율이 발생한다. 또한, 사용자의 모든 상호작용에 따른 업데이트를 서버와의 왕복(Round Trip)을 필요로 하므로, 매우 동적인 애플리케이션에는 적합하지 않을 수 있다.
네트워크라는 병목
RSC는 단순히 성능을 조금 개선하려는 시도가 아니었다. 이는 클라이언트 중심 아키텍처가 본질적으로 안고 있는 몇 가지 근본적인 문제를 해결하기 위한 구조적 변화이다.
클라이언트-서버 데이터 워터폴
전통적인 클라이언트 중심 애플리케이션에서 데이터 페칭은 종종 "워터폴" 현상을 유발한다. 예를 들어, 사용자 프로필을 보여주는 페이지를 생각해보자. 상위 컴포넌트가 먼저 사용자 정보를 가져오기 위해 API를 호출한다. 이 요청이 완료되어 응답을 받은 후에야, 하위 컴포넌트들이 해당 사용자 ID를 사용하여 친구 목록이나 게시물 목록을 가져오는 추가 API를 호출할 수 있다. 이러한 순차적이고 의존적인 네트워크 요청들은 각각 서버와의 RTT를 필요로 하며, 특히 사용자의 물리적 위치가 서버와 멀수록 지연 시간을 더욱 길어지게 된다. 결과적으로 전체 UI가 사용자에게 보여지기까지 상당한 시간이 소요된다.
번들 크기
리액트 생태계는 풍부한 서드파티 라이브러리를 통해 발전해왔다. 마크다운 파싱을 위한 라이브러리나 HTML을 안전하게 처리하기 위한 라이브러리들은 매우 유용하지만 클라이언트 자바스크립트 번들 크기를 키우기도 한다.
전통적인 SSR 모델에서도 이 문제는 해결되지 않는다. 상호작용이 없는 정적 콘텐츠를 렌더링하는 데 사용된 라이브러리 코드까지도, Hydration을 위해 클라이언트로 전송되어야 하기 때문이다. 이로 인해 사용자는 더 큰 자바스크립트 파일을 다운로드하고 파싱해야 하며 이는 FID를 지연시키는 직접적인 원인이 된다.
Hydration 오버헤드
SSR은 초기 렌더링 속도를 개선했지만, 근본적으로 클라이언트가 서버가 했던 작업을 다시 반복해야 하는 "이중 작업" 문제를 안고 있다. 이는 명백한 성능 비용이다.
이러한 문제들은 개별적인 이슈처럼 보이지만, 사실은 하나의 근본 원인에서 파생된 문제들이다. 그 원인은 바로 전통적인 리액트 애플리케이션의 "클라이언트 중심" 아키텍처에 있다. 애플리케이션의 두뇌, 즉 리액트 런타임과 컴포넌트 로직이 전적으로 클라이언트에 존재하기 때문에, 클라이언트는 무언가를 하기 위해 항상 네트워크를 통해 모든 코드와 데이터를 가져와야만 했다. 리액트 팀은 서버가 단순히 정적 파일을 제공하거나 API 엔드포인트 역할만 하는 수동적인 존재로 저활용되고 있음을 인지했고, RSC는 이러한 클라이언트 중심의 가정을 근본적으로 뒤집는 철학적 전환을 의미한다. 이는 연산을 데이터가 있는 곳으로 이동시켜, 서버를 리액트 렌더링 과정의 일등 시민으로 격상시키려는 시도이다. 쉽게 말해, 이제 데이터를 가져오고 처리하는 일을 서버에서 직접 하도록 해서, 서버가 리액트 앱을 만드는 데 더 중요한 역할을 하게 된 것이다.
새로운 모델
RSC는 앞서 언급된 문제들을 해결하기 위해 새로운 아키텍처 모델을 제시한다.
이 모델의 핵심은 개발자가 각 컴포넌트의 특성에 따라 최적의 렌더링 위치를 서버 또는 클라이언트로 직접 선택할 수 있다는 점이다. 예를 들어, 데이터베이스에서 게시물 목록을 가져와 표시하는 부분은 서버 컴포넌트로 만들어 번들 크기 증가 없이 서버에서 직접 렌더링하고, 각 게시물에 있는 좋아요 버튼과 같이 상호작용이 필요한 부분만 클라이언트 컴포넌트로 만드는 식이다.
RSC의 목표는 클라이언트를 완전히 배제하는 것이 아니라, 서버 코드와 클라이언트 코드를 매끄럽게 조합하여 개발자가 네트워크의 존재를 거의 느끼지 못하게 만드는 것이다. 즉, 서버와 클라이언트 사이의 간극을 메우는 것을 지향한다.
여기서 중요한 점은 RSC가 단순히 새로운 렌더링 기법이 아니라, 완전히 새로운 컴포넌트 타입을 도입했다는 것이다. SSR이 기존 컴포넌트에 적용되는 기술인 반면, RSC는 리액트의 컴포넌트 모델 자체를 '서버 컴포넌트'와 '클라이언트 컴포넌트'로 근본적으로 분리한다. 이 분리는 개발자에게 더 신중한 아키텍처 설계를 요구한다. 더 이상 모든 컴포넌트가 동일한 환경에서 동작한다고 가정할 수 없다. 각 컴포넌트의 목적(데이터 표시 vs 사용자 상호작용)을 명확히 파악하여 타입을 결정해야 하며, 이는 자연스럽게 서버 로직과 클라이언트 로직의 관심사 분리로 이어진다.
"use client"
RSC 패러다임에서 리액트 컴포넌트는 두 가지 유형으로 나뉜다. 서버 컴포넌트는 RSC 패러다임에서 기본 단위이다. 별도의 명시가 없는 한 모든 컴포넌트가 기본적으로 서버 컴포넌트로 간주된다.
"use client'
지시어를 추가하면 해당 모듈의 모든 컴포넌트는 클라이언트 컴포넌트로 간주된다. 즉 코드가 자바스크립트 번들에 포함되어 클라이언트로 전송되며 서버에서 초기 HTML 렌더링을 거친 후, 클라이언트에서 Hydration을 거친다.
이 글에서는 자세하게 다루지는 않지만 클라이언트 컴포넌트 파일 내에서 서버 컴포넌트를 직접 사용할 수 없다. 이는 서버 컴포넌트의 코드가 클라이언트 번들에 포함될 수 없기 때문이다. 하지만, 부모 컴포넌트로부터 props
를 통해 이미 렌더링된 서버 컴포넌트를 전달받아 렌더링하는 것은 가능하다.
"use client"
지시어는 단순히 특정 컴포넌트를 클라이언트용으로 표시하는 것 이상의 의미를 갖는다. 이는 서버와 클라이언트 모듈 그래프 사이에 명확한 네트워크 경계를 설정하는 역할을 한다.
"use client"
가 선언된 모듈은 클라이언트 경계의 시작점이 된다. 중요한 점은, 이 경계가 전이적인 특성을 가진다는 점이다. 즉, "use client"
모듈이 임포트하는 다른 모듈(하위 컴포넌트 포함)은 별도의 지시어가 없더라도 자동으로 클라이언트 번들의 일부가 된다.
이러한 동작 방식은 마치 색칠을 하는 것처럼, 컴포넌트 의존성 트리의 특정 지점에 "use client"
를 도입하면, 그 영향력이 트리를 따라 아래로 전파되어 모든 자손 모듈을 클라이언트 컴포넌트로 변환시킨다. 만약 이 특성을 고려하지 않고 트리의 상위 레벨에 있는 컴포넌트에 "use client"
를 적용하면 그 아래의 수많은 서버 컴포넌트의 후보들이 의도치 않게 클라이언트 컴포넌트로 전환되어 RSC의 핵심 이점인 번들 크기 감소 효과가 무력화될 수 있다.
이 때문에 RSC 아키텍처에서는 컴포넌트 구조 설계가 매우 중요해지게 된다. 개별 컴포넌트뿐만 아니라 전체 의존성 그래프를 고려해야 한다. 상호작용이 필요한 클라이언트 컴포넌트는 가능한 한 컴포넌트 트리의 가장 아래쪽, 리프에 위치시켜 클라이언트 측 모듈 그래프의 크기를 최소화해야 한다.
"use client"
는 렌더 트리가 아닌 모듈 의존성 트리에서 서버와 클라이언트 코드 간의 경계를 정의한다는 점을 기억해야한다.
데이터 페칭의 재해석
서버 컴포넌트는 데이터 페칭 방식을 근본적으로 변화시킨다.
페이지 이동 시 서버와의 적은 통신으로 데이터를 가져오는 것이 가장 이상적이다. 전통적인 HTML 기반 웹사이트는 가능했지만, 웹 기술이 발전하면서 많은 로직이 서버에서 클라이언트로 넘어갔다. 각 컴포넌트가 화면에 그려지기 위해서 필요한 데이터를 개별적으로 서버에 요청하기 시작했고, 이러한 방식은 클라이언트-서버 워터폴 현상을 유발했다. 하나의 요청이 끝나야 다음 요청을 보낼 수 있게 되어, 여러 번의 순차적인 왕복이 발생하고 결국 페이지 로딩 시간이 길어지는 비효율이 발생한 것이다.
이러한 비효율을 해결하기 위해 여러 가지 방법들이 시도되었다.
하위 컴포넌트에서 개별적으로 데이터를 요청하는 대신, 페이지의 최상위 컴포넌트에서 모든 데이터를 한 번에 요청하는 방식이 있었다. 하지만 이 방법은 하위 컴포넌트가 어떤 데이터를 필요로 하는지 상위 컴포넌트가 모두 알아야 하므로 유지보수가 어려워지는 단점이 있다.
필요한 모든 데이터를 하나의 구조화된 쿼리로 묶어 서버에 요청하는 방식이다. 이는 여러 API를 호출할 필요 없이 단 한 번의 요청으로 모든 데이터를 가져올 수 있게 해주어 매우 효율적이지만, 별도의 GraphQL 서버를 구축해야 하는 부담이 있다.
라우터 단에서 데이터 로딩을 처리하는 방식이다. Remix, Next.js에서 로더 함수를 통해서 페이지가 렌더링되기 전에 라우터가 해당 페이지에 필요한 모든 데이터를 로더를 통해 미리 가져온다. 클라이언트 또는 서버 양쪽에서 실행될 수 있다. 그렇지만 서버 로더의 장점이 더 많은데, 서버는 데이터베이스와 더 가까이 있기 때문에 데이터 요청-응답 속도가 빠르고 클라이언트의 느린 네트워크 환경에 영향을 받지 않는다. 또한 데이터베이스 접근 권한이나 API 키 같은 민감한 정보를 클라이언트에 노출하지 않을 수 있고 여러 데이터 요청을 서버 단에서 병렬로 처리한 후, 합쳐진 결과를 클라이언트에 한 번에 보내주므로 클라이언트-서버 워터폴 문제를 근본적으로 해결한다.
서버 컴포넌트는 서버 로더를 컴포넌트 단위로 분해하여 조합할 수 있게 만든 것이다. 각 컴포넌트는 자신이 필요한 데이터를 서버에서 직접 가져오는 로직을 가질 수 있다. 이는 데이터를 사용하는 컴포넌트와 데이터 페칭 로직을 같은 위치에 둘 수 있게 하여 코드의 가독성과 유지보수성을 높인다. 더 이상 useEffect
와 로딩 상태, 에러 상태를 수동으로 관리하는 복잡한 패턴이 필요 없어진다.
이 모든 과정이 서버에서 일어나기 때문에, 클라이언트-서버 간의 데이터 워터폴 현상을 원천적으로 방지하고, 데이터베이스나 파일 시스템과 같은 데이터 계층에 낮은 지연 시간으로 직접 접근할 수 있다.
import db from '@/lib/db';
import AuthorDetails from '@/components/AuthorDetails';
async function NotePage({ params }: { params: { id: string } }) {
const note = await db.notes.findUnique({ where: { id: params.id } });
if (!note) {
return <div>Note not found.</div>;
}
return (
<article>
<h1>{note.title}</h1>
<AuthorDetails authorId={note.authorId} />
<section>{note.content}</section>
</article>
);
}
export default NotePage;
웹 성능 최적화에서 핵심은 클라이언트와 서버의 통신을 통해서 생기는 네트워크 왕복 횟수를 줄이는 것이다. 이를 위해 데이터 로딩의 책임을 클라이언트에서 서버로 옮기는 것이 중요하다. 전통적인 HTML 방식의 단순함과 효율성을 현대적인 컴포넌트 기반 아키텍처에 접목하려는 시도를 통해서 서버 컴포넌트가 탄생한 것이다.
결론적으로 UI 로직과 데이터 요구사항을 한곳에 모으고 데이터 로딩의 효율성을 높이고자 한 것이다.
마지막으로
RSC는 단순히 새로운 렌더링 기술이 아니라, 기존 클라이언트 중심 아키텍처가 가진 데이터 워터폴과 번들 크기 문제를 근본적으로 해결하려는 패러다임의 전환이다.
웹 애플리케이션의 성능과 사용자 경험을 끌어올리려고 하는 노력이며, 서버-클라이언트 이중성을 이해하고 각 컴포넌트의 책임과 경계를 설계하는 능력이 더욱 더 중요해질 것 같다.