안녕하세요! 지난번 글자를 춤추게 만들던 폰트 깜빡임(FOUT/FOIT) 문제는 잘 해결하셨나요? 쾌적해진 텍스트 렌더링에 유저들도 분명 만족하고 있을 겁니다. 자, 오늘은 프론트엔드 개발자들을 당황하게 만드는 또 다른 형태의 무시무시한 깜빡임에 대해 이야기해 볼까 합니다.
요즘 웹사이트에 '다크 모드(Dark Mode)' 지원은 선택이 아닌 필수가 되었죠. 열심히 토글 버튼도 만들고 CSS 변수도 세팅해서 뿌듯하게 배포를 마쳤습니다. 그런데, 새벽에 불을 끄고 내 사이트에 접속하는 순간! 분명 내 OS 테마도 다크 모드고, 어제 다크 모드로 설정해 두고 껐는데도 불구하고... 접속 직후 약 0.5초 동안 새하얀 쌩얼(?) 화면이 번쩍! 하고 나타났다가 까만색으로 바뀝니다. 말 그대로 유저에게 엄청난 '눈뽕' 테러를 가하게 되는 것이죠.
대체 완벽하게 짰다고 생각한 다크 모드 로직이 왜 첫 로딩 때만 하얀색을 뱉어내는지, 이 지긋지긋한 'FOUC(Flash of Unstyled Content)' 현상의 원인과 가장 깔끔한 실전 해결법을 방출해 보겠습니다.

1. 앗 눈부셔! 다크 모드 깜빡임은 도대체 왜 생길까?
이 문제의 근본적인 원인은 바로 '서버와 클라이언트의 정보 불일치'에 있습니다. 특히 Next.js나 Gatsby 같은 SSR(서버 사이드 렌더링) 환경에서 아주 악명이 높죠.
서버가 HTML을 만들어서 유저의 브라우저로 쏴줄 때, 서버는 유저의 컴퓨터가 다크 모드인지 라이트 모드인지 알 길이 없습니다. 브라우저의 localStorage나 OS 설정(prefers-color-scheme)은 오직 유저의 프론트엔드 브라우저 환경에서만 접근할 수 있기 때문이죠. 결국 서버는 "일단 모르겠으니까 기본값인 하얀색(라이트 모드)으로 그려서 보낼게!" 하고 던져버립니다.
브라우저가 이 하얀 화면을 먼저 유저에게 보여준 뒤, 뒤늦게 자바스크립트(React)가 실행되면서 "어? 이 사람 다크 모드 유저네?" 하고 부랴부랴 까만색으로 색칠을 다시 하니까 그 사이의 0.5초 동안 눈부신 깜빡임이 발생하게 되는 것입니다.
2. 우리가 흔히 하는 실수: useEffect의 배신
다크 모드를 처음 구현할 때 가장 많이 하는 실수가 바로 React의 useEffect 안에서 테마를 판별하려고 하는 것입니다.
// 깜빡임을 절대 막을 수 없는 안타까운 코드 (X)
useEffect(() => {
const localTheme = localStorage.getItem('theme');
if (localTheme === 'dark') {
document.documentElement.classList.add('dark');
}
}, []);
useEffect는 리액트 컴포넌트가 브라우저에 '다 그려지고 난 후(Mount 된 후)'에 실행되는 녀석입니다. 즉, 이미 유저의 눈에 새하얀 화면이 보여진 다음에야 뒤늦게 다크 모드 클래스를 끼워 넣는다는 뜻이죠. 여기서는 아무리 발버둥을 쳐도 깜빡임을 막을 수 없습니다.

3. 완벽한 해결책: 렌더링을 멈추는 '블로킹 스크립트' 삽입하기
이 깜빡임을 잡는 가장 확실하고 유일한 방법은, 브라우저가 화면에 하얀색을 칠하기 그 직전에 멱살을 잡고 "잠깐! 테마부터 확인해!"라고 명령하는 것입니다. 이를 위해 순수 자바스크립트(Vanilla JS)로 짠 작은 스크립트를 HTML의 <head> 태그 안이나 <body> 태그의 맨 상단에 직접 꽂아 넣어야 합니다.
<!-- index.html 또는 Next.js의 _document.tsx 파일에 삽입 (O) -->
<script>
(function() {
try {
var localTheme = localStorage.getItem('theme');
var systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
// 로컬 스토리지에 다크모드가 있거나, OS 설정이 다크모드라면?
if (localTheme === 'dark' || (!localTheme && systemTheme)) {
document.documentElement.classList.add('dark');
}
} catch (e) {}
})();
</script>
이 스크립트는 리액트가 로드되기도 전에 브라우저가 HTML을 위에서 아래로 읽어 내려가는 과정에서 즉시 실행됩니다. 화면을 그리기(Painting) 전에 찰나의 순간 스토리지 값을 확인하고 <html> 태그에 미리 dark 클래스를 붙여버리기 때문에, 유저는 하얀 화면을 단 1밀리초도 보지 않고 곧바로 평온한 다크 모드를 만날 수 있게 됩니다.
마무리하며
오늘은 새벽에 유저의 시력을 위협하는 치명적인 다크 모드 깜빡임(FOUC) 버그의 원인과, 이를 단 한 줄의 블로킹 스크립트로 깔끔하게 잠재우는 방법에 대해 알아보았습니다. 프론트엔드 프레임워크가 아무리 발전해도, 브라우저가 HTML을 파싱하고 렌더링하는 순서를 정확히 이해하지 못하면 이런 디테일한 사용자 경험(UX)을 놓치기 쉽습니다. 만약 Next.js를 사용 중이시라면 next-themes라는 아주 훌륭한 라이브러리가 이 스크립트 삽입 과정을 자동으로 알아서 처리해 주니 꼭 도입해 보시길 추천해 드립니다!
[다음 포스팅 예고] 유저의 시력을 안전하게 지켜냈으니, 다음번에는 우리의 멘탈을 위협하는 지독한 호환성 지옥으로 떠나보겠습니다. "내 크롬에서는 레이아웃이 예쁜데, 부장님의 구형 브라우저에서는 화면이 다 깨져요!" 프론트엔드 개발자들의 영원한 숙제, 바로 "크로스 브라우징의 악몽, Flexbox와 Grid 구버전 브라우저 대응기" 편으로 돌아오겠습니다. IE는 죽었지만 사파리 구버전이 여전히 우리를 괴롭히고 있죠. 크로스 브라우징 문제로 고통받고 계신다면 다음 글도 절대 놓치지 마세요! 오늘도 버그 없는 평온한 하루 보내시길 응원합니다!
[실무에서 복붙해 쓰는 에러 해결 및 트러블슈팅 가이드 #10] 내 크롬에선 완벽했는데? Flexbox gap
안녕하세요! 지난번 유저의 시력을 위협하던 다크 모드 깜빡임(FOUC)은 무사히 잠재우셨나요? 블로킹 스크립트 한 줄의 위력을 실감하셨길 바랍니다! 자, 오늘은 프론트엔드 개발자들의 영원한
code-bricks.tistory.com