0%

Frontend

브라우저 탭과 설치된 앱은 같은 화면이 아니었다(PWA)

신*진··수정됨 2026.09.06

브라우저 탭과 설치된 앱은 같은 화면이 아니었다(PWA)

시작하며

"홈 화면에 추가"를 누른 사용자와 그냥 브라우저 탭에서 접속한 사용자, 두 사람에게 같은 화면을 보여주면 될까요? 저희 팀은 사내 업무 관리 시스템을 PWA로 만들면서 "아니오"라는 답을 얻었습니다. 매니페스트 파일 하나 추가한다고 끝나는 작업이 아니라, 설치 여부를 감지해서 네비게이션 구조 자체를 바꾸고, 서비스워커가 뭘 캐싱하고 뭘 캐싱하면 안 되는지를 하나씩 정해야 하는 작업이었습니다.

이 글은 그 과정에서 저희가 실제로 만든 세 가지 장치 — 독립 실행 모드 감지, 서비스워커 캐싱 전략, 그리고 그 위에 얹은 푸시 알림 — 을 정리한 기록입니다.

PWA로 가는 첫걸음: 매니페스트

시작은 Next.js의 manifest.ts 파일 컨벤션으로 앱 메타데이터를 정의하는 것이었습니다.

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "GIWORKS Command",
    short_name: "GIWORKS",
    start_url: "/",
    scope: "/",
    display: "standalone",
    icons: [/* 192/512, maskable 포함 4종 */],
  }
}

display: "standalone"을 지정하면 홈 화면에 추가된 앱은 브라우저 주소창 없이 독립된 창으로 열립니다. 그런데 이 한 줄이, 저희가 처음 생각했던 것보다 훨씬 많은 걸 요구했습니다. 브라우저 탭에서는 자연스러웠던 뒤로 가기, 주소창, 탭 전환 같은 것들이 standalone 모드에는 아예 없기 때문입니다.

문제 1: 브라우저 탭과 설치된 앱은 같은 화면이 아니었다

모바일 브라우저 탭으로 볼 때는 상단 메뉴 버튼을 눌러 여는 슬라이드 드로어 내비게이션이 자연스럽습니다. 하지만 홈 화면에 추가되어 standalone으로 열리면, 브라우저 주소창도 없고 뒤로 가기 버튼도 없어서 "탭 하나 눌러서 메뉴 여는" 방식보다는 네이티브 앱처럼 하단 탭 바가 항상 떠 있는 게 자연스럽습니다.

blog-image-1-browser-vs-standalone.png

왼쪽은 브라우저 탭(드로어 내비게이션), 오른쪽은 설치된 앱(하단 고정 탭 바 AppBottomNav)

이걸 구분하려면 지금 이 화면이 브라우저 탭인지 설치된 앱인지부터 판별해야 했습니다.

// src/hooks/use-pwa-standalone.ts
function getIsStandalone() {
  if (typeof window === "undefined") return false

  return (
    window.matchMedia("(display-mode: standalone)").matches ||
    window.matchMedia("(display-mode: fullscreen)").matches ||
    (window.navigator as StandaloneNavigator).standalone === true
  )
}

표준은 display-mode 미디어 쿼리지만, iOS Safari는 이 값을 제대로 주지 않는 버전이 있어서 비표준 속성인 navigator.standalone을 함께 봅니다. 세 조건 중 하나라도 참이면 설치된 앱으로 판단합니다.

한 번 읽고 끝내지 않고 change 이벤트를 구독하는 이유는, 실행 중에 표시 모드가 바뀔 수 있기 때문입니다.

React.useEffect(() => {
  const standaloneQuery = window.matchMedia("(display-mode: standalone)")
  const fullscreenQuery = window.matchMedia("(display-mode: fullscreen)")
  const update = () => setIsStandalone(getIsStandalone())

  update()
  standaloneQuery.addEventListener("change", update)
  fullscreenQuery.addEventListener("change", update)

  return () => {
    standaloneQuery.removeEventListener("change", update)
    fullscreenQuery.removeEventListener("change", update)
  }
}, [])

초기값을 false로 두고 useEffect 안에서 갱신하는 것도 의도한 부분입니다. 서버 렌더링 시점에는 표시 모드를 알 수 없어서, 서버와 클라이언트의 첫 렌더 결과를 맞추지 않으면 하이드레이션 불일치가 납니다.

이제 앱 셸에서 이 값 하나로 내비게이션을 분기합니다.

// src/components/layout/app-shell.tsx
const isStandalone = usePwaStandalone()

return (
  <>
    <div className="flex min-h-svh w-full bg-[#f6f7f9]">
      <AppSidebar className="flex shrink-0 max-lg:hidden lg:sticky lg:top-0 lg:h-svh" />
      <div
        className={cn(
          "flex min-w-0 flex-1 flex-col lg:pb-0",
          isStandalone ? "pb-[calc(62px+env(safe-area-inset-bottom))]" : "pb-0"
        )}
      >
        {children}
      </div>
    </div>
    {isStandalone ? (
      <AppBottomNav className="fixed inset-x-0 bottom-0 z-40 lg:hidden" />
    ) : (
      <MobileNavDrawer />
    )}
  </>
)

