Next.js + PWA를 이용해 웹에서 앱 푸쉬를 보내보자

코틀린스프링프론트엔드
By Jeongmin Seo4월 2일, 2025년

목차

    최근 사이드 프로젝트를 진행하며 웹 기반 서비스지만 앱처럼 쉽게 접근하고, 필요할 때 사용자에게 알림을 보내는 기능이 필요했다.

    웹과 앱의 장점을 최대한 쉽게 취하기 위해 여러 기술 스택을 찾아봤다.

    • Flutter
    • React Native + Capacitor
    • Native + (KMP): 네이티브로 각각 필요한 기능을 구현하고, 공통 로직들은 KMP로 분리
      • 가장 성능이 뛰어나지만, 플랫폼별 개발과 배포 관리에 부담이 있었다.
    • PWA (Progressive Web App): 웹 기술만으로 앱과 유사한 경험을 제공할 수 있었다.

    결론적으로 가장 친숙한 기술로 요구사항을 충족할 수 있는 PWA를 선택했다.
    이 글은 Next.js(Page Router) 프로젝트에 PWA를 구현하며 겪은 과정을 정리한 기록이다.


    1. manifest.json 정의

    PWA의 첫 단계는 웹사이트가 앱처럼 설치 가능하다는 것을 브라우저에게 알려주는 것이다.
    이 역할을 하는 것이 바로 public/manifest.json 파일이다.

    public/manifest.json 파일을 생성하고 아래와 같이 앱의 기본 정보를 정의한다. (앱 이름 등은 실제 서비스에 맞게 수정하면 된다.)

    { "name": "내 멋진 앱", "short_name": "내 앱", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#317EFB", "description": "이 앱은 사용자의 삶을 멋지게 만들어준다.", "icons": [ { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" } ] }

    각 속성의 의미는 다음과 같다.

    속성설명
    name앱 설치 시 표시되는 전체 이름
    short_name홈 화면 아이콘 아래에 표시될 짧은 이름
    start_url앱 실행 시 시작될 페이지 경로
    displaystandalone으로 설정 시, 주소창 없는 앱처럼 열린다.
    theme_color상단 툴바(Status Bar)의 색상을 지정한다.
    icons홈 화면에 표시될 앱 아이콘 목록

    이제 이 manifest.json을 모든 페이지에서 알 수 있어야 한다.
    나는 그래서_document.tsx<Head> 내에서 해당 파일을 불러오도록 했다.

    // src/pages/_document.tsx import { Html, Head, Main, NextScript } from 'next/document' export default function Document() { return ( <Html lang="ko"> <Head> <link rel="manifest" href="/manifest.json" /> <meta name="theme-color" content="#317EFB" /> {/* Apple 기기용 아이콘 */} <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" /> </Head> <body> <Main /> <NextScript /> </body> </Html> ) }

    2. 서비스 워커(Service Worker) 만들기

    서비스 워커는 PWA의 핵심 기술로, 브라우저 백그라운드에서 실행되는 스크립트다. 네트워크 요청을 가로채거나, 푸시 알림을 처리하고, 오프라인 캐싱을 관리하는 역할을 한다.

    먼저 public/sw.js 파일을 생성한다.

    // public/sw.js const CACHE_NAME = 'my-app-cache-v1'; // 캐싱할 주요 파일 목록 const FILES_TO_CACHE = [ '/', '/styles/globals.css', '/manifest.json', '/icons/icon-192x192.png', '/icons/icon-512x512.png', ]; // 1. 서비스 워커 설치 // 서비스 워커가 처음 등록될 때 한 번 발생한다. // 주로 오프라인에서 사용될 주요 정적 파일들을 캐싱하는 데 사용된다. self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => { console.log('Opened cache'); return cache.addAll(FILES_TO_CACHE); }) ); }); // 2. 네트워크 요청 처리 // 서비스 워커가 제어하는 페이지에서 네트워크 요청이 발생할 때마다 발생한다. // 오프라인 지원을 위해 캐시된 응답을 반환할지, 네트워크로 요청을 보낼지 결정할 수 있다. self.addEventListener('fetch', (event) => { event.respondWith( caches.match(event.request).then((response) => { // 캐시에 응답이 있으면 캐시된 것을, 없으면 네트워크 요청을 반환한다. return response || fetch(event.request); }) ); }); // 3. 오래된 캐시 정리 // 새로운 서비스 워커가 활성화될 때 발생한다. // 이전 버전의 서비스 워커가 사용하던 오래된 캐시를 정리하기에 적절한 시점이다. self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((keyList) => { return Promise.all( keyList.map((key) => { if (key !== CACHE_NAME) { return caches.delete(key); } }) ); }) ); });

    서비스 워커는 클라이언트(브라우저) 사이드에서 등록해주어야 하기에,
    _app.tsx에서 useEffect 훅을 사용해 앱이 로드될 때 서비스 워커가 등록되도록 해주었다.

    // src/pages/_app.tsx import { useEffect } from 'react'; // ... other imports function App({ Component, pageProps }) { useEffect(() => { // 'serviceWorker'가 브라우저에서 지원되는지 확인 if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker .register('/sw.js') // 서비스 워커 등록 .then((registration) => { console.log('Service Worker registered: ', registration); }) .catch((registrationError) => { console.log('Service Worker registration failed: ', registrationError); }); }); } }, []); return <Component {...pageProps} />; } export default MyApp;

    3. 푸시 알림 구독하기

    푸시 알림은 Push APIVAPID 키를 이용해 구현했다.
    VAPID 키는 우리 서버가 푸시 서비스를 통해 사용자에게 알림을 보낼 수 있도록 인증해주는 역할을 한다.

    클라이언트 측 (구독 요청):

    사용자에게 알림 권한을 요청하고, 허용하면 생성되는 PushSubscription 정보를 DB에 저장해야 한다.

    // VAPID 공개 키 const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY; // 푸시 구독 요청 export const subscribeUserToPush = async () => { try { const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, // 항상 사용자에게 보이는 알림 applicationServerKey: VAPID_PUBLIC_KEY, }); // 생성된 구독 정보를 백엔드로 전송 await fetch('/api/subscribe', { method: 'POST', body: JSON.stringify(subscription), headers: { 'Content-Type': 'application/json', }, }); console.log('User is subscribed.'); } catch (error) { console.error('Failed to subscribe the user: ', error); } };

    서버 측 (알림 발송):

    저장된 PushSubscription 정보를 불러와 web-push 같은 라이브러리를 사용해 해당 구독 정보로 알림을 보낼 수 있다.

    아래는 DB를 거치지 않는 발송만을 위한 예제 코드이다.

    // src/pages/api/send-notification.ts (예시) import webpush from 'web-push'; // VAPID 키 설정 (서버에서만 사용) webpush.setVapidDetails( 'mailto:your-email@example.com', process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY, process.env.VAPID_PRIVATE_KEY ); export default function handler(req, res) { if (req.method === 'POST') { const { subscription, payload } = req.body; webpush.sendNotification(subscription, JSON.stringify(payload)) .then(() => res.status(200).json({ success: true })) .catch(err => console.error(err)); } }

    서비스 워커 (푸시 수신 처리 및 표시):

    마지막으로, sw.jspush 이벤트를 수신하고 클릭 시 핸들링을 위한 코드를 추가해야 한다.
    이 코드가 실제로 알림을 띄우는 역할을 한다.

    // public/sw.js 에 추가 // 푸시 알림을 어떻게 구성할 지를 담당한다. self.addEventListener('push', (event) => { const data = event.data.json(); // 서버에서 보낸 payload const title = data.title || '새로운 알림'; const options = { body: data.body, icon: '/icons/icon-192x192.png', badge: '/icons/icon-192x192.png', // 안드로이드에서 표시될 작은 아이콘 }; event.waitUntil(self.registration.showNotification(title, options)); }); // 푸시 알림 클릭 시 어떻게 처리할 지를 담당한다. self.addEventListener("notificationclick", function (event) { event.notification.close(); event.waitUntil( clients.openWindow(`${URL}`) // 알림 클릭 시 이동할 URL ); });

    마무리

    위 과정을 통해 내 프로젝트는 이제 사용자가 홈 화면에 설치하고, 오프라인 상태에서도 일부 기능을 사용할 수 있으며, 필요시 푸시 알림을 받을 수 있는 웹앱이 되었다.

    PWA를 직접 구현하면서 다음과 같은 것들을 알게 되었다.

    • HTTPS는 필수: 서비스 워커와 푸시 알림은 보안 연결(HTTPS)에서만 동작한다.
    • 캐싱 전략의 중요성: 어떤 파일들을 캐싱하느냐에 따라 오프라인 경험이 달라질 것이다. 동적인 자원(API 응답)보다는 정적인 자원(CSS, JS, 이미지) 위주로 시작하는 것이 좋다.
    • 알림 권한 요청 시점: 페이지에 들어오자마자 권한을 요청하면 사용자가 거부할 확률이 높다. 사용자가 알림 수신 기능을 활성화하는 등, 맥락에 맞는 시점에 요청하는 것이 적절할 것이다.
    • 디버깅: 크롬 개발자 도구의 Application 탭은 서비스 워커의 생명주기, 캐시 상태, manifest.json 유효성 등을 확인하는 데 매우 유용하다.