토글 버튼을 누르면 화면은 곧바로 어두워진다. 그런데 새로고침을 한 번 하면 도로 하얗게 돌아왔다. 모든 브라우저에서 그런 건 아니었다. 특정 환경에서만 벌어지는 증상이었고, 정작 흔히 쓰는 개발 환경에서는 재현조차 안 됐다.

원인을 좁혀 보니 테마 값을 저장하는 통로가 딱 하나였다. 그 통로 하나에만 기대는 구조는, 통로가 막히는 순간 대안 없이 그대로 무너졌다. 더 골치 아픈 건 이런 버그가 대부분 조용히 묻힌다는 점이다. 사용자는 방금 고른 설정이 왜 사라졌는지 알 길이 없고, "내가 뭘 잘못 눌렀나" 하고 넘어가는 경우가 더 많다.

새로고침이 설정을 지우는 순간

토글을 누르면 <html> 태그의 class 속성이 곧바로 ""에서 "dark"로 바뀌었다. 눈으로 봐도 화면이 어두워졌으니 적용 자체는 확실했다. 문제는 그다음이었다. 브라우저를 새로고침하면 class가 다시 ""로 돌아가고 화면도 라이트 모드로 복귀했다. 방금 저장한 설정이 통째로 증발한 셈이다.

원인을 뜯어보니 저장 경로가 하나뿐이었다. 테마 값을 localStorage.theme 한 칸에만 적어 두고, 그 쓰기 작업이 실패하면 catch (_) {} 블록이 에러를 그대로 삼켰다. 실패했다는 신호가 어디에도 안 남으니 다음 렌더링에서도 값이 없다는 사실만 남는다. 삼켜진 실패는 티 나지 않게 그대로 반복됐다.

서버 쪽 사정은 더 단순했다. 서버는 이 설정을 애초에 몰랐다. 서버 사이드 렌더링(SSR, Server-Side Rendering) — 브라우저로 보내기 전에 서버가 첫 화면을 미리 완성해 두는 방식 — 단계에서는 항상 클래스 없이 렌더링됐고, 그 위에 클라이언트 쪽 localStorage가 클래스를 얹는 구조였다. localStorage 쓰기가 매번 성공하는 환경에서는 문제가 안 보인다. 그 하나의 통로가 막히는 환경에서만, 정확히 그 환경에서만 라이트 모드로 되돌아갔다.

화면에서 보이는 동작과 실제로 저장되는 값이 어긋나는 이 틈이 문제의 핵심이었다. 토글은 화면을 즉시 바꿔 주니 "적용됐다"는 확신을 준다. 하지만 그 확신은 눈앞의 렌더링에 대한 것이지 다음 방문 때도 유지된다는 보장은 아니었다. 두 가지를 같은 것으로 여긴 게 착각이었다.

창구 하나짜리 저장소가 무너지는 지점

localStorage는 편리하지만 항상 쓸 수 있는 저장소는 아니다. 사생활 보호 모드(시크릿·프라이빗 브라우징)에서는 브라우저가 저장 자체를 막아 두는 경우가 있고, 사이트 데이터 저장을 꺼 두면 쓰기 요청은 티 나지 않게 실패한다. 저장 용량 한도를 넘겨도 마찬가지다. 다만 쿠키와 localStorage를 한꺼번에 막아 두는 설정이라면 두 번째 통로도 같이 막힌다. 이번 수정이 살리는 건 localStorage만 막히고 쿠키는 살아 있는 경우다. 그 조건에 걸린 사용자는 매번 똑같은 증상을 겪는다.

정작 무서운 건 실패 자체가 아니라 실패를 감추는 방식이었다. catch (_) {}로 에러를 삼키면 개발자에게도 사용자에게도 아무 신호가 안 간다. 콘솔에도 안 찍히고 로그에도 안 남는다. 겉보기엔 앱이 멀쩡히 동작하니 버그 리포트가 들어와도 재현이 안 돼 방치되기 쉽다.

이 습관 자체는 새로운 것도 아니다. 저장소 쓰기나 네트워크 요청 하나를 급하게 감쌀 때 catch 블록에 처리 로직 대신 빈 함수만 남기는 경우는 흔하다. 당장은 에러 화면이 안 뜨니 편하고, 나중에 손보겠다는 마음으로 남겨 두지만 그 나중은 대개 안 온다. 실패를 삼킨 코드는 실패가 실제로 일어나기 전까지는 정상 코드와 구분이 안 된다.

