Fresh Notes
Fresh에서 Vite는 무슨 일을 할까? Deno와 역할 나누기 | Fresh Notes

Vite

Fresh에서 Vite는 무슨 일을 할까? Deno와 역할 나누기

Fresh 2 프로젝트에서 Deno와 Vite가 각각 어떤 역할을 담당하는지 deno.json, vite.config.ts, 개발 서버와 production build를 따라가며 살펴봅니다.

2026.10.02·Vite·8 min read

Fresh 2 프로젝트를 처음 만들었을 때 가장 먼저 궁금했던 것 중 하나가 Vite였습니다.

Fresh는 Deno에서 시작한 프레임워크입니다.

그런데 새 프로젝트를 열어보면 루트에 vite.config.ts가 있고, 개발 서버도 Vite가 실행합니다.

심지어 node_modules까지 생깁니다.

그러면 자연스럽게 이런 의문이 생깁니다.

Fresh가 이제 Vite 기반 프레임워크가 된 걸까?

또는 조금 더 극단적으로 생각하면,

Vite가 들어왔으니 Deno의 역할이 줄어든 걸까?

결론부터 말하면 둘은 경쟁하는 관계가 아닙니다.

현재 Fresh 2에서 Vite는 개발과 빌드 도구이고, Deno는 애플리케이션을 실제로 실행하는 런타임입니다.

이 차이는 설명을 읽는 것보다 우리가 만든 프로젝트의 deno.json을 직접 보는 편이 훨씬 빠르게 이해됩니다.

deno.json을 보면 역할이 바로 드러납니다

Fresh 프로젝트를 만들면 다음과 비슷한 task가 생성됩니다.

{
  "tasks": {
    "check": "deno fmt --check . && deno lint . && deno check",
    "dev": "vite",
    "build": "vite build",
    "start": "deno serve -A _fresh/server.js"
  }
}

한 줄씩 보면 꽤 재미있습니다.

개발할 때는:

deno task dev

실제로 실행되는 명령은:

vite

입니다.

빌드할 때는:

deno task build

내부적으로:

vite build

가 실행됩니다.

하지만 production 서버를 시작하면:

deno task start

실제 명령은 다음과 같습니다.

deno serve -A _fresh/server.js

여기서는 다시 Deno가 등장합니다.

정리하면 이렇습니다.

단계 주된 역할
개발 서버 Vite
HMR Vite + Fresh plugin
production build Vite + Fresh plugin
production 실행 Deno
HTTP 서버 Deno
Fresh 애플리케이션 Fresh

Vite가 Fresh의 런타임이 된 것이 아닙니다.

개발과 빌드 과정에 Vite가 들어온 것입니다.

Vite와 Deno는 애초에 종류가 다릅니다

이 부분은 Fresh를 떠나서 생각하면 더 단순해집니다.

Deno는 JavaScript와 TypeScript를 실행할 수 있는 런타임입니다.

반면 Vite는 웹 프로젝트를 개발하고 빌드하기 위한 도구입니다.

그래서 둘을 이런 식으로 비교하는 것은 조금 어색합니다.

Deno vs Vite

조금 더 정확하게 놓으면 다음 구조에 가깝습니다.

Deno
└─ Runtime

Fresh
└─ Web Framework

Vite
└─ Development / Build Tool

Preact
└─ UI

자동차에 비유한다면 엔진과 정비 장비를 비교하는 것에 가깝습니다.

둘 다 프로젝트 안에 있지만 담당하는 일이 다릅니다.

그렇다면 Fresh는 왜 Vite를 사용하게 됐을까요?

Fresh 1 시절에는 Fresh 자체의 Builder가 개발과 빌드 과정에서 많은 일을 처리했습니다.

Fresh 2에서는 Vite integration이 본격적으로 들어왔습니다.

Fresh가 Vite를 사용하면서 얻는 것 중 가장 체감하기 쉬운 것은 HMR입니다.

컴포넌트를 수정할 때마다 페이지 전체를 다시 불러오는 대신 변경된 부분을 빠르게 반영할 수 있습니다.

우리가 블로그를 만들면서 CSS를 수정했을 때 바로 브라우저에 반영되던 것도 이 개발 환경 덕분입니다.

하지만 Fresh와 Vite의 결합은 단순히 HMR 하나를 얻기 위한 것은 아닙니다.

현재 Fresh의 Vite plugin은 꽤 많은 일을 맡고 있습니다.

실제 핵심은 @fresh/plugin-vite입니다

