TL;DR

  • 다크 모드를 구현할 때
    • CSS에서 prefers-color-scheme 규칙을 사용해 시스템 테마에 스타일이 반응하도록 만들 수 있다.
    • 대신 해당 규칙을 사용해 다크 모드를 구현하기보단, 최상위 엘리먼트에 테마를 설정하는 클래스를 동적으로 제어하는 방식이 더 좋다.
    • React와 같은 프레임워크를 사용하면 전역 상태로 테마 상태를 관리하면 된다.
  • 전체 코드는 아래에서 확인하자.

이하 그리 중요하진 않은 내용들

블로그 재건축

정상영업합니다

약 2달 전, 워낙 방문자 수가 적은 블로그라서 아무도 알아차리지 못했을 테지만, 기존 Jekyll로 구축했던 블로그를 Next.js와 React 기반으로 리모델링했다. 손수 디자인까지 하는 등 밑바닥부터 새로 만든다고 고생깨나 했다. 외관도 신상, 소스 코드도 신상. 아주 따끈따끈한 블로그가 되었다. "이게 갈아엎은 거라고?" 라는 생각은 뒤로 넣어두시고, 이번 글에선 새 블로그를 만들며 겪은 어려웠던 점과 함께 자잘한 도움말 카테고리의 20회를 축하하는 의미로 새 기능들에 대해서도 소개하고자 한다.

Ver.3 패치 노트

게시글 URL이 변경되었습니다.

블로그를 운영하면 가장 많이 신경 쓰게 되는 부분이 SEO(Search Engine Optimization, 검색 엔진 최적화)다. 글을 아무리 많이 쓰고, 도네이션 버튼이나 광고를 아무리 많이 달아도 결국 구글 검색에 노출되지 않으면 말짱 꽝. 하지만 신경 쓴다고 항상 잘 되는 건 아니기 마련이라 스스로 "어차피 내가 보려고 글 쓰는 거야"라고 위로해 왔지만, 그래도 좋은 게 좋은 거지. 그래서 이번에 SEO에 도움이 될 변화를 도입하게 되었다.

슬러그(Slug)란, 일반적으로 URL의 끝에 포함되는 웹 주소의 식별자 부분이다. 이전 버전에선 이 부분이 아주 사무적이고 딱딱하게 작성되고 있었다. URL 주소 또한 검색 엔진이 크롤링하는 요소 중 하나이다. 그만큼 사용자 친화적인 슬러그가 SEO에 도움 된다고 한다. 최대한 5단어가 넘지 않도록 짧고 간결하게, 하지만, 이 페이지의 핵심 내용을 담고 있도록 모든 기존 작성 글에 신규 슬러그를 부여하였다.

그리고 단순히 URL이 변경되었다는 사실보다 중요한 것. 수년간 사용해 왔던 URL을 하루아침에 버릴 순 없었다. (혹시 모를) 기존 사용자들이 내 글을 링크해놓거나 북마크 해뒀을 수도 있잖아. 그래서 신규 URL을 적용함과 동시에 기존 방식의 URL 목록을 자동 생성하는 스크립트를 작성했으며, 과거 URL로 접속한 사용자를 자동으로 신규 URL로 리다이렉션 해주는 페이지를 생성하도록 기능을 구현했다. 이렇게 레거시 지원에도 게을리하지 않는 Anteater's laboratory가 되겠습니다 여러분.

예상 완독 시간이 추가되었습니다.

여러 블로그를 방문하며 눈독 들이고 있던 기능이었다. 멋있잖아. 멋도 멋인데, 나름 대시보드 서비스를 개발했던 사람으로서 이런 인사이트의 힘을 중요하게 생각하기 때문에 적극적으로 도입하게 되었다. 계산식에는 인터넷에 공개된 논문(「한국어 읽기 속도 측정 애플리케이션의 유효성 및 정상인의 읽기 속도에 대한 사전 연구」, 대한안과학회지, 2016)을 참고하였다. 대신 WPM(Words Per Minutes) 수치 정도만 참고했고, 이미지와 코드 수는 어림잡아서...

태그 시스템이 추가되었습니다.

기존 블로그에선 게시글을 카테고리 단위로만 분류하고 있었다. "자잘한 도움말", "독후감", "Not 4 Dev" 등등. 하지만 카테고리는 글의 형태에 따른 분류일 뿐. 내가 워낙 좋게 말하면 풀스택, 나쁘게 말하면 이도 저도 아닌 개발 커리어를 쌓고 있는 중이기 때문에 한 카테고리 안에서도 주제가 다양했다. 그래서 주제에 따라 게시글 목록을 분류할 수 있도록 태그 시스템을 구현했다.

