티스토리 뷰

반응형

AI 채팅 앱은 이제 모바일에서도 자연스럽게 쓸 수 있어야 합니다.
매번 브라우저를 열어 주소를 치고 들어가는 대신 홈 화면 아이콘으로 바로 띄울 수 있도록, 편의성을 더하기 위해 PWA를 붙였습니다.
이 글은 App Router에 설치형 PWA를 붙이고, 데스크톱 브라우저까지 설치 안내 기록입니다.

목차

설치형 PWA의 최소 조건

먼저 "설치 가능(installable)"이라는 상태가 무엇인지 정리하고 시작했습니다. 브라우저가 홈 화면 설치 프롬프트를 띄우려면 세 가지가 필요합니다.

  • 유효한 Web App Manifest (이름, 아이콘, display: standalone 등)
  • HTTPS로 서빙 (localhost는 예외로 보안 컨텍스트 취급)
  • 등록된 서비스 워커

여기서 중요한 건, 푸시 알림이나 오프라인 캐싱은 설치의 필수 조건이 아니라는 점입니다. 그래서 저는 푸시와 오프라인을 빼고, 위 세 가지만 충족하는 구성으로 잡았습니다. 결과적으로 손대야 할 곳은 manifest 파일, 서비스 워커 파일, 서비스 워커 등록 코드, 아이콘 정도였습니다.

Web App Manifest 만들기

App Router는 app/manifest.ts 파일 컨벤션을 제공합니다. 이 파일을 두면 Next가 알아서 /manifest.webmanifest로 서빙하고, HTML <head><link rel="manifest">까지 자동으로 넣어줍니다. 직접 link 태그를 달 필요가 없습니다.

다음은 다크 테마 앱에 맞춘 manifest입니다.

import type { MetadataRoute } from 'next'

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'AI Chat',
    short_name: 'AI Chat',
    description: 'GPT-5 기반 AI 채팅',
    start_url: '/',
    display: 'standalone',
    background_color: '#1A1A1A',
    theme_color: '#1A1A1A',
    lang: 'ko',
    icons: [
      { src: '/icon-192x192.png', sizes: '192x192', type: 'image/png' },
      { src: '/icon-512x512.png', sizes: '512x512', type: 'image/png' }
    ]
  }
}

홈 화면에서 앱을 열면 주소창과 탭이 사라지고 네이티브 앱처럼 독립 창으로 뜨도록 standalone 로 설정하였습니다.
아이콘은 192/512 두 사이즈가 필수 임으로, 이미지를 맞게 준비하시면 됩니다.

서비스 워커는 왜 따로 등록해야 하는가

manifest는 Next가 자동으로 연결해주지만, 서비스 워커는 그렇지 않습니다. public/sw.js에 파일을 둬도 그건 그냥 정적 파일일 뿐이고, 브라우저는 누군가 navigator.serviceWorker.register()를 호출해주기 전까지 그 존재를 모릅니다.
이 호출이 없으면 서비스 워커 미등록 상태가 되고, 설치 자격을 충족하지 못합니다.

설치형만 목표였기 때문에 서비스 워커 자체는 최소한으로 두었습니다. 오프라인 캐싱 로직 없이, 설치 자격을 위한 뼈대만 남깁니다.

// 설치형 PWA용 최소 서비스 워커.
// 푸시/오프라인 캐싱 없음 — 설치 가능 자격만 충족한다.
self.addEventListener('install', () => {
  self.skipWaiting()
})

self.addEventListener('activate', (event) => {
  event.waitUntil(self.clients.claim())
})

self.addEventListener('fetch', () => {
  // 의도적으로 비움: 요청은 평소대로 네트워크로 통과한다.
})

이제 이 파일을 등록할 차례입니다. navigator는 브라우저에만 존재하므로, 서버 컴포넌트에서는 호출할 수 없습니다. 그래서 'use client' 컴포넌트를 만들어 마운트 시점에 등록하도록 했습니다. UI는 없고 등록만 담당합니다.

