Tistory
리액트 서버 컴포넌트는 정말 성능을 개선할까요?
CSR/SSR/RSC 를 같은 앱에서 *직접 측정* 하면 — RSC 가 마법처럼 빠른 게 아니라 Streaming + Suspense 경계 가 있을 때만 의미가 있다는 사실이 드러난다.
핵심 요약
측정 메트릭은 (LCP, 사이드바 표시, 메시지 표시, 토글 상호작용, 상호작용 불가능 간극) 다섯 가지. CSR 은 모든 게 늦지만 보이는 즉시 인터랙티브. SSR(서버 패칭) 은 LCP 는 좋지만 JS 다운로드 동안 인터랙션 불가능 한 간극이 생긴다. 본 글의 핵심 결과: (1) Pages Router → App Router 그대로 옮기기 는 LCP 약 500ms 개선되지만 상호작용 가능 시간은 700ms 악화 — 자바스크립트 로드가 CSS 뒤로 밀리는 부수효과. (2) 서버 컴포넌트만 데이터 패칭 그대로 도입하면 번들이 거의 안 줄고 성능 변화 없음. (3) 데이터 패칭을 서버로 옮기되 Suspense 없이 하면 LCP 가 SSR 수준으로 악화. (4) 데이터 패칭을 서버 컴포넌트로 + Suspense 경계로 감싸기 — 이때만 사이드바/메시지가 동시에 빠르게 나타난다. RSC 의 진짜 이득은 Streaming + Suspense 와 한 묶음.
RSC 의 성능 이득을 '서버 컴포넌트 = 자동으로 빠름' 으로 보지 말고 '(1) 자바스크립트 번들에서 코드 빼기, (2) 데이터 패칭을 서버로 옮기기, (3) Suspense 로 청크 경계 그리기 — 이 셋을 동시에 했을 때만 의미 있는 이득이 생기는 도구' 로 본다. 상호작용 불가능 간극(uninteractive gap) 이라는 별도 메트릭으로 SSR 의 진짜 비용을 본다.
'App Router 로 옮기면 빨라진다' 는 흔한 오해를 본 글의 측정 결과는 정면으로 반박한다.
- 단순 lift-and-shift 는 LCP 만 약간 좋아지고 상호작용 가능 시간 은 오히려 악화
- 서버 컴포넌트로 일부 컴포넌트를 옮겨도 데이터 패칭 이 클라이언트에 남으면 번들이 거의 줄지 않음
- Suspense 경계 없이 비동기 서버 컴포넌트만 쓰면 모든 데이터를 await 한 뒤 HTML 을 보내 LCP 가 더 나빠짐
- 진짜 이득은 데이터 패칭을 서버 컴포넌트로 옮기고 + Suspense 로 점진 stream 했을 때
면접에서 'RSC 의 성능 이점' 을 묻는 질문에 'JS 번들이 줄어든다' 만 말하면 표면 답변이다. 언제 이득이 안 생기는지 까지 말할 수 있어야 한다.
학습 포인트
면접 답변으로 연결할 학습 포인트입니다.
*상호작용 불가능 간극* 은 SSR 의 진짜 비용
SSR 은 LCP 를 빠르게 만들지만, HTML 은 보이는데 JS 가 아직 로드 안 된 시간이 생긴다. 이 동안 버튼 클릭이 무반응이라 고장난 페이지처럼 보인다.
시간: HTML 파싱 → CSS → LCP → JS 다운로드 → JS 실행 → hydrate → 인터랙티브
↑ ↑ ↑
TTFB LCP 기록 토글 작동
←———— 상호작용 불가능 간극 ————→
이 간극을 줄이는 유일한 방법은 초기 로드에 필요한 JS 를 줄이는 것 — 코드 스플리팅, 서버 컴포넌트, 또는 더 작은 진입점.
LCP 만 보고 'SSR 도입했더니 빨라졌다' 고 끝내는 것. 사용자 체감은 클릭이 되는 시점 에 더 가깝다.
Suspense 경계가 없으면 streaming 도 없다
RSC 의 streaming 은 컴포넌트 단위 가 아니라 Suspense 경계 단위 로 청크가 나눠진다. Suspense 없이 비동기 컴포넌트만 쓰면 React 는 전체 트리 await 후 한 번에 응답한다.
// ❌ Suspense 없음 → 전체가 한 청크
export default async function Page() {
const fast = await fetchFast(); // 100ms
const slow = await fetchSlow(); // 1000ms
return <Layout><Sidebar data={fast}/><Messages data={slow}/></Layout>;
}
// ✅ Suspense 경계 → 빠른 부분 먼저 stream
export default function Page() {
return (
<Layout>
<Suspense fallback={<SidebarSkeleton/>}><Sidebar/></Suspense>
<Suspense fallback={<MessagesSkeleton/>}><Messages/></Suspense>
</Layout>
);
}
첫 청크가 즉시 나가고, 사이드바가 100ms 에 도착, 메시지는 1초 뒤 도착 — 체감 속도는 동일하게 빠름.
Page 컴포넌트에 loading.tsx 만 두고 모든 데이터 패칭이 자동으로 stream 된다 고 오해. 내부 컴포넌트마다 Suspense 가 따로 필요.
lift-and-shift 마이그레이션은 *느려질 수도* 있다
Pages Router → App Router 로 데이터 패칭은 그대로 두고 옮기기만 하면 본 글의 측정에서 결과는 다음과 같다.
| 메트릭 | Pages | App Router (lift-shift) | 변화 |
|---|---|---|---|
| LCP (캐시 X) | 1.76s | 1.28s | 🟢 -480ms |
| 사이드바 표시 | 3.7s | 4.4s | 🔴 +700ms |
| 메시지 표시 | 4.2s | 4.9s | 🔴 +700ms |
| 토글 상호작용 | 3.1s | 3.8s | 🔴 +700ms |
App Router 가 CSS 로드 후로 JS 를 지연시켜 LCP 는 좋아지지만, 그 대가로 모든 인터랙션이 늦어진다. 진짜 이득은 데이터 패칭 재작성 + Suspense 까지 했을 때만 나온다.
'프레임워크만 바꾸면 빨라질 것'. 측정 없는 마이그레이션은 체감 속도를 악화 시킬 수 있다.
읽는 순서
- 1이론
React 공식의
renderToPipeableStream과 Next.jsloading.tsx문서를 정독하고, Suspense 경계가 청크를 어떻게 만드는지 한 페이지로 정리. - 2구현
본 글의 GitHub 저장소 를 클론해 본문의 7개 시나리오를 직접 측정한다.
- 3실무
현재 프로젝트에서 가장 느린 데이터 패칭 을 가진 페이지에 Suspense 경계를 추가한 후 LCP/INP 변화를 RUM 으로 비교.
- 4설명
팀에 'App Router 마이그레이션이 자동으로 빠르지 않은 이유' 10분 발표. 축: lift-shift 측정, 서버 패칭 + Suspense, 인터랙션 메트릭.
면접 연결 질문
[감점 답변] '설정이 잘못됐다'. [좋은 답변] 대부분의 실제 앱은 상태/이벤트 핸들러 가 페이지 상단까지 거슬러 올라가 'use client' 가 트리 대부분을 덮는다. 이 경우 서버 컴포넌트의 코드는 클라이언트 번들에 그대로 포함 되므로 번들이 줄지 않는다. 데이터 패칭을 서버로 옮기는 것 + Suspense 경계 까지 함께 해야 진짜 이득이 시작된다.
[감점 답변] 'fallback 을 보여준다' 만. [좋은 답변] React 의 renderToPipeableStream 은 Suspense 경계마다 청크를 끊는다. 경계가 없으면 전체 트리를 await 한 뒤 하나의 청크로 보내므로 가장 느린 데이터를 끝까지 기다려야 HTML 이 시작된다. 즉 Suspense 가 없으면 기존 SSR 과 동일하게 동작.
[감점 답변] '다른 메트릭은 신경 안 써도 된다'. [좋은 답변] INP(Interaction to Next Paint) 와 상호작용 가능 시간 을 봐야 한다. LCP 는 시각적 도착, INP 는 클릭/입력 반응성. App Router lift-shift 처럼 LCP 가 좋아지면서 인터랙션이 느려지는 케이스가 흔하다.
자기 점검
'LCP 만 좋으면 끝'. 사용자는 클릭이 통하는 순간 에 가까운 무언가를 체감한다.
'Suspense 는 page 최상위에 하나만'. 데이터 의존성이 다른 컴포넌트마다 따로 둬야 빠른 부분을 먼저 보낼 수 있다.