기존 게시글 모두에 태그를 부여했으며, 태그를 통해 게시글을 필터링할 수 있는 기능도 구현했다. 필터 컴포넌트 구현에 어려움은 없었는데 한 50년쯤 뒤에 게시글이 500개가 넘어가면 어떻게 해야 하나 고민 중.

Buy Me A Coffee 버튼이 더 화려해졌습니다.

가난한 개발자는 커피 마실 돈이 필요해요. 하지만 글솜씨가 미려하지도 않고, 기술적으로 아주 깊은 통찰을 보여주지도 않는 데다 창의성도 부족한 이 블로그에서 저 버튼을 기꺼이 누를 사람이 얼마나 있을까. 버튼에 눈길이라도 한 번 주십사 재롱을 좀 부려봤다.

기본 모드와 다크 모드, 두 가지 테마를 지원합니다.

기존 블로그엔 어두운 색상을 사용했었다. 개발자의 친구 다크 모드지만 역시 칙칙해. 신규 블로그에선 디자인을 산뜻한 흰색을 바탕으로 꾸며보았다. 하지만 이걸로 끝낼 순 없지, 테마 시스템을 적용해 흰색 배경의 기본 모드와 검은색 배경의 다크 모드를 모두 지원 가능하도록 구현했다. 이제 새벽에 불 꺼놓고 몰래 블로그 보다가 안구 테러당할 일 없다구.

웹에서 다크 모드 구현하기

오늘은 위에 나열한 변경 점 중 다크 모드에 대해서 한 번 다뤄보자. 사이트의 핵심까지는 아니지만 없으면 눈이 아파 신경 쓰이는 이것. 웹 페이지에서 다크 모드는 어떻게 구현해야 할까? 일단 기본적으로 다크 모드와 같은 테마(Theme) 기능은 GUI의 스타일 영역에 포함되는 만큼 CSS를 기반으로 구현하면 된다. CSS에선 미디어 쿼리에 다음과 같은 규칙을 지원한다.

prefers-color-scheme
사용자의 시스템이 라이트와 다크 중 어떤 테마를 사용하는지 탐지한다.

/* 시스템이 다크 모드일 때 */
@media (prefers-color-scheme: dark) {
  body {
    background: black;
    color: white;
  }
}
 
/* 시스템이 라이트 모드일 때 */
@media (prefers-color-scheme: light) {
  body {
    background: white;
    color: black;
  }
}

일종의 반응형 디자인 구현이라고 볼 수 있다. 반응하는 게 뷰포트의 크기가 아니라 시스템의 테마일 뿐. 하지만 이 방식 만으로 다크 모드를 구현하는 건 비추천한다. 첫째 이유는 레거시 지원. Web API 관련 글을 쓸 때마다 비슷한 이야기를 반복하는 것 같다. 2010~2020년대쯤 처음 소개된 웹 기반 기술을 다룰 땐 2024년 현재 기준 과거 버전의 웹 브라우저에서 잘 동작하지 않을 수 있다는 사실을 염두에 둬야 한다.

물론 웬만한 상황에선 동작하긴 한다. 그러나 이 방식엔 또 다른 문제도 있다. 오직 시스템의 설정에 따라서만 웹 앱의 테마가 결정된다는 것. 이 방식에선 웹 앱 자체적으로 테마를 변경할 수 없다. 현재 내 블로그 우측 하단에 존재하는 테마 변경 버튼 같은 기능을 구현하려면 어떻게 해야 할까.

/* CSS */
body.dark {
  background: black;
  color: white;
}
 
body.light {
  background: white;
  color: black;
}
// JavaScript
 
// 다크 모드로 전환
window.document.body.classList.add('dark');
window.document.body.classList.remove('light');
 
// 라이트 모드로 전환
window.document.body.classList.add('light');
window.document.body.classList.remove('dark');

최상위 엘리먼트(<body> 혹은 의사 클래스 :root에 해당하는 엘리먼트)에 테마를 설정하는 클래스를 동적으로 제어해 주면 된다. 이렇게 JavaScript가 개입할 수 있게 되면 다양한 가능성이 발생한다. 첫 번째 방식과 조합해 편의성을 조금 더 높여볼 수도 있다.

// JavaScript에서 Media query를 확인해 시스템 테마가 바뀔때 마다 자동으로 웹 페이지의 테마를 바꾼다.
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (event) => {
  if (e.matches) {
    window.document.body.classList.add('dark');
    window.document.body.classList.remove('light');
  } else {
    window.document.body.classList.add('light');
    window.document.body.classList.remove('dark');
  }
});

