service worker를 사용하여 타이머를 돌린 후 시간이 되면 서버로 api 요청을 날리는 로직을 구현해놓았다.
그런데 한 가지 문제를 발견했다.
탭을 여러개 열어놓고 똑같이 투표 데이터를 날리게 되면, 하나를 제외한 나머지 탭에선 DEFAULT 값으로 요청이 간다.
이는 service worker의 특성으로 인한 문제이다.
service worker는 하나의 도메인당 하나만 등록할 수 있다.
즉, 여러 탭 사이에 독립적으로 동작하지 않고 공유된다는 것이다.
그렇기에 service worker에 데이터를 저장하는 로직이 있다면, 해당 데이터는 여러 탭 중 마지막에 선택한 데이터로 갱신되게 된다.
이를 해결하기 위해선, 탭별로 고유한 값을 두어 객체 형태로 데이터를 저장하면 된다.
그래서 나는 아래와 같이 작성해주었다.
// service worker
let tabVotes = {};
if (action === 'initialize') {
if(event.data.baseUrl) {
baseUrl = event.data.baseUrl;
}
tabVotes = {
...tabVotes,
[event.data.tabId]: { voteType: '' },
};
}
처음 service worker에 연결할 때 initialize
메시지를 post한다.
이때 고유한 tabId를 service worekr에 보내서 tabVotes에 key값으로 추가해준다.
// Header.tsx
useEffect(() => {
if (!URL.BASE_URL) return;
if (enterAgora.status !== 'CLOSED') {
const tabId = new Date().getTime().toString();
SWManager.setTabId(tabId);
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
action: 'initialize',
tabId,
baseUrl: URL.BASE_URL,
});
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enterAgora.status, URL.BASE_URL]);
tabId
는 연결을 시작할 때의 시간을 뽑아서 고유값으로 생성해준다.
새로운 탭을 열게 된다고 해도, 탭 생성 혹은 마우스 클릭으로 인한 시간 차이가 존재하기 때문에 항상 고유한 값이 된다.
위의 방식대로 하면 탭이 열려 있는 상태에서 initialize
메시지를 보낼 땐 문제가 없다.
하지만, initialize
메시지를 보낸 후 새로운 탭을 열어 메시지를 또 다시 보내게 되면 service worekr의 데이터가 처음 값으로 초기화된다.
service worker는 탭 사이에 공유가 되지만, reload될 땐 데이터가 초기화된다.
이 초기화된 데이터가 모든 탭에 공유되는 것이다.
service worekr가 리로드될 때 데이터가 초기화되지 않도록 하기 위해선, 기존에 저장했던 데이터를 어딘가에 보관해야 한다.
그리고 보관해둔 데이터를 리로드 후 다시 불러와 해당 값으로 갱신시켜주면 된다.
그렇다면, 클라이언트에서 메모리 외 데이터를 저장하여 언제든지 뽑아올 수 있는 방법은?
브라우저 스토리지인
local storage, session storage, indexedDB, cookie가 있다.
하지만, local storage는 아래 사진처럼 MDN 파일에 service worekr에서 사용불가라고 알려준다.
service worekr에선 비동기적으로 동작하는 api만 사용할 수 있는 것이다.
session storage도 비슷한 동작이기 때문에, service worker에선 접근할 수 없다.
cookie는 사용자 인증관련 정보를 저장할 때 유용하기 때문에, 이 경우에선 제외한다.
그렇다면, 남은건 indexedDB이다.
indexedDB는 비동기적으로 동작하기 때문에, service worekr에서 접근할 수 있다.
indexedDB에 대한 설명은 아래 사이트에서 자세하게 확인할 수 있다.
Mong dev blog - indexDB에 대하여
indexedDB를 사용하여 tabId를 탭별로 저장해두고, service worekr가 initialize 메시지를 보낼 때 indexedDB에서 데이터를 불러와 이전 값들까지 포함하여 초기화시켜주면 된다.
위의 사이트를 참고하여 indexedDB에 데이터를 저장하고 호출하는 코드를 작성해보자.
과정은 아래와 같다.
// indexedDB.ts
const DB_NAME = 'TabDatabase';
const STORE_NAME = 'Tabs';
interface Tab {
id: string;
created: Date;
}
먼저 사용할 DB 이름과 storage 이름을 선언해준다.
계속 사용할 것이기 때문에, 상수로 선언해주는 것이 좋다.
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = (event) => {
resolve((event.target as IDBOpenDBRequest).result);
};
request.onerror = (event) => {
reject((event.target as IDBOpenDBRequest).error);
};
});
}
DB를 열어야 접근할 수 있으니 먼저 열어준다.
그리고 만약 storage가 존재하지 않다면, createObjectStore
로 store를 생성해준다. (데이터베이스에 객체 저장소(Object store) 생성하기)
트랜젝션(Transaction) 시작하기 (데이터 쓰기 요청)
export async function saveTabId(tabId: string): Promise<void> {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const tab = { id: tabId, created: new Date() };
store.put(tab);
return transactionComplete(transaction);
}
DB를 열어서 readwrite
모드로 트랜잭션을 뽑아내고 사용할 객체 저장소를 지정해준다.
그리고 저장할 데이터를 생성하여 store에 저장한다.
트랜잭션 결과 처리
function transactionComplete(transaction: IDBTransaction): Promise<void> {
const AssignTransaction = Object.assign(transaction);
return new Promise((resolve, reject) => {
AssignTransaction.oncomplete = () => {
resolve();
};
AssignTransaction.onerror = (event: Event) => {
reject((event.target as IDBOpenDBRequest).error);
};
AssignTransaction.onabort = (event: Event) => {
reject((event.target as IDBOpenDBRequest).error);
};
});
}
이후 해당 트랜잭션의 실패/성공/에러 처리를 해줄 transactionComplete
함수를 호출해준다.
DB에서 데이터 불러오기
export async function getTabIds(): Promise<Tab[]> {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error);
};
});
}
똑같이 DB를 열어주고, 어떤 모드로 트랜잭션을 동작할 것인지 뽑아준다.
이후 객체 저장소에 접근하여 store에 저장된 데이터를 가져온다.
나는 getAll()
로 모든 데이터를 가져오도록 했다.
key 값으로 삭제하기
export async function deleteTabId(tabId: string): Promise<void> {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
store.delete(tabId);
return transactionComplete(transaction);
}
영구적으로 저장하는 것이기 때문에, 적절한 시기에 삭제해주어야 한다.
똑같이 DB 열기, transaction 선언, 객체 저장소 접근의 과정을 거친 후
지우고자 하는 key값으로 store에서 제거해준다.
이후 똑같이 트랜잭션의 결과 처리를 진행해준다.
전체 코드는 아래와 같다.
// indexedDB.ts
const DB_NAME = 'TabDatabase';
const STORE_NAME = 'Tabs';
interface Tab {
id: string;
created: Date;
}
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = (event) => {
resolve((event.target as IDBOpenDBRequest).result);
};
request.onerror = (event) => {
reject((event.target as IDBOpenDBRequest).error);
};
});
}
function transactionComplete(transaction: IDBTransaction): Promise<void> {
const AssignTransaction = Object.assign(transaction);
return new Promise((resolve, reject) => {
AssignTransaction.oncomplete = () => {
resolve();
};
AssignTransaction.onerror = (event: Event) => {
reject((event.target as IDBOpenDBRequest).error);
};
AssignTransaction.onabort = (event: Event) => {
reject((event.target as IDBOpenDBRequest).error);
};
});
}
export async function saveTabId(tabId: string): Promise<void> {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const tab = { id: tabId, created: new Date() };
store.put(tab);
return transactionComplete(transaction);
}
export async function getTabIds(): Promise<Tab[]> {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error);
};
});
}
export async function deleteTabId(tabId: string): Promise<void> {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
store.delete(tabId);
return transactionComplete(transaction);
}
이제 tabId를 생성해주는 곳에서 indexedDB에도 저장해주는 로직을 추가해주면 된다.
// Header.tsx
import { saveTabId } from '@/app/_components/utils/indexedDB';
useEffect(() => {
if (!URL.BASE_URL) return;
if (enterAgora.status !== 'CLOSED') {
const tabId = new Date().getTime().toString();
saveTabId(tabId); // 추가!!
SWManager.setTabId(tabId);
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
action: 'initialize',
tabId,
baseUrl: URL.BASE_URL,
});
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enterAgora.status, URL.BASE_URL]);
이제 service worker에서 해당 데이터 값들을 불러와 사용해주면 된다.
여기서 나한테는 한 가지 문제가 있었다.
service worker는 commonJS 모듈을 사용하고, 나머지 파일들은 ESM을 사용한다는 것이다.
그래서 import/export 구문을 다르게 적용해주어야 하는데, indexedDB 파일이 service worker에서만 사용된다면 똑같이 commonJS로 바꿔주면 되지만, ESM 파일에서도 사용하기 때문에, 어떤 한 가지로 수정할 수가 없었다.
그래서 service worker 파일에 DB에 접근해 데이터를 가져오는 코드를 추가해주었다.
const DB_NAME = 'TabDatabase';
const STORE_NAME = 'Tabs';
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = (event) => {
resolve(event.target.result);
};
request.onerror = (event) => {
reject(event.target.error);
};
});
}
async function getTabIds() {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error);
}
});
}
function transactionComplete(transaction) {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => {
resolve();
};
transaction.onerror = (event) => {
reject(event.target.error);
};
transaction.onabort = (event) => {
reject(event.target.error);
}
});
}
위 코드는 어차피 이미 설명했던 내용이고, 수정된게 없기 때문에 넘어가겠다.
이후 데이터를 비동기적으로 가져오는 함수를 추가해주고, initialize
메시지를 받을 때 DB에서 데이터를 뽑아와 초기화시켜주었다.
// service worker
async function loadTabVotesFromDB() {
const tabIds = await getTabIds();
tabIds.forEach((tab) => {
tabVotes[tab.id] = { voteType: '' };
});
}
if (action === 'initialize') {
if(event.data.baseUrl) {
baseUrl = event.data.baseUrl;
}
if(!Object.keys(tabVotes).length) { // 추가!!
await loadTabVotesFromDB();
}
tabVotes = {
...tabVotes,
[event.data.tabId]: { voteType: '' },
};
}
Application 탭을 열고 IndexedDB를 선택하여 내부를 확인하면,
위의 사진처럼 잘 저장되는 것을 알 수 있다.
현재 서비스 단계가 비회원 로그인으로만 접근하기 때문에 위의 문제들이 발생했던 것이다.
메모리로 엑세스 토큰을 저장하기 때문에, 새로고침이나 새로운 탭을 열게 되면 새로운 유저라고 인식한다.
로그인으로 바꾸면 해결될 문제인 것 같다.
indexedDB에 대해 이번에 처음 알게 되었는데, 신기하기도 하고 재밌었다.
클라이언트 스토리지가 생각보다 많은 것 같아서 관리를 잘 해야 복잡하지 않게 유지할 수 있을 것 같다.