๐ก 01. Infinite Queries์ ํ์ด์ง๋ค์ด์ ๊น๊ฒ ํ๋ณด๊ธฐ
๐ ๊ฐ์
useInfiniteQuery์ ๋์ ์๋ฆฌ์ Cursor ๊ธฐ๋ฐ ๋ฌดํ ์คํฌ๋กค ๊ตฌํ๋ฒ, ๊ทธ๋ฆฌ๊ณ select ์ต์ ์ ํ์ฉํ ๋ฐ์ดํฐ ๋ค์ด์ดํธ ๋ฐ ์ฑ๋ฅ ์ต์ ํ ์ ๋ต์ ๋ฐฐ์๋๋ค.
๐ ๋ชฉ์ฐจ
- โ๏ธ ์์ฒ ์ด์ ๊ณ ๋ฏผ: "๋ฌดํ ์คํฌ๋กค, ๋ ๋๋ง์ด ๊ฐ์๋ก ๋๋ ค์ ธ์!"
- ๐ค ์ ์์์ผ ํ๋๊ฐ: ๋ฐฐ์ด์ ํจ์ ๊ณผ ๋ค์ด์ดํธ
- 1.
useInfiniteQueryํด๋ถํ (Cursor ๊ธฐ๋ฐ) - 2. ๋ฐ์ดํฐ ๋ค์ด์ดํธ ๋ํ์:
select์ต์ - ๐ ๋ฐฐ์ด ๋ด์ฉ ์ ๊ฒํ๊ธฐ (Quiz)
"์์ฒ ๋, ๋ฌดํ ์คํฌ๋กค ๋ฐ์ดํฐ ๋ฐฐ์ด 5๊ฐ ์ด์ด ๋ถ์ธ๋ค๊ณ ํ๋ก ํธ ๋ฐฐ์ด ๋ณต์ฌ(
concat)๋ก ์ฐ์ฐ ๋ค ๋๋ฆฌ๊ณ ๊ณ์ ๊ฐ์?"
โ๏ธ ์์ฒ ์ด์ ๊ณ ๋ฏผ: "๋ฌดํ ์คํฌ๋กค, ๋ ๋๋ง์ด ๊ฐ์๋ก ๋๋ ค์ ธ์!"
(์์์ผ ์คํ, ๋์์ด ๋ด๋ ค๊ฐ๋ ์ผํ๋ชฐ ์ํ ๋ฆฌ์คํธ๋ฅผ ๋ณด๋ฉฐ ํ์จ ์ฌ๋ ์์ฒ )
๐ฃ ์์ฒ : ๋ฆฌ๋ ๋! ์ ๋๋์ด ์ปค๋ฎค๋ํฐ ์ฑ ๋ฉ์ธ ํผ๋์ ๋ฌดํ ์คํฌ๋กค์ useQuery๋ก ๋ฌ์์ต๋๋ค.
ํ์ด์ง ๋ฒํธ(pageState) 1์ฉ ์ฌ๋ฆด ๋๋ง๋ค ๊ธฐ์กด ๋ฐฐ์ด์ [...old, ...newData] ์ด๋ ๊ฒ ํฉ์ณ์ State๋ก ์ ์ฅํ๊ณ , ๊ทธ๊ฑธ map ๋๋ ค์ ๊ทธ๋ฆฌ๊ณ ์์ด์. ๊ทผ๋ฐ ๋ฐ์ดํฐ๊ฐ ํ 1,000๊ฐ ๋์ด๊ฐ๋๊น ํ ๋ด๋ฆด ๋๋ง๋ค ๋ ๋๋ง์ด ํฑํฑ ๊ฑธ๋ฆฌ๋ค์ ใ
ใ
.
๐ฆ ์ํธ: ์์ฒ ๋, ๋ฐ์ดํฐ ํจ์นญ์ด ๋ฐฑ๊ทธ๋ผ์ด๋์์ ์ผ์ด๋ ๋๋ง๋ค 1000 + 20 ๊ฐ์ ๊ฑฐ๋ํ ๋ฐฐ์ด ๋ญํ
์ด๋ฅผ ํต์งธ๋ก ๋ฅ ์ปดํ์ด(Deep Compare) ๋๋ฆฌ๊ณ , ๊ทธ๊ฑธ ๋ ๋ก์ปฌ useState ์ ๋ณต์ฌํด์ ๋ฐ์ด๋ฃ์ผ๋๊น ๋ธ๋ผ์ฐ์ ๋ฉ๋ชจ๋ฆฌ๊ฐ ํฐ์ ธ๋๊ฐ๋ ๊ฒ๋๋ค.
๋ฌดํ ์คํฌ๋กค์ useQuery ํ๋๋ก ๋ฐฐ์ด์ ๋ง๋๋ ๊ฒ ์๋๋ผ, useInfiniteQuery ์ select ์ต์
์ ์๋ ๊ฒ์ผ๋ก ์บ์ ํฌ์ธํฐ๋ง ๋๋ ค๊ฐ๋ ์์ฃผ ์ฐ์ํ ์ํคํ
์ฒ๊ฐ ํ์ํฉ๋๋ค.
๐ค ์ ์์์ผ ํ๋๊ฐ: ๋ฐฐ์ด์ ํจ์ ๊ณผ ๋ค์ด์ดํธ
์๋ง์ ๋ฐ์ดํฐ(์: ํธ์ํฐ ํ์๋ผ์ธ, ์ธ์คํ ํผ๋)๋ฅผ ์ ์ ์๊ฒ ํ ๋ฒ์ ์๋ ๊ฑด ์๋ฒ๋น์ ๋ญ๋น์ ๋๋ค. ๊ทธ๋์ ์ฐ๋ฆฌ๋ Cursor(๋ง์ง๋ง์ผ๋ก ๋ณธ ๊ธ ๋ฒํธ)๋ Page(ํ์ด์ง ๋ฒํธ)๋ฅผ ๊ฑด๋ค์ 20๊ฐ์ฉ ์ชผ๊ฐ ๋ฐ์ต๋๋ค(Pagination).
๋ฌธ์ ๋ ํ๋ก ํธ์๋์
๋๋ค. ๊ณ์ ๋ฐ์์ค๋ ๊ฐ์ฒด ๋ฉ์ด๋ฆฌ๋ค์ ์ด๋์ ์บ์ฑํ๊ณ , ์ด๋ป๊ฒ ํฉ์น ๊ฒ์ธ๊ฐ?
React Query์ useInfiniteQuery๋ ์ด ํ์ด์ง ์ค๋
์ท๋ค์ ๋ฐฐ์ด ์์ ๋ฐฐ์ด(pages[0], pages[1])๋ก ์บ์์ ์์๋๊ณ , ๋ค์ ํ์ด์ง(Next Cursor)๊ฐ ๋ฌด์์ธ์ง ์๊ธฐ ์ค์ค๋ก ์ถ์ (Tracking)ํ๋ ์์ ๊ด๋ฆฌํ ํ
์
๋๋ค.
๋ํ ์ฌ๊ธฐ์ ๋ฐ์ดํฐ ์ฌ์ด์ฆ๋ฅผ ๊น์์ฃผ๋ ๊ถ๊ทน๊ธฐ select ์ต์
์ด ๊ฒฐํฉ๋๋ฉด, ๋ธ๋ผ์ฐ์ ๊ฐ ๋ฒ๋ฆด ๋ฐ์ดํฐ๋ ์ ์ด์ ์บ์ ๊ตฌ๋
๊ณ์ธต์์๋ถํฐ ์๋ผ๋ฒ๋ฆด ์ ์์ต๋๋ค.
1. useInfiniteQuery ํด๋ถํ (Cursor ๊ธฐ๋ฐ)
TkDodo์ ์ํฐํด #26์ ๋ณด๋ฉด, ๋ฌดํ ์ฟผ๋ฆฌ๋ ์ฌ์ค "ํ์ด์ง๋ณ ์ค๋ ์ท์ ๋ด์ 2์ฐจ์ ๋ฐฐ์ด"์ ๋๋ค.
// ๐ types.ts
export type Post = { id: number; title: string; content: string };
export type FetchPostsResponse = {
data: Post[];
nextCursor: number | null; // ์๋ฒ์์ "๋ค์ ํธ์ถ์ ์ด ๋ฒํธ๋ถํฐ ํด!" ๋ผ๊ณ ์ค
};๊ฐ์ฅ ๊ธฐ์ด์ ์ด๋ฉด์ ์๋ฒฝํ TypeScript ๊ธฐ๋ฐ ๋ฌดํ ์ฟผ๋ฆฌ ์ต์ ๊ฐ์ฒด๋ฅผ ์ก์๋ด ์๋ค.
// ๐ hooks/queries/useInfinitePosts.ts
import { useInfiniteQuery } from '@tanstack/react-query';
import axios from 'axios';
import { FetchPostsResponse } from '@/types';
export const useInfinitePosts = () => {
return useInfiniteQuery({
queryKey: ['posts', 'infinite'],
// 1๏ธโฃ pageParam: ์ด๊ฒ ๋ฐ๋ก Next.js์ Cursor์
๋๋ค. ์ต์ด์ ๋ฌด์กฐ๊ฑด ์ฒซ ํ์ด์ง(0)๋ก ์์.
queryFn: async ({ pageParam = 0 }) => {
const res = await axios.get<FetchPostsResponse>(`/posts?cursor=${pageParam}`);
return res.data;
},
// 2๏ธโฃ TypeScript์ ์์: ์ด๊ธฐ ์ปค์๊ฐ์ ํ์๋ก ์ ์ (v5 ๊ธฐ๋ฅ)
initialPageParam: 0,
// 3๏ธโฃ ํต์ฌ ๋ง๋ฒ: ๋ฐฉ๊ธ ์๋ต๋ฐ์ ๋ฐ์ดํฐ(lastPage)๋ฅผ ๊น๋ณด๊ณ , ๋ค์ ์ปค์๊ฐ ์์ผ๋ฉด ๋ฆฌํด!
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
};์ด์ ์ปดํฌ๋ํธ๋ ์ํ ๋ณ์(page=1)๋ setPage๋ฅผ ๋ค๊ณ ์์ ํ์๊ฐ ์ผ์ ์์ต๋๋ค.
์ค์ง ํ
์ด ์ฃผ๋ fetchNextPage() ํจ์ ํ๋๋ง ํธ์ถํ๋ฉด ๋๋ฉ๋๋ค. ํ๋ฉด์ ๋ถ์ผ ๋๋ data.pages.map์ผ๋ก ํ์ด์ ๊ทธ๋ฆฝ๋๋ค.
function Feed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfinitePosts();
return (
<div>
{/* data.pages๋ [ {data:[20๊ฐ], next:1}, {data:[20๊ฐ], next:2} ] ํํ์ 2์ฐจ์ ๋ฐฐ์ด์ด๋ค. */}
{data?.pages.map((page, index) => (
<React.Fragment key={index}>
{page.data.map(post => <PostCard key={post.id} post={post} />)}
</React.Fragment>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? '๋ถ๋ฌ์ค๋ ์ค...' : hasNextPage ? '๋ ๋ณด๊ธฐ' : '๋ง์ง๋ง ๊ธ์
๋๋ค'}
</button>
</div>
)
}๐ฅ ์๋์ด์ ์กฐ์ธ:
์ค๋ฌด์์๋๋ ๋ณด๊ธฐ๋ฒํผ ๋์IntersectionObserver๋ฅผ ์ด์ฉํด ๋ฐ๋ฅ์ ๋ฟ์ผ๋ฉด(ViewPort์ ๋ค์ด์ค๋ฉด)fetchNextPage()๋ฅผ ์๋์ผ๋ก ๋ฐ์ฌํ๊ฒ๋ ๋ง๋ค์ด ์ง์ง ๋ฌดํ ์คํฌ๋กค์ ์์ฑํฉ๋๋ค. (react-intersection-observer ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ถ์ฒ)
2. ๋ฐ์ดํฐ ๋ค์ด์ดํธ ๋ํ์: select ์ต์
์, ์์ฒ ์ด์ "๋ ๋๋ง ํฑํฑ ๊ฑธ๋ ค์" ๋ฌธ์ ๊ฐ ์์ง ์ ํ๋ ธ์ฃ ?
์๋ฒ์์ Post ์ ๋ณด๋ก title, content ๋ง ์ค๋ฉด ๋คํ์ธ๋ฐ... ์ ์ ์ ๋ณด ๋ญํ๊ธฐ, ์ข์์ ๋๋ฅธ ์ ์ ๋ฐฐ์ด, ์ด๋ฏธ์ง URL ์ค๋ฌด ๊ฐ, ์ธ๋ฐ์๋ ๋ฉํ๋ฐ์ดํฐ๊น์ง ํต์งธ๋ก ๋ด๋ ค์จ๋ค๋ฉด? 1,000๊ฐ๋ฅผ ๋ชจ์ ๊ทธ๋ฆด ๋ ๋ธ๋ผ์ฐ์ ๋ ๋๊ฐ ๋ฌด๊ฑฐ์์ง๋๋ค.
๊ฒ๋ค๊ฐ 2์ฐจ์ ๋ฐฐ์ด(data.pages.map)์ ๊ทธ๋ฆฌ๋ ๊ฒ๋ ์๊ทผํ ์ฝ๋ ๋์ค๊ฐ ๊น์ด์ ธ์ ๊ฐ๋
์ฑ์ด ๋จ์ด์ง๋๋ค.
์ฐ๋ฆฌ๋ React Query์ select ์ต์
์ผ๋ก ์ด ์์ API ๋ฐ์ดํฐ๋ฅผ "๋ด ์
๋ง์ ๋ง๋ ๊ฐ๋ฒผ์ด 1์ฐจ์ ๋ ๋๋ง ๋ฐฐ์ด"๋ก ๊น์๋ฒ๋ฆด ๊ฒ๋๋ค!
export const useOptimizedInfinitePosts = () => {
return useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: fetchInfinitePosts,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
// ๐ ๋ฐ์ดํฐ ํญํ ๋ค์ด์ดํธ & 1์ฐจ์ ๋ฐฐ์ด(Flat) ํ!
select: (data) => ({
// pages (2์ฐจ์) ๋ฐฐ์ด์ flatMap์ผ๋ก 1์ฐจ์์ผ๋ก ์ซ ํผ์นจ
// ๊ทธ๋ฆฌ๊ณ ๋ฌด๊ฑฐ์ด content ๋ค ๋ฒ๋ฆฌ๊ณ ๋ฑ id, title๋ง ๋ฉ๋ชจ๋ฆฌ ๋ ๋๋ก ์ฌ๋ ค๋ณด๋
pages: data.pages.flatMap((page) =>
page.data.map((post) => ({
id: post.id,
title: post.title,
}))
),
pageParams: data.pageParams,
}),
});
};select ํจ์ ๋ด๋ถ์ ์ฐ์ฐ์ React Query๊ฐ ๋ด๋ถ์ ์ผ๋ก ๊ทนํ์ Memoization(์บ์ฑ) ์ ๋๋ ค์ค๋๋ค.
์๋ณธ ๋ฐ์ดํฐ(์บ์ ๊ธ๊ณ )์ ๋ฐ์ดํฐ 1๊ฐ๋ผ๋ ๋ฐ๋์ง ์๋ ์ด์ ์ ๋ flatMap์ ๋ค์ ์ฐ์ฐํ์ง ์์์!
๋๋ถ์ ์ปดํฌ๋ํธ ์ชฝ์์๋ ์๋ฌด ์๊ฐ ์์ด ๊ฐ๋ฒผ์์ง data.pages.map ๋ง 1์ฐจ์ ๋ฐฐ์ด๋ก ํธ์ํ๊ฒ ์ฆ๊ธฐ๋ฉด ๋ ๋๋ง ์ค๋ชฐ ๋ค์ด์ดํธ๊ฐ ์์ฑ๋๋ ๊ฒ๋๋ค.
๐ฃ ์์ฒ ์ด์ ํด๊ทผ ์ผ๊ธฐ
์... ์ค๋ select ์ต์
์ flatMap ํ์์ ๋ฐ์ดํฐ ๊ฐ๋ณ๊ฒ ๊น์๋๋๋, ๋ฌดํ ์คํฌ๋กค 1,000๊ฐ ๋ด๋ ค๊ฐ๋ ๊ฑฐ ํ๋ ์ ์์ ๋ถ๋๋ฝ๊ฒ ๊ฝํ๋ค ๋ฏธ์ณค๋ค ์ง์ง ใ
ใ
๐ก ์ค๋์ ๊ตํ: "๋ฌดํ ์คํฌ๋กค์ ๋ด๊ฐ ๋ฐฐ์ด ๋ณต์ฌ(Local State) ํด์ ๋ถ์ด๋ ๊ฒ ์๋๋ผ
useInfiniteQuery์๊ฒ ์ปค์ ํฌ์ธํฐ๋ง ๋์ ธ์ฃผ๋ ๊ฑฐ๋ค! ๊ทธ๋ฆฌ๊ณ ๋ฐ์ดํฐ๊ฐ ๋น๋ํด์ง ๋select๊ณผ๋๋ก ๋ทฐ ์ ์ฉ ๊ฐ์ฒด๋ก ๋ ์นด๋กญ๊ฒ ์กฐ๋ฆฝ(Memoize) ์์ผ์ UI๋ฅผ ๊ตฌ์ถํ์."
์์ ์ useEffect ์์์ ๋ฐฐ์ด ์๋ฅด๊ณ ๋ถ์ด๊ณ ์คํฌ๋กค ์ด๋ฒคํธ ๋ฐ์ธ๋ฉ ๊ฑธ๊ณ ์์ฃผ ์คํ๊ฒํฐ ์๋ฆฌ์ฌ๊ฐ ๋ฐ๋ก ์์๋๋ฐ, ์ญ์ ๋๊ตฌ๊ฐ ์ข์ผ๋๊น ๋ก์ง์ด ์ปดํฌ๋ํธ ์ฝ๋ 10์ค ์ด๋ด๋ก ์์ถ๋๋ค. ๋ฌดํ ์คํฌ๋กค ๋ฌด์์ ๋๋ฐ ์ด์ ์คํฌ๋กค ๋ฐ๋ฅ ์น ๋๋ง๋ค ์ง๋ฆฟํ๋ค! ๋ด์ผ ์ํธ ๋ฆฌ๋ ๋ํํ
์ฝ๋ ๋ฆฌ๋ทฐ ์์ ๋น๋นํ ์ฌ๋ ค์ผ์ง ๐.
๐ ๋ฐฐ์ด ๋ด์ฉ ์ ๊ฒํ๊ธฐ (Quiz)
Q. ์์ฒ ์ด๊ฐ ์ผํ๋ชฐ ๋ฌดํ ์คํฌ๋กค์ ๊ตฌํํ๋ ์ค, ํน์ ์ํ ํ๋๊ฐ ์ญ์ ๋๊ฑฐ๋ ํ์ ๋ก ์ํ๊ฐ ๋ณ๊ฒฝ๋๋ ์ก์
(useMutation)์ด ์ผ์ด๋ฌ์ต๋๋ค. ์ด๋, ๋ฌดํ ์คํฌ๋กค ํํ์ ๊ฑฐ๋ํ ๋ค์ฐจ์ ๋ฐฐ์ด ๋ฐ์ดํฐ ์ค ๋ฑ ๊ทธ ํ๋์ ์ํ ์ํ๋ง (์ ์ฒด ๋ฆฌํจ์นญ ์์ด) '๋๊ด์ ๋ฎ์ด์ฐ๊ธฐ(setQueryData)'๋ก ๊ณ ์ณ ์น๋ ค๋ฉด, TypeScript ํ๊ฒฝ์์ ์ด๋ป๊ฒ ์ฝ๋ฐฑ์ ์์ฑํด์ผ ์์ ํ ๊น์?
- A)
queryClient.setQueryData(['posts', 'infinite'], (old) => { ... })์์์ 1์ฐจ์ ๋ฐฐ์ด๋กfilter์น๊ณ ๋๋ ค์ค๋ค. - B) ๋ถ๊ฐ๋ฅํ๋ค. ๋ฌดํ ์คํฌ๋กค ๋ฐ์ดํฐ ๊ตฌ์กฐ(
pages,pageParams)๋ ๋๋ฌด ๋ณต์กํด์ ๋ถ๋ถ ์์ ์ด ๊ผฌ์ด๋ฏ๋ก ๋ฌด์กฐ๊ฑดinvalidateQueries๋ก ์ ์ฒด ์ด๊ธฐ๋ถํฐ ์ฌํต์ ํด์ผ๋ง ํ๋ค. - C)
setQueryData์oldํด๋ก์ ์์์old.pages๋ฐฐ์ด์map์ํํ์ฌpage๋ญ์น๋ฅผ ์ด๊ณ , ๊ทธ ์์page.data๋ฐฐ์ด์ ํ ๋ฒ ๋map์ผ๋ก ์ฐพ์ ํด๋น ์ํ๋ง ์์ ํด์ค ๋๊ฐ์ 2์ฐจ์ ๊ฐ์ฒด ๊ตฌ์กฐ({ pages, pageParams })๋ฅผ ๋ฐํํ๋ค.
โ
์ ๋ต: C
๐ก ์์ธ ํด์ค:
- ์๋ฆฌ ์ค๋ช
: TkDodo ์ํฐํด #26๊ณผ #2๋ฅผ ์ดํด๋ณด๋ฉด
useInfiniteQuery์ ์บ์ ๋ฉ๋ชจ๋ฆฌ ๋ฐ์ดํฐ ์๋ณธ์ ํญ์{ pages: [...], pageParams: [...] }ํํ์ ๊ธฐ๊ดดํ ๊ฐ์ฒด ๋ฉ์ด๋ฆฌ์ ๋๋ค. ์๋์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ์ชผ๊ฐ ์์ (setQueryData) ํ ๋๋ ๋ฐ๋์ ๊ธฐ์กด์ 2์ฐจ์(ํ์ด์ง ์์ ์์ดํ ) ๋ฐฐ์ด ๊ตฌ์กฐ๋ฅผ ํผ์ํ์ง ์๊ณ ์๋ฒฝํ๊ฒ ๋์ผํ ๊ป๋ฐ๊ธฐ๋ก ๊ฐ์ธ์ ๋ฐํํด ์ฃผ์ด์ผ React Query๊ฐ ์๋ฌ๋ฅผ ๋ฟ์ง ์์ต๋๋ค. - ์ค๋ต ํผ๋๋ฐฑ: "์์ฒ ๋! A๋ฒ์ฒ๋ผ 1์ฐจ์ ์ฉ ๋ฐฐ์ด๋ก ๋ฆฌํดํด๋ฒ๋ฆฌ๋ฉด ์บ์ํต ๊ตฌ์กฐ๊ฐ ํฐ์ ธ์ ๋ค์ ๋ฌดํ ์คํฌ๋กค ํ์นญํ ๋ ํฌ๋์ ์๋ฌ๊ฐ ํญ๋ฐํฉ๋๋ค. ๊ตฌ์กฐ์ ๊ป๋ฐ๊ธฐ๋ ํญ์ ์ ์งํด์ผ ํด์!"
- ๐ ํต์ฌ ๊ธฐ์ต๋ฒ: ๋ฌดํ ๋ฌดํจํ ๋ฎ์ด์ฐ๊ธฐ(
setQueryData) ํ ๋๋,pages๊ฐ์ฒด ๊ตฌ์กฐ ๊ทธ๋๋ก ์ด์ค๋ฃจํ(map์์map)๋ก ๊น๊ณ ๋ซ๊ธฐ!