서비스 워커란?
서비스 워커란 웹 애플리케이션을 네이티브 앱처럼 로컬에 설치하여 실행 성능을 향상 시키는 역할을 하고, 전통적인 웹 개발 모델을 확장하는 개념으로 사용됩니다. 즉, 웹 애플리케이션에 서비스 워커를 추가하는 것은 애플리케이션을 progressive Web App으로 전환하는 과정 중 하나의 단계라고 할 수 있습니다.
조금 더 자세히 설명드리자면, 서비스 워커는 애플리케이션의 캐시를 관리하기 위해 웹 브라우저가 백그라운드에서 실행하는 스크립트입니다. 이러한 서비스 워커는 네트워크와 브라우저 사이의 프록시 서버 역할을 하며, 애플리케이션에서 보내는 HTTP 요청을 모두 인터셉트해서 해당 요청에 대한 응답을 직접 보낼 수 있습니다. 그래서 이미 응답을 받은 요청이 있다면, 해당 응답을 로컬에 캐싱해 뒀다가 재사용할 수 있습니다.이러한 서비스 워커로 푸시 알림이나 오프라인 상태에서는 백그라운드 동기화 같은 기능을 구현할 수 있습니다.
프록시 기능은 fetch와 같은 API뿐만 아니라 HTML 파일이 참조하는 리소스에도 적용할 수 있습니다. 웹 페이지와 별개로 생명주기를 갖고 동작하며, 보안상의 이유로 HTTPS와 localhost에서만 실행됩니다.
서비스 워커 적용하기
public 폴더에 sw.js 파일을 만들어 어떤 작업을 할 것인지 정의해주고, useEffect 훅으로 감싸준 후 등록 가능한 애플리케이션에 대해서 설정한 sw.js 파일을 등록하면 됩니다. 브라우저에 종속적이기 때문에 useEffect 훅으로 감싸줘야 합니다.
정상적으로 등록이 된다면 개발자 도구에서 확인이 가능합니다. (아래에서 이미지와 함께 조금 더 자세히 설명드리겠습니다.) 이러한 서비스 워커 캐싱은 몇가지 패턴이 존재하는데 상황에 맞는 패턴으로 개발을 진행하면 됩니다.
- Cache Only
- Network Only
- Cache, falling back to Network
- Cache and network race
- Network falling back to cache
- Cache then network
- Generic Fallback
- Service worker-side templating
출저 : https://80000coding.oopy.io/2c8097c6-117f-41b4-ae65-2bd0beec5f18
제가 프로젝트에 적용하고자 하는 패넡은 Cache, falling back to Network 입니다. 즉 캐싱한 응답이 있다면 캐싱된 데이터를 보내주고, 그렇지 않다면 원래의 요청을 보내는 것입니다.
이제 직접 프로젝트에 적용해보겠습니다.
1. public 폴더에 sw.js 파일을 만들어 어떤 작업을 할 것인지 정의해 줍니다.
// public/sw.js
const cacheName = 'useServiceWorker'
const cacheList = []
// fetch가 일어날 때
self.addEventListener('fetch', event => {
// 응답을 가져와서 수정
event.respondWith(
// request에 대한 response를 캐싱했는지 확인
caches.match(event.request).then(response => {
// 캐싱했다면 캐싱한 데이터 반환
if (response) {
return response
}
const fetchRequest = event.request.clone()
// 캐싱한 데이터가 없다면 원래 요청을 보내주기
return fetch(fetchRequest).then(response => {
if (!response || response.status !== 200 || response.type !== 'basic') {
return response
}
const requestUrl = event.request.url
const responseToCache = response.clone()
// POST 요청에 대한 응답이나 chrome extension에 대한 응답은 캐싱하지 않음
if (
!requestUrl.startsWith('chrome-extension') &&
event.request.method !== 'POST'
) {
// 캐시에 요청에 대한 응답 저장
caches.open(cacheName).then(cache => {
cache.put(event.request, responseToCache)
})
}
// 요청 반환
return response
})
}),
)
})
2. 서비스 워커 등록 컴포넌트를 만들어줍니다.
최상위 컴포넌트가 마운트 될 때 서비스 워커를 등록해 주면 되는데 위에서 언급했듯이 서비스워커는 브라우저에 종속적이기 때문에 useEffect로 묶어서 사용해 줘야 합니다. 처음에 적용할 때 서비스워커가 아예 작동하지 않는 문제가 있었는데 이 문제도 짚어가면서 등록한 과정을 작성하겠습니다.
- 처음 작성했던 등록 코드
최상위 컴포넌트인 layout.tsx는 서버 컴포넌트를 유지 시켜 줘야 하기 때문에 따로 컴포넌트를 만들어서 넣었습니다.
// /utils/RegisterServiceWorker.tsx
'use client'
import {useEffect} from 'react'
export default function RegisterServiceWorker () {
useEffect(()=>{
if('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW registered: ', registration)
})
.catch(registrationError => {
console.log('SW registration failed: ', registrationError)
})
})
}
},[])
// 렌더링하는 요소는 없기 때문에 null을 반환해준다.
return null
}
이 코드를 작성하면서 문제를 깨달았어야 했는데, 그러지 못했습니다.. 하핳
원래window.addEventListener(’load’, () ⇒ {})를 사용한 이유는 페이지 로드가 완료된 후에 서비스워커를 등록함으로써 페이지 로드 속도에 영향을 미치지 않을려고 작성했습니다. 하지만 이로 인해 서비스워커가 등록되지 않았습니다.
왜냐하면 useEffect(()⇒{},[]) 내에서 window.addEventListener(’load’, () ⇒ {내부코드})를 사용할 경우 내부코드가 실행되지 않기 때문이었습니다.
이 이유를 찾기 위해 챗지피티한테 물어보기도 하고 구글링도 하고 직접 다른 코드들을 작성하며 도달한 결론은 다음과 같습니다. 각 이벤트가 호출되는 기준 때문이라고 생각합니다. load 이벤트는 모든 리소스가 다운로드 된 다음에 이벤트가 호출되는데 리액트 기준에서는 load 이벤트가 일어나지 않았기 때문에 window로 load 이벤트 이후에 서비스 워커를 등록할려고 한 작업은 실행되지 않은 것입니다. 여기서 확실하게 파악하지 못한 부분은 컴포넌트가 마운트 되기 이전에 이미 페이지 로드가 되었다고 판단하여 이벤트가 실행되지 않는 것인지 컴포넌트가 마운트된 이후에 업데이트가 계속 발생하기 때문에 페이지가 로드되었다고 판단하지 않는 것입니다. 하지만 DOM 트리를 완성하는 시점에 이벤트를 호출할 수 있는 DOMContentLoaded를 사용할 경우 서비스 워커 등록이 아주 잘되는 것을 확인하고, 후자가 맞지 않나라고 생각하고 있습니다. 이 부분은 더 찾아보고 공부해야할 것 같습니다.
그리고 사실 따로 이벤트를 설정할 필요 없이 컴포넌트가 렌더링 되는 시점에 서비스 워커를 등록하면 문제없이 잘 실행됩니다. 여기서 주의해야할 점은 navigator.serviceWorker.register는 Promise 객체를 반환하기 때문에 .then이나 catch를 사용하거나 async/await을 사용해야 합니다. 저는 가독성이 좋은 async/await을 사용했습니다.
- 실험 코드
// /utils/RegisterServiceWorker.tsx
'use client'
import { useEffect } from 'react'
export default function RegisterServiceWorker() {
useEffect(() => {
console.log(navigator.serviceWorker)
if ('serviceWorker' in navigator) {
const registerServiceWorker = async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js')
console.log('SW registered: ', registration)
} catch (error) {
console.log('SW registration failed: ', error)
}
}
registerServiceWorker() // 실행 됨
window.addEventListener('DOMContentLoaded', () => {
registerServiceWorker() // 실행 됨
})
window.addEventListener('load', () => {
console.log('페이지 로드 완료')
registerServiceWorker() // 실행 안됨
})
}
}, [])
return null
}
- 최종 코드
// /utils/RegisterServiceWorker.tsx
'use client'
import { useEffect } from 'react'
export default function RegisterServiceWorker() {
useEffect(() => {
console.log(navigator.serviceWorker)
if ('serviceWorker' in navigator) {
const registerInit = async () => {
const registration = await navigator.serviceWorker.register('/sw.js')
registration.waiting?.postMessage({ type: 'SKIP_WAITING' })
}
registerInit()
// 그냥 naviaator.serviceWorker.register('/sw.js') 사용하는 경우
// navigator.serviceWorker
// .register('/sw.js')
// .then(registration => {
// console.log('Service Worker registered: ', registration)
// })
// .catch(registrationError => {
// console.log('SW registration failed: ', registrationError)
// })
}
}, [])
return null
}
// /app/layout.tsx
import type { Metadata } from 'next'
import ReactQueryProvider from '../utils/provider/ReactQueryProvider'
import RegisterServiceWorker from '@/utils/client/RegisterSeviceWorker'
import WebView from '@/Components/WebView/WebView'
import { WeatherProviderByContext } from '../../contexts/WeatherContext'
import './globals.css'
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ko">
<title>옷늘날씨</title>
<body>
<ReactQueryProvider>
<RegisterServiceWorker />
<WebView>
<WeatherProviderByContext>{children}</WeatherProviderByContext>
</WebView>
</ReactQueryProvider>
</body>
</html>
)
}
3. 서비스 워커 잘 작동되는지 확인
개발자 도구의 application으로 들어가서 확인할 수 있습니다.
캐시된 데이터 크기는 Storage에서 확인 가능합니다.
위와 같이 적용하고 배포까지 했는데 문제가 발생했습니다. 서비스 워커를 적용한 이후 서비스 워커에서 데이터를 가져오는 방식이 기존 fetch를 이용한 방식보다 더 느리다는 것이었습니다. 코드를 보면서 뭐가 문제인지 알아보겠습니다.
// public/sw.js
const cacheName = 'useServiceWorker'
const cacheList = []
// fetch가 일어날 때
self.addEventListener('fetch', event => {
// 응답을 가져와서 수정
event.respondWith(
// request에 대한 response를 캐싱했는지 확인
caches.match(event.request).then(response => {
// 캐싱했다면 캐싱한 데이터 반환
if (response) {
return response
}
const fetchRequest = event.request.clone()
// 캐싱한 데이터가 없다면 원래 요청을 보내주기
return fetch(fetchRequest).then(response => {
if (!response || response.status !== 200 || response.type !== 'basic') {
return response
}
const requestUrl = event.request.url
const responseToCache = response.clone()
// POST 요청에 대한 응답이나 chrome extension에 대한 응답은 캐싱하지 않음
if (
!requestUrl.startsWith('chrome-extension') &&
event.request.method !== 'POST'
) {
// 캐시에 요청에 대한 응답 저장
caches.open(cacheName).then(cache => {
cache.put(event.request, responseToCache)
})
}
// 요청 반환
return response
})
}),
)
})
해당 코드를 보면 캐싱한 데이터가 없을 경우 이벤트의 request를 클론하여 원래 요청을 진행하는데요 생각해보면 이 방식은 기존 요청이 있는 상태에서 다시 요청하는 방식이기 때문에 불필요한 중복 요청을 발생시키고 있습니다. 이러한 중복 요청을 제거하겠습니다.
수정 코드
// /public/sw.js
const cacheName = 'useServiceWorker'
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
if (response) {
return response
}
// request 클론 과정 삭제
// const fetchRequest = event.request.clone()
return fetch(event.request).then(response => {
if (!response || response.status !== 200 || response.type !== 'basic') {
return response
}
const requestUrl = event.request.url
const responseToCache = response.clone()
if (
!requestUrl.startsWith('chrome-extension') &&
event.request.method !== 'POST'
) {
caches.open(cacheName).then(cache => {
cache.put(event.request, responseToCache)
})
}
return response
})
}),
)
})
해당 코드로 배포 후 테스트 진행해보겠습니다. 성공적으로 서비스 워커를 이용해 fetch로 가져오는 데이터를 캐싱할 수 있었고, 기존 로딩 시간을 97% 단축했습니다.
기존 리소스 로딩 시간
서비스 워커 적용 후 리소스 로딩 시간 (이미지 포맷도 변경)
이상입니다,