테마를 바꾸는 것과 복원하는 것은 다른 작업입니다
1편에서 daisyUI를 추가했을 때 예상과 다르게 사이트가 어두워지는 상황을 만났습니다.
당시에는 기본 테마를 끄고 freshblog라는 라이트 테마를 직접 정의했습니다. 이후 ThemeToggle을 Island로 만들어 테마를 전환하는 구조도 살펴봤습니다.
버튼을 눌러 색상을 바꾸는 코드는 길지 않습니다.
document.documentElement.dataset.theme =
"freshblog-dark";하지만 블로그에서 계속 사용하려면 그다음 동작도 정해야 합니다.
다크 모드를 선택한 뒤 다른 글로 이동하면 어떻게 될까요? 새로고침했을 때도 같은 테마가 유지될까요? 서버가 만드는 첫 HTML은 어떤 테마를 사용해야 할까요?
이번에는 ThemeToggle의 저장 방식을 쿠키로 바꾸고, 서버가 선택한 테마를 읽어 첫 HTML부터 적용하도록 연결해보겠습니다.
예제는 Fresh 2.3.3과 Preact 10 계열, Tailwind CSS 4와 daisyUI 5의 구성 방식을 기준으로 합니다.
localStorage만으로는 서버가 선택한 테마를 알 수 없습니다
앞선 ThemeToggle 예제는 선택한 테마를 localStorage에 저장했습니다.
localStorage.setItem("theme", nextTheme);브라우저 안에서 선택을 기억하기에는 간단한 방법입니다.
문제는 서버가 페이지를 만드는 시점입니다. 브라우저의 localStorage 값은 일반적인 페이지 요청에 자동으로 포함되지 않습니다. 서버는 그 브라우저가 다크 모드를 선택했는지 알 수 없습니다. Web Storage API
서버가 항상 라이트 테마로 HTML을 만들고 브라우저가 나중에 저장된 값을 읽는다면 다음 순서가 됩니다.
서버
→ 라이트 테마 HTML 생성
브라우저
→ HTML과 CSS로 화면 표시
→ 저장된 다크 테마 확인
→ 다크 테마 적용저장된 값을 언제 읽느냐에 따라 밝은 화면이 먼저 보였다가 어두워질 수 있습니다.
첫 화면 전에 실행하는 초기화 스크립트로 처리하는 방법도 있습니다. 이번에는 서버 렌더링을 활용해 서버가 응답을 만들 때부터 선택한 테마를 알고 있도록 구성하겠습니다.
쿠키는 조건에 맞는 요청에 브라우저가 함께 보내므로 서버에서도 값을 읽을 수 있습니다. HTTP cookies
먼저 테마 선택 규칙을 정합니다
이번 구현에서는 두 가지 테마만 사용합니다.
| 상황 | 적용할 테마 |
|---|---|
| 저장된 값이 없음 | freshblog |
저장된 값이 freshblog |
freshblog |
저장된 값이 freshblog-dark |
freshblog-dark |
| 그 밖의 값 | freshblog |
운영체제의 테마를 자동으로 따라가는 기능은 추가하지 않습니다. 처음 방문하면 라이트 테마를 사용하고, 사용자가 선택한 뒤에는 그 값을 유지합니다.
이렇게 정하면 서버 HTML, 버튼 상태, 저장된 값이 같은 기준을 사용할 수 있습니다.
전체 흐름은 다음과 같습니다.
페이지 요청 + 테마 쿠키
↓
서버에서 테마 값 확인
↓
<html data-theme="...">
+
ThemeToggle의 초기 상태
↓
브라우저에서 버튼 클릭
↓
DOM과 버튼 상태 변경
+
쿠키 저장
↓
다음 페이지 요청에 반영daisyUI에 라이트와 다크 테마를 준비합니다
1편에서 정의한 freshblog 테마는 그대로 사용합니다.
다크 테마는 freshblog-dark라는 이름으로 추가합니다. 두 테마의 이름은 이후 TypeScript 코드에서도 동일하게 사용해야 합니다.
assets/styles.css의 테마 설정은 다음과 같은 형태입니다.
@import "tailwindcss";
@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;
}
@plugin "daisyui/theme" {
name: "freshblog-dark";
default: false;
prefersdark: false;
color-scheme: dark;
--color-base-100: #18181b;
--color-base-200: #202024;
--color-base-300: #303036;
--color-base-content: #ededf0;
--color-primary: #91a1ff;
--color-primary-content: #111827;
--color-secondary: #b6a1ff;
--color-secondary-content: #18181b;
}위 설정은 본문 배경과 주요 색상을 보여주는 기본 예제입니다. 사이트에서 사용하는 다른 색상 토큰이 있다면 두 테마에 함께 정의합니다.
prefersdark: false는 운영체제 설정을 기본 테마 선택에 사용하지 않기 위한 설정입니다.
color-scheme은 브라우저가 제공하는 폼 컨트롤 등의 색상 체계에도 관여합니다.
daisyUI는 data-theme 속성으로 테마를 적용하고, @plugin "daisyui/theme"으로 사용자 정의 테마를 등록할 수 있습니다. daisyUI Themes
컴포넌트도 테마 색상을 사용해야 합니다
테마를 정의해도 컴포넌트에 색상을 고정하면 해당 부분은 바뀌지 않습니다.
<div class="bg-white text-black">
...
</div>테마에 따라 바뀌어야 하는 영역에서는 의미가 있는 색상 토큰을 사용합니다.
<div class="bg-base-100 text-base-content">
...
</div>Tailwind의 dark: 유틸리티를 함께 사용한다면 기준도 맞춰야 합니다.
@custom-variant dark (
&:where(
[data-theme="freshblog-dark"],
[data-theme="freshblog-dark"] *
)
);이 설정은 dark:가 운영체제 설정 대신 선택한 daisyUI 테마를 기준으로 동작하게 만듭니다. daisyUI: Tailwind dark selector
테마 이름과 쿠키 형식을 한곳에서 관리합니다
서버와 Island가 같은 테마 이름을 사용하도록 공통 유틸리티를 만듭니다.
utils/theme.ts:
export const LIGHT_THEME = "freshblog";
export const DARK_THEME = "freshblog-dark";
export const THEME_COOKIE = "fresh-notes-theme";
export type Theme =
| typeof LIGHT_THEME
| typeof DARK_THEME;
export function resolveTheme(
value: string | undefined,
): Theme {
return value === DARK_THEME
? DARK_THEME
: LIGHT_THEME;
}
export function createThemeCookie(
theme: Theme,
secure: boolean,
): string {
const attributes = [
`${THEME_COOKIE}=${theme}`,
"Path=/",
"Max-Age=31536000",
"SameSite=Lax",
];
if (secure) {
attributes.push("Secure");
}
return attributes.join("; ");
}resolveTheme()은 허용한 값만 반환합니다.
쿠키 값이 없거나 잘못된 값이면 라이트 테마로 돌아갑니다. 브라우저에서 넘어온 문자열을 그대로 HTML의 테마 이름으로 사용하지 않습니다.
이 파일에는 document, location, Deno 파일 시스템 API가 없습니다. 따라서 서버와 Island 양쪽에서 가져올 수 있습니다.
쿠키 속성은 다음 의도로 사용했습니다.
| 속성 | 역할 |
|---|---|
Path=/ |
사이트 전체 경로에서 사용 |
Max-Age=31536000 |
유효 기간을 1년으로 설정 |
SameSite=Lax |
교차 사이트 전송 조건 지정 |
Secure |
HTTPS에서만 전송 |
실제 보존 기간은 브라우저 정책이나 사용자의 삭제 동작에 영향을 받을 수 있습니다.
이번 쿠키는 브라우저 JavaScript로 변경해야 하므로 HttpOnly를 사용하지 않습니다. 로그인 정보가 아니라 화면 설정을 저장하는 용도입니다. Document.cookie
middleware에서 쿠키를 읽습니다
요청 쿠키를 읽는 데는 Deno 표준 라이브러리의 getCookies()를 사용합니다.
deno add jsr:@std/http/cookiegetCookies()는 요청의 Cookie 헤더를 이름과 값으로 나누어 줍니다. Deno Standard Library: cookie
먼저 기존 utils.ts의 State에 테마를 추가합니다.
import { createDefine } from "fresh";
import type { Theme } from "./utils/theme.ts";
export interface State {
theme: Theme;
}
export const define = createDefine<State>();그리고 routes/_middleware.ts에서 값을 설정합니다.
import { getCookies } from "@std/http/cookie";
import { define } from "../utils.ts";
import {
resolveTheme,
THEME_COOKIE,
} from "../utils/theme.ts";
export default define.middleware(async (ctx) => {
const cookies = getCookies(ctx.req.headers);
ctx.state.theme = resolveTheme(
cookies[THEME_COOKIE],
);
const response = await ctx.next();
if (
response.headers
.get("content-type")
?.includes("text/html")
) {
response.headers.set(
"Cache-Control",
"private, no-store",
);
}
return response;
});이미 middleware가 있다면 기존 처리에 테마 설정을 추가합니다.
Fresh의 ctx.state는 요청별로 만들어지는 상태입니다. middleware에서 설정한 값을 이후 페이지와 레이아웃에서 사용할 수 있습니다. Fresh Context
7편에서 주의했던 모듈 전역 상태와는 다릅니다. 사용자의 테마를 서버 전체가 공유하는 변수에 넣지 않고, 현재 요청의 상태에 저장합니다.
쿠키로 HTML이 달라지면 캐시도 함께 봐야 합니다
위 코드에는 HTML 응답의 캐시 설정도 포함되어 있습니다.
Cache-Control: private, no-store같은 URL이라도 쿠키에 따라 HTML의 data-theme 값이 달라집니다. 이 응답을 URL만 기준으로 공용 캐시에 저장하면 다른 사용자에게 다른 테마의 HTML을 전달할 수 있습니다.
이번 예제는 단순하게 HTML 응답을 저장하지 않는 정책을 사용합니다. CSS나 JavaScript 파일까지 이 조건으로 처리하지는 않습니다.
private는 공유 캐시의 저장을 제한하고, no-store는 응답을 저장하지 않도록 지시합니다. HTTP Caching: RFC 9111
나중에 HTML 캐시가 필요해지면 테마별 캐시 구분이나 초기화 방식까지 함께 검토해야 합니다.
첫 HTML의 data-theme를 서버에서 설정합니다
이제 app wrapper에서 요청 상태를 사용합니다.
routes/_app.tsx의 최소 연결 형태입니다.
import type { PageProps } from "fresh";
import type { State } from "../utils.ts";
export default function App(
{ Component, state }: PageProps<unknown, State>,
) {
return (
<html
lang="ko"
data-theme={state.theme}
>
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
</head>
<body class="min-h-screen bg-base-100 text-base-content">
<Component />
</body>
</html>
);
}기존 _app.tsx에 SEO나 공통 설정이 있다면 유지하고, data-theme를 결정하는 부분을 연결합니다.
Fresh의 app wrapper는 문서의 바깥 구조를 만들며 서버에서 렌더링됩니다. 요청 상태도 전달받을 수 있습니다. Fresh App wrapper
다크 테마 쿠키가 있는 요청에는 처음부터 다음 HTML을 보냅니다.
<html lang="ko" data-theme="freshblog-dark">브라우저가 Island를 실행한 뒤 테마를 복원하는 단계가 필요하지 않습니다.
이 방식은 서버가 다른 테마를 보내서 생기는 초기 색상 전환을 피하기 위한 것입니다. 스타일시트 로딩이나 사이트 전체의 다른 렌더링 문제까지 해결하는 것은 아닙니다.
ThemeToggle은 DOM과 쿠키를 함께 변경합니다
서버가 초기 테마를 정했으니 버튼에서도 같은 값을 사용합니다.
islands/ThemeToggle.tsx:
import { useState } from "preact/hooks";
import {
createThemeCookie,
DARK_THEME,
LIGHT_THEME,
resolveTheme,
type Theme,
} from "../utils/theme.ts";
interface Props {
initialTheme: Theme;
}
export default function ThemeToggle(
{ initialTheme }: Props,
) {
const [theme, setTheme] = useState<Theme>(
initialTheme,
);
function toggleTheme() {
const current = resolveTheme(
document.documentElement.dataset.theme,
);
const next = current === DARK_THEME
? LIGHT_THEME
: DARK_THEME;
document.documentElement.dataset.theme = next;
setTheme(next);
document.cookie = createThemeCookie(
next,
location.protocol === "https:",
);
}
return (
<button
type="button"
class="btn btn-ghost"
aria-pressed={theme === DARK_THEME}
onClick={toggleTheme}
>
다크 모드
</button>
);
}버튼을 클릭하면 세 가지 값을 맞춥니다.
document.documentElement.dataset.theme
→ 현재 화면의 테마
컴포넌트의 theme 상태
→ 버튼의 눌림 상태
쿠키
→ 다음 요청에서 사용할 테마document와 location은 클릭 핸들러 안에서만 접근합니다. 서버 렌더링 중에는 실행하지 않습니다.
버튼의 텍스트는 다크 모드로 고정하고, 활성화 여부를 aria-pressed로 표현했습니다.
서버와 브라우저의 초기 상태도 같아야 합니다
초기 상태를 항상 라이트로 만들면 안 됩니다.
// 서버가 다크 테마를 선택한 경우와 맞지 않습니다.
const [theme, setTheme] = useState(LIGHT_THEME);HTML은 다크 테마인데 버튼은 라이트 테마라고 판단할 수 있기 때문입니다.
이번 구현은 서버가 결정한 값을 props로 전달합니다.
<ThemeToggle initialTheme={state.theme} />테마를 초기화하는 별도 useEffect는 필요하지 않습니다. 서버 HTML과 Island의 초기 상태가 같은 값에서 시작합니다.
기존 Header에 초기 테마를 전달합니다
Header가 공통 레이아웃에 있다면 레이아웃에서 초기 테마를 전달하면 됩니다.
예를 들어 routes/_layout.tsx의 연결부는 다음과 같습니다.
import Header from "../components/Header.tsx";
import { define } from "../utils.ts";
export default define.page(
({ Component, state }) => {
return (
<>
<Header initialTheme={state.theme} />
<Component />
</>
);
},
);Header는 props를 받아 기존 ThemeToggle에 넘깁니다.
import ThemeToggle from "../islands/ThemeToggle.tsx";
import type { Theme } from "../utils/theme.ts";
interface Props {
initialTheme: Theme;
}
export default function Header(
{ initialTheme }: Props,
) {
return (
<header class="flex items-center justify-between p-6">
<a href="/" class="font-bold">
Fresh Notes
</a>
<nav class="flex items-center gap-4">
<a href="/search">
Search
</a>
<ThemeToggle initialTheme={initialTheme} />
</nav>
</header>
);
}이 코드는 데이터 전달 경로를 보여주는 최소 예제입니다. 기존 Header의 메뉴와 스타일은 유지하고 initialTheme 전달만 연결하면 됩니다.
Header 전체를 Island로 옮길 필요는 없습니다. 서버 컴포넌트인 Header 안에 ThemeToggle만 Island로 둡니다.
기존 localStorage 초기화 코드는 정리합니다
저장 방식을 바꿀 때는 기존 코드도 확인해야 합니다.
이전 ThemeToggle에 있던 다음 코드는 새 구현에서는 사용하지 않습니다.
localStorage.setItem("theme", nextTheme);페이지를 열 때 localStorage의 테마를 읽어 적용하는 코드가 있다면 함께 제거하거나 비활성화합니다.
서버는 쿠키를 보고 다크 테마를 선택했는데, 오래된 초기화 코드가 localStorage의 라이트 테마를 다시 적용하면 두 저장소가 충돌할 수 있습니다.
이번 구현의 저장 기준은 쿠키 하나입니다.
기존 localStorage 값이 쿠키로 자동 이전되지는 않습니다. 이전에 다크 모드를 사용하던 브라우저도 새 쿠키가 없으면 라이트 테마로 시작하며, 한 번 선택한 뒤부터 쿠키에 저장됩니다.
또한 쿠키 저장이 차단된 환경에서는 현재 화면의 전환은 가능하더라도 다음 요청까지 선택이 유지되지 않을 수 있습니다.
서버 응답부터 확인합니다
예제의 TypeScript 검사와 Fresh 요청 처리를 확인했습니다.
확인에 사용한 테마별 결과는 다음과 같습니다.
| 요청 쿠키 | 응답의 data-theme |
버튼의 aria-pressed |
|---|---|---|
| 없음 | freshblog |
false |
freshblog |
freshblog |
false |
freshblog-dark |
freshblog-dark |
true |
| 잘못된 값 | freshblog |
false |
각 요청은 HTTP 200을 반환했고, HTML 응답의 캐시 헤더도 확인했습니다.
HTTPS용 쿠키 문자열에는 Secure가 포함되고, HTTP용 문자열에는 포함되지 않는 것도 확인했습니다.
이 검증은 Fresh의 요청 처리와 서버 렌더링 결과를 대상으로 했습니다. 브라우저에서의 색상 표현과 쿠키 저장은 프로젝트에 연결한 뒤 별도로 확인해야 합니다.
브라우저에서는 클릭뿐 아니라 다음 요청을 봅니다
확인 순서는 다음처럼 잡을 수 있습니다.
- 테마 쿠키가 없는 상태에서 라이트 테마로 시작하는지 확인합니다.
- 버튼을 눌러 다크 테마와 버튼 상태가 함께 바뀌는지 확인합니다.
- 개발자 도구에서
fresh-notes-theme쿠키를 확인합니다. - 다른 글로 이동하거나 새로고침합니다.
- Network에서 HTML document의 응답을 열어
data-theme를 확인합니다.
마지막 단계에서는 Elements 패널만 확인하지 않는 것이 좋습니다.
Elements에는 JavaScript가 변경한 현재 DOM이 보입니다. 서버가 처음부터 올바른 값을 보냈는지 확인하려면 HTML 응답을 봐야 합니다.
다크 테마를 선택한 뒤 새 요청의 응답이 다음과 같다면 서버 복원 경로가 연결된 것입니다.
<html lang="ko" data-theme="freshblog-dark">JavaScript를 끈 상태에서도 저장된 테마로 문서가 표시되는지 확인할 수 있습니다. 다만 이 구현의 전환 버튼은 Island이므로 JavaScript 없이 새 테마를 선택하는 기능까지 제공하지는 않습니다.
테마 전환도 서버와 브라우저의 역할을 나누는 작업입니다
ThemeToggle의 클릭 처리는 여전히 작은 Island 안에 있습니다.
달라진 것은 첫 화면을 결정하는 데이터 흐름입니다.
기존 방식
브라우저에서 저장된 테마를 읽고 복원
이번 방식
요청 쿠키를 서버에서 읽어 HTML에 반영서버는 다음 페이지를 어떤 테마로 시작할지 결정합니다. Island는 현재 화면을 바꾸고 다음 요청을 위해 선택을 저장합니다.
이렇게 연결하면 페이지마다 테마를 다시 읽는 코드를 넣거나, Header 전체를 클라이언트 컴포넌트로 만들 필요가 없습니다.
대신 쿠키에 따라 HTML이 달라진다는 점은 캐시 정책에 반영해야 합니다. 화면 설정 하나도 저장 위치를 바꾸면 요청과 응답의 구조에 영향을 준다는 것을 확인할 수 있습니다.
다음 글에서는 지금까지 만든 블로그를 배포하는 과정으로 넘어가겠습니다. production build뿐 아니라 Markdown 파일의 포함 여부, 실행 경로, 환경 설정과 실제 응답까지 배포 환경에서 확인할 항목을 정리하겠습니다.