水美想館soomisang

다크 모드를 열었다

#기록#개발#수미상관

다크 모드를 열었다. 그런데 어두운 색은 처음부터 있었다.

나만 그걸 모르고 있었다.

있는 줄도 몰랐다

만든 기억이 없었다.

그런데 개발하는 내내 클로드 코드가 자꾸 다크 모드 얘기를 꺼냈다. 이 색은 어두운 쪽에서 어떻게 되느냐, 새로 넣는 색은 어두운 쪽에도 넣어야 한다, 그런 말들이었다. 처음엔 그러려니 하고 넘겼는데 몇 번 반복되니까 궁금해져서 직접 확인해봤다.

정말로 있었다. 사이트에 쓰는 색이 두 벌로 적혀 있었다. 밝은 화면용 한 벌, 어두운 화면용 한 벌.

그리고 둘 중 어느 쪽을 쓸지 정하는 조건이 하나 걸려 있었다. 방문자의 기기가 어두운 화면으로 설정돼 있는가. 그렇다면 어두운 쪽, 아니면 밝은 쪽.

어디서 왔는지도 찾아봤다. 첫 커밋부터 있었다.

Next 프로젝트를 새로 시작하면 CSS 파일이 하나 딸려온다. 거기에 색 두 벌과 그 조건이 이미 들어 있다. 흰 바탕에 검은 글씨, 그리고 검은 바탕에 흰 글씨. 그 파일을 받아 색만 바꿔 썼고 구조는 그대로 남았다.

테일윈드 때문인가 했는데 아니었다. 확인해보니 테일윈드를 안 쓰고 시작해도 똑같이 딸려온다. 테일윈드가 어두운 화면을 다루는 방식은 따로 있는데 그건 아예 안 쓰고 있었다. 그냥 처음 받은 골격에 들어 있던 것이다.

그리고 그 뒤로 색이 하나 늘 때마다 두 벌에 같이 들어갔다. 어두운 쪽 한 벌은 한 번도 안 보고 여기까지 온 셈이다.

전환 버튼도 없었다.

그러니까 기기를 다크로 쓰는 사람은 내가 한 번도 눈으로 본 적 없는 화면을 보고 있었다. 나는 밝게 쓰니까 만드는 내내 그 화면을 마주칠 일이 없었고, 마음에 안 들어도 그쪽에서 할 수 있는 게 없었다.

전환 버튼이 없다는 건 다크가 꺼져 있다는 뜻이 아니다. 선택권이 없다는 뜻이다.

밝은 화면의 머리말. 흰 바탕에 짙은 글씨와 옅은 물결.
내가 보던 화면.
어두운 화면의 머리말. 짙은 바탕에 흰 글씨와 푸른 물결.
같은 자리, 어두운 쪽. 이걸 나는 한 번도 안 보고 있었다.

일단 잠갔다

클로드 코드에게 밝은 쪽으로 고정해달라고 했다.

만들어둔 걸 살리는 것보다 내가 못 본 게 나가는 걸 막는 게 먼저였다. 색은 지우지 않고 그대로 두되, 어두운 쪽을 고르는 조건만 떼어냈다. 없던 것은 색이 아니라 끄는 길이었으니까.

그리고 이번에 그 길을 만들었다.

안 고른 것도 값이다

방문자는 셋 중 하나다. 아직 아무것도 안 고른 사람, 밝게를 고른 사람, 어둡게를 고른 사람.

고르면 페이지에 표시가 하나 붙는다. 밝게를 골랐으면 light, 어둡게를 골랐으면 dark. 안 고른 사람에게는 그 표시가 아예 없다. 표시가 없다는 것 자체가 "기기 설정을 따르겠다"는 뜻이 된다.

이 표시는 브라우저가 주는 게 아니라 내가 붙이는 것이다. 이름도 내가 지었다. HTML에는 data-로 시작하는 이름을 아무거나 만들어 붙일 수 있게 열어둔 자리가 있고, 거기에 data-theme이라고 지어 넣었다. 뜻은 붙이는 쪽이 정한다.

반대로 기기 설정은 내가 붙일 수 없다. 그건 방문자 쪽 사정이라 브라우저에게 물어봐야 알 수 있고, 묻는 방법은 prefers-color-scheme 하나뿐이다.

