SSR에서 CSR 왜 바꿨을까?

notion image
우아콘에서 Next.js 기반 SSR 웹뷰를 React + CSR로 전환하고, 이후 성능까지 개선해 나간 과정에 대한 발표를 듣고, 관련 내용을 직접 실험해 보고 정리해 보기로 했습니다.
발표에서 다룬 큰 축은 다음과 같습니다.
  • SSR → CSR
  • Critical JavaScript
  • 서버드리븐 UI
  • Image lazy load
  • Code Splitting
  • Tanstack Virtual
이번 글에서는 이 중에서도 SSR vs CSR vs SSG와 FCP(First Contentful Paint) 관점에 집중해서 정리해 보겠습니다.

SSR → CSR, 왜 바꾸었을까?

발표에 따르면, 최초에는 Next.js 기반 SSR로 웹뷰를 개발했다고 합니다. 하지만 실제 앱에 붙이고 보니, 네이티브 화면과는 미묘하게 다른 로딩 방식과 어색한 체감 속도가 문제가 되었다고 했습니다.
로드 시간은 사용자 경험에 아주 큰 영향을 줍니다. 예를 들어:
  • 페이지 로딩 시간이 1초 늘어날 때마다 조회수가 4.9% 감소했다는이 있고,
  • 로딩 속도를 0.1초 개선했더니 매출이 1% 증가했다는 도 있습니다.
notion image
발표 자료에서 요약하자면:
  • SSR: 필수 API 호출 → 응답 수신 → 서버에서 렌더링 완료 → HTML 전송 → 브라우저 렌더
    • 필수 데이터가 다 준비된 후에야 FCP가 발생하기 쉬움
  • CSR: 최소한의 JS만 먼저 실행해서 스켈레톤 같은 껍데기 UI를 빠르게 그리기 쉬움
    • FCP를 앞당기기 유리
즉, 필수 API를 언제까지 기다리느냐
  • 서버에서 다 받아 놓고 HTML을 보낼 것이냐,
  • 아니면 일단 껍데기(스켈레톤)를 먼저 보여 준 뒤에 데이터를 채울 것이냐
에 따라 FCP 시점이 크게 달라질 수 있습니다.
배달의민족 팀은 다음과 같이 트레이드오프를 정리했다고 합니다.
  • SEO가 중요한가?
    • → 앱 내부에서만 사용하는 웹뷰라 SEO는 거의 의미 없음
  • SSR 때문에 오히려 초기 로딩이 느려지는가?
    • → 네이티브에 가깝게 빠른 첫 화면을 보여 주는 것이 더 중요
이 기준에서 SSR 대신 CSR이 더 적합하다고 판단했고, 실제로 React 기반 CSR로 전환했다고 합니다.

“SSR은 그냥 느린가?” 라는 의문

그런데, 이부분에서 한가지 의문이 생겼습니다.
“SSR은 초기 로딩이 느리다”
→ 그렇다면 SSR에서는 FCP를 앞당길 방법이 아예 없는 걸까?
SSR이 느리게 느껴지는 이유는 단순합니다.
서버에서 데이터를 다 모아서 완성 HTML을 만든 뒤에야 브라우저로 보내기 때문입니다.
반면 CSR은 HTML은 빠르게 보내고, 데이터 로딩은 나중에 JS로 처리하니, 스켈레톤만 본다면 더 빨리 보일 수 있습니다.
하지만 요즘 SSR은 예전처럼 “완성본 한 번에 쏘기”만 있는 게 아니라, Streaming SSR이라는 방식도 있습니다.
결론부터 말하면,
Streaming SSR + 스켈레톤을 잘 설계하면 FCP를 충분히 앞당길 수 있습니다.
이제 이 부분을 조금 더 구체적으로 풀어 보겠습니다.

두 가지 SSR 모드: Blocking vs Streaming

SSR은 크게 두 가지 모드로 나눠볼 수 있습니다.

