Developer Should Know

[Tech] PWA란?

minliz 2025. 3. 30. 02:17

✅ 네이티브 앱이 뭔가요?

👉 앱스토어나 구글 플레이스토어에서 다운받아서 설치하는 앱

ex. 카톡, 배민,…

장단점

빠르고 성능 좋음 설치 필수!
카메라, GPS, 알림 등의 기능 사용 가능 앱 업데이트 귀찮음
앱스토어에 등록되어 있어서 신뢰성 높음 개발 비용이 많이 들기도 함

✅  PWA란?

👉 프로그레시브 웹 앱(Progressive Web Apps)의 줄임말로, 모바일 기기에서 네이티브 앱과 같은 사용자 경험을 제공하는 웹 앱

👉 웹사이트를 앱처럼 사용할 수 있게 만드는 기술

👉 웹에서 사용하는 기술과 네이티브 앱의 장점을 결합한 것.

👉 설치 없이 바로 사용할 수 있는 앱

👉 특징

  1. APP 같다 - 실제 앱처럼 홈화면에 앱 아이콘을 설치하여 쉽게 바로가기가 가능하다.
  2. push 메시지 기능
  3. 원래 웹은 클라이언트에서 서버로 요청이 있어야만 결과물을 보내주는 형태로 구현된다. push는 반대로 클라이언트의 요청이 없더라도 서버의 필요에 의해서 클라이언트에게 데이터를 보낼 수 있는 기능이다. PWA에서는 push도 가능하다!
  4. offline 접속 기능

장단점

설치가 필요 없음 성능이 네이티브 앱보다 살짝 느림
개발 비용 저럼 아이폰에서 알림 같은 기능 제한됨
업데이트 자동 고사양 기능(블루투스, AR) 사용이 어려움
  수익관련 문제로 아직 PWA 거부하는 곳도 있음

👉 사용 방법

  1. 모바일에서 웹 사이트 들어가기
  2. “홈 화면에 추가” 버튼을 누르면 끝
  3. 아이콘이 앱처럼 핸드폰에 생김

👉 (정리_)기존의 전통적인 웹 앱과 뭐가 다른가?

  • 네이티브 앱과 유사한 기능을 제공
  • PWA를 사용하면 사용자가 앱을 다운로드하거나, 업데이트할 필요 없이 웹 브라우저를 통해 앱을 바로 사용할 수 있다.
  • 웹 페이지와 달리 오프라인에서도 작동 가능
    • 웹 페이지가 로딩되는 동안 오프라인에서 캐시된 데이터를 사용할 수 있고, 네트워크 연결이 되면 새로운 데이터를 불러와 업데이트 할 수 있다.
  • 네이티브 앱과 마찬가지로 푸시 알림 기능 제공
  • 카메라, 마이크 등 모바일 기기 자체의 기능도 사용 가능

✅  PWA를 알아야 하는 이유

굳이 네이티브 앱을 두고 왜 PWA를 학습하고 적용해야 하는가?

  1. 네이티브 앱 수준의 모바일 친화적 웹 개발 가능
    1. 모바일 환경에서의 웹은 사용자에게 푸시 알림을 보내거나, 오프라인 상태에서 동작하는 기능 자체를 제공하지 않음
  2. 네이티브 앱을 공부하지 않아도 동등한 수준으로 개발 가능
    1. 네이티브 앱 개발을 학습하는데 비해 훨씬 낮은 학습량을 가짐. 네이티브 앱의 경우 처음부터 다시 학습해야 하고, 보통 OS마다 다른 방식으로 개발하기 때문에 이에 따른 학습량이 더 많음. 하지만 PWA의 경우 한 번의 개발로 안드로이드,iOS 모두 호환 가능
  3. 기업에서 바라보는 PWA
    1. 기업의 입장에서는 프론트엔드 개발자와 네이티브 앱 개발자를 따로 채용해 서비스를 구성하는 것보다, PWA를 아는 프론트엔드 개발자만 채용하는 것이 상대적으로 비용 절감이 되기 때문에 PWA를 잘 아는 프론트엔드 개발자에 대한 기업 수료가 증가할 것으로 예상할 수 있다.

✅  PWA 구현을 위한 기술적 요소