'use client'

import { useEffect } from 'react'

export const ServiceWorkerRegister = () => {
  useEffect(() => {
    if (!('serviceWorker' in navigator)) return

    navigator.serviceWorker
      .register('/sw.js', { scope: '/', updateViaCache: 'none' })
      .catch((error) => {
        console.error('Service worker registration failed:', error)
      })
  }, [])

  return null
}

이 컴포넌트를 루트 레이아웃 <body>에 넣어두면 모든 페이지에서 서비스 워커가 등록됩니다.

const RootLayout = ({ children }: { children: ReactNode }) => (
  <html lang="ko">
    <body>
      <ServiceWorkerRegister />
      <Providers>
        <AppShell>{children}</AppShell>
      </Providers>
    </body>
  </html>
)

iOS 설치 안내와 하이드레이션 함정

iOS Safari는 안드로이드 크롬과 달리 자동 설치 프롬프트를 띄우지 않습니다. 사용자가 직접 공유 버튼을 눌러 "홈 화면에 추가"를 해야 합니다. 그래서 iOS에서만, 그리고 아직 설치 전(standalone이 아닐 때)에만 안내 문구를 보여주는 컴포넌트가 필요했습니다.

처음엔 렌더 시점에 navigator.userAgentmatchMedia를 읽어 iOS 여부를 판단했습니다.
그런데 클라이언트 컴포넌트라도 SSR 단계에서 한 번 서버 렌더링이 되다보니, 서버에는 navigator가 없어 null을 반환하지만 iOS 클라이언트의 첫 렌더에서는 안내 문구를 그리니, 둘이 어긋나면서 하이드레이션 불일치가 발생했습니다. 결국 클라이언트 전용 값이라도 서버 사이드 렌더링까지 고려해야 한다는 게 핵심이었습니다.

해결책은 useSyncExternalStore였습니다. 이 훅은 React가 직접 관리하지 않는 "외부 저장소"의 값을 읽어야 할 때 쓴다고 합니다. useState로 만든 값은 React가 변경 시점을 알지만, 그 바깥에 사는 값은 React가 언제 바뀌는지 모르기 때문에 따로 연결해줘야 합니다. 여기서 외부 저장소란 이런 것들입니다.

  • 브라우저 API: matchMedia, navigator, window.location, 온라인 여부(navigator.onLine)
  • 상태 관리 라이브러리: Redux, Zustand 등의 store
  • 브라우저 저장소: localStorage, sessionStorage
  • 실시간 연결: WebSocket, EventSource 같은 외부 이벤트 소스

우리 경우의 외부 저장소는 브라우저 환경 그 자체(navigator, matchMedia)입니다. 이 훅은 세 인자를 받는데, subscribe(값이 바뀌면 React에 알림), getSnapshot(클라이언트에서 현재 값 읽기), getServerSnapshot(SSR일 때 쓸 값)입니다. 제가 이 훅을 고른 핵심은 세 번째 인자에 있습니다. navigatormatchMedia는 서버에 아예 없는 외부 저장소라, 서버용 값을 () => false로 고정하면 SSR과 클라이언트 첫 렌더가 모두 null로 일치하고, 하이드레이션 이후에야 클라이언트의 실제 값으로 다시 평가됩니다. effect 안에서 setState를 하지 않으므로 앞서 걸렸던 린트 규칙도 자연스럽게 피해 갑니다.

'use client'

import { useTheme } from '@emotion/react'
import { useSyncExternalStore } from 'react'

import { styles } from './InstallPrompt.styles'

// 값이 마운트 후 바뀌지 않으므로 구독은 no-op
const subscribe = () => () => {}

const getIsIOS = () =>
  /iPad|iPhone|iPod/.test(navigator.userAgent) && !('MSStream' in window)

const getIsStandalone = () =>
  window.matchMedia('(display-mode: standalone)').matches

