Deno 2와 Fresh: 현대적인 풀스택 웹 개발 프레임워크

프론트엔드

DenoFresh풀스택TypeScript웹 프레임워크

이 글은 누구를 위한 것인가

  • Node.js의 보안 문제와 복잡한 설정 없이 TypeScript를 바로 실행하고 싶은 팀
  • Deno 2의 npm 호환성 수준을 파악하고 마이그레이션을 고려하는 개발자
  • Fresh Islands 아키텍처로 빠른 풀스택 앱을 만들고 싶은 개발자

들어가며

Deno 2는 npm 완전 호환과 함께 Node.js의 대안으로 자리잡았다. 빌드 단계 없이 TypeScript를 바로 실행하고, 기본적으로 권한 없이 파일/네트워크 접근이 불가능한 보안 모델을 갖는다. Fresh는 Deno 위의 풀스택 프레임워크다.

이 글은 bluefoxdev.kr의 Deno 2 실전 가이드 를 참고하여 작성했습니다.


1. Deno 2 핵심 특성

[Deno 2 vs Node.js]

설정:
  Node.js: package.json, tsconfig.json, .eslintrc 등 다수
  Deno: deno.json 하나 (또는 없어도 됨)

TypeScript:
  Node.js: ts-node/tsx/swc 필요
  Deno: 기본 지원 (트랜스파일 내장)

패키지:
  Node.js: npm, node_modules
  Deno 2: npm 호환 + JSR(JavaScript Registry) + URL import

보안:
  Node.js: 모든 권한 기본
  Deno: 기본 권한 없음, --allow-* 플래그로 명시적 허용

런타임 API:
  Node.js: CommonJS + ESM 혼재
  Deno: Web Standard API 우선 (fetch, crypto, Web Streams)

[Deno KV]
  내장 키-값 데이터베이스 (SQLite 기반 로컬)
  Deno Deploy에서 글로벌 분산 복제
  타입 안전한 API
  사용 사례: 세션, 캐시, 설정 저장

[Fresh 프레임워크]
  파일 기반 라우팅
  Islands 아키텍처 (Astro와 유사)
  미들웨어 지원
  빌드 단계 없음 (JIT 컴파일)
  Tailwind 내장

2. Fresh 풀스택 앱 구현

// deno.json
// {
//   "tasks": {
//     "dev": "deno run -A --watch=static/,routes/ dev.ts",
//     "build": "deno run -A dev.ts build",
//     "start": "deno run -A main.ts"
//   },
//   "imports": {
//     "$fresh/": "https://deno.land/x/fresh@1.7.0/",
//     "preact": "https://esm.sh/preact@10.22.0",
//     "preact/": "https://esm.sh/preact@10.22.0/"
//   }
// }

// routes/index.tsx - 페이지 라우트
import { Handlers, PageProps } from '$fresh/server.ts';
import { Head } from '$fresh/runtime.ts';

interface Product {
  id: number;
  name: string;
  price: number;
}

// 서버 사이드 데이터 로딩
export const handler: Handlers<Product[]> = {
  async GET(req, ctx) {
    const products = await fetchProducts();
    return ctx.render(products);
  },
};

export default function ProductsPage({ data }: PageProps<Product[]>) {
  return (
    <>
      <Head>
        <title>상품 목록</title>
      </Head>
      <main>
        <h1>상품 목록</h1>
        <ProductList products={data} />
      </main>
    </>
  );
}

function ProductList({ products }: { products: Product[] }) {
  return (
    <ul>
      {products.map(p => (
        <li key={p.id}>
          <a href={`/products/${p.id}`}>{p.name}</a>
          <span>₩{p.price.toLocaleString()}</span>
        </li>
      ))}
    </ul>
  );
}

async function fetchProducts(): Promise<Product[]> {
  return [
    { id: 1, name: '상품 A', price: 10000 },
    { id: 2, name: '상품 B', price: 20000 },
  ];
}
// islands/AddToCart.tsx - 클라이언트 컴포넌트 (Island)
import { useState } from 'preact/hooks';

interface Props {
  productId: number;
  initialCount?: number;
}

export default function AddToCart({ productId, initialCount = 0 }: Props) {
  const [count, setCount] = useState(initialCount);
  const [loading, setLoading] = useState(false);

  async function addToCart() {
    setLoading(true);
    try {
      await fetch('/api/cart', {
        method: 'POST',
        body: JSON.stringify({ productId, quantity: 1 }),
        headers: { 'Content-Type': 'application/json' },
      });
      setCount(c => c + 1);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div>
      <span>장바구니: {count}개</span>
      <button onClick={addToCart} disabled={loading}>
        {loading ? '추가 중...' : '장바구니 추가'}
      </button>
    </div>
  );
}
// routes/api/cart.ts - API 라우트
import { Handlers } from '$fresh/server.ts';

// Deno KV로 장바구니 저장
const kv = await Deno.openKv();

export const handler: Handlers = {
  async GET(req) {
    const sessionId = getSessionId(req);
    const entries = kv.list<CartItem>({ prefix: ['cart', sessionId] });
    const items: CartItem[] = [];
    
    for await (const entry of entries) {
      items.push(entry.value);
    }
    
    return Response.json(items);
  },
  
  async POST(req) {
    const sessionId = getSessionId(req);
    const { productId, quantity } = await req.json();
    
    const key = ['cart', sessionId, productId];
    const existing = await kv.get<CartItem>(key);
    
    await kv.set(key, {
      productId,
      quantity: (existing.value?.quantity ?? 0) + quantity,
      updatedAt: new Date().toISOString(),
    });
    
    return Response.json({ success: true });
  },
};

interface CartItem { productId: number; quantity: number; updatedAt: string }
function getSessionId(req: Request): string {
  return req.headers.get('x-session-id') ?? crypto.randomUUID();
}
// routes/_middleware.ts - 전역 미들웨어
import { FreshContext } from '$fresh/server.ts';

export async function handler(req: Request, ctx: FreshContext) {
  const start = Date.now();
  
  // 인증 체크
  if (ctx.destination === 'route') {
    const url = new URL(req.url);
    const isProtected = url.pathname.startsWith('/dashboard');
    
    if (isProtected) {
      const session = req.headers.get('cookie');
      if (!session || !isValidSession(session)) {
        return Response.redirect(new URL('/login', req.url));
      }
    }
  }
  
  const resp = await ctx.next();
  
  // 응답 시간 헤더 추가
  resp.headers.set('X-Response-Time', `${Date.now() - start}ms`);
  return resp;
}

function isValidSession(cookie: string): boolean {
  return cookie.includes('session=');
}
# Deno 권한 모델
deno run \
  --allow-net=api.example.com:443 \  # 특정 호스트만
  --allow-read=/data \               # 특정 경로만
  --allow-env=DATABASE_URL,PORT \    # 특정 환경변수만
  server.ts

# npm 패키지 사용 (Deno 2)
deno run --allow-net npm:hono@latest server.ts

# Deno Deploy 배포
deployctl deploy --project=my-app main.ts

# 성능 벤치마크
deno bench benchmarks/http.ts