Fresh Notes
Fresh 2.3 Zero JS를 직접 확인해봤다: Island 하나로 무엇이 달라질까? | Fresh Notes

Fresh

Fresh 2.3 Zero JS를 직접 확인해봤다: Island 하나로 무엇이 달라질까?

Fresh 2.3에서 Island가 없는 페이지와 있는 페이지를 비교하며 Zero JS가 실제 HTML과 네트워크 요청에서 어떻게 나타나는지 확인합니다.

2026.10.02·Fresh·7 min read

Fresh를 설명할 때 자주 등장하는 표현이 있습니다.

Zero JavaScript by default.

페이지에 JavaScript가 필요하지 않다면 브라우저에 JavaScript를 보내지 않는다는 뜻입니다.

설명만 놓고 보면 간단합니다.

그런데 Fresh 2로 블로그를 만들다가 이 특징을 예상하지 못한 곳에서 직접 마주쳤습니다.

기본 Counter Island를 삭제했더니 브라우저에서 Vite HMR이 동작하지 않았고, Counter를 다시 넣자 HMR도 다시 살아났습니다.

처음에는 설정을 잘못 건드린 줄 알았습니다.

알고 보니 이 현상을 이해하는 열쇠가 Fresh 2.3의 Zero JS에 있었습니다.

이번에는 설명만 읽지 않고 실제 페이지를 만들어 Island가 없을 때와 있을 때 브라우저로 전달되는 결과가 어떻게 달라지는지 확인해보겠습니다.

Fresh의 Zero JS는 예전부터 있었던 것 아닌가?

Fresh는 처음 등장했을 때부터 클라이언트 JavaScript를 기본적으로 보내지 않는다는 철학을 강조했습니다.

그런데 Fresh 2.3 발표 내용을 보면 재미있는 설명이 하나 나옵니다.

Fresh 2.2까지는 페이지에 Island가 하나도 없어도 Island와 Partial 시스템을 시작하기 위한 작은 client-entry 스크립트가 포함됐습니다.

즉 개념적으로는 Zero JS를 지향했지만 실제 응답에는 약간의 JavaScript가 남아 있었습니다.

Fresh 2.3에서는 이 부분까지 제거됐습니다.

페이지에 다음 두 가지가 없다면 클라이언트 런타임을 삽입하지 않습니다.

Island
f-client-nav를 사용하는 Partial navigation

이 조건을 만족하면 Fresh 2.3은 다음 항목도 만들지 않습니다.

client-side JavaScript bundle
<script> boot code
module preload

공식 발표에서 말하는 true zero-JS page가 이 상태입니다.

말로만 보면 차이가 작아 보이니 직접 확인해보겠습니다.

개발 서버가 아니라 production 환경에서 확인합니다

처음 이 현상을 발견한 것은 개발 서버에서였습니다.

하지만 Zero JS 자체를 확인하려면 개발 환경보다 production build를 사용하는 편이 정확합니다.

개발 서버에는 Vite의 HMR 같은 개발 전용 기능이 개입하기 때문입니다.

먼저 프로젝트를 빌드합니다.

deno task build

빌드가 끝났다면 Fresh 서버를 실행합니다.

deno task start

이제 브라우저에서 로컬 사이트를 열고 개발자 도구의 Network 패널을 확인할 준비를 합니다.

우선 Island를 모두 제거해봅니다

테스트를 위해 페이지에서 브라우저 JavaScript가 필요한 컴포넌트를 모두 제거합니다.

예를 들어 현재 블로그 Header에 다음과 같은 코드가 있다면 잠시 빼둡니다.

<ThemeToggle />;

ThemeToggle이 islands/ThemeToggle.tsx에 있다면 이것 하나만으로도 해당 페이지는 Zero JS 페이지가 아닙니다.

테스트 페이지는 최대한 단순하게 만들 수 있습니다.

export default function ZeroJsTest() {
  return (
    <main>
      <h1>Zero JS Test</h1>

      <p>
        이 페이지에는 Island가 없습니다.
      </p>
    </main>
  );
}

중요한 것은 페이지 안에 Island가 없어야 한다는 점입니다.

또 f-client-nav를 사용하고 있다면 그것도 제거해야 합니다.

Network에서 JavaScript 요청을 확인합니다

Chrome이나 다른 Chromium 기반 브라우저라면 개발자 도구를 열고 Network 탭으로 이동합니다.

필터에서 JS를 선택한 뒤 페이지를 새로고침합니다.

Island나 Partial navigation을 사용하지 않은 Fresh 2.3 페이지라면 애플리케이션 동작을 위한 클라이언트 JavaScript가 없어야 합니다.