export const InstallPrompt = () => {
  const theme = useTheme()
  const s = styles(theme)
  // 서버 스냅샷은 false → SSR/하이드레이션 시 null로 일치, 이후 클라이언트에서 재평가
  const isIOS = useSyncExternalStore(subscribe, getIsIOS, () => false)
  const isStandalone = useSyncExternalStore(
    subscribe,
    getIsStandalone,
    () => false
  )

  if (isStandalone || !isIOS) {
    return null
  }

  return (
    <div css={s.container} role="note">
      <p css={s.text}>
        홈 화면에 추가하려면 공유 버튼을 누른 뒤 "홈 화면에 추가"를 선택하세요.
      </p>
    </div>
  )
}

getSnapshot이 매번 새 객체가 아니라 불리언 원시값을 반환한다는 점이 중요합니다.

const subscribe = (onChange: () => void) => {
  const mql = window.matchMedia('(prefers-color-scheme: dark)')
  mql.addEventListener('change', onChange) // 값이 바뀌면 React에 알림
  return () => mql.removeEventListener('change', onChange)
}

정리하면 이 훅은 React 바깥의 값을 읽어야 하고, 특히 그 값이 서버에 없어 하이드레이션을 맞춰줘야 할 때 꺼내 쓰기 좋은 도구입니다.

설치 지원 넣기

  • Chromium 계열(크롬, 엣지, 안드로이드 크롬): beforeinstallprompt 이벤트를 가로채 직접 "설치" 버튼을 띄울 수 있습니다.
  • iOS/iPadOS Safari: 자동 프롬프트가 없어 "홈 화면에 추가"를 손으로 안내합니다. (iPadOS는 데스크톱 UA를 보내기 때문에, 터치 지원 여부까지 함께 봐야 정확히 가려집니다.)
  • macOS Safari: 이쪽도 beforeinstallprompt가 없어, 메뉴 막대의 "파일 → Dock에 추가"를 안내합니다.
  • 그 외(파이어폭스 등): 마땅한 설치 수단이 없어 아무것도 보여주지 않습니다.

여기서 beforeinstallprompt가 새 변수를 만들었습니다.
앞 절의 isIOS/isStandalone은 마운트 시점에 한 번 정해지고 바뀌지 않는 값이라 subscribe를 no-op으로 둘 수 있었습니다.
그런데 beforeinstallprompt는 페이지 로드 후 브라우저가 비동기로 쏘는 이벤트라, "마운트 후 안 바뀐다"는 전제가 깨집니다. 이건 진짜로 구독해야 하는 외부 이벤트입니다.

그래서 두 종류의 값을 분리했습니다. 브라우저 환경 판별(iOS, macOS Safari, standalone)은 그대로 useSyncExternalStore로 읽고, beforeinstallprompt와 설치 완료(appinstalled)만 useEffect에서 이벤트로 구독합니다.

// 자동 설치 이벤트만 진짜로 구독한다 (마운트 후 비동기 발생)
useEffect(() => {
  const handleBeforeInstallPrompt = (event: BeforeInstallPromptEvent) => {
    event.preventDefault() // 브라우저 기본 인포바를 막고
    setDeferredPrompt(event) // 이벤트를 보관해 버튼으로 노출
  }
  const handleAppInstalled = () => setDeferredPrompt(null)

  window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
  window.addEventListener('appinstalled', handleAppInstalled)
  return () => {
    window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
    window.removeEventListener('appinstalled', handleAppInstalled)
  }
}, [])

이제 환경 판별 값과 보관해둔 이벤트를 합쳐, 무엇을 보여줄지 한 군데에서 결정합니다. 우선순위는 이미 설치됨 → 자동 설치 버튼 → iOS 안내 → macOS Safari 안내 순입니다.

const mode = resolveMode({
  isStandalone,
  hasInstallPrompt: deferredPrompt !== null,
  isIOS,
  isMacSafari
})
// 'hidden' | 'button' | 'iosGuide' | 'macSafariGuide'

