[FE] 기존 리액트 프로젝트를 PWA로 만드는 방법

 

 

 

- JS, 리액트를 사용한 프로젝트 진행 중 막대창으로 인한 불편함이나 웹사이트를 들어가야하는 불편함이 크게 느껴져서 PWA로 만들기로 했다. 

- 막 어려웠던 게 아니긴 한데... PWA 개념도 모르다가 한 번에 적용까지 하려니 삽질을 좀 했다

 

 


PWA란 ?!

Progressive Web App. 쉽게 말해 웹 앱이다. 웹페이지를 그대로 '앱'화해서 방문과 공유를 쉽게 만든다.

이미 만든 웹사이트를 그대로 모바일에서 앱처럼 동작하게 만들 수 있어서 좋다.

이미 트위터, 스타벅스, 핀터레스트 등은 PWA를 사용하고 있다.

 

중요한 점

PWA를 만들기 위해서 중요한 개념인 service-worker (브라우저와 네트워크 중간에 있는 가상 프록시를 의미한다)는 안전한 컨텍스트, 즉 HTTPS 주소에서만 실행된다는 점이다. HTTPS를 가진 주소가 있으면 가장 좋을 것 같고, PWA를 위해 코드를 수정하면서 실험해 보니 localhost에서도 테스트가 가능한 것 같다. 


 

PWA 만드는 법 정리 

1. manifest.json 파일을 정의한다.

가장 맨 아래에 있는 manifest.json 파일.

기존 리액트 프로젝트 생성 시 필요 없는 파일이기 때문에 manifest.json 파일을 삭제해 없는 경우가 많은데, PWA 파일을 만들기 위해서는 반드시 필요하니 다시 manifest.json 파일을 public 파일 안에 생성해 넣어준다. 파일의 내용은 아래의 PWA Builder 에서 쉽게 생성 할 수 있다. PWA Builder는 PWA를 쉽게 적용할 수 있도록 도와주는 도구이니 끄지 말고 한 구석에 계속 켜 놓기를 추천한다. 

 

https://www.pwabuilder.com/

 

여기서 'Enter the URL to your PWA'에 주소를 넣으면 (로컬이든 배포 주소든) 

- manifest.json 파일이 있을 시 보통 아래처럼 내 주소가 얼마나 PWA에 적합한 지를 알려주며 점수를 매겨준다.

 (지금 나의 점수는 보다시피 딱히 높지는 않다..ㅎㅎ;)

- manifest.json 파일이 없을 시 아마 찾을 수 없다며 manifest.json 파일을 수정하거나 추가하라며 알려줄 것이다. 

 

 

 

Edit your Manifest 파일에서 보이는 요소들. 

name, short name, id, description 등 프로젝트에 맞춰서 후루룩 쓴다. 어차피 파일에서 바로바로 수정할 수 있으니까 엄청 신중할 필요는 없다. Settings에서 있는 요소들도 맞춰서 변경해준다. StartURL은 홈 화면, 웹의 시작 주소를 말한 거니까 보통은 / 그대로 놔두면 된다. Language도 알아서 맞춰준다. Display는 화면이 어떤 방식으로 보이느냐인데 보통 standalone 혹은 fullscreen으로 많이 쓰는 것 같다. 나는 위에 도구 막대 빼고 탭은 있는 게 안정적으로 보이는 것 같아서 standalone을 선택했다. 

 

 

그리고 아이콘 만들기가 나오는데... 우리 프로젝트는 로고도 없고 상징적인 아이콘도 아직 없어서 내가 급하게 하나 그렸다;; 나중에 제대로 갈아끼워야지 여기서 하나 그려서 사이즈별로 만들어서 넣으면 favIcon, 모든 기종/화면에서 앱 사용 시 보이는 이미지를 퉁칠 수 있다. 그래서 가급적 예쁘고 귀여울수록 좋은 것 같다 .... 512x512 이상 이미지를 넣으면 알아서 크기별로 만들어주고 파일로 압축해준다. 이 파일들 역시 public/icons/ 파일 안에 모두 넣어주면 된다. ( 꼭 이 주소는 아니여도 된다)

 