개발자 도구에서 확인하려는 것은 단순합니다.

페이지 HTML은 정상적으로 표시됨

하지만

애플리케이션용 JavaScript 요청은 없음

글 제목도 보이고 CSS도 적용되고 링크도 있습니다.

JavaScript만 없습니다.

블로그처럼 대부분의 화면이 읽기 위한 콘텐츠라면 이 상태만으로도 페이지 자체는 충분히 동작할 수 있습니다.

HTML도 확인해봅니다

Network만 보지 말고 실제 응답 HTML도 살펴보겠습니다.

브라우저의 View Page Source를 사용하거나 Network에서 HTML document의 Response를 확인할 수 있습니다.

Island와 Partial navigation이 없다면 Fresh가 클라이언트 동작을 시작하기 위해 삽입하는 script가 보이지 않아야 합니다.

여기서 중요한 것은 단순히 JavaScript 파일 크기가 작다는 것이 아닙니다.

작은 JavaScript를 전송한다

가 아니라

필요하지 않으니 JavaScript 자체를 전송하지 않는다

라는 차이입니다.

이제 Island 하나를 추가합니다

이번에는 아주 작은 Counter를 만들어보겠습니다.

islands/ZeroJsCounter.tsx를 만듭니다.

import { useSignal } from "@preact/signals";

export default function ZeroJsCounter() {
  const count = useSignal(0);

  return (
    <button
      type="button"
      onClick={() => count.value++}
    >
      Count: {count}
    </button>
  );
}

그리고 방금 만든 테스트 페이지에서 불러옵니다.

import ZeroJsCounter from "../islands/ZeroJsCounter.tsx";

export default function ZeroJsTest() {
  return (
    <main>
      <h1>Zero JS Test</h1>

      <p>
        이번에는 Island가 하나 있습니다.
      </p>

      <ZeroJsCounter />
    </main>
  );
}

다시 production build를 만듭니다.

deno task build

그리고 서버를 다시 실행합니다.

deno task start

페이지를 새로고침하면 눈으로 보이는 차이는 버튼 하나뿐입니다.

하지만 브라우저 입장에서 페이지의 성격은 달라졌습니다.

이제 JavaScript가 필요합니다

Counter는 클릭할 때 값이 바뀌어야 합니다.

서버가 HTML을 한 번 만들어주는 것만으로는 클릭 이벤트를 처리할 수 없습니다.

따라서 Fresh는 Counter가 브라우저에서 동작하는 데 필요한 JavaScript를 전달해야 합니다.

Network의 JS 필터를 다시 확인하면 이전과 달리 JavaScript 요청을 확인할 수 있습니다.

구조를 단순화하면 이렇습니다.

Island 없음

Server
  ↓
HTML
CSS
  ↓
Browser

JavaScript 필요 없음

Island가 생기면 다음과 같이 달라집니다.

Island 있음

Server
  ↓
HTML
CSS
Island용 JavaScript
  ↓
Browser
  ↓
Island hydration

페이지 전체가 클라이언트 애플리케이션으로 바뀐 것이 아닙니다.

JavaScript가 필요한 Counter 부분만 브라우저에서 활성화됩니다.

두 페이지를 비교하면 차이가 더 명확합니다

정리하면 다음과 같습니다.

항목 Island 없음 Island 있음
서버 HTML 렌더링 O O
페이지 표시 O O
클라이언트 JavaScript 필요 X O
hydration X Island만
사용자 인터랙션 정적인 기능만 가능

여기서 Fresh의 특징이 잘 드러납니다.

JavaScript를 사용하는 페이지와 사용하지 않는 페이지를 서로 다른 종류의 애플리케이션으로 만들 필요가 없습니다.

같은 Fresh 프로젝트 안에서 컴포넌트 단위로 경계를 나눌 수 있습니다.

이걸 블로그에서 실제로 경험했습니다

처음부터 Zero JS를 테스트하려고 Counter를 제거했던 것은 아닙니다.

Fresh가 기본으로 만들어준 예제 코드를 정리하고 있었을 뿐입니다.

Counter가 블로그에 필요하지 않았기 때문에 삭제했습니다.

그리고 어느 순간 CSS를 수정했는데 브라우저가 자동으로 갱신되지 않는다는 것을 발견했습니다.

터미널에서는 분명 다음과 같은 로그가 출력됐습니다.

[vite] (client) hmr update /assets/styles.css
[vite] (ssr) hmr update /@id/fresh:server_entry

그런데 브라우저 개발자 도구에는 평소 보던 Vite client 연결이 없었습니다.

혹시 Counter와 관련이 있는지 확인하기 위해 삭제했던 Counter Island를 다시 추가했습니다.