더 근본적인 문제는 서버가 그 설정을 확인할 방법이 없었다는 점이다. 클라이언트 저장소는 서버 입장에서 보이지 않는 상자다. 요청이 서버에 도착하는 순간 서버는 이 사용자가 다크모드를 골랐는지 전혀 알 수 없고, 그러니 첫 렌더링은 항상 라이트로 시작한다. 그 위에 클라이언트 스크립트가 클래스를 덧씌우는 구조라서, 덧씌우는 단계가 실패하면 남는 건 서버가 처음부터 그려 둔 라이트 화면뿐이다.

저장 경로가 localStorage 하나뿐일 때와 쿠키를 더한 두 경로일 때, 쓰기가 실패하면 각각 어떤 결과로 갈라지는지 보여주는 분기도
통로가 하나면 실패가 그대로 화면까지 전달되고, 오른쪽처럼 쿠키를 더하면 한쪽이 막혀도 다른 통로가 남는다.

실패를 코드로 강제해 재현한 방법

처음엔 이 버그를 로컬에서도 프로덕션에서도 재현하지 못했다. 재현이 안 되면 버그가 없는 걸까? 그렇게 넘겼다면 애초에 원인을 찾지도 못했을 것이다. 일반적인 브라우저 환경에서는 localStorage 쓰기가 멀쩡히 성공하니 재현이 안 되는 게 당연했다.

재현 실패를 "버그 없음"으로 오해하지 않으려면 실패 조건 자체를 직접 만들어야 했다. Playwright로 Storage.prototype.setItem이 강제로 예외를 던지도록 만들었다. 브라우저의 정상 동작을 흉내 내는 대신, 저장소 쓰기가 실제로 실패하는 환경을 코드로 재현한 것이다.

순서는 이랬다. 페이지를 처음 열면 class=""였다. 토글을 누르면 시각적으로 class="dark"로 바뀌어 화면이 어두워진다. 여기까지는 정상이다. 새로고침을 하면 class가 다시 ""로 돌아왔다. 정상 환경에서는 절대 안 보이던 이 순간이, setItem을 강제로 실패시키자 매번 재현됐다.

이 재현 하나로 두 가지가 분명해졌다. 하나는 원인이 저장 실패 지점에 정확히 있다는 점이다. 다른 하나는 재현 실패가 "환경 조건을 아직 못 갖췄다"는 신호였을 뿐 "문제가 없다"는 증거는 아니었다. 실패 조건을 직접 시뮬레이션하지 않았다면 이 버그는 지금도 "가끔 그런다더라"는 소문으로만 남아 있었을 것이다.

강제로 만든 이 실패 조건은 한 번 쓰고 버리지 않았다. 그대로 테스트 코드에 남겨 앞으로도 매번 자동으로 돌아가게 했다. 사람이 매번 사생활 보호 모드를 켜고 손으로 확인할 필요 없이, 저장소가 막힌 환경을 코드가 계속 흉내 내 준다.

개발자가 저장소 쓰기를 강제로 실패시키는 테스트 코드를 작성해 다크모드가 풀리는 순간을 화면으로 확인하는 모습을 표현한 일러스트
재현이 안 되던 증상을 실패 조건을 직접 만들어 화면에 다시 띄운 순간을 표현한 이미지.

쿠키를 두 번째 창구로 얹은 설계

고친 방향은 단순했다. localStorage 하나에만 기대던 구조에 서버가 읽을 수 있는 두 번째 채널을 얹었다. theme 쿠키다. 쿠키는 요청 헤더에 실려 서버까지 도착하니, 서버가 첫 렌더링을 하는 순간부터 사용자의 선택을 알 수 있다.

root loader가 요청에 담긴 쿠키를 읽어 <html class="dark">를 서버 쪽에서 직접 렌더링하도록 바꿨다. 이제 서버가 기준값을 쥐게 됐다. localStorage는 여전히 남아 있지만 유일한 통로가 아니라 두 통로 중 하나가 됐다.

여기에 함정이 하나 있었다. 서버가 쿠키를 보고 class="dark"를 렌더링해도, 화면 깜빡임을 막으려고 심어 둔 인라인 스크립트가 그 클래스를 제 마음대로 벗겨낼 수 있다. 그 스크립트가 예전처럼 localStorage만 확인하고 쿠키를 안 본다면, 서버가 애써 그려 둔 클래스를 클라이언트가 도로 지워 버리는 셈이다. 그래서 클라이언트 스크립트의 판단을 서버 결론과 어긋나지 않게 맞췄다. 쿠키가 있으면 서버가 이미 그 값으로 렌더했으니 스크립트도 쿠키를 따르고, 쿠키가 없을 때만 localStorage를, 그마저 없으면 시스템 색상 선호(prefers-color-scheme) — 브라우저나 운영체제가 사용자의 다크·라이트 선호를 알려주는 값 — 를 본다. 클라이언트 스크립트가 이 순서를 그대로 따르니 첫 렌더링과 그 직후 스크립트 실행이 서로 다른 결론을 내는 일이 사라졌다. 부수 효과로 잠깐 밝은 화면이 스쳐 지나가던 라이트 플래시 현상도 같이 없어졌다.

