Replicache로 로컬 우선 동기화: Mutator와 낙관적 업데이트, Pull·Push, 충돌 처리

이 글의 핵심

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

Replicache는 웹 클라이언트에 로컬 우선(local-first) 데이터 계층을 제공하는 동기화 라이브러리입니다. UI는 항상 로컬 저장소를 읽고, 변경은 Mutator라는 이름 붙은 함수로 표현하며, 네트워크가 있을 때 Push로 서버에 보내고 Pull로 서버의 최신 상태를 받아옵니다. 문서 단위 실시간 공동 편집보다는, 빠른 반응성과 오프라인 허용, 서버와의 일관된 상태를 함께 원하는 앱에 잘 맞습니다.

Replicache를 만든 Rocicorp는 이후 Replicache를 무료로 전환하고 후속 제품인 Zero에 개발 역량을 옮겼습니다. 아래 개념(낙관적 Mutator, 서버 권한, 리베이스)은 Zero에도 거의 그대로 이어지지만, 새 프로젝트에 도입한다면 두 제품의 현재 유지보수 상태를 먼저 확인하는 것이 좋습니다.


Replicache가 해결하는 문제

전통적인 “서버가 진실” 모델에서는 사용자 입력마다 API 응답을 기다려야 하므로 네트워크 지연이 그대로 체감 성능이 됩니다. 로컬 우선 모델은 먼저 로컬에 반영하고 나중에 동기화하지만, 여러 기기와 여러 사용자의 변경을 같은 규칙으로 합치기가 어렵습니다.

Replicache는 이 문제를 같은 의미의 Mutator를 클라이언트와 서버에서 각각 실행하는 방식으로 풉니다. 클라이언트는 Mutator를 즉시 실행해 UI를 갱신하고(낙관적 실행), 서버는 같은 이름과 인자의 mutation을 받아 자기 구현으로 권한 있는 데이터베이스를 갱신합니다. 클라이언트는 Pull로 서버 상태를 받아 로컬을 맞추고, 아직 서버가 처리하지 않은 자신의 mutation은 그 위에 다시 실행합니다. 화면은 즉시 바뀌고 최종 판단은 서버가 하는 구조를 반복 가능한 프로토콜로 고정한 것입니다.


핵심 개념

Replicache 인스턴스

보통 Replicache 인스턴스 하나가 하나의 동기화 범위(사용자나 워크스페이스의 데이터 공간)를 담당합니다. 인스턴스는 로컬 키-값 저장소를 갖고, pullURL·pushURL(또는 커스텀 puller/pusher)로 백엔드와 통신합니다.

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

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

export const rep = new Replicache<Mutators>({
  name: "chat-user-123", // 로컬 저장소(IndexedDB)를 구분하는 이름
  licenseKey: import.meta.env.VITE_REPLICACHE_LICENSE_KEY,
  pushURL: "/api/replicache/push",
  pullURL: "/api/replicache/pull",
  mutators: {
    async putMessage(tx, { id, body }) {
      await tx.set(`message/${id}`, { id, body });
    },
  },
});

name은 브라우저 안의 로컬 저장소를 구분하므로 사용자 계정마다 다르게 줍니다. 같은 name을 쓰는 탭들은 로컬 데이터를 공유하고 서로 변경을 전파합니다. 로그아웃 후 다른 계정으로 로그인했는데 name이 같다면 이전 사용자의 데이터가 보이는 사고로 이어지므로, 사용자 ID를 이름에 넣는 것이 일반적입니다.

읽기와 쓰기

읽기는 rep.query()로 한 번 읽거나 rep.subscribe()로 구독합니다. 구독 함수는 일관된 스냅샷 위에서 실행되고, 결과가 바뀔 때만 콜백이 호출됩니다. 쓰기는 Mutator로만 할 수 있습니다. 로컬 저장소를 직접 고치는 API가 없다는 점이 중요한데, 모든 변경이 이름 붙은 Mutator라야 서버에 같은 연산을 보내고 Pull 이후 다시 실행할 수 있기 때문입니다.

Push와 lastMutationID

