블로그 플랫폼을 만들면서 "다크 모드 지원하세요?"라는 질문을 수도 없이 받았다. 그때마다 "당연하죠"라고 대답했는데, 정작 커스텀 CSS 에디터를 열어주는 건 전혀 다른 차원의 문제였다.
prefers-color-scheme 하나로 끝나는 줄 알았다
Postlark 블로그의 독자 쪽 다크 모드는 사실 간단한 편이다. 블로그 HTML은 서버에서 렌더링되고 JavaScript는 0바이트다. 그래서 prefers-color-scheme: dark 미디어 쿼리 하나로 시스템 설정을 따라가게 했다. CSS 변수로 배경, 텍스트, 보더 색상을 잡아두고, 다크 모드에서 값만 바꾸는 구조.
여기까지는 교과서적이다. 문제는 디테일에 있었다.
이미지 처리가 대표적이다. 다크 배경에서 밝은 이미지를 그대로 보여주면 눈이 부시다. 처음에는 brightness(0.85)를 걸었는데, 사진이 너무 칙칙해 보인다는 피드백이 왔다. 결국 brightness(0.92) contrast(1.05)로 조정했다. 미세한 차이인데 체감은 꽤 크다. data-no-dark 속성을 넣으면 필터를 끌 수 있게 해뒀다 — 로고처럼 이미 다크 배경용으로 만들어진 이미지가 있으니까.
코드 블록은 Shiki 듀얼 테마로 해결했다. 라이트에서는 밝은 테마, 다크에서는 어두운 테마가 적용된다. 이건 Shiki가 잘 만들어져 있어서 별 고생 없이 됐다.
대시보드 다크 모드는 좀 더 복잡했다
블로그 독자 쪽은 OS 설정만 따라가면 되지만, 대시보드는 사용자가 직접 테마를 고를 수 있어야 한다. System / Dark / Light 세 가지 옵션. 사이드바 하단에 토글 버튼을 넣고, localStorage에 저장하고, document.documentElement에 data-theme 속성을 설정하는 방식으로 구현했다.
골치 아팠던 건 FOUC(Flash of Unstyled Content)다. React 앱이 로드되기 전에 잠깐 라이트 모드가 번쩍이는 현상. index.html의 <head>에 인라인 스크립트를 넣어서 localStorage를 먼저 읽고 테마를 적용하게 했다. React가 마운트되기도 전에 올바른 테마가 잡힌다.
다크 모드 팔레트는 블로그 쪽과 통일했다. 대시보드, 블로그, 랜딩 — 전부 같은 slate 계열을 쓴다. #0f172a 배경에 #f1f5f9 텍스트. 나중에 브랜드 컬러를 blue에서 purple(#5944EF)로 갈아탔을 때도 이 구조 덕에 토큰 값만 바꾸면 전 서비스가 한 번에 바뀌었다.
커스텀 CSS를 여는 순간 판도라의 상자가 열린다
다크 모드까지는 우리가 통제하는 영역이다. 문제는 사용자에게 CSS 에디터를 열어주는 것이었다.
"Custom CSS 넣을 수 있게 해주세요"라는 요청은 단순해 보이지만, 실제로 구현하면 바로 보안 문제가 터진다. CSS에 @import url(...) 을 넣으면 외부 리소스를 로드할 수 있고, url() 안에 트래킹 픽셀을 심을 수 있고, expression()이나 -moz-binding 같은 레거시 기능으로 스크립트를 실행할 수도 있다. 남의 블로그에 방문한 독자가 피해를 볼 수 있는 상황을 만들면 안 된다.
그래서 CSS sanitizer를 붙였다. @import를 차단하고, 위험한 함수를 필터링한다. 한 번은 CSS 주석 안에 @import를 숨기는 우회를 발견해서 — 주석을 먼저 제거한 뒤에 검사하도록 수정했다. 이런 건 직접 당해봐야 안다.
data attribute이라는 작은 아이디어
커스텀 CSS를 제공하면서 고민한 게 하나 있었다. 블로거가 자기 블로그 이름이나 설명을 CSS에서 참조하고 싶을 때가 있다. content: attr(data-blog-name) 같은 식으로. 그래서 <body>에 data-blog-name, data-blog-description, data-blog-domain 같은 data attribute를 넣어뒀다.
대시보드의 CSS 에디터 옆에는 물음표 아이콘을 달아서, 사용 가능한 CSS 클래스, data attribute, 변수 목록을 보여주는 도움말 모달을 만들었다. 문서를 따로 찾아보지 않아도 에디터 안에서 바로 확인할 수 있게.
헤더/푸터 HTML 커스텀도 같은 맥락이다. 커스텀 헤더/푸터를 설정하지 않으면 기본 헤더(블로그 이름 링크)와 기본 푸터(저작권 표시)가 나온다. 설정하면 그걸로 대체된다. 이건 Starter 플랜에서는 CSS만, Creator 플랜부터 HTML까지 열어주는 식으로 나눴다.
CDN 캐시라는 복병
테마 커스터마이징 기능을 다 만들고 나서 뿌듯하게 테스트하는데, CSS를 바꿔도 포스트 페이지에 반영이 안 됐다. 홈페이지는 바뀌는데 개별 글은 안 바뀌는 거다.
원인은 CDN 캐시였다. 기존 캐시 퍼지 로직은 홈(/), RSS, 사이트맵만 날리고 있었다. 포스트 페이지는 24시간 캐시가 걸려 있었으니 당연히 안 바뀌지. 결국 테마가 바뀔 때 KV에서 포스트 목록을 읽어서 전체 URL을 퍼지하도록 고쳤다.
사소해 보이는 버그인데, 실제 사용자 입장에서는 "CSS 저장했는데 왜 안 바뀌어요?"가 된다. CDN 앞단에 커스터마이징을 두면 항상 이런 함정이 있다.
자유를 주되, 기본값을 잘 만들어야 한다
Postlark Reader 앱에서는 세피아를 기본 테마로 바꿨다. 처음엔 라이트가 기본이었는데, 모바일에서 장시간 읽기엔 세피아가 더 편하다는 판단이었다. 이것도 결국 "사용자가 아무것도 안 건드려도 괜찮은 경험"을 만드는 문제다.
다크 모드든 커스텀 CSS든, 핵심은 같다고 생각한다. 대부분의 사용자는 기본값 그대로 쓴다. 그러니 기본값이 충분히 좋아야 한다. 그리고 바꾸고 싶은 사람에게는 깨지지 않는 범위 안에서 최대한의 자유를 줘야 한다. 그 "깨지지 않는 범위"를 정하는 게 기능 구현보다 어려웠다.