그러니까 답을 얻는 통로가 둘이다. 내가 적어둔 표시와, 브라우저에게 묻는 질문.

그런데 표시가 없는 쪽이 제일 흔하다. 대부분은 아무것도 안 누르고 그냥 보고 간다. 그러니 그 상태를 제대로 받는 게 일의 절반이다.

규칙을 이렇게 셋으로 뒀다.

언제어느 쪽
기본밝게
표시가 없고 기기가 어두우면어둡게
표시가 dark면어둡게

첫 줄이 바탕이고 아래 둘이 그 위에 덮어쓴다. 밝게를 고른 사람은 아래 둘 어디에도 안 걸리니까 첫 줄이 그대로 남는다. 아무것도 안 고른 사람은 기기 설정에 따라 첫 줄에 남거나 둘째 줄로 넘어간다.

CSS로는 이렇게 생겼다.

:root {
  /* 밝은 쪽 색 */
}
 
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) {
    /* 어두운 쪽 색 */
  }
}
 
:root[data-theme='dark'] {
  /* 어두운 쪽 색 */
}

data-theme이 위에서 말한 그 표시고, @media (prefers-color-scheme: dark)가 브라우저에게 던지는 질문이다. 서로 다른 두 통로가 여기서 나란히 보인다.

가운데 규칙의 :not([data-theme='light'])은 "밝게를 고르지는 않은" 이라는 뜻이다. 그래서 아무것도 안 고른 사람과 어둡게를 고른 사람이 함께 여기로 들어온다.

여기에 함정이 하나 있다. 색은 세 경우 모두에서 값이 있어야 한다.

새 색을 만들면서 밝은 쪽에만 적고 어두운 쪽에 빠뜨렸다고 하자. 어둡게 보는 사람 화면에서 그 색 하나만 밝은 쪽 값으로 남는다. 어두운 바탕에 흰 화면용 글씨가 얹힌다. 반대로 어두운 쪽에만 적으면 밝게 보는 사람에게 그 색이 통째로 빠진다.

어두운 화면이 두 벌이다

어두운 화면이 나오는 길이 둘이다. 기기 설정이 어두워서 어두운 사람, 그리고 버튼을 눌러서 어두운 사람. 위에서 규칙을 셋으로 나눌 때 아래 두 줄이 그 둘이다.

이 둘은 서로 다른 규칙이라 어두운 색 목록도 두 번 적혀 있다. 똑같은 내용으로 두 벌.

하나로 합칠 수는 없나 싶어서 해봤는데 안 된다. 이렇게 쓰면 될 것 같지만

/* 안 된다 */
@media (prefers-color-scheme: dark) { :root:not([data-theme='light']) },
:root[data-theme='dark'] {
  --background: #0b1116;
}

@media는 선택자가 아니라 블록이다. 쉼표로 잇는 선택자 목록에 블록을 끼워 넣을 수는 없다. 한쪽 조건은 "미디어 쿼리 안에 있음"이고 다른 쪽은 "그냥 있음"이라, 둘은 애초에 같은 자리에 설 수가 없다.

어두운 쪽 두 벌 사이에서도 같은 실수가 가능하다. 밝음과 어둠 사이가 아니라 어둠과 어둠 사이라는 것만 다르다. 한쪽에만 넣으면 버튼을 눌러서 보는 내 화면은 멀쩡한데 기기 설정만 어두운 사람 화면에서는 그 색 하나가 어긋난다. 둘 다 어두운 화면이라 두 장을 나란히 놓고 보기 전에는 다른 줄 모른다. 그리고 내가 안 쓰는 쪽이 대개 그 한쪽이다.

이 글이 시작한 자리가 딱 그거다 — 안 보는 화면은 틀려도 티가 안 난다.

그래서 검사를 하나 만들어 붙였다. CSS 파일을 직접 읽어서 두 벌이 글자 하나까지 같은지 대조한다. 밝은 쪽에만 있고 어두운 쪽에 없는 색이 있는지도 보고, 표시를 저장하는 이름과 읽는 이름이 서로 맞는지도 본다.

색 하나를 일부러 빼보고 정말 잡히는지도 확인했다. 안 그러면 검사가 있다는 사실만 남는다.

