
본문 이미지를 나중에 넣는 게 아니라 — SEOJing 글쓰기.
SEOJing에서 새 글을 쓸 때 대표 이미지와 본문 이미지를 빼먹지 않도록, 블로그 맵과 글쓰기 파이프라인 안에 시각 판단 단계를 넣은 과정을 정리했다.
vinext는 Cloudflare가 만든 Vite 기반 Next.js 대체 프레임워크다. 빌드 속도
4.4배, 번들 크기 57% 감소를 자랑하고, vinext deploy 한 줄이면 Cloudflare
Workers에 배포된다.
그런데 "빠르다"라고만 알고, 왜 빠르고 어떻게 빠른지에 대한 이해가 없었다. Next.js가 빠른 이유가 SSR 때문이라면, SSR은 왜 빠르고 어떻게 빠른 건지. 이 글은 그 질문에서 출발해서 웹 렌더링 성능의 전체 그림을 처음부터 정리한 기록이다.
브라우저가 빈 HTML을 받고, JS를 다운로드하고, 실행해서 DOM을 만든다. JS가 실행될 때까지 사용자는 빈 화면을 본다.
서버 → 빈 HTML 전송 → JS 다운로드 → JS 실행 → DOM 생성 → 화면 표시
서버가 미리 HTML을 완성해서 보낸다. 사용자는 JS가 로드되기 전에 이미 콘텐츠를 볼 수 있다.
서버에서 HTML 완성 → 완성된 HTML 전송 → 화면 즉시 표시 → JS 다운로드 → Hydration
| 지표 | CSR | SSR |
|---|---|---|
| FCP (First Contentful Paint) | 느림 (JS 실행 후) | 빠름 (HTML 도착 즉시) |
| TTI (Time to Interactive) | JS 실행 후 | Hydration 완료 후 |
| SEO | 불리 (빈 HTML) | 유리 (완성된 HTML) |
SSR이 "빠르다"는 건 정확히는 사용자가 콘텐츠를 처음 보는 시점(FCP)이 빠르다는 뜻이다. 인터랙션 가능 시점(TTI)은 Hydration이 끝나야 하므로 CSR과 비슷하거나 오히려 느릴 수도 있다.
서버가 보낸 정적 HTML에 JS 이벤트 핸들러를 붙여서 인터랙티브하게 만드는 과정이다.
정적 HTML (클릭해도 반응 없음)
↓ Hydration
동적 HTML (클릭, 입력 등 가능)
핵심은 서버가 만든 HTML과 클라이언트 JS가 1:1로 매칭되어야
한다는 것이다. 서버에서 안녕을 보냈는데 클라이언트 JS가 반가워를 기대하면
불일치가 생겨서 Hydration Error가 발생한다.
Next.js는 SSR만 하는 게 아니라 여러 렌더링 전략을 페이지별로 조합한다.
webpack은 모든 모듈을 하나의 의존성 그래프로 묶어서 처리한다. 프로젝트가 커지면 이 그래프가 거대해지고, 빌드 시간이 급격히 늘어난다. 개발 서버도 시작할 때 전체 앱을 번들링하기 때문에 느리다.
모든 파일 읽기 → 의존성 그래프 구축 → 변환 → 번들링 → 최적화 → 출력
1. 개발 시 번들링을 안 한다.
브라우저의 네이티브 ES Modules를 활용한다. import문을 브라우저가 직접
해석하므로, 요청이 온 파일만 그때그때 변환한다.
[webpack] 모든 모듈 → 번들링 → 하나의 파일 → 브라우저
[Vite] 브라우저가 import 요청 → 해당 파일만 변환 → 브라우저로 전송
2. esbuild로 사전 번들링.
node_modules의 라이브러리만 esbuild(Go로 작성, JS 대비 10-100배 빠름)로 미리
번들링해둔다.
3. 프로덕션 빌드에 Rollup 사용. Rollup은 ES Modules 기반으로 설계되어 tree-shaking(사용하지 않는 코드 제거)이 webpack보다 효율적이다.
| webpack (Next.js) | Vite | |
|---|---|---|
| Dev 서버 시작 | 전체 번들링 (느림) | 번들링 없음 (빠름) |
| HMR (코드 수정 반영) | 모듈 체인 재빌드 | 수정된 파일만 변환 |
| 사전 번들링 도구 | JS 기반 | esbuild (Go, 10-100x) |
| 프로덕션 번들 | webpack | Rollup (더 나은 tree-shaking) |
서버가 특정 지역(예: 미국 동부)에 있다. 전 세계 어디서든 접근은 가능하지만, 물리적 거리만큼 네트워크 지연이 생긴다.
한국 사용자 → 미국 서버 (왕복 ~200ms) → 응답 느림
미국 사용자 → 미국 서버 (왕복 ~20ms) → 응답 빠름
전 세계 CDN 엣지 노드(수백 개 지점)에서 코드가 실행된다. 사용자와 가까운 곳에서 실행하니까 네트워크 지연이 줄어든다.
한국 사용자 → 한국 엣지 노드 (~20ms) → 빠름
미국 사용자 → 미국 엣지 노드 (~20ms) → 빠름
브라질 사용자 → 브라질 엣지 노드 (~20ms) → 빠름
엣지 노드는 개인 PC가 아니라, Cloudflare나 AWS 같은 기업이 전 세계에 설치해둔 데이터센터(서버)다. Cloudflare는 300개 이상 도시에 데이터센터를 두고 있다.
Edge에서는 가벼운 V8 Isolate 런타임을 쓴다. V8 엔진만 있어서 JS/WASM만 실행 가능하고, 시작 시간이 약 5ms다. Node.js는 V8 위에 파일시스템, 네트워크 등 OS 기능을 붙인 것이라 무겁지만, 뭐든 할 수 있다.
| Node.js | Edge (V8 Isolate) | |
|---|---|---|
| 기능 | 전부 가능 (fs, net 등) | JS/WASM만 |
| DB 직접 연결 | 가능 | 불가 (HTTP API로 우회) |
| 시작 속도 | ~200ms+ | ~5ms |
| 배포 위치 | 특정 리전 | 전 세계 엣지 |
Vercel은 두 런타임을 모두 제공하고, 페이지별로 선택할 수 있다.
export const runtime = "nodejs"; // 기본값
export const runtime = "edge"; // Edge Runtime
Next.js: Next.js API → webpack/Turbopack → Node.js 서버 (특정 리전)
vinext: Next.js API → Vite (esbuild+Rollup) → Cloudflare Workers (전 세계 Edge)
Google이 정한 사용자 체감 성능의 3가지 핵심 지표다. 실제로 검색 순위(SEO)에도 영향을 준다.
가장 큰 콘텐츠가 화면에 나타나는 시간. 메인 이미지, 큰 텍스트 블록, 비디오 썸네일 등이 해당한다.
페이지 요청
│ 0.5s 헤더, 네비게이션 표시
│ 1.2s 메인 이미지 표시 ← 이게 LCP
│ 1.8s 나머지 로딩
좋음: ≤ 2.5s / 나쁨: > 4s
각 렌더링 방식의 LCP 영향: CSR은 JS 실행 후에야 표시되므로 느리고, SSG는 미리 만든 HTML을 CDN에서 서빙하므로 가장 빠르다.
사용자가 클릭/탭/키 입력 후 화면이 반응하기까지의 시간. FID를 대체하여 2024년 3월부터 Core Web Vitals 지표가 됐다.
사용자가 버튼 클릭
│ JS 이벤트 핸들러 실행
│ 상태 업데이트
│ DOM 변경
│ 화면 다시 그림 ← 여기까지가 INP
좋음: ≤ 200ms / 나쁨: > 500ms
SSR/CSR보다 클라이언트 JS 코드의 효율성과 관련이 크다. Hydration이 무거우면 JS 메인 스레드가 막혀서 INP가 나빠진다.
페이지 로딩 중 레이아웃이 얼마나 흔들리는가. 광고가 갑자기 끼어들어서 읽던 텍스트가 밀리는 경험이 대표적이다.
좋음: ≤ 0.1 / 나쁨: > 0.25
흔한 원인: 이미지 크기 미지정, 폰트 로딩 후 글자 크기 변경, 동적 콘텐츠 삽입.
해결: width/height 명시, font-display: swap + 비슷한 크기의 fallback
폰트, Skeleton으로 공간 미리 확보.
| 지표 | 측정하는 것 | 좋음 기준 | 개선하는 기술 |
|---|---|---|---|
| LCP | 콘텐츠 표시 속도 | ≤ 2.5s | SSR, SSG, Streaming, CDN |
| INP | 인터랙션 반응 속도 | ≤ 200ms | RSC, 코드 스플리팅, 가벼운 Hydration |
| CLS | 레이아웃 안정성 | ≤ 0.1 | 이미지 크기 지정, Skeleton, 폰트 최적화 |
핵심 질문: 이 컴포넌트가 꼭 브라우저에서 실행돼야 하는가? 대부분의 컴포넌트는 데이터를 받아서 UI를 그리기만 한다. 클릭 이벤트도 없고 상태도 없다. 이런 걸 굳이 브라우저에 JS로 보낼 필요가 없다.
// Server Component (기본값, 'use client' 없음)
// 서버에서만 실행됨. 브라우저로 JS가 안 감.
async function PostList() {
const posts = await db.query("SELECT * FROM posts");
return (
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
// Client Component ('use client' 선언)
// 브라우저에서 실행됨. JS 번들에 포함됨.
("use client");
function LikeButton() {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(true)}>좋아요</button>;
}
Server Component는 JS 번들에 포함되지 않고, Hydration도 필요 없다. 페이지에 컴포넌트가 20개 있을 때 상태/이벤트가 필요한 3개만 Client Component로 두면, 나머지 17개는 JS 번들에서 빠진다.
SSR은 "HTML을 어디서 만드는가"이고, RSC는 "컴포넌트가 어디서 실행되는가"이다. SSR은 서버에서 HTML을 만들어 보내지만, JS 번들은 전부 보내고 Hydration도 전부 한다. RSC는 Server Component를 JS 번들에서 아예 제외하고, Hydration 자체가 필요 없다. 둘은 경쟁이 아니라 같이 쓰이는 관계다.
일반 SSR은 페이지 전체가 렌더링될 때까지 기다린 후 한번에 보낸다. DB 조회가 3초 걸리면, 사용자는 3초 동안 아무것도 못 본다.
Streaming SSR은 준비된 부분부터 쪼개서 보낸다. 느린 부분을 기다리지 않고 빠른 부분부터 보여줄 수 있다.
<Layout>
{" "}
{/* 즉시 전송 */}
<Header /> {/* 즉시 전송 */}
<Suspense fallback={<CommentSkeleton />}>
<Comments /> {/* DB 조회 끝나면 전송. 그 전엔 Skeleton 표시 */}
</Suspense>
</Layout>
Suspense는 React에서 "아직 준비 안 됐으면 대신 이걸 보여줘"를 선언하는
컴포넌트다. Comments가 데이터를 가져오는 동안 fallback에 넣은 Skeleton을
보여주고, 준비되면 자동으로 교체한다.
Skeleton은 로딩 중일 때 보여주는 빈 뼈대 UI다. 실제 콘텐츠와 비슷한 모양의 회색 박스를 보여줘서 "콘텐츠가 곧 나오겠구나"라고 예측할 수 있게 한다. 빈 화면이나 스피너보다 체감 로딩이 빠르게 느껴지고, 공간을 미리 잡아놓으므로 CLS도 줄어든다.
전 세계에 서버(엣지 노드)를 두고 콘텐츠를 복사해놓는 것이다. Edge Runtime이 "코드를 엣지에서 실행"하는 거라면, CDN은 "이미 만들어진 파일을 캐싱해서 전달"하는 거다.
1. 첫 요청: 한국 사용자 → 한국 엣지 노드 (없음) → 미국 원본 서버 → 응답 + 엣지에 복사본 저장
2. 다음 요청: 한국 사용자 → 한국 엣지 노드 (있음!) → 바로 응답 (20ms)
서버가 "이 응답을 얼마나 캐싱해도 되는지" 브라우저와 CDN에게 알려주는 방법이다. Next.js 같은 프레임워크가 렌더링 방식에 맞게 알아서 설정해주므로, 대부분은 커스텀 헤더를 쓸 필요가 없다.
# 정적 파일 (JS, CSS, 이미지) - 파일명에 해시가 있어서 변경 시 URL이 바뀜
Cache-Control: public, max-age=31536000, immutable → 1년간 캐싱
# HTML 페이지 - 항상 최신인지 확인
Cache-Control: public, max-age=0, must-revalidate → 매번 확인
# 개인 데이터 (마이페이지 등)
Cache-Control: private, no-store → CDN 캐싱 금지
가장 중요한 캐싱 패턴이다. 캐시가 좀 오래됐어도 일단 보여주고, 뒤에서 조용히 업데이트한다. 사용자는 항상 즉시 응답을 받는다.
Cache-Control: public, max-age=60, stale-while-revalidate=3600
0~60초: 캐시 그대로 반환 (신선함)
60~3660초: 캐시 그대로 반환 + 백그라운드에서 새 버전 가져옴
3660초 후: 캐시 만료, 새로 요청
SSG는 빌드 시점에 HTML을 만들어 CDN에 올리므로 가장 빠르다. 하지만 데이터가 바뀌면 전체를 다시 빌드해야 한다. 페이지가 1000개면 1000개 전부.
ISR은 이 문제를 해결한다. stale-while-revalidate 패턴을 렌더링에 적용한 것이다.
// Next.js에서 ISR 설정
export const revalidate = 60; // 60초
빌드: HTML 생성 → CDN에 올림
0~60초: CDN 캐시 그대로 반환 (SSG처럼 빠름)
60초 후: 다음 요청이 오면
1. 일단 기존 캐시 반환 (사용자는 기다림 없음)
2. 원본 서버가 해당 페이지 HTML만 새로 생성
3. 새 HTML로 CDN 캐시 교체
4. 다음 요청부터 새 HTML 서빙
핵심은 1000페이지 전부가 아니라 요청이 온 그 페이지 1개만 재생성한다는 것이다. 요청이 안 오면 재생성도 안 한다. 그래서 "Incremental(점진적)"이다.
"옛날 걸" 받는 사람은 재생성을 트리거한 딱 한 명이고, 그 사람도 정상적인 페이지를 본다. 데이터가 60초 전 버전일 뿐이다. 블로그 글이나 상품 설명처럼 실시간성이 필요 없는 콘텐츠에 적합하다.
| 속도 | 데이터 신선함 | 서버 부하 | |
|---|---|---|---|
| SSG | 가장 빠름 | 빌드 시점 고정 | 없음 |
| SSR | 느림 | 항상 최신 | 매 요청마다 |
| ISR | SSG와 동일 | 약간의 지연 | revalidate 시에만 |
ISR의 한계: 페이지 전체를 정적 또는 동적으로 취급한다. 상품 페이지에서 이름/설명/이미지는 거의 안 바뀌지만, 가격/재고는 실시간으로 바뀐다. ISR로는 페이지 전체를 60초마다 재생성해야 한다.
PPR은 한 페이지 안에서 정적/동적을 컴포넌트 단위로 분리한다.
export default function ProductPage() {
return (
<div>
{/* 정적: 빌드 시 생성, CDN 캐싱 */}
<ProductName />
<ProductImage />
<Description />
{/* 동적: 요청 시 Streaming SSR */}
<Suspense fallback={<PriceSkeleton />}>
<Price />
</Suspense>
<Suspense fallback={<StockSkeleton />}>
<StockCount />
</Suspense>
</div>
);
}
사용자 요청 시:
1. CDN에서 정적 부분 + Skeleton 즉시 전송
2. 서버에서 동적 부분 준비되면 Streaming으로 전송
3. Skeleton이 실제 데이터로 교체
Web Vitals 관점에서: 정적 부분이 CDN에서 바로 오니까 LCP가 SSG급이고, RSC로 Hydration 대상이 줄어서 INP가 가벼워지고, Skeleton으로 공간을 확보해놨으니 CLS도 안정적이다.
CSR (느림)
→ SSR (LCP 개선, 서버 부하)
→ SSG (빠름, 데이터 고정)
→ ISR (빠름 + 데이터 갱신)
→ PPR (컴포넌트별로 정적/동적 분리) ← 현재 최신
vinext는 이 흐름 위에서, 빌드 도구를 Vite로 바꾸고 런타임을 Edge로 올린 것이다. 렌더링 전략의 진화(SSR → SSG → ISR → PPR)와 빌드/런타임의 진화(webpack → Vite, Node.js → Edge)는 별개의 축이고, vinext는 후자를 개선한 프레임워크다.
Post Q&A
vinext는 왜 빠를까? — SSR, Vite, Edge, 그리고 Web Vitals까지 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!

SEOJing에서 새 글을 쓸 때 대표 이미지와 본문 이미지를 빼먹지 않도록, 블로그 맵과 글쓰기 파이프라인 안에 시각 판단 단계를 넣은 과정을 정리했다.
SEOJing 글을 소셜용 영상으로 따로 소비시키는 게 아니라, 포스트 상단 요약과 블로그 유입 장치로 연결하기 위해 summaryVideo frontmatter와 Supertonic3 기반 요약 쇼츠 파이프라인을 붙인 과정을 정리했다.

SEOJing 블로그에 대표 이미지를 자동으로 붙이는 실험을 실제로 돌려봤다. 검색 기반 cover 삽입과 Codex CLI 기반 정적 SVG 생성이 같은 frontmatter 경로로 연결됐다.

SEOJing 포스트 목록을 파일 탐색기처럼만 두지 않고, 최신 글부터 실제 사진 기반 리소그래프 배경을 붙이는 실험을 정리합니다. 이미지는 배경만 만들고, 제목과 아이콘은 블로그 UI가 맡는 쪽으로 방향을 바꿨습니다.
SEO Jing 개발 열두째 날. code-block 테스트 14개 추가로 커버리지 대폭 개선, 모바일 프레젠테이션에서 FullscreenView 방향 전환 문제와 스크롤 멈춤 버그 수정.
useEffect 의존성 배열에 불필요한 값이 포함되면 cleanup과 재실행이 뒤엉켜 DOM 상태가 꼬일 수 있다. 프레젠테이션 모드에서 발생한 모바일 스크롤 고착 버그를 통해 원인과 해결 패턴을 정리한다.
SEO Jing 개발 열한째 날. PC 프레젠테이션 확대/축소 컨트롤 추가, 모바일 orientation 판단 로직 개선, FullscreenView를 독립 컴포넌트로 분리 및 PC 대응.
한국어 MDX 블로그를 만들다 vinext 프레임워크의 ByteString 버그를 발견하고, 이슈를 작성하고, PR을 올리기까지의 과정
vinext가 빠른 이유를 이해하기 위해, SSR부터 Hydration, 빌드 도구, Edge Runtime, Web Vitals, RSC, CDN 캐싱, ISR, PPR까지 웹 렌더링 성능의 전체 그림을 정리한다
모바일 Safari에서 100vh가 화면을 넘치는 이유, vh/svh/lvh/dvh의 차이, JavaScript에서 실제 뷰포트를 구하는 방법, 그리고 전체화면 UI를 만들 때 알아야 할 CSS zoom과 모바일 판정 패턴까지 정리한다.
SEO Jing 개발 열째 날. 프레젠테이션 모드의 모바일 UX 문제들을 전면 수정. 퀴즈·코드블록·이미지·포스트목록 처리 개선, 롱프레스 UX 및 하단 바 레이아웃 안정화. 모바일 뷰포트·리스트 분할 문제 수정, 채움 비율 보수적으로 조정, 포스트 탐색기 자연 정렬 적용.
SEO Jing 개발 아홉째 날. 프레젠테이션 기능 추가, 퀴즈 구조 변경, 모바일 반응형, 코드블럭 사용성, 테스팅 도입.
모바일 웹에서 코드 블록을 가로로 넓게 보여주고 싶었다. screen.orientation.lock()은 iOS에서 안 되고, PWA manifest는 브라우저에서 무시된다. 결국 CSS transform으로 가짜 회전을 만들었고, 그 과정에서 엄지 접근성까지 고민하게 됐다.
MDX 파일을 수정하지 않고, 렌더된 DOM을 h2 기준으로 자르고 화면 높이에 맞춰 자동 페이지네이션하는 프레젠테이션 모드를 만들었다. 리스트 높이 측정이 왜 틀리는지 디버깅한 과정과, ul/ol을 li 단위로 분할하는 해결책을 정리한다.
SEO Jing 개발 여덟째 날. 아티클 퀴즈 디자인시스템 구현과 스터디 대면 자료 작성.
MDX 블로그에 퀴즈 컴포넌트를 만들면서, Context 기반 Compound Component로 시작했다가 index 문제에 막혀 React.Children API를 채택하게 된 과정을 정리한다.
SEO Jing 개발 일곱째 날. Front Matter CMS 설치와 관련 게시물 이동 탐색기 구현.
SEO Jing 개발 여섯째 날. 데스크탑 비율 수정과 씨랩 스터디 사전 진단 자료 작성.
SEO Jing 개발 다섯째 날. shiki를 rehype-prism-plus로 교체하고, gray-matter 직접 구현, MDX 모듈화, 페이지 내 검색, 테이블 디자인시스템까지.
SEO Jing 개발 넷째 날. lint, codecov, Cloudflare 배포, fs 런타임 이슈.
배포 후 블로그 포스트가 404를 반환하던 문제부터, gray-matter eval 차단, next-mdx-remote eval 차단까지 — 세 겹으로 터진 이슈를 하나씩 해결한 기록
localStorage를 읽는 컴포넌트에서 하이드레이션 불일치가 발생하는 원인과, useState+useEffect가 아닌 useSyncExternalStore가 정답인 이유를 정리한다.
vinext 프로젝트를 GitHub Actions로 Cloudflare Workers에 자동 배포하는 방법과 실제 겪은 트러블슈팅 기록
코드 하이라이팅에 Shiki를 쓰면 왜 RSC에서 WebAssembly.instantiate() 에러가 터지는지, 그리고 빌드 타임 하이라이팅으로 어떻게 해결했는지 정리한다.
CLI 코드 리뷰에서 받은 피드백과 전체 코드 수정 계획을 정리했다.
localStorage만으로 글 읽기 추적, 스크롤 진행률, 댓글 감지를 구현한 과정을 정리했다.
블로그 디테일 페이지에서 MDX를 렌더링하기 위해 검토한 라이브러리들과 최종 선택 과정.
SEO Jing 개발 셋째 날. MDX 라이브러리 이슈, 반응형, 코드블럭, 댓글, 다크모드, 코드 리뷰.
디자인 시스템 구현 시 파일 구조, 디자인 토큰, 유의 사항을 정리했다.
Tailwind v4 환경에서 폰트가 메인 페이지에서만 적용되지 않던 원인과 Hydration Mismatch 이슈를 정리했다.
MDX 파일의 경로 탐색 로직과 콘텐츠 트리 생성 과정을 정리했다.
MDX 파일 구조를 JSON으로 변환하기 위해 Node.js의 fs 모듈을 배워봤다.
SEO Jing 개발 둘째 날. 디자인 시스템 확장, 블로그 스켈레톤, 폰트 이슈 해결.
SEO Jing 프로젝트의 기술 스택 선정과 전체적인 개발 플로우 정리.
Storybook의 사용법과 디자인 시스템 개발에서의 장점을 정리했다.
MDX의 개념과 블로그에서 활용하는 이유를 정리했다.
SEO Jing 개발 첫째 날. 디자인 컨셉 설정과 디자인 시스템 구축을 시작했다.
SEO Jing을 개발하게 된 이유입니다.
프로젝트가 중단되는 이유에 대한 자기 회고입니다.