본문 바로가기

개발/Next

[Next] react-query QueryClient 올바르게 관리하기 (w. App Router)

반응형

React에서는 QueryClient를 싱글톤 패턴으로 관리하는 것이 일반적이지만, Next.js App Router환경에서는 다른 접근이 필요합니다. 이번 글에서는 왜 그런지, 그리고 어떻게 관리해야 하는지 자세히 알아보겠습니다!

 


 

Next.js 서버의 Stateful 한 특성 이해하기

먼저 Next.js 서버 환경의 특성을 이해해야 합니다.

 

Stateless vs Stateful 서버

Stateless 환경 (예: AWS Lambda):

요청 A 들어옴 → [새 Lambda 인스턴스 생성] → 처리 → 인스턴스 종료
요청 B 들어옴 → [새 Lambda 인스턴스 생성] → 처리 → 인스턴스 종료
요청 C 들어옴 → [새 Lambda 인스턴스 생성] → 처리 → 인스턴스 종료

stateless 한 환경에서는 각 요청이 완전히 독립된 환경에서 실행되기 때문에, 요청 간 메모리를 공유하지 않습니다.

 

Stateful 환경 (예: Next.js 서버):

서버 프로세스 시작
  ↓
[공유 메모리 공간 할당]
  ↓
요청 A 처리 중... ─┐
요청 B 처리 중... ─┼→ 같은 프로세스, 같은 메모리 공간 사용
요청 C 처리 중... ─┘
  ↓
서버 계속 실행 중... (상태를 메모리에 유지)

stateful 환경에서는 모든 요청이 같은 메모리 공간을 공유하기 때문에, 모듈 레벨 변수는 모든 요청에서 접근이 가능합니다.

 

간단한 예시로 확인하기

모듈 레벨에서 선언된 변수가 어떻게 요청 간에 공유되는지 간단한 예시로 확인해 보겠습니다.

 

실행 코드:

// lib/counter.ts
let requestCount = 0  // 모듈 레벨 변수

export function incrementCount() {
  requestCount++
  console.log(`총 요청 수: ${requestCount}`)
}
// app/page.tsx
import { incrementCount } from '@/lib/counter'

export default async function Page() {
  incrementCount()
  return <div>Hello</div>
}

 

실행 결과:

Stateful 환경에서는

사용자 A 방문 → "총 요청 수: 1"
사용자 B 방문 → "총 요청 수: 2"  // A의 카운트가 유지됨!
사용자 C 방문 → "총 요청 수: 3"  // 계속 누적됨!

Stateless 환경에서는

사용자 A 방문 → "총 요청 수: 1"
사용자 B 방문 → "총 요청 수: 1"  // 새 인스턴스, 초기화됨
사용자 C 방문 → "총 요청 수: 1"  // 새 인스턴스, 초기화됨

Next.js서버는 요청 간에 메모리 상태가 공유됩니다. 즉, Next.js 서버는 "stateful"합니다.

 


 

QueryClient를 싱글톤 패턴으로 사용했을 때 문제점

이제 위에서 설명한 Stateful 한 특성 때문에 싱글톤 패턴이 왜 위험한지 살펴보겠습니다.

 

// lib/query-client.ts
import { QueryClient } from '@tanstack/react-query'

// 모듈 레벨에서 생성된 싱글톤 -> 서버에 queryClient 인스턴스가 계속 살아있음
export const queryClient = new QueryClient()
// app/providers.tsx
'use client'

import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '@/lib/query-client' // 모듈 레벨에서 생성된 인스턴스

export function Providers({ children }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}

위 코드는 언뜻 보면 문제가 없어 보이지만, Next.js의 Stateful 한 특성 때문에 문제들이 발생합니다.

 

 

문제 1: 요청 간 데이터 공유로 인한 메모리 누수

실행 코드:

// app/user/[id]/page.tsx
import { queryClient } from '@/lib/query-client' // 싱글톤

export default async function UserPage({ params }) {
  await queryClient.prefetchQuery({
    queryKey: ['user', params.id],
    queryFn: () => fetchUser(params.id),
  })

  return <HydrationBoundary state={dehydrate(queryClient)}>...</HydrationBoundary>
}

 

문제 발생 시나리오:

10:00:00 - 사용자 A → /user/1 방문
  → queryClient 캐시: { 'user-1': {...} }

10:00:01 - 사용자 B → /user/2 방문
  → queryClient 캐시: { 'user-1': {...}, 'user-2': {...} }

10:00:02 - 사용자 C → /user/3 방문
  → queryClient 캐시: { 'user-1': {...}, 'user-2': {...}, 'user-3': {...} }

싱글톤으로 생성된 QueryClient는 서버 프로세스가 종료될 때까지 메모리에 남아있습니다. 각 사용자의 요청이 처리될 때마다 캐시에 데이터가 계속 쌓이게 됩니다.  즉, 서버가 계속 실행되는 동안 각 사용자의 요청마다 QueryClient의 캐시에 데이터가 추가되고 절대 제거되지 않습니다.

 

 

문제 2: 보안 취약점 - 사용자 간 데이터 유출

실행 코드:

// app/dashboard/page.tsx
export default async function Dashboard() {
  await queryClient.prefetchQuery({
    queryKey: ['currentUser'],
    queryFn: getCurrentUser, // 쿠키에서 세션을 읽어 사용자 정보 반환
  })

  return <HydrationBoundary state={dehydrate(queryClient)}>...</HydrationBoundary>
}

 

