Fresh Notes
Fresh Island에서 서버 코드를 import하면 어떻게 될까? | Fresh Notes

Fresh

Fresh Island에서 서버 코드를 import하면 어떻게 될까?

Island에서 Markdown 로더를 직접 가져오면 빌드와 브라우저에서 어떤 일이 생기는지 확인합니다. 서버 핸들러에서 읽은 데이터를 props로 전달하고, import type을 추가했을 때의 번들도 비교합니다.

2026.11.09·Fresh·5 min read

버튼을 눌렀을 때 글을 읽으면 될까요?

15편의 글 저장 버튼은 서버에서 받은 slug로 브라우저의 저장 키를 만들었습니다. 이번에는 같은 글에 제목과 설명을 펼쳐 보는 버튼을 추가한다고 가정해 보겠습니다.

5편에서 만든 Markdown 로더에 이미 getPost(slug)가 있으니, 버튼에서 이 함수를 부르면 편할 것 같습니다.

실제로 그렇게 빌드해 보니 빌드는 성공했고 서버도 200을 반환했지만, 브라우저에서 버튼이 동작하지 않았습니다. 클라이언트에 생성된 Island 파일에는 YAML 파서와 서버용 모듈 참조까지 들어 있었습니다.

이번 실험은 Fresh 2.3.3, @fresh/plugin-vite 1.1.2, Vite 7.3.6, Deno 2.8.3을 사용하는 프로젝트의 임시 복사본에서 진행했습니다. 서버가 만든 HTML과 빌드 자산을 오프라인 브라우저에 제공해 확인했습니다. 광고 스크립트는 요청하거나 실행하지 않았습니다.

Island에서 기존 로더를 직접 가져옵니다

먼저 islands/PostInfo.tsx에 다음 코드를 넣었습니다.

import { useState } from "preact/hooks";
import { getPost } from "../utils/posts.ts";

export default function PostInfo({ slug }: { slug: string }) {
  const [details, setDetails] = useState("");

  async function showInfo() {
    try {
      const post = await getPost(slug);
      setDetails(post?.meta.title ?? "글 없음");
    } catch (error) {
      console.error(error);
      setDetails("글 정보를 읽지 못했습니다.");
    }
  }

  return (
    <section>
      <button type="button" onClick={showInfo}>글 정보 보기</button>
      <p id="post-info-result">{details}</p>
    </section>
  );
}

테스트용 라우트 routes/import-demo.tsx에서는 기존 글의 slug를 넘겼습니다.

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

export default function ImportDemo() {
  return (
    <main>
      <PostInfo slug="fresh-markdown-blog" />
    </main>
  );
}

서버는 버튼을 HTML로 그릴 수 있었습니다. 클릭 핸들러 안의 파일 읽기는 SSR 중에 실행되지 않기 때문입니다. 그런데 클라이언트는 버튼을 연결하기 위해 Island 모듈과 그 의존성을 불러와야 합니다. Fresh의 Island는 서버에서 렌더링되면서 브라우저에서도 실행됩니다. Fresh Islands 문서

4편에서 확인한 서버·클라이언트 빌드 분리는 프로젝트에 두 실행 환경이 있다는 뜻입니다. 서버 로더를 브라우저 쪽 진입점에서 직접 가져오면, 그 import가 어느 환경에서 필요한지 따로 살펴봐야 합니다.

빌드 성공 뒤에 브라우저 모듈 로딩이 실패했습니다

프로덕션 빌드의 _fresh/client/assets/에서 fresh-island__PostInfo-*.js를 확인했습니다. 이 프로젝트의 직접 import 버전에는 다음 내용이 남았습니다.

브라우저 콘솔에는 node:fs 등을 불러올 수 없다는 오류가 표시됐습니다. 화면에는 서버가 보낸 버튼이 보였지만, 클릭해도 결과가 표시되지 않았습니다.

콘솔에 CORS라는 단어가 포함되어 있었지만, 여기서 서버에 CORS 헤더를 추가할 문제는 아니었습니다. 브라우저 모듈이 웹에서 로드할 수 없는 node: 경로를 참조한 것이 먼저였습니다.

PostInfo Island
  → getPost()
    → 파일 읽기, Front Matter 파싱, Markdown 렌더링
      → 서버 런타임에 기대는 의존성

제목 한 줄을 표시하려고 가져온 함수가 실제로 어떤 작업을 하는지 확인해야 하는 이유입니다. 같은 함수 import를 components/의 중간 컴포넌트로 옮겨도, Island에서 그 컴포넌트에 도달하는 경로가 남아 있으면 문제를 해결한 것이 아닙니다.

파일 읽기 하나만 남겨도 클릭에서 실패합니다

큰 로더의 의존성 문제와 파일 읽기 문제를 구분하기 위해, 별도 빌드에서는 다음 함수만 가져왔습니다.

// utils/read-post.server.ts
export function readPostSource(slug: string) {
  return Deno.readTextFile(`./content/posts/${slug}.md`);
}

앞선 Island에서 getPost import를 readPostSource로 바꾸고, 클릭 시 다음처럼 파일 길이를 표시하도록 했습니다. 나머지 오류 처리와 버튼은 그대로 두었습니다.

import { readPostSource } from "../utils/read-post.server.ts";

// showInfo()의 try 블록
const source = await readPostSource(slug);
setDetails(String(source.length));