우리 프로젝트의 vite.config.ts는 처음에 거의 비어 있었습니다.

import { defineConfig } from "vite";
import { fresh } from "@fresh/plugin-vite";

export default defineConfig({
  plugins: [
    fresh(),
  ],
});

Fresh 프로젝트에서 Vite가 특별해지는 지점은 이 fresh() plugin입니다.

Vite 자체가 Fresh의 routes/나 islands/ 구조를 알고 있는 것은 아닙니다.

Fresh plugin이 그 둘을 연결합니다.

현재 Fresh 공식 문서에서 설명하는 plugin의 역할을 정리하면 대략 다음과 같습니다.

Preact JSX 설정
Island 탐색
HMR
Client / Server 코드 분리
React → Preact alias
production server entry 생성
잘못된 client/server import 검사

즉 Fresh가 Vite 위에 얹힌 단순한 라우터 정도인 것은 아닙니다.

Fresh plugin이 Vite에게 Fresh 프로젝트를 어떻게 해석해야 하는지 알려주는 역할을 합니다.

Islands도 Vite가 발견합니다

Fresh에는 특별한 디렉터리가 있습니다.

islands/

예를 들어:

islands/
└── ThemeToggle.tsx

Fresh에서는 이 파일이 브라우저 JavaScript의 경계를 의미합니다.

그런데 Vite만 놓고 보면 ThemeToggle.tsx는 그냥 평범한 TSX 파일입니다.

이 파일을 Island로 알아보고 별도의 client bundle에 포함시키는 작업을 Fresh plugin이 처리합니다.

단순화하면 이런 흐름입니다.

islands/ThemeToggle.tsx
        │
        ▼
Fresh Vite Plugin
        │
        ├─ 이것은 Island다
        │
        ├─ browser bundle에 포함
        │
        └─ hydration에 필요한 정보 생성

앞선 글에서 살펴본 Islands Architecture와 이번 글의 Vite가 여기서 연결됩니다.

Island라는 개념은 Fresh가 정의하지만, 현대 Fresh 2 프로젝트에서는 이를 실제 개발과 빌드 결과물로 만드는 과정에 Vite가 참여합니다.

서버 코드와 클라이언트 코드도 나눕니다

Fresh 애플리케이션에는 서로 성격이 다른 코드가 존재합니다.

예를 들어 Markdown 파일을 읽는 코드는 서버에서만 실행하면 됩니다.

const source = await Deno.readTextFile(
  "./content/posts/example.md",
);

반면 ThemeToggle은 브라우저에서 실행됩니다.

localStorage.setItem("theme", nextTheme);

둘은 같은 TypeScript 프로젝트 안에 있지만 실행 환경은 완전히 다릅니다.

Server

Deno.readTextFile()
Database
Environment variables
Markdown rendering

──────────── 경계 ────────────

Browser

document
window
localStorage
click event

Fresh Vite plugin은 이 client와 server의 코드를 분리해 각각에 필요한 bundle을 생성합니다.

이 경계가 중요한 이유는 단순히 파일 크기 때문만은 아닙니다.

서버 전용 코드가 실수로 브라우저 bundle에 들어가면 안 되기 때문입니다.

예를 들어 Island에서 서버 전용 모듈을 잘못 가져오는 상황은 빌드 시스템 입장에서 잡아야 할 문제입니다.

Fresh plugin은 이런 종류의 잘못된 import를 검증하는 역할도 담당합니다.

React 패키지도 Preact에서 사용할 수 있게 도와줍니다

Fresh의 UI 계층은 Preact입니다.

그런데 npm 생태계의 많은 패키지는 React를 기준으로 만들어져 있습니다.

예전에는 React용 패키지를 Preact 프로젝트에서 사용하려면 preact/compat alias를 직접 신경 써야 하는 경우가 있었습니다.

Fresh Vite plugin은 이 작업도 처리합니다.

개념적으로는 다음과 같습니다.

React를 기대하는 npm package
          │
          ▼
Fresh Vite Plugin
          │
          ▼
preact/compat

Fresh 프로젝트 안에서 npm 패키지를 사용할 때 개발자가 이 호환 계층을 매번 직접 설정하지 않아도 되는 이유 중 하나입니다.

Fresh 2와 함께 Vite가 들어온 것이 단순한 개발 서버 교체 이상의 변화라는 것을 알 수 있는 부분입니다.

client.ts는 무엇을 하는 파일일까요?

Fresh 2 프로젝트를 만들면 루트에 다음 파일도 있습니다.

client.ts