beforeinstallprompt로 받은 이벤트는 한 번만 쓸 수 있습니다. 버튼을 누르면 event.prompt()로 네이티브 설치 창을 띄우고, 쓰임이 끝난 이벤트는 폐기해 버튼을 감춥니다.

const promptInstall = async () => {
  if (!deferredPrompt) return
  await deferredPrompt.prompt()
  setDeferredPrompt(null) // 일회성 이벤트 → 사용 후 폐기 (버튼도 사라짐)
}

환경 판별은 useSyncExternalStore, 비동기 이벤트는 useEffect 구독으로 역할을 나누고 나니, 같은 컴포넌트가 크롬에선 버튼을, 사파리에선 안내를 보여주게 됐습니다. 설치 로직이 제법 커져서, 이 부분은 useInstallPrompt 훅으로 분리해 컴포넌트는 mode에 따라 렌더만 하도록 두었습니다.

보안 헤더 추가하기

마지막으로 next.config.ts에 헤더를 추가했습니다. 전역 보안 헤더와 더불어, 서비스 워커가 항상 최신으로 받아지도록 /sw.js에 캐시 무효화 헤더를 지정했습니다.

async headers() {
  return [
    {
      source: '/(.*)',
      headers: [
        { key: 'X-Content-Type-Options', value: 'nosniff' },
        { key: 'X-Frame-Options', value: 'DENY' },
        { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }
      ]
    },
    {
      source: '/sw.js',
      headers: [
        { key: 'Content-Type', value: 'application/javascript; charset=utf-8' },
        { key: 'Cache-Control', value: 'no-cache, no-store, must-revalidate' }
      ]
    }
  ]
}

서비스 워커를 캐시해버리면 사용자가 오래된 버전을 계속 들고 있게 되므로, no-cache, no-store, must-revalidate로 매번 새로 받게 하는 게 안전합니다.

로컬에서 확인하기

데스크톱에서 확인할 때는 일반 pnpm dev로 충분합니다. 브라우저는 localhost(와 127.0.0.1)를 보안 컨텍스트로 취급해서, HTTP라도 서비스 워커 등록과 설치가 모두 동작하기 때문입니다.

pnpm dev

확인은 Chrome DevTools의 Application 탭에서 합니다.

  • Manifest: 이름과 아이콘, display: standalone이 에러 없이 잡히는지
  • Service Workers: /sw.js가 activated 상태로 running인지
  • 주소창의 설치 아이콘이나 화면의 "앱 설치" 버튼으로 standalone 창이 뜨는지

데스크톱 크롬에서는 설치 버튼이, macOS Safari에서는 "Dock에 추가" 안내가 보입니다.

문제는 실제 휴대폰에서 확인할 때입니다. 같은 네트워크에서 http://192.168.0.x:3000 같은 IP로 접속하면 이건 localhost가 아니라서 보안 컨텍스트로 잡히지 않고, 서비스 워커가 등록되지 않아 설치 자체가 막힙니다. 특히 iOS Safari에서 "홈 화면에 추가" 흐름을 실제로 보려면 HTTPS가 필요합니다. 이때 Next가 자체 서명 인증서를 만들어주는 옵션을 씁니다.

pnpm dev --experimental-https

이제 폰 브라우저에서 https://192.168.0.x:3000으로 접속하면(인증서 경고는 허용) localhost와 똑같이 서비스 워커와 설치가 동작합니다. 정리하면, 데스크톱 localhost 확인은 pnpm dev, 폰에서 IP로 붙어 확인할 때만 --experimental-https가 필요합니다.

마무리

설치형 PWA는 manifest, 최소 서비스 워커, 서비스 워커 등록 컴포넌트, 아이콘만 있으면 됩니다. 가장 기억에 남는 건 클라이언트 전용 값을 다루다 만난 하이드레이션 불일치였고, 마운트 후 고정되는 값은 useSyncExternalStore로, 비동기로 도착하는 beforeinstallpromptuseEffect 구독으로 나눠 다루면 깔끔하게 풀립니다.

참고한 자료입니다.

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/08   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
글 보관함