SENTENCE U | Day 30 (PWA/폰트 최적화)

블로그 이사 완료·2023년 2월 3일
0
post-thumbnail

PWA 추가

유저들 대부분 모바일 환경에서 사용하고있어, PWA(Progressive Web App)를 추가했다.

보통 CRA로 리액트 환경을 만들때 PWA를 사용하지만, 나는 CRA도 사용하지 않았고 이미 개발도 어느정도 완료가 된 상태여서 PWA를 추가하는 방식으로 찾아봤다.

PWA란?

  • 웹 앱을 웹 브라우저 API와 결합하여 크로스플랫폼 동작하는 앱으로 만들 수 있다.

  • PWA는 한 번 릴리즈 하고 나서 앱을 다시 배포 할 필요 없이 지속적으로 수정할 수 있다.

  • 모든 코드가 서버에서 호스팅되고 APK나 IPA의 일부가 아니기 때문에 어떤 변경이든 실시간으로 적용할 수 있는 것이다.

  • 네트워크 연결에 의존하는 앱은, 네트워크 연결이 없을 때 아무것도 할 수 없게 된다.
    PWA는 네트워크에 문제가 있어도 유저에게 오프라인으로 앱을 사용할 수 있도록 한다.

프로그레시브 웹 앱

내 프로젝트에 적용한 방법

설치하기 전 필수 조건이 있다.

설치 기준

나는 아래 기준만 충족했다.

  • 웹 매니페스트 (manifest)
  • 서비스 워커 (service worker)
  • 유저의 앱 설치 여부 (install experience)
  • 웹이 HTTPS일 것 (보안 상)

웹 매니페스트

유저 화면에 표시되는 PWA앱의 설정을 작성한다.

{
  "filename": "manifest.json",
  "short_name": "센텐스유",
  "name": "센텐스유",
  "start_url": ".",
  "display": "fullscreen",
  "crossorigin": "use-credentials",
  "theme_color": "#fbfdfc",
  "background_color": "#fbfdfc",
  "icons": [
    {
      "src": "./src/assets/images/favicon.ico",
      "sizes": "16x16",
      "type": "image/x-icon"
    },
    {
      "src": "./src/assets/images/logo192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "./src/assets/images/logo512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ]
}

반드시 name이나 short name key를 입력해야 한다.

둘 다 설정하면 short_name이 홈스크린과 런쳐에 사용되고, name은 홈 화면에 추가 같은 프롬프트에 사용된다.

display의 속성은 네 가지가 있다.

  • fullscreen : 앱이 열릴 때 전체 화면을 차지하도록 허용한다.
  • standalone : 앱이 네이티브 어플리케이션처럼 보이게 한다. 브라우저의 요소들을 감춘다.
  • minimal-ui : 약간의 브라우저 컨트롤을 제공한다. (크롬 모바일에서만 지원)
  • browser : 앱이 브라우저 환경의 경험과 동일하게 표현된다. (인터넷에서 접속한 것과 동일)

[필수] 마지막으로 HTML에 매니페스트파일을 추가하면 설정이 완료된다.

How to make PWAs installable

<link rel="manifest" href="manifest.json" />

service-worker.js / serviceWorkerRegistration.js

서비스워커와 서비스워커등록 파일은 CRA의 PWA템플릿에서 코드를 가져왔다.

service-worker.js

// service-worker.js
/* eslint-disable no-restricted-globals */
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('./index.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();
  }
});

내가 수정한 것은 createHandlerBoundToURL의 파일 경로만 변경해줬다.

배포를 클라우드타입의 서버에 빌드파일을 보내서 하는 방식을 사용해서 그런지 윗줄의 기존 코드로하면 서비스워커 파일이 등록이 되지 않았다.

createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html'))

createHandlerBoundToURL('./index.html'))

serviceWorkerRegistration.js

// serviceWorkerRegistration.js
const isLocalhost = Boolean(
  window.location.hostname === 'localhost' ||
    // [::1] is the IPv6 localhost address.
    window.location.hostname === '[::1]' ||
    // 127.0.0.0/8 are considered localhost for IPv4.
    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(process.env.PUBLIC_URL, window.location.href);
    if (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);
      }
    });
  }
}

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.ready
      .then((registration) => {
        registration.unregister();
      })
      .catch((error) => {
        console.error(error.message);
      });
  }
}

