Next.js 15에서 PWA 설정하여 블로그 글 캐싱하기

Next.js 15에서 PWA 설정하여 블로그 글 캐싱하기

김태홍 (bluemiv)
최종 수정일: 2025-04-15 13:13:07
작성일: 2025-01-16 10:47:29
AD

1. PWA란?

PWA는 웹 애플리케이션을 네이티브 앱처럼 동작하게 만드는 기술입니다. PWA는 다음과 같은 기능을 제공해줍니다.

  1. 오프라인 지원: 서비스 워커(service worker)를 사용하여 네트워크 연결이 없거나 불안정한 상황에서도 애플리케이션을 사용할 수 있음
  2. 푸시 알림: 사용자가 애플리케이션을 사용하지 않을 때에도 푸시 알림을 통해 정보 전달이 가능
  3. 앱 설치: 사용자가 애플리케이션을 설치하여 네이티브 앱 처러 사용할 수 있음
  4. 빠른 로딩 속도: 서비스 워커의 캐싱 기능을 사용하여, 애플리케이션의 로딩 속도를 향상시킬 수 있음

2. Next.js 15에서 PWA 설정하기

2.1. manifest.json 생성

다음과 같이 app/manifest.webmanifest/route.ts 작성하여 PWA의 메타데이터(앱 이름, 아이콘, 시작 URL 등)를 설정합니다.

// app/manifest.webmanifest/route.ts
export function GET() {
  const manifest = {
    name: 'My PWA App',
    short_name: 'PWAApp',
    description: 'A Progressive Web App built with Next.js',
    start_url: '/',
    display: 'standalone',
    background_color: '#ffffff',
    theme_color: '#000000',
    icons: [
      {
        src: '/icons/icon-192x192.png',
        sizes: '192x192',
        type: 'image/png',
      },
      {
        src: '/icons/icon-512x512.png',
        sizes: '512x512',
        type: 'image/png',
      },
    ],
  };
 
  return new Response(JSON.stringify(manifest), {
    headers: {
      'Content-Type': 'application/manifest+json',
    },
  });
}

그리고, manifest의 경로를 metadata에 명시합니다.

export const metadata: Metadata = {
  // ...
  icons: { icon: '/favicon.ico' },
  manifest: '/manifest.webmanifest',
};
 
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  // ...
}

2.2. service worker 설정

public/sw.js 파일을 생성하여 service worker를 설정합니다. 이 파일은 PWA의 오프라인 기능과 푸시 알림을 처리하는 데 사용됩니다.

self.addEventListener('install', (event) => {
  console.log('서비스 워커 설치됨');
});
 
self.addEventListener('activate', (event) => {
  console.log('서비스 워커 활성화됨');
});

이후 sw.js를 등록하는 hook을 만듭니다. app/layout.tsx 파일에 다음 코드를 추가합니다.

import { useEffect } from 'react';
 
export default function useRegisterServiceWorker() {
  useEffect(() => {
    if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
      window.addEventListener('load', () => {
        navigator.serviceWorker
          .register('/sw.js')
          .then((registration) => {
            console.log('서비스 워커 등록 성공:', registration);
          })
          .catch((error) => {
            console.error('서비스 워커 등록 실패:', error);
          });
      });
    }
  }, []);
}

최초에는 Service Worker가 설치되고 활성화되는 콘솔 로그가 출력됩니다. 이후 페이지를 새로고침하면 Service Worker가 등록되는 콘솔 로그만 출력됩니다.

Service Worker 설치 및 등록 콘솔 출력
Service Worker 설치 및 등록 콘솔 출력

2.3. 블로그 글 캐싱

Service Worker 등록까지 완료되었으니, 블로그 글을 캐싱해야 합니다. 블로그 글을 캐싱하기 위해서는 fetch 이벤트를 사용하여 네트워크 요청을 가로채고, 캐시된 응답을 반환하는 로직을 추가해야 합니다.

