본문으로 건너뛰기
Previous
Next
Fresh 완전 가이드 | Deno 기반 0 JavaScript 웹 프레임워크

Fresh 완전 가이드 | Deno 기반 0 JavaScript 웹 프레임워크

Fresh 완전 가이드 | Deno 기반 0 JavaScript 웹 프레임워크

이 글의 핵심

Deno 전용 차세대 웹 프레임워크 Fresh. 기본적으로 0 JavaScript를 전송하고, Islands 아키텍처로 부분 Hydration을 지원합니다. 빌드 없이 TypeScript를 바로 실행하며, Lighthouse 점수 100점을 쉽게 달성합니다.

이 글의 핵심

Fresh는 Deno 전용 0 JavaScript 웹 프레임워크입니다. Islands 아키텍처로 부분 Hydration을 지원하고, 빌드 없이 TypeScript를 바로 실행하며, Preact 컴포넌트로 초고속 웹사이트를 구축합니다. Lighthouse 100점을 쉽게 달성합니다. 아래에서는 아키텍처 원리, Deno 권한·표준 라이브러리와의 맞물림, 라우트·핸들러·스타일·배포까지 실전 위주로 정리하고, Next.js와의 trade-off프로덕션에서 쓰며 느낀 점을 솔직히 기술합니다.

목차

Fresh의 핵심 아키텍처: Islands, Zero JS

Zero JS by default가 의미하는 것

