Fresh로 기술 블로그를 만들어보기로 했습니다.
블로그는 대부분의 시간을 글을 읽는 데 사용합니다. 모든 페이지에 많은 JavaScript가 필요한 것도 아닙니다. 그래서 서버에서 HTML을 만들고 필요한 부분만 브라우저에서 동작시키는 Fresh의 방식이 꽤 잘 어울릴 것 같았습니다.
그런데 최신 Fresh 2 프로젝트를 직접 만들어보니 예전에 알고 있던 Fresh와 조금 다른 모습이 보였습니다.
vite.config.ts가 있고, node_modules도 생깁니다. 개발 서버는 Vite가
실행합니다.
Fresh는 Deno 프레임워크였는데 Vite는 왜 들어온 걸까요?
이번 글에서는 완성된 결과물보다 Fresh 2 프로젝트를 처음 만들면서 실제로 확인했던 구조와 설정 과정을 따라가 보겠습니다.
먼저 Deno를 설치합니다
Fresh는 Deno 생태계에서 시작한 웹 프레임워크입니다.
따라서 프로젝트를 만들기 전에 Deno가 필요합니다.
설치가 끝났다면 먼저 버전을 확인합니다.
deno --version제가 프로젝트를 만들 때 사용한 환경은 Deno 2.x였습니다.
현재 Fresh 공식 문서에서는 다음과 같은 방법으로 프로젝트를 생성할 수 있습니다.
deno run -Ar jsr:@fresh/init저는 처음에 deno create를 사용해 다음처럼 실행했습니다.
deno create @fresh/init그런데 제 환경에서는 바로 프로젝트가 만들어지지 않고 다음 오류가 발생했습니다.
Missing jsr: or npm: prefix메시지를 보면 원인은 비교적 명확합니다.
실행할 패키지가 JSR에 있는지 npm에 있는지 명시하라는 뜻입니다.
그래서 jsr:를 붙여 다시 실행했습니다.
deno create jsr:@fresh/init이번에는 정상적으로 Fresh 프로젝트 생성 과정이 시작됐습니다.
같은 오류를 만났다면 Fresh 설정부터 확인할 필요는 없습니다. 먼저 실행 명령에 패키지 소스가 제대로 지정되어 있는지 확인하면 됩니다.
이 부분은 Deno 버전과 Fresh가 안내하는 초기화 방법에 따라 달라질 수 있습니다. 이 글에서는 제가 프로젝트를 생성할 당시 사용한 환경을 기준으로 설명합니다.
패키지를 실행하기 전에 정말 신뢰하는지 묻습니다
프로젝트를 생성하는 과정에서 조금 낯선 메시지도 만났습니다.
Do you fully trust jsr:@fresh/init/create package?
Deno will invoke code from it with all permissions.Fresh 자체의 기능이라기보다 Deno의 권한 모델을 엿볼 수 있는 부분입니다.
초기화 프로그램은 프로젝트 디렉터리에 파일을 만들고 여러 작업을 수행해야 합니다. 따라서 상당한 권한이 필요합니다.
Deno는 이런 작업을 실행하기 전에 해당 코드에 권한을 부여해도 되는지 명시적으로 보여줍니다.
평소 프레임워크의 프로젝트 생성 명령을 별 생각 없이 실행했다면 잠깐 멈칫할 수 있지만, 오히려 어떤 코드가 어떤 권한으로 실행되는지 드러낸다는 점에서는 이해하기 쉬웠습니다.
생성된 Fresh 2 프로젝트를 살펴봅니다
프로젝트 생성을 마치고 디렉터리를 열어보면 대략 다음과 같은 구조를 볼 수 있습니다.
.
├── assets/
│ └── styles.css
├── client.ts
├── components/
├── deno.json
├── deno.lock
├── islands/
├── main.ts
├── node_modules/
├── routes/
├── static/
├── utils.ts
└── vite.config.ts처음 여기서 가장 눈에 들어온 것은 두 가지였습니다.
vite.config.ts와 node_modules입니다.
Deno 하면 node_modules 없이 패키지를 사용하는 모습을 먼저 떠올릴 수도
있습니다. 게다가 Fresh는 Deno 기반 프레임워크인데 프로젝트 안에 Vite까지 들어와
있습니다.
그렇다고 Fresh가 Node.js 기반 프레임워크로 바뀐 것은 아닙니다.
이 관계는 deno.json을 보면 훨씬 이해하기 쉽습니다.
Deno와 Vite는 서로 다른 일을 합니다
생성된 프로젝트의 deno.json에는 다음과 같은 task가 있습니다.
{
"tasks": {
"check": "deno fmt --check . && deno lint . && deno check",
"dev": "vite",
"build": "vite build",
"start": "deno serve -A _fresh/server.js"
}
}세 줄만 떼어놓고 보면 역할이 꽤 선명합니다.
| 작업 | 담당 |
|---|---|
| 개발 서버 | Vite |
| 빌드 | Vite |
| 서버 실행 | Deno |
Vite가 Deno를 대체한 것이 아닙니다.
Deno는 애플리케이션을 실행하는 런타임이고, Fresh는 그 위에서 라우팅과 서버 렌더링 등을 담당하는 웹 프레임워크입니다.
Vite는 개발 서버, HMR, 빌드와 플러그인 생태계를 담당합니다.
즉 이 프로젝트를 아주 단순하게 나누면 다음과 같습니다.
Deno Runtime
Fresh Web Framework
Preact UI
Vite Dev / Build ToolFresh 2를 처음 접한다면 이 역할부터 구분해 두는 것이 좋습니다.
저도 vite.config.ts를 처음 봤을 때는 "Fresh에서 이제 Vite가 런타임 역할까지
하는 건가?"라는 의문이 있었는데, 실제 task를 확인하고 나니 그 경계가 훨씬
명확해졌습니다.
기본 예제 코드는 먼저 정리했습니다
Fresh가 만들어주는 기본 프로젝트에는 프레임워크의 기능을 확인할 수 있는 예제가 포함되어 있습니다.
대표적으로 Counter Island나 API route 같은 코드입니다.
Fresh를 처음 살펴볼 때는 유용하지만, 실제 블로그를 만드는 입장에서는 사용하지 않는 예제가 섞여 있으면 구조를 파악하기 어려워집니다.
그래서 필요한 부분만 남겼습니다.
main.ts는 다음 정도면 충분했습니다.
import { App, staticFiles } from "fresh";
import { type State } from "./utils.ts";
export const app = new App<State>();
app.use(staticFiles());
app.fsRoutes();utils.ts에는 Fresh의 타입 정의를 위한 최소 구조를 남겼습니다.
import { createDefine } from "fresh";
export interface State {}
export const define = createDefine<State>();State가 비어 있어 조금 이상해 보일 수도 있습니다.
하지만 이후 middleware나 route context에 값을 추가할 수 있으므로 기본 구조는 그대로 유지했습니다.
처음부터 필요하지 않은 추상화를 추가할 필요는 없지만, Fresh가 제공하는 타입 구조까지 걷어낼 이유도 없었습니다.
브라우저는 정상인데 VS Code가 온통 빨간색이었습니다
프로젝트 자체는 잘 실행됐지만 에디터에서 바로 문제가 하나 생겼습니다.
TSX 파일 곳곳에서 다음 오류가 표시됐습니다.
JSX element implicitly has type 'any'
because no interface 'JSX.IntrinsicElements' exists.브라우저에서는 정상적으로 페이지가 나오는데 에디터만 보면 프로젝트 전체가 고장 난 것처럼 보였습니다.
이럴 때는 Fresh나 Preact 설정부터 수정하고 싶어집니다.
하지만 제 경우 원인은 훨씬 단순했습니다.
VS Code에 공식 Deno extension이 설치되어 있지 않았습니다.
Deno extension을 설치하고 프로젝트에서 Deno 지원이 활성화되자 JSX 관련 오류도 사라졌습니다.
Fresh 프로젝트를 처음 열었는데 실행은 잘 되고 에디터에서만 JSX 타입 오류가
쏟아진다면 deno.json이나 Preact 설정을 수정하기 전에 VS Code의 Deno 지원부터
확인해 보는 편이 좋습니다.
Tailwind CSS 4를 연결합니다
블로그 스타일링에는 Tailwind CSS 4를 사용하기로 했습니다.
Deno에서는 npm 패키지도 직접 사용할 수 있기 때문에 다음과 같이 설치할 수 있습니다.
deno add npm:tailwindcss npm:@tailwindcss/viteFresh 2 프로젝트는 이미 Vite를 사용하고 있으므로 @tailwindcss/vite 플러그인을
연결하면 됩니다.
vite.config.ts를 수정합니다.
import { defineConfig } from "vite";
import { fresh } from "@fresh/plugin-vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [
fresh(),
tailwindcss(),
],
});그리고 assets/styles.css에는 Tailwind를 가져옵니다.
@import "tailwindcss";여기까지 설정한 다음 아무 컴포넌트에서나 Tailwind 클래스를 넣어보면 바로 확인할 수 있습니다.
<h1 class="text-4xl font-bold">
Fresh Notes
</h1>;Tailwind CSS 3를 오래 사용했다면 별도의 tailwind.config.js부터 떠올릴 수
있는데, Tailwind CSS 4에서는 설정 방식이 상당히 달라졌습니다.
이번 블로그처럼 단순한 프로젝트라면 CSS와 Vite 플러그인만으로도 시작할 수 있습니다.
daisyUI 5도 추가했습니다
Tailwind만으로 모든 컴포넌트를 직접 만드는 것도 가능하지만 버튼, badge 같은 기본 UI까지 매번 만들 필요는 없었습니다.
그래서 daisyUI도 함께 사용했습니다.
deno add npm:daisyuiTailwind CSS 4 환경에서는 CSS에서 플러그인을 선언할 수 있습니다.
@import "tailwindcss";
@plugin "daisyui";이 상태에서 btn, badge 같은 daisyUI 클래스를 바로 사용할 수 있습니다.
여기까지는 금방 끝났습니다.
문제는 페이지를 새로 열었을 때였습니다.
daisyUI를 넣었더니 갑자기 다크 모드가 됐습니다
제가 만들고 있던 디자인은 밝은 배경이 기본이었는데 daisyUI를 추가한 다음 사이트가 예상과 다르게 어두워졌습니다.
처음에는 기존 CSS가 덮어쓰인 줄 알았습니다.
하지만 확인해 보니 기본 테마 선택과 운영체제의 색상 설정이 영향을 주고 있었습니다.
블로그의 색상은 직접 관리하고 싶었기 때문에 daisyUI 기본 테마를 사용하지 않고 별도의 테마를 정의했습니다.
먼저 기본 테마를 끕니다.
@plugin "daisyui" {
themes: false;
}그리고 블로그용 테마를 추가했습니다.
@plugin "daisyui/theme" {
name: "freshblog";
default: true;
prefersdark: false;
color-scheme: light;
--color-base-100: #f7f7f3;
--color-base-200: #efefe9;
--color-base-300: #e5e5df;
--color-base-content: #171717;
--color-primary: #4f6bff;
--color-primary-content: #ffffff;
--color-secondary: #7a5cff;
--color-secondary-content: #ffffff;
}HTML에는 사용할 테마를 지정합니다.
<html data-theme="freshblog">이렇게 하면 daisyUI의 컴포넌트 시스템은 그대로 사용하면서 색상 체계는 블로그에 맞게 직접 관리할 수 있습니다.
나중에는 별도의 dark theme도 만들어 ThemeToggle Island에서 전환하도록 확장했습니다.
그 과정에서 Fresh의 Islands가 실제로 어떤 역할을 하는지도 자연스럽게 확인할 수 있었습니다.
여기까지 오면 Fresh 2의 구조가 조금씩 보입니다
프로젝트를 만들기 전에는 Fresh를 단순히 Deno용 SSR 프레임워크 정도로 생각하기 쉽습니다.
하지만 직접 구성해보면 각각의 도구가 담당하는 영역이 조금 더 구체적으로 보입니다.
Deno
└─ JavaScript / TypeScript Runtime
Fresh
└─ Routing / SSR / Islands
Preact
└─ UI
Vite
└─ Dev Server / HMR / Build
Tailwind CSS
└─ Styling
daisyUI
└─ UI Components / Theme특히 Vite와 Deno의 관계를 구분하고 나면 Fresh 2 프로젝트 구조가 훨씬 덜 낯설어집니다.
Vite가 들어왔다고 Deno가 밀려난 것도 아니고, node_modules가 있다고 Fresh가
Node.js 프레임워크가 된 것도 아닙니다.
필요한 도구를 각자의 위치에서 사용하는 구조에 가깝습니다.
그런데 Counter Island를 지우자 HMR까지 사라졌습니다
기본 프로젝트를 정리하면서 Counter Island도 삭제했습니다.
블로그 첫 화면에는 당장 브라우저에서 실행해야 할 기능이 없었기 때문입니다.
그런데 CSS를 수정하다가 이상한 점을 발견했습니다.
터미널에는 분명 HMR 로그가 출력됐습니다.
[vite] (client) hmr update /assets/styles.css
[vite] (ssr) hmr update /@id/fresh:server_entry하지만 브라우저에서는 수정한 내용이 자동으로 반영되지 않았습니다.
개발자 도구를 확인해 보니 평소 Vite 개발 환경에서 보이던 client 연결도 없었습니다.
처음에는 Vite 설정을 잘못 건드렸다고 생각했습니다.
그래서 테스트 삼아 삭제했던 Counter Island를 다시 넣었습니다.
그러자 브라우저에서 Vite client가 다시 연결됐고 HMR도 정상적으로 동작했습니다.
이 현상은 Fresh 2.3의 Zero JavaScript 동작을 이해하면서 실마리가 보였습니다.
Fresh는 페이지에 Island나 Partial처럼 클라이언트 동작이 필요한 요소가 없다면 브라우저에 불필요한 JavaScript를 보내지 않는 방향으로 동작합니다.
제가 Counter를 제거한 페이지는 클라이언트에서 실행할 코드가 사실상 없는 페이지가 된 것입니다.
결국 블로그에는 억지로 HMR만을 위한 Island를 넣는 대신 실제 필요한 기능인 ThemeToggle을 Island로 만들었습니다.
서버에서만 필요한 페이지
→ JavaScript 최소화
ThemeToggle처럼 상호작용이 필요한 부분
→ IslandFresh의 설명을 문서에서 읽을 때보다 직접 Island를 없앴다가 다시 추가해 보니 개념이 훨씬 선명하게 느껴졌습니다.
Zero JS와 HMR에서 실제로 어떤 차이가 나타났는지는 별도의 글에서 조금 더 자세히 살펴보겠습니다.
첫 설정을 마치고 나서
Fresh 2로 블로그를 만들면서 가장 먼저 배운 것은 특정 설정 방법보다 각 도구의 역할을 구분하는 것이었습니다.
처음 생성된 프로젝트만 보면 Deno, Fresh, Preact, Vite, npm 패키지가 한꺼번에 등장합니다.
하지만 하나씩 뜯어보면 생각보다 단순합니다.
Deno는 코드를 실행합니다.
Fresh는 서버에서 페이지를 만들고 라우팅합니다.
Preact는 UI를 구성합니다.
Vite는 개발 경험과 빌드를 담당합니다.
그리고 브라우저에서 실제 동작이 필요한 부분만 Island로 내려보낼 수 있습니다.
이 구조를 이해하고 나면 이후 Tailwind를 추가하거나 Markdown 렌더러를 붙이는 작업도 훨씬 수월해집니다.
다음 글에서는 여기서 한 단계 뒤로 물러나 Fresh가 왜 서버 퍼스트 구조와 Islands Architecture를 선택했는지 살펴보겠습니다.