처음 생성된 파일은 상당히 단순합니다.

import "./assets/styles.css";

코드가 이것뿐이라면 굳이 파일을 하나 따로 둘 필요가 있나 싶을 수 있습니다.

하지만 여기에는 Vite 관점에서 중요한 의미가 있습니다.

CSS를 client.ts에서 import하면 해당 CSS가 Vite의 module graph 안으로 들어갑니다.

그래서 개발 중 CSS 변경을 추적하거나 production build에서 최적화된 asset으로 처리할 수 있습니다.

우리도 Tailwind CSS를 다음처럼 연결했습니다.

@import "tailwindcss";

그리고 Tailwind Vite plugin을 추가했습니다.

import { defineConfig } from "vite";
import { fresh } from "@fresh/plugin-vite";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [
    fresh(),
    tailwindcss(),
  ],
});

Fresh 공식 문서에서도 추가 Vite plugin을 사용할 때 Fresh plugin을 일반적으로 먼저 두는 구성을 안내합니다.

그래서 현재 설정도 다음 순서를 유지하고 있습니다.

fresh()
↓
tailwindcss()
↓
기타 Vite plugins

node_modules가 있다고 Node.js 프로젝트인 것은 아닙니다

Fresh 2 프로젝트를 처음 열었을 때 개인적으로 가장 낯설었던 부분이 이것이었습니다.

node_modules/

Deno를 사용하면서 다시 node_modules를 보게 될 줄은 예상하지 못했습니다.

하지만 이것도 런타임과 패키지 호환성을 구분해서 보면 이해하기 쉽습니다.

Deno 2는 npm 패키지를 사용할 수 있습니다.

우리 프로젝트에도 이미 다음과 같은 패키지가 들어 있습니다.

Preact
Vite
Tailwind CSS
daisyUI
PrismJS

이 중 상당수는 npm 생태계의 패키지입니다.

현재 프로젝트 설정에는 다음 옵션도 있습니다.

{
  "nodeModulesDir": "manual"
}

따라서 필요한 npm 의존성을 로컬 node_modules에 설치해 사용할 수 있습니다.

중요한 점은 이것입니다.

node_modules가 존재한다
≠
Node.js가 production runtime이다

production 실행 명령을 다시 보면:

deno serve -A _fresh/server.js

입니다.

실제 서버를 실행하는 것은 여전히 Deno입니다.

node_modules는 패키지를 어디에서 가져오고 어떻게 배치하느냐의 문제이지, 어떤 런타임이 애플리케이션을 실행하는지를 결정하는 표시는 아닙니다.

build를 하면 _fresh가 생깁니다

그렇다면 Vite의 역할은 어디까지일까요?

직접 build를 실행하면 가장 잘 보입니다.

deno task build

실제로는 다음 명령이 실행됩니다.

vite build

빌드가 끝나면 프로젝트에 _fresh 디렉터리가 생깁니다.

_fresh/

여기에는 production에서 사용할 최적화된 asset과 Fresh 서버 entry가 만들어집니다.

특히 중요한 파일이 있습니다.

_fresh/server.js

Fresh Vite plugin이 production에서 실행할 서버 entry를 생성한 것입니다.

그다음부터는 Vite 개발 서버가 필요하지 않습니다.

Deno가 이 결과물을 실행합니다.

deno serve -A _fresh/server.js

전체 흐름을 이어보면 이렇습니다.

Source Code
    │
    ▼
Vite + Fresh Plugin
    │
    │ build
    ▼
_fresh/
├── server.js
└── optimized assets
    │
    ▼
Deno
    │
    │ serve
    ▼
Production Server

이 그림을 보고 나면 "Vite가 Deno를 대체했다"는 표현이 왜 맞지 않는지 훨씬 명확해집니다.

Vite가 만들어낸 production 결과물을 Deno가 실행합니다.

_fresh는 Git에 올리지 않습니다

_fresh는 소스 코드가 아니라 build 결과물입니다.

따라서 Git 저장소에 넣을 필요가 없습니다.

.gitignore에는 다음 항목을 두면 됩니다.

_fresh/

배포 환경에서는 소스 코드를 받은 뒤 다시 build를 수행하면 됩니다.

Git Repository
      │
      ▼
dependencies install
      │
      ▼
deno task build
      │
      ▼
_fresh 생성
      │
      ▼
deno task start

우리가 나중에 실제 배포할 때도 이 과정이 중요한 부분이 됩니다.

개발할 때는 왜 Deno 명령으로 Vite를 실행할까요?

