PWA

김지유·2026년 5월 28일
post-thumbnail

PWA

PWA란?

PWA(Progressive Web App)는 웹 앱과 네이티브 앱의 장점을 모두 갖춘 웹 앱이다.

일반 웹 앱은 브라우저 탭 안에서만 동작하고 오프라인이 되면 흰 화면이 뜬다.
네이티브 앱은 기능이 풍부하지만 iOS/Android 각각 따로 만들어야 하고 앱스토어 심사도 거쳐야 한다.

PWA는 그 중간이다. 웹 기술(HTML/CSS/JS)로 만들지만 홈 화면에 설치할 수 있고 오프라인에서도 동작하며, 푸시 알림도 받을 수 있다.

구분웹 앱PWA네이티브 앱
설치불필요홈 화면 추가 가능앱스토어 필요
오프라인불가가능 (캐싱)가능
푸시 알림불가가능가능
개발 비용낮음낮음높음 (플랫폼별)
앱스토어 심사없음없음필요
SEO가능가능불가

트위터가 PWA를 도입한 대표적인 사례이다.
인터넷 속도나 기기 성능이 낮은 모바일 사용자를 위한 경량화 버전으로 웹사이트 접속 대기시간을 30% 이상 줄이고 로딩 속도를 75% 이상 향상시켰다고 한다.


PWA 조건

PWA라고 불리려면 아래 조건을 갖춰야 한다.

  • 설치 가능
    브라우저 탭이 아닌 독립 실행형 창으로 열려야 한다 → manifest.json
  • 오프라인 동작
    네트워크 없이도 리소스를 제공할 수 있어야 한다 → Service Worker + Cache API
  • 백그라운드 동기화
    앱을 사용하지 않을 때도 데이터 동기화가 가능해야 한다 → Background Sync
  • 푸시 알림
    사용자 재참여를 유도할 수 있어야 한다 → Push API
  • Web API 활용
    위치, 카메라, 진동 등 네이티브 기능에 접근할 수 있어야 한다
  • HTTPS
    보안 연결이 필수다. localhost는 예외적으로 허용되ㄴ다

PWA 장단점

장점

  • 설치 마찰이 없다
    앱스토어 심사 없이 배포 즉시 반영된다. 업데이트도 서버 배포만으로 끝난다
  • 하나의 코드베이스
    iOS, Android, 데스크탑을 별도 개발 없이 지원한다
  • SEO 가능
    본질적으로 웹이기 때문에 검색엔진을 통해 유입된다.
  • 오프라인 동작
    캐싱 전략으로 네트워크 없이도 앱이 동작한다
  • 푸시 알림
    과거엔 네이티브 앱만 가능했지만 PWA에서도 지원된다

단점

  • iOS 지원이 불완전하다
    Background Sync는 아직 미지원이고, 푸시 알림은 iOS 16.4부터 지원됐다. 여전히 Android보다 제약이 많다
  • 앱스토어 노출이 없다
    스토어를 통한 자연 유입이 불가능하다. (Google Play는 TWA(Trusted Web Activity) 방식으로 등록할 수 있긴 함)
  • 고사양 앱 개발 한계
    운영체제 자원을 직접 사용하는 네이티브 앱의 퍼포먼스를 따라잡기 어렵다.
    게임 개발에는 적합하지 않다
  • 브라우저마다 지원 범위가 다르다
    같은 기능이라도 Chrome, Safari, Firefox에서 동작이 다를 수 있다

Service Worker

Service Worker란?

Service Worker는 PWA의 가장 큰 핵심기능 중 하나이다.

Service Worker를 이해하려면 일반 JavaScript와의 차이부터 알안ㅂ자

일반 JavaScript는 메인 스레드에서 실행된다.
HTML 페이지에 여러 JS 파일이 로딩되더라도 모두 하나의 스레드에서 동작하며 DOM에 자유롭게 접근할 수 있다.

