
현재 진행 중인 프로젝트에서 네트워크가 불안정한 환경에서도 사용자가 입력한 자료가 사라지지 않도록 개선하는 작업을 진행했다.
단순히 오프라인에서도 화면이 보이게 하는 것뿐 아니라, 사용자가 작성한 폼 데이터와 등록/수정 요청을 브라우저에 저장해두고 온라인 상태로 돌아왔을 때 다시 전송하는 흐름이 필요했다. 이 과정에서 PWA, Service Worker, Cache Storage, IndexedDB를 함께 적용하게 되었고, 적용하면서 알게 된 내용을 정리해보려고 한다.
PWA는 Progressive Web App의 약자로, 웹을 앱처럼 동작하게 만드는 여러 기능을 포함한다. 홈 화면 설치, 전체 화면 실행, 푸시 알림 같은 기능도 PWA의 일부지만, 이번 프로젝트에서 중요했던 부분은 오프라인 대응이었다.
PWA를 적용하면 네트워크가 불안정하거나 끊긴 상황에서도 캐시된 리소스를 활용해 화면을 유지할 수 있다. 또한 자주 사용하는 파일을 캐시해두면 재방문 시 로딩 속도도 개선할 수 있다.
이번 프로젝트에서 기대했던 효과는 크게 두 가지였다.
1️⃣ 오프라인 또는 느린 네트워크에서도 최소한의 화면을 유지하는 것
2️⃣ 오프라인 상태에서 입력한 데이터를 저장해두고, 온라인 전환 시 다시 전송하는 것
즉, 단순히 앱처럼 설치되는 웹을 만드는 것이 아니라, 네트워크 상태가 좋지 않아도 사용자가 하던 작업을 이어갈 수 있도록 만드는 것이 핵심이었다.
PWA를 적용할 때 기본적으로 봐야 하는 요소는 크게 세 가지다.
1️⃣ HTTPS
Service Worker는 보안 연결 환경에서 동작하기 때문에 운영 환경에서는 HTTPS가 필요하다.
2️⃣ Manifest
앱 이름, 아이콘, 시작 URL, 테마 색상처럼 브라우저가 이 웹앱을 설치 가능한 앱으로 인식하기 위한 정보를 담는다.
3️⃣ Service Worker
Service Worker는 브라우저와 서버 사이에서 네트워크 요청을 가로채고, 캐시 전략이나 오프라인 동작을 처리하는 역할을 한다.