Fresh에서 기본 0 JavaScript는 “브라우저로 보내는 번들이 없다”는 뜻에 가깝습니다. routes/*.tsx페이지 컴포넌트는 기본적으로 서버에서만 렌더되고, 클라이언트에는 정적 HTML이 전달됩니다. 따라서 문서·블로그·랜딩처럼 인터랙션이 적은 구간은 JS 페이로드 제로에 가깝게 유지할 수 있습니다. 반면 <button onClick>처럼 클라이언트 상태가 필요한 UI는 Island(islands/ 아래)로 분리해야 하며, 그때에만 해당 청크를 Hydration용으로 로드합니다.

Islands가 파이프라인에서 자리 잡는 방식

Islands 아키텍처는 “페이지 전체를 하나의 SPA로 묶지 않고, 필요한 섬(island)만 클라이언트에서 살리는” 패턴입니다. Fresh는 islands/ 디렉터리에 둔 컴포넌트를 Preact로 트리에 삽입하고, 라우트 트리와 별도로 부분 Hydration 경로를 둡니다. 그래서 LCP(최초 콘텐츠 페인트)에 불필요한 JS를 끼워 넣지 않고, TTI(상호작용 가능 시점)만 필요한 위젯에서 지불하는 형태로 설계할 수 있습니다.

// routes/index.tsx — 서버 컴포넌트에 가까운: 기본 JS 없음
import SearchBox from '../islands/SearchBox.tsx';

export default function Home() {
  return (
    <main>
      <h1 class="text-2xl">정적 제목</h1>
      <p>이 문단은 HTML만 전송됩니다.</p>
      <SearchBox />
    </main>
  );
}
// islands/SearchBox.tsx — 이 파일만 클라이언트 번들·Hydration 대상
import { useState } from 'preact/hooks';

export default function SearchBox() {
  const [q, setQ] = useState('');
  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <input
        value={q}
        onInput={(e) => setQ((e.target as HTMLInputElement).value)}
        placeholder="검색"
      />
    </form>
  );
}

실무 팁: Island는 “작을수록 좋다”는 말이 Fresh에서 특히 맞습니다. 큰 컨테이너 하나를 Island로 올리면 전역 상태·이벤트가 붙기 쉬워 번들이 비대해집니다. 캐러셀·검색창·토글처럼 경계를 쪼개 넣는 편이 유지보수와 성능 둘 다에 유리합니다.

Deno와의 통합: permissions, 표준 라이브러리

권한(Permissions) 모델

Deno는 기본으로 샌드박스이며, 네트워크·파일·환경 변수 등은 명시적 플래그로 허용합니다. Fresh를 로컬에서 돌릴 때는 deno taskdeno.json의 권한을 묶어 주는 경우가 많지만, 직접 deno run할 때는 --allow-net, --allow-read 등이 필요할 수 있습니다. 프로덕션에서는 “필요한 권한만” deno.json/deploy 설정에 맞춰 열고, Deno.env로 시크릿을 읽는 코드 경로는 최소화하는 것이 안전합니다.

// deno.json 예: 태스크마다 권한을 제한해 두면 실수로 과도한 권한이 열리지 않음
{
  "tasks": {
    "dev": "deno run -A --watch=static/,routes/ dev.ts"
  }
}

운영 시에는 -A를 남용하지 말고, deno run --allow-net=example.com처럼 스코프를 좁힌 권한을 습관화하는 것이 좋습니다.

표준 라이브러리·Web API

Fresh 앱은 Node fs 대신 Deno.readTextFile 같은 Deno API를 쓰거나, fetch·Request·ResponseWeb 표준에 맞춥니다. HTTP 서버·미들웨어는 Fresh가 감싸 주므로, 라우트 핸들러 안에서 fetch로 upstream API를 호출하거나 crypto.randomUUID()로 id를 만드는 패턴이 자연스럽습니다. std는 버전 URL로 고정해 가져오는 Deno 스타일을 유지하되, 같은 모듈을 여러 URL로 중복하지 않도록 import_map이나 deno.jsonimports단일 별칭을 두는 편이 좋습니다.

라우팅 실전: 파일 기반, 동적 라우트

routes/ 아래 경로가 URL에 매핑됩니다. index.tsx는 디렉터리 루트, [slug].tsx·[...slug].tsx는 동적·캐치올 구간입니다.

// routes/blog/[slug].tsx
import { PageProps } from '$fresh/server.ts';
import { Handlers } from '$fresh/server.ts';

type Post = { title: string; body: string };

export const handler: Handlers<Post | null> = {
  async GET(_req, ctx) {
    const { slug } = ctx.params;
    const post = await loadPostBySlug(slug); // DB·파일·fetch 등
    if (!post) {
      return ctx.renderNotFound();
    }
    return ctx.render(post);
  },
};

export default function BlogPost(props: PageProps<Post>) {
  const { data } = props;
  return (
    <article>
      <h1>{data.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: data.body }} />
    </article>
  );
}

async function loadPostBySlug(slug: string): Promise<Post | null> {
  // 예: 마크다운 파일, 외부 CMS, Deno KV 등
  return { title: `글: ${slug}`, body: '<p>본문</p>' };
}

중첩 동적 예: routes/shop/[category]/[id].tsx/shop/electronics/42. ctx.paramscategory, id가 동시에 들어옵니다. API-only 라우트는 routes/api/...에서 handler만 export하고 default 컴포넌트를 생략하는 패턴이 흔합니다.

데이터 페칭: Handlers, async routes

데이터는 핸들러에서 준비하고, 컴포넌트는 props.data로만 그린다는 규율이 Fresh에서 가장 읽기 쉬운 구조입니다. GET에서 외부 API를 await fetch 하고, 실패 시 return new Response(..., { status: 502 })처럼 HTTP 시맨틱을 맞추면 됩니다.

// routes/weather/[city].tsx
import { Handlers, PageProps } from '$fresh/server.ts';

type Weather = { city: string; temp: number };

export const handler: Handlers<Weather> = {
  async GET(_req, ctx) {
    const { city } = ctx.params;
    const res = await fetch(`https://api.example.com/weather/${encodeURIComponent(city)}`);
    if (!res.ok) {
      return new Response('Upstream error', { status: 502 });
    }
    const j = await res.json();
    return ctx.render({ city, temp: j.temp });
  },
};

export default function Page(props: PageProps<Weather>) {
  return (
    <p>
      {props.data.city}: {props.data.temp}°C
    </p>
  );
}

POSTreq.formData()·Response 리다이렉트(303 + Location) 패턴이 익숙한 서버 폼 스타일과 잘 맞습니다. “클라이언트에서만 검증”에 의존하지 말고, 핸들러에서도 반드시 검증하는 것이 운영 기준에 맞습니다.

Preact 컴포넌트 작성 팁

  • 도구 import 경로: Island는 preact·preact/hooks를 사용합니다. React 전용 훅·패키지를 그대로 가져오면 번들/타입에서 걸릴 수 있으니, Preact 호환 여부를 먼저 확인합니다.
  • 이벤트 vs 서버: 서버에서 그려지는 일반 TSX는 이벤트 핸들러가 의미가 없을 수 있습니다. 클릭·입력이 필요하면 Island로 이동하거나, 폼이면 method="POST"서버 액션을 쓰는 편이 낫습니다.
  • 상태 끌어올리기 최소화: Island 간 전역 스토어를 크게 잡기보다, URL·서버 state로 나눌 수 있는지 먼저 검토하는 것이 Fresh 철학과 맞습니다.
  • 크기: useEffect로 무거운 라이브러리를 동적 import해 초기 Island 페이로드를 줄이는 것도 전략입니다.
// islands/ChartBlock.tsx — 무거운 위젯은 동적 import로 Island 초기 페이로드 절감
import { useEffect, useState } from 'preact/hooks';
import type { ComponentType } from 'preact';

export default function ChartBlock() {
  const [Comp, setComp] = useState<ComponentType | null>(null);
  useEffect(() => {
    void import('https://esm.sh/some-chart@1').then((m) =>
      setComp(() => m.default as ComponentType)
    );
  }, []);
  if (!Comp) return <p>로딩…</p>;
  return <Comp />;
}

스타일링 전략: Twind 통합

Fresh의 기본 스캐폴드는 Twind(twind + 유틸리티)를 쓰는 경우가 많습니다. 별도의 CSS 번들 파이프라인 없이 클래스 문자열로 UI를 잡을 수 있어, “빌드 스텝 없음”이란 프레임워크 메시지와 잘 맞습니다. 유틸리티가 길어지면 컴포넌트로 추출하거나, 공통 토큰을 const card = "rounded shadow p-4 border"처럼 문자 상수로 모읍니다.

// components/Card.tsx — islands가 아닌 공용 presentational
import type { ComponentChildren } from 'preact';

export function Card(props: { children: ComponentChildren }) {
  return <section class="rounded-2xl border border-gray-200 p-4 shadow-sm">{props.children}</section>;
}

다크 모드·테마는 class 토글 + CSS 변수, 혹은 Twind의 dark: 변형을 문서 루트에서 한 번 정리해 두는 방식이 관리에 유리합니다. “전역 CSS 파일”이 꼭 필요하면 static/에 두고 routes/_app.tsx 또는 레이아웃에서 링크할 수 있으나, Fresh 생태는 유틸리티 우선인 경우가 많습니다.

배포 전략: Deno Deploy

Deno Deploy는 Edge에서 Deno 런타임을 돌리는 호스팅이며, Fresh와 동일 팀이 밀고 있는 경로라 통합이 매끈합니다. GitHub와 연동해 푸시마다 배포하거나, deployctl로 직접 올릴 수 있습니다. 환경 변수는 Deploy 대시보드에서 시크릿으로 주입하고, 지역(Region)·도메인·HTTP/2는 플랫폼 쪽 설정을 따릅니다. KV를 쓰는 경우 Deploy와 KV가 같은 생태라는 점이 소규모 풀스택·프로토타입에 강합니다.

체크리스트: 프로덕션 import URL은 버전을 고정했는지, deno task build(해당 시)와 엔트리포인트가 문서대로인지, ALLOW 범위가 과한지, 서버리스/엣지에서 콜드 스타트에 민감한 싱크 작업이 없는지 점검합니다.

Next.js와의 비교

항목FreshNext.js (App Router 기준)
런타임Deno (Edge 친화)Node(기본), Edge 옵션
기본 JS0 (정적 UI)RSC·최적화에 따라 다름, 클라이언트 번들은 보통 큼
데이터Route Handlers + ctx.renderServer Components, Route Handlers, fetch 캐시
생태npm 대비 작음매우 큼
팀 숙련도Deno/Preact 경험 필요React 생태·채용 용이

솔직한 비교: Next.js는 Vercel·npm·React가 한 축에서 움직이는 최강의 일반해법이고, Fresh는 Deno·Edge·0 JS에 최적화된 특화 해법입니다. “이미 팀이 React·Next에 정착”했다면 이주 비용이 큽니다. 반면 문서·마케팅·내부 툴처럼 초기 로딩·TTI가 중요하고 런타임이 Deno여도 괜찮다면 Fresh가 Lighthouse·운영 단순성에서 강한 인상을 줍니다. E-commerce 대규모처럼 복잡한 클라이언트 상태·레거시 통합이 많다면 Next가 여전히 현실적입니다.

프로덕션 사용 후기

좋았던 점: 개발 루프가 가볍습니다. 빌드 스텝이 없어 “저장 → 즉시 반영”이 빠르고, Islands만 분리해 두면 성능 예측이 쉽습니다. Deno 권한으로 “로컬에서 실수로 시스템 전체에 손 댐”이 줄어듭니다. Deno Deploy + KV 조합은 작은 백오피스·대시보드 MVP에 적합했습니다.

아쉬웠던 점: npm 생태를 그대로 가져오기 어렵고, React 전용 라이브러리는 래핑·대체를 고민해야 합니다. 팀이 React만 알면 Onboarding 비용이 있습니다. 구인·레퍼런스는 Next 대비 확실히 적고, 특이한 엣지 케이스는 Discord·이슈 트래커를 뒤져야 할 때가 있습니다.

언제 쓰면 좋은지: 콘텐츠 위주 사이트, Form+DB가 단순한 내부 도구, Edge에서 낮은 JS로 SLA를 맞춰야 할 때. React만 고집해야 하거나, Design System이 React에 묶인 조직이면 도입이 불리할 수 있습니다. 저는 “성능·단순함이 1순위이고, 팀이 Deno를 감수할 수 있을 때” Fresh를 다시 택할 것 같습니다.

아래부터는 빠른 복습·기본 문법을 코드 중심으로 정리한 섹션입니다. 앞 절의 아키텍처·배포 설명과 중복되는 표·예제가 일부 있으나, 복붙용으로 쓰기 쉬운 최소 템플릿에 가깝게 유지했습니다.

Fresh란?

Fresh는 2022년 Luca Casonato (Deno 팀)가 개발한 Deno 전용 웹 프레임워크입니다. 위에서 다룬 것처럼 서버에서 TSX를 렌더하고, Islands로만 클라이언트 JavaScript를 보내는 구조가 기본이며, Deno 런타임·도구 팀과 같은 로드맵 위에서 다듬어집니다.

🚀 핵심 특징

1. 0 JavaScript 기본

// 정적 페이지 (JavaScript 0KB)
export default function Home() {
  return (
    <div>
      <h1>Hello Fresh!</h1>
      <p>No JavaScript sent to the client</p>
    </div>
  );
}

2. Islands 아키텍처

// 필요한 컴포넌트만 Hydration
<Counter /> {/* JavaScript 전송됨 */}
<p>Static content</p> {/* JavaScript 없음 */}