서비스 워커

  • 백그라운드에서 실행되는 스크립트
  • 웹 앱의 작업을 보조하고 추가 기능을 제공하는 역할
  • 네트워크 요청을 가로채고 캐싱하는 역할
    • 왜냐? 서비스 워커를 통해 오프라인 지원, 데이터 캐싱, 푸시 알림 등의 기능 구현 가능
  • 주요 기능
    • 오프라인 기능 제공: 인터넷 연결이 끊겼을 때오 중요한 파일들을 미리 캐시해두면, 캐시된 자원으로 웹 앱 사용 가능
    • 푸시 알림: 사용자가 웹 페이지를 떠나더라도 알림을 보낼 수 있음
    • 백그라운드 데이터 동기화 : 앱을 사용하지 않더라도 백그라운드에서 데이터를 동기화하거나 서버와의 통신 처리 가능
    • ex. 사용자가 오프라인 상태에서 작성한 데이터가 다시 온라인 상태가 되면 서버와 자동으로 동기화될 수 있음
    • 캐시 관리: 특정 리소스를 미리 저장해두고, 사용자가 요청할 때 빠르게 제공 가능

웹 앱 매니페스트

  • 웹 애플리케이션에 대한 메타데이터를 제공하는 JSON 파일
  • 애플리케이션을 홈 화면에 추가할 때의 아이콘, 이름, 시작 URL 등을 정의

애플리케이션 셸 아키텍쳐

  • 사용자 인터페이스의 기본 구조를 미리 로딩
  • ⇒ 콘텐츠가 동적으로 로드 되는 동안에도 사용자에게 빠른 경험 제공

✅ 서비스 워커 동작 방식 예시 코드

manifest.json 설정하기

먼저 public/manifest.json 파일

  • 웹 애플리케이션의 메타데이터를 포함하고 있다.
  • 이 파일을 통해 브라우저가 앱을 어떻게 표시하고 동작하게 할지 결정한다.
// 웹 앱의 이름, 아이콘, 배경 색상 등 기본 정보를 지정.
// 웹 앱을 설치했을 떄의 모습과 동작 방식을 정의

