간단한 링크 스크랩 웹앱을 만들어보고 싶었다. 나의 경우 주로 휴대폰으로 링크를 저장하는 편이기 때문에, 웹 앱을 네이티브 앱처럼 만들어보면 좋을 것 같았다.
네트워크가 불안정한 상태, 오프라인 상태에서도 최소한 저장한 링크에 대한 간략한 정보가 보이려면 어떻게 해야할까 방법을 찾아보았다.

결과적으로 오프라인 대응을 위해 다음과 같은 전략을 세웠다!
1. 브라우저에 내장된 IndexedDB를 사용하여 링크 정보(메타데이터)를 저장한다.
2. Service Worker를 사용해 리소스를 캐싱한다.
사용자가 저장한 링크 목록을 네트워크 없이도 바로 가져올 수 있는 방법으로 브라우저에 내장된 저장소를 활용하기로 했다.
기본 IndexedDB API를 사용하면 코드가 꽤 복잡해진다.
// DB 열기
const request = indexedDB.open("link-pocket", 1);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains("links")) {
db.createObjectStore("links", { keyPath: "id" });
}
};
request.onsuccess = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// 데이터 추가
const tx = db.transaction("links", "readwrite");
const store = tx.objectStore("links");
const addReq = store.add({ id: 1, url: "https://example.com" });
addReq.onsuccess = () => {
console.log("링크 저장 완료!");
// 데이터 조회
const getReq = store.get(1);
getReq.onsuccess = () => {
console.log("조회 결과:", getReq.result);
};
};
addReq.onerror = () => {
console.error("링크 저장 실패");
};
};
request.onerror = () => {
console.error("DB 연결 실패");
};
그래서 idb라는 가볍고 많이 사용되는 라이브러리를 사용하기로 했다.
idb는 Promise 기반의 API를 제공하여 await 키워드로 깔끔하게 코드를 작성할 수 있고, DB 연결이나 트랜잭션 관리 같은 세부적인 구현 없이 간단히 db를 사용할 수 있다.
import { DBSchema, openDB } from "idb";
interface LinkPocketDB extends DBSchema {
links: {
key: string;
value: LinkRecord;
indexes: { createdAt: Date };
};
}
export async function getDB() {
return openDB<LinkPocketDB>("link-pocket", undefined, {
upgrade(db) {
const linkStore = db.createObjectStore("links", { keyPath: "id" });
linkStore.createIndex("createdAt", "createdAt");
},
});
}
// 데이터 추가/삭제/업데이트/조회
export async function addLink(record: LinkRecord) {
const db = await getDB();
await db.add(TABLE_NAME, record);
}
export async function updateLink(id: string, record: Partial<LinkRecord>) {
const db = await getDB();
const cur = await db.get(TABLE_NAME, id);
if (!cur) {
throw new Error(`Link with id ${id} not found`);
}
await db.put(TABLE_NAME, { ...cur, ...record }, id);
}
export async function deleteLink(id: string) {
const db = await getDB();
await db.delete(TABLE_NAME, id);
}
export async function getLink(id: string): Promise<LinkRecord | undefined> {
const db = await getDB();
const record = await db.get(TABLE_NAME, id);
return record;
}
export async function getAllLinks(): Promise<LinkRecord[]> {
const db = await getDB();
const records = await db.getAll(TABLE_NAME);
return records;
}

IndexedDB에 데이터를 추가하거나 삭제하면 링크 목록 컴포넌트에 변화를 알려주어야 한다.
처음엔 단순히 invalidateQueries를 호출해 데이터를 다시 가져오도록 하면 되지 않을까 생각했다.
// AddLinkForm.tsx
const onSubmit = async (e: FormEvent) => {
// 생략
await addLink(record);
queryClient.invalidateQueries({ queryKey: ["links"] });
};
근데 위와 같은 코드는 의존성이 매우 높은 코드가 된다! UI 컴포넌트가 DB도 조작(addLink)하고, 상태관리(invalidateQueries)도 하고 있기 때문이다.
데이터 변경에 따라 UI를 직접 갱신하는 대신, 자동으로 동기화되면 좋지 않을까?
이런 생각에서 이벤트 기반 구조를 선택했다. 저장소에서 변화가 발생하면 이벤트를 발행하고, 이를 구독하는 곳에서 알아서 invalidate한다. (느슨한 결합)

최근 nodejs에서 EventEmitter를 사용해본 경험이 있어서 브라우저에서도 사용해도 되는가 GPT에게 물어보니, 브라우저에서는 EventTarget이 적합하다고 소개해주었다.

EventTarget은 이벤트를 수신할 수 있고, 수신한 이벤트에 대해 리스너를 가질 수 있는 객체가 구현하는 인터페이스이다. (ex: document, window)
이벤트 버스 역할의 LinkEventBus 클래스는 EventTarget을 변수로 가지며, on과 emit 메소드를 통해 이벤트 관리해주는 역할을 하게 된다.
// /lib/linkEventBus.ts
import { LinkRecord } from "@/types/link";
export enum LINK_EVENT {
"LINKS_CHANGED",
}
export type RepoEvents = {
[LINK_EVENT.LINKS_CHANGED]:
| { op: "add"; record: LinkRecord }
| { op: "update"; record: LinkRecord }
| { op: "delete"; id: string };
};
type EventName = LINK_EVENT;
class LinkEventBus {
private et: EventTarget;
constructor() {
this.et = new EventTarget();
}
public emit<T extends EventName>(eventName: T, eventDetail: RepoEvents[T]) {
this.et.dispatchEvent(
new CustomEvent(LINK_EVENT[eventName], { detail: eventDetail })
);
}
public on<T extends EventName>(
eventName: T,
listener: (detail: RepoEvents[T]) => void
) {
const type = LINK_EVENT[eventName];
const eventListener = (e: Event) => listener((e as CustomEvent).detail);
this.et.addEventListener(LINK_EVENT[eventName], eventListener);
return () => this.et.removeEventListener(type, eventListener);
}
}
export const linkEventBus = new LinkEventBus();
링크 목록에 변화가 만드는 함수에서 이벤트를 발행시키도록 하였다.
// /lib/linkRepository.ts
export async function addLink(record: LinkRecord) {
const db = await getDB();
await db.add("links", record);
linkEventBus.emit(LINK_EVENT.LINKS_CHANGED, { op: "add", record });
}
이벤트를 구독하는 컴포넌트를 만들어주었다. 해당 컴포넌트는 링크 목록 변화 이벤트가 발생하면, 쿼리를 invalidate하도록 한다.
import { LINK_EVENT, linkEventBus } from "@/lib/linkEventBus";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
const LinkEventSubscriber = () => {
const queryClient = useQueryClient();
useEffect(() => {
const removeListener = linkEventBus.on(LINK_EVENT.LINKS_CHANGED, () =>
queryClient.invalidateQueries({ queryKey: ["links"] })
);
return () => removeListener();
}, [queryClient]);
return null;
};
export default LinkEventSubscriber;
이렇게 IndexedDB을 사용해 오프라인에서도 조회할 수 있도록 데이터를 저장해보고, 이벤트 기반으로 UI와 저장소를 느슨하게 연결하는 작업을 해보았다. 지금은 브라우저에 탭 여러개를 열어놓고 링크 목록을 업데이트하면 바로 반영되지 않는다. 추후에 BroadcastChannel을 사용해 브라우저 탭 간의 동기화도 해볼 생각이다.