하지만 Service Worker는 다르다.
JS 파일이긴 하지만 메인 스레드와 완전히 분리된 백그라운드 스레드에서 실행된다.
그래서 두 가지 중요한 특징이 생겨난다.

  • DOM에 직접 접근할 수 없다. 페이지를 직접 조작하는 건 불가능하다
  • 브라우저의 모든 페이지가 닫혀있어도 여전히 동작한다. 모바일에서 브라우저를 닫아도 백그라운드 프로세스로 계속 실행될 수 있다

Service Worker 이벤트

Service Worker는 단독으로 동작하지 않고 이벤트에 반응하는 방식으로 동작한다.
주요 이벤트는 다음과 같다.

Fetch 이벤트

브라우저가 HTML, CSS, JS, 이미지 등 리소스를 로딩할 때마다 발생한다. Service Worker를 네트워크 프록시처럼 활용할 수 있다. 모든 요청을 가로채서 캐시에서 반환하거나 네트워크로 전달하거나 선택할 수 있다.

주의할 점이 하나 있다. Ajax 요청, 즉 XMLHttpRequest는 fetch 이벤트를 발생시키지 않는다. fetch() API로 보낸 요청만 해당된다.

그래서 axios는 내부적으로 XMLHttpRequest를 사용하기 때문에 Service Worker의 fetch 이벤트에 잡히지 않습니다.
다만 axios도 최신 버전에서는 브라우저 환경에 따라 fetch를 사용하도록 설정할 수 있고 아예 fetch 기반의 kywretch 같은 라이브러리로 교체하는 방법도 있다.
PWA에서 오프라인 지원이나 캐싱을 목적으로 쓰려는 거라면 axios 대신 네이티브 fetch를 쓰는 게 자연스럽다.

Push Notification 이벤트

푸시 알림은
서버 → 브라우저 벤더의 Push 서버(Chrome은 Google) → 클라이언트 순으로 전달된다. Service Worker가 이 push 이벤트를 수신한다.

왜 일반 페이지 JS가 아니라 Service Worker에서 처리해야 하냐면 페이지가 닫혀있어도 Service Worker는 백그라운드에서 실행되기 때문이다.
사용자가 앱을 열지 않아도 알림을 받을 수 있다.

Notification Interaction 이벤트

푸시 알림을 받은 후 사용자가 알림을 클릭했을 때의 동작을 처리한다.
예를 들어 특정 URL로 이동시키거나 캐시에서 데이터를 불러오는 동작을 여기서 구현한다.

Background Sync 이벤트

인터넷 연결이 불안정한 환경에서 특정 작업을 수행하고 싶을 때 사용한다.
지금은 안 되더라도 인터넷이 재연결되면 실행되도록 이벤트를 발행할 수 있다.
브라우저가 닫혀있는 상황에서도 가능하다.
다만 Safari 지원이 아직 불안정하므로 실무에서는 주의가 필요하다!

Service Worker 생명주기

register() → install → (waiting) → activate → idle → terminated(일시적)

install 단계

Service Worker가 처음 설치될 때 딱 한 번 실행된다.
이 시점에 오프라인에서 필요한 정적 자산들을 미리 캐싱해두는 작업을 주로 한다.

waiting 단계

기존에 열려있는 탭이나 윈도우가 있으면 새 Service Worker는 install은 되지만 activate되지 않고 대기 상태로 머문다.
그 이유는 페이지가 기존 Service Worker와 통신 중인데 새 버전으로 갑자기 교체되면 잘못된 데이터를 가져올 수 있기 때문이다.

activate 단계

이전에 실행 중인 Service Worker가 없다면 install 직후 바로 activate된다.
이 단계에서 오래된 캐시를 정리하는 작업을 한다.

idle → terminated

Service Worker가 activate되면 백그라운드에서 idle 상태가 된다.
이벤트가 없으면 아무것도 하지 않다가 일정 시간이 지나면 terminated된다.
삭제나 등록 해제가 되는 게 아니라 Sleeping 상태가 되는 것이다.
이벤트를 수신하면 다시 깨어난다.