웹 페이지가 복잡해질수록 다크 모드의 영향을 받는 엘리먼트도 늘어날 것이다. 엘리먼트 하나마다 일일이 클래스를 조작하며 다크 모드와 라이트 모드 속성을 구분해 주긴 귀찮으니 다음과 같이 CSS 변수(사용자 지정 속성)를 설정하는 것이 좋다.

/* 테마에 따라 CSS 변수 설정 */
body.dark {
  --bg-color-main: black;
  --text-color-main: white;
  --text-color-sub: #ddd;
}
 
body.light {
  --bg-color-main: white;
  --text-color-main: black;
  --text-color-sub: #333;
}
 
/* 엘리먼트에서 CSS 변수 사용 */
.my-container {
  background-color: var(--bg-color-main);
  color: var(--text-color-main);
}

Next.js, React에서 다크 모드 구현하기

프론트엔드 프레임워크를 사용하는 현대적인 웹 개발에선 다크 모드를 어떻게 구현하면 될까? 앞서 CSS를 사용해 다크 모드를 구현하는 방식을 살펴보았다. 그런데 다크 모드 기능은 조금 더 복잡해질 수 있다. 예를 들어, 다크 모드에 따라 스타일이 아닌 HTML 엘리먼트의 내용까지 변경될 수도 있다. 이를 구현하기 위해선 컴포넌트에서 다크 모드에 대한 상태를 참조해 값을 변경하면 될 것이다. 그렇다면 그 "다크 모드에 대한 상태"는 역시 전역 상태로 관리해야겠지?

이번 블로그 Next.js 기반 개편 작업을 사례로 들어보자. 작업에는 CSS-in-JS 라이브러리 styled-components와 전역 상태 관리 라이브러리 Zustand가 사용되었다.

// lib/store.ts
// zustand 라이브러리를 사용해 전역 다크 모드 상태를 생성/관리하는 모듈
 
import { useEffect } from "react";
import { create } from "zustand";
 
type Store = {
  isDarkMode: boolean;
  toggleDark: () => void;
  setIsDarkMode: (isDarkMode: boolean) => void;
};
 
const useStore = create<Store>()((set) => {
  return {
    isDarkMode: false,
    toggleDark: () => set((state) => ({ isDarkMode: !state.isDarkMode })),
    setIsDarkMode: (isDarkMode) => set((state) => ({ isDarkMode })),
  };
});
 
/**
 * 시스템 테마 설정 변경에 따라 다크 모드 상태가 변경되도록 설정하는 커스텀 훅
 */
export const useMatchMedia = () => {
  const { setIsDarkMode } = useStore();
 
  useEffect(() => {
    const mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
    const updateDarkMode = (e: MediaQueryListEvent) => {
      setIsDarkMode(e.matches);
    };
 
    // 초기 상태 설정
    setIsDarkMode(mediaQueryList.matches);
 
    // 리스너 등록
    mediaQueryList.addEventListener("change", updateDarkMode);
 
    // 컴포넌트 언마운트 시 리스너 제거
    return () => {
      mediaQueryList.removeEventListener("change", updateDarkMode);
    };
  }, []);
};
 
/**
 * 모듈 외부에서 필요한 인터페이스만 사용할 수 있도록 캡슐화하고 싶다면 이렇게
 */
export const useDarkMode = () => {
  const { isDarkMode, toggleDark } = useStore();
 
  return { isDarkMode, toggleDark };
};
 
export default useStore;
// styles/theme.ts
// 전역 스타일 정의 모듈
 
import { createGlobalStyle } from "styled-components";
 
export const lightTheme = {
  colors: {
    bgMain: "#fbfbf8",
    textMain: "#222222",
    shadow: "#d4d4d4",
    textSub: "#797981",
    border: "#848484",
    bgInlineCode: "#22272e",
    textInlineCode: "#adbac7",
  },
};
 
export const darkTheme: CustomTheme = {
  colors: {
    bgMain: "#222222",
    textMain: "#fbfbf8",
    shadow: "#1b1b1b",
    textSub: "#c7c7b8",
    border: "#4a4a4a",
    bgInlineCode: "#443933",
    textInlineCode: "#f5ad8e",
  },
};
 
export type CustomTheme = typeof lightTheme;
 
