검색 결과 화면도 Preact로 만들었습니다
앞선 글에서는 Markdown 메타데이터를 검색하고 결과를 서버에서 HTML로 반환했습니다.
검색창에 별도의 클라이언트 상태를 만들지 않았고, 검색을 위한 Island도 추가하지 않았습니다. 그렇다고 그 화면에서 Preact를 사용하지 않은 것은 아닙니다.
검색 결과를 출력했던 JSX도 Preact 컴포넌트입니다.
<p>
“{query}” 검색 결과 {results.length}개
</p>Fresh에서 Preact를 이해할 때는 이 부분부터 구분하는 편이 좋습니다.
Preact를 사용한다는 것과 해당 컴포넌트가 브라우저에서 실행된다는 것은 별개의 문제입니다.
이번 글에서는 Preact의 기본 문법을 살펴보고, 글 주소 복사 버튼을 예제로 상태와 이벤트를 연결해보겠습니다. React 코드를 가져올 때 확인해야 할 차이도 함께 정리합니다.
이 글은 Fresh 2의 Vite 프로젝트를 기준으로 설명합니다. 2026년 10월 8일 공식 릴리스 페이지에서 확인한 최신 안정 릴리스는 Fresh 2.3.3과 Preact 11.0.0입니다. 다만 아래 예제와 검증은 Fresh 공식 Vite 구성 문서에 제시된 Preact 10 계열을 기준으로 했습니다. 최신 Preact 릴리스가 나왔다고 기존 프로젝트의 의존성이 자동으로 변경되지는 않습니다. 실제 적용 버전은
deno.json과deno.lock에서 확인해야 합니다.
Preact 컴포넌트는 props를 받아 화면을 반환합니다
Fresh Notes에서 글 카드를 만든다고 해보겠습니다.
제목, 설명, 링크를 받아 출력하는 컴포넌트입니다.
components/PostCard.tsx:
interface Props {
title: string;
description: string;
href: string;
}
export default function PostCard(
{ title, description, href }: Props,
) {
return (
<article class="rounded-xl border p-6">
<h2 class="text-xl font-bold">
<a href={href}>
{title}
</a>
</h2>
<p class="mt-3">
{description}
</p>
</article>
);
}사용하는 쪽에서는 필요한 데이터를 전달합니다.
<PostCard
title={post.meta.title}
description={post.meta.description}
href={`/posts/${post.slug}`}
/>React 경험이 있다면 익숙한 형태입니다.
함수가 props를 받고 JSX를 반환합니다. 조건부 렌더링이나 배열의 map()을 사용하는 방식도 비슷합니다. Preact 공식 문서에서도 함수 컴포넌트를 props를 첫 번째 인자로 받는 일반 함수로 설명합니다. Preact Components
이 카드에는 브라우저 이벤트가 없습니다. Fresh가 서버에서 HTML로 렌더링하면 됩니다.
하지만 같은 컴포넌트를 Island 내부에서 사용하면 브라우저 렌더링에도 참여할 수 있습니다. 컴포넌트의 문법만 보고 실행 위치를 결정할 수는 없습니다.
components 폴더가 서버 실행을 보장하지는 않습니다
앞선 글에서는 정적인 UI를 components/, 대화형 UI를 islands/로 나눴습니다.
실무에서는 유용한 정리 방식이지만, 한 가지를 더 알아두어야 합니다.
components/에 있는 파일도 Island가 가져오면 클라이언트 코드에 포함될 수 있습니다.
예를 들어 버튼의 공통 모양을 별도 컴포넌트로 만들 수 있습니다.
components/ActionButton.tsx:
import type { JSX } from "preact";
export default function ActionButton(
props: JSX.ButtonHTMLAttributes<HTMLButtonElement>,
) {
return (
<button
type="button"
class="btn btn-outline"
{...props}
/>
);
}이 컴포넌트는 버튼의 기본 스타일과 속성 전달을 담당합니다.
children, disabled, onClick 같은 버튼 속성을 그대로 받을 수 있습니다. {...props}를 마지막에 두었으므로 사용하는 쪽에서 기본 type이나 class를 덮어쓸 수도 있습니다.
이 버튼을 일반 서버 컴포넌트에서 사용하면 서버 렌더링에 사용됩니다. Island에서 가져오면 해당 Island의 브라우저 동작에도 사용됩니다.
서버 페이지
└── ActionButton
└── 서버에서 HTML 생성
Island
└── ActionButton
└── 서버 렌더링 + 브라우저 동작에 참여클라이언트에 포함되는 범위를 판단할 때는 폴더 이름뿐 아니라 Island가 무엇을 import하는지 확인해야 합니다. Fresh의 Vite 플러그인은 이 의존 관계를 바탕으로 클라이언트와 서버 번들을 구성합니다. Fresh Vite Plugin
따라서 공통 버튼 같은 컴포넌트에서 Markdown 파일을 읽는 서버 유틸리티까지 가져오지 않도록 역할을 나누는 것이 좋습니다.
글 주소 복사 버튼에 useState를 붙입니다
이제 실제로 브라우저 동작이 필요한 기능을 만들어보겠습니다.
글 상세 페이지에서 주소를 복사하고, 성공 여부를 화면에 표시하는 버튼입니다.
필요한 상태는 네 가지입니다.
| 상태 | 의미 |
|---|---|
idle |
아직 복사하지 않음 |
copying |
복사 요청 처리 중 |
copied |
복사 성공 |
error |
복사 실패 |
islands/CopyLink.tsx를 만듭니다.
import { useState } from "preact/hooks";
import ActionButton from "../components/ActionButton.tsx";
type Status = "idle" | "copying" | "copied" | "error";
interface Props {
url: string;
}
export default function CopyLink({ url }: Props) {
const [status, setStatus] = useState<Status>("idle");
async function copyLink() {
setStatus("copying");
try {
await navigator.clipboard.writeText(url);
setStatus("copied");
} catch {
setStatus("error");
}
}
return (
<div>
<ActionButton
onClick={copyLink}
disabled={status === "copying"}
>
{status === "copying"
? "복사 중…"
: "글 주소 복사"}
</ActionButton>
<p role="status">
{status === "copied" &&
"주소를 복사했습니다."}
{status === "error" &&
"복사하지 못했습니다. 주소창에서 직접 복사해 주세요."}
</p>
</div>
);
}Preact의 기본 Hooks는 preact/hooks에서 가져옵니다.
import { useState } from "preact/hooks";useState()는 현재 값과 그 값을 변경하는 함수를 반환합니다.
const [status, setStatus] = useState<Status>("idle");상태를 바꾸면 Preact가 변경된 상태를 화면에 반영합니다. 현재 상태를 기준으로 다음 값을 계산할 때는 함수형 업데이트도 사용할 수 있습니다. React의 Hooks를 사용해봤다면 익숙한 방식입니다. Preact Hooks
이번 예제에서는 복사 중에 버튼을 비활성화하고, 결과를 role="status" 영역에 표시합니다. 타이머로 메시지를 지우는 기능은 추가하지 않았습니다. 다음 복사를 시작하면 상태가 다시 바뀝니다.
글 상세 페이지에서는 앞선 SEO 구현에서 만든 canonical URL을 전달하면 됩니다.
<CopyLink url={canonical} />현재 주소창의 query string까지 복사할지, 글의 대표 URL을 복사할지는 서비스 정책에 따라 선택하면 됩니다. 여기서는 대표 URL을 전달하는 구조로 만들었습니다.
브라우저 API는 클릭했을 때 실행합니다
복사 버튼에서 중요한 부분은 navigator.clipboard의 위치입니다.
async function copyLink() {
await navigator.clipboard.writeText(url);
}이 함수는 버튼을 클릭했을 때 실행됩니다.
Island가 서버에서 렌더링될 때는 함수가 정의될 뿐, 복사를 수행하지 않습니다. 서버는 우선 버튼 HTML을 만들고, 브라우저에서 Island가 활성화된 뒤 클릭 이벤트를 처리합니다.
반면 브라우저 API를 컴포넌트 본문에서 바로 실행하면 서버 렌더링에도 영향을 줍니다.
예를 들어 다음 코드는 문제가 됩니다.
export default function ThemeName() {
const theme =
document.documentElement.dataset.theme;
return <p>{theme}</p>;
}컴포넌트가 서버에서 실행될 때도 document를 읽으려고 하기 때문입니다.
이 예제를 Deno에서 Preact 서버 렌더러로 실행하면 다음 오류가 발생합니다.
document is not definedislands/에 넣었다고 컴포넌트 전체가 브라우저에서만 실행되는 것은 아닙니다. Fresh의 Island는 서버에서도 렌더링됩니다. Fresh Islands
브라우저 API를 사용할 때는 실행 시점을 구분해야 합니다.
| 작업 | 실행 위치 |
|---|---|
| 클릭 후 클립보드에 복사 | 이벤트 핸들러 |
| 브라우저에서 DOM을 읽거나 이벤트 구독 | useEffect 등 |
| 첫 응답에 필요한 글 데이터 조회 | 서버의 route 또는 데이터 처리 코드 |
클립보드 복사에는 브라우저 조건도 있습니다. Async Clipboard API는 secure context에서 제공되며, 권한이나 사용자 동작에 관한 제한을 받을 수 있습니다. 복사 요청은 실패할 수 있으므로 try/catch로 결과를 처리했습니다. Clipboard API 명세
실제 배포 환경에서는 HTTPS에서 복사 버튼을 누르고, 다른 입력창에 붙여넣어 전달한 URL과 일치하는지 확인해야 합니다.
useEffect는 브라우저와 연결할 때 사용합니다
클릭에 반응하는 작업은 이벤트 핸들러로 처리할 수 있습니다.
반면 컴포넌트가 브라우저에 연결된 뒤 DOM 정보를 읽거나 이벤트를 구독해야 한다면 useEffect를 사용할 수 있습니다.
다음은 초기 테마 이름을 한 번 읽는 최소 예제입니다.
import { useEffect, useState } from "preact/hooks";
export default function ThemeName() {
const [theme, setTheme] = useState("unknown");
useEffect(() => {
const current =
document.documentElement.dataset.theme;
setTheme(current ?? "unknown");
}, []);
return <p>초기 테마: {theme}</p>;
}서버에서는 unknown을 출력합니다. 브라우저에서 effect가 실행되면 DOM의 테마 이름으로 상태를 변경합니다.
이 예제는 초기 값을 읽는 용도입니다. 이후 ThemeToggle이 DOM 속성을 바꾸더라도 자동으로 추적하지는 않습니다. 버튼과 표시 영역이 계속 같은 값을 사용해야 한다면 상태를 공유하는 별도 설계가 필요합니다.
Preact의 useEffect는 외부 시스템과의 연결이나 정리에 사용할 수 있고, 필요하면 cleanup 함수를 반환합니다. Preact Hooks: useEffect
앞선 검색 페이지의 초기 데이터를 여기로 옮길 필요는 없습니다.
서버에서 이미 검색 결과를 알고 있다면 첫 HTML에 넣으면 됩니다. 브라우저에서 effect가 실행된 다음 다시 검색 결과를 가져오도록 바꾸면 요청과 로딩 상태를 추가로 관리해야 합니다.
예제에서 실제로 확인한 범위
복사 버튼 예제는 Preact 10 계열에서 타입 검사와 서버 렌더링을 확인했습니다.
서버 렌더링 결과는 다음과 같습니다.
<div>
<button type="button" class="btn btn-outline">
글 주소 복사
</button>
<p role="status"></p>
</div>확인한 것은 다음 세 가지입니다.
- 브라우저의 클립보드에 접근하지 않고 버튼 HTML을 생성합니다.
onClick함수가 HTML 속성 문자열로 출력되지 않습니다.useEffect내부의 코드는 서버 렌더링 중 실행되지 않습니다.
컴포넌트 본문에서 document를 읽는 예제에서는 앞서 본 오류도 확인했습니다.
이 검증은 Preact의 서버 렌더링 동작을 확인한 것입니다. Fresh가 생성하는 전체 응답이나 브라우저에서의 복사 성공까지 검증한 것은 아닙니다.
프로젝트에 연결한 뒤에는 별도로 다음을 확인하면 됩니다.
글 페이지 첫 응답
→ 버튼이 표시되는가?
Island 활성화 후 클릭
→ 전달한 URL이 복사되는가?
복사 실패
→ 실패 안내가 표시되는가?서버에서 HTML이 만들어진다는 것과 브라우저 기능이 성공한다는 것은 서로 다른 검증 항목입니다.
Signals는 useState와 다른 상태 표현 방식입니다
3편의 Counter에서는 다음 코드를 사용했습니다.
const count = useSignal(0);이번 복사 버튼에서는 useState()를 사용했습니다.
Fresh에서 둘 중 하나만 사용할 수 있는 것은 아닙니다. Preact Hooks와 @preact/signals를 함께 사용할 수 있습니다.
차이는 값을 읽고 변경하는 방식에서 먼저 드러납니다.
import { useSignal } from "@preact/signals";
export default function CopyStatusExample() {
const message = useSignal("아직 복사하지 않았습니다.");
return (
<div>
<button
type="button"
onClick={() => {
message.value = "상태를 변경했습니다.";
}}
>
상태 변경
</button>
<p>{message}</p>
</div>
);
}Signal은 .value에 현재 값을 보관합니다.
message.value = "상태를 변경했습니다.";Preact에서는 Signal을 JSX의 텍스트 위치에 직접 전달할 수도 있습니다.
<p>{message}</p>컴포넌트 렌더링 중 .value를 읽는 방식도 지원합니다.
<p>{message.value}</p>다만 두 표현의 내부 업데이트 경로까지 항상 같다고 생각하면 안 됩니다. Signal을 텍스트 위치에 직접 전달하는 방식에는 Preact의 렌더링 최적화가 적용될 수 있습니다. Preact Signals
단순한 지역 상태를 표현하는 데 useState를 사용해도 충분합니다. Signal을 사용하면 상태를 객체로 전달하거나 여러 값 사이의 반응 관계를 구성하는 다른 선택지가 생깁니다.
어느 쪽이든 상태를 어디에서 만들고 누가 변경하는지는 분명해야 합니다.
사용자별 상태를 모듈 전역에 만들지 않습니다
Signal 예제를 찾아보면 다음과 같은 코드를 볼 수 있습니다.
import { signal } from "@preact/signals";
export const selectedTheme = signal("light");브라우저만 실행하는 작은 예제에서는 의도적인 공유 상태일 수 있습니다.
하지만 Fresh에서는 같은 모듈이 서버에서도 사용될 수 있습니다. 서버 프로세스의 모듈 전역 상태는 여러 요청이 공유할 수 있으므로, 여기에 사용자별 값을 저장하면 요청 사이에 상태가 섞일 수 있습니다.
컴포넌트 자체의 상태라면 내부에서 useSignal()을 사용하는 편이 명확합니다.
const selectedTheme = useSignal("light");서로 떨어진 Island가 값을 공유해야 한다면 요청별 상위 컴포넌트에서 Signal을 만들고 props로 전달하는 방식도 사용할 수 있습니다. Fresh 공식 예제도 이 방식과 함께 모듈 전역 상태에 대한 주의를 설명합니다. Fresh: Sharing state between islands
useSignal()을 각각 호출했다고 상태가 자동으로 공유되는 것은 아닙니다. 각 컴포넌트의 지역 상태와 여러 Island가 함께 사용하는 상태를 구분해야 합니다.
입력 이벤트는 React와 차이가 있습니다
Preact 문법이 React와 비슷하더라도 모든 동작이 같지는 않습니다.
직접 작성한 Preact 입력 컴포넌트에서 먼저 확인할 부분은 onInput입니다.
import { useState } from "preact/hooks";
export default function QueryPreview() {
const [query, setQuery] = useState("");
return (
<div>
<label>
검색어
<input
type="search"
value={query}
onInput={(event) => {
setQuery(event.currentTarget.value);
}}
/>
</label>
<p>입력한 검색어: {query}</p>
</div>
);
}텍스트를 입력하는 동안 상태를 갱신하려면 Preact core에서는 onInput을 사용합니다.
React의 텍스트 입력 onChange와 Preact core의 onChange는 의미가 다릅니다. Preact는 브라우저의 표준 이벤트 동작을 사용하며, preact/compat에서는 React 호환을 위해 일부 동작을 조정합니다. class와 className은 모두 지원합니다. Preact: Differences to React
위 코드는 입력 이벤트를 설명하기 위한 예제입니다. 6편의 서버 검색 폼을 이 컴포넌트로 교체할 필요는 없습니다.
입력값을 서버에 제출하기만 한다면 기존 HTML 폼으로 충분합니다. 입력 중 미리보기나 자동완성이 필요해질 때 클라이언트 상태를 추가하면 됩니다.
React 패키지 호환성과 Fresh의 실행 경계는 별개입니다
Fresh의 Vite 플러그인은 React 의존성을 Preact로 연결하는 alias 설정을 제공합니다. React용 패키지를 사용할 때 호환 설정을 매번 직접 작성하지 않아도 되는 이유입니다. Fresh Vite Plugin
여기서 React용 패키지가 모두 같은 동작을 보장받는다고 해석하면 안 됩니다.
preact/compat는 React 생태계와의 호환을 위한 계층입니다. Preact 공식 문서도 Preact를 React의 완전한 재구현으로 설명하지는 않습니다. 사용하는 React API와 패키지 구현에 따라 확인이 필요합니다. Preact: Differences to React
Fresh에서는 서버 렌더링 가능 여부도 함께 확인해야 합니다.
예를 들어 패키지가 import되는 순간 document에 접근한다면, React 호환 여부와 별개로 서버에서 문제가 생길 수 있습니다.
또 하나는 props 전달입니다.
일반적인 Preact 컴포넌트 사이에서는 함수를 전달할 수 있습니다. 복사 버튼 내부에서 다음처럼 작성한 것이 그 예입니다.
<ActionButton onClick={copyLink}>
글 주소 복사
</ActionButton>이 코드는 Island 내부의 컴포넌트 관계입니다.
반면 서버 페이지에서 Island로 함수를 전달하는 코드는 다릅니다.
// 서버 페이지에서 Island로 함수를 전달하는 잘못된 예
<CopyLink
url={canonical}
onCopied={() => {
console.log("copied");
}}
/>서버에서 브라우저로 넘어가는 Island props에는 직렬화 경계가 있습니다. 일반 함수는 그 경계를 넘겨 전달할 수 없습니다.
따라서 이번 예제는 URL 문자열만 전달하고, 클릭 처리 함수는 Island 내부에서 정의했습니다. Fresh 공식 문서도 Island props의 직렬화 가능한 값과 함수 전달 제한을 명시합니다. Fresh Islands: Passing props
React 패키지를 가져올 때도 같은 기준을 적용하면 됩니다.
필요한 API가 Preact와 호환되는가?
↓
서버에서 import하고 렌더링할 수 있는가?
↓
브라우저 동작을 Island 안에 둘 수 있는가?
↓
서버에서 전달하는 props를 직렬화할 수 있는가?개발 서버에서 화면이 보이는 것에 더해 production build와 실제 상호작용까지 확인해야 합니다.
컴포넌트 문법과 실행 위치를 함께 봅니다
Fresh Notes에서는 이미 Preact를 여러 곳에서 사용하고 있었습니다.
글 카드, 검색 결과, Header는 서버에서 화면을 만드는 데 사용했습니다. ThemeToggle 같은 Island에서는 브라우저 이벤트를 연결하는 데 사용했습니다.
이번 복사 버튼도 같은 방식으로 나눴습니다.
글 상세 페이지
└── canonical URL 전달
└── CopyLink Island
├── 복사 상태 관리
├── 클릭 후 Clipboard API 실행
└── ActionButton 재사용Preact의 문법 자체는 React 경험이 있다면 비교적 익숙합니다. Fresh에서 추가로 살펴봐야 하는 것은 그 코드가 실행되는 위치입니다.
공통 컴포넌트를 Island가 가져오면 브라우저 코드에 참여할 수 있습니다. Island도 서버에서 렌더링되므로 컴포넌트 본문에서 브라우저 API를 바로 실행해서는 안 됩니다. 서버에서 Island로 넘기는 props에는 직렬화 제약도 있습니다.
이 경계를 알고 나면 작은 상호작용을 추가할 때 필요한 코드를 어디에 둘지 판단하기 쉬워집니다.
기존 글 목록과 검색 결과는 서버에 두고, 복사 버튼에 필요한 상태와 이벤트만 Island 안에서 관리하면 됩니다.