3. 빌드 없음

Next.js:
- TypeScript → Babel → Webpack → 출력
- 빌드 시간: 30초

Fresh:
- TypeScript → 바로 실행
- 빌드 시간: 0초

4. Deno 네이티브

// Deno 표준 라이브러리 직접 사용
import { serve } from 'https://deno.land/std/http/server.ts';

Fresh 시작하기

1️⃣ Deno 설치

# macOS/Linux
curl -fsSL https://deno.land/install.sh | sh

# Windows (PowerShell)
irm https://deno.land/install.ps1 | iex

# 버전 확인
deno --version

2️⃣ Fresh 프로젝트 생성

# Fresh 프로젝트 생성
deno run -A -r https://fresh.deno.dev my-fresh-app

cd my-fresh-app

# 개발 서버 실행
deno task start

프로젝트 구조

my-fresh-app/
├── routes/
│   ├── index.tsx        # 홈페이지
│   ├── about.tsx        # /about
│   └── api/hello.ts     # API 라우트
├── islands/
│   └── Counter.tsx      # 인터랙티브 컴포넌트
├── static/
│   └── logo.svg
├── deno.json
└── fresh.gen.ts         # 자동 생성

라우팅

앞의 라우팅 실전 절에서 동적 세그먼트·handlerctx.renderNotFound() 흐름을 다뤘다면, 여기서는 가장 흔한 스캐폴드만 짧게 반복합니다. routes/greet/[name].tsx 식의 한 단계 동적 라우트는 props.params로 항상 문자열이 넘어오므로, 숫자 id가 필요하면 Number()·zod 등으로 파싱·검증하세요.