Blocking SSR (전통적인 SSR)

  • 서버가 모든 데이터 로딩 + 템플릿 렌더를 끝낸 뒤
    • 완성된 HTML을 한 번에 브라우저로 보냅니다.
  • 특징:
    • TTFB(Time to First Byte)가 상대적으로 길어지기 쉽고
    • FCP도 그만큼 뒤로 밀릴 여지가 큼

Streaming SSR

  • 서버가 그릴 수 있는 부분(레이아웃, 헤더, 스켈레톤 등)을 먼저 렌더링해서
    • 조각(Chunk) 단위로 브라우저에 스트리밍합니다.
  • 데이터 의존 컴포넌트는 나중에 별도 청크로 이어붙입니다.
  • 특징:
    • 브라우저는 첫 Chunk를 받는 즉시 렌더링할 수 있어 FCP를 앞당기기 유리
    • 이후 Chunk로 실제 데이터를 채워 넣음
Next.js(App Router, RSC)를 사용하면 기본적으로 Streaming SSR이 가능하기 때문에, 이 방식을 활용하면 “SSR이라서 FCP가 무조건 느리다”는 한계를 줄일 수 있습니다.

Streaming SSR은 어떻게 구성할까?

구현 관점에서 보면 핵심은 다음 네 가지입니다.
  1. 데이터 비의존 셸 분리
      • 레이아웃 / 헤더 / 푸터 / 기본 프레임 등은 데이터 없이도 그릴 수 있게 설계합니다.
  1. 데이터 의존 영역은 Suspense 경계로 분리
      • fallback에는 서버에서 바로 렌더링 가능한 스켈레톤을 둡니다.
  1. 스트리밍 응답 활성화
      • Next App Router + 서버 컴포넌트 환경에서는 기본적으로 지원됩니다.
  1. 초기 Chunk vs 후속 Chunk를 의식적으로 나눠 설계
      • 초기 Chunk: 문서 구조 + 크리티컬 JS + 셸 HTML + 스켈레톤
      • 후속 Chunk: 데이터가 준비된 실제 컨텐츠 + 나머지 JS 청크
간단한 예시는 다음과 같습니다.
이렇게 구성하면:
  • Header/스켈레톤은 첫 Chunk에서 바로 도착 → FCP를 빠르게 찍고
  • Reviews 실제 데이터는 준비되는 순간 후속 Chunk로 전달되어 해당 영역만 채워짐
즉, SSR이라고 해도 “어디까지를 FCP에서 보여줄 셸로 볼 것인가?”를 잘 설계하면 CSR 못지않은, 혹은 케이스에 따라 더 나은 체감 속도를 만들 수도 있습니다.

CSR vs Streaming SSR: FCP 관점에서 다시 비교

이제 FCP만 놓고 CSR과 Streaming SSR을 한 번 더 비교해 보겠습니다.

CSR – 기본 형태

일반적인 CSR 앱의 흐름은 다음과 같습니다.
  1. HTML 응답 수신
  1. JS 번들 다운로드
  1. JS 파싱 + 실행 (React 초기화)
  1. React가 실제 UI를 렌더
  1. 이 시점에서 처음으로 의미 있는 컨텐츠가 그려짐 → FCP
즉, 아무 장치도 안 해 두면 JS가 실행될 때까지는 화면이 비어 있고
FCP는 자연스럽게 DOMContentLoaded 이후로 밀립니다.
하지만 HTML 안에 스켈레톤 UI를 미리 넣어두면 1번 단계에서 FCP를 만들 수도 있습니다.
이 경우 흐름은 이렇게 바뀝니다.
  1. HTML 파싱 시작
  1. 스켈레톤 마크업이 함께 파싱 → 바로 DOM에 추가
  1. CSS가 적용되며 스켈레톤이 화면에 그려짐
  1. 이때 이미 눈에 보이는 컨텐츠가 있으므로 FCP 기록
  1. 이후 JS 번들이 로드·실행되며 실제 데이터로 스켈레톤을 교체