// sw.js
// ...
const CACHE_NAME = 'v1';
 
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
 
  // Serice Worker가 자기 자신(sw.js)은 캐싱하면 안 되므로 제외
  if (/\/sw\.js$/.test(url.pathname)) return;
 
  if (url.pathname.startsWith('/posts/')) {
    event.respondWith(
      caches.open(CACHE_NAME).then((cache) => {
        return cache.match(event.request).then((cachedResponse) => {
          const fetchPromise = fetch(event.request)
            .then((networkResponse) => {
              cache.put(event.request, networkResponse.clone());
              return networkResponse;
            })
 
          // 캐시가 있으면 먼저 응답하고, 없으면 네트워크 응답을 사용
          return cachedResponse || fetchPromise;
        });
      }),
    );
  }
});

이제 /posts/ 하위 경로는 Service Worker에 의해 캐싱됩니다. 블로그 글을 열어보면, 캐싱된 블로그 글이 로드되고 오프라인 상태에서도 블로그 글을 확인할 수 있습니다.

3번째 줄에는 const CACHE_NAME = 'v1'; 가 있습니다. 캐싱 키 값은 새로운 내용이 있을때, 강제로 내용을 업데이트 하기 위해 사용됩니다. 이어서 캐싱 삭제하는 방법에 대해 소개합니다.

2.4. 블로그 글 캐싱 제거

블로그 글이 캐싱되어 글을 수정해도 변경된 내용이 반영되지 않습니다. 따라서, 캐싱된 블로그 글을 제거하는 로직이 필요합니다.

caches.delete() 메서드를 사용하여 캐시를 삭제할 수 있습니다. 다음과 같이 sw.js 파일에 캐시 삭제 로직을 추가합니다.

self.addEventListener('activate', (event) => {
  const cacheWhitelist = [CACHE_NAME];
 
  event.waitUntil(
    caches
      .keys()
      .then((cacheNames) => {
        return Promise.all(
          cacheNames.map((cacheName) => {
            if (!cacheWhitelist.includes(cacheName)) {
              return caches.delete(cacheName);
            }
          }),
        );
      })
      .then(() => self.clients.claim()),
  );
});

activate 이벤트를 사용하여, Service Worker가 활성화가 되었을때, 새로운 캐시 버전이 있다면 이전 캐시를 삭제합니다.

2.5. 빌드시 캐시명 자동 업데이트

CACHE_NAME을 수동으로 업데이트하는 것은 번거롭습니다. 따라서, 빌드 시점에 자동으로 캐시 이름을 업데이트하는 방법을 적용하기 위해 스크립트를 작성해봅시다.

sw.js 캐시명에 __CACHE_NAME__을 할당하여, 빌드시간을 치환하는 방식을 사용할겁니다. 우선 public/build-sw.js를 작성합니다.

// public/build-sw.js
const fs = require('fs');
const path = require('path');
 
const cacheVersion = `blog-${Date.now()}`;
 
const templatePath = path.join(__dirname, '../public/sw.template.js');
const outputPath = path.join(__dirname, '../public/sw.js');
 
let swTemplate = fs.readFileSync(templatePath, 'utf8');
 
// __CACHE_NAME__을 실제 버전(빌드 시간)으로 치환
swTemplate = swTemplate.replace('__CACHE_NAME__', cacheVersion);
 
fs.writeFileSync(outputPath, swTemplate);
 
console.log(`sw.js 생성 완료. CACHE_NAME = ${cacheVersion}`);

위 스크립트는 public/sw.template.js__CACHE_NAME__을 빌드 시간으로 치환하여 sw.js 파일을 새롭게 생성해주는 스크립트 입니다. 그리고, sw.template.js의 캐시명을 수정합니다.

// public/sw.template.js
const CACHE_NAME = '__CACHE_NAME__';
// ...

마지막으로 package.json 스크립트에 prebuild를 추가하여, 빌드 전에 node scripts/build-sw.js 명령어가 실행될 수 있도록 합니다.

prebuild는 build 전에 실행되는 스크립트입니다.

{
  "scripts": {
    "prebuild": "node scripts/build-sw.js",
    "build": "next build"
  },
  ...
}

다음 사진과 같이 빌드 할때마다 캐시 값이 변경되므로 따로 관리하지 않아도 됩니다.

__CACHE_NAME__이 제대로 치환됨
__CACHE_NAME__이 제대로 치환됨
AD