위와같이 파일을 생성해놓고 나처럼 웹팩으로 빌드를 하는 환경은 웹팩 설정을 추가로 해줘야한다.

PWA 웹팩 설정

manifest.json파일을 불러와서 WebpackPwaManifest플러그인으로 옵션을 불러와야하는데

ES모듈을 사용하고 있어서 json파일을 불러오면 오류가 발생한다.

ES모듈에서도 json파일을 불러오는 방법이 있지만 플러그인의 옵션자리에 그냥 manifest설정을 작성해서 사용했다.

자바스크립트에서의 JSON 모듈

import WebpackPwaManifest from 'webpack-pwa-manifest';
import WorkboxPlugin from 'workbox-webpack-plugin';

...plugin:[
  new WebpackPwaManifest({
    filename: 'manifest.json',
    short_name: '센텐스유',
    name: 'Sentence U',
    start_url: '.',
    display: 'fullscreen',
    crossorigin: 'use-credentials',
    theme_color: '#fbfdfc',
    background_color: '#fbfdfc',
    icons: [
      {
        src: './src/assets/images/favicon.ico',
        sizes: '16x16',
        type: 'image/x-icon',
      },
      {
        src: './src/assets/images/logo192.png',
        type: 'image/png',
        sizes: '192x192',
      },
      {
        src: './src/assets/images/logo512.png',
        type: 'image/png',
        sizes: '512x512',
      },
    ],
  }),
  new WorkboxPlugin.InjectManifest({
    swSrc: './src/service-worker.js',
    swDest: 'service-worker.js',
  })
]

client.jsx 설정 (index.jsx)

마지막으로 client.jsx에 웹페이지 로드 시 서비스워커를 등록해주는 코드를 작성하면 끝이다.

if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
      navigator.serviceWorker
        .register('./service-worker.js')
        .then((registration) => {
          console.log('SW registered', registration);
          registration.pushManager.subscribe({ userVisibleOnly: true });
          Notification.requestPermission().then((p) => {
            console.log(p);
          });
        })
        .catch((e) => {
          console.log('SW registration failed: ', e);
        });
    });
  }

설정을 다 하고 웹팩으로 빌드를 해보니 아래와 같은 에러가 발생했다.

  1. Configure maximumFileSizeToCacheInBytes to change this limit.
    : PWA를 webpack.config.js에서 배포환경일때만 사용하도록해서 해결함
if (!isDevelopment && config.plugins) {
  config.plugins?.push(
    new WebpackPwaManifest(...),
  );
  config.plugins?.push(
    new WorkboxPlugin.InjectManifest(...),
  );
}
  1. Registration failed - missing applicationServerKey, and manifest empty or missing
    : client.jsx에 작성된 PWA등록 코드도 배포환경일때만 사용하도록 변경
const isDevelopment = process.env.NODE_ENV !== 'production';

if (!isDevelopment) {
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
      ...
    });
  }
}

빌드해보면 App Manifest Service Workers Cache Storage가 정상적으로 활성화 된 것을 볼 수 있다.


폰트 최적화

기존에는 ttf형식의 파일을 받아 로컬에서 불러오도록 했었다. 네트워크를 보면 불러오는 속도가 느린 것을 볼 수 있다.
잠시라도 유저는 폰트를 적용하지 못한 화면을 볼 것이고, 빨리 최적화를 해야겠다고 마음먹었다.

/* IBMPlexSansKR */
@font-face {
  font-family: IBM-Bold;
  src: url(./src/assets/fonts/IBMPlexSansKR-Bold.ttf);
}
...

아래 글을 보고 최적화를 해봤다.

웹폰트 최적화 하기

웹폰트 확장자 종류

  • EOT: IE8 이하일 경우
  • TTF: 구형 안드로이드버전(4.4)에서 필요
  • WOFF: 대부분의 모던 브라우저에서 지원
  • WOFF2: WOFF보다 압축률이 30%정도 더 좋음

웹폰트 확장자 순서

  • woff2를 가장 앞에
  • 브라우저는 선언된 순서대로 지원 가능한 파일 형식을 다운로드받기 때문에 압축률이 가장 좋은 woff2를 먼저 선언
  • format()은 반드시 써야한다.
  • 쓰지 않으면 브라우저는 지원 가능한 파일 형식이 나올 때까지 순서대로 다운받음
  • IE8이하를 지원해야 할 경우 eot가 가장 먼저
  • IE는 format을 읽지 못하기 때문에 가장 앞에 선언되어야 함.