처음에는 이 세 가지를 각각 따로 봤지만, 실제로 구현해보니 역할을 나누어 이해하는 것이 더 중요했다. Manifest는 설치 정보를 담당하고, Service Worker는 요청 처리와 캐싱을 담당한다. 그리고 실제 데이터 저장은 Cache Storage와 IndexedDB를 상황에 맞게 나누어 사용해야 했다.
오프라인 대응을 하면서 가장 먼저 정리해야 했던 부분은 어떤 데이터를 어디에 저장할 것인가였다.
정적 리소스와 사용자가 입력한 데이터는 성격이 다르기 때문에 별도의 저장소를 필요로 할 것 같다는 생각이 들었다.
Cache Storage
Service Worker가 관리하는 응답 캐시 저장소로 JS, CSS, 이미지, 페이지 문서, API 응답처럼 네트워크 응답 형태로 다루기 좋은 데이터에 적합하다.
IndexedDB
브라우저 내장 로컬 DB에 가깝다. 리스트, 셀렉트 옵션, 트리 데이터처럼 구조화된 조회 데이터를 저장하거나, 오프라인 상태에서 발생한 등록/수정 요청을 큐 형태로 쌓아두기 좋다.
이번 프로젝트에서는 역할을 아래처럼 나누었다.
Cache Storage
IndexedDB
이렇게 나누니 구조가 더 명확해졌다. Cache Storage는 앱을 다시 켜기 위한 껍데기 역할을 하고, IndexedDB는 화면 안에 채워 넣을 데이터와 사용자가 입력한 요청을 보관하는 역할을 하게 되었다.
Next.js에서 PWA를 적용하기 위해 next-pwa를 사용하여 Workbox 기반으로 서비스 워커와 캐시 전략을 빌드 단계에서 생성하는 방식을 선택했다.
기본 설정은 아래와 같이 구성했다.
const withPWA = require("next-pwa")({
dest: "public",
register: true,
skipWaiting: true,
clientsClaim: true,
cacheOnFrontEndNav: true,
cacheStartUrl: true,
disable: process.env.NODE_ENV === "development",
fallbacks: {
document: "/",
},
runtimeCaching: [
// cache rules
],
});
각 옵션의 역할을 정리하면 다음과 같다.
dest : "public"은 생성된 sw.js와 Workbox 관련 파일을 public 폴더에 생성하기 위한 설정이다. Service Worker는 브라우저에서 정적 파일처럼 접근할 수 있어야 하므로 일반적으로 public에 생성되도록 둔다.
register: true는 앱에서 Service Worker를 자동으로 등록하도록 한다.
skipWaiting: true는 새 Service Worker가 설치되었을 때 대기 상태에 머무르지 않고 바로 활성화되도록 하는 옵션이다.
clientsClaim: true는 활성화된 Service Worker가 현재 열려 있는 탭도 바로 제어하도록 한다. skipWaiting과 함께 사용하면 업데이트된 Service Worker를 비교적 빠르게 반영할 수 있다.
disable: process.env.NODE_ENV === "development"는 개발 환경에서는 PWA를 비활성화하기 위한 설정이다. 개발 중에는 캐시가 오히려 디버깅을 방해할 수 있기 때문에 운영 환경에서만 활성화되도록 했다.
PWA를 적용하면서 가장 먼저 마주한 문제는 오프라인 상태에서 새로고침을 했을 때 앱이 아예 뜨지 않는 상황이었다.
사용자가 이미 화면을 보고 있다가 네트워크가 끊기는 것은 어느 정도 대응할 수 있었지만, 오프라인 상태에서 새로고침을 하거나 특정 URL로 바로 접근하면 문서 요청이 실패하면서 앱 자체가 뜨지 않았다.
이 문제를 해결하기 위해 App Shell 방식을 사용했다.
앱을 실행하는 데 필요한 최소한의 껍데기를 캐시해두고, 오프라인에서도 우선 앱의 기본 화면을 띄우는 방식이다. 이후 화면에 필요한 실제 데이터는 IndexedDB에서 복원한다.
이번 설정에서는 아래 옵션들이 이 역할을 했다.
cacheStartUrl: true,
cacheOnFrontEndNav: true,
fallbacks: {
document: "/",
},
cacheStartUrl: true는 앱의 시작 URL을 캐시에 저장한다. 오프라인에서 /로 폴백할 계획이라면 시작 URL 자체가 캐시에 있어야 안전하다.
cacheOnFrontEndNav: true는 Next.js의 클라이언트 라우팅 상황에서도 캐시가 쌓이도록 도와준다.
fallbacks: { document: "/" }는 문서 요청이 실패했을 때 /로 폴백하도록 한다. 즉, 오프라인에서 어떤 경로를 새로고침하더라도 우선 앱의 진입점을 띄우고, 이후 필요한 데이터는 별도로 복원하는 방향이다.
정리하면 아래와 같다.
오프라인에서 문서 요청 실패
↓
캐시된 "/" 문서로 폴백
↓
앱 Shell 실행
↓
IndexedDB에서 화면 데이터 복원
다음으로는 어떤 요청을 어떤 전략으로 캐싱할지 정리했다.
모든 요청을 같은 방식으로 캐싱하면 문제가 생길 수 있다. JS/CSS 번들처럼 변경이 적고 해시가 붙는 파일은 캐시에 오래 두어도 괜찮지만, 페이지 데이터나 API 응답처럼 최신성이 중요한 데이터는 네트워크를 먼저 확인하는 편이 낫다.
그래서 요청 성격에 따라 캐시 전략을 나누었다.
1️⃣. Next 데이터 JSON은 Network First
{
urlPattern: /^\/_next\/data\/.*\.json$/i,
handler: "NetworkFirst",
options: {
cacheName: "next-data",
networkTimeoutSeconds: 3,
},
}
/_next/data/*.json은 페이지 전환이나 프리패치에서 사용되는 데이터다. 최신 데이터가 중요하기 때문에 NetworkFirst 전략을 사용했다.
다만 네트워크가 애매하게 느릴 때 계속 기다리면 화면이 멈춘 것처럼 보일 수 있다. 그래서 networkTimeoutSeconds: 3을 두어 3초 안에 응답이 오지 않으면 캐시로 폴백하도록 했다.
2️⃣. Next 정적 번들은 Cache First
{
urlPattern: /^https?.\/_next\/static\/./i,
handler: "CacheFirst",
options: {
cacheName: "next-static",
},
}
/_next/static 하위의 JS/CSS 번들은 파일명에 해시가 붙기 때문에 캐시와 잘 맞는다. 한 번 받은 파일은 빠르게 재사용하는 것이 더 중요하다고 판단해 CacheFirst 전략을 사용했다.
3️⃣. 이미지는 Stale While Revalidate
{
urlPattern: /^https?.*\/_next\/image\?url=.*/i,
handler: "StaleWhileRevalidate",
options: {
cacheName: "next-image",
expiration: {
maxEntries: 200,
maxAgeSeconds: 60 * 60 * 24 * 30,
},
},
}
이미지는 매번 최신을 받기보다 일단 빠르게 보여주는 것이 사용자 경험에 더 좋을 때가 많다. 그래서 캐시에 이미지가 있으면 먼저 보여주고, 뒤에서 최신 이미지를 받아 캐시를 갱신하는 StaleWhileRevalidate 전략을 사용했다.
이미지 캐시는 용량이 커질 수 있기 때문에 최대 개수와 보관 기간도 함께 제한했다.
4️⃣. 문서 요청은 Network First
{
urlPattern: ({ request }) => request.mode === "navigate",
handler: "NetworkFirst",
options: {
cacheName: "pages",
networkTimeoutSeconds: 3,
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 7,
},
},
}
문서 요청은 오프라인 새로고침 안정성과 관련이 있다. 온라인 상태에서는 최신 문서를 받는 것이 좋지만, 오프라인이거나 네트워크 응답이 늦으면 캐시된 문서를 보여줘야 한다.
그래서 문서 요청은 NetworkFirst로 두고, 네트워크 응답이 3초 이상 지연되면 캐시로 폴백하도록 했다.
PWA로 설치 가능한 앱처럼 동작하려면 Manifest 설정도 필요하다.
Next.js App Router에서는 app/manifest.ts로 Manifest를 제공할 수 있다.
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "My Next PWA",
short_name: "MyPWA",
description: "Next.js로 만든 PWA",
start_url: "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#000000",
icons: [
{
src: "/icon-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icon-512x512.png",
sizes: "512x512",
type: "image/png",
},
],
};
}
start_url: 설치된 앱을 실행했을 때 처음 열리는 경로다.
display: "standalone"은 주소창이 보이지 않는 독립 앱처럼 실행되도록 한다.
icons: 홈 화면에 추가했을 때 사용할 아이콘이다. 보통 192px, 512px 아이콘은 기본적으로 준비하는 것이 좋다.
Pages Router를 사용하거나 public/manifest.json으로 직접 제공하는 경우에는 _app.tsx 또는 문서 설정에 아래처럼 manifest 링크를 추가할 수 있다.
<link rel="manifest" href="/manifest.json" />
설정을 마친 뒤 npm run build와 npm run start로 운영 빌드 환경에서 실행하면 public 폴더에 sw.js와 Workbox 관련 파일이 생성된다.


