블로그워드프레스에 PWA와 다크모드 추가하기 — 앱처럼 만들기

워드프레스에 PWA와 다크모드 추가하기 — 앱처럼 만들기

워드프레스 블로그를 모바일에서 앱처럼 실행하고, 밤에는 눈이 편한 다크모드로 전환하고 싶었다. 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">과 동일한 값을 권장
  • orientationportrait-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))
            );
        })
    );
});

동작 흐름을 단계별로 보면:

  1. install — SW가 처음 등록될 때 실행. urlsToCache에 지정한 URL을 미리 캐시한다(precache). 여기서는 메인 페이지와 manifest.json만 넣었다
  2. fetch — 모든 네트워크 요청을 가로챈다. Cache-First 전략: 캐시에 있으면 캐시에서 즉시 응답하고, 없으면 네트워크로 요청한 뒤 응답을 캐시에 저장한다. response.clone()이 핵심인데, Response 객체는 한 번만 읽을 수 있어서 캐시 저장용으로 복제해야 한다
  3. 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-capableapple-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>

우선순위 체계:

  1. localStorage에 저장된 값이 있으면 그걸 따른다 (사용자가 명시적으로 선택한 것)
  2. 저장된 값이 없으면 prefers-color-scheme: dark 미디어 쿼리로 OS 설정을 확인한다
  3. 둘 다 없으면 라이트 모드(기본값)

이 방식의 장점은 사용자가 한 번 다크모드를 켜면 다음 방문에도 유지된다는 것이다. 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줄이 안 되니 유지보수 부담도 거의 없다.

이 글이 도움이 되셨다면 공유해주세요!

댓글 남기기

무엇이든 물어보세요! 💬