명령 자체가 조금 재미있습니다.

우리는 다음처럼 입력합니다.

deno task dev

그런데 task 내용은:

"dev": "vite"

입니다.

Deno는 여기서 단순히 애플리케이션 서버 역할만 하는 것이 아니라 프로젝트 task와 npm package ecosystem을 함께 사용할 수 있는 개발 환경 역할도 합니다.

그래서 개발자는 여전히:

deno task dev
deno task build
deno task start

처럼 하나의 명령 체계 안에서 프로젝트를 다룰 수 있습니다.

내부에서는 상황에 따라 Vite가 실행되기도 하고 Deno의 serve가 실행되기도 합니다.

개발할 때는 이 구분을 매번 의식할 필요가 없지만, 문제가 생겼을 때는 꽤 중요합니다.

문제가 어디에서 생겼는지 구분하기 쉬워집니다

예를 들어 HMR이 동작하지 않는다면 우선 살펴볼 곳은 Vite 쪽입니다.

HMR
Vite plugin
client module graph
CSS update

반대로 서버에서 파일을 읽거나 환경 변수, HTTP 요청 처리에 문제가 있다면 Deno와 Fresh 쪽을 봐야 합니다.

Deno.readTextFile()
Deno.env
Fresh route
middleware
server response

production build 과정에서 문제가 난다면 다시 Vite와 Fresh plugin 쪽이 후보가 됩니다.

vite build
Fresh plugin
client/server bundle
asset processing

문제 영역을 이렇게 나누는 것만으로도 디버깅 범위가 상당히 줄어듭니다.

Fresh 2의 Vite 통합은 꽤 깊습니다

처음에는 Fresh 2가 단순히 개발 서버만 Vite로 바꾼 줄 알았습니다.

하지만 실제 프로젝트를 따라가 보면 그렇지 않습니다.

Vite는 개발 중 HMR만 담당하는 것이 아니라 production build에도 사용됩니다.

Fresh plugin은 그 과정에서 Island를 발견하고, client와 server 코드를 분리하며, Preact 관련 설정과 production server entry 생성까지 처리합니다.

따라서 현재 Fresh 2의 개발 흐름을 아주 단순화하면 다음과 같이 표현할 수 있습니다.

개발

Source
  ↓
Vite
  +
Fresh Plugin
  ↓
HMR / Dev Server


빌드

Source
  ↓
Vite
  +
Fresh Plugin
  ↓
_fresh/


실행

_fresh/server.js
  ↓
Deno
  ↓
HTTP Server

이렇게 놓으면 Fresh 프로젝트 안에서 Deno와 Vite가 동시에 등장하는 것이 더 이상 이상하지 않습니다.

결국 중요한 것은 실행 시점을 보는 것입니다

Fresh 프로젝트의 파일만 보면 여러 기술이 뒤섞여 있는 것처럼 보입니다.

하지만 "언제 사용되는가?"를 기준으로 보면 구조가 단순해집니다.

코드를 작성한다
        ↓
Vite가 개발 환경을 제공한다
        ↓
Fresh plugin이 Fresh 구조를 해석한다
        ↓
Vite가 production 결과물을 만든다
        ↓
Deno가 그 결과물을 실행한다

Deno와 Vite 중 하나를 고르는 구조가 아닙니다.

둘이 서로 다른 단계에서 사용됩니다.

Fresh 2에서 Vite가 들어왔다는 것은 Fresh가 Deno를 떠났다는 뜻이라기보다, 웹 개발 도구 영역을 이미 강력한 생태계를 가진 Vite와 나누게 됐다고 보는 편이 더 정확합니다.

그리고 이 구조 덕분에 우리가 Tailwind CSS 4를 붙일 때도 Fresh 전용 integration을 찾을 필요 없이 공식 Vite plugin을 그대로 사용할 수 있었습니다.

처음 봤을 때 낯설었던 vite.config.ts, client.ts, node_modules도 여기까지 살펴보고 나면 각각 왜 존재하는지 설명할 수 있게 됩니다.

다음 글에서는 지금까지 만든 Fresh 프로젝트 위에 Markdown 콘텐츠 시스템을 직접 만들면서 글 파일 하나가 실제 /posts/... 페이지가 되기까지 어떤 과정을 거치는지 살펴보겠습니다.

Front Matter를 읽고, Markdown을 HTML로 변환하고, 동적 route와 category를 만들고, 마지막에는 SEO와 sitemap까지 연결해보겠습니다.

참고 자료

#Fresh#Vite#Deno#Preact#Build