더 이상 리렌더링은 없다, TanStack Query를 위한 내장형 클라이언트 DB 'TanStack DB'

더 이상 리렌더링은 없다, TanStack Query를 위한 내장형 클라이언트 DB 'TanStack DB'

할 일 목록의 체크박스 하나를 칠했다고 리액트(React) 대시보드 전체가 멈칫거려서는 안 되는데요.

하지만 여전히 낙관적 업데이트(Optimistic Update) 하나가 수많은 리렌더링, 필터링, useMemo 연산, 그리고 스피너의 깜빡임까지 불러오곤 합니다.

"아니, 2025년인데 이게 아직도 이렇게 어렵다고?"라고 중얼거려본 적 있다면, 네, 저도 똑같은 생각을 했습니다.

탠스택 디비(TanStack DB)가 바로 그 해답인데요.

이것은 차분 데이터 흐름(differential dataflow) 기술을 기반으로 하는 클라이언트 사이드 데이터베이스 계층으로, 기존의 useQuery 호출에 곧바로 연결해서 사용할 수 있습니다.

변경된 부분만 정확하게 다시 계산하기 때문에 M1 Pro 기준으로 정렬된 10만 개의 데이터 중 행 하나를 업데이트하는 데 0.7ms밖에 걸리지 않거든요. (코드샌드박스 데모 확인)

리니어(Linear)와 유사한 앱을 만들던 한 초기 알파 테스터는 몹엑스(MobX) 코드를 걷어내고 탠스택 디비(TanStack DB)로 교체한 뒤 안도하며 이렇게 말했습니다.

"이제 수천 개의 작업이 로드된 상태에서도 앱을 클릭할 때마다 모든 게 완전히 즉각적으로 반응합니다."

왜 이것이 중요할까요?

오늘날 대부분의 팀은 갈림길에서 골치 아픈 선택을 해야 하는데요.

선택지 A. 뷰(View) 전용 API 만들기: 렌더링은 빠르지만 네트워크가 느려지고, 엔드포인트가 끝도 없이 늘어납니다.

선택지 B. 다 불러와서 필터링하기: 백엔드는 단순해지지만, 클라이언트가 거북이처럼 느려집니다.

차분 데이터 흐름 기술은 선택지 C라는 새로운 길을 열어줍니다.

정규화된 컬렉션을 한 번만 로드하고, 탠스택 디비(TanStack DB)가 브라우저 내에서 밀리초 단위의 증분 조인(incremental joins)을 수행하도록 하는 것이죠.

코드를 완전히 새로 짤 필요도 없고, 스피너도 사라지며, 화면 떨림도 없습니다.

실시간 쿼리, 힘들이지 않는 낙관적 쓰기(Optimistic Writes), 그리고 훨씬 더 단순해진 아키텍처까지, 이 모든 것을 점진적으로 도입할 수 있거든요.

내부에서는 무슨 일이 일어나고 있을까요?

탠스택 디비(TanStack DB)는 정규화된 컬렉션 저장소를 메모리에 유지하고, 차분 데이터 흐름을 사용해 쿼리 결과를 증분적으로 업데이트하는데요.

마치 머테리얼라이즈(Materialize) 스타일의 스트리밍 SQL이 브라우저에 내장되어 리액트 쿼리(React Query)의 캐시와 직접 연결된 것이라고 생각하면 됩니다.

  • 컬렉션(Collections): 기존의 useQuery 호출을 감쌉니다. REST, tRPC, GraphQL, 웹소켓 등 무엇이든 상관없습니다. 다른 방식으로 데이터를 동기화하나요? 커스텀 컬렉션을 만들면 됩니다.
  • 트랜잭션(Transactions): 컬렉션을 낙관적으로 변경할 수 있게 해줍니다. 실패하면 자동으로 롤백됩니다.
  • 라이브 쿼리(Live Queries): 필요한 데이터를 선언하면, 탠스택 디비(TanStack DB)가 변경된 행만 1ms 미만으로 스트리밍해 줍니다.