src: url(/static_fonts/NanumGothic-Regular.eot), 
     url(/static_fonts/NanumGothic-Regular.woff2) format("woff2"), 
     url(/static_fonts/NanumGothic-Regular.woff) format("woff"),
     url(/static_fonts/NanumGothic-Regular.ttf) format("truetype");

local 문법을 쓰자

  • 위처럼 선언하면 시스템에 폰트 유무와 관계없이 무조건 다운로드받게 된다.
  • 불필요한 리소스 요청
    local 문법을 앞에 선언해주면 시스템에 설치되어 있다면 리소스를 요청하지 않는다.
src: local('Nanum-Gothic'), 
     url(/static_fonts/NanumGothic-Regular.woff2) format("woff2"), 
     url(/static_fonts/NanumGothic-Regular.woff) format("woff");

같은 폰트라면 같은 font-family로

@font-face {
    font-family: 'Nanum Gothic'; 
    font-style: normal;
    font-weight: 400;
    src: url(/static_fonts/NanumGothic-Regular.woff2) format("woff2"), 
        url(/static_fonts/NanumGothic-Regular.woff) format("woff"),
        url(/static_fonts/NanumGothic-Regular.ttf) format("truetype");
}

@font-face {
    font-family: 'Nanum Gothic'; /* Nanum Gothic Bold x */
    font-style: normal;
    font-weight: 700;
    src: url(/static_fonts/NanumGothic-Bold.woff2) format("woff2"),
        url(/static_fonts/NanumGothic-Bold.woff) format("woff"), 
        url(/static_fonts/NanumGothic-Bold.ttf) format("truetype");
}
...

그렇다고 모든 범위의 서체를 분류할 필요는 없다. 꼭 필요한 굵기와 스타일의 폰트만 다운로드 받도록 한다.

나는 일단 woff2형식으로 변환이 필요했다.

https://transfonter.org/

위 사이트에서 ttf형식의 파일을 woff2로 변환했다.

그리고 woff2형식만 사용했고, 같은 폰트를 font-family로 묶어서 기존 코드보다 간결하게 globalCSS를 작성할 수 있었다.

@font-face {
  font-family: 'IBM Sans KR';
  src: url(./src/assets/fonts/IBMSansKR-Light.woff2) format('woff2'),
    url(./src/assets/fonts/IBMSansKR-Light.woff) format('woff');
  font-weight: 300;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'IBM Sans KR';
  src: url(./src/assets/fonts/IBMSansKR-Medium.woff2) format('woff2'),
    url(./src/assets/fonts/IBMSansKR-Medium.woff) format('woff');
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'IBM Sans KR';
  src: url(./src/assets/fonts/IBMSansKR-Regular.woff2) format('woff2'),
    url(./src/assets/fonts/IBMSansKR-Regular.woff) format('woff');
  font-weight: normal;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'IBM Sans KR';
  src: url(./src/assets/fonts/IBMSansKR-Bold.woff2) format('woff2'),
    url(./src/assets/fonts/IBMSansKR-Bold.woff) format('woff');
  font-weight: bold;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Montserrat';
  src: url(./src/assets/fonts/Montserrat-Light.woff2) format('woff2'),
    url(./src/assets/fonts/Montserrat-Light.woff) format('woff');
  font-weight: 300;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Montserrat';
  src: url(./src/assets/fonts/Montserrat-Regular.woff2) format('woff2'),
    url(./src/assets/fonts/Montserrat-Regular.woff) format('woff');
  font-weight: normal;
  font-style: normal;
  font-display: swap;
}

woff2형식으로 변환 후에 웹사이트 초기 로딩 시 폰트 로드에 대한 속도가 확실히 줄어든 것을 볼 수 있다.


모바일 전용 메뉴 생성

모바일에서만 보이는 DotMenu를 클릭 시 공지사항과 모바일에서는 볼 수 없었던 유저목록 메뉴를 생성했다.

공지사항을 누르면 센텐스유 전용 계정으로 들어가게 되고 작성한 공지사항만 볼 수 있게 설정했다.

센텐스유 계정은 유저목록에 보이지 않는다.

const sortedUsers = [...onlineUsers, ...offlineUsers.sort()].filter((v) => v !== '센텐스유');

사용하는 유저가 늘어가고 있어 기분이 좋다.

profile
https://kyledev.tistory.com/

0개의 댓글