ReactThemeLibraryFrontend

ThemeScript: React 렌더링 전에 테마 적용하기

Kim Yeon Jin

시작은 ThemeProvider였다

Colbrush에서는 ThemeProvider로 테마 상태를 관리했다.

사용자가 선택한 테마를 localStorage에 저장하고, 앱이 다시 실행되면 저장된 값을 읽어 document.documentElement에 반영하는 구조였다.
React 앱이 마운트된 뒤에는 이 방식으로 충분히 동작했다.

문제는 새로고침 직후였다.

저장된 테마가 있어도 첫 화면에서는 기본 테마가 잠깐 보이고, 이후에 저장된 테마로 바뀌었다.
기능 자체는 맞게 동작했지만, 화면이 한 번 깜빡이는 것처럼 보였다.

처음에는 ThemeProvider의 초기값 처리 문제라고 생각했다.
하지만 원인은 상태값이 아니라 실행 시점에 있었다.

FOUC와 비슷한 문제

이 현상은 FOUC와 비슷했다.

FOUC는 Flash of Unstyled Content의 약자로, 스타일이 적용되기 전의 화면이 잠깐 보였다가 뒤늦게 스타일이 적용되는 현상을 말한다.

Colbrush에서 발생한 문제는 스타일이 아예 없는 화면이 보이는 것은 아니었다.
다만 브라우저가 기본 테마로 먼저 화면을 그리고, React가 실행된 뒤 저장된 테마를 다시 적용한다는 점에서 같은 계열의 문제였다.

다크 모드에서도 자주 볼 수 있는 상황이다.
사용자는 다크 모드를 저장해뒀지만, 새로고침하는 순간 라이트 모드가 잠깐 보였다가 다크 모드로 바뀐다.
테마 값은 제대로 저장되어 있어도, 적용 시점이 늦으면 이런 깜빡임이 생긴다.

Colbrush도 마찬가지였다.
localStorage에는 값이 있었고, ThemeProvider도 값을 정상적으로 읽고 있었다.
문제는 React가 실행되기 전 첫 페인트 시점에는 아직 그 값이 DOM에 반영되지 않았다는 점이었다.

React 안에서는 너무 늦었다

React의 초기 state를 함수로 잡거나, useEffect 대신 더 빠른 시점에 값을 읽는 방식도 생각할 수 있다.
하지만 이 문제는 React 내부에서 해결하기 어렵다.

브라우저는 HTML과 CSS를 먼저 해석하고 화면을 그린다.
React는 그 이후에 실행된다.
ThemeProvider가 아무리 빨리 localStorage를 읽어도, React가 시작되기 전의 첫 화면까지 제어할 수는 없다.

결국 필요한 것은 React 상태 관리가 아니라, React보다 먼저 실행되는 작은 스크립트였다.

ThemeScript의 역할

그래서 ThemeScript를 분리했다.

ThemeScript가 하는 일은 단순하다.

  • localStorage에서 저장된 테마 값을 읽는다.
  • 값이 있으면 document.documentElement에 반영한다.
  • 값이 없으면 기본 테마를 적용한다.

예를 들어 저장된 테마가 protanopia라면 React가 마운트되기 전에 HTML 루트에 먼저 반영된다.

html
<html data-theme="protanopia"></html>

이 상태에서 CSS 변수가 적용되면 첫 화면부터 저장된 테마 기준으로 렌더링된다.
그 다음 React가 마운트되면 ThemeProvider가 같은 값을 기준으로 상태를 이어받는다.

역할을 나누면 구조는 단순해진다.

  • ThemeScript: React 실행 전 초기 테마 적용
  • ThemeProvider: React 실행 후 테마 상태 관리
  • ThemeSwitcher: 사용자가 테마를 변경하는 UI

ThemeScript는 테마를 계산하거나 상태를 관리하지 않는다.
초기 렌더링 전에 DOM에 필요한 속성을 넣는 역할만 한다.

CSR에서는 라이브러리만으로 해결할 수 없었다

처음에는 ThemeScript도 라이브러리에서 컴포넌트처럼 제공하면 된다고 생각했다.
하지만 CSR 환경에서는 이 방식에 한계가 있었다.