캐싱 전략

Service Worker의 fetch 이벤트에서 네트워크 요청을 어떻게 처리할지 전략을 정해야 한다.
상황에 따라 다른 전략을 선택하는 게 핵심이다.

Cache First (캐시 우선)

캐시를 먼저 확인하고 있으면 바로 반환한다.
캐시에 없을 때만 네트워크로 요청한다.

// 캐시 있으면 즉시 반환 없으면 네트워크
caches.match(request).then((cached) => cached || fetch(request))

사용 예 — 폰트, 아이콘, 로고처럼 거의 변경되지 않는 정적 자산.

Network First (네트워크 우선)

네트워크를 먼저 시도하고, 실패하면 캐시로 폴백한다.

fetch(request)
  .then((res) => {
    // 성공하면 캐시에 저장하고 반환
    const clone = res.clone()
    caches.open('v1').then((cache) => cache.put(request, clone))
    return res
  })
  .catch(() => caches.match(request)) // 실패하면 캐시 반환

사용 예 — API 응답, 자주 바뀌는 HTML.

Stale While Revalidate

캐시를 즉시 반환하면서, 동시에 네트워크 요청을 보내 캐시를 업데이트한다.

caches.match(request).then((cached) => {
  const networkFetch = fetch(request).then((res) => {
    caches.open('v1').then((cache) => cache.put(request, res.clone()))
    return res
  })
  return cached || networkFetch // 캐시 있으면 즉시 반환, 없으면 네트워크 기다림
})

사용 예 — 뉴스 피드, 프로필 이미지처럼 조금 오래돼도 괜찮은 데이터.

Cache Only

캐시에서만 응답하고 네트워크 요청을 보내지 않는다.

사용 예 — install 시 미리 캐시해둔 App Shell(HTML 껍데기, 기본 CSS 등). 완전히 오프라인에서도 껍데기는 보여줘야 할 때 사용한다.

Network Only

캐시 없이 항상 네트워크로만 요청한다.

언제 쓰나 — 결제, 인증처럼 반드시 서버와 통신해야 하는 요청. 캐시된 낡은 데이터를 쓰면 안 되는 경우에 사용한다.

PWA 구현하기

next-pwa 같은 라이브러리 없이 순수 Next.js로 직접 구현해보자
라이브러리가 편하긴 하지만 내부를 모르면 문제가 생겼을 때 대응이 어렵다.

프로젝트 구조

apps/web/
├── public/
│   ├── ServiceWorker.js              # Service Worker 직접 작성
│   ├── manifest.json      # 앱 메타정보
│   └── icons/
│       ├── icon-192x192.png
│       └── icon-512x512.png
└── app/
    ├── layout.tsx          # 메타 태그 + SW 등록
    ├── offline/
    │   └── page.tsx        # 오프라인 폴백 페이지
    └── components/
        └── ServiceWorkerRegister.tsx

manifest.json

{
  "name": "Jiyu App",
  "short_name": "App",
  "description": "Jiyu PWA application",
  "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"
    }
  ]
}

display: "standalone"이 핵심이다. 이 값을 설정해야 브라우저 주소창 없이 앱처럼 뜬다.
아이콘은 192x192와 512x512 두 가지가 필수이다.

ServiceWorker.js 작성

// public/ServiceWorker.js
const CACHE_NAME = 'my-app-v1'

const PRECACHE_URLS = [
  '/',
  '/offline',
]

// install — 정적 자산 pre-caching
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(PRECACHE_URLS)
    })
  )
  self.skipWaiting()
})

// activate — 이전 캐시 정리
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys
          .filter((key) => key !== CACHE_NAME)
          .map((key) => caches.delete(key))
      )
    )
  )
  self.clients.claim()
})

