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 등 어떤 소스에서든 데이터를 채워 넣을 수 있습니다.
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 기준) 응답이 가능한 이유가 바로 이것입니다.
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 — 트랜잭션 단위 롤백
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된다는 개념을 이해하고 나니 선택 기준이 명확해졌습니다.
// 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 스키마 자체는 바꾸지 않아도 됩니다. 클라이언트 코드는 굉장히 간결합니다.
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 쿼리의 타입 추론이 정확하게 작동합니다.
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년 기준 주요 브라우저에서 지원하지만, 구형 환경 대응이 필요하다면 별도 검토가 필요합니다.
실무에서 가장 흔한 실수
-
TanStack Query 없이 쓰려는 시도: TanStack DB는 독립적으로 동작하지 않습니다.
@tanstack/react-query와 함께 설치해야 하며,QueryClientProvider도 여전히 필요합니다. 이 전제를 모르고 시작하면 초반에 원인 파악이 어려운 에러를 마주하게 됩니다. -
모든 데이터에 Eager 모드 적용: 행 수가 많은 테이블에 기본 Eager 모드를 그대로 두면 초기 로드 시 불필요한 데이터를 전부 가져옵니다. 50k 행 이상이라면 On-Demand를, 협업 앱이라면 Progressive 모드를 검토해보시면 좋습니다.
-
컬렉션 범위를 너무 넓게 잡는 것: 하나의 거대한 컬렉션에 모든 걸 넣으려 하면 오히려 관리가 복잡해집니다. 도메인 모델 단위로 컬렉션을 분리하고 Includes로 관계를 표현하는 패턴이 훨씬 유지보수하기 좋습니다.
마치며
이제 여러분은 낙관적 업데이트가 실패했을 때 롤백 로직을 별도로 작성하지 않고도 안전한 mutation을 구현할 수 있습니다. 컬렉션 기반 트랜잭션이 그 부담을 대신 짊어집니다.
지금 바로 시작해볼 수 있는 3단계:
-
패키지 설치 —
pnpm add @tanstack/db @tanstack/query-collection @tanstack/react-query로 필요한 패키지를 추가하고, 기존 TanStack Query 프로젝트에 컬렉션 하나를 얹어보는 것부터 시작할 수 있습니다. -
낙관적 업데이트 하나 교체 — 현재 프로젝트에서
onMutate·onError·onSettled세트가 가장 복잡하게 얽혀있는 mutation 하나를useOptimisticMutation으로 바꿔보시면 좋습니다. 롤백 로직이 얼마나 간결해지는지 체감하면 나머지는 자연스럽게 따라옵니다. -
컬렉션 하나에 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