로컬 DB를 화면의 기준으로 삼는다는 것: hearth를 직접 만든 이유

hearth를 직접 만든 이유와 설계 과정을 소개합니다

Wes • Gihyeon Lee, Web Desk Engineer Thanks.

  • Frontend

프로젝트 관리 도구인 Linear를 써본 적 있다면, 유독 빠르다고 느꼈을 겁니다. 이슈 목록에서 상세로, 상세에서 보드로 넘어갈 때 스피너를 본 기억이 별로 없을 겁니다. 네트워크가 끊겨도 이슈를 읽고 만들고 상태를 바꿀 수 있고, 인터넷이 돌아오면 알아서 서버에 반영됩니다.

비결은 Linear가 서버 응답을 기다리지 않는다는 데 있습니다. 화면에 필요한 데이터가 이미 브라우저 안에 있기 때문입니다.

이런 구조를 오프라인 퍼스트(local-first)라고 부릅니다. 저희도 Drive(채널톡의 AI 비서 CoS가 활용할 파일·데이터를 올려 팀과 함께 관리하는 기능)를 만들면서 같은 길을 갔고, 그 결과가 hearth라는 사내 라이브러리입니다.

이 글은 hearth가 뭘 하는 물건인지보다, 왜 기존 것들을 그대로 안 쓰고 직접 만들었는지에 초점을 맞춥니다.

오프라인 퍼스트란

로컬 DB가 화면의 기준이고, 서버 동기화는 뒤에서 따라오는 일이 됩니다.

  • 서버에서 온 변경은 로컬 DB에 반영되고, 화면은 자동으로 따라옵니다.

  • 사용자의 조작도 로컬 DB에 먼저 쓰고, 서버로는 뒤이어 보냅니다.

한 문장으로 줄이면, "서버 응답을 화면에 그린다"가 "로컬 DB를 그리고, 서버와 DB를 맞춘다"로 바뀝니다. 이 구조에서 얻는 건 네 가지입니다.

사용자 경험

화면이 즉시 뜸

재방문·새로고침에 스피너 없음

오프라인 동작

지하철·비행기에서도 읽기/쓰기

클릭이 즉시 반영

서버 응답을 안 기다림, 실패하면 되돌림

실시간 반영

소켓 이벤트를 DB에 쓰면 화면이 알아서 갱신

개발 관점에서 이득이 하나 더 있습니다. 공통 엔진이 동기화를 처리해주기 때문에, 화면마다 동기화 로직을 다시 짤 필요가 없습니다.

기존 오프라인 퍼스트는 이렇게 푼다

Linear만 이런 구조를 쓰는 건 아닙니다. 로컬 DB와 클라이언트 쪽 동기화 엔진을 묶어서 제공하는 오픈소스 라이브러리들이 이미 있습니다. React·React Native 앱용으로 만들어진 WatermelonDB, 브라우저부터 모바일·데스크톱까지 JavaScript 환경 전반을 지원하는 RxDB가 대표적인데, 큰 그림은 셋 다 같습니다. 로컬 DB, 변경분 동기화, 서버 쪽 커서(버전) 엔드포인트.

로컬 DB

충돌 처리

서버에 요구하는 것

Linear (자체 구축)

IndexedDB

전역 순서(sync 번호) 기반, 그 순서로 LWW

bootstrap + delta 엔드포인트, 전역 sync 번호

WatermelonDB

SQLite(네이티브) / LokiJS 경유 IndexedDB(웹)

컬럼 단위 client-wins

pull(마지막 동기화 시각) / push 직접 구현

RxDB

IndexedDB 등

클라이언트 conflict handler

체크포인트 기반 pull/push

셋이 충돌을 푸는 방식은 조금씩 다릅니다.

이 구조들은 로컬 우선 시스템의 두 큰 갈래 중 하나에 속합니다. 로컬 퍼스트 동기화 엔진 Replicache를 만든 Aaron Boodman은 이 분야를 크게 두 갈래로 나눕니다.

  • server-authority 계열 - 서버가 최종 권위를 갖고 로컬은 낙관적 사본 역할만 함 (Replicache·Zero·PowerSync)

  • decentralized·CRDT 계열 - 서버 없이도 여러 클라이언트의 변경이 충돌 없이 자동으로 수렴함 (Yjs·Automerge 같은 공동 편집기용)

