현재 프로젝트에서 토론 duration, 투표하는 시간, 투표 후 대기 시간 이렇게 3개의 타이머를 사용하고 있다.
토론의 경우 사용자가 어딘가 다른 일을 하다 다시 브라우저를 켰을 때 시간을 알아내서 서버로부터 전달받는 토론 시작 시간과 duration
으로 남은 토론 시간을 계산하면 된다.
그렇기에 토론 타이머는 필요할 때마다 계산하여 출력하면 되기에 service worker
를 사용하지 않았다.
대신, 시간을 계산해줄 custom hook
을 만들어 처리했다.
메인 스레드에서 타이머를 동작시키면 정상적으로 타이머가 작동하지 않을 수도 있다. 현재는 메인 스레드에서 작동하는걸로 진행하고, 배포 후 web worker로 리팩토링 하는 것으로 결정했다.
하지만 투표 타이머에 대해선 로직이 조금 달랐다.
투표를 약 15초간 진행하는데, 시간이 종료되면 서버로 데이터를 전송해야 한다.
여기에서 문제가 있다.
만약 사용자가 투표를 15초 이내에 한 후, 브라우저를 닫았다가 시간이 종료된 후 다시 브라우저를 연다면?
핸드폰 화면을 껐다가 15초가 지난 후에 다시 켠다면?
위의 상황에서도 15초의 타이머가 정상적으로 동작해야 하며, 15초가 지난 후엔 서버로 데이터를 전송해야 한다.
그리고 바로 5초의 타이머가 작동해야 하며 5초가 지난 후, 서버로부터 데이터를 전달받아야 한다.
즉, 화면이 닫혀진 상태, 브라우저가 비활성화된 상태에서도 위의 로직이 정상적으로 동작해야 하는 것이다.
Service worker의 주 기능으로 네트워크 프록시 역할을 수행하여 Fetch와 같은 api 요청을 가로채서 커스텀한 Handling을 할 수 있다.
또한 브라우저가 닫히더라도 백그라운드에 남아 있기 때문에 PWA(Progressive Web App)의 Push 알림 등의 기능을 지원한다.
Web Worker는 브라우저의 탭 개별적인 Worker를 생성할 수 있으며, 탭 종료 시 해당 탭에서 동작되는 Web Worker가 종료된다. Web Worker는 UI Block(기타 연산 실행으로 인한 멈춤현상)이 되지 않도록 구현하고 싶을 때 사용한다.
위의 사항을 고려해서 나중에 추가될 기능도 함께 생각해보았다.
현재는 알림 기능을 넣지 않았지만, 배포 후 2차 기획 시엔 알림 기능이 추가된다.
투표 로직이 종료되고 서버로부터 결과 데이터를 받아왔을 때, 사용자에게 투표가 종료되었으니 결과를 확인하라는 알림을 전송할 것이다.
또한, 브라우저가 닫히더라도 서버로 요청이 가서 사용자의 데이터가 전달되어야 한다.
브라우저가 닫히더라도 사용자가 투표를 했다면 해당 데이터가 서버로 전달되어야 정확한 결과를 얻을 수 있을 것이다.
만약 투표 하고 브라우저를 닫는 사용자가 대부분일 경우, 해당 데이터가 db에 저장되지 않아 엉망이 될 수도 있다.
위의 경우를 모두 고려하여 service worker
를 사용하기로 결정했다.
먼저, service worker
로 작동할 파일을 만들어주어야 한다.
let baseUrl = '';
let voteData = {};
self.addEventListener('install', event => {
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('message', event => {
const { action, data } = event.data;
if (action === 'initialize') {
baseUrl = event.data.baseUrl;
console.log('Base URL received:', baseUrl);
}
if (action === 'updateVote') {
voteData = data;
console.log('Vote data received:', voteData);
}
if (action === 'startTimer') {
// Set a timeout to send the POST request after 15 seconds
setTimeout(() => {
console.log('Sending vote data:', data); fetch(`${data.baseUrl}/api/v1/auth/agoras/vote`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.token}`,
},
body: JSON.stringify({voteType: data.voteType}),
}).then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(result => {
console.log('Vote data sent:', result);
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
action: 'voteSent',
});
});
});
// Set another timeout to send the GET request after an additional 5 seconds
setTimeout(() => {
console.log('Fetching vote result:', `${data.baseUrl}/api/v1/auth/agoras/${data.agoraId}voteResult`, {
method: 'GET',
Authorization: `Bearer ${data.token}`,
})
fetch(`${data.baseUrl}/api/v1/auth/agoras/${data.agoraId}/voteResult`)
.then(response => response.json())
.then(result => {
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
action: 'voteResult',
result: result.response,
});
});
});
});
}, 5000);
});
}, 15000);
}
});
(아직 에러 처리는 하지 않았다.)
토큰을 서버에게 데이터와 함께 전송해야 하기에 process.env
를 사용하려고 했는데, service worker는 브라우저에서 실행되는 것이고, process.env는 nodejs에서 실행되는 것이기에 접근할 수 없었다.
그래서 worker에게 전달하는 message에 토큰과 base url을 함께 담아 보내주어 처리해주었다.
// src/app/layout.tsx
import ServiceWorkerRegistration from './_components/utils/ServiceWorkerRegistration';
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko" className="dark">
<link rel="manifest" href="/manifest.json" />
<body className={`h-dvh inset-y-full under-large:w-full lg:flex scrollbar-hide overflow-x-hidden justify-center items-start w-full dark:bg-dark-bg-light ${noto.className} antialiased`}>
<MSWComponent />
<ServiceWorkerRegistration />
<RQProvider>
{children}
</RQProvider>
</body>
</html>
);
}
나는 웹 서비스가 로드되면 service worker도 함께 등록하도록 루트 레이아웃에 service worker 등록 컴포넌트를 추가해주었다.
// src/app/_components/utils/ServiceWorkerRegistration.tsx
'use client';
import { useEffect } from 'react';
export default function ServiceWorkerRegistration() {
// useEffect(() => {
// if (typeof window !== 'undefined' && '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);
// });
// });
// }
// }, []);
useEffect(() => {
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/custom-sw.js').then((registration) => {
console.log('Custom SW registered: ', registration);
}).catch((registrationError) => {
console.log('SW registration failed: ', registrationError);
});
});
}
}, []);
return null;
}
서비스 워커는 하나의 도메인당 하나만 등록할 수 있으므로, custom-sw.js
파일에 msw mock Api를 사용할 때 생성된 sw.js
코드를 추가해주었다.
// 투표 상태 업데이트
useEffect(() => {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
action: 'updateVote',
data: {
voteType: vote,
},
});
}
}, [vote]);
// 타이머 시작 및 Service Worker와의 통신 설정
useEffect(() => {
const startTime = new Date();
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
action: 'startTimer',
startTime: startTime.toISOString(),
data: {
agoraId,
voteType: vote,
token: tokenManager.getToken(),
baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL,
},
});
}
const handleServiceWorkerMessage = (event: MessageEvent) => {
if (event.data.action === 'voteSent') {
setIsFinished(true);
} else if (event.data.action === 'voteResult') {
setVoteResult(event.data.result);
setVoteEnd(true);
console.log('투표 결과:', event.data.result);
router.replace(`/agoras/${agoraId}/flow/result-agora`);
}
};
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage);
const timerId = setInterval(() => {
const diffTime = differenceInSeconds(new Date(), startTime);
setRemainingTime(15 - diffTime > 0 ? 15 - diffTime : 0);
if (15 - diffTime <= 0) {
clearInterval(timerId);
}
}, 1000);
return () => {
clearInterval(timerId);
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage);
};
}, [agoraId, router, setVoteEnd, setVoteResult, vote]);
보내는 action message
에 따라 worker에서 동작을 다르게 해주도록 한다.
그리고 data로 worker에서 사용할 데이터도 전달할 수 있도록 한다.
worker에서 client.postMessage
로 worker에서 작업한 결과를 받을 수도 있다.
이렇게 하고 실행하면
등록 완료!
투표할 때 타이머 동작, 서버 요청, 응답 받기 등도 잘 실행되는 것을 확인했다.