CSR, SSR, SSG, ISR, 스트리밍, RSC를 정리하고 내가 고른 건 반쪽짜리 SSR이었다

궁금해서 내 서버에서 돌아가는 서비스들의 package.json을 전부 열어봤다. 이 글을 쓸 때 기준으로 블로그, 코인, 커플, 타로, 여행, 게임, 모니터링, 포트폴리오까지 여덟 개다.

blog-server/blog                 ->  vite
portfolio-server/final-portfolio ->  vite
coin-server/coinSite             ->  vite
monitor-server/monitor-client    ->  vite
couple-server/...                ->  vite
travel-server/...                ->  vite
game-server/...                  ->  vite
taro-server/...                  ->  vite

여덟 개가 전부 Vite다. next가 하나도 없다. 무슨 신념이 있어서 그런 건 아니고, 그냥 손에 익어서 새 프로젝트를 만들 때마다 아무 고민 없이 vite로 시작한 결과다.

그렇게 몇 년을 별문제 없이 지냈는데, 얼마 전 블로그에서 문제가 터졌다. 애드센스가 내 블로그를 보고 "게시자 콘텐츠가 없는 화면"이라고 판정한 것이다. 이 글을 쓸 때 글이 21편, 평균 3,500자쯤 올라가 있던 블로그한테 말이다.

이유가 궁금해서 렌더링 방식을 처음부터 다시 봤다. 이 글에서는 CSR, SSR, SSG, ISR, 스트리밍 SSR, RSC, 아일랜드까지 코드와 장단점을 하나씩 정리하고, 그중에서 내가 왜 가장 어중간한 방법을 골랐는지까지 적어본다.

결국 질문은 하나다: HTML을 누가, 언제 만드나

이름이 많아서 복잡해 보이지만, 따지고 보면 축은 두 개뿐이다. HTML을 누가 만드는가와 언제 만드는가다. 이 두 가지의 조합이 아래 방식들의 전부다.

               누가 만드나        언제 만드나
CSR         ->  브라우저          사용자가 들어온 뒤
SSR         ->  서버              요청이 올 때마다
SSG         ->  빌드 머신          배포하기 전에 미리
ISR         ->  빌드 머신 + 서버   미리 만들고, 주기적으로 다시
스트리밍 SSR ->  서버              요청 시점, 대신 나눠서 전송
RSC         ->  서버 + 브라우저    컴포넌트마다 다름
아일랜드     ->  빌드 머신 + 브라우저  대부분 미리, 일부만 브라우저

표만 보면 다 다른 것 같지만, 결국 이 두 질문에 어떻게 답하느냐의 차이다. 이제 하나씩 코드로 보자.

1. CSR: 서버는 빈 껍데기만 내려준다

내 블로그가 바로 이 방식이다. 빌드하면 dist 폴더가 나오고, Express는 그걸 정적 파일로 내려주기만 한다.

// app.js - 이게 전부다
app.use(express.static(path.join(__dirname, 'dist')));

app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});

그래서 브라우저가 받는 첫 HTML은 이렇게 생겼다.

<body>
  <div id="root"></div>          <!-- 비어 있다 -->
  <script type="module" src="/assets/index-93b1bdd9.js"></script>
</body>

실제로 재보니 이 index.html이 5,905바이트였다. 글이 21편 있는 블로그의 첫 HTML이 6KB도 안 된다는 건, 글 내용이 한 글자도 들어있지 않다는 뜻이다. 화면은 저 아래의 자바스크립트(gzip 399KB)가 브라우저에서 실행되어야 비로소 그려진다.

장점은 배포가 단순하다는 것이다. 정적 파일만 올리면 끝이라 서버가 할 일이 없고, 한 번 로딩된 뒤에는 서버를 다시 거치지 않으니 화면 전환도 빠르다. CDN에 그대로 올려도 된다.

단점은 첫 화면이 늦다는 것, 그리고 더 중요하게는 크롤러가 저 빈 div를 본다는 것이다. 검색엔진이나 공유 봇은 자바스크립트가 화면을 그릴 때까지 기다려주지 않는다.

2. SSR: 요청이 올 때마다 서버가 그려서 내려준다

반대로 SSR은 서버에서 React를 실행해 HTML을 완성한 뒤 내려준다. 프레임워크 없이 직접 짜면 이런 모양이다.

import { renderToString } from 'react-dom/server';

