개인정보처리방침© 2026 DEV BAK - 기술블로그. All rights reserved.
DEV BAK - 기술블로그
frontend

TanStack DB 실전 도입: 클라이언트 사이드 DB가 낙관적 업데이트를 어떻게 바꾸는가

솔직히 "클라이언트 사이드 임베디드 DB"라는 말을 처음 들었을 때 '또 유행어겠지' 싶었습니다. TanStack Query가 이미 서버 상태 관리를 잘 해주고 있는데, 그 위에 DB를 하나 더 올린다는 게 무슨 의미인가 싶었거든요.

그런데 TanStack Query에서 낙관적 업데이트를 복잡하게 구현해본 경험이 있다면 이 고통을 아실 겁니다. onMutate에서 이전 데이터 스냅샷 저장, onError에서 롤백 처리, onSettled에서 캐시 무효화... 쿼리 하나라면 그나마 견딜 만한데, 서로 엮인 쿼리가 두세 개만 돼도 코드가 걷잡을 수 없이 복잡해집니다. TanStack DB는 이 문제를 컬렉션 기반 트랜잭션으로 통째로 해결합니다. 이 글을 읽고 나면 낙관적 업데이트 실패 시 롤백 로직을 별도로 작성할 필요가 없어집니다.

이 글은 TanStack Query를 한 번이라도 써본 분을 대상으로 합니다. 아직 Query가 낯설다면 Query 기초를 먼저 다지고 오시는 게 좋습니다. 모든 코드 예시는 v0.6 기준입니다.

TL;DR — 낙관적 업데이트 자동 롤백 + 서브밀리초 Live Query + TanStack Query 위에 점진적 도입. 독립 실행 불가, TanStack Query 병용 필수.


핵심 개념

잠깐, 시작 전에 — TanStack DB는 TanStack Query 없이 단독으로 동작하지 않습니다. @tanstack/react-query와 QueryClientProvider가 반드시 필요합니다. "Query 대신 쓰는 것"이 아니라 Query 위에 올라가는 레이어입니다.

Query 캐시 vs. DB 컬렉션 — 뭐가 다른가

저도 처음에 "결국 똑같은 캐시 아닌가?"라고 생각했습니다. 그런데 내부 구조를 들여다보면 꽤 다릅니다.

TanStack Query 캐시 TanStack DB 컬렉션
저장 구조 Map<queryKey, data> 형태의 단순 캐시 정규화된 레코드 인덱스
쿼리 방식 queryKey 단위로 전체 교체 필드 단위 필터·정렬·조인 가능
업데이트 쿼리 전체를 무효화해 재페칭 변경된 레코드만 증분 업데이트
관계 표현 각 쿼리 독립, 관계 표현 없음 Includes로 컬렉션 간 관계 쿼리 가능

Query는 서버 통신(fetching, caching, revalidation)을 담당하고, DB는 그 데이터를 받아 로컬 쿼리 엔진에 정규화해서 저장합니다. 둘 다 필요한 이유가 바로 여기에 있습니다.

Collections — 타입이 붙은 로컬 테이블

Collection은 TanStack DB의 기본 단위입니다. DB 테이블이라고 생각하면 편한데, REST, GraphQL, tRPC, Electric SQL, PowerSync 등 어떤 소스에서든 데이터를 채워 넣을 수 있습니다.

typescript
import { createCollection } from '@tanstack/db'
import { queryCollectionOptions } from '@tanstack/query-collection'
 
const todosCollection = createCollection(
  queryCollectionOptions({
    queryKey: ['todos'],
    queryFn: () => fetch('/api/todos').then(r => r.json()),
    getId: (todo) => todo.id,
  })
)

여러 컴포넌트가 같은 컬렉션을 구독해도 네트워크 요청은 단 한 번만 발생합니다. TanStack Query의 queryKey 기반 캐싱 원리를 그대로 쓰면서, 그 위에 정규화된 인덱스를 올리는 구조입니다.

Live Queries — 변경된 부분만 증분 업데이트

Live Query가 TanStack DB의 핵심 차별점입니다. 내부적으로 d2ts라는 TypeScript Differential Dataflow 구현체를 사용하는데, 이 라이브러리는 TanStack DB 패키지에 번들되어 있어서 별도 설치는 필요 없습니다.