덧붙이면 light-dark()라는 함수로 이걸 아예 안 겪을 수도 있다. 어느 블록에 있느냐를 묻는 대신 색마다 두 값을 나란히 적어두는 방식이라 목록이 한 벌로 준다. 2024년 5월부터 최신 브라우저에는 다 들어와 있다.

그래도 안 썼다. 아직 「널리 쓸 수 있음」 단계로 넘어가지 않았고, 모르는 브라우저에서는 그 색만 빠지는 게 아니라 값이 통째로 무효가 된다. 흔히 쓰는 폴백 적는 방법도 여기서는 안 통한다. 해와 달을 바꾸는 것처럼 색이 아닌 것들은 어차피 못 옮기기도 하고. 절반만 줄고 위험은 새로 생기는 셈이었다.

브라우저가 직접 그리는 것들

화면에 분명히 보이는데, 페이지가 색을 정할 수 없는 것들이 있다.

가장 눈에 띄는 건 스크롤바다. 글이 길어지면 오른쪽에 생기는 그 막대는 페이지가 그리는 게 아니라 브라우저가 자기 기본값으로 그린다. 글자를 끌어서 선택했을 때 깔리는 바탕, 탭 키로 옮겨 다닐 때 둘러지는 테두리도 그렇다. 글을 쓰는 화면의 입력칸처럼 브라우저가 원래 모양을 가지고 있는 것들도 전부 여기에 든다.

이것들은 페이지가 정한 색을 안 본다. 대신 "이 페이지는 밝은 쪽인가 어두운 쪽인가" 하나만 본다. color-scheme이 그 질문에 대한 답이다. 위에서 본 색 목록 안에 한 줄씩 넣어두면 된다. 밝은 쪽에는 color-scheme: light, 어두운 쪽에는 color-scheme: dark. 그러면 그것들이 그 줄을 보고 따라온다.

이 줄을 빼면 어떻게 되냐면, 본문은 어두운데 스크롤바만 흰 막대로 남는다. 어두운 화면으로 긴 글을 읽는 내내 오른쪽에 흰 줄이 하나 서 있는 것이다.

고장난 것처럼 보인다. 실제로 고장은 아니어서 더 나쁘다.

흰 화면이 한 번 번쩍인다

고른 값은 방문자의 브라우저 안에 저장된다. 서버에는 안 간다. 그래서 서버는 이 사람이 밝게를 골랐는지 어둡게를 골랐는지 알 방법이 없다.

서버가 내보내는 화면이 늘 밝은 쪽인 건 그래서다. 어느 쪽인지는 그 화면이 브라우저에 도착한 다음에야 정해진다.

문제는 언제 정하느냐다.

페이지가 다 뜨고 자바스크립트까지 다 돈 다음에 고치면 순서가 이렇게 된다. 흰 화면이 먼저 그려지고, 그다음에 어두워진다. 아주 짧지만 눈에 남는다.

그래서 문서 맨 위에 짧은 코드를 하나 뒀다. 브라우저는 문서를 위에서 아래로 읽어 내려가는데, 이 코드는 읽다가 만나는 그 자리에서 곧바로 실행된다. 아직 아무것도 그리기 전이다. 그 틈에 저장된 값을 꺼내 표시를 붙여두면, 브라우저가 처음 그리는 그림부터 이미 어두운 쪽이다.

전부 이만큼이다.

<script>
  (function () {
    try {
      var t = localStorage.getItem('soomisang.theme');
      if (t === 'light' || t === 'dark') {
        document.documentElement.setAttribute('data-theme', t);
      }
    } catch (e) {}
  })();
</script>

<head> 안에 두면 된다. 실제 페이지에는 한 줄로 눌러서 들어가 있다.

try로 감싼 데는 이유가 있다. 시크릿 모드나 저장소를 막아둔 브라우저에서는 localStorage가 있는데도 읽으려는 순간 오류를 낸다. 감싸지 않으면 거기서 스크립트가 멈춘다.

그리고 아는 값 둘만 세운다. 저장소에 엉뚱한 게 들어 있어도 그게 그대로 페이지에 박히지는 않게.