이 틀에 비춰보면 Linear도 앞의 계열에 속합니다. 후자(CRDT)는 여러 클라이언트가 서버 조율 없이 동시에 편집하는 상황(대표적으로 공동 문서 편집)에 강합니다. hearth를 적용한 제품은 공동 편집기가 아니라 문서·파일을 다루는 쪽이라 동시 편집이 드물고, CRDT의 힘까지는 필요하지 않았습니다. 그래서 저희도 전자 계열을 골랐습니다.

셋 중 클라이언트만으로 완성되는 건 하나도 없습니다. 자체 API 서버를 쓴다면 어느 길을 고르든 서버 쪽 동기화 구현은 우리 몫입니다.

우리는 어떻게 했나 - hearth

hearth는 어디서 왔나

hearth는 처음부터 프레임워크로 설계된 게 아니라, 실전에서 검증된 코드가 나중에 라이브러리로 독립한 경우입니다.

저희 사내 프로젝트 하나가 몇 달 전부터 이 로컬 퍼스트 구조를 먼저 실험하고 있었고, 그 안에 로컬 DB 동기화를 처리하는 계층이 자라났습니다. 처음엔 딱 필요한 만큼만 짜서 그 프로젝트 안에 두고 있었는데, 이건 의도한 것이었습니다. 빠르게 공통 모듈로 추상화하기보다, 같은 필요가 실제로 반복되는 걸 눈으로 확인한 뒤에 추출하자는 게 원래 원칙이었습니다.

그 반복은 생각보다 빨리 왔습니다. Drive를 포함한 다른 제품도 같은 문제(화면 진입마다 뜨는 스피너, 오프라인일 때 이전 데이터조차 못 보는 문제)를 겪으면서, 이 코드를 여러 제품이 나눠 쓸 수 있는 독립 패키지로 떼어내자는 결정이 내려졌습니다. 그렇게 사내 npm 패키지로 독립한 게 hearth입니다.

전체 그림은 이렇습니다.

화면은 언제나 로컬 DB만 봅니다. 로컬 DB가 바뀌면 화면이 알아서 따라오고, 재방문하면 서버 응답을 기다리지 않고 즉시 뜹니다. 이 그림 안에서 무엇을 어떻게 가져오고 읽고 쓸지는 제품 코드가 몇 가지 옵션만 채우면 되고, 동기화의 골치 아픈 부분(다음에 설명할 LWW, 낙관적 쓰기 보호)은 엔진이 처리합니다.

hearth는 어떻게 만들어져 있나

hearth는 브라우저 내장 IndexedDB를 직접 다루지 않고, Dexie라는 오픈소스 래퍼 위에 올라가 있습니다. Dexie를 고른 이유는 화면 갱신에 필요한 반응형 쿼리(데이터가 바뀌면 구독 중인 화면이 자동으로 다시 그려지는 기능)를 기본 제공하고, 애드온으로 자연스럽게 확장할 수 있어서입니다. hearth가 의존하는 외부 라이브러리는 사실상 Dexie와 그 주변 패키지 정도입니다.

내부는 크게 coresync 두 계층으로 나뉩니다. 경계 기준은 “이 개념이 서버 없이도 말이 되는가”입니다.

Plaintext
core          # 서버 없이 순수 로컬 IndexedDB만으로 성립하는 계층
├─ hooks      # 테이블 단위 - 쓰기가 반영되기 전에 개입
│               (LWW 비교, 낙관적 쓰기 보호, 동기화 시각 기록)
├─ eviction   # 테이블 단위 - 캐시 수명 정책 (테이블 정의 시 필수 옵션)
└─ plugin     # DB 전체 단위 - 생애주기 이벤트에 반응
                (초기화 성공, 저장 공간 부족, 앱 종료)

sync          # core 위에서 서버 fetch·push를 붙이는 계층
                hooks가 남긴 흔적으로 "이 데이터를 다시 받아올까"를 판단