이번에는 Island가 로드되고 클릭 핸들러도 실행됐습니다. 파일을 읽는 줄에서 다음 오류가 발생했습니다.

ReferenceError: Deno is not defined

이 실험에서 .server.ts라는 이름은 의도를 표시했지만, 사용 중인 설정에서는 클라이언트 빌드를 막지 않았습니다. 파일 이름을 바꾸는 것과 브라우저에서 실행할 수 없는 의존성을 제거하는 것은 별개의 작업입니다.

파일은 서버에서 읽고, 버튼에는 표시할 값만 전달합니다

이 버튼은 글 내용을 새로 가져올 필요가 없습니다. 이미 서버가 알고 있는 제목과 설명을 펼쳐 보여주면 됩니다.

routes/import-demo.tsx를 다음처럼 바꿨습니다.

import { HttpError } from "fresh";
import { define } from "../utils.ts";
import { getPost } from "../utils/posts.ts";
import PostInfo from "../islands/PostInfo.tsx";

export const handler = define.handlers({
  async GET() {
    const post = await getPost("fresh-markdown-blog");
    if (!post) throw new HttpError(404);

    return {
      data: {
        title: post.meta.title,
        description: post.meta.description,
      },
    };
  },
});

export default define.page<typeof handler>(
  function ImportDemo({ data }) {
    return (
      <main>
        <PostInfo title={data.title} description={data.description} />
      </main>
    );
  },
);

Island에는 이제 파일 경로나 로더가 필요 없습니다.

import { useState } from "preact/hooks";

interface Props {
  title: string;
  description: string;
}

export default function PostInfo({ title, description }: Props) {
  const [open, setOpen] = useState(false);

  return (
    <section>
      <button
        type="button"
        aria-expanded={open}
        onClick={() => setOpen(!open)}
      >
        {open ? "글 정보 닫기" : "글 정보 보기"}
      </button>
      {open && (
        <div id="post-info-result">
          <strong>{title}</strong>
          <p>{description}</p>
        </div>
      )}
    </section>
  );
}

실제 글 상세 페이지라면 이미 읽어 둔 data.meta에서 두 값을 전달하면 됩니다. 이 버튼을 위해 getPost()를 한 번 더 호출할 필요가 없습니다.

제목과 설명은 패널을 열기 전에도 브라우저에 전달할 수 있는 공개 데이터입니다. 화면이 닫혀 있다고 props까지 숨겨지는 것은 아닙니다. 14편에서 살펴본 공개 데이터의 경계와 마찬가지로, 내부 경로나 비밀값은 전달하지 않습니다.

import type을 추가하면 로더도 따라올까요?

이번에는 Island의 Props 선언만 다음 코드로 교체했습니다.

import type { PostMeta } from "../utils/posts.ts";

type Props = Pick<PostMeta, "title" | "description">;

import type은 타입 검사에 사용되고 JavaScript 출력에서 제거됩니다. PostMeta를 참조해도, 이 선언 때문에 getPost()를 실행하거나 로더를 클라이언트로 가져오지는 않습니다. TypeScript의 타입 전용 import 문서

다시 빌드한 결과, 로컬 Props 인터페이스를 쓴 버전과 import type을 쓴 버전의 PostInfo 청크가 바이트 단위로 같았습니다. 두 버전 모두 브라우저에서 글 정보를 열고 닫을 수 있었습니다.

Island가 가져온 것 서버 응답 브라우저 결과 PostInfo JS 파일 크기
실제 getPost() 함수 200 node: 모듈 로딩 실패, 버튼 미동작 1,046,625 B
Deno.readTextFile()만 쓰는 함수 200 클릭에서 Deno is not defined 499 B
공개 데이터 props 200 열기·닫기 정상 414 B
공개 데이터 props + import type 200 열기·닫기 정상 414 B

크기는 이번 빌드에서 생성된 Island 파일 하나의 압축 전 크기입니다. 페이지 전체 JavaScript 크기나 실제 전송량은 아닙니다. 비교의 핵심은 작은 함수 하나도 실행 위치가 틀리면 실패하고, 큰 로더를 가져오면 의존성까지 포함될 수 있다는 점입니다.

빌드와 브라우저를 각각 확인합니다

비슷한 문제를 만나면 다음 순서로 좁힐 수 있습니다.

  1. 서버 응답을 확인합니다. 버튼이 HTML에 보인다는 것은 SSR 결과를 확인한 것입니다.
  2. 클라이언트 산출물을 확인합니다. _fresh/client/에서 파일 읽기 호출이나 서버 모듈 참조가 남았는지 살펴봅니다.
  3. 실제 이벤트를 실행합니다. 모듈 로딩에서 실패하는지, 클릭할 때 실패하는지 구분합니다.
  4. 데이터를 준비할 위치를 결정합니다. 처음부터 필요한 공개 값은 서버에서 읽고 props로 넘깁니다. 클릭할 때 새로 읽어야 하는 데이터라면 브라우저는 서버 엔드포인트에 요청하고, 파일 읽기는 서버가 맡도록 구성합니다.

이 예제에서는 제목과 설명만 필요했으므로 props로 충분했습니다. 데이터는 서버가 준비하고, Island는 그 데이터를 언제 보여줄지만 결정합니다.

#Fresh#Vite#Islands#TypeScript#Bundling