CSR 앱은 보통 비어 있는 HTML을 먼저 받고, 그 다음 브라우저에서 JavaScript 번들을 실행해 React 앱을 만든다.
라이브러리 컴포넌트도 결국 React 앱 안에서 실행된다.
즉, ThemeScript를 React 컴포넌트로 제공해도 CSR에서는 이미 첫 페인트가 지난 뒤 실행될 수 있다.

FOUC를 막으려면 스크립트가 React보다 먼저 실행되어야 한다.
그래서 CSR 환경에서는 라이브러리 내부 컴포넌트만으로 완전히 해결하기 어렵다고 봤다.

대신 CSR 사용자를 위해 HTML에 직접 넣을 수 있는 스크립트를 제공했다.
사용자는 이 코드를 index.html의 React 번들보다 앞쪽에 넣으면 된다.

html
<script>
  (function () {
    try {
      var theme = localStorage.getItem('colbrush-theme') || 'default';
      document.documentElement.setAttribute('data-theme', theme);
    } catch (_) {
      document.documentElement.setAttribute('data-theme', 'default');
    }
  })();
</script>

이 방식은 멋진 API는 아니지만, CSR에서는 가장 확실했다.
첫 화면 전에 실행되어야 하는 코드는 React 컴포넌트로 감싸는 순간 이미 늦을 수 있기 때문이다.

반대로 SSR 환경에서는 ThemeScript를 라이브러리에서 제공할 수 있었다.
Next.js 같은 환경에서는 서버에서 내려주는 문서에 스크립트를 먼저 포함시킬 수 있기 때문이다.
이 경우 사용자는 ThemeScript를 문서의 초기 스크립트 위치에 넣어 첫 렌더링 전에 테마를 맞출 수 있다.

결국 Colbrush에서는 환경별로 다른 사용 방식을 안내했다.

  • SSR 환경: 라이브러리에서 제공하는 ThemeScript 사용
  • CSR 환경: HTML에 직접 삽입할 수 있는 스크립트 코드 제공

처음에는 하나의 API로 모든 환경을 덮고 싶었지만, 이 문제는 그렇게 처리하면 오히려 부정확했다.
중요한 건 API 모양이 아니라 실행 시점이었다.

라이브러리에서 더 중요한 이유

애플리케이션 코드라면 프로젝트에 맞춰 초기 스크립트를 직접 작성해도 된다.
하지만 Colbrush는 라이브러리이기 때문에 사용자가 매번 원인을 찾아서 처리하게 두고 싶지는 않았다.

테마 라이브러리를 쓰는 입장에서는 보통 다음 동작을 기대한다.

  • 새로고침해도 이전 테마가 유지된다.
  • 첫 화면부터 저장된 테마가 적용된다.
  • 기본 테마가 잠깐 보였다가 바뀌지 않는다.

이건 기능 목록에 크게 보이는 항목은 아니지만, 실제 사용감에는 영향을 준다.
특히 테마처럼 화면 전체에 영향을 주는 기능에서는 초기 적용 시점이 중요하다.

다만 모든 환경에서 같은 형태로 제공할 수는 없었다.
그래서 SSR에서는 ThemeScript를 제공하고, CSR에서는 복사해서 붙여 넣을 수 있는 코드를 문서화하는 쪽으로 정리했다.

마무리

ThemeScript는 큰 기능은 아니다.
하지만 ThemeProvider만으로는 해결할 수 없는 시점을 보완한다.

이번 작업을 하면서 테마 기능을 React 상태 관리만으로 보면 안 된다는 것을 알게 됐다.
저장된 값을 읽는 것과, 첫 화면이 그려지기 전에 그 값을 적용하는 것은 다른 문제였다.

Colbrush에서는 이 둘을 분리했다.
React 이전에는 SSR용 ThemeScript나 CSR용 인라인 스크립트가 초기 테마를 맞추고, React 이후에는 ThemeProvider가 상태를 관리하도록 했다.

Enjoyed this article? Check out more projects and posts on my portfolio.

Explore this project