hooks·eviction·plugin은 core 계층의 서로 다른 확장점입니다.

  • hooks - Dexie는 데이터가 생성·수정될 때 가로챌 수 있는 훅을 제공하는데, hearth는 이 훅에 LWW 비교·낙관적 쓰기 보호·동기화 시각 기록을 끼워 넣습니다. 제품 코드가 테이블을 정의할 때 옵션만 채우면, 그 옵션에 맞는 훅들이 자동으로 연결됩니다.

  • eviction - 테이블 단위 캐시 수명 정책입니다. 테이블을 정의할 때 이 정책은 선택이 아니라 필수 옵션입니다. 아무도 안 정하면 언젠가 브라우저 저장 공간이 감당 못 할 규모로 캐시가 쌓이기 때문입니다.

  • plugin - 앞의 둘과 범위가 다릅니다. 테이블 하나가 아니라 DB 전체의 생애주기에 반응합니다. 오프라인 중 축출을 멈추는 동작이 이 방식인데, 특정 쓰기 하나가 아니라 온라인/오프라인이라는 DB 전체의 상태 변화에 반응해야 하는 일이라 plugin으로 분리돼 있습니다.

이 core 위에 얹힌 sync는 hooks가 테이블에 남긴 흔적을 근거로 서버 관련 판단을 내립니다. 예를 들어 “언제 이 데이터를 로컬에 담았나”와 “언제 서버와 마지막으로 확인했나”는 서로 다른 시계인데, 낙관적 쓰기를 하는 순간엔 전자만 갱신되고 후자는 서버 확인이 돌아올 때까지 그대로입니다. sync는 이 후자의 기록을 보고 “이 데이터를 다시 받아와야 하는가”를 판단합니다. (신선도 검사는 서버의 존재를 전제하기 때문에 core가 아니라 sync에 속합니다.)

왜 그대로 안 쓰고 직접 만들었나

Linear·WatermelonDB·RxDB 중 어느 것도 “설치하면 끝”이 아닙니다.

  • 자체 API 서버를 쓰는 이상 서버 쪽 동기화는 어차피 직접 구현해야 합니다. pull/push 엔드포인트를 우리 손으로 짜야 하는 건 WatermelonDB를 골라도 RxDB를 골라도 마찬가지입니다.

  • RxDB의 스토리지 플러그인 추상화는 IndexedDB든 네이티브 앱의 SQLite든 여러 저장소에 맞추기 위한 구조인데, 저희는 이미 자체 서버 하나만 상대하면 되니 그 추상화 계층이 오히려 군더더기가 됩니다.

무엇보다 저희에게는 이미 작동하는 코드가 있었습니다. 앞서 말했듯 이 로직은 사내 프로젝트 하나 안에서 자라나 실전 검증까지 끝난 상태였고, 저희 서버의 응답 형태와 이미 맞물려 있었습니다.

선택지는 둘이었습니다.

  • 검증된 우리 코드를 여러 제품이 나눠 쓸 수 있게 다듬는다

  • 이미 잘 도는 걸 버리고 남의 라이브러리에 우리 서버를 새로 맞춘다

전자가 합리적이었습니다. 다만 그 코드가 한 프로젝트에 고정된 방식으로는 재사용할 수 없었기 때문에, 특정 데이터의 의미를 몰라도 동작하는 도메인 무지(domain-agnostic) 구조로 다듬는 게 hearth를 만드는 핵심 과제가 됐습니다.

LWW로 충돌을 화해한다

여러 클라이언트, 여러 탭, 실시간 소켓 이벤트가 동시에 같은 데이터를 건드리면 순서가 꼬일 수 있습니다. hearth는 Linear와 같은 LWW를 택하되, 서버가 매긴 순번 대신 타임스탬프 비교로 승자를 정합니다. 서버 기준으로 더 최신 시각을 가진 값이 이깁니다.

이건 나중에 골라도 되는 옵션이 아니었습니다. 실시간 소켓을 쓰는 순간 “서버가 방금 보낸 변경”과 “사용자가 로컬에서 막 만든 변경”이 동시에 존재할 수 있는 경쟁 상태가 생기고, 이걸 화해시킬 규칙 없이는 애초에 화면을 못 그립니다. WatermelonDB도 같은 이유로 타임스탬프로 충돌을 감지하고 있어서, 저희만의 독특한 선택은 아닙니다.

이 선택이 실전에서 어떤 문제를 낳았는지 사례 하나를 소개합니다.

Drive에서 어떤 항목의 생성자 이름이 옛 값으로 계속 보이는 문제가 있었습니다. 새 브라우저나 시크릿 모드에서는 정상으로 보였고, 에러나 로그도 전혀 없었습니다.

