프론트엔드 시스템 디자인 - 8. 렌더링 전략 심화 (CSR, SSR, SSG, RSC)
인프런 강의인 미국 빅테크 프론트엔드 시스템 디자인 실전: 단순 구현자로 남지 않기 위한 프론트엔드 개발자를 위해 를 참고하여 공부하며 포스트로 정리하고 있습니다. 처음부터 보시려면 프론트엔드 시스템 디자인 - 1. RADIO 접근법 부터..!
렌더링 전략은 무엇이 더 빠른가의 문제가 아니라, 어떤 비용을 서버, 빌드, 브라우저, CDN, 사용자 시간 중 어디에 둘 것인가의 문제다.
1. 렌더링 전략이 정하는 네 가지
CSR, SSR, SSG, RSC를 고른다는 건 아래 네 가지를 정하는 일이다.
- 언제 데이터를 가져오는가
- 어디서 UI를 만드는가
- 브라우저에 자바스크립트를 얼마나 보내는가
- 언제부터 사용자가 조작할 수 있는가
이 글에서는 이 네 가지를 하나의 축으로 묶어서 본다.
사용자가 화면을 보는 시점과, 화면을 조작할 수 있는 시점 -> 모든 전략은 이 두 시점을 앞당기거나 미루는 대신 다른 곳에 비용을 넘긴다.
2. 보이는 시점과 조작 가능한 시점은 다르다
브라우저가 URL을 받아 화면을 그리기까지의 흐름이다.
Request → HTML response
→ Parse HTML → DOM
→ Load CSS → CSSOM
→ Load / Execute JavaScript
→ Render Tree → Layout → Paint → Composite
→ 사용자가 화면을 본다 ← 보이는 시점
→ Hydration / 이벤트 핸들러 연결
→ 사용자가 조작할 수 있다 ← 조작 가능한 시점
두 시점 사이에는 JS 다운로드, 실행, Hydration이 끼어 있다. 이 간격이 길면 사용자는 버튼이 보이는데 눌리지 않는 화면을 만난다.
+) 지표와 연결하면
- 보이는 시점은 LCP로 측정된다
- 조작 가능한 시점의 지연은 INP와 롱태스크로 드러난다. Hydration은 메인 스레드에서 도는 큰 작업이라 이 구간에 사용자가 클릭하면 그 입력은 Hydration이 끝날 때까지 밀린다
그래서 “SSR로 바꿨더니 LCP는 좋아졌는데 INP는 그대로거나 나빠졌다”는 결과가 충분히 나올 수 있다. 전략이 어느 시점을 움직이는지 알면 이 결과가 이상하지 않다.
3. 전략별 비용 비교
| 전략 | 앞당기는 것 | 대신 생기는 비용 |
| CSR | 서버 부담 | 클라이언트 JS 실행, 빈 초기 HTML |
| SSR | 보이는 시점 | 요청마다 서버 렌더링, Hydration |
| SSG | 서버 응답 속도 | 빌드와 재검증 전략, 데이터 신선도 |
| RSC | 조작 가능한 시점 (JS 감소) | 서버와 클라이언트 경계 설계, RSC payload와 스트리밍 이해 |
아래에서 하나씩 이 표를 근거로 풀어본다.
4. CSR
브라우저가 페이지 요청
→ 서버가 minimal HTML + JS bundle 반환
→ JS 다운로드 → 실행 → React가 UI 렌더링
→ 데이터 fetch → UI 업데이트
CSR에서는 보이는 시점과 조작 가능한 시점이 거의 붙어 있다. JS가 실행되어야 화면이 생기고, 생기는 순간 이미 이벤트도 붙어 있기 때문이다. 대신 둘 다 늦다.
장점: 서버 렌더링 부담이 작고, JS와 정적 에셋을 CDN에 캐시하기 쉽다. 로그인 후 대시보드처럼 클라이언트 상태가 많고 첫 진입 이후 화면 전환이 잦은 앱에 맞는다.
단점: 초기 HTML이 비어 있어 SEO에 불리하고, 저사양 기기에서는 JS parse와 execute 비용이 그대로 대기 시간이 된다.
+) API waterfall이 CSR에서 특히 생기기 쉬운 이유
데이터 요청은 그 요청을 아는 코드가 실행된 뒤에야 시작되는 것이 당연한데, CSR에서는 이 순서가 강제된다.
HTML → JS 다운로드 → 실행 → 렌더 → useEffect에서 fetch
→ 응답 후 자식 렌더 → 자식의 useEffect에서 또 fetch
부모가 데이터를 받아야 자식이 마운트되고, 자식이 마운트되어야 자식의 요청이 나간다. 서버 렌더링 전략들이 유리한 지점 중 하나가 이 순서를 끊고 데이터 요청을 HTML 응답 이전으로 당긴다는 것이다.
CSR과 SPA
많은 SPA가 CSR로 동작해서 같은 말처럼 쓰이지만 축이 다르다. SPA는 앱 구조(전체 리로드 없이 브라우저에서 라우팅), CSR은 렌더링 위치다. SSR로 첫 페이지를 그리고 이후엔 클라이언트 라우팅하는 Next.js 앱도 SPA처럼 동작한다.
5. SSR
브라우저 요청 → 서버가 데이터 fetch → 서버가 HTML 렌더링
→ 브라우저가 HTML 수신 → 사용자가 본다
→ JS 다운로드 → hydrate → 사용자가 조작할 수 있다
SSR은 보이는 시점을 앞당긴다. 초기 HTML에 콘텐츠가 있으니 SEO에 유리하고, 데이터 fetch를 서버 근처에서 할 수 있고, 요청 시점에 개인화된 HTML도 만들 수 있다. 상품 상세, 뉴스, 랜딩 페이지에 맞는다.
하지만 조작 가능한 시점은 자동으로 당겨지지 않는다. 두 시점 사이 간격이 오히려 벌어지는 전략이다.
Hydration 비용이 큰 이유
서버가 HTML을 이미 줬는데 왜 클라이언트에서 또 비용이 들까. Hydration은 이벤트만 붙이는 작업이 아니라 클라이언트 컴포넌트를 브라우저에서 다시 전부 실행하는 작업이기 때문이다.
JS 다운로드 → 파싱 → 실행
→ 컴포넌트 함수를 다시 호출해 React 트리 생성
→ 기존 DOM과 매칭 → 이벤트 핸들러 연결
서버 HTML 덕분에 DOM을 새로 만드는 일만 생략될 뿐, 렌더링 계산은 한 번 더 한다. 그래서 Hydration 비용은 "클라이언트로 보낸 컴포넌트의 양"에 비례한다. 뒤에서 RSC가 이 지점을 겨냥한다.
+) Hydration이 어긋나면
서버가 그린 결과와 클라이언트가 다시 계산한 결과가 다르면 hydration mismatch가 난다. Date.now(), Math.random(), window 분기, 로케일에 따른 날짜 포맷처럼 서버와 브라우저에서 값이 달라지는 코드가 흔한 원인이다. React는 이 경우 해당 영역을 클라이언트에서 다시 그리기 때문에, SSR로 얻은 이득을 그 영역만큼 잃는다.
+) 서버 쪽 비용: 가장 느린 API가 응답 시간을 정한다
스트리밍 없이 SSR하면 서버는 페이지에 필요한 데이터를 전부 받은 뒤에야 HTML을 보낸다. 상품 정보는 50ms인데 추천 API가 800ms면 사용자는 800ms 동안 아무것도 못 본다. 이걸 푸는 게 Suspense 기반 스트리밍 SSR이다. 준비된 부분부터 HTML을 흘려보내고, 느린 부분은 fallback을 먼저 보낸 뒤 나중에 채운다. React 18부터는 Hydration도 Suspense 경계 단위로 나뉘어서, 사용자가 클릭한 영역을 먼저 hydrate한다.
+) 하이드레이트 안 된 버튼을 클릭하면
전제: React는 버튼에 리스너를 붙이지 않는다
React 17부터 이벤트 리스너는 각 DOM 요소가 아니라 루트 컨테이너 하나에 붙는다. onClick을 100개 써도 실제 네이티브 리스너는 루트에 click 하나다. 클릭이 일어나면 이벤트가 버블링(정확히는 캡처 단계부터)으로 루트까지 올라오고, React가 event.target을 보고 어떤 컴포넌트의 onClick을 부를지 찾는다.
그리고 hydrateRoot는 하이드레이션을 시작하기 전에 루트에 모든 이벤트 리스너부터 붙인다. 그래서 아직 hydrate 안 된 영역을 클릭해도 그 이벤트는 루트의 React 리스너에 도착한다. 이게 트리거의 출발점이다.
클릭이 루트에 도착했을 때
1. 사용자가 아직 hydrate 안 된 추천 섹션 안의 버튼을 클릭
2. 루트 리스너가 이벤트를 받음
3. React가 target을 따라 올라가다 hydrate 안 된 상태의 Suspense 경계를 발견
→ 이 이벤트는 "막혀 있다(blocked)"고 판단
4. click 같은 discrete 이벤트면 그 경계의 하이드레이션 우선순위를 최상위로 올림
- 코드와 데이터가 이미 있으면: 그 자리에서 동기적으로 그 경계를 hydrate한 뒤
이벤트를 정상 디스패치 → onClick이 실행된다
- 아직 없으면: 우선순위만 올라가고, 준비되는 대로 그 경계가 다른 경계보다 먼저 hydrate된다
"사용자가 클릭한 영역을 먼저 hydrate한다"는 게 4번이다. 원래는 문서 순서대로 위에서부터 hydrate하는데, 사용자가 건드린 경계가 새치기를 한다. 사용자가 관심을 보인 곳이 가장 먼저 살아나야 한다는 판단이다.
마우스오버 같은 연속 이벤트는 대상 경계의 우선순위를 올리는 신호로도 쓰인다. 사용자가 호버하는 곳을 먼저 준비해두는 식이다.
그 클릭 자체는 실행되나
초기 설계 논의에서는 "클릭을 기록해뒀다가 hydrate 후 재실행(replay)한다"고 소개됐는데, 실제 구현은 버전과 상황에 따라 다르다. 그 자리에서 동기 hydrate가 가능하면 그 클릭은 바로 처리된다. 하지만 코드나 데이터가 아직 없어서 동기 hydrate가 불가능한 상황이면 우선순위만 올라가고 그 클릭은 다시 실행되지 않을 수 있다. 사용자 입장에선 한 번 더 눌러야 하는 거다. 이 부분은 쓰는 React 버전에서 직접 확인해보는 게 정확하다.
- React JS 자체가 아직 로드 안 됐을 때. 루트 리스너도 아직 없다. 서버 HTML만 있는 상태라 onClick은 아무 의미가 없다. 이때 동작하는 건 브라우저 기본 동작뿐이다. <a href>는 이동하고, <form action>은 네이티브로 제출된다. React 19의 form action이나 Next.js Server Actions가 <form action={...}> 형태를 밀어주는 이유 중 하나가 이거다. JS가 오기 전에도 제출은 된다.
- Suspense 경계가 없어서 페이지 전체가 한 단위일 때. 클릭한 곳만 골라서 먼저 hydrate할 단위가 없다. => 안됨
- 메인 스레드가 다른 긴 작업으로 막혀 있을 때. 루트 리스너가 있어도 이벤트 처리 자체가 그 작업이 끝날 때까지 밀린다. INP가 나빠지는 지점이다.
"하이드레이션 안 끝난 버튼은 안 눌린다"는 기본값으로는 맞고, Suspense 경계와 이벤트 위임이 갖춰지면 React가 그 버튼을 우선 살리는 쪽으로 대응한다고 이해했다.
6. SSG
Static Site Generation, 빌드 시점에 HTML을 미리 만들어둔다. 요청마다 서버가 렌더링할 필요가 없으니 서버 응답 비용이 거의 없고, CDN 캐싱에 가장 유리하다. 블로그, 문서, 마케팅 페이지처럼 변경 빈도가 낮은 페이지에 맞는다.
단점은 빌드한 지 6시간 지난 페이지는 6시간 전의 데이터이기 때문에, 가격, 재고, 개인화 데이터는 SSG만으로 감당할 수 없다.
이를 보완할 방법은 조합을 하는 것인데, 정적 HTML은 그대로 두고, 자주 바뀌는 부분만 클라이언트에서 다시 가져오는 방식을 채택할 수 있다.
+) 재검증 전략
매번 전체를 다시 빌드하지 않고 페이지 단위로 다시 만드는 방식이 있다.
- 시간 기반: 일정 시간이 지나면 다음 요청 때 백그라운드에서 다시 생성한다. 그 요청은 기존 페이지를 받고, 다음 요청부터 새 페이지를 받는다. 4편의 stale-while-revalidate와 같은 구조다
- 이벤트 기반: CMS에서 글이 수정되면 해당 경로만 무효화한다
SSG의 데이터 신선도 관리는 staleTime 결정과 같은 결이다. 이 데이터가 얼마나 자주 바뀌고, 틀렸을 때 얼마나 위험한가를 고려하여 결정한다.
7. RSC
React Server Component, SSR과 가장 많이 헷갈리는 부분이다. 출발점을 다르게 잡으면 구분이 쉽다.
- SSR은 "첫 응답에 HTML을 담아 보낼 것인가"의 문제다
- RSC는 "이 컴포넌트의 코드를 브라우저로 보낼 것인가"의 문제다
서버 컴포넌트는 서버에서만 실행되고 코드가 클라이언트 번들에 들어가지 않는다. 브라우저는 실행 결과만 RSC payload로 받는다. 그래서 Hydration할 것도 없다. Hydration 비용이 클라이언트로 보낸 컴포넌트의 양에 비례한다고 했는데, RSC는 그 양 자체를 줄인다.
| Server Component | Client Component | |
| 실행 위치 | 서버 | 서버(SSR 시) + 브라우저 |
| JS 번들 | 포함 안 됨 | 포함됨 |
| Hydration | 없음 | 필요 |
| 데이터 fetch | DB나 API 가까이서 직접 | 브라우저에서 |
| useState, useEffect, onClick, 브라우저 API | 불가 | 가능 |
| 맞는 것 | 상품 설명, 리뷰 초기 목록, 문서 콘텐츠, 권한 기반 표시 | 모달, 드롭다운, 폼 입력, 드래그 앤 드롭 |
SSR과 RSC는 대체 관계가 아니라 함께 쓴다. 서버 컴포넌트로 코드량을 줄이고, 남은 클라이언트 컴포넌트는 SSR로 첫 HTML을 그린 뒤 hydrate한다.
+) 클라이언트 컴포넌트도 서버에서 렌더링된다
표의 "실행 위치"가 헷갈리기 쉬운 부분이다. ‘use client’는 "브라우저에서만 실행"이라는 뜻이 아니라 "이 컴포넌트의 코드를 브라우저로 보낸다"는 뜻이다. 첫 요청에서는 클라이언트 컴포넌트도 서버에서 HTML로 그려지고, 브라우저에서 다시 hydrate된다.
+) 경계를 설계한다는 것
강의의 "서버 클라이언트 경계 설계 필요"를 구체적으로 보면 세 가지다.
- ‘use client’는 파일이 아니라 경계를 선언한다. 그 파일이 import하는 모듈도 전부 클라이언트 번들로 들어간다. 페이지 최상단에 ‘use client’를 붙이면 RSC를 쓰는 의미가 사라진다. 인터랙션이 필요한 말단 컴포넌트에만 붙이는 게 기본이다
- 경계를 넘는 props는 직렬화 가능해야 한다. 서버에서 클라이언트로 넘기는 값은 payload로 전송되니까 함수나 클래스 인스턴스는 넘길 수 없다
- 클라이언트 컴포넌트 안에 서버 컴포넌트를 넣으려면 children으로 넘긴다. 클라이언트 컴포넌트가 서버 컴포넌트를 직접 import하면 그것도 클라이언트로 끌려온다. 서버 쪽 부모가 둘을 조합해서 children으로 내려주면 서버 컴포넌트로 남는다
+) RSC payload는 첫 요청 이후에 더 중요하다
첫 요청에서는 HTML이 오지만, 클라이언트 라우팅으로 페이지를 이동할 때는 HTML이 아니라 RSC payload만 받아서 트리를 갱신한다. 그래서 RSC는 첫 로딩 전략이 아니라 앱 전체의 데이터 전달 방식에 가깝다.
8. CRP: 전략을 골라도 남는 병목
Critical rendering path. 렌더링 전략은 HTML과 UI를 언제 어디서 만들지를 정하고, CRP는 브라우저가 받은 리소스를 어떻게 픽셀로 바꾸는지를 설명한다. 전략을 바꿔도 CRP에서 막히면 효과가 사라진다.
- CSS는 렌더 블로킹이다. CSSOM이 없으면 어떤 스타일로 그릴지 모르니까 Render Tree를 못 만든다
- defer나 async 없는 script는 파서 블로킹이다. JS가 DOM을 바꿀 수 있으니 파서가 멈추고 기다린다
그래서 이런 일이 생긴다.
- SSR로 HTML을 빨리 받아도 CSS가 막히면 페인트가 늦다
- SSG로 HTML이 CDN에서 빨리 와도 JS가 크면 Hydration이 늦다
- CSR에서는 JS가 늦으면 DOM 생성 자체가 늦다
- RSC로 클라이언트 JS를 줄이면 Hydration은 줄지만 서버 응답과 스트리밍, 캐시 설계가 새 병목이 된다
성능이 기대만큼 안 나올 때 "전략이 틀렸나"보다 "어느 단계에서 막혔나"를 먼저 보는 게 맞다.
9. 페이지가 아니라 섹션 단위로 고른다
Page = Σ Section_i
한 페이지 안에서도 섹션마다 SEO, 캐시 가능성, 실패 비용, 데이터 최신성이 다르다. 그래서 전략은 페이지가 아니라 섹션 단위로 고른다.
+) 상품 상세 페이지에 직접 적용해보기
강의에서는 섹션별 성질까지 나왔는데 앞의 내용을 근거로 전략+근거를 붙여본 표이다.
| 섹션 | 성질 | 전략 | 근거 |
| Title, Image, 설명 | SEO 중요, 잘 안 바뀜 | 서버 컴포넌트 + 정적 생성과 재검증 | 초기 HTML에 있어야 하고 JS가 필요 없다 |
| Price, Discount | 자주 바뀜, 지역과 프로모션별로 다름 | 요청 시 서버 렌더링 또는 짧은 staleTime으로 클라이언트 fetch | 정적 스냅샷으로 두면 틀린 가격이 노출된다 |
| Stock | 최신성이 가장 중요 | 클라이언트 fetch, 아주 짧은 staleTime | 틀렸을 때 비용이 가장 크다 |
| Add to Cart | 강한 인터랙션 | 클라이언트 컴포넌트 (말단에만 ‘use client’) | 이벤트 핸들러가 필요하고, 경계를 좁혀야 나머지가 서버에 남는다 |
| Reviews | SEO, 컨텐츠 가치 O, 페이지네이션 | 첫 페이지는 서버 렌더링, 이후 페이지는 클라이언트 fetch | 검색엔진에는 첫 페이지면 충분하다 |
| Recommendation | 개인화, 늦어도 됨 | Suspense로 스트리밍하거나 지연 로드 | 느린 API가 페이지 전체 응답을 붙잡지 않게 한다 |
| Recently Viewed | 사용자별 클라이언트 상태 | 클라이언트 전용 | 원본이 브라우저(로컬 저장소)다. 서버에서 그리면 mismatch가 난다 |
정리
- 렌더링 전략은 보이는 시점과 조작 가능한 시점을 움직이고, 그 대가로 서버, 빌드, 브라우저, CDN, 사용자 시간 중 어딘가에 비용을 둔다.
- CSR은 두 시점이 붙어 있지만 둘 다 늦고, 데이터 요청이 JS 실행 뒤로 밀려 waterfall이 생기기 쉽다.
- SSR은 보이는 시점을 당기지만 Hydration 때문에 두 시점 사이 간격이 벌어진다. Hydration은 클라이언트 컴포넌트를 다시 실행하는 일이라 보낸 컴포넌트 양에 비례한다.
- SSG는 요청 시점의 렌더링 비용을 없애는 대신 데이터가 낡는 문제를 떠안는다. 얼마나 자주 다시 만들지는 staleTime을 정할 때와 같은 기준으로 판단한다.
- SSR은 HTML을 보낼지, RSC는 코드를 보낼지의 문제다. RSC는 Hydration 대상 자체를 줄이고, 그 대가로 경계 설계가 필요하다.
- 렌더링 전략을 잘 골라도 브라우저가 CSS나 JS에서 막히면 화면은 늦게 뜬다. 느릴 때는 CRP의 어느 단계가 병목인지 먼저 본다.
- 렌더링 전략은 페이지 단위가 아니라 섹션 단위로 정한다. 상태를 어디에 둘지 정할 때처럼, 데이터의 원본이 어디인지, 얼마나 자주 바뀌는지, 틀렸을 때 얼마나 위험한지가 기준이 된다.