이 우선순위 통일이 왜 중요한지는 하이드레이션(hydration) — 서버가 미리 그려 보낸 정적 HTML에 클라이언트 자바스크립트가 다시 붙어 눌러도 반응하는 화면으로 바꾸는 과정 — 을 알면 더 분명해진다. 서버가 그린 결과와 클라이언트가 그다음에 그리는 결과가 한 프레임이라도 어긋나면 화면이 깜빡이거나, 이번 버그처럼 방금 그려진 값을 뒤엎어 버릴 수 있다. 우선순위 규칙을 서버·클라이언트 어느 쪽에서 실행하든 같은 답이 나오게 맞추는 것, 그게 이번 수정의 핵심이었다.

실제 구현은 새 모듈 하나로 정리했다. 쿠키 문자열에서 값을 뽑아내는 함수, 그 값을 dark 또는 light 허용목록(allowlist)과 대조해 둘 중 하나가 아니면 무조건 null로 처리하는 함수, 쿠키를 만들어 응답에 실어 보내는 함수 셋이다. 쿠키를 만들 때는 네 가지 속성을 붙였다. 경로 범위(Path)는 /로 둬서 사이트 전체 어느 페이지에서든 이 쿠키가 적용되게 했다. 유효기간(Max-Age)은 1년으로 잡아 자주 다시 고르는 번거로움을 없앴다. 사이트 간 전송 제한(SameSite)은 Lax로 뒀는데, 이러면 다른 사이트에서 링크를 타고 들어올 때 정도는 쿠키가 따라가되 위험한 교차 사이트 요청에는 안 실린다. 보안 연결 전용(Secure) 속성은 HTTPS로 접속했을 때만 붙였다. Secure 속성이 붙은 쿠키는 HTTPS가 아니면 저장되지 않고, localhost만 이 요건의 예외다. 그래서 일반 HTTP 프리뷰에는 Secure를 붙이지 않고 운영 HTTPS에서만 붙이도록 조건을 나눴다.

요청이 도착해 쿠키를 읽고 허용목록을 검사한 뒤 화면 클래스를 렌더링하기까지의 순서와 쿠키·로컬스토리지·시스템 선호 사이의 우선순위를 나타낸 다이어그램
쿠키가 없을 때만 localStorage, 그마저 없을 때만 시스템 선호로 넘어간다.

검증 규모와 남은 숙제

고치고 나서 확인한 결과는 아래 표로 정리했다. 실제 서비스에 반영한 뒤 다시 확인했을 때도 결과는 같았고, 서버가 내려보낸 HTML 자체에 class="dark"가 박혀 있는 것도 확인했다. 표의 항목 중 가장 깨지기 쉬웠던 쪽은 운영체제가 다크인데 사용자가 라이트를 직접 고른 경우였다. 우선순위 규칙이 조금만 어긋나도 시스템 값이 사용자의 명시적 선택을 덮어써 버리는 방향으로 깨지기 때문이다. 사용자가 직접 내린 선택은 시스템 기본값보다 항상 위에 있어야 하는데, 그 순서가 코드 어딘가에서 뒤집히면 겉으로는 멀쩡해 보여도 방금 고른 설정이 신호 없이 무시된다.

점검 항목 결과
저장소 쓰기 실패 조건에서 다크 유지 PASS
OS 다크 + 사용자 라이트 선택 유지 PASS
정상 저장 경로 회귀 없음
실제 서비스 SSR 결과물의 class 확인 class="dark" 확인
보안 감사 등급별 결함 Critical·High·Medium 0건, Low 3건

