본문 바로가기
IT/AI

[AI] Claude와 함께 타임박싱 웹앱 만들기 #6 — PWA: 웹앱을 앱처럼 쓰는 마법 🪄

by 수누다 2026. 3. 24.

"야, 이거 앱으로 못 만들어?" — 그 한마디에서 시작된 이야기

금요일 밤, 아이들을 재우고 소파에 누워서 핸드폰으로 타임박싱 앱을 열려는데… 매번 브라우저 열고, 주소 치고, 로그인하고. 이 과정이 은근히 귀찮거든요. 앱 아이콘 톡 누르면 바로 열리면 얼마나 좋을까 싶었어요. 네이티브 앱을 처음부터 만들면 좋겠지만, 지금까지 5편에 걸쳐 열심히 만든 웹앱을 버리고 Flutter나 React Native로 다시 만드는 건 솔직히 미친 짓이잖아요.

그래서 찾은 답이 바로 PWA(Progressive Web App)였습니다. 결론부터 말하면, 파일 딱 2개 추가하니까 앱처럼 동작하더라고요. 물론 그 과정에서 캐시라는 녀석과 몇 시간 동안 싸웠지만요. (스포일러: 제가 졌습니다.)

PWA가 뭔지 간단하게 정리해볼게요

PWA는 웹사이트를 네이티브 앱처럼 쓸 수 있게 해주는 기술이에요. 2026년 현재, 전 세계 인터넷 사용자의 약 96%가 모바일 기기를 사용하고, 모바일 웹 트래픽이 전체의 약 60%를 차지하는 상황에서 PWA의 존재감은 더욱 커지고 있습니다. Pinterest는 PWA 도입 후 사용자 참여가 60% 증가하고 광고 수익이 44% 늘었다는 사례도 있고, Twitter Lite는 데이터 사용량을 70%까지 줄였다고 하니까요.

PWA의 핵심은 세 가지에요.

첫째, manifest.json입니다. 앱의 이름, 아이콘, 시작 URL 같은 메타 정보를 정의하는 JSON 파일이에요. 브라우저가 이 파일을 읽고 "아, 이건 설치 가능한 앱이구나"하고 판단하는 거죠.

둘째, Service Worker입니다. 브라우저와 네트워크 사이에서 중간 프록시 역할을 하는 자바스크립트 파일이에요. 네트워크 요청을 가로채서 캐시를 관리하고, 오프라인에서도 앱이 동작할 수 있게 해줍니다.

셋째, HTTPS입니다. Service Worker는 보안 연결에서만 동작하거든요. 다행히 이전 편에서 Nginx에 Let's Encrypt SSL을 이미 설정해놨기 때문에, 이건 별도 작업 없이 바로 충족됐어요.

이 세 가지가 갖춰지면 핸드폰에서 "홈 화면에 추가"를 하면 앱 아이콘이 생기고, PC 크롬이나 엣지에서 "앱으로 설치"를 하면 독립된 창으로 실행돼요. 브라우저 주소창도 없고, 진짜 앱 같은 느낌이 나거든요.

manifest.json — 앱의 신분증 만들기

manifest.json은 PWA의 신분증 같은 파일이에요. 브라우저에게 "나는 이런 앱이야, 이렇게 보여줘"라고 알려주는 역할을 합니다.

