워드프레스 블로그를 모바일에서 앱처럼 실행하고, 밤에는 눈이 편한 다크모드로 전환하고 싶었다. PWA(Progressive Web App)와 다크모드를 직접 구현한 과정을 코드 중심으로 정리한다. GeneratePress 테마 기준이지만 대부분의 테마에 적용 가능하다.
1. manifest.json — PWA의 신분증
브라우저가 “이 사이트는 앱으로 설치할 수 있다”고 판단하려면 manifest.json이 필요하다. 워드프레스 루트 디렉토리(/home/wpadmin/public_html/manifest.json)에 아래 내용을 넣는다.
{
"name": "무엇이든알아보자",
"short_name": "알아보자",
"description": "운세, 심리 테스트, 계산기, IT 정보까지",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3498db",
"orientation": "portrait-primary",
"icons": [
{
"src": "/wp-content/pwa-icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/wp-content/pwa-icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
각 필드가 하는 일:
- name — 설치 프롬프트와 스플래시 화면에 표시되는 전체 이름
- short_name — 홈 화면 아이콘 아래 표시되는 축약 이름. 12자 이내 권장
- description — 앱스토어 스타일 설명. PWA 설치 UI에서 노출
- start_url — 앱을 열었을 때 첫 화면 URL.
/로 설정하면 메인 페이지 - display: “standalone” — 브라우저 UI(주소창 등)를 숨기고 네이티브 앱처럼 보이게 한다.
fullscreen,minimal-ui,browser등도 가능 - background_color — 앱 로딩 중 스플래시 화면 배경색
- theme_color — 모바일 브라우저 상단 상태바 색상.
<meta name="theme-color">과 동일한 값을 권장 - orientation —
portrait-primary면 세로 고정. 블로그는 세로가 자연스럽다 - icons — 192×192는 필수, 512×512는 스플래시 화면용. 두 크기 모두 제공해야 크롬에서 설치 프롬프트가 뜬다
2. Service Worker — 오프라인에서도 돌아가게
서비스 워커는 브라우저와 네트워크 사이에 앉아서 요청을 가로채는 프록시다. /sw.js 파일을 워드프레스 루트에 배치한다. 반드시 루트에 있어야 전체 사이트 범위(scope)를 가진다.
const CACHE_NAME = 'nalkkul-v1';
const urlsToCache = [
'/',
'/manifest.json'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) return response;
return fetch(event.request)
.then(response => {
if (!response || response.status !== 200) return response;
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then(cache => cache.put(event.request, responseToCache));
return response;
});
})
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.filter(name => name !== CACHE_NAME)
.map(name => caches.delete(name))
);
})
);
});
동작 흐름을 단계별로 보면:
- install — SW가 처음 등록될 때 실행.
urlsToCache에 지정한 URL을 미리 캐시한다(precache). 여기서는 메인 페이지와 manifest.json만 넣었다 - fetch — 모든 네트워크 요청을 가로챈다. Cache-First 전략: 캐시에 있으면 캐시에서 즉시 응답하고, 없으면 네트워크로 요청한 뒤 응답을 캐시에 저장한다.
response.clone()이 핵심인데, Response 객체는 한 번만 읽을 수 있어서 캐시 저장용으로 복제해야 한다 - activate — 새 버전의 SW가 활성화될 때 실행.
CACHE_NAME이 바뀌면(예:nalkkul-v2) 이전 캐시를 삭제한다. 배포할 때 캐시 이름의 버전 번호만 올리면 자동으로 구 캐시가 정리된다
3. PWA 메타 태그 주입 — mu-plugin 방식
manifest.json과 sw.js를 만들었으면 HTML에서 이것들을 연결해야 한다. 워드프레스에서는 wp-content/mu-plugins/에 PHP 파일을 넣으면 자동으로 로드된다. 활성화/비활성화 없이 항상 실행되므로 인프라성 코드에 적합하다.
wp-content/mu-plugins/pwa-support.php 전체 코드:
<?php
/**
* Plugin Name: PWA Support
* Description: Progressive Web App support with manifest, service worker, and meta tags.
*/
if (!defined('ABSPATH')) exit;
// PWA meta tags via wp_head
add_action('wp_head', function () {
?>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#3498db">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="apple-touch-icon" href="/wp-content/pwa-icons/icon-192.png">
<?php
}, 1);
// Service worker registration via wp_footer
add_action('wp_footer', function () {
?>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js')
.then(function(reg) {
console.log('SW registered:', reg.scope);
})
.catch(function(err) {
console.log('SW registration failed:', err);
});
});
}
</script>
<?php
}, 998);
몇 가지 포인트:
wp_head훅의 우선순위를1로 설정해서 다른 메타 태그보다 먼저 출력한다. theme-color는 빨리 파싱돼야 상태바 색이 깜박이지 않는다wp_footer훅의 우선순위998은 페이지 렌더링을 방해하지 않도록 가능한 늦게 SW를 등록하려는 의도apple-mobile-web-app-capable와apple-touch-icon은 iOS Safari용. iOS는 manifest.json을 완전히 지원하지 않아서 별도 메타 태그가 필요하다- SW 등록은
window.addEventListener('load', ...)안에서 한다. 페이지 로딩이 끝난 뒤에 등록해야 초기 렌더링 성능에 영향을 주지 않는다
4. 다크모드 구현 — CSS 변수 + JS 토글
다크모드는 크게 세 부분이다: (1) CSS 커스텀 프로퍼티로 색상 정의, (2) GeneratePress 테마 셀렉터를 구체적으로 오버라이드, (3) JS로 토글과 상태 저장. 전체 코드가 wp-content/mu-plugins/dark-mode.php 하나에 들어간다.
4-1. CSS 커스텀 프로퍼티
body.dark-mode 클래스가 붙으면 활성화되는 색상 변수를 먼저 정의한다.
/* Dark Mode CSS Variables */
body.dark-mode {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-card: #1f2937;
--text-primary: #e4e4e7;
--text-secondary: #a1a1aa;
--border-color: #374151;
--shadow-color: rgba(0,0,0,0.3);
}
#1a1a2e는 순수 검정이 아니라 약간 남색 기운이 있는 다크 배경이다. 순수 #000은 AMOLED에서는 배터리에 좋지만 가독성이 떨어져서 피했다. --bg-card(#1f2937)는 카드형 레이아웃에서 배경과 구분되는 약간 밝은 톤이다.
4-2. GeneratePress 테마 호환 오버라이드
GeneratePress는 자체 CSS 셀렉터 specificity가 높아서 단순히 body 레벨에서 색을 바꾸면 안 먹힌다. body.dark-mode .셀렉터 형태로 구체적으로 잡아야 한다.
/* Smooth transition */
body, body .site-header, body .main-navigation,
body .site-content, body article, body .inside-article,
body .sidebar .widget, body .site-footer,
body input, body textarea, body select,
body a {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
/* Dark mode overrides for GeneratePress */
body.dark-mode {
background-color: var(--bg-primary) !important;
color: var(--text-primary) !important;
}
body.dark-mode .site-header,
body.dark-mode .main-navigation {
background-color: var(--bg-secondary) !important;
}
body.dark-mode .site-content,
body.dark-mode article,
body.dark-mode .inside-article {
background-color: var(--bg-card) !important;
color: var(--text-primary) !important;
}
body.dark-mode .entry-title a,
body.dark-mode h1, body.dark-mode h2, body.dark-mode h3 {
color: var(--text-primary) !important;
}
body.dark-mode .sidebar .widget {
background-color: var(--bg-card) !important;
}
body.dark-mode input, body.dark-mode textarea, body.dark-mode select {
background-color: var(--bg-secondary) !important;
color: var(--text-primary) !important;
border-color: var(--border-color) !important;
}
body.dark-mode .site-footer {
background-color: var(--bg-secondary) !important;
color: var(--text-secondary) !important;
}
/* Links */
body.dark-mode a { color: #60a5fa !important; }
body.dark-mode a:hover { color: #93c5fd !important; }
/* Cards and generic backgrounds */
body.dark-mode .tg-card,
body.dark-mode .tg-tool-wrap,
body.dark-mode [class*="tgq-wrap"] {
background-color: var(--bg-card) !important;
color: var(--text-primary) !important;
}
!important를 남발하는 게 보기 싫을 수 있지만, GeneratePress가 인라인 스타일이나 높은 specificity 셀렉터로 색상을 지정하기 때문에 어쩔 수 없다. 다크모드처럼 전역적으로 스타일을 뒤집는 기능에서는 !important가 실용적인 선택이다.
주목할 셀렉터들:
.inside-article— GeneratePress 고유 클래스. 글 본문 영역의 안쪽 래퍼다. 이걸 빠뜨리면 글 배경이 하얗게 남는다.entry-title a— 제목이 링크로 감싸져 있어서a까지 잡아야 한다[class*="tgq-wrap"]— GeneratePress Premium의 쿼리 루프 블록 래퍼. 와일드카드 속성 셀렉터로 잡는다- 링크 색상
#60a5fa는 Tailwind CSS의 blue-400. 다크 배경에서 가독성이 좋다
4-3. 플로팅 토글 버튼
화면 왼쪽 하단에 고정되는 원형 버튼이다.
/* Toggle button */
#dark-mode-toggle {
position: fixed;
bottom: 20px;
left: 20px;
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
cursor: pointer;
z-index: 9999;
font-size: 22px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
transition: transform 0.3s ease, background-color 0.3s ease;
background-color: #1a1a2e;
color: #fff;
line-height: 1;
padding: 0;
}
#dark-mode-toggle:hover {
transform: scale(1.1);
}
body.dark-mode #dark-mode-toggle {
background-color: #f0f0f0;
color: #1a1a2e;
}
라이트 모드일 때는 어두운 버튼(달 아이콘), 다크 모드일 때는 밝은 버튼(해 아이콘)으로 반전된다. z-index: 9999로 모든 요소 위에 뜬다.
4-4. JS — localStorage + prefers-color-scheme
상태 관리 로직은 wp_footer 훅으로 주입한다.
<button id="dark-mode-toggle" aria-label="다크 모드 전환">🌙</button>
<script>
(function() {
var btn = document.getElementById('dark-mode-toggle');
var body = document.body;
function setDark(on) {
if (on) {
body.classList.add('dark-mode');
btn.textContent = '☀️';
} else {
body.classList.remove('dark-mode');
btn.textContent = '🌙';
}
}
// Check saved preference or system preference
var saved = localStorage.getItem('dark-mode');
if (saved !== null) {
setDark(saved === '1');
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
setDark(true);
}
btn.addEventListener('click', function() {
var isDark = body.classList.contains('dark-mode');
setDark(!isDark);
localStorage.setItem('dark-mode', isDark ? '0' : '1');
});
})();
</script>
우선순위 체계:
- localStorage에 저장된 값이 있으면 그걸 따른다 (사용자가 명시적으로 선택한 것)
- 저장된 값이 없으면
prefers-color-scheme: dark미디어 쿼리로 OS 설정을 확인한다 - 둘 다 없으면 라이트 모드(기본값)
이 방식의 장점은 사용자가 한 번 다크모드를 켜면 다음 방문에도 유지된다는 것이다. localStorage는 도메인 단위로 영구 저장되므로 쿠키 만료 걱정이 없다.
5. 트러블슈팅 — 실제로 겪은 문제들
5-1. GeneratePress 셀렉터가 안 먹힘
증상: 다크모드를 켜도 글 본문 배경이 하얗게 남는다.
원인: GeneratePress는 .inside-article 클래스에 직접 background-color를 지정한다. body.dark-mode article만으로는 specificity가 부족하다.
해결: body.dark-mode .inside-article을 명시적으로 추가하고 !important를 건다. 브라우저 개발자 도구에서 “Computed” 탭으로 어떤 셀렉터가 이기는지 확인하면서 하나씩 잡아야 한다.
5-2. FOUC (Flash of Unstyled Content)
증상: 다크모드 사용자가 페이지를 새로 열면 흰 화면이 번쩍였다가 어두워진다.
원인: JS가 wp_footer(페이지 하단)에서 실행되므로 DOM이 다 그려진 뒤에야 dark-mode 클래스가 붙는다.
해결 방향: wp_head에 인라인 스크립트를 넣어서 <body>에 클래스를 미리 추가하는 방법이 있다. 다만 현재 구현은 transition이 0.3초라 체감상 크게 거슬리지 않아서 그대로 두었다. 만약 심하다면 아래 스니펫을 wp_head 훅에 추가한다:
<script>
(function(){
var s = localStorage.getItem('dark-mode');
if (s === '1' || (s === null && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark-mode');
}
})();
</script>
5-3. PWA 아이콘 404
증상: Lighthouse에서 “Manifest icon could not be fetched” 경고.
원인: /wp-content/pwa-icons/ 디렉토리가 없거나 아이콘 파일을 안 넣었다.
해결: 192×192와 512×512 크기의 PNG 파일을 반드시 해당 경로에 준비한다. 이미지 변환은 ImageMagick으로 가능:
mkdir -p /home/wpadmin/public_html/wp-content/pwa-icons/
convert source.png -resize 192x192 icon-192.png
convert source.png -resize 512x512 icon-512.png
5-4. Service Worker 캐시가 업데이트 안 됨
증상: 글을 수정했는데 방문자에게 옛날 버전이 보인다.
원인: Cache-First 전략은 캐시에 있으면 무조건 캐시를 쓴다. SW 파일 자체가 바뀌지 않으면 새 버전이 활성화되지 않는다.
해결: CACHE_NAME의 버전을 올린다. 'nalkkul-v1'을 'nalkkul-v2'로 바꾸면 activate 이벤트에서 구 캐시가 삭제되고 새로 캐싱한다. 배포 자동화를 한다면 빌드 해시를 캐시 이름에 넣는 방법도 있다.
5-5. SW 범위(scope) 문제
증상: SW를 등록해도 하위 페이지에서 동작하지 않는다.
원인: sw.js를 /wp-content/ 같은 하위 디렉토리에 넣으면 해당 디렉토리 아래만 스코프가 된다.
해결: sw.js는 반드시 사이트 루트(/sw.js)에 배치한다. 워드프레스의 Nginx 설정에서 sw.js에 대한 접근이 차단되지 않는지도 확인하자.
정리
전체 구조를 한눈에 보면:
/manifest.json— PWA 앱 메타데이터/sw.js— Cache-First 서비스 워커wp-content/mu-plugins/pwa-support.php— manifest 연결 + SW 등록 + iOS 메타 태그wp-content/mu-plugins/dark-mode.php— CSS 변수 + GeneratePress 오버라이드 + 토글 JS
플러그인 설치 없이 mu-plugin 4개 파일로 PWA와 다크모드를 모두 처리했다. mu-plugin 방식이 좋은 이유는 관리자 UI에서 실수로 비활성화할 수 없고, 테마를 바꿔도 그대로 동작한다는 점이다. 코드 전체가 200줄이 안 되니 유지보수 부담도 거의 없다.