파일 기반 라우팅

// routes/index.tsx (홈페이지)
export default function Home() {
  return (
    <div>
      <h1>Welcome to Fresh</h1>
      <p>0 JavaScript by default</p>
    </div>
  );
}

// routes/about.tsx (/about)
export default function About() {
  return <h1>About Page</h1>;
}

// routes/blog/[slug].tsx (/blog/:slug)
import { PageProps } from '$fresh/server.ts';

export default function BlogPost(props: PageProps) {
  const { slug } = props.params;
  return <h1>Post: {slug}</h1>;
}

Islands (인터랙티브 컴포넌트)

Island 생성

// islands/Counter.tsx
import { useState } from 'preact/hooks';

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </div>
  );
}

Island 사용

// routes/index.tsx
import Counter from '../islands/Counter.tsx';

export default function Home() {
  return (
    <div>
      <h1>Fresh Islands Demo</h1>
      
      {/* 정적 HTML (JavaScript 없음) */}
      <p>This is static content</p>
      
      {/* Island (JavaScript 전송됨) */}
      <Counter />
      
      {/* 다시 정적 HTML */}
      <p>More static content</p>
    </div>
  );
}

Handlers (서버 로직)

export const handler같은 URL에 대해 HTTP 메서드별서버에서 먼저 실행됩니다. GET에서 ctx.render(data)직렬화 가능한 data를 페이지에 꽂고, 컴포넌트는 반드시 그 데이터만 그리는 패턴이 디버깅에 유리합니다. 인증이 필요하면 ctx.state(미들웨어와 함께)나 쿠키·세션을 req에서 읽어 핸들러 초반에서 차단하는 식이 일반적입니다.