{
  "name": "TimeBoxing Planner",
  "short_name": "TimeBox",
  "description": "하루를 의도적으로 설계하는 타임박싱 플래너",
  "start_url": "/",
  "display": "standalone",
  "orientation": "any",
  "background_color": "#f1f5f9",
  "theme_color": "#2563eb",
  "lang": "ko",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

여기서 몇 가지 포인트를 짚어볼게요.

display: "standalone"이 핵심이에요. 이 설정 덕분에 브라우저 UI(주소창, 뒤로가기 버튼 등)가 전부 사라지고 앱처럼 보이거든요. 참고로 fullscreen은 게임 같은 데 쓰고, minimal-ui는 최소한의 브라우저 컨트롤을 남겨두는 모드에요. 타임박싱 앱은 독립 앱 느낌이 필요하니까 standalone이 딱이었습니다.

orientation: "any"로 설정한 이유는, 이 앱을 핸드폰에서도 쓰고 PC에서도 쓰거든요. 가로 세로 모두 허용해야 두 환경에서 자연스러워요.

아이콘은 192px과 512px 두 가지 사이즈가 필수에요. 192px은 홈 화면 아이콘에, 512px은 스플래시 스크린이나 앱 스토어 같은 곳에서 쓰입니다. 그리고 HTML의 <head> 태그에 아래 코드를 추가해야 브라우저가 manifest.json을 인식해요.

<link rel="manifest" href="/manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#2563eb">

아이콘은 디자이너한테 부탁하면 좋겠지만, 저는 4자녀 아빠에 본업도 있는 엔지니어라 그런 여유가 없었어요. Python Pillow로 파란 배경에 "TIME BOXING" 텍스트와 진행률 바를 넣어서 간단하게 만들었습니다. 예쁘지는 않지만, 기능은 해요. (미적 감각은 다음 생에...)

Service Worker — 앱의 비밀 요원

Service Worker는 브라우저 뒤에서 조용히 일하는 비밀 요원 같은 존재에요. 페이지가 만드는 모든 네트워크 요청을 중간에서 가로채서, 캐시된 응답을 줄 수도 있고 네트워크에서 새로 가져올 수도 있어요. 2026년 현재 모든 주요 브라우저가 Service Worker를 완전히 지원하고 있어서, 호환성 걱정은 거의 안 해도 됩니다.

캐시 전략: Cache First에서 Network First로 갈아탄 사연

처음에는 기존에 있던 sw.jsCache First 전략을 쓰고 있었어요. 캐시에 파일이 있으면 무조건 캐시에서 꺼내서 주는 방식이죠. 폰트나 이미지처럼 안 바뀌는 파일에는 완벽한 전략이에요.

근데 문제는, 저처럼 매일 코드를 고치고 있는 상황에서는 재앙이라는 거예요. sync-wrapper.js를 수정해도 캐시된 옛날 파일을 계속 서빙하니까, "분명 고쳤는데 왜 안 바뀌지?" 하면서 머리를 쥐어뜯게 되거든요.

결국 Network First 전략으로 바꿨습니다.

self.addEventListener('fetch', e => {
  const url = new URL(e.request.url);

  // PocketBase API는 캐시하지 않음
  if (url.pathname.startsWith('/pb/')) return;

  e.respondWith(
    fetch(e.request)
      .then(res => {
        // 네트워크 성공 → 캐시 업데이트
        if (res.ok) {
          const clone = res.clone();
          caches.open(CACHE).then(c => c.put(e.request, clone));
        }
        return res;
      })
      .catch(() => {
        // 네트워크 실패 → 캐시에서 제공
        return caches.match(e.request);
      })
  );
});

이 전략의 로직은 간단해요. 온라인이면 항상 서버에서 최신 파일을 받아오고, 받아온 김에 캐시도 갱신해요. 네트워크가 안 되는 상황에서만 캐시에 있는 파일을 꺼내서 줍니다. 개발 단계에서는 이 방식이 정답이에요.

참고로, 캐시 전략은 콘텐츠 유형에 따라 다르게 적용하는 게 2026년 현재 권장되는 베스트 프랙티스에요. 정적 자산(폰트, 아이콘)은 Cache First, 동적 콘텐츠(API 응답)는 Network First, 그리고 그 중간 정도의 콘텐츠에는 Stale While Revalidate(일단 캐시에서 빠르게 주고 백그라운드에서 갱신)를 쓰는 식이죠. 저도 앱이 안정화되면 파일 유형별로 전략을 분리할 계획이에요.

캐시 대상 파일 — 3개에서 12개로

오프라인에서 제대로 동작하려면 어떤 파일을 캐시할지도 신경 써야 해요. 처음에는 핵심 HTML 3개만 캐시했는데, 오프라인으로 들어가면 폰트가 깨지고 아이콘이 안 보이더라고요.

const STATIC = [
  '/', '/index.html', '/login.html', '/review.html',
  '/sync-wrapper.js', '/manifest.json',
  '/fonts/MaruBuri-Regular.woff2',
  '/fonts/Pretendard-Regular.woff2',
  '/fonts/Pretendard-SemiBold.woff2',
  '/fonts/Pretendard-Bold.woff2',
  '/icons/icon-192.png', '/icons/icon-512.png'
];

12개로 확장했어요. 폰트 4종, 아이콘 2종, 핵심 파일 6개. 이렇게 하니까 오프라인에서도 깨지지 않고 깔끔하게 렌더링 됩니다. 다만 여기서 주의할 점은, Service Worker 파일 자체(sw.js)는 캐시 목록에 넣으면 안 돼요. SW가 자기 자신을 캐시하면 업데이트가 꼬이거든요.

설치 방법 — 플랫폼별로 정리

PWA의 매력은 하나의 코드베이스로 안드로이드, iOS, PC 모든 플랫폼에서 앱처럼 쓸 수 있다는 거예요.

안드로이드에서는 크롬 브라우저로 접속하면 주소창 아래에 "앱 설치" 배너가 자동으로 뜹니다. 또는 크롬 메뉴에서 "앱 설치"를 선택하면 돼요. 홈 화면에 TimeBox 아이콘이 생기고, 터치하면 독립된 앱처럼 열려요. 안드로이드 크롬은 PWA 지원이 가장 잘 되어 있어서 경험이 정말 매끄럽습니다.

아이폰(iOS)에서는 Safari로 접속한 후 공유 버튼(⬆️)을 눌러서 "홈 화면에 추가"를 선택하면 돼요. iOS에서는 자동 설치 프롬프트가 아직 지원되지 않아서 사용자가 직접 추가해야 해요. 그래도 2023년 iOS 16.4부터 PWA에서 푸시 알림이 지원되기 시작했고, 점점 Safari의 Service Worker 안정성도 나아지고 있어서 기대할 만합니다.

PC(Chrome/Edge)에서는 주소창 오른쪽에 설치 아이콘(⊕)이 나타나요. 클릭하면 독립 창으로 실행되고, 윈도우 작업표시줄에 고정할 수도 있어요. 저는 회사 PC에서 이렇게 설치해두고, 점심시간에 오후 일정을 정리하는 용도로 쓰고 있어요.

캐시와의 전쟁 — 진짜 삽질은 여기서 시작

PWA 구현 자체는 솔직히 쉬웠어요. manifest.json 만들고, sw.js 넣고, HTML에 링크 걸면 끝이니까요. 근데 진짜 지옥은 캐시 관리였습니다. 이거 때문에 금요일 밤을 통째로 날렸어요.

삽질 1: Nginx의 immutable 캐시

이전에 Nginx 설정에서 정적 파일에 Cache-Control: public, immutable을 걸어뒀거든요. "이 파일은 절대 안 변하니까 캐시 써" 라는 뜻인데, 폰트 파일에는 완벽한 설정이에요. 문제는 JS 파일에도 똑같이 걸려 있었다는 거죠.

sync-wrapper.js를 수정해도 브라우저가 7일 동안 캐시된 옛날 파일을 물고 놔주질 않았어요. 서버에서 curl로 확인하면 분명 최신 파일인데, 브라우저에서는 구버전이 나와요.

해결책은 파일 타입별로 캐시 정책을 분리하는 거였어요. JS와 HTML은 no-cache(매번 서버에 확인), 폰트 같은 정적 자산만 7일 캐시. 이렇게 하니까 JS 업데이트가 바로 반영되기 시작했어요.

삽질 2: 좀비 Service Worker

SW를 수정하고 다시 배포했는데, 이전 버전 SW가 살아서 옛날 캐시를 계속 서빙하고 있었어요. SW를 제거하고 캐시를 삭제해도 브라우저가 뭔가를 물고 있더라고요.

결국 브라우저 콘솔에서 강제 초기화를 했습니다.

// 모든 캐시 삭제
caches.keys().then(k => k.forEach(n => caches.delete(n)));
// 모든 Service Worker 등록 해제
navigator.serviceWorker.getRegistrations()
  .then(r => r.forEach(w => w.unregister()));

이 두 줄이 제 정신 건강을 지켜줬어요. 참고로, SW 업데이트 시에는 activate 이벤트에서 이전 버전 캐시를 정리하는 코드를 넣는 게 좋습니다. SW의 버전 번호를 바꾸면 브라우저가 새 SW로 교체하거든요.

삽질 3: 정체불명의 중간 캐시

서버 파일은 맞는데 브라우저가 받는 파일 크기가 달라요. 서버에서 curl로 확인하면 최신인데, 브라우저에서는 옛날 파일이에요. Nginx 캐시? 아닌데. CDN? 안 쓰는데. 원인을 찾지 못한 미스터리한 중간 캐시였어요.

최종 해결책은 의외로 단순했습니다. 파일명에 버전 쿼리를 추가하는 거예요.

<script src="/sync-wrapper.js?v=4.7"></script>

?v=4.7이 붙으면 브라우저 입장에서는 완전히 다른 URL이거든요. 업데이트할 때마다 숫자만 올리면 돼요. 이 방법은 "캐시 버스팅(Cache Busting)"이라고 불리는데, 원시적이지만 확실한 방법이에요. 2026년에도 여전히 현역으로 쓰이는 테크닉입니다.

저처럼 개발 단계에서 캐시 때문에 고생하시는 분들께 한마디 드리자면, Service Worker가 살아있는 한 Nginx 캐시 설정을 아무리 바꿔도 소용없어요. SW가 네트워크 요청을 먼저 가로채거든요. 반드시 SW를 먼저 제거하거나 업데이트한 후에 서버 캐시 설정을 확인하세요.

이번 편에서 배운 것들

PWA를 직접 구현하면서 느낀 점들을 솔직하게 정리해볼게요.

PWA는 생각보다 진입장벽이 낮아요. manifest.json과 sw.js 두 파일만 추가하면 기본적인 앱 설치가 가능해지거든요. 네이티브 앱을 처음부터 새로 만드는 것과 비교하면 압도적으로 적은 노력으로 앱 같은 경험을 줄 수 있어요.

하지만 캐시 관리는 생각보다 훨씬 까다로워요. 특히 개발 중에는 캐시가 최대의 적이 됩니다. Cache First 전략은 프로덕션에서는 훌륭하지만, 개발 단계에서는 Network First가 정신건강에 이로워요.

?v=버전번호 쿼리를 붙이는 캐시 버스팅은 원시적이지만 만능 해결책이에요. 복잡한 빌드 도구 없이도 캐시 문제를 즉시 해결할 수 있어요.

그리고 가장 중요한 교훈 하나. Service Worker, Nginx, 브라우저 — 이 세 녀석은 각각 독립적으로 캐시를 관리해요. 문제가 생기면 세 곳을 모두 확인해야 합니다. 하나만 봐서는 원인을 절대 못 찾아요.

4자녀 아빠인 제가 밤에 짬 내서 작업하다 보니 삽질에 할당할 시간도 한정적인데, 이 캐시 전쟁 덕분에 디버깅 실력이 한 단계 올라간 것 같아요. (긍정적으로 생각하겠습니다…)

다음 편에서는 가족끼리 일정을 공유할 수 있는 기능을 만들어볼 예정이에요. 아이 넷을 키우다 보면 누가 언제 어디에 가는지 파악하는 것 자체가 전쟁이거든요. 이걸 앱으로 해결해보려고 합니다.


👉 #1화 — 타임박싱이란? 앱 만들게 된 이유
👉 #2화 — PocketBase + Nginx + Proxmox 서버 인프라 구축
👉 #3화 — 멀티유저 인증과 데이터 동기화
👉 #4화 — 전면 리디자인: 오버레이형 통합 레이아웃
👉 #5화 — 모바일 최적화: 터치와의 전쟁
👉 #6화 — PWA: 웹앱을 앱처럼 쓰는 마법
👉 #7화 — 가족끼리 일정 공유
👉 #8화 — 가족끼리 일정 공유