원인은 이랬습니다. 서버가 응답 값만 바꾸고 그 값의 타임스탬프는 갱신하지 않은 것입니다. LWW 규칙은 “새로 온 값의 타임스탬프가 기존과 같거나 더 오래됐네? 그럼 무시”라고 판단했고, 이미 그 데이터를 로컬에 갖고 있던 사용자의 화면에는 새 값이 영영 반영되지 않았습니다.

브라우저 콘솔에서 로컬 DB를 직접 지우고 새로고침하면 정상으로 돌아온다는 게 그나마 확실한 디버깅 방법이었습니다. 로컬 DB가 원인이라는 걸 알기 전까지는 “서버는 정상인데 화면만 이상하다”는 것 말고는 단서가 거의 없었습니다.

여기서 배운 건, LWW를 쓰는 순간 정합성의 책임이 프론트엔드 코드를 넘어 서버 코드까지 확장된다 사실입니다. “값을 바꾸면 타임스탬프도 반드시 함께 갱신한다”는 규칙을 서버 쪽과 같이 지켜야 하고, 이건 프론트 코드만 봐서는 절대 보이지 않는 종류의 버그입니다.

낙관적 쓰기를 지키는 법

사용자가 뭔가를 바꾸면 서버 응답을 기다리지 않고 화면에 바로 반영합니다(낙관적 업데이트). 그런데 그 직후에 서버에서 그 변경이 반영되기 전 시점의 응답이 뒤늦게 도착하면, 방금 사용자가 바꾼 값을 조용히 덮어써 버릴 수 있습니다.

hearth는 이 문제를 컬럼(필드) 단위로 풉니다. 로컬에서 바꿨지만 아직 서버 확인을 못 받은 필드를 추적해두고, 확인되지 않은 서버 응답이 그 필드를 되돌리지 못하게 막습니다.

이때 "지금 사용자 입력 처리 중"이라는 별도 상태 플래그는 일부러 쓰지 않았습니다.

  • 플래그는 언제 켜고 끌지를 수동으로 관리해야 해서 타이밍이 어긋나는 경쟁 상태를 스스로 만들기 쉽습니다.

  • 대신 들어오는 데이터 자체를 보고 “이건 서버가 보낸 응답인지, 사용자가 만든 변경인지”를 판별하게 했습니다. 판단 근거를 전역 플래그처럼 수동으로 켜고 끄는 상태가 아니라, 행마다 붙어 서버 확인이 오면 자동으로 사라지는 형태로 두면 그만큼 어긋날 자리가 줄어듭니다.

WatermelonDB도 컬럼 단위로 비슷한 보호 장치를 두는데, 저희 상황(자체 서버, 여러 제품 공유)에는 그 시그니처를 그대로 쓸 수 없어서 참고해 저희 방식으로 조합·확장했습니다. 다만 이 장치도 아직 완벽하진 않습니다. 서버가 실패 없이 응답했지만 재시도 로직과 겹치는 드문 경우처럼, 여전히 다듬는 중인 구석이 남아 있습니다.

동기화 여부를 다시 정의하다

hearth를 실제로 Drive에 붙여 운영하다, 데이터가 있어야 할 자리에 없거나 없어야 할 자리에 유령처럼 남아있는 사고를 겪었습니다. 원인을 추적한 결과는 패치 하나로 덮을 문제가 아니라 설계 자체의 범주 오류였습니다.

hearth는 “이 데이터를 다시 받아와야 하는가”라는 현재 상태 질문을, “예전에 동기화한 적이 있다”는 과거 기록으로 판단하고 있었습니다.

  • 과거 기록으로 현재를 추론하는 건 “그때 받아온 결과물이 지금도 실물로 남아있다”는 전제 위에서만 성립합니다.

  • 그런데 캐시 축출은 정확히 그 실물을 지우는 장치입니다.

  • 결과물은 지워지고 “동기화했다”는 기록만 남는 건, 영수증은 남았는데 물건은 사라진 상황입니다. 그 결손은 영원히 복구되지 않습니다.