저도 처음엔 "Differential Dataflow"가 뭔 소린가 싶었는데, 한 줄로 설명하면 이렇습니다: 쿼리 결과가 바뀌었을 때 전체를 다시 계산하지 않고 변경된 부분만 찾아서 전파합니다. 100k 행 컬렉션에서 단일 행 하나를 업데이트했을 때 ~0.7ms(M1 Pro 기준) 응답이 가능한 이유가 바로 이것입니다.

typescript
function ActiveTodos() {
  const { data, isPending, error } = useLiveQuery((q) =>
    q.from({ todos: todosCollection })
     .where(({ todos }) => eq(todos.status, 'active'))
     .orderBy(({ todos }) => todos.createdAt)
  )
 
  if (isPending) return <div>로딩 중...</div>
  if (error) return <div>오류가 발생했습니다: {error.message}</div>
 
  return (
    <ul>
      {data.map(t => <li key={t.id}>{t.title}</li>)}
    </ul>
  )
}

isPending과 error는 실무 코드에서 반드시 다루게 되는 상태입니다. 이 패턴을 기본으로 익혀두면 나중에 고생할 일이 줄어듭니다.

Optimistic Mutations — 트랜잭션 단위 롤백

typescript
const { mutate } = useOptimisticMutation({
  mutationFn: (todo) => fetch(`/api/todos/${todo.id}`, {
    method: 'PATCH',
    body: JSON.stringify(todo),
  }),
  onMutate: (newTodo) => {
    // 서버 응답 전에 로컬 컬렉션 즉시 반영
    todosCollection.update(newTodo)
  },
  // 실패 시 onMutate에서 적용된 변경이 자동으로 롤백됩니다
})

TanStack Query에서 onMutate → onError → onSettled 세트로 작성하던 낙관적 업데이트 코드가 onMutate 하나로 줄어듭니다. 롤백은 컬렉션 트랜잭션 단위로 자동 처리됩니다.


실전 적용

예시 1: 동기화 모드 — 데이터 규모에 맞는 로드 전략

v0.5에서 도입된 Query-Driven Sync는 컬렉션에 데이터를 얼마나, 어떤 방식으로 로드할지를 세 가지 모드로 제어합니다.

모드 동작 방식 적합한 시나리오
Eager (기본) 컬렉션 전체를 사전 로드 10k 행 미만의 정적 참조 데이터
On-Demand 쿼리가 요청한 서브셋만 로드 50k+ 행, 검색·카탈로그 UI
Progressive 쿼리 서브셋 먼저 로드 후 전체를 백그라운드에서 싱크 협업 앱, 즉시 첫 화면 + 이후 서브밀리초 쿼리

저는 처음에 기본 Eager 모드로 상품 50k개를 전부 불러왔다가 초기 로드가 8초 가까이 걸리는 경험을 했습니다. 그제서야 On-Demand로 마이그레이션했고, 쿼리 조건이 서버로 push-down된다는 개념을 이해하고 나니 선택 기준이 명확해졌습니다.

typescript
// On-Demand 모드: 50k+ 상품 카탈로그
const productsCollection = createCollection(
  queryCollectionOptions({
    mode: 'on-demand',
    queryFn: (params) =>
      fetch(`/api/products?${params}`).then(r => r.json()),
    getId: (p) => p.id,
  })
)
 
// Live Query의 filter 조건이 서버 로더로 push-down됩니다
function ProductSearch({ keyword }: { keyword: string }) {
  const { data, isPending } = useLiveQuery((q) =>
    q.from({ products: productsCollection })
     .where(({ products }) => like(products.name, `%${keyword}%`))
     .limit(20)
  )
 
  if (isPending) return <ProductSkeleton />
  return <ProductGrid items={data} />
}

예시 2: Electric SQL로 Postgres 실시간 Sync

백엔드 Postgres 변경사항을 클라이언트로 스트리밍하고 싶을 때 Electric SQL 어댑터를 사용할 수 있습니다. Electric SQL 자체는 Postgres 앞단에 별도 서버 설정이 필요하지만, Postgres 스키마 자체는 바꾸지 않아도 됩니다. 클라이언트 코드는 굉장히 간결합니다.

typescript
import { createCollection } from '@tanstack/db'
import { electricCollectionOptions } from '@tanstack/electric-db-collection'
 
