쿼리 컬렉션
쿼리 컬렉션은 TanStack DB와 TanStack Query를 원활하게 통합하여 로컬 데이터베이스와 원격 데이터 소스 간의 자동 동기화를 지원합니다.
개요
@tanstack/query-db-collection 패키지를 사용하면 다음과 같은 컬렉션을 만들 수 있습니다:
- TanStack Query를 통해 원격 데이터를 자동으로 가져옵니다
- 오류 발생 시 자동 롤백을 지원하는 낙관적 업데이트를 지원합니다
- 사용자 지정 가능한 뮤테이션 핸들러를 통해 영속성을 처리합니다
- 동기화 저장소에 직접 쓰는 기능을 제공합니다
설치
npm install @tanstack/query-db-collection @tanstack/query-core @tanstack/db
기본 사용법
import { QueryClient } from "@tanstack/query-core"
import { DbClient, collectionOptions } from "@tanstack/db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"
const queryClient = new QueryClient()
const db = new DbClient({ queryClient })
const todosCollection = collectionOptions("todos", (client) =>
queryCollectionOptions({
id: "todos",
queryKey: ["todos"],
queryFn: async () => {
const response = await fetch("/api/todos")
return response.json()
},
queryClient: client.requireDependency<QueryClient>("queryClient"),
getKey: (item) => item.id,
})
)
const todos = db.collection(todosCollection)
구성 옵션
queryCollectionOptions 함수는 다음 옵션을 허용합니다:
필수 옵션
queryKey: TanStack Query의 쿼리 키입니다. 정적 배열이거나LoadSubsetOptions를 받아 키를 반환하는 함수일 수 있습니다. 함수를 사용할 때 반환되는 모든 키는 기본 키(queryKey({}))를 접두사로 공유해야 합니다 — 쿼리 키 접두사 규칙을 참조할 수 있습니다.queryFn: 서버에서 데이터를 가져오는 함수입니다queryClient: TanStack Query 클라이언트 인스턴스입니다getKey: 항목에서 고유 키를 추출하는 함수입니다
요청 범위 QueryClient
queryCollectionOptions에는 queryClient가 필요합니다. SSR, TanStack Start, 테스트 또는 멀티 테넌트 앱에서는 해당 클라이언트가 모듈 전역이 아니라 요청별로 존재합니다.
DbClient에 배치한 다음 컬렉션 설명자 팩토리 내부에서 이를 확인합니다:
import { QueryClient } from "@tanstack/query-core"
import { DbClient, collectionOptions } from "@tanstack/db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"
interface Todo {
id: string
title: string
}
export const todoCollection = collectionOptions("todos", (client) =>
queryCollectionOptions<Todo>({
id: "todos",
queryKey: ["todos"],
queryFn: async () => {
const response = await fetch("/api/todos")
return response.json() as Promise<Array<Todo>>
},
queryClient: client.requireDependency<QueryClient>("queryClient"),
getKey: (todo) => todo.id,
})
)
export function createRequestClients() {
const queryClient = new QueryClient()
const dbClient = new DbClient({ queryClient })
return { queryClient, dbClient }
}
dbClient.collection(todoCollection)는 해당 설명자와 클라이언트에 대해 하나의 컬렉션 인스턴스를 메모이제이션합니다. 두 번째 DbClient는 새로운 어댑터 상태를 구체화하고 자체 QueryClient를 사용합니다.
queryClient를 queryCollectionOptions에 직접 전달하는 방식은 createCollection(...) 및 기존 앱에서 계속 지원됩니다. 설명자가 구체화될 때는 명시적인 DbClient 종속성이 우선하며, 구성된 queryClient는 이전 버전과의 호환성을 위한 대체 수단입니다.
비즈니스 범위 컬렉션 팩토리
테넌트, 프로젝트, 계정 또는 라우트 매개변수는 비즈니스 범위를 정의할 수 있습니다. 이는 컬렉션이 나타내는 서버 리소스입니다. 이 범위를 설명자 id, Query 키 및 queryFn에 포함합니다. 이는 요청 범위 QueryClient 패턴을 명시적인 범위 매개변수로 확장합니다:
interface Todo {
id: string
title: string
projectId: string
}
async function fetchProjectTodos(projectId: string): Promise<Array<Todo>> {
const response = await fetch(`/api/projects/${projectId}/todos`)
return response.json()
}
function createProjectTodosDescriptor(
projectId: string,
) {
return collectionOptions(`project:${projectId}:todos`, (client) =>
queryCollectionOptions<Todo>({
id: `project:${projectId}:todos`,
queryKey: ["projects", projectId, "todos"],
queryFn: () => fetchProjectTodos(projectId),
queryClient: client.requireDependency<QueryClient>("queryClient"),
getKey: (todo) => todo.id,
})
)
}
범위는 설명자 식별자의 일부입니다. DbClient는 동일한 id를 가진 별도로 생성된 설명자를 동일한 컬렉션으로 확인하므로, React 훅은 현재 매개변수에서 설명자를 생성할 수 있습니다:
export function useProjectTodos(projectId: string) {
return useDbClient().collection(createProjectTodosDescriptor(projectId))
}
id에 대한 첫 번째 설명자만 구체화됩니다. 컬렉션을 변경하는 모든 범위 값을 설명자 id와 Query 키에 모두 포함합니다. 클라이언트 범위가 종료되면 await dbClient.cleanup()를 호출합니다.
비즈니스 범위는 라이브 쿼리가 요청하는 관계형 부분 집합과 별개입니다. syncMode: "on-demand"를 사용할 때 LoadSubsetOptions는 비즈니스 범위가 같은 단일 컬렉션 안에서 조건자, 정렬, 제한 및 오프셋을 설명합니다. 이러한 옵션은 queryFn을 대상으로 하며 ctx.meta.loadSubsetOptions을 통해 전달되어 부분 집합 Query 키를 결정합니다. QueryFn 및 조건자 푸시다운을 참조할 수 있습니다.
각 where, orderBy 또는 limit에 대해 컬렉션을 생성하면 안 됩니다. 비즈니스 범위 컬렉션을 재사용하고 필요 시 로딩으로 해당 부분 집합을 나타냅니다. 서로 다른 서버 리소스에 대해서만 별도의 컬렉션을 생성합니다.
쿼리 옵션
Query 컬렉션은 내부적으로 TanStack Query를 사용하며 지원되는 Query 옵저버 옵션을 최상위 queryCollectionOptions 필드로 노출합니다.
다음 최상위 Query 컬렉션 옵션은 기본 Query 옵저버로 전달됩니다:
select: 래핑된 Query 응답에서 TanStack DB가 구체화하는 행 배열을 추출하는 함수enabled: 쿼리를 자동으로 실행할지 여부(기본값:true)refetchInterval: 밀리초 단위의 다시 가져오기 간격retry: 실패한 쿼리의 재시도 구성retryDelay: 재시도 사이의 지연 시간staleTime: 데이터가 최신 상태로 간주되는 시간gcTime: 사용되지 않는 쿼리 데이터가 Query 캐시에 유지되는 시간refetchOnWindowFocus: 창이 다시 포커스를 얻을 때 다시 가져올지 여부refetchOnReconnect: 네트워크가 다시 연결될 때 다시 가져올지 여부refetchOnMount: 옵저버가 마운트될 때 다시 가져올지 여부networkMode: Query 네트워크 모드initialData: 즉시 동기화 컬렉션의 초기 Query 응답initialDataUpdatedAt: TanStack Query가 초기 데이터의 최신 상태를 판단하는 데 사용하는 타임스탬프meta: 쿼리 함수 컨텍스트에 전달되는 메타데이터입니다. Query 컬렉션은 필요 시 쿼리에loadSubsetOptions를 추가할 수 있습니다.
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: fetchTodos,
queryClient,
getKey: (todo) => todo.id,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchOnMount: "always",
networkMode: "online",
})
)
최상위 meta는 Query 컬렉션이 필요 시 loadSubsetOptions를 추가할 수 있도록 항상 병합됩니다. 지원되는 다른 최상위 Query 옵션은 정의한 경우에만 TanStack Query에 전달됩니다. 생략하면 QueryClient.defaultOptions가 계속 적용될 수 있습니다.
일부 필드는 일반적인 Query 옵션 전달로 처리되지 않고 컬렉션 어댑터가 소유하거나 재해석합니다:
queryKey: Query 캐시 항목을 식별하며, 필요 시 모드에서는 부분 집합 로드 옵션으로 구성될 수 있습니다.queryFn: 전체 컬렉션 상태 또는 요청된 필요 시 부분 집합을 가져옵니다.select: 래핑된 응답에서 배열 행을 추출한 후 컬렉션에 저장합니다. 이는 TanStack Query의select옵션과 동일한 계약이 아닙니다.queryClient: 컬렉션이 사용하는 Query 클라이언트 인스턴스를 제공합니다.syncMode: 컬렉션을 즉시 동기화할지 필요 시 동기화할지 제어합니다.getKey: 각 행의 안정적인 TanStack DB 키를 추출합니다.onInsert,onUpdate및onDelete와 같은 뮤테이션 핸들러입니다.
일부 TanStack Query 필드는 Query 컬렉션이 소유하거나 재해석하므로 일반적인 Query 옵저버 옵션으로 의도적으로 노출되지 않습니다:
queryKey,queryFn및queryClientselect(Query 컬렉션은 이를 행 추출에 사용하며 TanStack Query의 옵저버 수준select계약에는 사용하지 않습니다)meta(필요 시loadSubsetOptions를 포함할 수 있도록 Query 컬렉션이 병합합니다)subscribed(Query 컬렉션이 옵저버 구독 수명 주기를 소유합니다)structuralSharing및notifyOnChangeProps(Query 컬렉션 동기화가 관리합니다)
placeholderData는 의도적으로 지원되지 않습니다. TanStack Query는 자리 표시자 데이터를 캐시된 Query 데이터가 아닌 옵저버 로컬 프레젠테이션 상태로 처리합니다. 이를 구체화하면 임시 UI 데이터가 컬렉션 전체의 정규화된 행으로 노출됩니다. 대신 사용하는 UI에서 자리 표시자를 렌더링합니다.
QueryFunctionContext.signal을 사용한 요청 취소
TanStack Query는 AbortSignal을 쿼리 함수 컨텍스트를 통해 queryFn에 전달합니다.
요청을 취소할 수 있도록 ctx.signal을 fetch 또는 취소 가능한 다른 클라이언트에 전달합니다:
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: async (ctx) => {
const response = await fetch("/api/todos", {
signal: ctx.signal,
})
if (!response.ok) {
throw new Error("Failed to fetch todos")
}
return response.json() as Promise<Array<Todo>>
},
queryClient,
getKey: (todo) => todo.id,
}),
)
명시적인 컬렉션 정리는 Query 캐시에서 제거하기 전에 컬렉션이 현재 추적 중인 각 정확한 Query 키를 취소합니다:
await todosCollection.cleanup()
기본 요청은 해당 클라이언트가 ctx.signal를 소비할 때만 중단됩니다. 신호를 무시하는 클라이언트는 컬렉션이 정리된 후에도 요청을 계속할 수 있습니다.
로드되지 않은 필요 시 부분 집합은 더 이상 추적되지 않습니다. 이후의 명시적인 컬렉션 정리는 해당 Query 키를 다시 확인하지 않습니다.
Query 캐시 항목은 하나의 QueryClient 내에서 공유됩니다. 명시적인 정리는 동일한 정확한 Query 키를 사용하는 다른 소비자에게 영향을 줄 수 있습니다.
필요 시 부분 집합을 언로드해도 queryClient.cancelQueries()를 명시적으로 호출하지 않습니다. 부분 집합의 Query 옵저버를 제거합니다. 이것이 마지막 옵저버이고 쿼리 함수가 ctx.signal를 소비했다면 TanStack Query가 요청을 중단합니다. 신호가 무시되었거나 다른 옵저버가 동일한 정확한 Query 키를 계속 사용하면 요청이 완료되고 gcTime까지 캐시된 상태로 남을 수 있습니다.
queryOptions(...)과 함께 사용하기
앱에서 이미 TanStack Query의 queryOptions 헬퍼(예: @tanstack/react-query)를 사용하는 경우 호환되는 최상위 옵션을 queryCollectionOptions에 전개할 수 있습니다. 쿼리 컬렉션은 타입과 런타임 모두에서 이를 필요로 하므로 queryFn를 명시적으로 제공해야 합니다. 또한 Query 컬렉션의 select 옵션은 TanStack Query 옵저버 수준 선택이 아니라 행 추출을 위한 옵션입니다:
import { QueryClient } from "@tanstack/query-core"
import { DbClient, collectionOptions } from "@tanstack/db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"
import { queryOptions } from "@tanstack/react-query"
const queryClient = new QueryClient()
const db = new DbClient({ queryClient })
const listOptions = queryOptions({
queryKey: ["todos"],
queryFn: async () => {
const response = await fetch("/api/todos")
return response.json() as Promise<Array<{ id: string; title: string }>>
},
})
const todosCollection = collectionOptions("todos", (client) =>
queryCollectionOptions({
id: "todos",
...listOptions,
queryFn: (context) => listOptions.queryFn!(context),
queryClient: client.requireDependency<QueryClient>("queryClient"),
getKey: (item) => item.id,
}),
)
const todos = db.collection(todosCollection)
런타임에 queryFn가 누락되면 queryCollectionOptions가 QueryFnRequiredError를 발생시킵니다.
초기 데이터
즉시 동기화 Query 컬렉션은 TanStack Query의 initialData 및 initialDataUpdatedAt 옵션을 지원합니다. 초기 데이터는 원래 Query 응답 형태를 가지며 Query 캐시에 저장되고 정규화된 컬렉션 행으로 즉시 구체화됩니다. TanStack Query는 initialDataUpdatedAt와 staleTime를 함께 사용하여 가져올지 결정합니다.
const serverRenderedAt = Date.now()
const initialTodos = [
{ id: "1", title: "Write documentation" },
{ id: "2", title: "Ship initial data support" },
]
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: fetchTodos,
queryClient,
getKey: (todo) => todo.id,
initialData: initialTodos,
initialDataUpdatedAt: serverRenderedAt,
staleTime: 60_000,
}),
)
기존에 캐시되었거나 하이드레이션된 Query 응답은 initialData보다 우선합니다. Query 키는 캐시 식별자로 유지됩니다. 동일한 QueryClient와 정확한 Query 키를 사용하는 두 컬렉션은 하나의 공유 Query 문서를 관찰하며, 이후 컬렉션의 초기화 함수는 이를 대체하지 않습니다. 독립적인 문서에는 서로 다른 Query 키를 사용합니다.
초기 데이터는 즉시 동기화 컬렉션에서만 지원됩니다. 컬렉션 전체 값으로는 임의의 필요 시 조건자, 정렬, 제한 및 오프셋에 대한 행 소속을 설정할 수 없습니다. syncMode: "on-demand"의 경우에는 정확한 파생 Query 캐시 항목을 대신 시드하거나 하이드레이션합니다.
오래된 초기 응답으로 인해 가져오기가 시작되면 가져오기가 진행되는 동안에도 초기 행을 사용할 수 있습니다. 성공한 응답은 일반 행 소유권 파이프라인을 통해 이를 조정하며, 오류가 발생하면 초기 행이 유지됩니다. 직접 쓰기는 가져온 데이터와 동일한 Query 캐시 패치 규칙을 사용하며, 이후 성공한 서버 응답이 해당 쓰기를 조정하거나 대체할 수 있습니다.
래핑된 응답에서 행 선택하기
많은 API는 페이지 매김 커서, 총계 또는 요청 정보와 같은 메타데이터도 포함하는 응답 래퍼 안에 행을 반환합니다. TanStack DB가 구체화해야 하는 행 배열을 추출하려면 select를 사용합니다:
interface TodosResponse {
items: Array<{ id: string; title: string }>
nextCursor?: string
total: number
}
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: async (): Promise<TodosResponse> => {
const response = await fetch("/api/todos")
return response.json()
},
initialData: {
items: [{ id: "1", title: "Initial todo" }],
nextCursor: undefined,
total: 1,
},
select: (response) => response.items,
queryClient,
getKey: (item) => item.id,
}),
)
select는 query-db-collection 행 추출 훅입니다. TanStack Query 캐시는 원래 Query 응답 형태를 유지하면서 TanStack DB가 구체화할 행을 지정합니다. 위 예제에서 queryClient.getQueryData(["todos"])는 TodosResponse 전체를 계속 반환하며, 여기에는 nextCursor 및 total이 포함됩니다.
동일한 프로젝션이 initialData에도 적용됩니다. 전체 응답 래퍼를 제공하면 Query 컬렉션은 select가 반환한 행을 구체화하면서 Query 캐시의 래퍼를 유지합니다.
이는 TanStack Query의 옵저버 수준 select와 다릅니다. query-db-collection은 이 옵션을 사용하여 Query의 응답 객체를 DB의 정규화된 행 저장소로 연결합니다.
writeInsert, writeUpdate 및 writeDelete와 같은 직접 쓰기 유틸리티는 래퍼 메타데이터를 유지하면서 래핑된 Query 캐시 항목 내부의 일치하는 행 배열을 최선의 방식으로 업데이트합니다.
이는 다음과 같은 단순한 래퍼에서 자동으로 작동합니다:
{ data: [...] }{ items: [...] }{ results: [...] }
select: (response) => response.edges.map((edge) => edge.node)와 같은 파생 프로젝션은 읽기 측 행 추출만 수행합니다. query-db-collection은 업데이트된 행에서 원래 응답 래퍼를 일반적으로 재구성할 수 없습니다. 파생 프로젝션에서 래핑된 캐시가 직접 쓰기를 정확히 반영해야 한다면 쿼리를 다시 가져오거나 무효화합니다.
컬렉션 옵션
id: 컬렉션의 고유 식별자schema: 항목 검증을 위한 스키마sync: 사용자 지정 동기화 구성startSync: 동기화를 즉시 시작할지 여부(기본값:true)
영속성 핸들러
onInsert: 삽입 작업 전에 호출되는 핸들러onUpdate: 업데이트 작업 전에 호출되는 핸들러onDelete: 삭제 작업 전에 호출되는 핸들러
사용자 지정 속성으로 Meta 확장하기
meta 옵션을 사용하면 쿼리 함수에 추가 메타데이터를 전달할 수 있습니다. 기본적으로 Query 컬렉션은 메타 객체에 loadSubsetOptions를 자동으로 포함하며, 여기에는 필요 시 쿼리를 위한 필터링, 정렬 및 페이지 매김 옵션이 포함됩니다.
타입 안전 Meta 액세스
ctx.meta.loadSubsetOptions 속성은 추가 가져오기나 타입 어설션 없이 LoadSubsetOptions로 자동 타입 지정됩니다:
import { parseLoadSubsetOptions } from "@tanstack/query-db-collection"
const collection = createCollection(
queryCollectionOptions({
queryKey: ["products"],
syncMode: "on-demand",
queryFn: async (ctx) => {
// ✅ Type-safe access - no @ts-ignore needed!
const options = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions)
// Use the parsed options to fetch only what you need
return api.getProducts(options)
},
queryClient,
getKey: (item) => item.id,
})
)
사용자 지정 Meta 속성 추가하기
TypeScript의 모듈 보강을 사용하여 메타 타입을 확장하고 고유한 사용자 지정 속성을 포함할 수 있습니다:
// In a global type definition file (e.g., types.d.ts or global.d.ts)
declare module "@tanstack/query-db-collection" {
interface QueryCollectionMeta {
// Add your custom properties here
userId?: string
includeDeleted?: boolean
cacheTTL?: number
}
}
인터페이스를 확장하면 애플리케이션 전체에서 사용자 지정 속성이 완전히 타입 지정됩니다:
const collection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: async (ctx) => {
// ✅ Both loadSubsetOptions and custom properties are typed
const { loadSubsetOptions, userId, includeDeleted } = ctx.meta
return api.getTodos({
...parseLoadSubsetOptions(loadSubsetOptions),
userId,
includeDeleted,
})
},
queryClient,
getKey: (item) => item.id,
// Pass custom meta alongside Query Collection defaults
meta: {
userId: "user-123",
includeDeleted: false,
},
})
)
중요 참고 사항
- 모듈 보강 패턴은 메타 타입 지정을 위한 TanStack Query의 공식 접근 방식을 따릅니다
QueryCollectionMeta는 타입 별칭이 아닌 인터페이스이므로 적절한 TypeScript 선언 병합이 가능합니다- 사용자 지정 속성은 기본
loadSubsetOptions속성과 병합됩니다 - 모든 메타 속성은
Record<string, unknown>와 호환되어야 합니다 - 보강은 TypeScript 컴파일에 포함되는 파일에서 수행해야 합니다
예제: API 요청 컨텍스트
일반적인 사용 사례는 쿼리 함수에 요청 컨텍스트를 전달하는 것입니다:
// types.d.ts
declare module "@tanstack/query-db-collection" {
interface QueryCollectionMeta {
authToken?: string
locale?: string
version?: string
}
}
// collections.ts
const productsCollection = createCollection(
queryCollectionOptions({
queryKey: ["products"],
queryFn: async (ctx) => {
const { loadSubsetOptions, authToken, locale, version } = ctx.meta
return api.getProducts({
...parseLoadSubsetOptions(loadSubsetOptions),
headers: {
Authorization: `Bearer ${authToken}`,
"Accept-Language": locale,
"API-Version": version,
},
})
},
queryClient,
getKey: (item) => item.id,
meta: {
authToken: session.token,
locale: "en-US",
version: "v1",
},
})
)
영속성 핸들러
뮤테이션이 발생할 때 호출되는 핸들러를 정의할 수 있습니다. 이러한 핸들러는 변경 사항을 백엔드에 영속화하고 작업 후 쿼리를 다시 가져올지 여부를 제어할 수 있습니다.
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: fetchTodos,
queryClient,
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
const newItems = transaction.mutations.map((m) => m.modified)
await api.createTodos(newItems)
// Returning nothing or { refetch: true } will trigger a refetch
// Return { refetch: false } to skip automatic refetch
},
onUpdate: async ({ transaction }) => {
const updates = transaction.mutations.map((m) => ({
id: m.key,
changes: m.changes,
}))
await api.updateTodos(updates)
},
onDelete: async ({ transaction }) => {
const ids = transaction.mutations.map((m) => m.key)
await api.deleteTodos(ids)
},
})
)
리페치 동작 제어
기본적으로 모든 영속성 핸들러(onInsert, onUpdate 또는 onDelete)가 성공적으로 완료되면 로컬 상태가 서버 상태와 일치하도록 쿼리가 자동으로 리페치됩니다.
객체를 반환하고 refetch 속성을 설정하여 이 동작을 제어할 수 있습니다:
onInsert: async ({ transaction }) => {
await api.createTodos(transaction.mutations.map((m) => m.modified))
// Skip the automatic refetch
return { refetch: false }
}
다음과 같은 경우에 유용합니다:
- 서버 상태가 전송한 내용과 일치한다고 확신하는 경우
- 불필요한 네트워크 요청을 피하려는 경우
- 다른 메커니즘(예: WebSocket)을 통해 상태 업데이트를 처리하는 경우
유틸리티 메서드
컬렉션은 collection.utils을 통해 다음 유틸리티 메서드를 제공합니다:
refetch(opts?): 쿼리의 리페치를 수동으로 트리거합니다opts.throwOnError: 리페치에 실패할 경우 오류를 발생시킬지 여부입니다(기본값:false)enabled: false을 우회하여 명령형/수동 리페치 패턴을 지원합니다(훅refetch()동작과 유사)- 결과를 검사할 수 있도록
QueryObserverResult을 반환합니다
직접 쓰기
직접 쓰기는 일반적인 쿼리/뮤테이션 흐름이 요구 사항에 맞지 않는 시나리오를 위한 것입니다. 동기화된 데이터 저장소에 직접 쓸 수 있으며, 낙관적 업데이트 시스템과 쿼리 리페치 메커니즘을 우회합니다.
데이터 저장소 이해하기
쿼리 컬렉션은 두 개의 데이터 저장소를 유지합니다:
- 동기화된 데이터 저장소 -
queryFn을 통해 서버와 동기화되는 권위 있는 상태 - 낙관적 뮤테이션 저장소 - 서버 확인 전에 낙관적으로 적용되는 임시 변경 사항
일반적인 컬렉션 작업(삽입, 업데이트, 삭제)은 다음과 같은 낙관적 뮤테이션을 생성합니다:
- UI에 즉시 적용됩니다
- 영속성 핸들러를 통해 서버로 전송됩니다
- 서버 요청이 실패하면 자동으로 롤백됩니다
- 쿼리가 리페치되면 서버 데이터로 대체됩니다
직접 쓰기는 이 시스템을 완전히 우회하여 동기화된 데이터 저장소에 직접 쓰므로, 대체 소스에서 실시간 업데이트를 처리하는 데 적합합니다.
직접 쓰기를 사용하는 경우
직접 쓰기는 다음과 같은 경우에 사용해야 합니다:
- WebSocket 또는 서버 전송 이벤트에서 실시간 업데이트를 동기화해야 하는 경우
- 모든 데이터를 다시 가져오는 비용이 너무 커서 대규모 데이터 세트를 처리하는 경우
- 증분 업데이트 또는 서버에서 계산된 필드 업데이트를 수신하는 경우
- 복잡한 페이지 매김 또는 부분 데이터 로드 시나리오를 구현해야 하는 경우
개별 쓰기 작업
// Insert a new item directly to the synced data store
todosCollection.utils.writeInsert({
id: "1",
text: "Buy milk",
completed: false,
})
// Update an existing item in the synced data store
todosCollection.utils.writeUpdate({ id: "1", completed: true })
// Delete an item from the synced data store
todosCollection.utils.writeDelete("1")
// Upsert (insert or update) in the synced data store
todosCollection.utils.writeUpsert({
id: "1",
text: "Buy milk",
completed: false,
})
이러한 작업은 다음과 같습니다:
- 동기화된 데이터 저장소에 직접 기록합니다
- 낙관적 뮤테이션을 생성하지 않습니다
- 자동 쿼리 다시 가져오기를 트리거하지 않습니다
- TanStack Query 캐시를 즉시 업데이트합니다
- UI에 즉시 표시됩니다
일괄 작업
writeBatch 메서드를 사용하면 여러 작업을 원자적으로 수행할 수 있습니다. 콜백 내에서 호출된 모든 쓰기 작업은 수집되어 단일 트랜잭션으로 실행됩니다:
todosCollection.utils.writeBatch(() => {
todosCollection.utils.writeInsert({ id: "1", text: "Buy milk" })
todosCollection.utils.writeInsert({ id: "2", text: "Walk dog" })
todosCollection.utils.writeUpdate({ id: "3", completed: true })
todosCollection.utils.writeDelete("4")
})
실제 예시: WebSocket 통합
// Handle real-time updates from WebSocket without triggering full refetches
ws.on("todos:update", (changes) => {
todosCollection.utils.writeBatch(() => {
changes.forEach((change) => {
switch (change.type) {
case "insert":
todosCollection.utils.writeInsert(change.data)
break
case "update":
todosCollection.utils.writeUpdate(change.data)
break
case "delete":
todosCollection.utils.writeDelete(change.id)
break
}
})
})
})
예시: 증분 업데이트
서버가 계산된 필드(예: 서버에서 생성된 ID 또는 타임스탬프)를 반환하는 경우, 불필요한 다시 가져오기를 방지하면서 서버 응답을 계속 동기화하려면 onInsert 핸들러를 { refetch: false }와 함께 사용할 수 있습니다:
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: fetchTodos,
queryClient,
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
const newItems = transaction.mutations.map((m) => m.modified)
// Send to server and get back items with server-computed fields
const serverItems = await api.createTodos(newItems)
// Sync server-computed fields (like server-generated IDs, timestamps, etc.)
// to the collection's synced data store
todosCollection.utils.writeBatch(() => {
serverItems.forEach((serverItem) => {
todosCollection.utils.writeInsert(serverItem)
})
})
// Skip automatic refetch since we've already synced the server response
// (optimistic state is automatically replaced when handler completes)
return { refetch: false }
},
onUpdate: async ({ transaction }) => {
const updates = transaction.mutations.map((m) => ({
id: m.key,
changes: m.changes,
}))
const serverItems = await api.updateTodos(updates)
// Sync server-computed fields from the update response
todosCollection.utils.writeBatch(() => {
serverItems.forEach((serverItem) => {
todosCollection.utils.writeUpdate(serverItem)
})
})
return { refetch: false }
},
})
)
// Usage is just like a regular collection
todosCollection.insert({ text: "Buy milk", completed: false })
예시: 대규모 데이터 세트 페이지 매김
// Load additional pages without refetching existing data
const loadMoreTodos = async (page) => {
const newTodos = await api.getTodos({ page, limit: 50 })
// Add new items without affecting existing ones
todosCollection.utils.writeBatch(() => {
newTodos.forEach((todo) => {
todosCollection.utils.writeInsert(todo)
})
})
}
중요한 동작
전체 상태 동기화
쿼리 컬렉션은 queryFn 결과를 컬렉션의 전체 상태로 처리합니다. 즉:
- 컬렉션에는 있지만 쿼리 결과에는 없는 항목은 삭제됩니다
- 쿼리 결과에는 있지만 컬렉션에는 없는 항목은 삽입됩니다
- 양쪽 모두에 있는 항목은 서로 다를 경우 업데이트됩니다
동일한 엔터티 유형을 여러 REST
엔드포인트에서 로드할 수 있는 경우 이는 중요합니다. 예를 들어, 한 번의 로드에서는 동일한 Query Collection을
/api/documents/preview에 연결하고 다른 로드에서는 /api/documents/deleted에 연결하면 안 됩니다.
각 결과가 해당 컬렉션 범위의 전체 상태를 나타내는 경우는 예외입니다.
그렇지 않으면 더 좁은 엔드포인트가 다른 엔드포인트에서 로드된 행을 제거할 수 있습니다.
여러 엔드포인트 또는 부분 집합 로드 사용 사례에서는 API 의미에 맞는 패턴을 선택합니다:
- 하나의 논리적 컬렉션이 서로 다른 데이터 부분 집합을 제공할 수 있을 때
syncMode: 'on-demand'을 사용합니다. 이 모드에서는 쿼리 조건자(where,orderBy,limit, 그리고offset)은queryFn에ctx.meta.loadSubsetOptions을 통해 전달되며, 이를 API 매개변수로 변환할 수 있습니다. - 엔드포인트가 서로 다른 서버 범위를 나타내며 결과가 서로를 대체해서는 안 되는 경우 별도의 Query Collection을 사용합니다
unionAll을 사용하여 이를 결합합니다 엔드포인트 전반의 통합된 뷰가 필요할 때 단일 쿼리로 결합할 수 있습니다. - 더 낮은 수준의 증분 로딩에는
writeUpsert/writeBatch와 같은 직접 쓰기를 사용합니다 서버 응답을 직접 병합하려는 경우 새 데이터를 의도적으로 동기화된 스토어에 병합할 수 있습니다.
빈 배열 동작
queryFn이 빈 배열을 반환하면 컬렉션의 모든 항목이 삭제됩니다. 이는 컬렉션이 빈 배열을 "서버에 항목이 없음"으로 해석하기 때문입니다.
// This will delete all items in the collection
queryFn: async () => []
부분/증분 페치 처리
쿼리 컬렉션은 queryFn이 전체 상태를 반환할 것으로 예상하므로, 새 데이터를 기존 데이터와 병합하여 부분 페치를 처리할 수 있습니다:
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: async ({ queryKey }) => {
// Get existing data from cache
const existingData = queryClient.getQueryData(queryKey) || []
// Fetch only new/updated items (e.g., changes since last sync)
const lastSyncTime = localStorage.getItem("todos-last-sync")
const newData = await fetch(`/api/todos?since=${lastSyncTime}`).then(
(r) => r.json()
)
// Merge new data with existing data
const existingMap = new Map(existingData.map((item) => [item.id, item]))
// Apply updates and additions
newData.forEach((item) => {
existingMap.set(item.id, item)
})
// Handle deletions if your API provides them
if (newData.deletions) {
newData.deletions.forEach((id) => existingMap.delete(id))
}
// Update sync time
localStorage.setItem("todos-last-sync", new Date().toISOString())
// Return the complete merged state
return Array.from(existingMap.values())
},
queryClient,
getKey: (item) => item.id,
})
)
이 패턴을 사용하면 다음을 수행할 수 있습니다:
- API에서 증분 변경 사항만 페치
- 해당 변경 사항을 기존 데이터와 병합
- 컬렉션이 예상하는 전체 상태 반환
- 매번 모든 데이터를 페치하는 성능 오버헤드 방지
직접 쓰기 및 쿼리 동기화
직접 쓰기는 컬렉션을 즉시 업데이트하고 TanStack Query 캐시도 업데이트합니다. 그러나 일반적인 쿼리 동기화 동작을 방지하지는 않습니다. queryFn이 직접 쓰기와 충돌하는 데이터를 반환하면 쿼리 데이터가 우선합니다.
이를 올바르게 처리하려면:
- 직접 쓰기를 사용할 때 영속성 핸들러에서
{ refetch: false }를 사용합니다 - 불필요한 재페치를 방지하도록 적절한
staleTime을 설정합니다 - 증분 업데이트를 인식하도록
queryFn을 설계합니다(예: 새 데이터만 페치)
직접 쓰기 API 전체 참조
모든 직접 쓰기 메서드는 collection.utils에서 사용할 수 있습니다:
writeInsert(data): 하나 이상의 항목을 직접 삽입writeUpdate(data): 하나 이상의 항목을 직접 업데이트writeDelete(keys): 하나 이상의 항목을 직접 삭제writeUpsert(data): 하나 이상의 항목을 직접 삽입하거나 업데이트writeBatch(callback): 여러 작업을 원자적으로 수행refetch(opts?): 쿼리의 다시 가져오기를 수동으로 트리거
QueryFn 및 Predicate 푸시다운
syncMode: 'on-demand'을 사용할 때 컬렉션은 쿼리 조건(predicate)(where 절, orderBy, limit 및 offset)을 자동으로 queryFn에 푸시다운합니다. 이를 통해 전체 데이터 세트를 가져오는 대신 각 특정 쿼리에 필요한 데이터만 가져올 수 있습니다.
LoadSubsetOptions 전달 방식
LoadSubsetOptions는 queryFn에 전달되며 쿼리 컨텍스트의 meta 속성을 통합니다:
queryFn: async (ctx) => {
// Extract LoadSubsetOptions from the context
const { limit, offset, where, orderBy } = ctx.meta.loadSubsetOptions
// Use these to fetch only the data you need
// - where: filter expression (AST)
// - orderBy: sort expression (AST)
// - limit: maximum number of rows
// - offset: number of rows to skip (for pagination)
// ...
}
where 및 orderBy 필드는 파싱해야 하는 추상 구문 트리(AST)입니다. TanStack DB는 이를 쉽게 처리할 수 있도록 헬퍼 함수를 제공합니다.
표현식 헬퍼
import {
parseWhereExpression,
parseOrderByExpression,
extractSimpleComparisons,
parseLoadSubsetOptions,
} from '@tanstack/db'
// Or from '@tanstack/query-db-collection' (re-exported for convenience)
이러한 헬퍼를 사용하면 복잡한 AST 구조를 수동으로 순회하지 않고도 표현식 트리를 파싱할 수 있습니다.
빠른 시작: 간단한 REST API
import { createCollection } from '@tanstack/react-db'
import { queryCollectionOptions } from '@tanstack/query-db-collection'
import { parseLoadSubsetOptions } from '@tanstack/db'
import { QueryClient } from '@tanstack/query-core'
const queryClient = new QueryClient()
const productsCollection = createCollection(
queryCollectionOptions({
id: 'products',
queryKey: ['products'],
queryClient,
getKey: (item) => item.id,
syncMode: 'on-demand', // Enable predicate push-down
queryFn: async (ctx) => {
const { limit, offset, where, orderBy } = ctx.meta.loadSubsetOptions
// Parse the expressions into simple format
const parsed = parseLoadSubsetOptions({ where, orderBy, limit })
// Build query parameters from parsed filters
const params = new URLSearchParams()
// Add filters
parsed.filters.forEach(({ field, operator, value }) => {
const fieldName = field.join('.')
if (operator === 'eq') {
params.set(fieldName, String(value))
} else if (operator === 'lt') {
params.set(`${fieldName}_lt`, String(value))
} else if (operator === 'gt') {
params.set(`${fieldName}_gt`, String(value))
}
})
// Add sorting
if (parsed.sorts.length > 0) {
const sortParam = parsed.sorts
.map(s => `${s.field.join('.')}:${s.direction}`)
.join(',')
params.set('sort', sortParam)
}
// Add limit
if (parsed.limit) {
params.set('limit', String(parsed.limit))
}
// Add offset for pagination
if (offset) {
params.set('offset', String(offset))
}
const response = await fetch(`/api/products?${params}`)
return response.json()
},
})
)
// Usage with live queries
import { createLiveQueryCollection } from '@tanstack/react-db'
import { eq, lt, and } from '@tanstack/db'
const affordableElectronics = createLiveQueryCollection({
query: (q) =>
q.from({ product: productsCollection })
.where(({ product }) => and(
eq(product.category, 'electronics'),
lt(product.price, 100)
))
.orderBy(({ product }) => product.price, 'asc')
.limit(10)
.select(({ product }) => product)
})
// This triggers a queryFn call with:
// GET /api/products?category=electronics&price_lt=100&sort=price:asc&limit=10
// When paginating, offset is included: &offset=20
복잡한 API를 위한 사용자 지정 핸들러
특정 형식의 API에는 사용자 지정 핸들러를 사용합니다:
queryFn: async (ctx) => {
const { where, orderBy, limit } = ctx.meta.loadSubsetOptions
// Use custom handlers to match your API's format
const filters = parseWhereExpression(where, {
handlers: {
eq: (field, value) => ({
field: field.join('.'),
op: 'equals',
value
}),
lt: (field, value) => ({
field: field.join('.'),
op: 'lessThan',
value
}),
and: (...conditions) => ({
operator: 'AND',
conditions
}),
or: (...conditions) => ({
operator: 'OR',
conditions
}),
}
})
const sorts = parseOrderByExpression(orderBy)
return api.query({
filters,
sort: sorts.map(s => ({
field: s.field.join('.'),
order: s.direction.toUpperCase()
})),
limit
})
}
GraphQL 예제
queryFn: async (ctx) => {
const { where, orderBy, limit } = ctx.meta.loadSubsetOptions
// Convert to a GraphQL where clause format
const whereClause = parseWhereExpression(where, {
handlers: {
eq: (field, value) => ({
[field.join('_')]: { _eq: value }
}),
lt: (field, value) => ({
[field.join('_')]: { _lt: value }
}),
and: (...conditions) => ({ _and: conditions }),
or: (...conditions) => ({ _or: conditions }),
}
})
// Convert to a GraphQL order_by format
const sorts = parseOrderByExpression(orderBy)
const orderByClause = sorts.map(s => ({
[s.field.join('_')]: s.direction
}))
const { data } = await graphqlClient.query({
query: gql`
query GetProducts($where: product_bool_exp, $orderBy: [product_order_by!], $limit: Int) {
product(where: $where, order_by: $orderBy, limit: $limit) {
id
name
category
price
}
}
`,
variables: {
where: whereClause,
orderBy: orderByClause,
limit
}
})
return data.product
}
표현식 헬퍼 API 레퍼런스
parseLoadSubsetOptions(options)
모든 LoadSubsetOptions를 한 번에 파싱하는 편의 함수입니다. 간단한 사용 사례에 적합합니다.
const { filters, sorts, limit, offset } = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions)
// filters: [{ field: ['category'], operator: 'eq', value: 'electronics' }]
// sorts: [{ field: ['price'], direction: 'asc', nulls: 'last' }]
// limit: 10
// offset: 20 (for pagination)
parseWhereExpression(expr, options)
각 연산자에 사용자 지정 핸들러를 사용하여 WHERE 표현식을 파싱합니다. 출력 형식을 완전히 제어해야 할 때 사용합니다.
const filters = parseWhereExpression(where, {
handlers: {
eq: (field, value) => ({ [field.join('.')]: value }),
lt: (field, value) => ({ [`${field.join('.')}_lt`]: value }),
and: (...filters) => Object.assign({}, ...filters)
},
onUnknownOperator: (operator, args) => {
console.warn(`Unsupported operator: ${operator}`)
return null
}
})
parseOrderByExpression(orderBy)
ORDER BY 표현식을 간단한 배열로 파싱합니다.
const sorts = parseOrderByExpression(orderBy)
// Returns: [{ field: ['price'], direction: 'asc', nulls: 'last' }]
extractSimpleComparisons(expr)
WHERE 표현식에서 간단한 AND 연결 비교를 추출합니다. 참고: 단순한 AND 조건에서만 작동합니다.
const comparisons = extractSimpleComparisons(where)
// Returns: [
// { field: ['category'], operator: 'eq', value: 'electronics' },
// { field: ['price'], operator: 'lt', value: 100 }
// ]
지원되는 연산자
eq- 같음 (=)gt- 초과 (>)gte- 이상 (>=)lt- 미만 (<)lte- 이하 (<=)and- 논리 ANDor- 논리 ORin- IN 절
쿼리 키 빌더 사용
서로 다른 필터 조합에 대해 서로 다른 캐시 항목을 생성합니다:
const productsCollection = createCollection(
queryCollectionOptions({
id: 'products',
// Dynamic query key based on filters
queryKey: (opts) => {
const parsed = parseLoadSubsetOptions(opts)
const cacheKey = ['products']
parsed.filters.forEach(f => {
cacheKey.push(`${f.field.join('.')}-${f.operator}-${f.value}`)
})
if (parsed.limit) {
cacheKey.push(`limit-${parsed.limit}`)
}
return cacheKey
},
queryClient,
getKey: (item) => item.id,
syncMode: 'on-demand',
queryFn: async (ctx) => { /* ... */ },
})
)
쿼리 키 접두사 규칙
함수 기반 queryKey을 사용할 때 파생된 모든 키는 기본 키를 접두사로 확장해야 합니다. 기본 키는 옵션 없이 호출했을 때 함수가 반환하는 값입니다(queryKey({})).
TanStack Query는 내부적으로 캐시 작업에 접두사 일치를 사용합니다. 쿼리 컬렉션은 이를 통해 컬렉션에 속한 모든 캐시 항목을 찾습니다 — gcTime로 인해 캐시에 계속 보관되는, 삭제된 쿼리 옵저버의 오래된 항목도 포함됩니다. 파생된 키가 기본 접두사를 공유하지 않으면 캐시 업데이트가 항목을 조용히 누락하여 오래된 데이터가 발생할 수 있습니다.
// ✅ Correct: base key ['products'] is a prefix of all derived keys
queryKey: (opts) => {
if (opts.where) {
return ['products', JSON.stringify(opts.where)]
}
return ['products']
}
// ❌ Wrong: base key ['products-all'] is NOT a prefix of ['products-filtered', ...]
queryKey: (opts) => {
if (opts.where) {
return ['products-filtered', JSON.stringify(opts.where)]
}
return ['products-all']
}
팁
- 간단한 사용 사례에는
parseLoadSubsetOptions부터 시작합니다 - 특정 형식의 API에는
parseWhereExpression를 통한 사용자 지정 핸들러를 사용합니다 onUnknownOperator콜백으로 지원되지 않는 연산자를 처리합니다- 개발 중에는 파싱된 결과를 기록하여 정확성을 확인합니다