Replicache 완벽 가이드 — 로컬 우선 동기화·오프라인·협업

Replicache 완벽 가이드 — 로컬 우선 동기화·오프라인·협업

이 글의 핵심

Replicache는 브라우저에 로컬 데이터를 두고 즉시 반응하는 낙관적 업데이트와 서버 동기화를 같은 추상화로 다루는 로컬 우선 동기화 라이브러리입니다. Mutator, Pull/Push, 충돌 모델, IndexedDB 영속성, 협업 앱 설계까지 한 흐름으로 설명합니다.

이 글에서 다루는 내용

Replicache는 웹 클라이언트에 로컬 우선(local-first) 데이터 계층을 제공하는 동기화 라이브러리입니다. UI는 항상 로컬 저장소를 읽고, 변경은 Mutator로 표현하며, 네트워크가 있을 때 Push로 서버에 전달하고 Pull로 최신 상태를 받아옵니다. 이 글은 다음을 순서대로 설명합니다.

  • Replicache의 핵심 개념(클라이언트, 스페이스, 트랜잭션 모델)
  • Mutator낙관적 업데이트가 동작하는 방식
  • PullPush 프로토콜의 역할과 구현 시 유의점
  • 충돌이 발생하는 지점과 해결 전략(서버 권한, 재생 순서)
  • 브라우저 IndexedDB를 통한 영속성과 성능
  • 실전 협업 앱을 설계할 때의 아키텍처 패턴

문서·스프레드시트 수준의 실시간 공동 편집까지가 목표가 아니라, 빠른 반응성오프라인 허용, 서버와의 일관된 상태를 동시에 만족시키는 앱에 특히 잘 맞습니다.


Replicache가 해결하는 문제

전통적인 “서버가 진실” 모델에서는 사용자 입력마다 API를 기다려야 하므로, 지연 시간이 그대로 체감 성능이 됩니다. 반면 로컬 우선 모델은 먼저 로컬에 반영하고 나중에 동기화하지만, “여러 기기·여러 사용자 간에 같은 규칙으로 합쳐지게 만들기”가 어렵습니다.

Replicache는 이를 동일한 Mutator 코드를 클라이언트와 서버에서 실행한다는 아이디어로 정리합니다.

  1. 클라이언트: Mutator를 즉시 실행해 UI를 갱신한다(낙관적).
  2. 서버: 같은 이름의 Mutator를 같은 인자로 재생(replay) 하여 권한 있는 데이터베이스를 갱신한다.
  3. 동기화: 서버 상태의 스냅샷·증분을 Pull로 내려받아 로컬과 맞춘다.

이 구조 덕분에 “화면은 즉시, 진실은 서버”라는 실무에서 자주 쓰는 타협을, 반복 가능한 프로토콜로 고정할 수 있습니다.


핵심 개념

Replicache 클라이언트와 스토어

앱은 보통 Replicache 인스턴스 하나가 하나의 동기화 스코프(예: 사용자별 데이터 공간)를 담당합니다. 인스턴스는 로컬 키-값 저장소에 연결되고, pullURL·pushURL(또는 커스텀 핸들러)로 백엔드와 대화합니다.

import { Replicache, type WriteTransaction } from "replicache";

type M = {
  putMessage: (tx: WriteTransaction, args: { id: string; body: string }) => Promise<void>;
};

export const rep = new Replicache<M>({
  name: "chat-user-123", // 로컬 DB(IndexedDB) 이름·격리에 사용
  licenseKey: import.meta.env.VITE_REPLICACHE_LICENSE_KEY,
  mutators: {
    async putMessage(tx, { id, body }) {
      await tx.set(`message/${id}`, { id, body, updatedAt: Date.now() });
    },
  },
});

name은 브라우저 내 IndexedDB 데이터베이스를 구분하는 데 쓰이므로, 사용자 계정이나 워크스페이스마다 다르게 주는 것이 일반적입니다. 같은 name을 쓰는 탭들은 동일한 로컬 캐시를 공유합니다.

