service worker와 indexedDB 사용

dobby·2024년 6월 27일
0

service worker를 사용하여 타이머를 돌린 후 시간이 되면 서버로 api 요청을 날리는 로직을 구현해놓았다.

그런데 한 가지 문제를 발견했다.

service worker 문제

탭 사이 데이터 공유 문제

탭을 여러개 열어놓고 똑같이 투표 데이터를 날리게 되면, 하나를 제외한 나머지 탭에선 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는 연결을 시작할 때의 시간을 뽑아서 고유값으로 생성해준다.
새로운 탭을 열게 된다고 해도, 탭 생성 혹은 마우스 클릭으로 인한 시간 차이가 존재하기 때문에 항상 고유한 값이 된다.

새로고침 시 service worekr 데이터 초기화 문제

위의 방식대로 하면 탭이 열려 있는 상태에서 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에 데이터 저장/불러오기

과정은 아래와 같다.

  • 데이터베이스 열기
  • 데이터베이스에 객체 저장소(Object store) 생성하기
  • 트랜젝션(Transaction) 시작하기 (데이터 읽기, 쓰기 제거 등 데이터베이스 작업 요청)
  • 이벤트 리스너를 사용하여 요청이 완료될때까지 기다리기
  • 요청 결과를 가지고 어떤 동작하기

// 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에 대해 이번에 처음 알게 되었는데, 신기하기도 하고 재밌었다.
클라이언트 스토리지가 생각보다 많은 것 같아서 관리를 잘 해야 복잡하지 않게 유지할 수 있을 것 같다.

profile
성장통을 겪고 있습니다.

0개의 댓글