고친 방향은 하나, 판단 근거를 결과물과 한 몸으로 묶는 것입니다. “언제 마지막으로 확인했는가”라는 기록을 별도 장부가 아니라 행 자신에 새겨 넣어, 행이 지워지면 기록도 함께 지워지게 했습니다. 그 위에서 “동기화가 필요한가”의 기준을 과거 기록 조회가 아니라 “행이 존재하고 신선한가”로 바꾸고, 이 판단을 도메인 코드가 손댈 수 없도록 라이브러리 안으로 회수했습니다. 오프라인 중 캐시 축출 금지도 이참에 전제 조건으로 승격했습니다. 안 그러면 “이 폴더는 원래 비어 있었다”는 거짓 상태가 만들어지기 때문입니다. 아주 드물게 세 조건이 겹쳐야만 남는 사각지대 하나는 완벽히 봉쇄하는 대신 캐시 만료 상한에 기대 자연 소멸하도록 남겨뒀는데, 이건 방치가 아니라 지금 자원을 어디에 쓰는 게 맞는지 따진 결과입니다.

이 버그 클래스는 저희만의 것이 아닙니다. PouchDB도 체크포인트 기록은 남는데 실제 문서가 지워져 재복제가 막히는 동일한 문제를 공식 이슈로 갖고 있습니다. 결론은 하나로 요약됩니다.

판단 근거와 그 판단이 가리키는 대상의 수명을 하나로 묶어라. 게이트는 요청을 줄이는 최적화일 뿐, 그 자체가 진실의 원천이 되면 안 된다.

다음 도전: delta sync

지금 hearth는 화면에 들어갈 때마다 데이터를 통째로 다시 요청합니다. 로컬 DB가 먼저 화면을 채워주기 때문에 사용자는 스피너를 못 느끼지만, 네트워크 요청 자체가 줄어드는 건 아닙니다. 변경분만 주고받는 delta sync가 붙어야 이 구조가 완성됩니다.

이걸 처음부터 다 만들지 않은 데는 이유가 있습니다. hearth는 처음부터 전사 표준으로 밀어붙인 게 아니라 Drive 하나를 파일럿 삼아 “이 구조가 실제로 우리 제품에 값어치가 있는가”부터 검증하는 단계였습니다. 검증도 안 끝난 상태에서 서버까지 뜯어고쳐 delta sync를 먼저 만드는 건 순서가 거꾸로입니다. 클라이언트 쪽(LWW, 낙관적 쓰기 보호, 방금 설명한 재설계)만으로도 스피너 없는 화면과 오프라인 읽기 같은 효과는 이미 실전에서 확인됐고, 이제 그 가치가 눈에 보이는 수준까지 왔다고 판단해서 다음 단계로 넘어가려는 참입니다.

다음 단계가 왜 클라이언트 혼자 힘으로는 안 되는지는 기술적으로 분명합니다. 얼핏 “마지막으로 받은 시각 이후 것만 주세요” 정도면 될 것 같지만, 그렇지 않습니다.

  • 타임스탬프는 보통 트랜잭션이 시작된 시점에 찍힙니다.

  • 하지만 실제로 다른 곳에 보이는 시점은 트랜잭션이 커밋된 이후입니다.

  • 락 대기 등으로 두 시점 사이 간격이 벌어지면, 먼저 시작했지만 늦게 커밋된 변경이 클라이언트가 이미 지나친 시각보다 뒤로 밀려 영원히 누락될 수 있습니다.

진짜 안전한 커서는 DB의 커밋 로그이거나, Linear처럼 서버가 커밋 성공 직후 순서대로 발급하는 값이어야 하는데, 둘 다 서버(백엔드) 쪽에서 만들어야 하는 장치입니다. 그래서 지금은 서버 개발자들과 함께 이 다음 단계를 시작해보려는 자리에 있습니다.

마무리

정리하면 이렇습니다.

  • 화면은 로컬 DB를 그리고, 서버와는 변경분만 주고받는다. 이게 오프라인 퍼스트의 요지입니다.

  • 저희는 자체 서버·여러 제품 공유라는 조건 때문에 기존 라이브러리를 그대로 가져다 쓰지 않고 hearth를 직접 만들었습니다.

  • 클라이언트 쪽은 파일럿으로 시작해 실전에서 검증하며 다졌고, 그 가치가 확인된 지금 delta sync라는 다음 단계로 넘어가려 합니다.

이런 종류의 설계 문제(타임스탬프냐 커서냐, 어디까지 낙관적으로 처리하고 어디서 서버 권위로 되돌릴지)를 계속 파고드는 게 저희 팀이 일하는 방식이고, 이 다음 단계는 프론트엔드와 서버가 함께 풀어야 합니다. 이런 문제를 같이 풀어볼 동료를 찾고 있습니다.

We Make a Future Classic Product