GET 요청

// routes/users.tsx
import { Handlers, PageProps } from '$fresh/server.ts';

interface User {
  id: number;
  name: string;
}

export const handler: Handlers<User[]> = {
  async GET(_req, ctx) {
    // API 호출 또는 DB 쿼리
    const users = await fetchUsers();
    return ctx.render(users);
  },
};

export default function UsersPage(props: PageProps<User[]>) {
  const users = props.data;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

POST 요청

// routes/users/new.tsx
import { Handlers } from '$fresh/server.ts';

export const handler: Handlers = {
  async POST(req, ctx) {
    const form = await req.formData();
    const name = form.get('name') as string;
    const email = form.get('email') as string;

    // DB에 저장
    await createUser({ name, email });

    // 리다이렉트
    return new Response('', {
      status: 303,
      headers: { Location: '/users' },
    });
  },
};

export default function NewUser() {
  return (
    <form method="POST">
      <input name="name" placeholder="Name" />
      <input name="email" type="email" placeholder="Email" />
      <button type="submit">Create</button>
    </form>
  );
}

API 라우트

JSON APIContent-Type: application/json을 붙이고, CORS가 필요한 경우(별도 앱이 프론트를 띄우는 식)에는 Access-Control-* 헤더를 OPTIONS와 함께 처리합니다. 사내 Fresh 한 벌에서만 쓰면 동일 오리진이라 CORS가 사실상 필요 없는 경우가 많습니다.

// routes/api/hello.ts
import { HandlerContext } from '$fresh/server.ts';

export const handler = (req: Request, ctx: HandlerContext) => {
  return new Response(JSON.stringify({ message: 'Hello API!' }), {
    headers: { 'Content-Type': 'application/json' },
  });
};

// routes/api/users/[id].ts
export const handler = async (req: Request, ctx: HandlerContext) => {
  const { id } = ctx.params;
  
  if (req.method === 'GET') {
    const user = await db.users.findUnique({ where: { id } });
    return Response.json(user);
  }
  
  if (req.method === 'DELETE') {
    await db.users.delete({ where: { id } });
    return new Response(null, { status: 204 });
  }
  
  return new Response('Method Not Allowed', { status: 405 });
};

Deno KV (Database)

Deno.openKv()로컬Deno Deploy에서 경로는 다를 수 있으나, API는 동일해 한 코드로 CRUD·리스트(kv.list prefix)를 맞출 수 있습니다. 대량 스캔은 엣지에서 비용·지연이 될 수 있으니, 쿼리 패턴이 복잡해지면 전용 DB로 넘기는 기준을 잡는 편이 안전합니다.

// routes/todos.tsx
import { Handlers, PageProps } from '$fresh/server.ts';

const kv = await Deno.openKv();

interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

export const handler: Handlers<Todo[]> = {
  async GET(_req, ctx) {
    const todos = [];
    const iter = kv.list<Todo>({ prefix: ['todos'] });
    
    for await (const entry of iter) {
      todos.push(entry.value);
    }
    
    return ctx.render(todos);
  },
  
  async POST(req, ctx) {
    const form = await req.formData();
    const title = form.get('title') as string;
    
    const id = crypto.randomUUID();
    await kv.set(['todos', id], {
      id,
      title,
      completed: false,
    });
    
    return new Response('', {
      status: 303,
      headers: { Location: '/todos' },
    });
  },
};

export default function Todos(props: PageProps<Todo[]>) {
  const todos = props.data;
  
  return (
    <div>
      <h1>Todos</h1>
      
      <form method="POST">
        <input name="title" placeholder="New todo..." />
        <button type="submit">Add</button>
      </form>
      
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </div>
  );
}

Fresh vs Astro vs Next.js

Next.js와의 비교같은 축(런타임·JS·팀)에서 서술형으로 풀었고, 아래 표는 Astro까지 포함한 3프레임워크 스냅샷입니다. “Astro = 다중 UI 프레임워크·빌드”, “Fresh = Deno·Preact·빌드 없음”처럼 쇼핑할 때 한눈에 보면 됩니다.

기능FreshAstroNext.js
런타임Deno만Node.jsNode.js
초기 JS0KB0KB85KB
빌드❌ 없음✅ 필요✅ 필요
Islands✅ Preact✅ 다중 프레임워크
TypeScript✅ 네이티브⚠️ 컴파일⚠️ 컴파일
배포Deno DeployVercel·CloudflareVercel
생태계🌱 새로움🌿 성장 중🌳 성숙

핵심 정리

Fresh의 장점

  1. 0 JavaScript: 기본적으로 정적 HTML만 전송
  2. 빌드 없음: TypeScript를 바로 실행
  3. Islands 아키텍처: 부분 Hydration
  4. Deno 네이티브: 안전하고 빠른 런타임
  5. Lighthouse 100점: 쉽게 달성 가능

🚀 다음 단계


시작하기: deno run -A -r https://fresh.deno.dev로 5분 만에 프로젝트를 시작하고, 0 JavaScript 웹사이트를 만드세요! 🚀