클라이언트가 Mutator를 실행하면 mutation(이름, 인자, 클라이언트별로 1씩 증가하는 ID)이 대기 목록에 쌓이고, Push 요청으로 서버에 전달됩니다. 서버는 클라이언트마다 마지막으로 처리한 mutation ID(lastMutationID)를 저장합니다. 새로 받은 mutation의 ID가 그보다 작거나 같으면 이미 처리한 것이므로 건너뛰고, 정확히 다음 ID일 때만 실행한 뒤 lastMutationID를 같은 DB 트랜잭션 안에서 갱신합니다. 이 장치 덕분에 네트워크 오류로 Push가 재전송돼도 같은 mutation이 두 번 적용되지 않습니다. 서버 Mutator 자체를 멱등으로 만들 필요가 없는 이유입니다.

Pull, 쿠키, 리베이스

Pull 요청에는 클라이언트가 마지막으로 받은 상태를 나타내는 쿠키가 들어갑니다. 서버는 그 이후 바뀐 키들을 패치(put/del/clear 연산 목록)로 내려주고, 새 쿠키와 각 클라이언트의 lastMutationID를 함께 보냅니다. 쿠키를 무엇으로 만들지는 서버가 정합니다. 전역 버전 번호를 쓰면 증분 패치를 만들기 쉽고, 쿠키를 무시하고 매번 전체 상태를 보내는 단순한 구현도 프로토콜상 허용되지만 데이터가 커지면 감당하기 어렵습니다.

응답을 받은 클라이언트는 낙관적으로 적용해 둔 로컬 변경을 일단 걷어내고, 서버 패치를 적용한 뒤, lastMutationID보다 큰, 즉 서버가 아직 처리하지 않은 자기 mutation만 그 위에 다시 실행합니다. 이것이 리베이스입니다. 서버가 처리한 mutation의 결과는 이미 패치에 들어 있으므로 다시 실행할 필요가 없습니다.

Poke

Pull은 클라이언트가 요청해야 일어나므로, 실시간에 가깝게 맞추려면 서버가 “새 데이터가 있다”는 신호(poke)를 WebSocket이나 SSE로 보내고 클라이언트가 rep.pull()을 호출하게 합니다. poke에는 데이터를 담지 않고 신호만 보내므로 구현이 단순합니다. poke 없이 주기적 Pull(pullInterval)만 쓰면 지연이 커지고, 간격을 짧게 하면 서버 부하가 늘어납니다.


Mutator와 낙관적 업데이트

Mutator는 앱이 허용하는 상태 전이를 이름 붙인 함수입니다. 호출하면 로컬에서 즉시 실행되고, 같은 mutation이 Push 대기 목록에 쌓입니다.

type Todo = { id: string; title: string; done: boolean };

export const rep = new Replicache({
  name: "todo-user-123",
  licenseKey: "...",
  mutators: {
    async addTodo(tx: WriteTransaction, 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: WriteTransaction, { id }: { id: string }) {
      const prev = await tx.get<Todo>(`todo/${id}`);
      if (!prev) return;
      await tx.set(`todo/${id}`, { ...prev, done: !prev.done });
    },
  },
});

// 호출: ID는 클라이언트에서 생성
await rep.mutate.addTodo({ id: nanoid(), title: "우유 사기" });

addTodo는 네트워크 왕복 없이 로컬 저장소에 바로 쓰므로 화면이 즉시 바뀝니다. Replicache는 데이터를 메모리에 두고 IndexedDB에는 비동기로 저장하기 때문에 Mutator 실행은 매우 빠릅니다.

ID를 클라이언트에서 만드는 점에 주목할 필요가 있습니다. 서버가 자동 증가 ID를 발급하는 구조라면 오프라인에서 만든 항목의 키를 나중에 바꿔야 하고, 그 키를 참조하던 다른 mutation까지 고쳐야 합니다. nanoid나 UUID 같은 충돌 가능성이 무시할 만한 ID를 클라이언트에서 만들면 이 문제가 사라집니다.

서버가 거부할 수 있는 연산(권한, 할당량, 유효성)은 클라이언트 Mutator에서도 같은 규칙으로 검사해야 사용자가 “됐다가 사라지는” 화면을 덜 보게 됩니다. 그렇다고 서버 검사를 생략할 수는 없습니다. 클라이언트 코드는 사용자가 마음대로 바꿀 수 있으므로 최종 검증은 항상 서버 Mutator가 합니다. 서버가 mutation을 거부하더라도 lastMutationID는 올려야 합니다. 그래야 클라이언트가 그 mutation을 처리된 것으로 보고 대기 목록에서 지우며, 다음 Pull의 리베이스에서 로컬 변경이 자연스럽게 되돌아갑니다. 거부한 mutation의 ID를 올리지 않으면 클라이언트는 같은 mutation을 계속 다시 보냅니다.