달리 말하면, 탠스택 쿼리(TanStack Query)는 여전히 "어떻게 가져올까?"를 담당하고, 탠스택 디비(TanStack DB)는 "가져온 데이터를 어떻게 일관성 있고 빠르게 유지할까?"를 책임지는 것입니다.

그리고 이것은 단지 queryClient 위의 또 다른 계층일 뿐이므로, 한 번에 하나의 컬렉션씩 점진적으로 도입할 수 있습니다.

TanStack Query → TanStack DB 변환 예시

할 일 목록을 가져오고 수정하기 위해 /api/todos 엔드포인트를 제공하는 REST API 백엔드가 이미 있다고 가정해 보겠습니다.

이전: TanStack Query만 사용할 때

import {
  useQuery,
  useMutation,
  useQueryClient, // ❌ DB를 쓰면 필요 없음
} from '@tanstack/react-query'

const Todos = () => {
  const queryClient = useQueryClient() // ❌

  // 할 일 가져오기
  const { data: allTodos = [] } = useQuery({
    queryKey: ['todos'],
    queryFn: async () =>
      api.todos.getAll('/api/todos'),
  })

  // 완료되지 않은 할 일 필터링
  // ❌ 메모이제이션 안 하면 렌더링마다 실행됨
  const todos = allTodos.filter(
    (todo) => !todo.completed
  )

  // ❌ 수동으로 작성해야 하는 낙관적 업데이트 보일러플레이트
  const addTodoMutation = useMutation({
    mutationFn: async (newTodo) =>
      api.todos.create(newTodo),
    onMutate: async (newTodo) => {
      await queryClient.cancelQueries({
        queryKey: ['todos'],
      })
      const previousTodos =
        queryClient.getQueryData(['todos'])
      queryClient.setQueryData(
        ['todos'],
        (old) => [...(old || []), newTodo]
      )

      return { previousTodos }
    },
    onError: (err, newTodo, context) => {
      queryClient.setQueryData(
        ['todos'],
        context.previousTodos
      )
    },
    onSettled: () => {
      queryClient.invalidateQueries({
        queryKey: ['todos'],
      })
    },
  })

  return (
    <div>
      <List items={todos} />
      <Button
        onClick={() =>
          addTodoMutation.mutate({
            id: uuid(),
            text: '🔥 앱을 더 빠르게',
            completed: false,
          })
        }
      />
    </div>
  )
}

이후: TanStack DB 도입 후

// ✅ 쿼리 컬렉션 정의
import { createCollection } from '@tanstack/react-db'
import { queryCollectionOptions } from '@tanstack/query-db-collection'

const todoCollection = createCollection(
  queryCollectionOptions({
    queryKey: ['todos'],
    queryFn: async () =>
      api.todos.getAll('/api/todos'),
    getKey: (item) => item.id, // ✅ 새로 추가됨
    schema: todoSchema, // ✅ 새로 추가됨
    onInsert: async ({ transaction }) => {
      // ✅ 새로 추가됨
      await Promise.all(
        transaction.mutations.map((mutation) =>
          api.todos.create(mutation.modified)
        )
      )
    },
  })
)

// ✅ 컴포넌트에서 라이브 쿼리 사용
import { useLiveQuery } from '@tanstack/react-db'
import { eq } from '@tanstack/db'

const Todos = () => {
  // ✅ 자동 업데이트되는 라이브 쿼리
  const { data: todos } = useLiveQuery((query) =>
    query
      .from({ todos: todoCollection })
      // ✅ 타입 안전한 쿼리 빌더
      // ✅ 증분 연산 (변경된 것만 계산)
      .where(({ todos }) =>
        eq(todos.completed, false)
      )
  )

  return (
    <div>
      <List items={todos} />
      <Button
        onClick={() =>
          // ✅ 간단한 뮤테이션 - 보일러플레이트 없음!
          // ✅ 자동 낙관적 업데이트
          // ✅ 에러 시 자동 롤백
          todoCollection.insert({
            id: uuid(),
            text: '🔥 앱을 더 빠르게',
            completed: false,
          })
        }
      />
    </div>
  )
}

왜 새로운 클라이언트 저장소가 필요할까요?

탠스택 쿼리(TanStack Query)는 주당 1,200만 건 이상의 다운로드를 기록할 만큼 엄청난 인기를 누리고 있는데요.