그러자 브라우저에 Vite client가 다시 연결됐고 HMR도 다시 동작했습니다.

이 테스트는 현재 제가 사용한 Fresh 2.3 + Vite 개발 환경에서 확인한 동작입니다.

Fresh나 Vite 통합 구현이 바뀌면 개발 환경의 HMR 동작 역시 달라질 수 있으므로, Fresh의 Zero JS 자체와 개발 서버의 HMR 동작은 구분해서 보는 것이 좋습니다.

Zero JS 여부를 정확하게 확인하려면 앞에서 했던 것처럼 production build의 실제 응답을 확인하는 편이 안전합니다.

HMR 때문에 의미 없는 Island를 만들 필요는 없습니다

처음에는 이런 생각도 할 수 있습니다.

Island가 있어야 개발할 때 HMR이 편하다면
아무 Island나 하나 남겨둘까?

하지만 그렇게 할 이유는 없습니다.

실제 블로그에는 이미 브라우저 JavaScript가 필요한 기능이 있었습니다.

다크 모드와 라이트 모드를 바꾸는 ThemeToggle입니다.

<ThemeToggle />;

ThemeToggle은 사용자의 클릭을 처리하고 localStorage에 선택한 테마를 저장합니다.

브라우저에서 실행되어야 하는 기능이므로 Island가 되는 것이 자연스럽습니다.

현재 블로그의 경계는 대략 이렇게 나눌 수 있습니다.

Header
├── Logo             Server
├── Navigation       Server
└── ThemeToggle      Island

Article
├── Title            Server
├── Metadata         Server
├── Markdown         Server
└── Tags             Server

Footer                Server

JavaScript를 억지로 없애는 것도 아니고, 필요하지 않은 부분까지 JavaScript로 만드는 것도 아닙니다.

Zero JS의 핵심은 숫자 0 자체가 아닙니다

처음에는 0 KB JavaScript라는 숫자가 Fresh의 가장 재미있는 특징처럼 보였습니다.

하지만 직접 사용해보니 더 중요한 것은 기본값의 방향이었습니다.

Fresh에서는 먼저 서버에서 HTML을 만들고 시작합니다.

그다음 브라우저 인터랙션이 필요한 부분이 생겼을 때 Island를 추가합니다.

처음부터 모든 것을 Client Component로 만든다

가 아니라

HTML로 충분한가?
        ↓
       Yes
        ↓
서버에서 렌더링

        ↓ No

브라우저 동작이 필요한 부분만 Island

결과적으로 Zero JS는 목표라기보다 이 구조에서 자연스럽게 나오는 결과에 가깝습니다.

Island가 필요 없다면 JavaScript도 필요 없습니다.

Island가 필요하면 그때 필요한 만큼 추가합니다.

Partial을 사용한다면 이야기가 달라집니다

한 가지 주의할 점도 있습니다.

페이지에 Island가 없다고 항상 Zero JS가 되는 것은 아닙니다.

Fresh의 Partial navigation을 위해 f-client-nav를 사용한다면 브라우저에서 페이지 전환을 처리해야 하므로 클라이언트 JavaScript가 필요합니다.

따라서 Fresh 2.3의 Zero JS 조건을 확인할 때는 다음처럼 생각하는 편이 정확합니다.

Island 없음
+
f-client-nav 없음
=
클라이언트 JavaScript가 필요 없는 페이지

단순히 islands/ 디렉터리가 비어 있는지만 확인해서는 안 됩니다.

개발자 도구에서 직접 확인해보는 것이 가장 빠릅니다

Fresh의 Islands Architecture를 설명만 읽으면 약간 추상적으로 느껴질 수 있습니다.

하지만 테스트 방법은 의외로 단순합니다.

Island를 빼고 Network를 봅니다.

Island 하나를 넣고 다시 Network를 봅니다.

그 두 화면을 비교하면 Fresh가 말하는 클라이언트 JavaScript의 경계가 바로 보입니다.

이번 블로그를 만들면서 Counter를 삭제한 것이 우연히 좋은 실험이 됐습니다.

덕분에 "Fresh는 필요한 JavaScript만 보낸다"라는 문장을 설정 철학이 아니라 실제 브라우저 동작으로 확인할 수 있었습니다.

다음 글에서는 여기서 시선을 조금 바꿔보겠습니다.

Fresh 프로젝트에서 계속 등장하는 Vite가 정확히 어디까지 담당하고, Deno와는 어떤 경계로 나뉘는지 실제 deno.json과 빌드 과정을 중심으로 살펴보겠습니다.

참고 자료

#Fresh#Zero JS#Islands#Vite#Performance