즉, HTML에 스켈레톤을 미리 넣어 두기만 해도 1단계에서 FCP를 만들 수 있는 구조가 됩니다.

Streaming SSR – FCP 흐름

Streaming SSR의 대표적인 흐름은 다음과 같습니다.
  1. 서버에서 API 의존성이 없는 셸/스켈레톤을 먼저 렌더링
  1. 렌더가 되는 대로 첫 HTML Chunk를 스트리밍 시작
  1. 브라우저는 이 Chunk를 파싱하고, 그려지는 시점에 FCP 기록
  1. 서버는 동시에 API 호출을 진행
  1. 데이터가 준비되면 나머지 영역을 렌더링해 추가 Chunk로 스트리밍
  1. 브라우저는 이미 만들어진 DOM에 새로운 HTML을 붙이며 화면을 점진적으로 완성
여기서 FCP는 “첫 Chunk가 그려지는 타이밍”이 됩니다.

FCP만 보면, 누가 더 유리할까?

동일한 수준의 “껍데기 UI”를 비교한다고 가정하면:
  • CSR + HTML 스켈레톤
    • 서버는 미리 만들어 둔 정적 HTML을 그대로 반환하기만 하면 되기 때문에
      • 서버 렌더링 비용이 거의 없습니다.
    • 브라우저는 HTML만 받아도 곧바로 스켈레톤을 그릴 수 있어서
      • 이론적으로 FCP를 매우 빠르게 만들 수 있습니다.
  • Streaming SSR
    • 서버에서 React 렌더링을 한 번 거쳐서 HTML chunk를 만들어야 하므로
      • 그만큼 TTFB/FCP가 늦어질 여지가 있습니다.
그래서 “같은 스켈레톤을 보여주고, 서버 렌더 비용까지 포함한다”는 조건 아래에서 순수하게 FCP만 비교하면 CSR 쪽이 더 유리하게 나올 가능성이 높다고 볼 수 있습니다.
다만, Streaming SSR은
  • 실제 데이터가 채워진 영역을 더 일찍 보여줄 수 있다는 장점이 있고
  • 한 번에 완성본을 기다리는 Blocking SSR보다 훨씬 나은 체감을 주기 때문에
실무에서는 “어디까지 스켈레톤으로 두고, 어느 시점부터 실제 데이터를 보여줄지”를 기준으로 Suspense 경계를 자르는 것이 핵심입니다.
너무 잘게 쪼개면 오히려 네트워크·DOM 오버헤드가 커지기 때문에, 사용자 체감이 큰 블록 위주로 나누는 것이 현실적인 전략에 가깝습니다.

“SSG도 있는데요?” – Node 서버를 쓰지 않는 방향

Next.js는 SSR만 있는 게 아니라 SSG(Static Site Generation) 도 지원합니다. 게다가 우아콘 이후 연사님과 같은 팀 프론트엔드 개발자분께 질문해 보니,
배달의민족의 프론트 기술 방향 자체가
Node 서버를 두지 않는 쪽으로 가고 있다
라는 답변을 들었습니다.

* Node 서버를 사용하지 않는다?
Node 서버를 두지 않는다는 것은,
Next.js의 서버 런타임(SSR)을 운영하지 않고 정적 배포(static export) 위주로 간다는 뜻에 가깝습니다.
결국 결과물은
  • 빌드 시점에 생성된 정적 HTML + JS 번들을 CDN에 올려 놓고
  • 런타임에는 정적 파일만 서빙하는 구조
가 되기 때문에, 웹뷰에서 바라보면 CSR과 체감 구조가 유사해집니다.
(초기 로드 이후에는 JS가 대부분의 인터랙션을 담당한다는 점에서)