React를 쓴다면 하나 더 있다. 이 스크립트 때문에 서버가 만들어 보낸 화면과 브라우저에 실제로 있는 화면이 달라진다. React는 그걸 어긋남으로 보고 자기가 아는 쪽으로 되돌려버린다. 스크립트가 고쳐놓은 것이 도로 지워진다.

속성이 바뀌는 그 요소에 "여기는 다를 수 있다"고 미리 말해두면 된다.

<html lang="ko" suppressHydrationWarning>

한 줄이고 붙이는 자리도 딱 하나다. 표시가 붙는 그 요소여야 한다.

쿠키였다면 더 쉬웠다

쿠키에 담았으면 이 고생을 안 해도 된다. 쿠키는 요청과 함께 서버로 가니까, 서버가 처음부터 답을 알고 어두운 화면을 만들어 보낼 수 있다.

하지만 사용하지 않았다.

쿠키를 읽으려면 페이지를 요청이 올 때마다 새로 만들어야 한다. 사람마다 답이 달라질 수 있으니 하나 만들어두고 돌려쓸 수가 없다.

글 목록이나 달력은 어차피 로그인 상태를 봐야 해서 이미 그렇게 돌고 있다. 그런데 JLPT 학습관 쪽은 아니다. 로그인도 안 받고 누가 열어도 같은 화면이라, 한 번 만들어두면 그걸로 끝나는 페이지다. 테마 하나 때문에 거기까지 매번 새로 만들게 하고 싶지는 않았다.

언젠가 학습관에도 서버가 할 일이 생기면 그때 쿠키로 옮겨도 된다. 지금은 문서 머리의 짧은 코드로 충분하다.

버튼은 하나

처음에는 칸을 셋 놓았다. 기기 설정을 따르는 「시스템」, 그리고 밝게와 어둡게. 상태가 셋이니 자리도 셋이어야 한다고 생각했다.

그런데 「시스템」 칸은 누를 일이 거의 없다. 아무것도 안 고른 사람은 이미 그 상태이기 때문이다. 밝게나 어둡게를 골랐다가 되돌아올 때만 쓰이는데, 그 한 번을 위해 세 칸이 늘 서 있어야 했다.

그래서 화면에 놓인 것은 버튼 하나다.

스위치를 달까도 생각했다. 다크 모드에 스위치를 두는 곳이 꽤 많다.

그런데 스위치는 켜고 끄는 물건이다. 그러려면 무엇을 켜고 끄는 건지가 분명해야 하는데, 다크 모드는 그게 좀 애매하다. 켜져 있으면 어두운 쪽이라는 건가, 밝은 쪽이라는 건가. 스위치만 봐서는 알 방법이 없다.

해와 달을 양옆에 놔줘도 마찬가지다. 왼쪽에 해, 오른쪽에 달이 있고 손잡이가 오른쪽에 가 있으면 — 지금 달이라는 뜻인가, 달로 가라는 뜻인가. 나는 이런 걸 볼 때마다 한 번씩 멈춰서 생각했다.

버튼은 지금 상태가 아니라 누르면 될 상태를 그린다. 밝은 화면에서는 달이고, 어두운 화면에서는 해다. 달이 보이면 누르면 어두워진다. 무엇이 켜지고 꺼지는지 따져볼 일이 없다.

버튼에 그려진 것은 하려는 일이고, 지금 어디에 있는지는 화면 전체가 이미 말하고 있다.

대신 한 번 누르고 나면 그 선택이 기기 설정보다 앞선다. 화면에서 기기 설정으로 되돌리는 길은 없다. 칸 셋을 세우지 않은 값이다.

다 만들고 나서

아무튼 나도 모르는 새 나가고 있던 다크 모드를, 이번에 제대로 된 기능으로 만들었다.

만들면서 제일 인상 깊었던 건 화면은 둘인데 고를 수 있는 상태는 셋이라는 점이었다. 밝게, 어둡게, 그리고 아직 아무것도 안 고른 상태. 색을 두 벌 맞추는 것보다 그 셋째를 어떻게 대할지 정하는 데 시간이 더 들었다.

클로드 코드와 같이 만들다 보니 이런 일도 생기는구나 싶다. 내가 넣은 기억도 없는 것이 이미 돌아가고 있었으니까.

그래도 이렇게 하나씩 늘어나는 걸 보는 게 뿌듯하고 재미있다.