읽기와 쓰기 트랜잭션

Replicache의 데이터 접근은 트랜잭션 단위입니다. 읽기subscribe·query 등으로 일관된 스냅샷을 구독하고, 쓰기는 Mutator 안에서만 수행하는 패턴이 권장됩니다. Mutator 밖에서 임의로 저장소를 바꾸면 서버 재생 결과와 어긋날 수 있기 때문입니다.

버전·쿠키와 증분 Pull

서버는 클라이언트가 마지막으로 본 버전 식별자(쿠키) 를 저장해 두었다가, Pull 요청 시 “그 이후의 변경”만 내려줄 수 있습니다. 클라이언트는 응답에 포함된 새 쿠키를 보관하고, 다음 Pull부터 증분 동기화에 사용합니다. 전체 스냅샷을 매번 보내지 않도록 설계하는 것이 대규모 데이터에서 중요합니다.

Poke(알림)와 폴링

실시간에 가깝게 맞추려면 서버가 “새 데이터 있음”을 알리는 poke 채널(WebSocket, SSE 등)을 두고, 클라이언트가 rep.pull()을 트리거하게 할 수 있습니다. poke가 없으면 주기적 Pull이나 포그라운드 시 Pull로도 동작하지만, 지연은 커질 수 있습니다.


Mutator와 낙관적 업데이트

Mutator는 “이 앱에서 허용하는 상태 전이”를 이름 붙인 함수입니다. 사용자가 버튼을 누르면 클라이언트는 해당 Mutator를 즉시 실행하고, 동시에 서버로 보낼 작업 큐에 쌓습니다.

// mutators 예시: 할 일 추가
type Todo = { id: string; title: string; done: boolean };

export const rep = new Replicache({
  name: "todo-space-1",
  licenseKey: "...",
  mutators: {
    async addTodo(tx, args: { id: string; title: string }) {
      const todo: Todo = { id: args.id, title: args.title, done: false };
      await tx.set(`todo/${args.id}`, todo);
    },
    async toggleTodo(tx, { id }: { id: string }) {
      const prev = await tx.get<Todo>(`todo/${id}`);
      if (!prev) return;
      await tx.set(`todo/${id}`, { ...prev, done: !prev.done });
    },
  },
});

위 코드에서 addTodo는 로컬 DB에 바로 todo/{id} 키를 씁니다. 네트워크 왕복을 기다리지 않으므로 체감 지연은 로컬 디스크 수준으로 줄어듭니다.

낙관적 업데이트의 주의점은 다음과 같습니다.

  • 서버가 거부할 수 있는 연산(권한, 할당량, 유효성)은 Mutator 안에서도 검증하고, 서버에서 동일 규칙으로 한 번 더 검증해야 합니다.
  • 임시 ID를 쓰는 경우, 서버가 확정 ID를 발급하면 Pull 결과로 로컬 키가 바뀌거나 병합 규칙이 필요할 수 있습니다.
  • Mutator는 가능한 한 멱등에 가깝게 설계하는 것이 서버 재시도·중복 전송에 유리합니다.

Pull과 Push

Push: 클라이언트 → 서버

Push는 “클라이언트가 실행한 Mutator 목록”을 서버에 전달하는 단계입니다. 서버는 각 항목에 대해 같은 이름의 함수를 찾아 데이터베이스 트랜잭션 내에서 실행합니다. 실행 순서는 큐의 순서를 따르므로, 동일 스페이스에서의 인과 관계를 보존하려면 Mutator 설계 시 순서 의존성을 명확히 해야 합니다.

서버 측 구현에서 흔히 하는 일은 다음과 같습니다.

  1. 요청 본문 파싱 및 인증·권한 확인
  2. 각 mutation에 대해 서버 측 핸들러 실행(동일 비즈니스 로직)
  3. 커밋 후 새로운 버전·쿠키 계산