이렇게 보면,
  • 초기 로드 속도 측면에서는 정적 배포 + CSR 방식으로도 충분히 빠르게 만들 수 있고
  • 개인화/실시간 데이터는 초기 렌더 이후 API를 호출해 주입하는 방식으로 운용할 수 있어서
“굳이 Next.js에서 React로 완전히 넘어갈 필요가 있었을까?”라는 의문도 들었습니다.

그럼에도 SSG를 선택하지 않을 이

조금 더 생각해 보면, static export + SSG에는 다음과 같은 제약이 있습니다.
  • 페이지·경로가 늘어날 때마다 재배포가 필요
  • 배포를 자동화하더라도, 배포 자체가 항상 리스크를 동반
  • 특히 앱 내 웹뷰처럼, 페이지 수나 구성이 자주 변할 수 있는 도메인에서는 배포 부담이 커짐
예를 들어, 배달의민족 장보기 페이지에서
  • 처음에는 CU, 세븐일레븐만 입점했다가
  • 나중에 GS25가 추가로 입점하는 상황을 생각해 보면,
순수 SSG + static export라면 재배포가 완료되기 전까지 GS25 전용 페이지는 정상적으로 이용할 수 없는 상태가 됩니다.
ISR(Incremental Static Regeneration) 같은 기능을 활용하면 완화할 수 있지만,
Node 서버 없이 순수 static export만 사용하는 구조라면 결국
  • “페이지 추가/변경 = 배포 플로우 1회 통과”
  • “배포는 곧 장애 리스크”
가 되기 때문에, SSG를 적극적으로 선택하지 않을 이유가 될 수 있습니다.

그래서 직접 실험해 보기로 했다

이론만 가지고는 감이 잘 안 오기도 해서, 실제로 각 방식을 구현해 성능을 비교해 봤습니다.
성능 측정에는 Web Vitals를 사용했고, 비교 대상은 다음과 같습니다.
  • CSR – 일반적인 CSR
  • CSR – 스켈레톤 UI 포함
  • SSR – Streaming SSR
  • SSR – Blocking SSR
  • SSG
테스트에 사용한 코드는 에서 직접 확인하실 수 있습니다.

전제 조건

테스트를 진행하기 위해 다음과 같은 전제를 두었습니다.
  • CSR
    • setTimeout으로 렌더 지연을 주어 API 대기 시간을 흉내 냈습니다.
  • SSR
    • Next.js의 API Routes를 활용해 Fake API를 구현했습니다.
    • Next.js 서버 컴포넌트는 “정적 비동기 작업은 빌드 시 확정 가능하다”는 전제하에 동작하기 때문에, 기본적으로는 Promise의 결과를 빌드 타임에 들고 있게 됩니다.
    • 이를 피하기 위해 내부 API를 하나 더 두고, fetch를 통해 비동기 요청을 보내는 방식으로 동작을 구성했습니다.
  • SSR의 캐싱 전략
    • SSR에서는 fetchcache 옵션을 "no-store"로 설정했습니다.
    • 기본 설정에서는 fetch가 API 응답을 캐싱해 빌드 시점 값을 재사용하게 되는데, 이번 테스트에서는 FCP 측정데이터의 실시간성을 더 중요하게 보았기 때문에 캐시를 사용하지 않는 조건으로 비교했습니다.

CSR - 일반적인 경우

notion image
Network 탭에서 DOMContentLoaded와 FCP를 비교해보면, 3번 실행했을 때 모두 FCP가 DOMContentLoaded 이후에 기록되는 것을 확인할 수 있습니다.
이렇게 나오는 이유는 렌더링 페이즈를 어떻게 밟는지 보면 이해할 수 있습니다.
  1. HTML 파싱 → #root 생성 (비어 있음)
  1. 파싱 완료 → DOM 구성 완료
  1. defer로 로드된 /main.js 실행 시작
  1. /main.js의 동기 실행 종료 (React 초기화 및 렌더 준비 완료)
  1. 브라우저: “DOM 파싱 + defer 스크립트 실행 끝남” → DOMContentLoaded 이벤트 발생
  1. 이후 렌더 사이클에서 레이아웃 / 페인트 진행 → 첫 콘텐츠가 실제 화면에 그려짐 → FCP