const GlobalStyle = createGlobalStyle`
  :root {
    --bg-color-main: ${({ theme }) => theme.colors.bgMain};
    --bg-color-inline: ${({ theme }) => theme.colors.bgInlineCode};
    --text-color-main: ${({ theme }) => theme.colors.textMain};
    --text-color-sub: ${({ theme }) => theme.colors.textSub};
    --text-color-inline: ${({ theme }) => theme.colors.textInlineCode};
    --static-white: ${lightTheme.colors.bgMain};
    --static-black: ${lightTheme.colors.textMain};
    --shadow-color: ${({ theme }) => theme.colors.shadow};
    --border-color: ${({ theme }) => theme.colors.border}
  }
`;
 
export default GlobalStyle;
// lib/registry.tsx
// 사이트 로드시 초기 설정을 담당하는 레지스트리 모듈
// 이 경우 styled-component의 초기 설정을 진행
// 시스템 테마 설정 또한 여기서 불러옴
 
"use client";
 
import React, { useState } from "react";
import { useServerInsertedHTML } from "next/navigation";
import {
  ServerStyleSheet,
  StyleSheetManager,
  ThemeProvider,
} from "styled-components";
import { useDarkMode, useMatchMedia } from "./store";
import GlobalStyle, { darkTheme, lightTheme } from "@/styles/theme";
 
export default function StyledComponentsRegistry({
  children,
}: {
  children: React.ReactNode;
}) {
  // Only create stylesheet once with lazy initial state
  // x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
  const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());
 
  // 페이지 최초 빌드 시 시스템 다크 모드 설정 불러오기
  useMatchMedia();
 
  useServerInsertedHTML(() => {
    const styles = styledComponentsStyleSheet.getStyleElement();
    styledComponentsStyleSheet.instance.clearTag();
    return <>{styles}</>;
  });
 
  const { isDarkMode } = useDarkMode();
 
  if (typeof window !== "undefined")
    return (
      <>
        <ThemeProvider theme={isDarkMode ? darkTheme : lightTheme}>
          <GlobalStyle />
          {children}
        </ThemeProvider>
      </>
    );
 
  return (
    <StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
      <ThemeProvider theme={isDarkMode ? darkTheme : lightTheme}>
        <GlobalStyle />
        {children}
      </ThemeProvider>
    </StyleSheetManager>
  );
}
// app/layout.tsx
// 웹 사이트 레이아웃
// <body /> 부분에서 앞서 정의한 StyledComponentsRegistry를 사용
 
import { HOME_OG_IMAGE_URL } from "@/lib/constants";
import type { Metadata } from "next";
 
import "../styles/statics/initialize.scss";
import "../styles/statics/ReactToastify.min.css";
 
import GlobalHeader from "./_components/GlobalHeader";
import StyledComponentsRegistry from "@/lib/registry";
import { GoogleAnalytics } from "@next/third-parties/google";
import PageUtilities from "./_components/PageUtilities";
import { ToastContainer } from "react-toastify";
 
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <head>
        {/* ... */}
      </head>
      <body>
        <StyledComponentsRegistry>
          {children}
        </StyledComponentsRegistry>
      </body>
    </html>
  );
}

참 쉽죠? 일부 불필요한 부분은 제외했다. 정리하자면, 1) 전역 상태를 생성 2) 스타일시트 라이브러리에서 그 상태를 참조하도록 설정 3) 최상위 컴포넌트에서 스타일시트 라이브러리를 통해 테마를 주입. 이렇게 설명할 수 있겠다. 그리고 전역 다크 모드 상태는 아래처럼 필요한 컴포넌트에서 불러와 HTML 조작에 사용하면 된다.

// Indicator.tsx
// 전역 상태를 사용하는 임의의 컴포넌트 모듈
"use client";
 
import { useDarkMode } from "@/lib/store";
 
function Indicator() {
  const { isDarkMode } = useDarkMode();
 
  return (
    <div className={`indicator ${isDarkMode ? "dark" : "light"}`}>
      {isDarkMode ? "어두운" : "밝은"}
    </div>
  );
}
 
export default Indicator;

내가 배운 것

  • Next.js로 만든 웹 사이트
  • 다크 모드를 구현하는 다양한 방법들

사실, 10월 안에 이 글을 올리는 것이 목표였는데 그러지 못했다. 글의 뒷부분에 힘이 빠졌다는 느낌도 든다. 더 다뤄 보고 싶었던 주제가 있는데, 다른 글에서 다시 도전해 봐야 할 것 같다. 집에 좀 큰일이 생겨가지고 거기에 온 정신이 팔려 있거든 지금. 다들 건강 잘 챙기자.

  • 참고 자료 1 2