서버 쪽 Push 처리

서버 Push 핸들러의 뼈대는 다음과 같습니다. 핵심은 mutation 적용과 lastMutationID 갱신을 같은 DB 트랜잭션에서 하는 것입니다.

// 개념 예시: db.transaction, getLastMutationID 등은 앱의 DB 계층
export async function handlePush(userId: string, push: PushRequest) {
  for (const m of push.mutations) {
    await db.transaction(async (tx) => {
      const last = await getLastMutationID(tx, push.clientID);
      if (m.id <= last) return;                // 이미 처리함: 건너뜀
      if (m.id > last + 1) throw new Error("mutation gap"); // 순서가 비면 중단

      try {
        await serverMutators[m.name](tx, userId, m.args); // 권한·유효성 검사 포함
      } catch (e) {
        // 비즈니스 규칙 위반: 적용하지 않지만 ID는 올려서 클라이언트가 포기하게 함
        log.warn("mutation rejected", m.name, e);
      }
      await setLastMutationID(tx, push.clientID, m.id);
      await bumpVersion(tx); // 다음 Pull의 증분 패치를 위한 전역 버전
    });
  }
  await pokeClients(userId);
}

실제 구현에서는 Mutator가 실패했을 때 그 트랜잭션의 부분 변경을 롤백한 뒤 lastMutationID만 올리는 별도 트랜잭션으로 다시 처리하는 식으로 세부를 맞춥니다. 프로토콜 버전에 따라 클라이언트 ID 외에 클라이언트 그룹 ID도 다루므로, 요청·응답 형식은 사용하는 Replicache 버전의 문서를 기준으로 합니다.


충돌 해결

Replicache에서 충돌은 두 가지로 나타납니다. 하나는 두 클라이언트가 같은 데이터를 동시에 바꾸는 경우로, 서버가 mutation을 받은 순서대로 적용하므로 나중에 도착한 mutation이 앞의 결과 위에서 실행됩니다. 다른 하나는 클라이언트의 낙관적 결과와 서버 결과가 다른 경우로, 서버 Mutator가 권한 때문에 거부하거나 서버만 아는 값(시각, 순번)을 채우는 경우입니다. 두 경우 모두 Pull과 리베이스를 거치면 클라이언트는 서버 상태에 맞춰집니다.

중요한 점은 Mutator가 “값을 이것으로 덮어써라”가 아니라 “이 의도를 적용하라”를 표현한다는 것입니다. toggleTodo는 서버에서 실행될 때 서버의 현재 값을 읽어 뒤집습니다. 두 사람이 서로 다른 필드를 고친다면 각자의 Mutator가 자기 필드만 바꾸도록 설계하면 둘 다 살아남습니다. 문서 전체를 통째로 set하는 Mutator를 만들면 나중에 도착한 쪽이 앞의 변경을 지우므로, 필드 단위 Mutator로 나누는 것이 가장 효과적인 충돌 대책입니다.

이런 서버 순서 기반 모델로 충분하지 않은 경우도 있습니다. 같은 텍스트를 여러 사람이 동시에 타이핑하는 공동 편집은 의도 단위로도 합치기 어려우므로, 텍스트 값을 Yjs 같은 CRDT 문서로 저장하고 Mutator는 CRDT 업데이트를 적용하는 식으로 조합해야 합니다. 할 일, 댓글, 칸반 카드 정도라면 서버 순서와 리베이스로 충분한 경우가 대부분입니다.


IndexedDB 영속성과 데이터 크기

브라우저에서 Replicache는 데이터를 IndexedDB에 저장하므로 새로고침이나 재방문 후에도 로컬 상태가 남아 첫 화면을 네트워크 없이 그릴 수 있습니다. 동시에 클라이언트에 내려준 데이터 전체를 메모리에 올려 두고 동작하는 설계라서, 사용자가 접근하는 데이터 전부를 클라이언트에 두기 어려운 규모라면 맞지 않습니다. 첨부 파일 같은 큰 바이너리는 Replicache 키-값에 넣지 말고 별도 저장소(서버 스토리지, Cache API 등)에 두고 키에는 참조만 저장합니다.

