CSR, SSR, SSG, ISR, 스트리밍, RSC를 정리하고 내가 고른 건 반쪽짜리 SSR이었다
렌더링 방식 7가지 비교와 내가 실제로 고른 기준
#SSR #SSG #CSR #ISR #RSC #SEO #React
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을 손으로 짤 때의 함정인 라우트 순서 정도다. 나머지는 언젠가 실제로 써보고 나서 다시 쓰려고 한다. 정리하면 결국 SSR이냐 CSR이냐는 프레임워크를 고르는 문제가 아니었다. 이 페이지의 첫 HTML을 누가 봐야 하는가 를 정하는 문제였다. 사람만 본다면 CSR로 충분하다. 빈 div를 내려주고 자바스크립트가 그려도 아무도 불평하지 않는다. 하지만 봇이 봐야 한다면 서버가 무언가를 넣어서 내려줘야 한다. 공유 미리보기 봇도, 검색엔진도, 애드센스 심사도 전부 자바스크립트가 실행되기 전의 HTML을 보고 판단하기 때문이다. 내가 "콘텐츠가 없다"는 말을 들은 것도 결국 이것 때문이었다. 그리고 이 판단은 사이트 단위가 아니라 경로 단위 로 하는 게 맞다고 생각한다. 내 블로그도 관리자 페이지는 여전히 CSR이고, 글 상세만 서버가 챙긴다. 프레임워크를 통째로 바꾸기 전에, 내 사이트에서 봇이 실제로 들어오는 경로가 몇 개인지부터 세어보길 추천한다. 생각보다 몇 개 안 될 수도 있다. 나는 하나였다.