요즘 사이드 프로젝트로 블로그를 구성하려고 고민중에 있던 중 발견한 새 기술 스택이 있는데요. 바로 Vike라는 프레임워크입니다. 아직 국내외 커뮤니티를 비롯하여 아직까지 생소한 프레임워크지만, 개발의 유연성과 효율성을 확 끌어올릴 수 있는 흥미로운 요소들을 담고 있다고 생각해서 소개글과 간단한 사용법을 작성해보고자 합니다.
Vike는 UI 프레임워크, 렌더링 전략,서버 환경을 개발자가 원하는 대로 선택할 수 있도록 만들어진, 작지만 확장성 뛰어난 코어 프레임워크예요.


이러한 Vike의 철학은 GET Started 페이지에서도 엿볼 수 있습니다. 다양한 기술 스택에 맞게 입맛대로 프로젝트 생성이 가능하죠.
SSG와 SSR을 지원하는 프레임워크 시장에서의 선두주자는 단연 Next.js입니다. 특히 React Server Components(RSC) 모델이 도입된 이후 컴포넌트를 서버와 클라이언트에서 매우 세밀하게 분리할 수 있다는 명확한 장점을 제공하죠. 모든 컴포넌트가 기본적으로 서버에서 실행되어 JavaScript 번들에서 제외되므로, 하이드레이션 과정을 최소화하고 클라이언트로 전송되는 JS 양을 줄일 수 있어요.
하지만 이러한 세밀한 제어는 동시에 개발자에게 새로운 고민과 신경 써야 할 지점들을 안겨주기도 합니다. 예를 들어, 단순히 useState 훅 하나를 사용하기 위해 컴포넌트 상단에 'use client'를 명시해야 하고, 이로 인해 해당 컴포넌트 전체가 하이드레이션 대상에 포함될 수 있어요. 또한, dynamic import를 위해 컴포넌트를 별도의 파일로 분리해야 하는 상황이 발생하면서 오히려 개발 복잡도가 증가하는 느낌을 받기도 합니다.
반면 Vike는 전통적인 SSR과 하이드레이션(Hydration) 모델을 기반으로 하여 기존 React 개발 경험을 그대로 활용할 수 있어요. useState, useEffect 같은 익숙한 훅들을 자연스럽게 사용하면서,+Page.tsx와 +data.ts 파일 구조만 이해하면 별다른 러닝커브 없이 빠르게 시작할 수 있습니다. 새로운 멘탈 모델을 학습하는 부담 없이 기존에 알고 있던 React 지식을 바탕으로 서버 사이드 렌더링의 이점을 누릴 수 있다는 점이 매력적입니다.

