Island도 서버에서 먼저 렌더링됩니다
Fresh에서 버튼을 Island로 만들면 브라우저에서 클릭 이벤트를 처리할 수 있습니다.
하지만 Island의 컴포넌트 코드가 브라우저에서만 실행되는 것은 아닙니다. Fresh는 Island도 서버에서 HTML로 렌더링하고, 브라우저에서 해당 영역을 hydration합니다. Fresh Islands 문서
이 차이를 놓치면 다음 코드에서 문제가 시작됩니다.
const [saved, setSaved] = useState(() => {
return window.localStorage.getItem(
"saved",
) === "1";
});useState()의 초기화 함수 안에 넣었지만, 여전히 컴포넌트를 처음 렌더링할 때 실행하는 코드입니다.
서버에는 브라우저의 window가 없습니다.
이번에는 글 상세 페이지에 넣을 저장 버튼을 만들면서 다음 단계를 구분하겠습니다.
서버 HTML 생성
→ 클라이언트의 첫 렌더
→ hydration 이후 저장소 확인
→ 사용자 클릭7편에서 Hooks의 기본 역할을 설명했다면, 이번에는 첫 상태가 언제 결정되는지와 실제 DOM이 어떻게 연결되는지 확인합니다.
실험은 임시 작업 디렉터리와 프로젝트 복사본에서 진행했습니다. 사용한 버전은 Fresh 2.3.3, Preact 10.29.8, preact-render-to-string 6.7.0입니다. 원고의 기능을 실제 프로젝트에 적용한 것은 아닙니다.
첫 번째 실패는 SSR에서 발생했습니다
문제를 재현하기 위해 다음 컴포넌트를 만들었습니다.
import { useState } from "preact/hooks";
export default function SavePostButton() {
const [saved] = useState(() => {
return window.localStorage.getItem(
"saved",
) === "1";
});
return (
<button type="button">
{saved ? "저장됨" : "글 저장"}
</button>
);
}이 컴포넌트를 서버 렌더러에 전달했습니다.
import { renderToString } from
"npm:preact-render-to-string@6.7.0";
import SavePostButton from
"../islands/SavePostButton.tsx";
const html = renderToString(
<SavePostButton />,
);실행 결과는 다음과 같았습니다.
ReferenceError: window is not defineduseState()에 함수를 전달하는 것은 초기값 계산을 지연하는 방식입니다. 그 계산을 브라우저에서만 수행하도록 바꾸는 기능은 아닙니다.
서버 렌더링도 컴포넌트를 실행하므로 초기값 계산 과정에서 window에 접근했습니다.
window 검사만으로 첫 상태가 맞아지지는 않습니다
서버 오류를 피하려고 다음처럼 작성할 수 있습니다.
const [saved] = useState(() => {
if (typeof window === "undefined") {
return false;
}
return window.localStorage.getItem(
storageKey,
) === "1";
});이제 서버에서 window를 직접 읽는 문제는 피할 수 있습니다.
하지만 브라우저에 이미 저장값이 있다면 초기 상태가 달라집니다.
| 실행 위치 | 초기 상태 | 예상 문구 |
|---|---|---|
| 서버 | false |
글 저장 |
| 저장값이 있는 브라우저 | true |
저장됨 |
서버가 보낸 HTML과 클라이언트가 처음 기대하는 결과가 서로 다릅니다.
Preact의 hydrate()는 서버에서 만들어진 DOM에 컴포넌트를 연결하는 API입니다. 따라서 처음부터 다른 화면을 계산하도록 만들기보다, 서버와 클라이언트가 같은 초기 결과를 만들도록 구성해야 합니다. Preact hydrate API
특정 콘솔 경고가 나오는지만 확인하지도 않겠습니다. 첫 렌더의 상태와 문구, DOM 재사용 여부를 직접 확인할 수 있습니다.
서버가 모르는 상태는 확인 중으로 표현합니다
이번 저장 버튼은 브라우저의 localStorage를 사용합니다.
서버는 일반적인 페이지 요청만으로 그 브라우저의 저장값을 알 수 없습니다. 따라서 서버가 저장 여부를 임의로 결정하지 않도록 하겠습니다.
초기 상태를 다음과 같이 정합니다.
서버
→ 저장 상태 확인 중
클라이언트 첫 렌더
→ 저장 상태 확인 중
effect에서 저장소를 읽은 뒤
→ 글 저장 또는 저장됨이때 false는 아직 확인하지 않은 상태의 임시 데이터입니다. 확인 전에는 저장 여부를 나타내는 문구로 사용하지 않습니다.
상태도 별도로 나눕니다.
type Status =
| "loading"
| "ready"
| "unavailable";| 상태 | 의미 |
|---|---|
loading |
브라우저 저장소를 아직 확인하지 않음 |
ready |
읽기에 성공해 버튼을 사용할 수 있음 |
unavailable |
읽기 또는 쓰기에 실패함 |
저장되지 않은 것과 저장소를 읽지 못한 것은 다른 상황입니다.
글 저장 버튼을 구현합니다
islands/SavePostButton.tsx를 만듭니다.
import {
useEffect,
useState,
} from "preact/hooks";
interface Props {
slug: string;
}
type Status =
| "loading"
| "ready"
| "unavailable";
export default function SavePostButton({
slug,
}: Props) {
const storageKey =
`fresh-notes:saved:${slug}`;
const [saved, setSaved] = useState(false);
const [status, setStatus] = useState<Status>(
"loading",
);
useEffect(() => {
try {
const value = window.localStorage.getItem(
storageKey,
);
setSaved(value === "1");
setStatus("ready");
} catch {
setStatus("unavailable");
}
}, [storageKey]);
function toggleSaved() {
if (status !== "ready") {
return;
}
const next = !saved;
try {
window.localStorage.setItem(
storageKey,
next ? "1" : "0",
);
setSaved(next);
} catch {
setStatus("unavailable");
}
}
const label = status === "loading"
? "저장 상태 확인 중"
: status === "unavailable"
? "저장 불가"
: saved
? "저장됨"
: "글 저장";
return (
<div>
<button
type="button"
class="btn btn-outline min-w-40"
disabled={status !== "ready"}
aria-pressed={status === "ready"
? saved
: undefined}
onClick={toggleSaved}
>
{label}
</button>
<p
role="status"
class="mt-2 text-sm text-base-content/60"
>
{status === "unavailable"
? "브라우저 저장소를 사용할 수 없습니다."
: ""}
</p>
</div>
);
}저장 키에는 글의 slug를 넣었습니다.
fresh-notes:saved:fresh-markdown-blog
fresh-notes:saved:fresh-server-side-search따라서 각 글의 저장 상태를 따로 관리합니다.
이 기능에서 저장한다는 것은 해당 브라우저의 로컬 저장소에 표시를 남긴다는 뜻입니다. 서버의 계정 데이터에 저장하는 기능은 아닙니다.
useEffect로 옮긴 부분을 구분합니다
서버와 클라이언트의 초기 상태는 모두 같습니다.
const [saved, setSaved] = useState(false);
const [status, setStatus] = useState<Status>(
"loading",
);브라우저 저장소와 연결하는 작업은 effect 안으로 옮겼습니다.
useEffect(() => {
try {
const value = window.localStorage.getItem(
storageKey,
);
setSaved(value === "1");
setStatus("ready");
} catch {
setStatus("unavailable");
}
}, [storageKey]);서버 렌더링에서는 이 effect를 실행하지 않습니다. 브라우저에서 컴포넌트가 연결된 뒤 저장소를 확인하고 상태를 갱신합니다. Preact useEffect 문서
여기서 effect의 역할은 서버와 클라이언트의 차이를 숨기는 것이 아닙니다.
첫 렌더에서 필요한 값은 공통 초기값으로 정하고, 브라우저에만 있는 외부 상태를 나중에 반영하는 것입니다.
이후에 문구가 바뀌는 것은 의도한 상태 전환입니다.
저장 상태 확인 중
→ 저장됨저장 성공 뒤에 UI를 바꿉니다
클릭 처리에서는 저장소 쓰기를 먼저 시도합니다.
window.localStorage.setItem(
storageKey,
next ? "1" : "0",
);
setSaved(next);반대 순서로 작성하면 UI는 저장됨으로 바뀌었는데 실제 쓰기는 실패하는 상황이 생길 수 있습니다.
이번에는 쓰기에 성공한 뒤 상태를 바꾸고, 실패하면 저장 기능을 사용할 수 없다고 안내합니다.
localStorage는 브라우저 정책 등에 따라 접근이 제한될 수 있으며, 쓰기 작업도 실패할 수 있습니다. 저장소가 항상 사용 가능하다고 가정하지 않았습니다. MDN localStorage, MDN Storage.setItem
읽기와 쓰기의 예외를 각각 처리한 이유입니다.
읽기 실패
→ 저장 여부를 알 수 없음
→ 버튼 비활성화
쓰기 실패
→ 저장 완료로 표시하지 않음
→ 버튼 비활성화와 안내상세 페이지에서는 slug만 전달합니다
routes/posts/[slug].tsx에서 Island를 가져옵니다.
import SavePostButton from
"../../islands/SavePostButton.tsx";기존 상세 페이지 컴포넌트에는 글의 slug가 있으므로, 원하는 위치에 버튼을 추가합니다.
<SavePostButton slug={slug} />글 본문 전체를 Island로 옮길 필요는 없습니다.
글 제목과 Markdown 본문
→ 기존 서버 렌더링 유지
저장 버튼
→ Island로 상호작용 추가버튼은 저장소 확인 전까지 비활성화됩니다. JavaScript가 실행되지 않으면 확인 중 상태로 남지만, 글 본문과 기존 링크는 서버 HTML로 제공됩니다.
실제 적용 전에는 이 초기 안내 문구가 사이트의 사용 방식에 적절한지도 확인하면 됩니다.
서버 렌더링부터 확인합니다
브라우저를 열기 전에 컴포넌트가 서버에서 렌더링되는지 검사할 수 있습니다.
scripts/check-save-button-ssr.tsx를 만듭니다.
import { renderToString } from
"npm:preact-render-to-string@6.7.0";
import SavePostButton from
"../islands/SavePostButton.tsx";
const html = renderToString(
<SavePostButton
slug="fresh-markdown-blog"
/>,
);
if (
!html.includes("저장 상태 확인 중") ||
!html.includes("disabled")
) {
throw new Error(
"예상한 초기 버튼 HTML이 아닙니다.",
);
}
console.log(
"SSR 확인: 저장 상태 확인 중, 버튼 비활성화",
);필요한 의존성이 준비된 환경에서는 다음처럼 실행할 수 있습니다.
deno run \
--cached-only \
--deny-net \
scripts/check-save-button-ssr.tsx이 검사는 window 없이도 렌더링되는지와 초기 HTML을 확인합니다.
실험에서는 잘못된 초기화 코드가 window is not defined로 실패했고, 수정한 컴포넌트는 비활성화된 확인 중 버튼을 반환했습니다.
프로젝트 복사본에 테스트 페이지를 추가한 뒤 Fresh 빌드 결과에서도 확인했습니다.
HTTP 상태: 200
버튼 문구: 저장 상태 확인 중
버튼 상태: disabled13편의 HTTP 테스트로 확인할 수 있는 범위는 여기까지입니다. 클릭 이벤트와 hydration 이후 상태 전환은 브라우저에서도 확인해야 합니다.
브라우저에서는 첫 렌더와 effect 이후를 따로 봅니다
컴포넌트의 hydration 동작은 독립된 브라우저 테스트 페이지에서 확인했습니다.
먼저 서버 렌더러가 만든 HTML을 넣었습니다.
<div id="root">
<!-- 서버에서 생성한 버튼 HTML -->
</div>그다음 같은 props로 hydrate()를 호출했습니다.
import {
h,
hydrate,
} from "preact";
import SavePostButton from
"./SavePostButton.tsx";
const root = document.getElementById(
"root",
)!;
hydrate(
h(SavePostButton, {
slug: "fresh-markdown-blog",
}),
root,
);이 코드는 Preact 컴포넌트를 검증하기 위한 독립 테스트 코드입니다. Fresh 프로젝트의 실제 페이지에서는 Fresh가 Island를 연결하므로, 직접 hydrate()를 추가하지 않습니다.
테스트에서는 다음 순서로 확인했습니다.
서버 HTML의 버튼 확인
→ hydrate() 호출 직후 확인
→ effect에 의한 상태 변경 대기
→ 클릭
→ 저장값 확인hydrate() 전후의 버튼 노드도 비교했습니다.
const before = root.querySelector("button");
// hydrate() 실행
const after = root.querySelector("button");
console.log(before === after);이번 수정 컴포넌트에서는 같은 버튼 DOM이 재사용되었습니다. hydration 직후 문구도 확인 중 상태로 유지되었고, effect 이후 저장값에 맞게 변경되었습니다.
네 가지 저장소 상황을 검증했습니다
브라우저 테스트에서는 저장소 상태를 바꾸어 다음 경우를 확인했습니다.
| 상황 | 초기 문구 | effect 이후 | 클릭 결과 |
|---|---|---|---|
| 저장값 없음 | 저장 상태 확인 중 | 글 저장 | "1" 저장, 저장됨 표시 |
기존 값이 "1" |
저장 상태 확인 중 | 저장됨 | "0" 저장, 글 저장 표시 |
| 읽기 실패 | 저장 상태 확인 중 | 저장 불가 | 비활성화 |
| 쓰기 실패 | 저장 상태 확인 중 | 글 저장 | 저장 불가 안내 |
읽기 실패는 localStorage 접근에서 예외를 발생시키고, 쓰기 실패는 setItem()에서 예외를 발생시키는 방식으로 재현했습니다.
저장값이 없는 상태에서 버튼을 눌러 저장한 뒤 새로고침하는 경우도 확인했습니다.
글 저장 클릭
→ localStorage에 "1" 기록
→ 새로고침
→ 저장 상태 확인 중
→ 저장됨클릭 이후에는 aria-pressed 값도 저장 상태에 맞게 바뀌었습니다.
이 테스트들은 모두 통과했고, 브라우저의 처리되지 않은 JavaScript 오류도 발견되지 않았습니다.
브라우저 검증은 광고 코드가 없는 독립 HTML에서 수행했습니다. 오프라인 설정과 요청 가로채기를 사용했으며, 외부 광고 스크립트를 요청하거나 실행하지 않았습니다.
Fresh 앱 전체의 브라우저 동작을 검증한 결과와는 구분합니다. 이번에는 Fresh 복사본의 빌드·SSR과, 독립 페이지에서의 Preact hydration·저장소 동작을 각각 확인했습니다.
첫 화면부터 알아야 하는 값은 서버에도 전달합니다
이번 버튼에서는 저장 상태를 확인 중으로 표시해도 기능상 문제가 없습니다.
하지만 모든 상태를 같은 방식으로 처리할 필요는 없습니다.
8편의 다크 모드처럼 첫 화면부터 사용자 선택을 반영해야 한다면, 서버가 읽을 수 있는 쿠키 등의 정보로 초기값을 정하는 방식을 검토할 수 있습니다.
서버가 값을 알고 있다면 같은 값을 Island props로 전달합니다.
<SomeIsland initialValue={serverValue} />Island도 그 props에서 시작합니다.
const [value, setValue] = useState(
initialValue,
);반대로 서버가 알 수 없는 브라우저 전용 값이라면, 공통으로 렌더링할 초기 상태와 브라우저에서 확인한 뒤의 상태를 나눕니다.
| 초기 정보의 위치 | 구성 방식 |
|---|---|
| 서버가 요청에서 알 수 있음 | 서버에서 정한 값을 props로 전달 |
| 브라우저 저장소에만 있음 | 공통 초기 상태로 시작하고 effect에서 반영 |
| 빌드 시 고정됨 | 서버·클라이언트 번들에 들어간 값 확인 |
마지막 경우는 14편의 환경 변수 실험에서 살펴본 내용입니다.
서버에서 렌더링한다는 사실과 초기값이 언제 결정되는지는 따로 확인해야 합니다.
hydration 문제는 첫 렌더부터 추적합니다
글 저장 버튼에서 확인할 순서는 명확했습니다.
서버에서 실행할 수 있는 코드인가?
→ 서버가 어떤 HTML을 만드는가?
→ 클라이언트도 같은 초기 상태인가?
→ 브라우저 전용 값은 언제 읽는가?
→ 읽기와 쓰기에 실패하면 무엇을 보여주는가?상태 초기화에 있는 브라우저 API를 effect로 옮기는 것만으로 끝내지 않고, 확인 전 상태와 저장 실패까지 화면에 반영했습니다.
그 결과 서버 HTML을 유지한 채 버튼을 연결하고, 브라우저에 있는 저장값은 연결 이후의 상태 변경으로 처리할 수 있었습니다.