// fetch — 네트워크 요청 가로채기
self.addEventListener('fetch', (event) => {
  // POST 요청이나 크롬 익스텐션 요청은 무시
  if (
    event.request.method !== 'GET' ||
    event.request.url.startsWith('chrome-extension')
  ) return

  event.respondWith(
    caches.match(event.request).then((cached) => {
      return (
        cached ||
        fetch(event.request).catch(() => {
          // 네트워크도 실패하면 오프라인 페이지 반환
          if (event.request.destination === 'document') {
            return caches.match('/offline')
          }
        })
      )
    })
  )
})

skipWaiting()을 호출하면 waiting 단계 없이 새 SW가 바로 activate된다. clients.claim()은 현재 열려있는 페이지를 즉시 제어하게 해준다.

Service Worker 등록 컴포넌트

Next.js App Router에서 navigator는 클라이언트에서만 접근 가능하다.
'use client'로 분리된 컴포넌트에서 useEffect를 통해 등록해야 한다.

// app/components/ServiceWorkerRegister.tsx
'use client'

import { useEffect } from 'react'

export default function ServiceWorkerRegister() {
  useEffect(() => {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker
        .register('/ServiceWorker.js')
        .then((reg) => {
          console.log('SW registered:', reg.scope)

          // 새 버전 감지
          reg.addEventListener('updatefound', () => {
            const newSW = reg.installing
            newSW?.addEventListener('statechange', () => {
              if (newSW.state === 'installed' && navigator.serviceWorker.controller) {
                // 새 버전 배포 시 사용자에게 알림
                if (confirm('새 버전이 있어요. 업데이트할까요?')) {
                  window.location.reload()
                }
              }
            })
          })
        })
        .catch((err) => console.error('SW registration failed:', err))
    }
  }, [])

  return null
}

layout.tsx에 연결

// app/layout.tsx
import type { Metadata } from 'next'
import ServiceWorkerRegister from './components/ServiceWorkerRegister'

export const metadata: Metadata = {
  manifest: '/manifest.json',
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <head>
        <meta name="theme-color" content="#000000" />
        {/* IOS Safari는 manifest를 완전히 지원하지 않아 별도 메타 태그 필요 */}
        <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="/icons/icon-192x192.png" />
      </head>
      <body>
        <ServiceWorkerRegister />
        {children}
      </body>
    </html>
  )
}

IOS Safari는 manifest.json을 완전히 지원하지 않는다.
apple-mobile-web-app-capable 같은 메타 태그를 별도로 달아줘야 IOS에서 홈 화면 추가 시 제대로 동작한다.

오프라인 페이지

// app/offline/page.tsx
export default function OfflinePage() {
  return (
    <div>
      <h1>오프라인 상태예요</h1>
      <p>인터넷 연결을 확인하고 다시 시도해주세요.</p>
    </div>
  )
}

next.config.ts — ServiceWorker.js 캐시 설정

ServiceWorker.js 파일 자체를 브라우저가 캐싱해버리면 새 버전을 배포해도 Service Worker가 갱신되지 않는 문제가 생긴다.
반드시 no-cache 헤더를 설정해야 한다.

// next.config.ts
const nextConfig = {
  async headers() {
    return [
      {
        source: '/ServiceWorker.js',
        headers: [
          {
            key: 'Content-Type',
            value: 'application/javascript; charset=utf-8',
          },
          {
            key: 'Cache-Control',
            value: 'no-cache, no-store, must-revalidate',
          },
        ],
      },
    ]
  },
}

export default nextConfig

next-pwa 라이브러리 사용

next-pwa 라이브러리를 사용하면

npm install @ducanh2912/next-pwa

라이브러리 설치 후

// next.config.js
const withPWA = require('@ducanh2912/next-pwa').default({
  dest: 'public',
});

module.exports = withPWA({ /* 기존 설정 */ });

withPWA로 해당 라이브러리를 감싼 후 public/manifest.json 파일만 생성하면 세팅이 끝난다!

마무리

오늘은 PWA에 대해서 알아보았다.

profile
GSM - Front-end Developer

0개의 댓글