// queryCollectionOptions 기반 컬렉션과 구분하기 위해 이름을 명확히 합니다
const todosElectricCollection = createCollection(
  electricCollectionOptions({
    url: 'https://api.example.com/electric',
    params: { table: 'todos' },
    getId: (t) => t.id,
  })
)
 
function TodoList() {
  const { data } = useLiveQuery((q) =>
    q.from({ todos: todosElectricCollection })
     .orderBy(({ todos }) => todos.createdAt, 'desc')
  )
 
  return <ul>{data.map(t => <li key={t.id}>{t.title}</li>)}</ul>
}

Postgres에서 row가 변경되면 Electric을 통해 클라이언트 컬렉션으로 스트리밍되고, Live Query가 연결된 컴포넌트가 자동으로 업데이트됩니다. 폴링 없이, WebSocket 관리 코드 없이요.

예시 3: v0.6 Includes로 계층형 데이터 다루기

실무에서 JOIN이 필요한 상황은 정말 자주 맞닥뜨리게 됩니다. v0.6의 Includes는 정규화된 컬렉션을 UI 계층 구조 그대로 projection할 수 있게 해줍니다. 타입 파라미터를 명시해두면 Includes 쿼리의 타입 추론이 정확하게 작동합니다.

typescript
interface User {
  id: string
  name: string
  isActive: boolean
}
 
interface Todo {
  id: string
  userId: string
  title: string
}
 
const usersCollection = createCollection<User>(
  queryCollectionOptions({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(r => r.json()),
    getId: (u) => u.id,
  })
)
 
const todosCollection = createCollection<Todo>(
  queryCollectionOptions({
    queryKey: ['todos'],
    queryFn: () => fetch('/api/todos').then(r => r.json()),
    getId: (t) => t.id,
  })
)
 
function UserTodoList() {
  const { data } = useLiveQuery((q) =>
    q.from({ users: usersCollection })
     .include('todos', (q) =>
       q.from({ todos: todosCollection })
        .where(({ todos }, { users }) => eq(todos.userId, users.id))
     )
     .where(({ users }) => eq(users.isActive, true))
  )
 
  // data는 { ...User, todos: Todo[] }[] 형태로 타입이 추론됩니다
  return (
    <div>
      {data.map(user => (
        <div key={user.id}>
          <h3>{user.name}</h3>
          <ul>{user.todos.map(t => <li key={t.id}>{t.title}</li>)}</ul>
        </div>
      ))}
    </div>
  )
}

$synced · $origin 가상 프로퍼티 — 저도 처음엔 이게 뭔가 싶었는데, WhatsApp 메시지의 ✓, ✓✓ 전송 상태 표시를 구현한다고 생각하면 됩니다. $synced는 해당 레코드가 서버에 동기화됐는지를, $origin은 데이터 출처를 추적합니다. 아웃박스 UI를 별도 상태 관리 없이 네이티브로 구현할 수 있게 해줍니다.


장단점 분석

장점

항목 내용
서브밀리초 쿼리 Differential Dataflow 덕에 100k 행에서도 단일 행 업데이트 시 ~0.7ms 응답 (M1 Pro 기준)
낙관적 UX 신뢰성 트랜잭션 단위 자동 롤백으로 복잡한 낙관적 업데이트 패턴이 크게 단순화됨
점진적 도입 기존 TanStack Query 코드를 한 번에 바꿀 필요 없이 컬렉션 단위로 마이그레이션 가능
소스 무관 REST, GraphQL, tRPC, Electric, PowerSync 등 어떤 소스도 컬렉션으로 래핑 가능
오프라인 퍼스트 (v0.6+) SQLite 백엔드로 앱 재시작 후에도 로컬 데이터 유지, 재연결 시 sync 재개

단점 및 주의사항

항목 내용 대응 방안
아직 실험적 2026년 현재도 API가 빠르게 변화 중 주변부 도메인에서 파일럿 후 핵심 도메인으로 확장
TanStack Query 병용 필수 독립 동작 불가, 두 라이브러리를 함께 이해해야 함 Query 기초가 탄탄하다면 진입 장벽이 크게 줄어듦
학습 곡선 Differential Dataflow 개념, sync 모드 선택 등 Eager 모드 + 기본 Live Query부터 시작해 필요에 따라 확장
단순한 앱에는 과잉 단순 CRUD는 TanStack Query만으로 충분 복잡한 관계형 UI, 실시간·오프라인 요구사항이 있을 때 도입 검토
SQLite 의존성 (v0.6+) 브라우저 OPFS 기반 SQLite는 지원 범위 및 초기 로드 비용 별도 검토 필요 caniuse.com에서 OPFS 지원 현황 확인, 필요 시 메모리 모드 폴백 고려

