웹뷰 다크모드 대응기: 깜빡임 현상과 SSR의 한계를 넘어선 방법
Next.js 페이지를 iOS·Android WebView에 임베딩하면, 앱은 다크모드인데 웹 화면은 라이트로 뜨거나 잠깐 라이트가 보였다가 다크로 바뀌는 깜빡임(FOUC, Flash of Unstyled Content)이 자주 발생합니다. 동시에 SSG·ISR로 만든 static optimization은 그대로 유지하고 싶고, 테마는 네이티브 앱 설정(시스템 테마·앱 내 토글)을 따라가야 합니다.
이 글은 SSR 헤더 분기와 프론트 JS 테마 상태를 시도했다가 기각한 이유, 그리고
prefers-color-scheme + CSS 변수 + 네이티브 color scheme 강제 + 앱 버전 분기로 깜빡임 없이
맞춘 패턴을 정리합니다.
문제: WebView에서 다크모드가 어긋나는 이유
Next.js가 SSG/ISR로 내보내는 HTML은 빌드 시점에 테마 정보가 없습니다. WebView가 이 HTML을 받아 paint할 때 기본값은 라이트이고, 이후 JavaScript가 테마를 읽어 class를 붙이거나 CSS를 덮어쓰면 그 사이에 깜빡임이 생깁니다.
sequenceDiagram participant Native as NativeApp participant WebView as WebView participant Next as NextJS_StaticHTML Native->>WebView: load URL WebView->>Next: GET page.html Next-->>WebView: static HTML (light default) Note over WebView: paint light theme WebView->>WebView: JS reads theme / applies class Note over WebView: flash to dark theme
시도했지만 채택하지 않은 접근
| 접근 | 설명 | 왜 기각했는지 |
|---|---|---|
| 요청 헤더 + SSR 분기 |
네이티브가 WebView 요청에 X-Theme: dark 등을 주입 → 서버에서 HTML/class 분기
|
요청마다 HTML이 달라져 SSG/ISR 캐시 무효, CDN edge 캐시·빌드 산출물 재사용 불가 |
| 프론트 JS 테마 상태 |
localStorage, React context, classList 등 웹 단독 테마 관리
|
네이티브 설정과 이중 소스 → 불일치·동기화 버그, hydration 이후에야 반영 → 깜빡임 |
Next.js 맥락에서의 SSR 분기 한계
App Router에서 cookies() / headers()를 읽으면 해당 라우트는
dynamic rendering으로 전환됩니다. Pages Router의 getServerSideProps도
마찬가지입니다. 테마 헤더마다 HTML이 달라지면 ISR로 캐시해 둔 페이지를 그대로 쓸 수 없고, CDN edge에서
사용자별 HTML 분기도 필요해집니다.
테마별로 generateStaticParams로 N벌 빌드하는 방법도 있지만, 조합이 늘수록 빌드·배포·캐시
비용이 커지고 네이티브와 웹의 테마 정의가 따로 놀면 동기화 버그가 남습니다.
프론트 단독 테마 정의의 함정
웹에서 theme: 'dark'를 별도로 정의하면, 네이티브 앱 설정과 값이 어긋날 여지가 생깁니다.
앱에서 다크로 바꿨는데 웹은 localStorage의 라이트를 유지한다든지, bridge 이벤트를 놓쳐
반영이 늦는다든지 하는 버그가 반복됩니다. React useEffect로 document.documentElement에
class를 붙이는 패턴도 첫 paint 이후에 실행되므로 FOUC를 근본적으로 막지 못합니다.
채택한 핵심: prefers-color-scheme를 단일 진실 공급원으로
웹은 테마 값을 “저장”하지 않습니다. OS/브라우저가 보고하는 color scheme만 따르고, 네이티브는 WebView에 앱 테마와 동일한 color scheme을 강제합니다. 사용자가 앱에서 테마를 바꾸면 네이티브가 WebView color scheme을 다시 맞추고, 웹은 별도 동기화 코드 없이 CSS만으로 따라갑니다.
flowchart LR AppTheme["App theme setting"] NativeOverride["Native forces color scheme"] MediaQuery["prefers-color-scheme"] CSSVars["CSS variables"] Paint["First paint correct theme"] AppTheme --> NativeOverride NativeOverride --> MediaQuery MediaQuery --> CSSVars CSSVars --> Paint
웹: CSS 변수 + prefers-color-scheme
컬러 토큰은 CSS custom property로 정의하고, @media (prefers-color-scheme: dark)에서
덮어씁니다. 컴포넌트는 토큰만 참조하므로 JS 테마 토글이 필요 없고, CSS 파싱 시점부터 올바른 값이
적용됩니다.
:root {
--color-bg: #ffffff;
--color-text: #111111;
--color-border: #e5e5e5;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #1a1a1a;
--color-text: #f5f5f5;
--color-border: #333333;
}
}
body {
background: var(--color-bg);
color: var(--color-text);
}
네이티브: WebView color scheme 강제
iOS (WKWebView) — 앱 테마에 맞게 overrideUserInterfaceStyle을 설정하면
WebView의 prefers-color-scheme이 따라갑니다.
// 앱 테마가 dark일 때
webView.overrideUserInterfaceStyle = .dark
// light일 때
webView.overrideUserInterfaceStyle = .light
// 시스템 설정을 따를 때
webView.overrideUserInterfaceStyle = .unspecified
사용자가 앱 설정에서 테마를 바꿀 때마다 위 값을 다시 적용하면, 웹은 bridge 없이 CSS만으로 즉시 반영됩니다.
Android (WebView) — Activity·Application의 UI mode를 앱 테마와 일치시킵니다. API 29+
에서는 WebView가 이 설정을 prefers-color-scheme에 반영합니다.
// 앱 테마가 dark일 때 (AppCompat)
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
// light일 때
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
// WebView Activity에서 uiMode 확인
val isDark = (resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
구형 WebView는 prefers-color-scheme 미지원일 수 있으므로, 아래 버전 분기와 함께 라이트
fallback을 두는 것이 안전합니다.
네이티브 버전 분기: 구버전 호환 + 신규 다크모드
다크모드 WebView 연동은 특정 앱 버전 이상에서만 지원해야 하는 경우가 많습니다. 그
이전 버전 WebView에서는 라이트 고정(다크 CSS 미적용)이 요구사항일 수 있습니다. 이때 SSR로 HTML을
나누지 않고, render-blocking inline script + data-* attribute + CSS
selector 조합으로 한 벌의 static HTML에서 분기합니다.
1. head 최상단 blocking script
CSS 파싱 전에 <html>에 attribute가 있어야 하므로, async·
defer 없이 inline script를 <head> 최상단에 둡니다. 네이티브가
주입하는 앱 버전(예: window.__APP_VERSION__)을 읽어 기준 버전 이상이면 attribute를
설정합니다.
<script>
(function () {
var MIN_VERSION = '2.5.0';
var current = window.__APP_VERSION__ || '0.0.0';
if (compareSemver(current, MIN_VERSION) >= 0) {
document.documentElement.setAttribute('data-dark-mode-support', 'true');
}
function compareSemver(a, b) {
var pa = a.split('.').map(Number);
var pb = b.split('.').map(Number);
for (var i = 0; i < 3; i++) {
if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
}
return 0;
}
})();
</script>
스크립트는 semver 비교만 수행하도록 짧게 유지합니다. 외부 fetch나 bridge async 호출은 첫 paint를
지연시키므로 넣지 않습니다. 버전 문자열은 네이티브가 페이지 load 전
evaluateJavaScript / addJavascriptInterface로 주입합니다.
2. CSS: data attribute × prefers-color-scheme
data-dark-mode-support가 없는 구버전 WebView는 라이트 토큰만 적용됩니다. attribute가
있는 신버전에서만 다크 미디어 쿼리가 활성화됩니다.
/* 기본: 라이트 (모든 버전) */
:root {
--color-bg: #ffffff;
--color-text: #111111;
}
/* 다크 지원 네이티브 + color scheme dark 일 때만 */
@media (prefers-color-scheme: dark) {
:root[data-dark-mode-support='true'] {
--color-bg: #1a1a1a;
--color-text: #f5f5f5;
}
}
| 구분 | 구버전 WebView | 신버전 WebView |
|---|---|---|
data-dark-mode-support |
없음 | true |
prefers-color-scheme: dark |
무시 (라이트 고정) | CSS 변수 다크 적용 |
| static HTML | 동일 1벌 | 동일 1벌 |
3. Next.js 적용 위치
App Router에서는 app/layout.tsx의 <html> 직후, Pages Router에서는
pages/_document.tsx의 <Head> 안에 inline script를 배치합니다. 글로벌
CSS 또는 design token 파일에 위 selector 패턴을 적용하면, SSG/ISR HTML은 버전 무관 1벌로
빌드·캐시할 수 있습니다.
// app/layout.tsx (App Router)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function () {
var MIN_VERSION = '2.5.0';
var current = window.__APP_VERSION__ || '0.0.0';
if (compareSemver(current, MIN_VERSION) >= 0) {
document.documentElement.setAttribute('data-dark-mode-support', 'true');
}
function compareSemver(a, b) { /* ... */ }
})();
`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}
트레이드오프·주의사항
- blocking script — 의도적 trade-off입니다. semver 비교 수 ms 이내로 끝나도록 inline만 유지하고, 외부 리소스 로드를 넣지 않습니다.
-
일반 브라우저 —
window.__APP_VERSION__이 없으면 attribute가 설정되지 않아 라이트-only로 동작합니다. 마케팅 페이지를 브라우저에서도 다크로 보여줘야 한다면 별도 분기(예: WebView UA 감지 없이 시스템prefers-color-scheme만 따르는 CSS)를 검토합니다. -
JS 테마 class와 병행 금지 —
useEffect로darkclass를 붙이는 기존 패턴과 섞으면 다시 FOUC·네이티브 불일치가 발생합니다. CSS 변수 + 미디어 쿼리로 통일합니다. -
네이티브 테마 변경 — iOS
overrideUserInterfaceStyle, AndroidAppCompatDelegate변경 시 WebView를 reload하지 않아도 color scheme 미디어 쿼리가 재평가됩니다. 웹 쪽 추가 작업은 없습니다.
요약
- 네이티브 요청 헤더로 SSR 분기하면 SSG/ISR·CDN static optimization을 포기해야 하므로, static HTML 1벌을 유지한다.
- 프론트에서 테마 값을 따로 정의하면 네이티브와 이중 소스가 되어 불일치·깜빡임 버그가 생긴다.
-
prefers-color-scheme+ CSS 변수로 웹 테마를 표현하고, iOS·Android 네이티브가 WebView color scheme을 앱 설정에 맞게 강제한다. -
특정 앱 버전 이상에서만 다크모드를 켜려면, head blocking script로
data-dark-mode-support를 설정하고 CSS selector와prefers-color-scheme을 조합한다. -
React hydration 이후 class 토글·
localStorage테마 상태는 피하고, CSS만으로 첫 paint부터 맞춘다.