Pull: 서버 → 클라이언트

Pull은 서버의 권한 있는 상태를 패치 형태로 내려 로컬을 맞추는 단계입니다. 다른 사용자의 변경, 다른 기기에서의 변경, 또는 서버가 거부·수정한 결과가 여기서 반영됩니다.

클라이언트는 Pull 이후 구독 중인 쿼리를 갱신하고 UI를 다시 그립니다. 그래서 낙관적으로 보여 주던 값이 서버 상태와 다르면, 사용자는 잠시 후 화면이 “진실”에 맞게 조정되는 것을 볼 수 있습니다. 이 차이를 줄이려면 Mutator 규칙을 서버와 완전히 동일하게 맞추는 것이 핵심입니다.

네트워크 장애와 재시도

Push는 실패 시 재시도될 수 있습니다. 그래서 서버 핸들러는 중복 실행에 안전한지, 또는 mutation ID로 중복을 제거하는지를 문서화하는 것이 좋습니다. Pull은 “언제든지 다시 받아도 안전”해야 하며, 쿠키만 일관되게 유지되면 됩니다.


충돌 해결

Replicache 모델에서 “충돌”은 주로 다음 두 가지로 나타납니다.

  1. 동시 편집: 두 클라이언트가 같은 키를 다른 Mutator로 갱신한 경우, 서버에 적용되는 순서에 따라 최종 값이 결정됩니다.
  2. 낙관적 예측과 서버 진실의 불일치: 클라이언트는 이미 로컬에 써 두었지만, 서버 규칙상 값이 달라지는 경우(예: 서버가 타임스탬프를 덮어씀).

일반적인 전략은 다음과 같습니다.

  • 서버 권한 단일 진실 공급원: 비즈니스적으로 최종 판단은 항상 서버 트랜잭션 결과를 따른다.
  • 필드 단위 병합: 문서 전체가 아니라 필드별로 “마지막 쓰기” 또는 비즈니스 규칙을 정한다.
  • CRDT·버전 벡터: 동시 편집이 잦다면 값을 CRDT blob으로 저장하고 Mutator는 CRDT 연산만 적용한다.

Replicache 자체가 모든 CRDT를 내장하는 것은 아니므로, 협업 강도에 맞춰 저장 모델을 선택해야 합니다. 단순 할 일·댓글 수준은 서버 순서 + Pull로 충분한 경우가 많고, 문자 단위 공동 편집은 별도의 CRDT 레이어가 필요할 때가 많습니다.

// 예: 서버에서만 'version'을 올리고, 클라이언트는 Pull로 수렴
// (실제 키·필드는 앱 규약에 맞게 조정)
async function mergeTodoOnServer(
  current: Todo | undefined,
  next: Todo
): Promise<Todo> {
  if (!current) return next;
  if (next.updatedAt < current.updatedAt) return current;
  return next;
}

위와 같은 결정 함수를 서버에 두고, Mutator 재생 시 항상 그 경로를 타게 하면, 클라이언트도 동일한 데이터 모델을 유지하기 쉽습니다.


IndexedDB 백엔드

브라우저에서 Replicache는 기본적으로 IndexedDB에 데이터를 보관합니다. 이는 다음을 의미합니다.

  • 새로고침·재방문 후에도 로컬 상태가 남아, 첫 화면을 즉시 그릴 수 있다.
  • 용량 한도는 브라우저·사용자 설정의 영향을 받으므로, 대용량 바이너리는 별도 Object URL·Blob 저장 전략과 병행하는 편이 안전하다.
  • 프라이버시: 민감 데이터는 저장 전 암호화하거나, 키를 서버에서만 복호화하는 등의 정책이 필요할 수 있다.