{
  "short_name": "React App",   
  // 홈화면에 아이콘을 추가했을 때 React App이라는 이름이 표시됨
  "name": "Create React App Sample",
  "icons": [
    {
      "src": "favicon.ico",
      "sizes": "64x64 32x32 24x24 16x16",
      "type": "image/x-icon"
    },
    {
      "src": "logo192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "logo512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ],
  "start_url": ".",
  // standalone은 앱을 네이티브 앱처럼 화면 전체로 표시하도록 설정
  // 주소창이나 다른 브라우저 UI 요소 없이 전체 화면에 표시됨.
  "display": "standalone",
  "theme_color": "#000000",
  "background_color": "#ffffff"
}

 

service worker 설정하기

가장 주용한 흐름 : 설치(install) - 활성화(activate) - 요청처리(fetch)

Service Worker는 웹 애플리케이션을 백그라운드에서 실행할 수 있게 해주는 스크립트 ⇒ 인터넷 연결 없이도 앱을 사용할 수 있게 하거나, 빠르게 로딩하도록 함.

service-worker.ts에서는 Workbox 라이브러리를 사용하여 캐싱, 백그라운드 동기화, 푸시 알림 등의 작업 처리

 

오프라인 지원: Service Worker를 사용하여 웹 앱의 콘텐츠를 캐싱하여 오프라인 상태에서도 사용자가 앱을 이용할 수 있도록 합니다. 캐싱된 리소스를 이용하여 기본적인 사용자 경험을 제공하거나 일부 기능을 오프라인에서도 사용 가능하게 합니다.

 

//service-worker.ts

// 웹 앱의 Service Worker를 설정하고 관리하는 부분(타입스크립트 파일)
// 웹 애플리케이션이 PWA로 동작할 수 있도록 함.
// 이 파일에서 주로 사용되는 라이브러리는 workbox 라이브러리인데, 이 라이브러리는 서비스 워커의 구현을 단순화시켜주고, 다양한 전략을 쉽게 사용할 수 있도록 도와줌.

import { clientsClaim } from 'workbox-core';
// clientsClaim: 서비스 워커가 새로 설치된 후, 클라이언트(브라우저)에서 그 서비스를 바로 사용할 수 있도록 하는 역할

import { ExpirationPlugin } from 'workbox-expiration';
//캐시된 자원이 일정 시간 후에 만료되도록 설정하는 플러그인입니다. 이를 통해 오래된 자원을 자동으로 제거

import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
//precacheAndRoute: 정적 자원(html, css,js,이미지) 등을 미리 캐시하여 빠르게 로딩할 수 있도록 함. createHandlerBoundToURL: 앱의 URL을 특정 index.html로 처리하는 라우터 핸들러를 생성

import { registerRoute } from 'workbox-routing';
//요청에 대한 라우팅 정의하여, 특정 URL에 대해 어떤 캐싱 전략을 사용할지 결정

import { StaleWhileRevalidate } from 'workbox-strategies';
//먼저 캐시된 데이터를 제공하고, 그 후에 서버에서 새로운 데이터를 가져오는 방식

declare const self: ServiceWorkerGlobalScope;

clientsClaim();
// 기본적으로 서비스 워커는 새로 설치되면 기존 서비스 워커가 종료될 때까지 기다린 후 활성화되는데, clientsClaim을 사용하면 기존의 서비스 워커를 기다리지 않고 새로운 워커가 바로 활성화되어 바로 서비스 시작할 수 있음.   
// 새로운 서비스 워커가 설치되면 바로 활성화되어 클라이언트(웹페이지)에서 새로운 서비스를 사용할 수 있도록 하는 함수. 이전 서비스 워커가 종료될 때까지 기다리지 않고 즉시 새 워커가 클라언트를 제어하도록 함.
precacheAndRoute(self.__WB_MANIFEST);
//__WB_MNIFEST라는 특별한 변수를 사용하여 빌드 시 생성된 자원들을 미리 캐시함. 웹 앱의 주요 html, css,js,이미지 등을 미리 캐시해두고, 이후에는 그 캐시된 자원을 사용하여 빠르게 로딩

//앱의 Shell 스타일 라우팅 설정
// 이 코드는 싱글 페이지 애플리케이션에 사용됨.
// index.html 외에 다른 자원을 요청하는 경우에는 캐시하지 않도록 하는 정규 표현식 
const fileExtensionRegexp = new RegExp('/[^/?]+\\\\.[^/]+$');
//registerRoute는 요청을 처리하는 규칙을 정의
// 사용자가 다른 페이지로 이동할 때마다 해당 페이지를 index.html로 처리하여 라우팅을 가능하게 함.
registerRoute(
  ({ request, url }: { request: Request; url: URL }) => {
    if (request.mode !== 'navigate') {    return false;   }
    if (url.pathname.startsWith('/_')) {    return false; }
    if (url.pathname.match(fileExtensionRegexp)) {
     return false;  }
    return true;
  },
  createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html') // 파일 확장자를 가진 자원을 제외한 나머지 요청에 대해서는 index.html로 라우팅하게 설정
);

// 이미지 파일에 대한 캐시 전략 설정
// .png 확장자를 가진 이미지 파일을 처리
// -> 먼저 캐시된 데이터를 제공하고, 그 후에 서버에서 데이터를 가져와서 캐시를 갱신하는 전략 => 네트워크가 늦더라도 빠르게 로딩 가능, 최신 데이터는 백그라운드에서 가져옴.
registerRoute(
  ({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),

  new StaleWhileRevalidate({
    cacheName: 'images',
    plugins: [

		// 캐시 이름을 images로 지정한 후, 캐시된 이미지가 50개를 초과하면 가장 오래된 이미지부터 자동으로 삭제됨.
      new ExpirationPlugin({ maxEntries: 50 }),
    ],
  })
);

// SKIP_WAITING 메시지 처리
// 서비스 워커가 새로 설치되면 이전 버전의 서비스 워커가 종료되지 않고 계속 실행 중일 수 있음 -> 이때 SKIP_WAITING 메시지를 받으면 self.skipWaiting 함수를 호출해서 기존의 서비스 워커를 종료하고, 새 워커로 바로 전환하도록 함
	// => 업데이트가 즉시 적용될 수 있도록 도움.
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

전체적인 흐름 요약

  1. 서비스 워커 등록:
    • 브라우저에서 웹 애플리케이션을 로드하면, 서비스 워커가 등록됩니다. 서비스 워커 파일은 브라우저에서 별도로 실행되며, 이 파일은 주로 service-worker.js라는 이름을 가집니다.
  2. 서비스 워커 설치:
    • 서비스 워커가 처음 설치될 때, 앱의 중요한 파일들을 미리 캐시합니다. precacheAndRoute() 함수는 앱의 파일들을 미리 캐시하는 데 사용됩니다.
  3. 서비스 워커 활성화:
    • 새로 설치된 서비스 워커가 기존 워커를 종료하고 활성화됩니다. clientsClaim()은 즉시 새로운 서비스 워커가 클라이언트를 제어하도록 합니다.
  4. 요청 처리:
    • 사용자가 요청한 파일이나 리소스를 서비스 워커가 처리합니다. 예를 들어, 이미지 요청에 대해 StaleWhileRevalidate 전략을 사용하여 먼저 캐시된 이미지를 제공하고, 백그라운드에서 최신 이미지를 가져와서 캐시를 갱신합니다.
  5. 업데이트 처리:
    • 서비스 워커가 새로 설치되면, SKIP_WAITING 메시지를 사용하여 바로 새 서비스 워커가 활성화되도록 할 수 있습니다.

서비스 워커 등록, 관리 코드

serviceWorkerRegistration.ts

서비스 워커의 등록과 업데이트 관리, 에러 처리를 담당

//현재 페이지가 로컬 서버에서 실행되고 있는지 확인하는 코드
//localhost, [::1], 127.x.x. 
const isLocalhost = Boolean(
  window.location.hostname === 'localhost' ||
    window.location.hostname === '[::1]' ||
    window.location.hostname.match(/^127(?:\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
);

type Config = {
  onSuccess?: (registration: ServiceWorkerRegistration) => void;
  onUpdate?: (registration: ServiceWorkerRegistration) => void;
};

// register 함수
// 서비스 워커를 등록하는 주요 함수
// process.env.NODE_ENV === 'production' : 프로덕션 환경에서만 서비스 워커를 등록
// swUrl은 서비스 워커의 위치를 가리키며, 로컬 환경에서는 별도의 검사를 통해 서비스 워커가 유효한지 확인

export function register(config?: Config) {
  if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
    const publicUrl = process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL, window.location.href) : undefined;
    if (publicUrl && publicUrl.origin !== window.location.origin) {
      return;
    }

    window.addEventListener('load', () => {
      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
			//로컬 서버에서 실행중이면 서비스 워커가 유효한지 검사
      if (isLocalhost) {
        checkValidServiceWorker(swUrl, config);
        navigator.serviceWorker.ready.then(() => {
          console.log(
            'This web app is being served cache-first by a service ' +
              'worker. To learn more, visit <https://cra.link/PWA>'
          );
        });
      } else {
        registerValidSW(swUrl, config);
      }
    });
  }
}

//registerValidSW함수 : 
// 프로덕션 환경에서는 서비스 워커 등록
//서비스 워커가 정상적으로 등록되었을 때 동작함
//서비스 워커가 업데이트될 때마다 onupdatefound 이벤트가 발생하며, 새로 설치된 워커가 준비되면 이를 알려줌. 

function registerValidSW(swUrl: string, config?: Config) {
  **navigator.serviceWorker
    .register(swUrl)  -- 서비스 워커 등록**
    .then((registration) => {
	    //서비스 워커가 새로 설치되거나 업데이트될 때 발생
      registration.onupdatefound = () => {
        const installingWorker = registration.installing;
        if (installingWorker == null) {
          return;
        }
        
        
	   //서비스 워커가 설치되고 installed 상태가 되면, 새로운 콘텐츠가 준비되었음을 알리고, 캐시가 완료되었으면 onSuccess 콜백을 실행하거나, 업데이트가 필요하면 onUpdate 콜백을 실행
        installingWorker.onstatechange = () => {
          if (installingWorker.state === 'installed') {
            if (navigator.serviceWorker.controller) {
            //controller가 존재한다면 이미 페이지가 활성화된 서비스 워커에 의해 제어되고 있다는 의미-> 새로운 콘텐츠 준비되었음.
              console.log(
                '새로운 콘텐츠 준비 완료 ' +
                  '이 페이지의 모든 탭을 닫으면 새로운 콘텐츠 적용됨. See <https://cra.link/PWA.'>
              );

              if (config && config.onUpdate) {
                config.onUpdate(registration);
              }
            } else {
              console.log('콘텐츠가 오프라인 사용을 위해 캐시됨');
						//서비스 워커가 설치되고 오프라인용 콘텐츠가 캐시되었을 때 onSuccess 콜백이 호출됨. 이는 서비스 워커가 처음 활성화되었을 때 페이지가 오프라인 상태에서 실행될 수 있도록 캐시가 완료되었음을 의미
              if (config && config.onSuccess) {
                config.onSuccess(registration);
              }
            }
          }
        };
      };
    })
    .catch((error) => {
      console.error('에러 발생', error);
    });
}

//checkValidServiceWorker함수: 서비스 워커 파일이 유효한지 확인하는 함수
//만약 404 에러나 javaScript 파일이 아닌 경우, 기존 서비스 워커를 삭제하고 페이지를 새로 고침
function checkValidServiceWorker(swUrl: string, config?: Config) {
  fetch(swUrl, {
    headers: { 'Service-Worker': 'script' },
  })
    .then((response) => {
      const contentType = response.headers.get('content-type');
      if (response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1)) {
        navigator.serviceWorker.ready.then((registration) => {
          registration.unregister().then(() => {
            window.location.reload();
          });
        });
      } else {
        registerValidSW(swUrl, config);
      }
    })
    .catch(() => {
      console.log('No internet connection found. App is running in offline mode.');
    });
}

export function unregister() {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.ready
      .then((registration) => {
        registration.unregister();
      })
      .catch((error) => {
        console.error(error.message);
      });
  }
}

 