브라우저에서는 DevTools의 Application 탭에서 Service Worker가 정상적으로 등록되었는지 확인할 수 있다.

여기서 Service Worker 상태가 활성화되어 있고 실행 중으로 표시되면 기본 등록은 정상적으로 된 것이다.
PWA 설정만으로는 오프라인에서 입력한 데이터를 저장하고 재전송할 수 없다. Service Worker와 Cache Storage는 앱을 띄우고 리소스를 캐싱하는 데 도움을 주지만, 사용자가 작성한 폼 데이터나 등록 요청은 별도로 저장해야 한다.
이번 프로젝트에서는 온라인 상태일 때 미리 조회한 셀렉트 목록과 폼 입력 데이터를 IndexedDB에 저장했다.
오프라인 상태에서 사용자가 등록 또는 수정 요청을 하면, 바로 API를 호출하는 대신 요청 정보를 IndexedDB에 큐 형태로 저장했다. 이후 온라인 상태로 돌아오면 큐에 쌓인 요청을 하나씩 꺼내 서버로 전송하도록 구성했다.
흐름을 정리하면 아래와 같다.
온라인 상태
↓
셀렉트 목록, 기준 데이터 조회
↓
IndexedDB에 저장
오프라인 상태
↓
사용자가 폼 작성 후 등록/수정
↓
API 요청 대신 IndexedDB Queue에 저장
온라인 전환
↓
Queue에서 요청을 하나씩 꺼냄
↓
서버로 재전송
↓
성공한 요청은 Queue에서 제거
이 방식의 장점은 사용자가 네트워크 상태를 크게 신경 쓰지 않고 작업을 이어갈 수 있다는 점이다. 오프라인 상태에서 작성한 데이터가 사라지지 않고, 온라인으로 돌아왔을 때 자동으로 전송되기 때문이다.