그런데 왜 탠스택 디비(TanStack DB) 같은 새로운 것을 만들었을까요?

쿼리(Query)는 서버 상태 관리의 가장 어려운 문제들, 즉 지능형 캐싱, 백그라운드 동기화, 요청 중복 제거, 낙관적 업데이트, 매끄러운 에러 처리 등을 해결해 줍니다.

비동기 데이터 페칭의 복잡성과 보일러플레이트를 제거하면서도 자동 백그라운드 재요청(refetching), SWR(stale-while-revalidate) 패턴, 강력한 개발자 도구(DevTools) 같은 훌륭한 개발자 경험을 제공하기 때문에 사실상 표준이 되었죠.

하지만 쿼리(Query)는 데이터를 격리된 캐시 항목으로 취급합니다.

각 쿼리 결과는 독립적이며, 관계(relationship)에 대한 개념이나 여러 데이터 소스에 걸친 라이브 쿼리, 혹은 하나의 데이터가 변경될 때 다른 데이터가 반응형으로 업데이트되는 기능은 없습니다.

예를 들어 "프로젝트 상태가 '활성'인 모든 할 일을 보여줘"라고 요청했을 때, 프로젝트의 상태가 바뀌면 할 일 목록이 자동으로 업데이트되게 만드는 게 쉽지 않다는 뜻입니다.

탠스택 디비(TanStack DB)는 바로 이 간극을 메워주는데요.

쿼리(Query)가 서버 상태를 가져오고 캐싱하는 데 탁월하다면, DB는 그 위에 누락되었던 반응형 관계 계층을 제공합니다.

쿼리의 강력한 서버 상태 관리 능력과 전체 데이터 그래프에 걸쳐 조인, 필터링, 반응형 업데이트가 가능한 탠스택 디비의 내장형 클라이언트 데이터베이스 기능을 동시에 누릴 수 있는 것이죠.

하지만 단순히 현재 설정을 개선하는 것을 넘어, 아키텍처를 근본적으로 단순화할 수 있게 해줍니다.

아키텍처를 획기적으로 단순화하는 TanStack DB

앞서 언급한 세 가지 선택지를 다시 살펴볼까요?

선택지 A — 뷰 전용 API: 각 컴포넌트에 필요한 데이터만 정확히 반환하는 API를 만듭니다. 깔끔하고 빠르며 클라이언트 연산이 없습니다. 하지만 깨지기 쉬운 API 라우트의 홍수에 빠지게 되고, 관련 데이터가 필요한 컴포넌트들 사이에서 네트워크 워터폴(waterfall) 현상이 발생하며, 프론트엔드 뷰와 백엔드 스키마가 강하게 결합됩니다.

선택지 B — 다 불러와서 필터링: 더 포괄적인 데이터셋을 로드하고 클라이언트에서 필터링/가공합니다. API 호출은 줄어들고 프론트엔드는 유연해집니다. 하지만 성능의 벽에 부딪히게 되죠. todos.filter(), users.find(), posts.map(), useMemo()가 사방에 깔리고, 연쇄적인 리렌더링이 사용자 경험(UX)을 망칩니다.

대부분의 팀은 성능 문제를 피하기 위해 선택지 A를 고릅니다.

클라이언트의 복잡성을 줄이는 대신 API 증식과 네트워크 의존성을 택하는 셈이죠.

탠스택 디비(TanStack DB)는 선택지 C를 가능하게 합니다 — 정규화된 컬렉션 + 증분 조인: 더 적은 수의 API 호출로 정규화된 컬렉션을 로드한 다음, 클라이언트에서 번개처럼 빠른 증분 조인을 수행합니다.

포괄적인 데이터 로딩의 네트워크 효율성을 가져가면서도, 선택지 A가 필요 없을 만큼 빠른 밀리초 단위의 쿼리 성능을 얻을 수 있습니다.

이렇게 하는 대신:

// 페이지를 이동할 때마다 뷰 전용 API 호출
const { data: projectTodos } = useQuery({
  queryKey: ['project-todos', projectId],
  queryFn: () => fetchProjectTodosWithUsers(projectId)
})