OPFS(Origin Private File System)란? 저도 처음엔 낯선 API였는데, 브라우저가 제공하는 샌드박스 파일 시스템입니다. 웹 환경에서 SQLite를 파일 기반으로 실행하기 위해 사용됩니다. 2026년 기준 주요 브라우저에서 지원하지만, 구형 환경 대응이 필요하다면 별도 검토가 필요합니다.

실무에서 가장 흔한 실수

  1. TanStack Query 없이 쓰려는 시도: TanStack DB는 독립적으로 동작하지 않습니다. @tanstack/react-query와 함께 설치해야 하며, QueryClientProvider도 여전히 필요합니다. 이 전제를 모르고 시작하면 초반에 원인 파악이 어려운 에러를 마주하게 됩니다.

  2. 모든 데이터에 Eager 모드 적용: 행 수가 많은 테이블에 기본 Eager 모드를 그대로 두면 초기 로드 시 불필요한 데이터를 전부 가져옵니다. 50k 행 이상이라면 On-Demand를, 협업 앱이라면 Progressive 모드를 검토해보시면 좋습니다.

  3. 컬렉션 범위를 너무 넓게 잡는 것: 하나의 거대한 컬렉션에 모든 걸 넣으려 하면 오히려 관리가 복잡해집니다. 도메인 모델 단위로 컬렉션을 분리하고 Includes로 관계를 표현하는 패턴이 훨씬 유지보수하기 좋습니다.


마치며

이제 여러분은 낙관적 업데이트가 실패했을 때 롤백 로직을 별도로 작성하지 않고도 안전한 mutation을 구현할 수 있습니다. 컬렉션 기반 트랜잭션이 그 부담을 대신 짊어집니다.

지금 바로 시작해볼 수 있는 3단계:

  1. 패키지 설치 — pnpm add @tanstack/db @tanstack/query-collection @tanstack/react-query로 필요한 패키지를 추가하고, 기존 TanStack Query 프로젝트에 컬렉션 하나를 얹어보는 것부터 시작할 수 있습니다.

  2. 낙관적 업데이트 하나 교체 — 현재 프로젝트에서 onMutate·onError·onSettled 세트가 가장 복잡하게 얽혀있는 mutation 하나를 useOptimisticMutation으로 바꿔보시면 좋습니다. 롤백 로직이 얼마나 간결해지는지 체감하면 나머지는 자연스럽게 따라옵니다.

  3. 컬렉션 하나에 Live Query 연결 — 화면에서 실시간 업데이트가 필요한 목록 하나를 선택해 useLiveQuery로 연결해보시면 됩니다. isPending과 error 상태를 함께 처리하면 즉시 실무에서 쓸 수 있는 패턴이 완성됩니다.


참고 자료

  • TanStack DB Overview | TanStack 공식 문서
  • TanStack DB Quick Start | TanStack 공식 문서
  • Live Queries | TanStack 공식 문서
  • TanStack DB 0.6 — App-Ready with Persistence and Includes | TanStack Blog
  • Local-First Sync with TanStack DB | Electric SQL
  • How to use TanStack DB to build reactive, offline-ready React apps | LogRocket
#TanStackDB#TanStackQuery#낙관적업데이트#LiveQuery#DifferentialDataflow#ElectricSQL#오프라인퍼스트#React#TypeScript#클라이언트사이드DB
공유하기

목차

핵심 개념Query 캐시 vs. DB 컬렉션 — 뭐가 다른가Collections — 타입이 붙은 로컬 테이블Live Queries — 변경된 부분만 증분 업데이트Optimistic Mutations — 트랜잭션 단위 롤백실전 적용예시 1: 동기화 모드 — 데이터 규모에 맞는 로드 전략예시 2: Electric SQL로 Postgres 실시간 Sync예시 3: v0.6 Includes로 계층형 데이터 다루기장단점 분석장점단점 및 주의사항실무에서 가장 흔한 실수마치며참고 자료

추천 포스트

엣지 MFE 오케스트레이션으로 TTFB를 줄이는 Cloudflare Workers 구현법
frontend