이번 작업을 하면서 PWA는 단순히 Manifest를 추가하고 Service Worker를 등록하는 것으로 끝나는 기능이 아니라는 것을 느꼈다.
오프라인 화면을 유지하려면 Cache Storage가 필요하고, 사용자가 입력한 데이터를 보관하려면 IndexedDB가 필요했다. 또한 온라인으로 돌아왔을 때 요청을 다시 보내려면 큐 구조와 재전송 흐름도 함께 설계해야 했다.
특히 Cache Storage와 IndexedDB의 역할을 나누는 것이 중요했다.
Cache Storage는 앱을 다시 실행할 수 있게 해주는 정적 리소스와 응답 캐시에 적합했고, IndexedDB는 화면에 뿌릴 구조화된 데이터와 오프라인 요청을 저장하는 데 더 적합했다.
처음에는 오프라인 대응을 “캐시를 잘 하면 되는 문제”라고 생각했지만, 실제로는 화면 유지, 데이터 복원, 사용자 입력 저장, 온라인 복귀 후 동기화까지 함께 고려해야 하는 작업이었다.
앞으로 비슷한 기능을 구현하게 된다면 먼저 아래 질문부터 정리해볼 것 같다.
오프라인에서도 꼭 보여줘야 하는 화면은 어디까지인가?
어떤 데이터는 캐시하고, 어떤 데이터는 IndexedDB에 저장할 것인가?
사용자가 오프라인에서 작성한 요청은 어떤 형식으로 저장할 것인가?
온라인으로 돌아왔을 때 요청을 어떤 순서로 재전송할 것인가?
재전송 실패 시 사용자에게 어떻게 보여줄 것인가?
이번 경험을 통해 PWA는 단순히 웹을 앱처럼 보이게 만드는 기술이 아니라, 네트워크가 불안정한 상황에서도 사용자의 작업 흐름을 최대한 유지하기 위한 구조라는 것을 알게 되었다.