이렇게 manifest.json 파일이 완성되면 파일 내용을 넣어주고 index.html에서 manifest.json 파일을 찾을 수 있게 주소도 제대로 적어준다.

 

**

만들어진 아이콘들을 manifest와 index에 제대로 넣어줘야 하는 것 같다!

 

2. offline.html 페이지 만들기

 

루트 아래에 offline.html 페이지를 만들고 바르지 않은 주소로 간 경우 보여 줄 화면을 구현한다. 

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>인터넷 연결 없음</title>
  </head>
  <body>
    <p>인터넷 연결 없음</p>
  </body>
</html>

 

3. dependency 추가

        "workbox-background-sync": "^5.1.4",
        "workbox-broadcast-update": "^5.1.4",
        "workbox-cacheable-response": "^5.1.4",
        "workbox-core": "^5.1.4",
        "workbox-expiration": "^5.1.4",
        "workbox-google-analytics": "^5.1.4",
        "workbox-navigation-preload": "^5.1.4",
        "workbox-precaching": "^5.1.4",
        "workbox-range-requests": "^5.1.4",
        "workbox-routing": "^5.1.4",
        "workbox-strategies": "^5.1.4",
        "workbox-streams": "^5.1.4"

 

4. service-worker.js 파일 && serviceWorkerRegistration.js 파일 추가

역시 루트 아래에 두 파일을 추가해준다. 

service-worker.js

import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';

// 클라이언트 클레임
clientsClaim();

// 프리캐시 및 라우팅 설정
precacheAndRoute(self.__WB_MANIFEST);

const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
registerRoute(
  ({ request, 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'),
);

const offlineFallbackPage = 'offline.html';

// 이미지 캐싱
registerRoute(
  ({ url }) =>
    url.origin === self.location.origin && url.pathname.endsWith('.png'),
  new StaleWhileRevalidate({
    cacheName: 'images',
    plugins: [new ExpirationPlugin({ maxEntries: 50 })],
  }),
);

// 서비스 워커 업데이트 메시지 처리
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

 

serviceWorkerRegistration.js

 


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}$/,
    ),
);

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

    window.addEventListener('load', () => {
      const swUrl = `${PUBLIC_URL}/service/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);
      }
    });
  }
}

function registerValidSW(swUrl, config) {
  navigator.serviceWorker
    .register(swUrl)
    .then((registration) => {
      registration.onupdatefound = () => {
        const installingWorker = registration.installing;
        if (installingWorker == null) {
          return;
        }
        installingWorker.onstatechange = () => {
          if (installingWorker.state === 'installed') {
            if (navigator.serviceWorker.controller) {
              console.log(
                'New content is available and will be used when all ' +
                  'tabs for this page are closed. See https://cra.link/PWA.',
              );

              // 성공 콜백 실행
              if (config && config.onUpdate) {
                config.onUpdate(registration);
              }
            } else {
              console.log('Content is cached for offline use.');

              if (config && config.onSuccess) {
                config.onSuccess(registration);
              }
            }
          }
        };
      };
    })
    .catch((error) => {
      console.error('Error during service worker registration:', error);
    });
}

function checkValidServiceWorker(swUrl, 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
      .register('/service-worker.js')
      .then((registration) => {
        console.log(
          '서비스 워커가 다음 범위로 등록되었습니다:',
          registration.scope,
        );
      })
      .catch((error) => {
        console.error('서비스 워커 등록에 실패했습니다:', error);
      });
  } else {
    console.log('이 브라우저에서는 서비스 워커를 지원하지 않습니다.');
  }
}

 

5. 계속해서 light house와 PWA builder를 확인한다.

이렇게 깜찍한 앱으로 다운 받을 수 있을 때까지 시도하면 된당

'FE' 카테고리의 다른 글

[FE]useReducer 이해하기  (0) 2024.08.25