엣지 MFE 오케스트레이션으로 TTFB를 줄이는 Cloudflare Workers 구현법

솔직히 처음 마이크로 프론트엔드(MFE) 이야기를 들었을 때, "팀마다 독립 배포가 된다고? 좋아 보이는데, 그럼 사용자 브라우저에서 각자 번들을 다 받아야 하는 거 아냐?" 하는 생각부터 들었습니다. 클라이언트 사이드 컴포지션의 고질적인 문제, 그러니까 여러 MFE의 JS 번들이 순차...

2026년 06월 23일읽는 데 23분
EAA 발효 후 프론트엔드 접근성 점검 체크리스트 15가지 (유럽 접근성법 WCAG 2.1 AA 기준)
frontend

EAA 발효 후 프론트엔드 접근성 점검 체크리스트 15가지 (유럽 접근성법 WCAG 2.1 AA 기준)

2025년 6월 28일, 슬랙에서 흘러온 메시지를 보다가 멈췄습니다. "EAA 발효됐는데, 우리 서비스 괜찮아?" 처음엔 "EU 법이니까 유럽에서 직접 사업하는 회사 얘기겠지" 싶었는데, 역외 적용 조항을 읽고 나서 생각이 달라졌습니다. EU 고객에게 서비스를 제공하는 기업이라면 본사가...

2026년 06월 26일읽는 데 26분
CSS Anchor Positioning으로 Popper.js 제거하기 — 브라우저가 직접 처리하는 플로팅 UI
frontend

CSS Anchor Positioning으로 Popper.js 제거하기 — 브라우저가 직접 처리하는 플로팅 UI

프론트엔드를 하다 보면 어느 순간 이런 생각을 하게 됩니다. "버튼 아래에 드롭다운 하나 붙이는 게 왜 이렇게 복잡하지?" 몇 년 전 팀 내 디자인 시스템을 처음 만들 때, Floating UI 셋업과 뷰포트 플립 로직 디버깅에 하루를 통째로 날린 적이 있었습니다. 지금 돌아보면 그 복...

2026년 06월 26일읽는 데 21분
Tailwind v4 @container — 사이드바에서도 그리드에서도 깨지지 않는 반응형 컴포넌트
frontend

Tailwind v4 @container — 사이드바에서도 그리드에서도 깨지지 않는 반응형 컴포넌트

반응형 UI 작업을 하다 보면 어느 순간 이런 벽에 부딪힙니다. 공들여 만든 카드 컴포넌트가 메인 피드에서는 멀쩍멀쩍 잘 동작하는데, 똑같은 코드를 사이드바에 가져다 놓으니 이미지와 텍스트가 뭉개지는 상황이요. 브레이크포인트를 더 잘게 쪼개봐도, 뷰포트를 기준으로 판단하는 미디어 쿼리는...

2026년 06월 23일읽는 데 18분
린팅이 62배 빨라지면 개발 습관이 달라진다 — ESLint에서 Oxlint로, Vite 프로젝트 마이그레이션 경험
frontend

린팅이 62배 빨라지면 개발 습관이 달라진다 — ESLint에서 Oxlint로, Vite 프로젝트 마이그레이션 경험

린터가 느리다고 느낀 적 있으신가요? 저도 그랬습니다. 중간 규모 React 프로젝트에서 를 돌릴 때마다 30초 가까이 기다리는 게 당연한 줄 알았는데, Oxlint로 바꾸고 나서 499ms가 나오는 걸 보고 처음엔 "설정이 잘못된 거 아닌가?" 의심했을 정도입니다. 공식 벤치마크 수치...

2026년 06월 23일읽는 데 19분
CSS @layer로 마이크로 프론트엔드 스타일 충돌 잡기 | Cascade Layers 격리 전략
frontend

CSS @layer로 마이크로 프론트엔드 스타일 충돌 잡기 | Cascade Layers 격리 전략

마이크로 프론트엔드(MFE, Micro Frontend) 프로젝트를 처음 진행했을 때, 팀A가 배포한 버튼 스타일이 팀B의 화면에서 멋대로 바뀌어 있는 걸 보고 꽤 당황했던 기억이 있습니다. 그 시절엔 CSS Modules로 클래스명 해시를 만들거나, 선택자 앞에 팀 prefix를 붙이...

2026년 06월 23일읽는 데 22분