app.get('*', async (req, res) => {
  const posts = await db.getPosts();                    // 서버에서 데이터를 먼저 가져오고
  const html = renderToString(<App posts={posts} />);   // 그 데이터로 HTML을 만든다

  res.send(`
    <div id="root">${html}</div>                        <!-- 내용이 채워진 채로 나간다 -->
    <script>window.__DATA__ = ${JSON.stringify(posts)}</script>
    <script src="/client.js"></script>
  `);
});

여기서 눈여겨볼 건 window.__DATA__다. 서버가 만든 HTML은 글자만 있는 상태라서 버튼을 눌러도 아무 반응이 없다. 그래서 브라우저에서 React가 같은 데이터로 한 번 더 실행되면서 이벤트를 붙여준다. 이 과정을 하이드레이션이라고 부른다.

// 브라우저 쪽 - 서버가 만든 DOM에 이벤트만 붙인다 (다시 그리는 게 아니다)
hydrateRoot(document.getElementById('root'), <App posts={window.__DATA__} />);

장점은 크롤러가 완성된 HTML을 본다는 것이다. 첫 화면도 빨리 보이고, 로그인 상태처럼 사람마다 다른 내용도 서버에서 바로 넣어줄 수 있다.

단점은 요청마다 서버가 일한다는 것이다. 트래픽이 늘면 서버 비용도 같이 늘어난다. 게다가 데이터를 두 번 보내게 된다. HTML로 한 번, 하이드레이션용 JSON으로 또 한 번이다. 그리고 하이드레이션이 끝나기 전까지는 화면이 보여도 클릭이 안 되는 어정쩡한 구간이 생긴다.

3. SSG: 배포하기 전에 미리 다 만들어둔다

SSR이 매 요청마다 HTML을 만든다면, SSG는 빌드할 때 한 번만 만들어두고 그 결과를 계속 쓴다. 내 블로그를 SSG로 만든다면 대략 이런 스크립트가 될 것이다.

// 빌드 시점에 한 번 실행
const posts = await db.getPosts();          // 글 21편

for (const post of posts) {
  const html = renderToString(<Post data={post} />);
  fs.writeFileSync(`dist/blog/${post.id}.html`, wrap(html));
}
// -> dist/blog/49.html, 50.html ... 69.html 이 통째로 생긴다

이렇게 만들어두면 배포 후 서버는 그냥 파일을 내려주기만 하면 된다. 렌더링이 이미 끝나 있기 때문이다.

장점은 가장 빠르고 가장 저렴하다는 것이다. 서버가 계산할 게 없으니 부하 걱정이 없고, CDN에 올리면 어디서 접속해도 빠르다.

단점은 내용이 바뀔 때마다 다시 빌드해야 한다는 것이다. 글이 수만 개면 빌드에만 몇십 분이 걸린다. 그리고 사람마다 다른 내용은 애초에 넣을 수 없다. 모두에게 똑같은 페이지만 가능하다.

4. ISR: 미리 만들되, 주기적으로 다시 만든다

그럼 SSG의 "내용 바뀔 때마다 재빌드" 문제는 어떻게 푸는가. ISR이 그 절충안이다. 미리 만들어두는 건 같지만, 정해둔 시간이 지나면 그 페이지만 다시 만든다.

// Next.js 기준 - 이 페이지는 60초마다 다시 만든다
export const revalidate = 60;

export default async function Page() {
  const posts = await getPosts();
  return <PostList posts={posts} />;
}

60초 안에 들어온 사람은 미리 만들어둔 페이지를 받고, 60초가 지나서 들어온 사람은 일단 이전 페이지를 받은 뒤 서버가 뒤에서 새로 만든다. 그다음 사람부터 새 페이지를 받게 된다.

장점은 SSG의 속도를 거의 그대로 유지하면서 전체 재빌드를 피할 수 있다는 것이다.

단점은 내가 고친 내용이 언제 반영될지 정확히 알 수 없다는 것이다. 오타 하나를 고쳐도 누군가는 1분 동안 이전 글을 본다. 실시간이어야 하는 데이터에는 쓸 수 없다.

5. 스트리밍 SSR: 다 그려질 때까지 기다리지 않는다

SSR에는 한 가지 아쉬운 점이 있다. 가장 느린 데이터 하나가 페이지 전체를 붙잡는다는 것이다. 댓글 API가 3초 걸리면 글 본문까지 3초 뒤에 나간다. 스트리밍 SSR은 준비된 부분부터 먼저 내보내서 이 문제를 푼다.

