๐ 09. Next.js App Router ํ๊ฒฝ์์์ TanStack Query ์ ์
๐ ๊ฐ์
React Query๋ฅผ ์ต์ Next.js App Router(RSC) ํ๊ฒฝ์ ํ์ฌํ ๋ ํ์์ ์ธ QueryClientProvider ์ฑ๊ธํค ์ธํ ์ ๋ต๊ณผ ๋ฉ๋ชจ๋ฆฌ ๋ฆญ ๋ฐฉ์ง๋ฒ์ ๋ค๋ฃน๋๋ค.
๐ ๋ชฉ์ฐจ
- โ๏ธ ์์ฒ ์ด์ ๊ณ ๋ฏผ: "Next.js์์ Query ์ด๋ป๊ฒ ์ผ๋์?"
- ๐ค ์ ์์์ผ ํ๋๊ฐ: ๋ ์ธ๊ณ์ ์ถฉ๋
- 1. ์ ์ญ ๋์ ๋ฐฉ์ง: Request-Scoped QueryClient
- 2. ๋ ์ด์์ ํ๊ดด ๊ธ์ง: Provider ๊ป๋ฐ๊ธฐ ๋ถ๋ฆฌ (Boundary)
- ๐ ๋ฐฐ์ด ๋ด์ฉ ์ ๊ฒํ๊ธฐ (Quiz)
"์์ฒ ๋, Layout.tsx ํ์ผ ์ต์๋จ์
use client๋ถ์ฌ๋๊ณ ๋ธ๋ผ์ฐ์ ์ฝ์ ํ ๋ฒ ๋ณด์ธ์. ๋ฉ๋ชจ๋ฆฌ๊ฐ ์ด๋ป๊ฒ ํฐ์ ธ๋๊ฐ๊ณ ์๋์ง."
โ๏ธ ์์ฒ ์ด์ ๊ณ ๋ฏผ: "Next.js์์ Query ์ด๋ป๊ฒ ์ผ๋์?"
(์์์ผ ์์นจ, ๋ธ๋ผ์ฐ์ ๊ฐ ๋ฒ๋ฒ๋ฒ ๊ฑฐ๋ฆฌ๋ ํ์์ ๊ฒช๋ ์์ฒ )
๐ฃ ์์ฒ : ๋ฆฌ๋ ๋! ์ ํผ์ ์ฃผ๋ง์ Next.js 14 App Router๋ก ํ๋ก์ ํธ ๋ผ๋ ๋ค ๋ง๋ค์ด ๋จ๊ฑฐ๋ ์? ๊ทผ๋ฐ React Query๋ฅผ app/layout.tsx์ Provider๋ก ๊ฐ์ธ๋ ค๋๊น ์๋ฌ๊ฐ ๋จ๋๋ผ๊ณ ์. Provider๋ ๋ฆฌ์กํธ ํ
์ ์ฐ๋๊น ๋น์ฐํ ํด๋ผ์ด์ธํธ ์ปดํฌ๋ํธ์์์? ๊ทธ๋์ layout.tsx ๊ผญ๋๊ธฐ์ ๊ณผ๊ฐํ๊ฒ 'use client' ์ง์์ด ๋ฑ ๋ถ์ด๊ณ ์์ํ์ฃ !
๐ฆ ์ํธ: ์์ฒ ๋... ๊ทธ๊ฑด App Router์ ๋ฌ๋ฏธ์ธ ์๋ฒ ์ปดํฌ๋ํธ(RSC) ํ๊ฒฝ์ ์์ ํ ๋ฐ์ด๋ด๋ ์ํญ ๋ฒํผ์
๋๋ค. ์ต์๋จ ๋ ์ด์์ ์ ์ฒด๋ฅผ ํด๋ผ์ด์ธํธ๋ก ๋ฐ์ด๋ฒ๋ฆฌ์
จ๊ตฐ์.
๋ ์ฌ๊ฐํ ๊ฑด, const queryClient = new QueryClient() ๋ฅผ ๊ทธ๋ฅ ์ ์ญ ๋ณ์๋ก ์ ์ธํ์
จ๋ค์? ๋ค๋ฅธ ์ ์ A์ ์บ์๊ฐ ์๋ฑํ ์ ์ B์ ๋ชจ๋ํฐ์ ๋จ๋ ์ ์ญ ๋ฉ๋ชจ๋ฆฌ ์ค์ผ(Cross-Request State Leak) ์ฌ๊ณ ์น๊ธฐ ์ง์ ์
๋๋ค. ๋น์ฅ ๋์ธ์!
๐ค ์ ์์์ผ ํ๋๊ฐ: ๋ ์ธ๊ณ์ ์ถฉ๋
SPA ์์ React ๊ฐ๋ฐ์๋ค์ index.tsx ์ต์๋จ ํ์ผ ๋ฐ์๋ค๊ฐ ์๋ฌด์๊ฐ ์์ด const queryClient = new QueryClient() ํ๋ ์ ์ธํด๋๊ณ ๋๋ ค์ผ์ต๋๋ค. ๋ธ๋ผ์ฐ์ ๋ ์ด์ฐจํผ '๋ ํผ์' ์ฐ๋ ๊ฑฐ๋๊น์.
ํ์ง๋ง Next.js 13+ (App Router) ๋ ๊ฐ๋ ฅํ Node.js ์๋ฒ ์ฃผ๋ฐฉ์์ ์ปดํฌ๋ํธ๊ฐ ๊ตฌ์์ง๋๋ค. ์๋ฒ ๊ณต๊ฐ์ ์๋ง ๋ช
์ ์ ์ ์ ์ ์์ฒญ์ด ๋์์ ๋น๋ฐ์นฉ๋๋ค. ์ด ์ํฉ์์ ์๋ฒ ๋ฉ๋ชจ๋ฆฌ ์์ ์ ์ญ ๋ณ์(์ฑ๊ธํค)๋ก QueryClient ์ธ์คํด์ค๋ฅผ ํ๋ ๋ฉ๊ทธ๋ฌ๋ ๋ง๋ค์ด๋๋ฉด?
A ์ฌ์ฉ์๊ฐ ํ์นญํด ์จ [user, me] ์ ์ ์ ๋ณด ์บ์๊ฐ 0.1์ด ๋ค ์ ์ํ B ์ฌ์ฉ์์ ํ๋ฉด์ ๊ทธ๋๋ก ํํธ(Cache Hit) ๋๋ฒ๋ฆฌ๋ ์น๋ช
์ ๊ฐ์ธ์ ๋ณด ์ ์ถ ์ฌ๊ณ ๊ฐ ํฐ์ง๋๋ค.
์ด ๋จ์์์๋ RSC ์ธ๊ณ๊ด์์ ์๋ฒฝํ๊ณ ์์ ํ๊ฒ React Query๋ฅผ ์ค์นํ๋ '์ ์(Boilerplate)' ์ ์ ๋ฒ์ ์ตํ๋๋ค.
1. ์ ์ญ ๋์ ๋ฐฉ์ง: Request-Scoped QueryClient
์ ๋ต์ "ํด๋ผ์ด์ธํธ ํ๊ฒฝ(๋ธ๋ผ์ฐ์ )์ผ ๋๋ ์ ์ญ ์ฑ๊ธํค์ ์ฐ๋, ์๋ฒ ํ๊ฒฝ์ผ ๋๋ ๋ฌด์กฐ๊ฑด ์ฌ์ฉ์์ ์์ฒญ(Request) 1๋ฒ๋น 1๊ฐ์ QueryClient๋ฅผ ๋ ๋ฆฝ์ ์ผ๋ก ์ฐ์ด๋ธ๋ค(Factory)" ์ ๋๋ค.
์ด๋ Next.js์ TanStack Query ๊ณต์ ๋ฌธ์์์ ์๊ธฐ๋ฅผ ๋ฐ์ ํฉํ ๋ฆฌ ํจํด์
๋๋ค.
๋จผ์ ํ
๊ธฐ๋ฐ์ผ๋ก ์์ฑ๊ธฐ ํ์ผ์ ์ชผ๊ฐ์ผ ํฉ๋๋ค.
// ๐ app/providers/get-query-client.ts
import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query';
// ๋ฑ ์ด ์ต์
๋ฉ์ด๋ฆฌ ํ๋๋ง ์ ๋ง๋ค์ด ๋ก๋๋ค.
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// SSR ํ๊ฒฝ์์๋ ๋ณดํต 0 ์ด์์ผ๋ก ๋ก๋๋ค.
// 0์ด๋ฉด ์๋ฒ ์บ์๊ฐ ํด๋ผ์ด์ธํธ๋ก ๋์ด๊ฐ์๋ง์ ์ํด๋ฒ๋ ค์ ๋ฐ๋ก ๋ ๋ฆฌํจ์น ์นฉ๋๋ค.
staleTime: 60 * 1000,
},
dehydrate: {
// ๋ณด๋ฅ ์ค์ธ ํ๋ก๋ฏธ์ค๋ ์ ๋ ํด๋ผ์ด์ธํธ๋ก Hydrate ์์ง ๋ง๋ผ๋ Next.js ๊ถ์ฅ ์ต์
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
}
},
});
}
// ๐ ํด๋ผ์ด์ธํธ ์ฑ๊ธํค ์์ ๋ง ๋ณ์
let browserQueryClient: QueryClient | undefined = undefined;
export function getQueryClient() {
if (typeof window === 'undefined') {
// 1๏ธโฃ ์ง๊ธ ๋ด๊ฐ ์๋ฒ ์ฃผ๋ฐฉ์ด๋ผ๋ฉด?
// ์ ์ญ ๋ณ์ ๊ณต์ ์ ๋ ๊ธ์ง! ์์ฒญ ํ ๋ฒ๋ง๋ค ๋ฌด์กฐ๊ฑด ๋ผ๋ ์๋ก ์์ฑ!
return makeQueryClient();
} else {
// 2๏ธโฃ ๋ด๊ฐ ์ง๊ธ ์ ์ ์ ๋ธ๋ผ์ฐ์ (ํฐ) ์์ด๋ผ๋ฉด?
// ๋ฆฌ์กํธ๊ฐ ๋ ๋๋ง์ ์๋ฐฑ ๋ฒ ๋ค์ ๋์๋ ๋จ 'ํ๋'์ ์ ์ญ ์ธ์คํด์ค๋ง ๋ณด์กด!
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}2. ๋ ์ด์์ ํ๊ดด ๊ธ์ง: Provider ๊ป๋ฐ๊ธฐ ๋ถ๋ฆฌ (Boundary)
์์์ ์์ ํ ์์ ๋ชจ ํธ์ค๋ฅผ ๋ง๋ค์๋ค๋ฉด, ์ด์ ์ด๊ฑธ ์ ์ ์๊ฒ ๊ฐ์ธ์ค์ผ ํฉ๋๋ค.
์ ๋ layout.tsx ๊ผญ๋๊ธฐ์ 'use client'๋ฅผ ๋ฐ๋ฅด๋ ์ง์ ์ ๋ฉ๋๋ค.
๋์ , Provider ๊ป๋ฐ๊ธฐ ์ ์ฉ ํด๋ผ์ด์ธํธ ์ปดํฌ๋ํธ ๋ฅผ ์๊ฒ ํ๋ ๋ฐ์ ์์ ๋ถ์๋ฅผ ์ต์ํ์ํค๋ ๊ฒ๋๋ค.
// ๐ app/providers/react-query-provider.tsx
'use client' // โค๏ธ ์ด๊ฒ์ Provider ์ ์ฉ ๊ป์ง์ด๋ค!
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { getQueryClient } from './get-query-client';
export default function ReactQueryProvider({ children }: { children: React.ReactNode }) {
// useState์ ์ด๊ธฐํ๊ฐ(Initializer) ํจํด์ ํตํด,
// ๋ ๋๋ง์ด ์ผ์ด๋ ๋๋ง๋ค ํด๋ผ์ด์ธํธ ๊ฐ์ฒด๋ฅผ ๋ค์ ๊น์ง ์๋๋ก ๋ณด์ฅํฉ๋๋ค!
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
{/* ๋ค์ผ๋ก ๊ฐ๋ฐ ๋๊ตฌ๋ ์ฌ๊ธฐ์ ์ฌ์ด์ค๋ค */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}์ด ์์ ๊ป์ง์ ์์ฑํ์ผ๋, ๋๋์ด ๋๋ง์ ์ต์๋จ ์๋ฒ ์ฃผ๋ฐฉ์ธ layout.tsx ์ ๋ฌด์ฌ๊ณ ํ์ฌํ ์ ์์ต๋๋ค.
// ๐ app/layout.tsx
// (์ฌ๊ธด ์์ Server Component ์์ญ์
๋๋ค)
import ReactQueryProvider from './providers/react-query-provider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
{/* ๐ ์๋ฒ ์ปดํฌ๋ํธ์ ๊ฑฐ๋ํ ํธ๋ฆฌ๋ฅผ Provider ๋ฐ๊ตฌ๋๋ก ๋ด์์ฃผ๊ธฐ๋ง ํ๋ฉด ๋! */}
<ReactQueryProvider>
{children}
</ReactQueryProvider>
</body>
</html>
);
}์์ฒ ์ด๊ฐ ์ง๋ ค ํ๋ ๊ฒ์ฒ๋ผ layout.tsx ์์ฒด๊ฐ ํต์งธ๋ก ๋ฒ๋ค๋ง๋์ด ํฐ์ผ๋ก ๋ด๋ ค๊ฐ๋ ๋์ฐํ ์ฌํ๋ ํผํ๋ฉด์, ๋ด๋ถ์ ๊น๋ฆฌ๋ ๋ชจ๋ ํ์ ํด๋ผ์ด์ธํธ ์ปดํฌ๋ํธ๋ค์ ๋ง์๊ป useQuery ํ
์ ๋นจ์๋จน์ ์ ์๋ ์๋ฒฝํ ์ํคํ
์ฒ์
๋๋ค!
๐ฃ ์์ฒ ์ด์ ํด๊ทผ ์ผ๊ธฐ
์, ์ง์ง ์๋ฆ ๋์๋ค... ๋ฐฉ๊ธ ์ํธ ๋ฆฌ๋ ๋์ด ๋ธ๋ผ์ฐ์ ์ฝ์์์ ๋ด๊ฐ ๋ง๋ "์ ์ญ QueryClient" ํ๋ ๋๋ฌธ์ ์์๋ฆฌ ๋ด ๋๊ธฐ์ ํ๋กํ ํ ํฐ ์บ์๊ฐ ๋ด ๋ธ๋ผ์ฐ์ ํ๋ฉด์ ๋๊ฐ์ด ๋ ๋๋ง๋๋ ๊ฑธ ์ฌํํด์ฃผ์ จ๋๋ฐ ๋ฑ๊ณจ์ด ์น ์๋ํด์ก๋ค;;
๐ก ์ค๋์ ๊ตํ: "Next.js App Router ์๋์๋ ๋ด๊ฐ ์ง ์ฝ๋๊ฐ ์๋ฒ ์ฃผ๋ฐฉ์์ ๋์๊ฐ๋์ง, ์ ์ ์ ํฐ์์ ๋์๊ฐ๋์ง๋ฅผ ๊ตฌ๋ถ ๋ชป ํ๋ฉด ๋ํ ๋ณด์ ์ฌ๊ณ (Cross-Request Leak) ๊ฐ ๋ฐ์ํ๋ค. QueryClient๋ ํด๋ผ์ด์ธํธ์ผ ๋ ์ ์ญ ์ฌ์ฌ์ฉ ๋จ 1๊ฐ, ์๋ฒ์ผ ๋ ๋ฌด์กฐ๊ฑด ์์ฒญ๋น 1๊ฐ์ฉ ์ฐ์ด๋ด์(Factory)!"
๋ ๊ทธ๋ฅ ์ฝ๊ฒ ๊ฐ๋ ค๊ณ const queryClient = new QueryClient() ํ ์ค ๋ก ์น ๊ฒ ๋ฟ์ธ๋ฐ, ๊ทธ๊ฒ ์๋ง ๋ช
์ ์ ์์๊ฐ ๊ฐ์ด ํผ๋จน๋ ๊ฑฐ๋ํ ๊ณต์ฉ ๋๋น๊ฐ ๋ ์ค์ด์ผ ใ
ใ
ใ
. ์ค๋ ์ง์ ๊ฐ์ Next.js๋ก ์ฌ์ด๋ ํ๋ก์ ํธ ์ธํ
ํ ๋ ๋ฌด์กฐ๊ฑด ์ getQueryClient ๋ณด์ผ๋ฌํ๋ ์ดํธ ๋ณต๋ถํด๋์ผ์ง!
๐ ๋ฐฐ์ด ๋ด์ฉ ์ ๊ฒํ๊ธฐ (Quiz)
Q. ๋ค์๊ณผ ๊ฐ์ด ์ปดํฌ๋ํธ ๋ด๋ถ์ useState๋ฅผ ์ด์ฉํด QueryClient ํด๋ผ์ด์ธํธ๋ฅผ ์ด๊ธฐํ(์ด๊น๊ฐ)ํ์ฌ ๊ด๋ฆฌํ๋ ๋ฐฉ๋ฒ์ด ์์ต๋๋ค. ์ด๊ฒ์ด ํ
์ค ์๋ฆฌ์ ์ฌ๋ฐ๋ฅธ ๋ฐฉ๋ฒ์ด์ ์์ ํ ์ด์ ๋ ๋ฌด์์
๋๊น?
'use client'
import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
import { useState } from 'react'
export default function Providers({ children }: { children: React.ReactNode }) {
// ์ปดํฌ๋ํธ ๋ด๋ถ์์ ์์ฑ!
const [queryClient] = useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}- A)
useState์์์ ์ฝ๋ฐฑ ํจ์๋ก ๊ฐ์ธ ์ง์ฐ ์ด๊ธฐํ(Lazy Initialization)๋ฅผ ํ์ผ๋ฏ๋ก ์ปดํฌ๋ํธ๊ฐ ์๋ฌด๋ฆฌ ๋ฆฌ๋ ๋๋ง์ ์ณ๋ ์ ๋ QueryClient๋ฅผ ์ฌ์์ฑํ์ง ์๊ณ ๋ ํผ๋ฐ์ค๋ฅผ ์บ์ฑ(์ ์ง)ํ๊ธฐ ๋๋ฌธ์ ๋๋ค. - B) ๋ธ๋ผ์ฐ์ ๋ฉ๋ชจ๋ฆฌ์ ๋ฌดํ์ ์บ์๊ฐ ์์ด๋ ๊ฑธ ๋ง๊ธฐ ์ํด, ๋ฆฌ๋ ๋๋ง๋ง๋ค ๋ก์ QueryClient๊ฐ ๋ฎ์ด์์์ ธ ํ๊ดด๋๋๋ก ์๋ช ์ ์ ํํ๊ธฐ ๋๋ฌธ์ ๋๋ค.
- C) ์ ์ฝ๋๋ ํ๋ฆฐ ์ฝ๋์
๋๋ค.
useMemo๋ฅผ ๋ฐ๋์ ์ฌ์ฉํด์ผ ๋์ผํ ์ธ์คํด์ค๊ฐ ์ ์ง๋ฉ๋๋ค.
โ
์ ๋ต: A
๐ก ์์ธ ํด์ค:
- ์๋ฆฌ ์ค๋ช
: ์ผ๋ฐ์ ์ธ
new QueryClient()์คํ ํจ์๋ฅผ ์๋จ ์ ์ญ์ ๋๋ฉด ์๋ฒ์์ ๊ณต์ ์คํ ๋ฆฌ์ง ์คํ๊ฒํฐ(Leak)๊ฐ ๋๊ณ , ์ปดํฌ๋ํธ ๋ด๋ถ์const queryClient = new QueryClient()๋ ๋ค ์น๋ฉด ํ๋ก ํธ์์ ์กฐ๊ธ์ ๋ ๋๋ง ํฐ์ง ๋๋ง๋ค ์บ์ ์ธ์คํด์ค๊ฐ ๊ณ์ ์ด๊ธฐํ(ํ๊ดด)๋์ด ๋ฐ์ดํฐ๊ฐ ๋ ์๊ฐ๋๋ค. ํ์ง๋ง ์ ๋ ๊ฒuseState(() => new ...)์ฝ๋ฐฑํ์์ผ๋ก ๋๊ฒจ์ฃผ๋ฉด, ๊ฐ์ฅ ์๋ฒฝํ๊ฒ ๋ชจ๋ ์ ์ (์์ฒญ๋น 1๊ฐ)๋ง๋ค ๋ ๋ฆฝ๋๋ฉด์๋, ์ปดํฌ๋ํธ ์๋ช ์ฃผ๊ธฐ ๋ด๋ด(๋ธ๋ผ์ฐ์ ๊ฐ ์ผ์ ธ์๋ ํ) ์ ๋ ์ฌํ ๋น(ํ๊ดด) ์ ๋๋ 1ํ์ฑ ์ด๊ธฐํ ๊ฐ ๋ณด์ฅ๋ฉ๋๋ค! - ์ค๋ต ํผ๋๋ฐฑ: "์์ฒ ๋, B๋ฒ์ฒ๋ผ ๋ฆฌ๋ ๋๋ง๋ง๋ค ์บ์ํต(QueryClient) ์ด ์ฐข์ด๋ฐ๊ฒจ์ง๋ฉด ํ๋ฉด ๊น๋นก์์ด ์ด๋ง์ด๋งํ ๊ฒ๋๋ค. ์บ์๋ ์ด๋ ค๋๋ ๊ฒ ๋ชฉ์ ์ด์์!"
- ๐ ํต์ฌ ๊ธฐ์ต๋ฒ: ์ธ์คํด์ค ์๋ช
์ฐ์ฅ์ ์ ๊ฝ,
useState(() => init)์ง์ฐ ์ด๊ธฐํ(Lazy Init)!