Deno의 공식 웹 프레임워크 Fresh를 심층 분석합니다. Islands Architecture, Preact 기반 컴포넌트, 라우팅, 미들웨어, 데이터 페칭 등 핵심 기능을 다룹니다.
Fresh는 Deno 팀이 개발한 공식 웹 프레임워크입니다. "최소한의 JavaScript를 클라이언트에 전송한다"는 원칙 아래 설계되었으며, Islands Architecture(아일랜드 아키텍처)를 핵심으로 합니다.
기존의 SPA(Single Page Application) 프레임워크들이 전체 페이지를 클라이언트에서 렌더링하는 것과 달리, Fresh는 대부분의 렌더링을 서버에서 수행하고, 인터랙티브한 부분만 선택적으로 클라이언트에 하이드레이션(hydration)합니다.
Islands Architecture는 2019년 Katie Sylor-Miller가 제안하고 Jason Miller(Preact 제작자)가 발전시킨 아키텍처 패턴입니다. 페이지를 "정적 HTML의 바다 위에 떠 있는 인터랙티브한 섬(island)들"로 바라보는 관점입니다.
페이지의 대부분은 정적 HTML로 렌더링되어 JS 없이도 표시됩니다. 사용자 인터랙션이 필요한 부분(검색, 댓글, 캐러셀 등)만 Island 으로 지정되어 클라이언트 JavaScript가 로드됩니다.
1. 성능
- 전체 번들 대신 필요한 JS만 로드
- 초기 로딩 시간 대폭 감소
- Core Web Vitals (LCP, FID, CLS) 향상
2. SEO
- 서버에서 완전한 HTML 생성
- 검색 엔진이 JavaScript 실행 없이 콘텐츠 인덱싱
3. 접근성
- JavaScript가 비활성화되어도 기본 콘텐츠 표시
- 점진적 향상(Progressive Enhancement) 자연스럽게 구현
4. 개발 효율성
- Island 단위로 독립적 개발/테스트 가능
- 전역 상태 관리의 복잡성 감소# 프로젝트 생성
deno run -A jsr:@fresh/init my-project
cd my-project
# 개발 서버 실행
deno task devmy-project/
components/ # 공유 컴포넌트 (서버/클라이언트 모두 사용)
Button.tsx
Header.tsx
islands/ # Island 컴포넌트 (클라이언트 인터랙션)
Counter.tsx
SearchBar.tsx
routes/ # 파일 기반 라우팅
_app.tsx # 앱 레이아웃
_layout.tsx # 레이아웃 래퍼
_middleware.ts # 미들웨어
index.tsx # / 경로
about.tsx # /about 경로
api/
users.ts # /api/users API 라우트
blog/
[slug].tsx # /blog/:slug 동적 라우트
static/ # 정적 파일 (CSS, 이미지 등)
styles.css
logo.svg
deno.json # Deno 설정
fresh.config.ts # Fresh 설정Fresh의 핵심 규칙: islands/ 디렉토리에 있는 컴포넌트만 클라이언트 JavaScript로 번들됩니다. components/ 디렉토리의 컴포넌트는 서버에서만 렌더링되어 HTML로 전송됩니다. 이 구분이 Islands Architecture의 핵심입니다.
Fresh는 Next.js와 유사한 파일 기반 라우팅을 사용합니다. routes/ 디렉토리의 파일 구조가 URL 경로에 직접 매핑됩니다.
import { page } from "fresh";
import Counter from "../islands/Counter.tsx";
export default page(function Home() {
return (
<div class="max-w-screen-md mx-auto px-4 py-8">
<h1 class="text-4xl font-bold">Fresh 블로그에 오신 것을 환영합니다</h1>
<p class="mt-4 text-gray-600">
Deno와 Fresh로 구축된 현대적인 웹 사이트입니다.
</p>
<Counter start={0} />
</div>
);
});import { page, type RouteContext } from "fresh";
interface BlogPost {
slug: string;
title: string;
content: string;
publishedAt: string;
}
async function getBlogPost(slug: string): Promise<BlogPost | null> {
// 데이터 소스에서 블로그 포스트 조회
const posts: Record<string, BlogPost> = {
"hello-deno": {
slug: "hello-deno",
title: "Deno로 시작하는 웹 개발",
content: "Deno는 현대적인 JavaScript/TypeScript 런타임입니다...",
publishedAt: "2026-07-01",
},
};
return posts[slug] ?? null;
}
export default page(async function BlogPostPage(_props: unknown, ctx: RouteContext) {
const slug = ctx.params.slug;
const post = await getBlogPost(slug);
if (!post) {
return <h1>포스트를 찾을 수 없습니다</h1>;
}
return (
<article class="max-w-screen-md mx-auto px-4 py-8">
<h1 class="text-3xl font-bold">{post.title}</h1>
<time class="text-gray-500">{post.publishedAt}</time>
<div class="mt-6 prose">{post.content}</div>
</article>
);
});routes/api/ 디렉토리에서 서버 전용 API 엔드포인트를 정의할 수 있습니다.
import type { Handlers } from "fresh/server.ts";
interface User {
id: number;
name: string;
email: string;
}
const users: User[] = [
{ id: 1, name: "김개발", email: "dev@example.com" },
{ id: 2, name: "이서버", email: "server@example.com" },
];
export const handler: Handlers = {
GET(_req) {
return new Response(JSON.stringify(users), {
headers: { "Content-Type": "application/json" },
});
},
async POST(req) {
const body = await req.json();
const newUser: User = {
id: users.length + 1,
name: body.name,
email: body.email,
};
users.push(newUser);
return new Response(JSON.stringify(newUser), {
status: 201,
headers: { "Content-Type": "application/json" },
});
},
};Island은 islands/ 디렉토리에 위치하며, 클라이언트에서 실행되는 인터랙티브 컴포넌트입니다. Fresh는 Preact를 UI 라이브러리로 사용합니다.
import { useSignal } from "@preact/signals";
interface CounterProps {
start: number;
}
export default function Counter({ start }: CounterProps) {
const count = useSignal(start);
return (
<div class="flex items-center gap-4 py-4">
<button
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
onClick={() => count.value -= 1}
>
-1
</button>
<span class="text-2xl font-mono">{count}</span>
<button
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
onClick={() => count.value += 1}
>
+1
</button>
</div>
);
}Fresh는 Preact의 Signals를 상태 관리에 사용합니다. Signals는 React의 useState보다 세밀한 반응성을 제공하며, 값이 변경된 컴포넌트만 정확히 리렌더링합니다. 이를 통해 불필요한 리렌더링을 줄이고 성능을 향상시킵니다.
import { useSignal } from "@preact/signals";
interface SearchResult {
title: string;
url: string;
}
export default function SearchBar() {
const query = useSignal("");
const results = useSignal<SearchResult[]>([]);
const isLoading = useSignal(false);
async function handleSearch() {
if (!query.value.trim()) {
results.value = [];
return;
}
isLoading.value = true;
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query.value)}`
);
results.value = await response.json();
} catch (error) {
console.error("검색 오류:", error);
} finally {
isLoading.value = false;
}
}
return (
<div class="relative">
<div class="flex gap-2">
<input
type="text"
value={query}
onInput={(e) => {
query.value = (e.target as HTMLInputElement).value;
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
placeholder="검색어를 입력하세요"
class="flex-1 px-4 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleSearch}
class="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
disabled={isLoading.value}
>
{isLoading.value ? "검색 중..." : "검색"}
</button>
</div>
{results.value.length > 0 && (
<ul class="absolute w-full mt-2 bg-white border rounded shadow-lg">
{results.value.map((result) => (
<li key={result.url} class="px-4 py-2 hover:bg-gray-100">
<a href={result.url} class="text-blue-600 hover:underline">
{result.title}
</a>
</li>
))}
</ul>
)}
</div>
);
}미들웨어는 요청이 라우트 핸들러에 도달하기 전에 처리되는 로직입니다.
import type { MiddlewareFn } from "fresh/server.ts";
// 로깅 미들웨어
export const handler: MiddlewareFn[] = [
async function logMiddleware(ctx) {
const start = performance.now();
const response = await ctx.next();
const duration = (performance.now() - start).toFixed(2);
console.log(
`${ctx.req.method} ${new URL(ctx.req.url).pathname} - ${response.status} (${duration}ms)`
);
return response;
},
async function corsMiddleware(ctx) {
const response = await ctx.next();
response.headers.set("Access-Control-Allow-Origin", "*");
response.headers.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE"
);
return response;
},
];import type { MiddlewareFn } from "fresh/server.ts";
async function verifyToken(token: string): Promise<boolean> {
// JWT 토큰 검증 로직
return token === "valid-token"; // 실제로는 JWT 라이브러리 사용
}
export const handler: MiddlewareFn = async function authMiddleware(ctx) {
const authHeader = ctx.req.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return new Response("인증이 필요합니다", {
status: 401,
headers: { "WWW-Authenticate": "Bearer" },
});
}
const token = authHeader.slice(7);
const isValid = await verifyToken(token);
if (!isValid) {
return new Response("유효하지 않은 토큰입니다", { status: 403 });
}
return ctx.next();
};routes/admin/_middleware.ts에 정의된 미들웨어는 /admin/* 경로에만 적용됩니다. Fresh의 미들웨어는 디렉토리 단위로 범위가 결정되므로, 보호가 필요한 경로에 적절히 배치해야 합니다.
Fresh의 페이지 컴포넌트는 서버에서 실행되므로, 데이터베이스나 외부 API에 직접 접근할 수 있습니다.
import { page, type RouteContext } from "fresh";
interface Product {
id: string;
name: string;
price: number;
description: string;
}
export default page(async function ProductsPage(_props: unknown, ctx: RouteContext) {
// 서버에서 직접 데이터 조회
const kv = await Deno.openKv();
const products: Product[] = [];
const iter = kv.list<Product>({ prefix: ["products"] });
for await (const entry of iter) {
products.push(entry.value);
}
return (
<div class="max-w-screen-lg mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-6">상품 목록</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
{products.map((product) => (
<div key={product.id} class="border rounded-lg p-4">
<h2 class="text-xl font-semibold">{product.name}</h2>
<p class="text-gray-600 mt-2">{product.description}</p>
<p class="text-2xl font-bold mt-4">
{product.price.toLocaleString()}원
</p>
</div>
))}
</div>
</div>
);
});Fresh는 빌드 시 정적 HTML을 미리 생성하는 것도 지원합니다.
import { defineConfig } from "fresh";
export default defineConfig({
build: {
// 정적으로 생성할 경로 지정
outDir: "./_fresh",
},
});Fresh는 콘텐츠 중심의 웹사이트에서 특히 빛을 발합니다. 블로그, 문서 사이트, 마케팅 페이지, 이커머스 카탈로그 등 대부분의 콘텐츠가 정적이고 일부만 인터랙티브한 경우에 최적입니다.
클라이언트에 전송되는 JavaScript 양이 최소화되므로, 저사양 기기나 느린 네트워크에서도 빠른 로딩을 보장합니다.
반면 복잡한 SPA(Single Page Application)에는 적합하지 않을 수 있습니다. 전체 페이지가 인터랙티브한 대시보드, 실시간 협업 도구, 복잡한 폼 위자드 같은 경우에는 React/Vue 기반의 SPA가 더 적합할 수 있습니다.
Fresh가 적합한 경우
- 페이지의 70% 이상이 정적 콘텐츠
- SEO가 중요한 프로젝트
- Core Web Vitals 최적화가 필요한 경우
- Deno 생태계에서 풀스택 개발을 원할 때
다른 선택이 나을 수 있는 경우
- 전체 페이지가 고도로 인터랙티브한 앱
- React 생태계의 풍부한 라이브러리가 필요할 때
- 기존 React/Vue 팀의 전환 비용이 클 때Fresh는 Deno 생태계의 풀스택 프레임워크로서 빠르게 발전하고 있습니다. Preact를 기반으로 하므로 React와 유사한 개발 경험을 제공하면서도, Islands Architecture를 통해 성능을 극대화합니다. 10장의 실전 프로젝트에서 Fresh를 활용한 풀스택 애플리케이션을 직접 구축해봅니다.
다음 장에서는 Deno의 서버리스와 엣지 배포를 살펴봅니다. Deno Deploy를 중심으로, 엣지 컴퓨팅 환경에서 Deno가 어떤 장점을 가지는지 분석합니다.
이 글이 도움이 되셨나요?
Deno Deploy를 중심으로 서버리스 및 엣지 배포 전략을 다룹니다. 엣지 컴퓨팅 개념, 콜드 스타트 성능, Deno KV를 활용한 엣지 상태 관리, Cloudflare Workers 호환성을 분석합니다.
Deno 2와 Bun을 심층 비교합니다. 아키텍처 차이, 성능 벤치마크, API 호환성, 생태계 성숙도, 그리고 프로젝트 특성에 따른 선택 기준을 제시합니다.
Deno.serve()를 활용한 HTTP 서버 구축부터 Hono 프레임워크, PostgreSQL 및 Deno KV 데이터베이스 연동, 테스트 작성, 배포까지 REST API 개발 전 과정을 다룹니다.