React Server Components(RSC)에 대해 들어보셨나요? 매일 React로 작업하지 않더라도 아마 들어보셨을 것입니다. 지난 몇 년간 React 커뮤니티에서 가장 뜨거운 주제였으니까요.
Server Components는 가장 화려하고 새로운 도구일 뿐만 아니라, 성능 맥락에서도 매우 자주 언급됩니다. 성능에 정말 좋다고 여겨지기 때문이죠.
이들의 주요 약속은 비교적 간단합니다. 더 많은 작업을 서버로 밀어 넣고, JavaScript를 더 적게 전송하며, 데이터를 더 일찍 가져오고, 페이지의 초기 로딩을 더 빠르게 만드는 것입니다. 하지만 이것이 정확히 어떻게 일어날까요? 그리고 어느 정도의 성능 향상을 기대할 수 있을까요? 이것이 오늘 제가 조사하고자 하는 내용입니다.
참고로, 이 기사는 40분 분량의 전체 조사 내용을 React를 사용하지 않는 독자들을 위해 훨씬 짧게 요약한 개요입니다. 모든 구현 세부 사항을 정말로 알고 싶다면, 원본 글을 확인해 보세요.
조사 설정하기
하지만 여기서 함정이 있습니다. 제가 측정하고 싶은 것을 위해 Server Components만 단독으로 사용할 수는 없다는 점입니다. 이것은 제가 켜고 끌 수 있는 단순한 React 기능이 아닙니다.
이것은 현대적인 번들러(Bundler)와 프레임워크에 깊이 통합된 매우 복잡한 기술이며, 집에서 재현하기가 거의 불가능합니다. 적어도 합리적인 수준의 노력으로는 말이죠.
또한, 이해하기 가장 쉬운 기능도 아닙니다. 특히 성능 맥락에서 이를 실제로 이해하려면, React가 평소에 어떻게 렌더링하고 데이터를 가져오는지에 대한 매우 명확한 멘탈 모델(mental model)을 갖추는 것이 거의 필수적입니다.
참고로 클라이언트와 서버 양쪽 모두에서 말이죠! 왜냐하면 재미있는 부분이 여기 있습니다. 우리는 이미 서버 렌더링이라는 개념을 가지고 있습니다! 그리고 그것을 수년 동안 사용해 왔죠. 그렇다면 정확히 무엇이 다른 걸까요?
이 모든 것을 조사하기 위해, 저는 여러 클라이언트 사이드 경로와 클라이언트 사이드 데이터 페칭(Data fetching) 기능을 갖춘 싱글 페이지 앱(SPA)을 구축했습니다. 모든 실험을 직접 재현해보고 싶은 분들을 위해 GitHub에도 공개해 두었습니다.
해당 웹사이트의 페이지 중 하나는 다음과 같습니다:
사이드바 내비게이션이 왼쪽에 있고 메시지 목록이 오른쪽에 있는 데모 앱 인박스 페이지
이 페이지의 일부 데이터는 동적이며 REST 엔드포인트를 통해 가져옵니다. 구체적으로, 왼쪽 사이드바의 항목은 /api/sidebar 엔드포인트를 통해 가져오고, 오른쪽의 메시지 목록은 /api/messages 엔드포인트를 통해 가져옵니다.
/api/sidebar 엔드포인트는 실행에 100ms가 걸리는 꽤 빠른 속도를 보여줍니다. 그러나 /api/messages 엔드포인트는 1초가 걸립니다. 누군가 백엔드 최적화를 잊은 모양이네요. 이런 수치들은 오래되고 규모가 큰 프로젝트에서는 어느 정도 현실적인 숫자라고 할 수 있습니다.
head 태그 안에 script와 link 요소가 있고, body 안에 비어 있는 div가 있을 것입니다. 그게 전부입니다. 브라우저에서 JavaScript를 비활성화하면, 비어 있는 div에서 예상할 수 있듯이 빈 페이지를 보게 됩니다.
이 빈 div를 아름다운 페이지로 변환하려면 브라우저가 JavaScript 파일을 다운로드하고 실행해야 합니다. 이 파일에는 React 개발자로서 작성하는 모든 내용이 포함됩니다:
// 아름다운 앱의 진입점입니다
export default function App() {
return (
<SomeLayout>
<Sidebar />
<MainContent />
</SomeLayout>
);
}
여기에 다음과 같은 코드가 추가됩니다:
// 단순화를 위해 만든 가상의 API입니다
const DOMElements = renderToDOM(<App />);
const root = document.getElementById("root");
root.appendChild(DOMElements);
React 자체가 진입점인 App 컴포넌트를 DOM 노드로 변환합니다. 그런 다음 id를 통해 빈 div를 찾습니다. 그리고 생성된 요소들을 그 빈 div에 주입합니다.
갑자기 전체 인터페이스가 보이게 됩니다.
초기 로드에 대한 성능(Performance)을 기록하면 다음과 같은 모습이 됩니다:
클라이언트 사이드 렌더링을 보여주는 타임라인 다이어그램
JavaScript가 다운로드되는 동안 사용자는 여전히 빈 화면을 바라보고 있습니다. 모든 것이 다운로드되고 브라우저에 의해 JavaScript가 컴파일 및 실행된 후에야 UI가 보이고, LCP 지표가 기록되며, fetch 요청과 같은 사이드 이펙트(Side effects)가 트리거됩니다.
JavaScript가 캐시되지 않은 상태에서 CPU 및 네트워크 스로틀링(6배 감속 및 Slow 4G)을 적용한 초기 로딩 수치는 다음과 같습니다:
LCP
사이드바
메시지
클라이언트 사이드 렌더링
4.1 s
4.7 s
5.1 s
서버 사이드 렌더링 (데이터 페칭 없음)
빈 페이지를 너무 오랫동안 바라봐야 한다는 사실은 어느 시점부터 사람들을 짜증 나게 하기 시작했습니다. 비록 그것이 처음 한 번뿐일지라도 말이죠. 게다가 SEO(검색 엔진 최적화) 목적으로도 최선의 해결책은 아니었습니다.
그래서 사람들은 해결책을 고민하기 시작했습니다. 포기하기에는 너무나 편리했던 React 세계에 머물면서 말이죠.
우리는 전체 React 앱이 결국 다음과 같은 모습이 된다는 것을 알고 있습니다:
// 단순화를 위해 만든 가상의 API입니다
const DOMElements = renderToDOM(<App />);
하지만 React가 DOM 노드 대신 앱의 HTML을 생성할 수 있다면 어떨까요?
const HTMLString = renderToString(<App />);
서버가 빈 div 대신 브라우저에 보낼 수 있는 실제 문자열처럼 말이죠.
// 그러면 HTMLString은 이 문자열을 포함하게 됩니다:
<div className="...">
<div className="...">...</div>
...
</div>
이론적으로, 클라이언트 사이드 렌더링을 위한 우리의 극도로 단순한 서버는:
// 네, 이것이 클라이언트 사이드 렌더링에 필요한 거의 전부입니다
export const serveStatic = async (c) => {
const html = fs.readFileSync("index.html").toString();
return c.body(html, 200);
};
계속해서 단순함을 유지할 수 있습니다. 단지 한 가지 추가 단계만 필요할 뿐입니다. html 변수에서 문자열을 찾아 바꾸는 것이죠.
// SSR이 적용된 동일한 서버
export const serveStatic = async (c) => {
const html = fs.readFileSync("index.html").toString();
React의 서버 사이드 렌더링(SSR) 및 정적 사이트 생성(SSG) 시대에 오신 것을 환영합니다. renderToString은 실제로 React에서 지원하는 실제 API이기 때문입니다. 이것은 말 그대로 일부 React SSG/SSR 프레임워크 뒤에 있는 핵심 구현체입니다.
제 클라이언트 사이드 렌더링 프로젝트에 정확히 이 작업을 수행하면, 그것은 서버 사이드 렌더링 프로젝트가 됩니다. 성능 프로필이 약간 바뀔 것입니다. 전체 HTML이 초기 서버 응답에 포함되어 전송되고 모든 것이 즉시 보이기 때문에, LCP 수치는 HTML과 CSS가 다운로드된 직후인 왼쪽으로 이동할 것입니다.
서버 사이드 렌더링을 보여주는 타임라인 다이어그램
여기서 몇 가지 중요한 점이 있습니다.
첫째, 보시다시피 LCP 수치(페이지 "스켈레톤"이 보일 때)는 획기적으로 개선되어야 합니다(잠시 후에 측정해 보겠습니다).
하지만, 우리는 여전히 동일한 방식으로 동일한 JavaScript를 다운로드, 컴파일 및 실행해야 합니다. 페이지가 상호작용 가능해야 하기 때문입니다. 즉, 우리가 구현한 모든 드롭다운, 필터, 정렬 알고리즘이 작동해야 합니다. 그리고 우리가 기다리는 동안 전체 페이지는 이미 보이고 있습니다!
페이지가 이미 보이지만 상호작용을 가능하게 할 JavaScript 다운로드를 여전히 기다리고 있는 그 간극은, 사용자에게 페이지가 고장 난 것처럼 보이는 시간입니다. 물론 특별한 조치를 취하지 않는 한 말이죠.
또한, 이 그림에서는 LCP 마크만 이동했습니다. "사이드바 항목"과 "메시지"는 구조적으로 정확히 같은 위치에 있습니다. 코드를 조금도 바꾸지 않았고 여전히 클라이언트에서 데이터를 가져오고 있기 때문입니다!
결과적으로, 서버에서 페이지를 미리 렌더링했다는 사실은 사이드바 항목이나 목록 데이터가 나타나는 시간에는 전혀 영향을 미치지 않습니다.
수치는 다음과 같습니다:
LCP (캐시 없음)
사이드바 (캐시 없음)
메시지 (캐시 없음)
상호작용 불가 간극
클라이언트 사이드 렌더링
4.1 s
4.7 s
5.1 s
서버 사이드 렌더링 (클라이언트 데이터 페칭)
1.61 s
4.7 s
5.1 s
2.39 s
보시다시피, 초기 로드 시 LCP 값은 실제로 4.1초에서 1.61초로 급격히 떨어졌습니다! 이론적인 도식과 정확히 일치합니다. 그러나 초기 로드 시 페이지는 2초 이상 상호작용이 불가능합니다.
그 "상호작용 불가 간극"은 서버를 운영하는 비용과 함께, 클라이언트 사이드 렌더링에서 서버 사이드 렌더링으로 전환할 때 LCP 개선을 위해 지불해야 하는 대가입니다. 이를 없앨 방법은 없습니다. 첫 실행 시 사용자가 다운로드해야 하는 JavaScript의 양을 줄임으로써 최소화할 수 있을 뿐입니다.
하지만 아무도 SSR을 수동으로 구현하지는 않을 것입니다. 대부분의 경우 사람들은 즉시 SSR 친화적인 프레임워크를 사용할 것입니다. 예를 들어 제 앱을 Next.js로 옮기면 수치는 다음과 같습니다:
LCP (캐시 없음)
사이드바 (캐시 없음)
메시지 (캐시 없음)
상호작용 불가 간극
클라이언트 사이드 렌더링
4.1 s
4.7 s
5.1 s
서버 사이드 렌더링 (클라이언트 데이터 페칭)
1.61 s
4.7 s
5.1 s
2.39 s
Next.js SSR (최신 버전)
1.28 s
4.4 s
4.9 s
2.52 s
Next.js는 코드 분할(Code splitting)과 리소스 우선순위 지정을 매우 다르게 처리하므로 LCP에서 더 많은 성능을 끌어낼 수 있었습니다. 나머지 수치는 비슷합니다.
서버 사이드 렌더링 (데이터 페칭 포함)
"상호작용 불가 간극"은 제쳐두고, 이전 실험에는 또 다른 다소 문제가 있는 영역이 있습니다. 사이드바와 메시지 표시 시점에 변화가 없었다는 사실입니다. 하지만 이미 서버 영역에 와 있는데, 여기서 데이터를 추출하면 안 될까요? 분명 더 빠를 것입니다. 적어도 지연 시간(Latency)과 대역폭(Bandwidth)은 훨씬 나을 가능성이 높습니다.
답은: 당연히 가능합니다! 다만 우리가 했던 단순한 사전 렌더링에 비해 구현 측면에서 훨씬 더 많은 작업이 필요할 것입니다. 먼저 서버입니다. 거기서 데이터를 가져와야 합니다:
// SSR 서버에 데이터 페칭 추가
export const serveStatic = async (c) => {
const html = fs.readFileSync("index.html").toString();
그 후 해당 데이터를 HTML 코드에 주입하고, 앱 측에서 그 데이터를 추출하여 앱을 초기화하는 등의 수많은 마법 같은 작업이 필요합니다. 정확한 세부 사항은 지금 중요하지 않습니다. 중요한 것은 다음과 같습니다:
서버에서 데이터를 가져오는 것을 막을 것은 아무것도 없습니다. 단지 몇 개의 프로미스(Promise)를 await 하기만 하면 됩니다.
이것은 아주 잘 작동합니다!
성능 구조가 다시 바뀔 것입니다:
서버 데이터 페칭이 포함된 SSR을 보여주는 타임라인 다이어그램
이제 이전에 동적이었던 항목을 포함한 전체 페이지가 CSS 다운로드가 완료되는 즉시 보이게 됩니다. 그런 다음 여전히 이전과 동일한 JavaScript를 기다려야 하며, 그 후에야 페이지가 상호작용 가능해집니다.
다시 말하지만, 대부분의 경우 사람들은 프레임워크를 사용할 것이므로 Next.js 수치를 바로 보여드리겠습니다:
LCP (캐시 없음)
사이드바 (캐시 없음)
메시지 (캐시 없음)
상호작용 불가 간극
클라이언트 사이드 렌더링
4.1 s
4.7 s
5.1 s
서버 사이드 렌더링 (클라이언트 데이터 페칭)
1.61 s
4.7 s
5.1 s
2.39 s
Next.js SSR (클라이언트 데이터 페칭)
1.28 s
4.4 s
4.9 s
2.52 s
Next.js SSR (서버 데이터 페칭)
1.78 s
1.78 s
1.78 s
2.52 s
불행히도 LCP 값은 저하되었습니다. 이는 놀라운 일이 아닙니다. React 부분의 사전 렌더링을 진행하기 전에 데이터 페칭 프로미스가 해결될 때까지 기다려야 하기 때문입니다.
그리고 무엇인가를 렌더링하기 시작하려면 그 데이터가 필요하기 때문에 정말로 기다려야만 합니다.
하지만 사이드바와 메시지 항목은 이제 훨씬 더 빨리 나타납니다. 4.9초 대신 1.78초입니다. 따라서 전체 페이지 뷰에 비해 LCP 수치가 그렇게 중요하지 않다면 이를 개선이라고 부를 수 있을 것입니다. 또는, 참고로 사이드바만 미리 가져오고(이 엔드포인트는 꽤 빠르므로 성능 저하가 최소화됩니다), 메시지 부분은 클라이언트에 유지할 수도 있습니다.
React Server Components 소개
자, 이전 섹션을 요약하자면 서버에서 데이터를 가져오고 사전 렌더링하는 것은 초기 로드 성능 수치에 정말 좋을 수 있습니다. 하지만 여전히 문제가 하나 있습니다.
바로 데이터 페칭입니다! 현재 메시지를 서버에서 미리 가져오고 싶다면 메시지가 나타날 때까지의 대기 시간은 줄어들지만, 초기 로드와 사이드바 항목이 나타나는 시간 모두에 부정적인 영향을 미칩니다.
이는 현재 서버 렌더링이 동기적인 프로세스이기 때문입니다. 모든 데이터를 먼저 기다린 다음, 그 데이터를 renderToString에 전달하고, 그 결과를 클라이언트에 보냅니다.
동기식 SSR 구조를 보여주는 다이어그램
하지만 우리 서버가 더 똑똑해질 수 있다면 어떨까요? 저 fetch 요청들은 프로미스, 즉 비동기 함수입니다. 기술적으로 우리는 다른 일을 시작하기 위해 그것들을 기다릴 필요가 없습니다. 만약 우리가 다음과 같이 할 수 있다면 어떨까요?
fetch 프로미스를 기다리지 않고 트리거합니다.
해당 데이터가 필요 없는 React 요소들을 렌더링하기 시작하고, 준비가 되면 즉시 클라이언트로 보냅니다.
사이드바 프로미스가 해결되어 데이터를 사용할 수 있게 되면, 사이드바 부분을 렌더링하여 서버 페이지에 주입하고 클라이언트로 보냅니다.
메시지에 대해서도 동일하게 수행합니다.
기본적으로 클라이언트 사이드 렌더링에서 가졌던 것과 정확히 동일한 데이터 페칭 구조를 서버에서 재현하는 것입니다.
React Server Components 스트리밍 구조를 보여주는 다이어그램
이론적으로 이것이 가능하다면 엄청나게 빠를 수 있습니다. 가장 단순한 SSR의 속도로 플레이스홀더(Placeholder)가 포함된 초기 렌더링 페이지를 제공할 수 있으면서도, JavaScript가 다운로드되고 실행되기 훨씬 전에 사이드바와 메시지 항목을 볼 수 있게 될 것입니다.
React가 이를 수행하려면 단순한 동기식 renderToString을 포기하고, 렌더링 프로세스를 청크(Chunk) 단위로 재작성하며, 그 청크들을 렌더링된 구조에 주입할 수 있게 만들고, 그 청크들을 독립적으로 클라이언트에 제공할 수 있어야 합니다.
그것은 꽤나 큰 작업입니다! 그리고 그것이 바로 스트리밍(Streaming)과 결합된 React Server Components가 하는 일입니다.
이것이 정확히 어떻게 구현되는지는 정신이 아득해질 정도로 복잡하며 React 사용자가 아닌 분들에게는 그다지 중요하지 않습니다. 설명한 대로 정확히 작동한다고 가정해 봅시다. 이 경우, 이 실험의 목적을 위해서는 수치만 확인하면 됩니다.
수치는 다음과 같습니다:
LCP (캐시 없음)
사이드바 (캐시 없음)
메시지 (캐시 없음)
상호작용 불가 간극
클라이언트 사이드 렌더링
4.1 s
4.7 s
5.1 s
서버 사이드 렌더링 (클라이언트 데이터 페칭)
1.61 s
4.7 s
5.1 s
2.39 s
Next.js SSR (클라이언트 데이터 페칭)
1.28 s
4.4 s
4.9 s
2.52 s
Next.js SSR (서버 데이터 페칭)
1.78 s
1.78 s
1.78 s
2.52 s
Next.js App router (Suspense를 사용한 서버 페칭)
1.28 s
1.28 s
1.28 s
2.52 s
수치가 인상적이라는 점은 인정해야겠네요. 어떤 이유에서인지 수치들이 하나로 합쳐졌는데, 어딘가에서 일종의 배칭(Batching)을 수행하여 그 세 가지가 같은 청크에 포함된 것으로 추측됩니다.
하지만 /api/sidebar 시간을 3초로, /api/messages 시간을 5초로 늘리면 점진적 렌더링(Progressive rendering)의 모습이 뚜렷하게 보입니다. 비록 사용자에게는 클라이언트 사이드 렌더링과 똑같이 보이겠지만, 단지 더 빠를 뿐입니다.
성능 프로필은 아주 흥미로워집니다:
React Server Components를 보여주는 Chrome DevTools 성능 프로필
네트워크 섹션에 있는 저 기이이이인 HTML 바가 보이시나요? 저것은 데이터를 기다리는 동안 서버가 연결을 열어두고 있는 모습입니다. 좀 더 "전통적인" SSR과 비교해 보세요:
전통적인 SSR을 보여주는 Chrome DevTools 성능 프로필
HTML은 완료되는 즉시 끝나며, 기다림이 없습니다.
조사 결과
자, 이 수치들은 꽤 인상적이지 않나요? Server Components는 상호작용 불가 간극을 제외한 모든 카테고리에서 명백한 승자입니다.
LCP (캐시 없음)
사이드바 (캐시 없음)
메시지 (캐시 없음)
상호작용 불가 간극
클라이언트 사이드 렌더링
4.1 s
4.7 s
5.1 s
서버 사이드 렌더링 (클라이언트 데이터 페칭)
1.61 s
4.7 s
5.1 s
2.39 s
Next.js SSR (클라이언트 데이터 페칭)
1.28 s
4.4 s
4.9 s
2.52 s
Next.js SSR (서버 데이터 페칭)
1.78 s
1.78 s
1.78 s
2.52 s
Next.js App router (Suspense를 사용한 서버 페칭)
1.28 s
1.28 s
1.28 s
2.52 s
그렇다면 주변의 모든 사람에게 Server Components로 옮기라고 압박해야 할까요?
솔직히 말해서 판단하기 어렵습니다. 네, Server Components가 올바르게 구현된다면 초기 로드를 개선할 수 있습니다.
하지만!
Server Components의 성능 이점은 오직 데이터 페칭이 관여될 때만 볼 수 있습니다. 상호작용이 많은 앱을 렌더링하기만 하면 되거나, 동적 데이터에 크게 신경 쓰지 않고 LCP가 주된 관심사라면 "전통적인" SSR과 동일한 성능 결과를 얻게 될 것입니다.
그리고 Server Components로 이동하는 데는 엄청난 비용이 따를 것입니다. 새로운 서버 우선 방식으로 데이터를 가져오기 위해 전체 앱의 아키텍처를 완전히 재설계해야 하기 때문입니다. 여러분이 알던 모든 것이 (다시 한번) 뒤집힐 것입니다. 게다가 이를 올바른 방식으로 구현해야 합니다. 한 번의 실수로 성능이 전혀 개선되지 않거나, 최악의 경우 더 나빠질 수도 있습니다.
그리고 거기서 실수를 저지르기는 정말, 정말, 정말 쉽습니다. 잊지 마세요, 이것은 여전히 매우 실험적이고 최첨단인 기술입니다. 아직 확립된 최선의 패턴이 없으며, IDE 지원도 최소한이고, 올인할 경우 특정 벤더에 완전히 종속(Vendor lock-in)됩니다. 적어도 React 관련 서클에서 인터넷을 뜨겁게 달궜던 최근의 보안 취약점은 말할 것도 없고요.