유저들 대부분 모바일 환경에서 사용하고있어, PWA(Progressive Web App)를 추가했다.
보통 CRA로 리액트 환경을 만들때 PWA를 사용하지만, 나는 CRA도 사용하지 않았고 이미 개발도 어느정도 완료가 된 상태여서 PWA를 추가하는 방식으로 찾아봤다.
PWA란?
웹 앱을 웹 브라우저 API와 결합하여 크로스플랫폼 동작하는 앱으로 만들 수 있다.
PWA는 한 번 릴리즈 하고 나서 앱을 다시 배포 할 필요 없이 지속적으로 수정할 수 있다.
모든 코드가 서버에서 호스팅되고 APK나 IPA의 일부가 아니기 때문에 어떤 변경이든 실시간으로 적용할 수 있는 것이다.
네트워크 연결에 의존하는 앱은, 네트워크 연결이 없을 때 아무것도 할 수 없게 된다.
PWA는 네트워크에 문제가 있어도 유저에게 오프라인으로 앱을 사용할 수 있도록 한다.
내 프로젝트에 적용한 방법
설치하기 전 필수 조건이 있다.
나는 아래 기준만 충족했다.
웹 매니페스트
유저 화면에 표시되는 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의 속성은 네 가지가 있다.
[필수] 마지막으로 HTML에 매니페스트파일을 추가하면 설정이 완료된다.
<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설정을 작성해서 사용했다.
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);
});
});
}
설정을 다 하고 웹팩으로 빌드를 해보니 아래와 같은 에러가 발생했다.
if (!isDevelopment && config.plugins) {
config.plugins?.push(
new WebpackPwaManifest(...),
);
config.plugins?.push(
new WorkboxPlugin.InjectManifest(...),
);
}
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);
}
...
아래 글을 보고 최적화를 해봤다.
웹폰트 확장자 종류
웹폰트 확장자 순서
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 문법을 쓰자
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형식으로 변환이 필요했다.
위 사이트에서 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 !== '센텐스유');
사용하는 유저가 늘어가고 있어 기분이 좋다.