import { renderToPipeableStream } from 'react-dom/server';

app.get('*', (req, res) => {
  const { pipe } = renderToPipeableStream(<App />, {
    onShellReady() {
      res.setHeader('Content-Type', 'text/html');
      pipe(res);            // 뼈대(헤더, 본문)부터 바로 내보낸다
    },
  });
});
// 느린 부분만 Suspense로 감싸두면, 그 부분만 나중에 따라온다
<article>
  <PostBody />                            {/* 바로 나간다 */}
  <Suspense fallback={<CommentSkeleton />}>
    <Comments />                          {/* 준비되면 뒤늦게 전송된다 */}
  </Suspense>
</article>

장점은 느린 데이터 하나가 페이지 전체를 막지 않는다는 것이다. 사용자는 본문부터 읽고 있으면 된다.

단점은 서버 부담이 SSR과 똑같다는 것이다. 구조도 복잡해지고, 에러 처리가 까다로워진다. 응답을 이미 내보내기 시작한 뒤에 오류가 나면 상태 코드를 바꿀 수 없기 때문이다.

6. RSC: 컴포넌트 단위로 서버와 클라이언트를 나눈다

지금까지는 페이지 단위로 렌더링 방식을 골랐다. RSC(React Server Components)는 그 기준을 컴포넌트 단위로 내린다. 이 컴포넌트는 서버에서만 돌고, 저 컴포넌트는 브라우저까지 보내는 식이다.