운영 측면에서 IndexedDB는 때때로 마이그레이션(스키마 변경)이 필요합니다. 키 네이밍 규칙을 버전 접두어로 나누거나(v2/todo/...), 일회성 마이그레이션 Mutator를 두는 방식이 쓰입니다.

성능적으로는 구독 범위를 좁히는 것이 중요합니다. 수만 키를 한 번에 구독하면 UI 스레드 부담이 커질 수 있으므로, 화면 단위로 쿼리를 쪼개거나 페이지네이션 키를 설계합니다.


실전 협업 앱 구축

“협업”을 Replicache만으로 구현할 때의 일반적인 레이어는 다음과 같습니다.

레이어역할
인증사용자·워크스페이스 식별, Push/Pull 엔드포인트 보호
스페이스 IDname 또는 서버 쿠키와 연계해 데이터를 격리
Mutator제품이 허용하는 모든 상태 전이 정의
서버 재생동일 Mutator로 DB 갱신, 감사 로그·권한 검사
Pull다른 협업자의 변경 전파
Poke변경 알림으로 Pull 지연 최소화

예를 들어 공유 칸반을 만든다면 컬럼·카드 키를 board/{boardId}/card/{cardId} 형태로 두고, 이동은 moveCard Mutator 하나로 표현하는 식이 읽기 쉽습니다. 서버는 같은 moveCard에서 “같은 보드 권한이 있는가”, “WIP 한도를 넘지 않았는가”를 검증합니다.

// 협업 칸반: 카드 이동을 단일 Mutator로
type Card = { id: string; columnId: string; order: number };

mutators: {
  async moveCard(
    tx,
    { cardId, toColumnId, order }: { cardId: string; toColumnId: string; order: number }
  ) {
    const key = `card/${cardId}`;
    const card = await tx.get<Card>(key);
    if (!card) return;
    await tx.set(key, { ...card, columnId: toColumnId, order });
  },
},

프론트엔드에서는 rep.subscribe로 현재 보드의 키 접두어에 해당하는 카드만 구독해, 다른 보드의 변경이 불필요하게 렌더링을 흔들지 않게 합니다.

백엔드 프레임워크 선택은 자유이지만, Push/Pull 엔드포인트를 안정적으로 노출하고, DB 트랜잭션과 버전 쿠키를 일관되게 다룰 수 있는지가 기준이 됩니다. 팀에 익숙한 스택(Node, Go, .NET 등) 위에 얇은 동기화 레이어를 올리는 경우가 많습니다.


보안·테스트·운영 팁

  • 엔드포인트 인증: Push/Pull은 반드시 세션·JWT 등으로 보호하고, 스페이스 ID와 사용자 권한을 대조합니다.
  • 입력 검증: 클라이언트와 서버 모두에서 인자 스키마를 검증합니다(Zod 등).
  • 관측: Push 실패율, Pull 지연, 큐 길이를 메트릭으로 남기면 현장 이슈를 줄일 수 있습니다.
  • 테스트: Mutator 순수 로직은 단위 테스트하고, 서버 재생 경로를 통합 테스트해 클라이언트·서버 불일치를 조기에 발견합니다.

정리

Replicache는 로컬 우선 UX서버 권한 모델을 Mutator라는 동일한 언어로 잇습니다. Mutator로 낙관적 업데이트를 정의하고, Push로 서버에 재생시키며, Pull로 모든 클라이언트가 수렴합니다. 충돌은 서버의 재생 순서와 병합 규칙으로 풀며, 필요 시 CRDT 등을 값 레벨에서 조합합니다. IndexedDB는 빠른 로딩과 오프라인을 가능하게 하고, 실전 협업 앱에서는 스페이스 설계·poke·보안 경계를 함께 다져야 합니다.

공식 문서의 최신 API·라이선스 조건은 배포 전에 반드시 확인하고, 이 글의 코드는 개념 설명용으로 프로젝트 설정에 맞게 조정해 사용하시기 바랍니다.