복잡한 동기화 시스템을 삭제하고 얻은 것들
큐 시스템 vs 각 핸들러에서 직접 API 호출. 당신이라면 무엇을 선택하시겠나요?
브라우저 확장 프로그램에서 IndexedDB와 Supabase 간의 자동 동기화를 구현하면서,
“제대로 된” 시스템을 만들겠다는 욕심에 과도하게 복잡한 큐를 구현했습니다. Sean Goedecke의 “The Simplest Thing That Could Possibly Work”를 읽고, 왜 단순함이 더 나은 선택인지 깨닫고 리팩토링을 한 과정을 공유합니다.
문제 상황
Hanspoon은 웹 하이라이트 브라우저 확장 프로그램으로, 로컬 퍼스트(Local-first) 를 지향한다. 사용자가 웹 페이지에서 텍스트를 하이라이트하면, 그 데이터는 우선 IndexedDB에만 저장된다. 로그인하지 않아도, 서버 없이도 자신의 브라우저에서 하이라이트를 관리할 수 있다.
서버(Supabase)와의 동기화는 사용자가 공유하기 기능을 사용할 때 처음 발생한다. 하이라이트를 다른 사람에게 공유하려면 서버에 데이터가 있어야 하므로, 이 시점에 IndexedDB의 데이터가 Supabase로 업로드된다.
문제는 첫 동기화 이후였다. 공유 링크를 생성한 후에도 사용자는 계속 하이라이트를 추가하거나 삭제할 수 있다. 하지만 이 변경사항이 Supabase에 반영되지 않았다. IndexedDB는 업데이트되는데, Supabase는 첫 동기화 시점의 스냅샷 그대로 남아있는 것이다.
결과적으로 공유 링크를 받은 사람이 보는 하이라이트와, 원본 사용자가 보는 하이라이트가 달라지는 상황이 발생했다.
이 “첫 동기화 이후 변경 추적” 문제를 해결하기 위해, 나는 큐 기반의 동기화 시스템을 도입하기로 결정했다.
내가 처음 한 생각
“제대로 된” 동기화 시스템을 만들어야 한다고 생각했다. 머릿속에 그려진 이상적인 구조는 다음과 같았다:
- 큐 기반 처리: 동기화 작업을 IndexedDB의
syncQueue테이블에 저장 - 상태 관리:
pending→processing→completed/failed상태 전이 - 재시도 로직: 최대 3회 재시도
- 디바운싱: 300ms 디바운스로 중복 요청 방지
- 온라인 이벤트 리스너: 네트워크 복구 시 자동 재시도
이 모든 것을 갖춘 SyncQueueService 클래스를 설계했다.
실제로 해 본 선택
설계대로 구현에 들어갔다. 결과물은 250줄짜리 syncQueue.ts 파일이었다.
class SyncQueueService {
private isProcessing = false;
private readonly MAX_RETRIES = 3;
private readonly RETRY_DELAY = 5000;
private readonly DEBOUNCE_DELAY = 300;
async enqueue(postId: string, action: "upsert" | "delete") {
// 기존 pending 항목 체크, 중복 방지
const existing = await db.syncQueue
.where({ postId, status: "pending" })
.first();
// ... 40줄의 큐잉 로직
}
private async processQueue() {
// ... 30줄의 큐 처리 루프
}
private async processItem(item: SyncQueueItem) {
// ... 상태 전이, 에러 핸들링
}
private async handleError(item: SyncQueueItem, error: unknown) {
// ... 재시도 로직, 지수 백오프
}
// ... 그 외 여러 헬퍼 메서드들
}
background script에서는 이렇게 사용했다.
onMessage("DB_CREATE_HIGHLIGHT", async (message) => {
await createHightLightBackground({ data, postId });
const post = await db.posts.get(postId);
if (post?.isPublished) {
await syncQueue.enqueue(postId, "upsert"); // 큐에 추가
}
});
결과 (성공 / 실패)
작동은 했다. 하지만 구현하고 나서 몇 가지 근본적인 문제를 깨달았다.
1. 트랜잭션이 필요 없는 환경이었다
나는 데이터 무결성을 위해 트랜잭션 개념을 떠올렸고, 그래서 큐를 도입했다. 여러 동기화 작업이 순서대로, 안전하게 처리되어야 한다고 생각했다.
하지만 Hanspoon은 로컬 퍼스트 크롬 익스텐션이다. 데이터는 사용자 본인의 브라우저에만 존재하고, 동시에 여러 사용자가 같은 데이터를 수정하는 일이 없다. 다중 유저 간의 충돌을 방지하는 트랜잭션이 애초에 필요 없는 환경이었다.
2. “최소한의 네트워크 요청”을 달성하지 못했다
큐를 도입한 또 다른 이유는 네트워크 요청을 최소화하기 위해서였다. 여러 변경사항을 모아서 한 번에 보내면 효율적일 것이라 생각했다.
하지만 내가 구현한 큐는 그렇게 동작하지 않았다. enqueue()가 호출될 때마다 processQueue()가 트리거되었고, 300ms 디바운스가 있긴 했지만 결국 매 트리거마다 네트워크 요청이 발생했다. 진정한 배치 처리—일정 시간이나 개수만큼 모아서 한 번에 전송—가 아니었다.
3. 변경분만 보내는 게 아니라 전체를 덮어씌웠다
더 비효율적인 부분이 있었다. upsertToSupabase()는 변경된 하이라이트 하나만 보내는 게 아니라, 해당 post의 모든 annotations를 매번 전체 upsert하고 있었다. 하이라이트 하나를 추가해도 그 페이지의 전체 하이라이트 목록이 서버로 전송되었다.
결국 내가 만든 큐 시스템은
- 필요 없는 트랜잭션 보장을 위해 복잡성을 추가했고
- 최소한의 네트워크 요청이라는 목표도 달성하지 못했으며
- 오히려 매번 전체 데이터를 덮어씌우는 비효율적인 방식이었다
250줄의 코드가 실질적으로 해결한 문제는 없었다.
그러던 중 Sean Goedecke의 Do the simplest thing that could possibly work를 읽었다. 핵심 메시지는 이것이었다. (한글 번역 링크: https://chapdo.vercel.app/posts/%EB%B2%88%EC%97%AD-Do-the-simplest-thing-that-could-possibly-work/)
“미래의 몇 배 규모 증가에 대비한 사전 설계는 작동하지 않는다. 실제 병목 지점은 예측이 불가능하기 때문이다.”
내가 구현한 큐 시스템은 바로 이 “예측 기반 설계”으로부터 시작한 스파게티였다.
지금 다시 해본다면?
큐 시스템을 삭제했다. (링크)
그리고 각 메시지 핸들러에서 직접 Supabase API를 호출하도록 변경했다.
하이라이트 추가 시 (링크)
onMessage("DB_CREATE_HIGHLIGHT", async (message) => {
const { data: highlight, postId } = message.data;
await createHightLightBackground({ data: highlight, postId });
const post = await db.posts.get(postId);
if (post?.isPublished) {
const { session } = await browser.storage.local.get("session");
// 큐 대신 직접 호출
await supabase.from("annotations").insert({
id: highlight.id,
post_id: post.id,
start_meta: highlight.start,
end_meta: highlight.end,
text: highlight.text,
user_id: session.user.id,
share_id: post.shareId,
});
}
return { success: true };
});
하이라이트 삭제 시 (링크)
onMessage("DB_DELETE_HIGHLIGHT", async (message) => {
const { id } = message.data;
const annotation = await db.annotations.get(id);
await deleteHighlightBackground(id);
if (annotation) {
const post = await db.posts.get(annotation.postId);
if (post?.isPublished) {
// 큐 대신 직접 호출
await supabase.from("annotations").delete().eq("id", id);
}
}
return { success: true };
});
변경 결과
- 250줄 삭제, IndexedDB의 syncQueue 테이블 불필요
- 각 핸들러에서 10줄 내외의 직접 호출
- 디버깅 시 해당 핸들러만 보면 됨
FAQ
- 재시도 로직은 필요 없나?
- 현재로서는 충분하다. 지금 당장 필요하지 않은 것을 미리 구현하는 것은 YAGNI(You Aren’t Gonna Need It) 원칙에 위배된다.
- 오프라인 지원은 포기하는 건가?
- 완전히 포기하는 것은 아니다. 로컬 IndexedDB에는 여전히 데이터가 저장된다. 다만 서버 동기화가 실패하면 사용자에게 알리고, 필요하면 수동으로 재시도할 수 있게 하면 된다. 대부분의 사용자는 이 정도로 충분하다고 판단했다.
배운 점
- 최대한 쉬운 방법으로 해결하는 것이 중요하다.