// 서버 컴포넌트 (기본값) - 브라우저로 자바스크립트가 넘어가지 않는다
async function PostList() {
  const posts = await db.query('SELECT * FROM Board');  // DB를 직접 조회한다
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
'use client';   // 이 지시어가 붙은 것만 브라우저로 넘어간다

function LikeButton() {
  const [liked, setLiked] = useState(false);   // 클릭에 반응해야 하니 클라이언트로
  return <button onClick={() => setLiked(!liked)}>{liked ? '♥' : '♡'}</button>;
}

핵심은 서버 컴포넌트가 번들에 아예 포함되지 않는다는 점이다. 위의 PostList가 마크다운 파서 같은 무거운 라이브러리를 쓰더라도 그 라이브러리는 사용자에게 전송되지 않는다. DB 접속 코드를 컴포넌트 안에 그냥 써도 되는 것도 같은 이유다.

장점은 번들 크기가 크게 줄어든다는 것이다. 데이터를 가져오는 코드와 화면 코드가 한 파일에 붙어 있어 관리도 편하다.

단점은 머릿속에 경계선을 하나 더 그려야 한다는 것이다. 지금 짜는 게 서버 컴포넌트인지 클라이언트 컴포넌트인지 헷갈리기 시작하면, useState 하나 넣었다가 빌드가 실패한다. 그리고 사실상 Next.js 같은 프레임워크에 묶이게 된다.

7. 아일랜드: 대부분은 정적 HTML, 필요한 곳만 살린다

Astro 같은 도구가 쓰는 방식이다. 페이지 전체를 정적 HTML로 만들어두고, 실제로 움직여야 하는 부분만 섬처럼 따로 하이드레이션한다.

---
// Astro - 이 부분은 빌드할 때 서버에서만 실행된다
const posts = await getPosts();
---

<article set:html={post.content} />        <!-- 그냥 HTML. 자바스크립트 0바이트 -->

<SearchBox client:visible />               <!-- 화면에 보일 때 JS를 불러온다 -->
<LikeButton client:idle />                 <!-- 브라우저가 한가할 때 불러온다 -->

생각해보면 블로그 글 본문은 움직일 일이 없다. 그냥 글자다. 그런데 CSR은 그 글자를 그리려고 React 런타임을 통째로 내려받는다. 아일랜드는 그게 아깝다는 문제의식에서 출발한 방식이다.

장점은 콘텐츠 위주 사이트에서 자바스크립트가 거의 0에 가까워진다는 것이다. 블로그에는 사실상 가장 잘 맞는다.

단점은 섬끼리 상태를 주고받기가 번거롭다는 것이다. 화면 전체가 유기적으로 움직여야 하는 서비스에는 맞지 않는다.

한 번에 놓고 보면

            첫 화면    크롤러      서버 부담  갱신       잘 맞는 곳
CSR          느림      빈 div만 봄   없음     즉시      관리자, 대시보드, 로그인 뒤 화면
SSR          빠름      정상          매 요청   즉시      시세, 개인화 피드
SSG          가장 빠름  정상          없음     재빌드     문서, 블로그, 소개 페이지
ISR          빠름      정상          가끔     N초 지연   뉴스, 상품 목록
스트리밍 SSR   더 빠름   정상          매 요청   즉시      느린 API가 섞인 페이지
RSC          빠름      정상          매 요청   즉시      번들이 큰 앱
아일랜드      가장 빠름  정상          없음     재빌드     콘텐츠 위주 사이트

이렇게 늘어놓고 보니 내 블로그에 맞는 답은 분명했다. SSG나 아일랜드다. 글은 21편뿐이고, 자주 바뀌지도 않고, 모두에게 똑같은 내용이며, 본문은 움직이지도 않는다. 교과서적인 정답이 저기 있었다.

그런데도 나는 CSR을 그대로 뒀다. 이유가 있다.

왜 정답을 고르지 않았나

이유는 글을 저장하는 방식에 있었다. 내 블로그는 글이 DB에 들어있고, 관리자 페이지에서 작성한다. SSG로 바꾸면 글을 하나 고칠 때마다 다시 빌드하고 배포해야 한다. 그런데 내 배포는 자동화가 안 되어 있어서, 빌드를 돌리고 pm2를 재시작하는 걸 매번 손으로 친다. 오타 하나 고치자고 그 과정을 반복하고 싶지는 않았다.

만약 글을 마크다운 파일로 관리했다면 이야기가 완전히 달랐을 것이다. 결국 렌더링 방식은 글을 어디에 저장하느냐와 붙어 있는 문제였다. 이건 만들기 전에는 잘 보이지 않는다. 지금 처음부터 다시 만든다면 이 부분을 먼저 고민했을 것이다.

여기에 더해, 블로그 검색 노출 하나 때문에 프론트를 통째로 Next나 Astro로 옮기는 건 값이 너무 컸다. 나머지 일곱 개 서비스는 CSR로 아무 문제 없이 잘 돌아가고 있었으니 더 그랬다.

그래서 반쪽만 만들었다

대신 질문을 바꿔봤다. 크롤러가 실제로 들어오는 경로가 어디인가. 세어보니 글 상세 페이지 하나였다. 관리자 페이지는 검색엔진이 볼 일이 없고, 로그인 화면도 마찬가지다.

그렇다면 그 경로 하나만 서버에서 가로채면 된다. 요청이 오면 DB에서 글을 읽어 메타태그를 바꿔 넣고, 본문 텍스트를 noscript로 함께 내려준다.

// index.html의 기본 메타를 걷어내고, 글별 태그로 바꿔 넣는다
html = html
  .replace(/<title>[\s\S]*?<\/title>/i, '')
  .replace(/<meta\s+property="og:[^"]*"[^>]*>/gi, '');

const tags = `
  <title>${esc(post.title)}</title>
  <meta property="og:title" content="${esc(post.title)}" />
  <meta property="og:image" content="${esc(image)}" />
  <link rel="canonical" href="${esc(url)}" />
`;
html = html.replace('</head>', tags + '</head>');

// SPA라 #root가 비어 있으니, 본문 텍스트는 noscript로 채워준다
html = html.replace(/<noscript>[\s\S]*?<\/noscript>/i, `
  <noscript><article><h1>${esc(post.title)}</h1><div>${esc(bodyText)}</div></article></noscript>
`);

이게 내가 만든 것이다. React를 서버에서 실행하는 진짜 SSR이 아니라, HTML 문자열의 태그를 정규식으로 바꿔치기하는 반쪽짜리다. 교과서적으로는 예쁘지 않다. 하지만 실제로 필요한 건 딱 그만큼이었다.

직접 만들면 프레임워크가 대신 해주던 걸 다 겪는다

물론 대가는 있었다. 이걸 만들면서 가장 크게 데인 부분이 라우트 등록 순서다. Express는 위에서부터 먼저 걸리는 핸들러가 이긴다.

며칠 전에도 이것 때문에 한참 헤맸다. canonical을 접속한 도메인 기준으로 내보내도록 분명히 짰는데, 홈에서만 계속 엉뚱한 주소가 나왔다. 원인은 이 한 줄이었다.

app.use(express.static(path.join(__dirname, 'dist')));

이 줄이 / 요청을 조용히 가로채고 있었다. express.static은 기본값이 index: true라서, 루트로 들어오면 알아서 dist/index.html을 내보낸다. 그래서 내가 뒤에 등록해둔 핸들러까지는 요청이 도달하지도 않았던 것이다. 결국 static의 index를 끄고, SPA 경로는 내 핸들러가 받도록 고쳤다.

// index:false - '/' 를 static이 가로채지 못하게 막는다
app.use(express.static(path.join(__dirname, 'dist'), { index: false }));

app.get('/blog/:bid', blogMetaHandler);  // 글 상세는 글별 메타 주입
app.get('*', spaMetaHandler);            // 나머지는 접속 호스트에 맞춰 메타 보정

Next.js를 썼다면 이런 건 생각할 일조차 없었을 것이다. 이게 직접 만들 때의 진짜 비용이라고 생각한다. 코드를 쓰는 시간이 아니라, 이런 함정을 하나씩 밟아보는 시간 말이다.

안 써본 것도 있다

솔직하게 적어두면, RSC와 아일랜드는 실제 서비스에서 굴려본 게 아니다. 이번에 정리하면서 문서를 읽고 예제를 돌려본 수준이다. 그래서 위에 적은 단점들도 내가 직접 겪은 게 아니라 문서에서 본 내용이다.

내가 직접 겪은 건 CSR의 단점, 그러니까 빈 div와 크롤러 문제, 애드센스 경고까지다. 그리고 부분 SSR을 손으로 짤 때의 함정인 라우트 순서 정도다. 나머지는 언젠가 실제로 써보고 나서 다시 쓰려고 한다.

고치고 나서 결과가 오기까지

canonical과 noscript를 고쳐서 올린 게 8월 말이다. 그런데 구글이 다시 긁어가기 전까지는 고쳤는지 알 방법이 없다. 정책센터에서 재검토를 누르면 옛날에 긁어간 걸 기준으로 또 판단해버린다.

그래서 크롤러가 언제 오는지 지켜보는 스크립트를 하나 만들어서 cron으로 20분마다 돌렸다. Apache 접근 로그를 훑어서 아래 두 가지가 처음 보이면 메일을 보내고, 같은 일로 두 번 보내지 않게 상태 파일에 기록해둔다.

# crontab
*/20 * * * *  cd /path/to/portfolio-server && node scripts/adsense-crawl-watch.js >> logs/adsense-crawl-watch.log 2>&1

# 로그에서 아래가 처음 보이면 메일을 보낸다
#  - Googlebot 이 수정된 경로(/privacy, /portfolio)를 다시 긁어갈 때
#  - 애드센스 크롤러(AdsBot, Mediapartners)가 나타날 때

Search Console의 라이브 테스트는 일부러 제외했다. 그건 내가 버튼을 눌러서 부르는 거라 색인이랑 상관이 없다. 처음엔 그게 뜨는 걸 보고 "왔다"고 좋아했다가 헛물을 켰다.

처음 이 글을 올릴 때는 아직 메일이 안 온 상태였다. 이 글을 다시 손보는 9월 중순 기준으로 스크립트가 남긴 상태 파일을 열어보면 두 건이 잡혀 있다.

# scripts/.adsense-crawl-watch.state.json (일부)
"privacy":   { "at": "27/Aug/2026:18:51:13", "ua": "Googlebot/2.1" }
"adsensebot": { "at": "08/Sep/2026:20:32:11", "ua": "AdsBot-Google-Mobile" }

8월 27일 저녁에 Googlebot이 고친 /privacy를 다시 긁어갔고, 9월 8일 저녁에는 애드센스 쪽 크롤러가 왔다. 8월 27일 건은 뒤늦게 잡혔고 메일은 둘 다 9월 9일에 받았다. 그리고 로그를 날짜별로 세어보면 9월 들어서는 루트 도메인에 Googlebot 요청이 하루 수십에서 수백 건씩 찍힌다. 9월 3일 하루만 751건이다. 고친 페이지를 구글이 다시 봤다는 것까지는 확인된 셈이다.

다만 크롤러가 왔다는 것과 판정이 바뀌었다는 건 다른 얘기다. 정책센터 검토는 이 크롤이 쌓인 뒤에 넣어야 의미가 있고, 이 글을 고치는 시점에 결과라고 부를 만한 건 아직 없다. 결과가 나오면 이 글 끝에 덧붙일 생각이다.