IndexedDB에 저장된 데이터는 기기에 남으므로, 공용 기기에서 쓰일 수 있는 앱이라면 로그아웃 시 로컬 데이터를 지우는 처리와 민감 데이터의 범위를 함께 검토해야 합니다.

저장 형식을 바꿔야 할 때는 schemaVersion 옵션을 씁니다. 버전이 다르면 다른 로컬 저장소를 쓰므로, 새 버전의 클라이언트는 빈 상태에서 Pull로 데이터를 다시 받습니다. 서버는 Pull 요청에 담긴 schemaVersion을 보고 그 버전에 맞는 형태로 데이터를 내려줄 수 있습니다.

구독 범위도 성능에 영향을 줍니다. 수만 개 키를 하나의 구독으로 읽으면 변경마다 다시 계산할 양이 커지므로, 화면에 필요한 접두어만 scan하도록 키를 설계합니다.


협업 앱 설계

Replicache로 협업 앱을 만들 때의 구성 요소를 정리하면 다음과 같습니다.

구성 요소역할
인증사용자 식별, Push/Pull 엔드포인트 보호
name사용자별로 로컬 저장소 격리
Mutator제품이 허용하는 모든 상태 전이 정의
서버 Mutator권한 검사와 실제 DB 갱신, lastMutationID 관리
Pull다른 사용자의 변경 전파, 리베이스
Poke변경 알림으로 Pull 지연 최소화

공유 칸반이라면 카드 키를 board/{boardId}/card/{cardId}처럼 보드 접두어 아래에 두고, 카드 이동은 moveCard Mutator 하나로 표현합니다. 서버의 moveCard는 같은 이동을 적용하기 전에 그 보드에 대한 권한과 컬럼의 WIP 한도를 검사합니다.

type Card = { id: string; columnId: string; order: number };

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

// 현재 보드의 카드만 구독
rep.subscribe(
  (tx) => tx.scan<Card>({ prefix: `board/${boardId}/card/` }).values().toArray(),
  (cards) => render(cards)
);

order를 정수로 두면 두 사람이 같은 위치에 카드를 넣을 때 순번이 겹치므로, 실제로는 두 값 사이에 항상 새 값을 만들 수 있는 분수 인덱스(fractional indexing) 문자열을 쓰는 경우가 많습니다.

백엔드 언어와 프레임워크는 자유지만, Push 핸들러가 DB 트랜잭션 안에서 mutation과 lastMutationID를 함께 갱신할 수 있어야 하고, Pull이 쿠키 이후의 변경을 효율적으로 찾을 수 있도록 버전 정보를 저장해야 합니다.


보안, 테스트, 운영

Push와 Pull 엔드포인트는 세션이나 JWT로 보호하고, 요청한 사용자가 그 데이터에 접근할 권한이 있는지 서버 Mutator와 Pull 쿼리 양쪽에서 확인합니다. Mutator 인자는 클라이언트가 보낸 임의의 JSON이므로 Zod 같은 스키마 검증을 서버에서 반드시 거칩니다.

테스트는 Mutator 로직을 단위 테스트하는 것과 별도로, 같은 mutation 시퀀스를 클라이언트 Mutator와 서버 Mutator에 각각 적용해 결과가 같은지 비교하는 테스트가 효과적입니다. 두 구현이 어긋나면 “화면에서는 됐는데 새로고침하면 사라지는” 증상이 생기는데, 이 테스트가 그런 불일치를 배포 전에 잡아 줍니다. 클라이언트와 서버가 모두 TypeScript라면 Mutator 본문을 공유 패키지로 두는 것도 방법입니다.

운영에서는 Push 실패율, Pull 응답 시간과 패치 크기, 클라이언트 대기 mutation 수를 지표로 남겨 두면 문제를 빨리 찾을 수 있습니다.

증상별 점검

증상원인 후보대응
오프라인 후 복구 시 같은 변경이 계속 재전송됨서버가 거부한 mutation의 lastMutationID를 올리지 않음거부해도 ID는 올리기
변경이 새로고침 후 사라짐클라이언트와 서버 Mutator 구현 불일치공유 구현 또는 비교 테스트
다른 계정의 데이터가 보임사용자와 무관한 name 사용name에 사용자 ID 포함, 로그아웃 시 정리
Pull 응답이 점점 커짐쿠키를 무시하고 전체 상태 전송버전 기반 증분 패치

같이 보면 좋은 글