문제 발생 시나리오:

T1: 사용자 A(관리자) 요청 시작
  → queryClient.prefetchQuery(['currentUser'])
  → { id: 1, role: 'admin', email: 'admin@company.com' }

T2: 사용자 A의 prefetch가 아직 진행 중...

T3: 사용자 B(일반 사용자) 요청 시작
  → 같은 queryClient 인스턴스 사용

T4: 사용자 A의 prefetch 완료
  → queryClient 캐시에 admin 정보 저장

T5: 사용자 B가 queryClient 접근
  → 사용자 B가 사용자 A의 admin 정보를 받을 수 있음! 🚨

더 심각한 것은 위 예시와 같이 사용자 간 데이터가 섞일 수 있다는 점입니다. 동시에 처리되는 요청들이 같은 QueryClient를 사용하게 되면, 타이밍에 따라 다른 사용자의 데이터를 받을 수 있습니다.

 


 

Next.js에서 QueryClient를 올바르게 사용하는 방법

서버 컴포넌트에서는 매번 새 인스턴스 생성 

 

실행 코드:

// ✅ app/user/[id]/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'

export default async function UserPage({ params }) {
  // 서버 컴포넌트에서는 매 요청마다 새 인스턴스 생성
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['user', params.id],
    queryFn: () => fetchUser(params.id),
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <UserProfile />
    </HydrationBoundary>
  )
}

 

시나리오:

사용자 A 요청 → QueryClient A (독립)
사용자 B 요청 → QueryClient B (독립)
사용자 C 요청 → QueryClient C (독립)

서버 컴포넌트에서 데이터를 prefetch 할 때는 매 요청마다 새로운 QueryClient 인스턴스를 생성해야 합니다. 이렇게 하면 각 요청이 독립적인 캐시를 가지게 되어 데이터 격리가 보장되고, 요청이 완료된 후엔 가비지 컬렉터에 의해 메모리에서 삭제됩니다.

 

 

클라이언트 컴포넌트에서는 싱글톤 패턴 사용

방금까지 싱글톤 패턴을 사용하지 말라고 말씀드렸는데, 이는 어디까지나 서버 컴포넌트에 관한 이야기입니다. 클라이언트 컴포넌트에서는 페이지 간 이동 시 캐시 공유가 필요하기 때문에 싱글톤 패턴을 사용해야 합니다.

 

실행 코드:

// ✅ app/providers.tsx
'use client'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'

export function Providers({ children }: { children: React.ReactNode }) {
  // 브라우저 세션 생성 시 1개의 QueryClient 생성
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,
      },
    },
  }))

  return (
    <QueryClientProvider client={queryClient}> // 생성된 QueryClient를 Provider로 내려서 사용
      {children}
    </QueryClientProvider>
  )
}
// app/layout.tsx
import { Providers } from './providers'

export default function RootLayout({ children }) {
  return (
    <html lang="ko">
      <body>
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  )
}

 

시나리오:

사용자 A 브라우저 → QueryClient A (싱글톤처럼 동작)
  ├─ /dashboard 방문
  ├─ /products 이동 ← 같은 QueryClient A
  ├─ /settings 이동 ← 같은 QueryClient A
  └─ 캐시 유지되어 불필요한 API 호출 방지

사용자 B 브라우저 → QueryClient B (싱글톤처럼 동작)
  ├─ /dashboard 방문
  ├─ /profile 이동 ← 같은 QueryClient B
  └─ 캐시 유지되어 불필요한 API 호출 방지

클라이언트 사이드에서는 useState 훅을 사용하여 각 브라우저 세션마다 독립적인 QueryClient를 생성합니다. 그리고 생성한 인스턴스를 Provider로 내려주는 것이죠.

 

이렇게 하면 브라우저에서는 싱글톤처럼 동작하지만, 서버에서는 각 요청마다 새로운 인스턴스가 생성됩니다.

 


 

정리


 서버 컴포넌트(Server Component)에서 각 요청마다 새 QueryClient 생성
- 서버는 여러 사용자의 요청을 동시에 처리하는 stateful 환경
- 모듈 레벨에서 생성된 싱글톤은 모든 요청이 공유
- 따라서 각 요청별로 새로운 QueryClient를 생성하여 데이터를 격리

✅ 클라이언트 컴포넌트(Client Component)에서는 하나의 QueryClient 인스턴스를 유지
- 각 사용자의 브라우저는 독립적인 환경
- 브라우저 내에서는 단일 QueryClient 인스턴스를 유지해야 캐싱 효율적
- 따라서 싱글톤 패턴을 적용

 

Next.js App Router의 서버/클라이언트 하이브리드 아키텍처에서는 QueryClient 관리가 매우 중요합니다. 잘못 사용하면 보안 취약점과 메모리 누수로 이어질 수 있지만, 올바르게 사용하면 안전하고 효율적인 데이터 캐싱을 구현할 수 있습니다.

핵심은 서버와 클라이언트의 QueryClient를 명확히 구분하고, 각 환경에 맞는 생성 패턴을 사용하는 것입니다. 서버에서는 요청 격리를 위해 매번 새로 생성하고, 클라이언트에서는 앱 전체에서 하나의 인스턴스를 공유하여 효율적인 캐싱을 구현해야 합니다!

반응형