홈화면에 추가로 웹을 앱처럼 만들기! (PWA)

seoyeonpp·2025년 5월 16일
14

Frontend

목록 보기
10/11
post-thumbnail

우리 회사 서비스는 웹사이트로 이루어져있다.
단점은 유저가 웹사이트를 북마크 해두지 않는 이상 진입하기가 어렵다는 것이다😅

그래서 이번 요구사항은 웹 브라우저에서 홈화면에 추가하면 앱처럼 깔리는 기능을 구현하는것이었다.


PWA란?

Progressive Web App의 줄임말이다. 폰에서 네이티브 앱처럼 동작하게 하는 웹앱이다.


일단 모르는 기능이니까 chatGPT한테 냅다 물어봤다.😃

일단 홈화면에 추가하는 기능을 브라우저에서 구현하기 위해서는 3가지 조건이 충족되어야한다고 했다.

  1. HTTPS 여야 한다.
  2. 웹 앱 매니페스트 파일이 존재해야한다. (manifest.json)
  3. 서비스 워커가 등록되어야 한다.

잘 모르겠어서 라이브러리가 있는지 찾아보았다.

GPT가 next-pwa 라는 라이브러리를 사용하라고 했다.
하나씩 따라하고있었는데,
갑자기 문제가 발생했다!!! 🚨

지금 서비스는 Nx monorepo 환경에서 Next.js 앱이 3개가 있는 아키텍처인데, Next.js 14버전App router 방식을 사용하고있었다.
근데 build 할 때 자꾸 client component 경로를 undefined로 잡는 등 에러가 발생하는 것이었다.

이유를 찾아보니 next-pwa가 App router 방식을 완벽히 지원하지 않아서 발생하는 에러라고 했다.

할 수 없이 수동으로 구현하는 방법을 찾아봤다.
(물론 Cursor와 GPT와 함께)

참고로 안드로이드만 동작한다.
ios는 사파리의 공유 버튼을 누르고 홈화면에 추가를 눌러야한다.


1. public 폴더 하위에 manifest.json 파일을 만들기

⭐️⭐️⭐️
여기서 icon이 2종류가 필요한데,
192192 크기는 앱 아이콘처럼 홈화면에 보이는 아이콘이고, 512512 크기는 스플래쉬 화면에 보이는 아이콘이다. (안드로이드 폰에서 웹을 홈화면에 바로가기로 추가하면 스플래쉬 화면을 알아서 만들어준다 ! 신기..)
⭐️⭐️⭐️

{
  "name": "서비스명",
  "short_name": "서비스명",
  "description": "서비스 설명",
  "icons": [
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "start_url": "/",
  "display": "standalone",
  "theme_color": "#000000",
  "background_color": "#ffffff"
}

2. public/sw-template.js 파일 생성

⭐️⭐️⭐️
원래는 sw.js 파일이 public에 있어야하는데,
캐시명을 build시에 동적으로 정해주려고, sw-template.js를 먼저 만들고, build 전에 스크립트를 통해 BUILD_SW_DATE 변수를 할당하려고 sw-template.js를 먼저 만들었다.
⭐️⭐️⭐️

const CACHE_NAME = '캐시명-__BUILD_SW_DATE__'
const urlsToCache = ['/', '/캐싱할 경로']

// 설치 이벤트: 캐시 생성
self.addEventListener('install', (event) => {
  self.skipWaiting()
  event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(urlsToCache)))
})

// 활성화 이벤트: 이전 캐시 정리
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches
      .keys()
      .then((cacheNames) =>
        Promise.all(
          cacheNames.filter((cacheName) => cacheName !== CACHE_NAME).map((cacheName) => caches.delete(cacheName)),
        ),
      )
      .then(() => self.clients.claim()),
  )
})

// fetch 이벤트: 캐시에서 응답하거나 네트워크 요청
self.addEventListener('fetch', (event) => {
  event.respondWith(caches.match(event.request).then((response) => response || fetch(event.request)))
})

3. replace-sw-date.ts 작성

⭐️⭐️⭐️
build 전에 BUILD_SW_DATE 변수를 할당하는 script이다. 아무 폴더에 넣어도 된다. (public은 안됨!! )
이 파일을 통해 sw.js가 public에 생성이 된다.
⭐️⭐️⭐️

/* eslint-disable @typescript-eslint/no-var-requires */
const fs = require('fs')
const path = require('path')

const templatePath = path.resolve(__dirname, '../../../../public/sw-template.js')
const outputPath = path.resolve(__dirname, '../../../../public/sw.js')

const template = fs.readFileSync(templatePath, 'utf-8')
const buildDate = new Date().toISOString()

const result = template.replace(/__BUILD_SW_DATE__/g, buildDate)

fs.writeFileSync(outputPath, result)
console.log(`sw.js 의 BUILD_SW_DATE: ${buildDate}`)

4. service worker를 등록하는 client component 만들기

⭐️⭐️⭐️
만든 sw.js를 통해 서비스워커를 등록시키는 클라이언트 컴포넌트 코드이다.
⭐️⭐️⭐️

'use client'

import { useEffect } from 'react'

export default function RegisterSW() {
  useEffect(() => {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker
        .register('/sw.js')
        .then((registration) => {
          console.log('Service Worker 등록 성공:', registration.scope)
        })
        .catch((error) => {
          console.error('Service Worker 등록 실패:', error)
        })
    }
  }, [])

  return null
}

5. layout.tsx에 클라이언트 컴포넌트 import 하고, manifest 파일 경로 넣기