마지막으로 index.tsx 파일에 register 해주는 것 잊지 말기

  • index.tsx는 리액트 애플리케이션의 진입점
  • index.tsx에서 서비스 워커를 등록하는 이유 : 앱이 로드될 때 서비스 워커가 등록되도록 하기 위함 → 앱의 진입점에서 한 번만 등록하기 → 앱 전체에 걸쳐 적용
// index.tsx// ...

// 애플리케이션의 최상위 컴포넌트인 App을 렌더링함.

// ReactDOM.render()가 끝났다고 치고.. 앱이 렌더링된 상태에서 서비스 워커 등록하고 있는 상황 - 서비스 워커가 앱에 대한 모든 설정과 로딩 상태를 파악한 후에 등록중..

// 앱을 렌더링
root.render(
  <QueryClientProvider client={queryClient}>
    <BrowserRouter>
      <CookiesProvider>
        <App />
      </CookiesProvider>
    </BrowserRouter>
  </QueryClientProvider>
);

// 서비스 워커 등록
serviceWorkerRegistration.register();

 

✅ 전체적 요약

전체적으로, 이 파일은 Service Worker가 웹 앱의 자원들을 캐시하고새로운 데이터는 백그라운드에서 가져와서 캐시를 갱신하며, 사용자가 다른 페이지로 이동할 때 index.html을 이용해 라우팅을 처리하고, 이미지 캐싱을 위한 전략을 설정하는 역할을 한다. 또한, 서비스 워커가 새로 설치되면 즉시 활성화되어 사용자가 최신 앱을 사용할 수 있도록 도와준다.


✅ PWA를 적용한 기업

1. 스타벅스

구글 플레이에서 설치한 스타벅스 앱(왼쪽)과 PWA로 설치한 앱(오른쪽) . 겉으로는 구분하기 어렵다.

 

PWA는 앱 내부에서 표시되는 언어는 달랐지만, 실제 GPS로 근처 매장을 선택해 메뉴를 주문하는 과정은 같음.

 

왼쪽: 구글 플레이에서 설치한 앱 / 오른쪽: PWA로 설치한 앱 ⇒ 언어와 표시되는 화면 구성은 약간 다르지만 원하는 매장을 선택해 주문하는 과정은 같다.

 

2. 트위터의 PWA 버전 “Twitter Lite”

이외에도 넷플릭스, 코스트코 등 많은 기업이 PWA 기술을 자사 서비스로 적용하여 사용자 경험과 만족도 높이는 결과를 얻을 수 있었다고 합니다.