즉, React 렌더가 시작되기 전에는 화면이 비어 있기 때문에 FCP가 DOMContentLoaded보다 뒤로 갈 수밖에 없는 구조입니다.

CSR - 스켈레톤 UI 포함

notion image
반면, HTML에 스켈레톤을 함께 포함한 CSR의 경우:
  • 대부분 FCP가 DOMContentLoaded와 거의 같은 시점에 기록되거나
  • 경우에 따라 더 앞에서 찍히기도 했습니다.
이유는 단순합니다.
  • HTML 파싱 단계에서 이미 스켈레톤 마크업이 DOM에 올라가고
  • CSS만 적용되면 바로 그릴 수 있기 때문입니다.
실제 데이터가 준비되기 전이라도,
“사용자에게 보여줄 최소한의 화면”을 HTML에 미리 넣어 두는 것만으로 FCP는 충분히 앞당길 수 있습니다.

SSR - Blocking

notion image
Blocking SSR에서는 Fake API 응답이 끝난 뒤에야 HTML을 만들어 전송하기 때문에,
테스트에서도 FCP 시점이 Fake API 응답 완료 시점과 거의 일치했습니다.
데이터 지연 = 화면 첫 노출 지연이 되는 구조라,
실시간 리스트성 화면에는 부담이 될 수 있습니다.

SSR - Streaming

notion image
Streaming SSR로 구현한 경우에는,
  • API 의존성이 없는 레이아웃·공통 UI를 먼저 보내고
  • 이후에 데이터 의존 영역을 추가 Chunk로 붙이기 때문에,
Blocking SSR에 비해 FCP를 확실히 앞당길 수 있었습니다.
체감 상으로도
  • “첫 뼈대 화면이 바로 뜨는 느낌”은 CSR 스켈레톤과 유사하고
  • “실제 데이터가 채워지는 타이밍”은 Blocking SSR보다 훨씬 빠르게 느껴졌습니다.

SSG

notion image
SSG는 빌드 시점에 HTML과 데이터를 합쳐 정적 파일로 만들어 두는 방식이라,
  • 별도의 서버 렌더링 비용 없이
  • 정적 파일만 서빙하면 되기 때문에
전반적으로 FCP와 LCP 모두 안정적이고 빠르게 나오는 편이었습니다.
대신 앞서 이야기했듯,
  • 페이지가 추가·변경될 때마다 재배포가 필요하고
  • static export 환경에서는 ISR 같은 동적 재생성이 힘들기 때문에
빠른 초기 로딩 vs 배포 리스크를 어떻게 트레이드오프할지 고민해야 합니다.

결국 선택의 기준은?

운영 방식에 따라 세 가지 모두 유효한 선택지가 될 수 있습니다.
여기에는 단 하나의 정답은 없습니다.
  • 팀의 환경
  • 개발 문화
  • 인프라 한계
  • 운영 인력과 모니터링/배포 역량
이런 맥락 전체를 고려해서 “우리 서비스에 맞는 최선의 선택”을 찾는 과정이 더 중요합니다.
결국 핵심은 프레임워크 이름이 아니라 설계 원칙입니다.
어떤 방식을 택하든:
  • 사용자의 체감 속도를 얼마나 높일 수 있는지
  • 전환(Conversion)에 이르기까지의 경험을 얼마나 매끄럽게 만들 수 있는지
를 기준으로 판단해야 합니다.
그 관점에서 보면, Next.js든 React든 CSR이든 SSR이든 SSG든
모두 목표를 이루기 위한 수단일 뿐입니다.