변경 범위가 작아 보여도 검증은 전체 스위트를 다 돌렸다. 가장 조심스러웠던 부분은 쿠키 값을 읽는 함수 하나를 인증(auth) 코드 경로에서 옮겨 온 지점이었다. 옮기면서 함수 본문 로직은 한 글자도 안 바꿨다는 걸 증명해야 했는데, 육안 대조 대신 sha256 해시값을 비교했다. 바이트 단위로 완전히 같은 내용이면 해시값도 완전히 같게 나온다는 성질을 이용한 방법이다. 옮겨 온 함수 본문은 297바이트 그대로였고 sha256도 일치했다. 달라진 곳은 선언부에 export를 붙인 한 군데뿐이다. 여기에 더해 입력값 10종을 양쪽에 그대로 넣어 결과가 똑같이 나오는 것도 따로 확인했다. 인증 경로에 붙어 있던 함수라서 더 엄격하게 봤다. 눈으로 코드를 두 번 읽고 "똑같아 보인다"고 넘기는 것과, 해시값이 완전히 일치한다는 걸 확인하는 것은 신뢰의 무게가 다르다. 본문 쪽에 한 글자라도 바뀐 게 있었다면 해시값부터 달라졌을 것이다.

번들 경계도 같이 점검했다. 이 쿠키 관련 코드가 브라우저로 나가는 청크는 159바이트뿐이었다(서버 전용 함수 둘은 그 안에 없었다). import 개수도 0개였다. 쓰지 않는 코드를 빌드 시점에 걸러내는 트리 셰이킹(tree-shaking) 덕분에, 서버 전용으로 남아야 할 함수들이 애초에 브라우저 번들에 실리지 않은 것이다.

남은 낮음 등급 3건은 아직 손대지 않았다. 하나는 쿠키를 만드는 함수에 실행 시점 값 검증이 없다는 점이다. 지금 호출하는 쪽이 항상 boolean 값을 삼항 연산으로 넘기니 실제로 잘못된 값이 들어올 길은 없지만, 그건 타입 수준의 방어일 뿐 실행 시점 가드는 아니다. 다른 하나는 인증 경로 함수와 클라이언트로 나가는 함수가 한 모듈 안에 같이 있다는 점이다. 지금은 트리 셰이킹이 알아서 걸러 주고 있지만 그 분리를 지켜 주는 별도 장치는 없다. 누군가 무심코 import 하나를 잘못 추가하면 서버 전용 함수가 클라이언트 번들에 딸려 나갈 수 있다. 나머지 하나는 형제 서브도메인 사이의 쿠키 겹침(shadowing) 문제인데, 영향 범위가 색상 표시 하나뿐이라 우선순위가 낮았다.

비슷한 패턴을 다른 글에서도 본 적 있다. 예약 발행 기능도 상태값 하나만 저장하는 걸로는 안 끝났다. 공개 시점을 결정하는 값 하나가, 그 값을 확인해야 하는 모든 지점에 똑같이 반영돼야 비로소 기능이 완성됐다. 저장소 하나, 상태값 하나에만 기대는 설계는 겉으로 단순해 보여도 그 하나가 막히거나 다른 지점과 어긋나는 순간 조용히 무너진다. 다음으로 손볼 곳은 두 번째 Low 항목이다. 트리 셰이킹이라는 빌드 최적화에 기대는 대신, 서버 전용 함수가 클라이언트 번들에 섞이면 빌드 자체가 실패하도록 만드는 장치를 붙여 볼 생각이다.

자주 묻는 질문

localStorage와 쿠키를 모두 막은 환경도 이 수정으로 해결되나요?

아니다. 이 수정은 localStorage 쓰기만 실패하고 쿠키는 살아 있는 경우를 위한 것이다. 두 저장소가 함께 차단되면 두 번째 통로도 막히므로 테마 선택을 다음 방문까지 보존하는 해결책이 되지 못한다.

서버와 클라이언트가 테마를 고르는 우선순위는 무엇인가요?

쿠키가 있으면 서버와 클라이언트 모두 그 값을 따르고, 쿠키가 없을 때만 localStorage를 본다. 둘 다 값이 없을 때에만 prefers-color-scheme의 시스템 색상 선호를 사용한다.

Secure 쿠키 속성을 모든 환경에서 항상 붙이지 않은 이유는 무엇인가요?

Secure 속성이 붙은 쿠키는 HTTPS가 아니면 저장되지 않는다. 단, localhost는 이 HTTPS 요건의 예외다. 그래서 운영 환경에서는 HTTPS일 때 Secure를 붙이고, 일반 HTTP 프리뷰에는 붙이지 않도록 조건을 나눴다.

정상 브라우저에서 재현되지 않던 저장 실패를 어떻게 테스트했나요?

Playwright에서 Storage.prototype.setItem이 강제로 예외를 던지게 해 localStorage 쓰기 실패 조건을 만들었다. 그 상태에서 토글 뒤 새로고침 시 다크 클래스가 사라지는 흐름을 재현하고 자동 테스트로 남겼다.