설치된 앱이면 하단 고정 탭 바를, 브라우저 탭이면 슬라이드 드로어를 렌더합니다. 하단 탭 바는 fixed라서 문서 흐름에서 빠지기 때문에, 콘텐츠 영역에 62px + safe-area-inset-bottom만큼 아래 여백을 줘서 마지막 항목이 탭 바에 가려지지 않게 했습니다. env(safe-area-inset-bottom)은 아이폰 홈 인디케이터 영역을 피하기 위한 값입니다. 브라우저 탭에서는 이 여백 자체가 필요 없어서 isStandalone일 때만 적용됩니다.

문제 2: 서비스워커에 뭘 캐싱하고, 뭘 캐싱하면 안 되는가

서비스워커를 등록하는 코드 자체는 짧습니다.

// src/components/pwa/pwa-register.tsx
function PwaRegister() {
  React.useEffect(() => {
    if (!("serviceWorker" in navigator)) return

    navigator.serviceWorker.register("/sw.js").catch(() => {
      // PWA 등록 실패는 앱 사용을 막지 않는다.
    })
  }, [])

  return null
}

어려운 건 등록이 아니라 캐싱 전략이었습니다. 아이콘, 매니페스트 같은 정적 자산은 캐싱해서 오프라인에서도 앱 껍데기가 뜨게 하고 싶었지만, /api/로 시작하는 요청까지 캐싱해버리면 차량 예약 현황 같은 실시간 데이터가 오래된 채로 보이는 문제가 생깁니다.

// public/sw.js
self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url)

  if (event.request.method !== "GET" || url.origin !== self.location.origin) return
  if (url.pathname.startsWith("/api/")) return // API 응답은 캐싱하지 않는다

  const isStaticAsset = [
    "/manifest.webmanifest",
    "/favicon-192.png",
    "/favicon-512.png",
  ].includes(url.pathname)

  if (!isStaticAsset) return

  event.respondWith(
    caches.match(event.request).then((cached) => cached ?? fetch(event.request))
  )
})

캐싱 대상을 화이트리스트로 명시하고, 그 외 요청은 전부 그냥 네트워크로 흘려보내는 방식을 택했습니다. "캐싱해도 안전한 것"을 하나씩 골라 넣는 편이, "캐싱하면 안 되는 것"을 하나씩 빼는 것보다 실수할 여지가 적었습니다.

실제로 캐시에 남는 건 이게 전부입니다. 개발자 도구의 Cache Storage를 열어보면 아이콘과 매니페스트만 들어 있고, /api/ 응답은 한 건도 없습니다.

스크린샷 2026-07-28 오후 3.34.40.png

Cache Storage에 남은 항목 — 아이콘 4종과 manifest.webmanifest뿐이다

캐시 버전 관리는 CACHE_NAME으로 합니다.

const CACHE_NAME = "giworks-command-v1"

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()
})

정적 자산 구성이 바뀌면 CACHE_NAME을 올리고, activate 시점에 이전 버전 캐시를 정리합니다. skipWaiting()clients.claim()을 같이 써서 새 서비스워커가 배포되면 탭을 새로고침하지 않아도 곧바로 활성화되게 했습니다.

문제 3: 설치된 앱 위에 얹은 부가 기능, 푸시 알림

여기까지 오면 서비스워커가 이미 떠 있는 상태이기 때문에, 푸시 알림은 그 위에 이벤트 리스너 하나를 더 얹는 정도의 작업이라고 생각했습니다. 실제로는 CSP가 importScripts로 불러온 Firebase SDK를 막아버려서 서비스워커 등록 자체가 깨진 적도 있었고, "로그아웃한 사용자에겐 알림을 띄우지 말자"는 방어 로직이 오히려 알림을 들쭉날쭉 안 뜨게 만든 적도 있었습니다.

두 문제 모두 결론은 비슷했습니다. Firebase SDK 대신 표준 push 이벤트로 직접 처리하고, 세션 검증은 서비스워커가 아니라 서버의 토큰 비활성화 로직에만 맡기는 것. 서비스워커는 이미 캐싱 전략 때문에 "뭘 믿고 뭘 안 믿을지"를 계속 고민해야 하는 곳이었는데, 푸시 알림에서도 결국 같은 종류의 판단이 필요했던 셈입니다.

마치며

세 가지 문제를 관통하는 결론은, PWA는 "웹사이트에 매니페스트 하나 얹는 작업"이 아니라 브라우저 탭과 설치된 앱이라는 두 가지 실행 환경을 동시에 설계하는 작업이라는 것이었습니다.

  • 같은 화면도 실행 환경에 따라 다른 레이아웃이 필요합니다. → display-modenavigator.standalone으로 판별하고 네비게이션을 분기합니다

  • 서비스워커의 캐싱은 화이트리스트로, 버전 관리는 CACHE_NAME으로 합니다

  • 푸시 알림 같은 부가 기능도 결국 서비스워커의 신뢰 경계를 다시 고민하게 만듭니다

지금은 이 구조로 브라우저 탭과 설치된 앱 양쪽에서 안정적으로 동작하고 있습니다. PWA를 처음 붙이는 팀이라면, 매니페스트보다 이 실행 환경 분기와 캐싱 화이트리스트를 먼저 설계하시길 권합니다.

읽기 도구

12분 읽기

이 글이 도움이 되었나요?

Work with GIWorks

프로젝트에 GIWorks의 경험이 필요하신가요?

전시·콘텐츠·엔지니어링 프로젝트의 아이디어와 고민을 들려주세요. 필요한 기술과 실행 방법을 함께 찾겠습니다.

문의하기

다음으로 읽기