Next.js는 Vercel과의 깊은 통합으로 배포 경험이 매우 매끄럽긴 하지만, 동시에 Vercel의 인프라에 대한 종속성을 의미하기도 합니다. 물론 다른 플랫폼에도 배포할 수 있지만, 서버 액션이나 일부 고급 기능들은 Vercel에서 최적화되어 있어서 다른 환경에서는 추가 설정이나 우회가 필요한 경우가 빈번합니다. 최근에는 Next.js 15.1+에서 메타데이터 스트리밍 방식 변경으로 인해 Vercel 외 환경에서 SEO 문제가 발생하는 등 플랫폼 종속성이 더욱 심화되는 이슈도 존재하는 상황입니다.
Vike는 개발자의 입맛대로 초기 프로젝트를 구성할 수 있기 때문에 플랫폼 종속성이 훨씬 적다는 장점을 가집니다. 정적 사이트로 빌드하면 Netlify, GitHub Pages, Cloudflare Pages 어디든 배포할 수 있고, SSR이 필요하다면 Node.js가 실행되는 어떤 환경에서든 동작합니다.이런 유연성은 특히 여러 환경을 고려해야 하는 경우나 특별한 요구사항이 있는 프로젝트에서 중요한 고려사항이죠.
Vike는 Next.js와 동일하게 파일 시스템을 기반으로 라우팅을 사용합니다.
예시로 상품 목록과 개별 상품 상세 페이지를 만든다고 가정하면, 다음과 같은 디렉터리 구조를 만들 수 있어요
src/pages/
└─ product/
├─ +data.ts # 서버에서 데이터 페칭
├─ +Page.tsx # /product 컴포넌트
└─ @id/ # id 기반 개별 상품 페이지
├─ +data.ts # 서버에서 데이터 페칭
└─ +Page.tsx # /product/:id 컴포넌트
Vike의 데이터 패칭은 서버 사이드 우선 원칙을 따릅니다. +data.ts 파일은 서버에서만 실행되어 페이지에 필요한 데이터를 미리 페칭해 pageContext.data로 반환하고, +Page.tsx 컴포넌트는 useData 훅을 통해 이미 준비된 데이터를 받아와 UI를 렌더링하는 구조예요.
// src/pages/product/+data.ts
// 서버 사이드에서 실행되는 데이터 페칭
import type { PageContextServer } from 'vike/types';
export interface Product {
id: string;
name: string;
price: number;
description: string;
}
export async function data(pageContext: PageContextServer): Promise<Product[]> {
const response = await fetch(`/api/products`);
const products: Product[] = await response.json();
return products;
}
// src/pages/product/+Page.tsx
// 클라이언트에서 렌더링되는 페이지 컴포넌트
import React from 'react';
import { useData } from 'vike-react/useData';
import type { Product } from './+data';
export default function ProductPage() {
const products = useData<Product[]>();
return (
<div>
<h1>상품 목록</h1>
{products.map((product) => (
<div key={product.id}>
<h3>{product.name}</h3>
<p>{product.price}원</p>
<a href={`/product/${product.id}`}>상세보기</a>
</div>
))}
</div>
);
}
이런 구조의 장점들을 살펴보면:
+data.ts와 +Page.tsx가 쌍을 이루어 관심사 분리가 명확히 이루어져요.개별 상품 페이지는 @id 폴더를 사용해 동적 라우팅을 구현합니다:
// src/pages/product/@id/+data.ts
// 개별 상품 페이지 데이터 페칭
import type { PageContextServer } from 'vike/types';
export interface Product {
id: string;
name: string;
price: number;
description: string;
}
export async function data(pageContext: PageContextServer): Promise<Product> {
const productId = pageContext.routeParams.id;
const response = await fetch(`/api/products/${productId}`);
const product: Product = await response.json();
return product;
}
// src/pages/product/@id/+Page.tsx
// 개별 상품 상세 페이지
import React from 'react';
import { useData } from 'vike-react/useData';
import type { Product } from './+data';
export default function ProductDetailPage() {
const product = useData<Product>();
return (
<div>
<h1>{product.name}</h1>
<p>{product.price}원</p>
<p>{product.description}</p>
<button>장바구니 담기</button>
</div>
);
}
@id 폴더를 통한 동적 라우팅으로 RESTful한 URL 구조를 구현합니다.SSG(Static Site Generation)는 빌드 시점에 모든 페이지를 미리 정적 HTML 파일로 생성해두는 렌더링 전략입니다. Vike 역시 이 기능을 지원하며, 한 번 생성되면 변경 및 수정이 잦지 않은 블로그나 리포트 포스팅에 특히 유용하죠. 블로그 포스팅 예제를 통해 Vike의 SSG 전략을 알아보겠습니다.
글로벌 설정 (vite.config.ts): 프로젝트의 모든 페이지를 기본적으로 SSG로 설정하고 싶다면 Root vite.config.ts 파일의 vike 플러그인 옵션에 prerender: true를 추가해 주세요.
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import vike from 'vike/plugin';
export default defineConfig({
plugins: [
react(),
vike({
prerender: true, // 모든 페이지를 기본적으로 SSG로 설정
}),
],
});
페이지별 설정 (pages/+config.js 또는 해당 폴더의 +config.js): 특정 페이지 경로만 SSG로 만들고 싶을 경우 해당 페이지 폴더 root 경로에 config.js 파일을 생성하여 prerender: true로 설정해줍니다.
// config.js
export default {
prerender: true,
};
블로그 SSG를 위한 디렉터리 구조는 다음과 같습니다:
src/pages/
└─ blog/
├─ +config.js # 이 경로와 하위 경로를 SSG로 설정
├─ +Page.tsx # 블로그 목록 페이지 (필요하다면)
├─ +data.ts # 데이터 페칭
└─ @id/
├─ +data.ts # 개별 블로그 포스트 데이터 페칭
└─ +Page.tsx # 개별 블로그 포스트 컴포넌트
└── +onBeforePrerenderStart.ts # SSG 핵심 요소
이미 생성된 각 개별 블로그 포스팅의 동적 라우트는 URL에 변수가 포함돼요. /blog/1, /blog/2, /blog/3처럼 말이죠. 이런 경우 Vike는 어떤 ID 값들로 페이지를 만들어야 할지 알 수 없기 때문에 onBeforePrerenderStart 훅을 통해 미리 알려줘야 합니다.
onBeforePrerenderStart 훅의 역할
onBeforePrerenderStart 훅은 동적 라우트에서 SSG를 구현하는 핵심 요소예요. 이 훅은 빌드 시점에 실행되어 생성할 페이지들의 목록을 Vike에게 알려주는 역할을 합니다. Next.js에서 제공하는 getStaticPaths와 유사하게 동작하죠.
// pages/blog/@id/+onBeforePrerenderStart.ts
import type { OnBeforePrerenderStartAsync } from 'vike/types';
import {
fetchAllBlogPosts,
fetchBlogPostById,
} from '../../../features/blog/blog.api';
import type { BlogPost } from '../../../features/blog/blog.type';
export type Data = {
post: BlogPost;
};
export const onBeforePrerenderStart: OnBeforePrerenderStartAsync<
Data
> = async () => {
const posts = await fetchAllBlogPosts();
// 각 포스트에 대해 상세 데이터를 가져와 URL과 pageContext 생성
const blogPages = await Promise.all(
posts.map(async (post) => {
const detailPost = await fetchBlogPostById(post.id);
return {
url: `/blog/${post.id}`, // 생성할 정적 URL
pageContext: {
data: {
post: detailPost, // 페칭된 데이터
},
},
};
})
);
return blogPages;
};
이 훅을 통해 각 포스트마다 생성할 URL과 해당 페이지에서 사용할 데이터를 Vike에게 전달합니다.
이제 전체적인 블로그 SSG 구현을 살펴볼게요.
// pages/blog/index/+data.ts
import type { PageContextServer } from 'vike/types';
import { fetchAllBlogPosts } from '../../../features/blog/blog.api';
import type { BlogPost } from '../../../features/blog/blog.type';
export type Data = {
posts: BlogPost[];
};
export default async function data(
_pageContext: PageContextServer
): Promise<Data> {
const posts = await fetchAllBlogPosts();
return { posts };
}
// pages/blog/index/+Page.tsx
import { useData } from 'vike-react/useData';
import type { Data } from './+data';
import { BlogList } from '../../../features/blog/components/BlogList';
export default function Page() {
const { posts } = useData<Data>();
return <BlogList posts={posts} />;
}
// pages/blog/@id/+data.ts
import type { PageContextServer } from 'vike/types';
import { fetchBlogPostById } from '../../../features/blog/blog.api';
import type { BlogPost } from '../../../features/blog/blog.type';
export type Data = {
post: BlogPost;
};
export default async function data(
pageContext: PageContextServer
): Promise<Data> {
const { id } = pageContext.routeParams;
const postId = parseInt(id, 10);
const post = await fetchBlogPostById(postId);
return { post };
}
// pages/blog/@id/+Page.tsx
import { useData } from 'vike-react/useData';
import type { Data } from './+data';
export default function Page() {
const { post } = useData<Data>();
return (
<article className='container mx-auto px-4 py-8 max-w-4xl'>
<header className='mb-8'>
<h1 className='text-4xl font-bold text-gray-900 leading-tight mb-4'>
{post.title}
</h1>
</header>
<div className='prose prose-lg max-w-none'>
<div className='bg-white rounded-lg shadow-sm border border-gray-200 p-8'>
<div className='text-gray-700 leading-relaxed whitespace-pre-wrap'>
{post.body}
</div>
</div>
</div>
</article>
);
}
빌드 과정과 결과물
이제 build 명령어를 실행하면 다음과 같은 과정이 진행됩니다:
빌드 결과물은 다음과 같은 구조로 생성돼요:
static/
├── blog/
│ ├── index.html # 블로그 리스트 페이지
│ ├── 1/index.html # 포스트 #1 상세 페이지
│ ├── 2/index.html # 포스트 #2 상세 페이지
│ ├── 3/index.html # 포스트 #3 상세 페이지
│ └── ... (이외 포스트) # ...
주의사항과 팁
하이드레이션(Hydration)은 서버에서 이미 렌더링된 HTML에 클라이언트 측 JavaScript 코드를 주입하여 사용자와 상호작용할 수 있는 웹 애플리케이션으로 만드는 과정이에요. Vike의 하이드레이션 과정은 크게 두 개의 핵심 훅, onRenderHtml과 onRenderClient 를 통해 이루어집니다.
사용자가 Vike 앱에 처음 들어오면, Vike 서버는 onRenderHtml 훅을 실행하여 해당 페이지의 컴포넌트들을 HTML 문자열로 변환해요. 이 HTML이 브라우저로 전송되면 사용자는 초기 화면을 빠르게 볼 수 있죠.
먼저 서버 측에서 어떻게 HTML을 생성하는지 살펴보겠습니다:
// renderer/+onRenderHtml.tsx (React SSR 예시)
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { PageShell } from './PageShell'; // 전체 페이지 구조를 담는 컴포넌트
import { escapeInject, dangerouslySkipEscape } from 'vike/server';
import type { OnRenderHtmlHook } from 'vike/types';
const onRenderHtml: OnRenderHtmlHook = async (
pageContext
): ReturnType<OnRenderHtmlHook> => {
const { Page, pageProps } = pageContext; // +Page.tsx 컴포넌트와 데이터
// 1단계: React 컴포넌트를 HTML 문자열로 렌더링
const pageHtml = ReactDOMServer.renderToString(
<PageShell pageContext={pageContext}>
<Page {...pageProps} />
</PageShell>
);
// 2단계: HTML 템플릿에 렌더링된 내용과 필요한 스크립트를 삽입
const documentHtml = escapeInject`<!DOCTYPE html>
<html>
<head>
<title>Vike App</title>
<link rel="stylesheet" href="/assets/index.css" />
<script type="module" src="/assets/entry-client.js"></script>
</head>
<body>
<div id="react-root">${dangerouslySkipEscape(pageHtml)}</div>
</body>
</html>`;
return {
documentHtml,
pageContext: {
// 클라이언트로 전달할 추가적인 pageContext 데이터 (선택 사항)
},
};
};
export default onRenderHtml;
위 코드에서 ReactDOMServer.renderToString을 통해 React 컴포넌트가 서버에서 HTML 문자열로 변환되고, escapeInject를 사용하여 <div id="react-root"> 내부에 삽입됩니다. 이 HTML에는 클라이언트 측 JavaScript 번들을 로드하는 <script> 태그도 포함되어 있어요.
브라우저는 서버로부터 받은 HTML을 파싱하는 동안, Vike가 만든 클라이언트 측 JavaScript 번들을 다운로드해요. 이 번들 안에는 페이지를 구성하는 데 필요한 코드와 모든 클라이언트 로직이 포함되어 있습니다. (예시에서는 /assets/entry-client.js가 해당 번들입니다.)
클라이언트 번들이 다운로드되고 실행되면, onRenderClient 훅이 호출됩니다. 이 훅은 서버에서 렌더링된 HTML 구조와 일치하는 가상 DOM 트리를 구축하고, 이를 실제 DOM에 연결(마운트)하며, 버튼 클릭이나 입력 필드 변경과 같은 사용자 인터랙션에 반응할 수 있도록 이벤트 리스너를 부착합니다. 이 과정이 바로 하이드레이션이에요.
// renderer/+onRenderClient.tsx (예시: React Hydration)
import React from 'react';
import ReactDOM from 'react-dom/client';
import { PageShell } from './PageShell';
import type { OnRenderClientHook } from 'vike/types';
const onRenderClient: OnRenderClientHook = async (
pageContext
): ReturnType<OnRenderClientHook> => {
const { Page, pageProps } = pageContext;
const container = document.getElementById('react-root');
if (!container) {
throw new Error('Root element not found');
}
// 3단계: 클라이언트에서 React 애플리케이션을 하이드레이션
// 서버에서 렌더링된 HTML에 React 앱을 '부트스트랩'하여 상호작용 가능하게 만듭니다.
// ReactDOM.hydrateRoot를 사용하면 기존 HTML 구조를 재사용하며 이벤트 리스너를 부착합니다.
const root = ReactDOM.hydrateRoot(
container,
<PageShell pageContext={pageContext}>
<Page {...pageProps} />
</PageShell>
);
};
export default onRenderClient;
onRenderClient 훅에서는 ReactDOM.hydrateRoot를 사용하여 서버에서 생성된 HTML을 재활용하면서 클라이언트 측 React 애플리케이션을 마운트하고, 이벤트를 연결하여 페이지를 상호작용 가능한 상태로 만듭니다.
Vike의 Hydration 과정 전체 흐름:
지금까지 Vike의 핵심 개념과 특징들을 빠르게 살펴보았습니다. 직접 사용해 본 결과, Vike는 아직 Next.js와 같은 거대 프레임워크에 비해 생태계 규모가 작고, 공식 문서 외에 참고할 만한 레퍼런스가 부족했습니다. 이 때문에 트러블슈팅을 위해 이슈 탭과 내부 코드를 직접 확인해야 하는 경우도 있었죠. 이러한 관점에서 볼 때, Vike를 선뜻 도입하기에는 부담이 될 수 있다는 점은 분명합니다.
그럼에도 불구하고, Vike가 사용자에게 높은 유연성과 제어권을 제공하며 프로젝트의 특정 요구사항에 맞춰 최적화된 스택을 구축할 수 있게 한다는 점은 분명 매력적인 대안이라고 생각합니다. Vike가 추구하는 방향은 매우 흥미롭기에, 앞으로의 행보가 기대되는 프레임워크입니다.
Reference