이렇게 할 수 있습니다:

// 정규화된 컬렉션을 미리 로드 (더 포괄적인 호출 3번)
const todoCollection = createQueryCollection({
  queryKey: ['todos'],
  queryFn: fetchAllTodos,
})
const userCollection = createQueryCollection({
  queryKey: ['users'],
  queryFn: fetchAllUsers,
})
const projectCollection = createQueryCollection({
  queryKey: ['projects'],
  queryFn: fetchAllProjects,
})

// 페이지 이동은 즉각적임 — 새로운 API 호출 필요 없음
const { data: activeProjectTodos } = useLiveQuery(
  (q) =>
    q
      .from({ t: todoCollection })
      .innerJoin(
        { u: userCollection },
        ({ t, u }) => eq(t.userId, u.id)
      )
      .innerJoin(
        { p: projectCollection },
        ({ u, p }) => eq(u.projectId, p.id)
      )
      .where(({ t }) => eq(t.active, true))
      .where(({ p }) =>
        eq(p.id, currentProject.id)
      )
)

이제 프로젝트, 사용자, 뷰 사이를 클릭하며 이동할 때 API 호출이 전혀 필요 없습니다.

모든 데이터가 이미 로드되어 있으니까요.

"모든 프로젝트에 걸친 사용자 작업량 보기" 같은 새로운 기능도 백엔드를 건드리지 않고 즉시 구현할 수 있습니다.

API는 더 단순해지고, 네트워크 호출은 급격히 줄어들며, 데이터셋이 커질수록 프론트엔드는 더 빨라집니다.

20MB짜리 질문

수백 개의 자잘한 API 호출을 하는 대신 20MB의 정규화된 데이터를 처음에 한 번 로드한다면 앱이 얼마나 빨라질까요?

리니어(Linear), 피그마(Figma), 슬랙(Slack) 같은 회사들은 클라이언트에 거대한 데이터셋을 로드하고 커스텀 인덱싱, 차분 업데이트, 최적화된 렌더링에 막대한 투자를 하여 놀라운 성능을 달성했습니다.

하지만 이런 솔루션은 대부분의 팀이 직접 구축하기에는 너무 복잡하고 비용이 많이 듭니다.

탠스택 디비(TanStack DB)는 차분 데이터 흐름 기술을 통해 모든 팀에게 이 능력을 제공하는데요.

실제로 변경된 쿼리 부분만 다시 계산하는 기술입니다.

"네트워크 워터폴이 있는 수많은 빠른 API 호출"과 "클라이언트 처리가 느린 적은 API 호출" 사이에서 고민할 필요 없이, 두 가지 장점을 모두 가질 수 있습니다.

네트워크 왕복 횟수는 줄이면서, 대용량 데이터셋에서도 밀리초 미만의 클라이언트 쿼리 속도를 누리는 것이죠.

이것은 단지 일렉트릭(Electric) 같은 동기화 엔진에 관한 이야기만은 아닙니다(물론 이 패턴을 엄청나게 강력하게 만들어주긴 하지만요).

REST, GraphQL, 실시간 동기화 등 어떤 백엔드와도 작동하는 근본적으로 다른 데이터 로딩 전략을 가능하게 한다는 점이 핵심입니다.

왜 동기화 엔진이 흥미로울까요?

탠스택 디비(TanStack DB)는 REST나 GraphQL과도 훌륭하게 작동하지만, 동기화 엔진과 함께할 때 진가를 발휘하는데요.