import { Metadata } from 'next'
import RegisterSW from './register-sw'

export const metadata: Metadata = {
...... 기존의 코드,
  manifest: '/manifest.json'
};


export default function RootLayout({ children }) {
  return (
    <html lang="ko">
      <body>
        {children}
        <RegisterSW />
      </body>
    </html>
  );
}

6. beforeinstallprompt 이벤트를 감지하는 커스텀 훅 파일 생성

'use client'

import { useEffect, useState } from 'react'

type BeforeInstallPromptEvent = Event & {
  prompt: () => void
  userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>
}

export const useAddToHomeScreen = (): [BeforeInstallPromptEvent | null, () => void] => {
  const [promptEvent, setPromptEvent] = useState<BeforeInstallPromptEvent | null>(null)

  useEffect(() => {
    const handler = (event: Event) => {
      event.preventDefault()
      setPromptEvent(event as BeforeInstallPromptEvent)
    }

    window.addEventListener('beforeinstallprompt', handler)

    return () => window.removeEventListener('beforeinstallprompt', handler)
  }, [])

  const promptToInstall = () => {
    if (promptEvent) {
      promptEvent.prompt()
      promptEvent.userChoice.then(() => {
        setPromptEvent(null)
      })
    }
  }

  return [promptEvent, promptToInstall]
}

7. 실제로 사용해야 하는 페이지에 해당 훅으로 제어

import { useAddToHomeScreen } from '경로'

export default function PWA() {

const [promptEvent, promptToInstall] = useAddToHomeScreen()

return (
    <div>
      <h2>안드로이드</h2>
      <button type="button" onClick={promptToInstall}>
        설치
      </button>
    </div>
  )
}

8. github action ci.yml

⭐️⭐️⭐️
ci.yml 파일에서 해당 프로젝트 일때만 replace-sw-date.ts 스크립트 실행시키도록 한다. (모노레포에서 특정 앱에만 적용될 예정!) 모노레포가 아니라면 package.json에 prebuild로 스크립트 작성해도 될것같다.
⭐️⭐️⭐️

... ci.yml 코드
# A 프로젝트일 때만 sw.js 생성
      - name: Generate service worker for A
        if: matrix.project == 'A'
        run: pnpm ts-node ./apps/A/src/lib/scripts/replace-sw-date.ts

이렇게 하면 button을 클릭하면 설치 메세지가 나오고 설치를 하면 앱처럼 동작한다!!!

이렇게 ai와 함께 내가 해본적 없는 기능을 개발해봤는데, 시간이 엄청나게 절약되어서 너무 좋았당

끝~~


2025.05.19 추가로 알아낸 사실!
Q. 사용자가 service worker 버전1일때 홈화면에 추가하기를 눌러서 웹앱처럼 만들어버리면, service worker가 버전2로 업데이트 되었을때 자동으로 버전 업이 되는지?
A. 서비스 워커가 업데이트되면 사용자가 홈 화면에 추가한 PWA도 다음 실행 시 자동으로 최신버전으로 업데이트 된다.
다만, "즉시" 반영되는것이 아니라 "다음 실행 시점"에 업데이트된다.

  1. 버전1 서비스워커가 설치된 상태에서 사용자가 홈 화면에 추가함.
  2. 개발자가 버전2로 새 서비스워커 배포함.
  3. 사용자가 앱을 종료하고 다시 실행하면:
    • 버전2 서비스워커가 install → activate
    • 새로운 리소스를 다시 캐싱하고 기존 캐시는 삭제됨.
    • 앱 화면이 자동으로 새 코드 기반으로 렌더링됨

2025.05.28 또 알아낸 사실!
안드로이드만 홈화면에 바로가기를 제어할 수 있는데, 크롬이랑 삼성 브라우저에서만 가능하다는 사실을 알아냈다.
유저가 네이버 인앱브라우저나, 카카오톡 인앱브라우저로 접속하면 버튼으로 홈화면에 추가가 안된다!
그래서 삼성 브라우저로 리다이렉트 되도록 처리했다.

7. 실제로 사용해야 하는 페이지에 해당 훅으로 제어
이 코드를 수정해서

import { useAddToHomeScreen } from '경로'

export default function PWA() {

const [promptEvent, promptToInstall] = useAddToHomeScreen()

const ua = navigator.userAgent
const isAndroid = ua.indexOf('Android') > -1
  const isNotAvailableInstall = ua.includes('KAKAOTALK') || ua.includes('NAVER')
  
const androidInstallHandler = () => {
    if (isAndroid && isNotAvailableInstall) {
      alert(
        '앱 설치는 삼성 인터넷에서만 가능합니다. 확인 버튼을 눌러 새로 열린 페이지에서 다시 안드로이드 설치 버튼을 눌러주세요.',
      )
      window.open(
        'intent://{서비스URL}/install#Intent;scheme=https;package=com.sec.android.app.sbrowser;end',
        '_blank',
      )
      return
    }

    promptToInstall()
  }


return (
    <div>
      <h2>안드로이드</h2>
      <button type="button" onClick={androidInstallHandler}>
        설치
      </button>
    </div>
  )
}

intent:란????
안드로이드 인텐트 시스템에서 사용하는 URI 스킴으로 , 해당 앱이 설치되어 있지 않으면 Play store로 이동하고, 있으면 앱을 실행하는 용도로 사용된다고 한다.

이렇게 하니까 네이버브라우저나 카카오 브라우저로 실행 시 삼성 브라우저로 앱실행 되는 부분을 확인했다!!

0개의 댓글