동기화 엔진이 강력한 보완재인 이유는 다음과 같습니다.

  • 손쉬운 실시간 처리: 실시간 업데이트가 필요할 때 웹소켓 설정, 재연결 처리, 이벤트 핸들러 연결 등은 정말 고통스러운 작업입니다. 많은 최신 동기화 엔진은 실제 데이터 저장소(예: Postgres)에 네이티브로 통합되어 있어, 데이터베이스에 직접 쓰기만 하면 모든 구독자에게 실시간으로 업데이트가 스트리밍 됩니다. 더 이상 수동으로 웹소켓 배관 공사를 할 필요가 없습니다.
  • 사이드 이펙트 자동 전파: 백엔드에서 데이터를 변경하면 여러 테이블에 걸쳐 연쇄적인 업데이트가 발생하는 경우가 많습니다. 할 일의 상태를 업데이트하면 프로젝트 완료율이 변하고, 팀 지표가 업데이트되거나, 워크플로우 자동화가 트리거될 수 있죠. 탠스택 쿼리만으로는 이 모든 잠재적 사이드 이펙트를 추적하고 올바른 데이터를 다시 로드하기 위해 수동으로 관리해야 합니다. 동기화 엔진은 이 복잡성을 제거합니다. 뮤테이션 중에 발생한 모든 백엔드 변경 사항이 추가 작업 없이 자동으로 모든 클라이언트에 푸시됩니다.
  • 훨씬 더 많은 데이터를 효율적으로 로드: 동기화 엔진을 사용하면 클라이언트 데이터를 업데이트하는 비용이 훨씬 저렴합니다. 변경 후 전체 컬렉션을 다시 로드하는 대신, 실제로 변경된 항목만 전송하기 때문입니다. 덕분에 훨씬 더 많은 데이터를 미리 로드하는 것이 실용적이게 되며, 리니어(Linear) 같은 앱이 그토록 빠르게 느껴지게 만드는 "모든 것을 한 번에 로드하기" 패턴이 가능해집니다.

탠스택 디비(TanStack DB)는 처음부터 동기화 엔진을 지원하도록 설계되었습니다.

컬렉션을 정의할 때, 백엔드에서 로컬 컬렉션으로 동기화된 트랜잭션을 쓸 수 있는 API가 제공됩니다.

일렉트릭(Electric), 트레일블레이즈(Trailblaze), 그리고 (곧 지원될) 파이어베이스(Firebase)를 위한 컬렉션 구현체를 확인해 보세요!

DB는 컴포넌트가 데이터를 쿼리할 수 있는 공통 인터페이스를 제공하므로, 클라이언트 코드를 변경하지 않고도 필요에 따라 데이터 로딩 전략을 쉽게 전환할 수 있습니다.

REST로 시작했다가 나중에 필요하면 동기화 엔진으로 전환하세요. 컴포넌트는 그 차이를 알 필요가 없습니다.

TanStack DB의 목표

저희는 모든 팀이 결국 마주하게 되는 클라이언트 사이드 데이터 병목 현상을 해결하기 위해 탠스택 디비(TanStack DB)를 만들고 있는데요.

저희의 목표는 다음과 같습니다.

  • 진정한 백엔드 유연성: 플러그 가능한 컬렉션 생성자를 통해 어떤 데이터 소스와도 작동합니다. REST API, GraphQL, Electric, Firebase를 사용하든 직접 만든 것을 사용하든, 탠스택 디비는 여러분의 스택에 맞춰집니다. 현재 가진 것으로 시작하고, 필요하면 업그레이드하고, 같은 앱에서 여러 접근 방식을 섞어서 사용하세요.
  • 실제로 작동하는 점진적 도입: 하나의 컬렉션으로 시작해서 새로운 기능을 만들 때마다 추가하세요. 대규모 마이그레이션이나 개발 중단이 필요 없습니다.
  • 대규모 데이터에서의 쿼리 성능: 수천 개의 아이템이 있는 앱에서도 차분 데이터 흐름을 통해 대규모 데이터셋에 대해 밀리초 미만의 쿼리 성능을 제공합니다.
  • 깨지지 않는 낙관적 업데이트: 복잡한 커스텀 상태 관리 없이, 네트워크 요청이 실패했을 때 신뢰할 수 있는 롤백 동작을 제공합니다.
  • 전반적인 타입 및 런타임 안전성: 스키마부터 컴포넌트까지 완벽한 타입스크립트(TypeScript) 추론을 지원하여, 컴파일 및 런타임에 데이터 불일치를 잡아냅니다.

저희는 어떤 백엔드가 가장 적합한지 선택할 자유를 보장하면서도, 클라이언트 사이드 데이터를 처리하는 근본적으로 더 나은 방법을 팀들에게 제공할 수 있어 매우 기쁩니다.