요즘은 Nextjs가 대세다. 암튼 대세다! 근데 백오피스나 물류시스템 등을 개발하려면 사실 SEO와 여러 기능들이 있는 nextjs의 기능들이 too much 하다고 느끼는 경우가 많다. 나는 nextjs를 사용하지만 최소한의 필요 기능만 사용하고 react로 재고 관리 현황 백오피스를 구현해보고자 한다. 그리고 tanstack query가 좋다는데, 아직 안써봐서 이번에 써보며 좋은 점을 함께 나눠보자.
일단 엄청나게 코드 효율이 좋아진 걸 느끼면서 시작!
방대해진 로직에 useEffect가 복잡해진다.
useEffect
의 의존성 배열과 내부 로직이 복잡해집니다. setState
를 호출하여 발생하는 메모리 누수(memory leak)를 방지하기 위한 추가 코드가 필요합니다. 이러한 문제들을 해결하기 위해 우리는 수많은 useState
와 useEffect
를 작성하며 비즈니스 로직보다 서버 데이터 상태를 관리하는 데 더 많은 시간을 쏟게 된다.
똑똑한 초기로딩
리액트를 사용할 때 초기 데이터를 불러오는 건 useEffect 안에 fetch를 넣어서 페칭되는 시간을 기다렸다. 이 때 isLoading이나 적절한 error 처리를 해주지 않으면 데이터보다 렌더링이 먼저되는 현상이 발생하여, 많은 주의를 요하였다. 이를 쉽게 해결해주는 여러 라이브러리들이 존재했으니, Tanstack query도 일부다. 근데 Nextjs를 사용하고자 하였으니, 초기 데이터는 서버컴포넌트에서 불러와서 전달해준다.
// src/app/inventory/page.tsx
export default async function InventoryPage() {
const initialData = await fetchInventory(1);
return <InventoryClient initialData={initialData} />;
}
이렇게 서버에서 가져온 initData를 Tanstack query가 물려받아 캐시를 초기화 한뒤, 그 이후 모든 상호작용 (필터링, 페이지 이동 등)은 클라이언트 사이드에서 관리 된다.
한 마디로 서버 상태 관리 라이브러리이다. React App의 서버 상태를 클라이언트 상태와 분리하여 관리하고 동기화해준다. 뭔가 데이터가 섞이지 않을 것 같아서 좋다.
위에 언급한 isLoading 등 보일러플레이트 코드를 내장하고 있어서 사용하기 쉽다.
Before: useState
와 useEffect
를 사용한 전통적인 방식
function InventoryList() {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setIsLoading(true);
fetch('/api/inventory', { signal: controller.signal })
.then(res => res.json())
.then(data => setData(data))
.catch(err => {
if (err.name !== 'AbortError') {
setError(err);
}
})
.finally(() => setIsLoading(false));
return () => controller.abort(); // Cleanup
}, []);
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>에러 발생: {error.message}</div>;
// ... 데이터 렌더링
}
After: TanStack Query의 useQuery
를 사용한 방식
import { useQuery } from '@tanstack/react-query';
function InventoryList() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ['inventory'],
queryFn: () => fetch('/api/inventory').then(res => res.json()),
});
if (isLoading) return <div>로딩 중...</div>;
if (isError) return <div>에러 발생: {error.message}</div>;
// ... 데이터 렌더링
}
압도적으로 간결해졌다. 로딩, 에러, 데이터 상태를 위한 react 상태관리 로직이 useQuery 훅으로 대체 된다.
useQuery
) 흐름export default function InventoryClient({ initialData }: { initialData: InventoryResponse }) {
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<InventoryFilters>({});
const { data = initialData, isLoading, isFetching, error } = useInventory(page, filters);
재고관리 페이지의 초기 코드이다. 어라 tanstack query가 없는대요. 할수 있지만 이는 hook으로 래핑해놨다.
export function useInventory(page: number, filters: InventoryFilters) {
return useQuery({
queryKey: ['inventory', page, filters],
queryFn: () => fetchInventory(page, filters),
staleTime: 2 * 60 * 1000, // 2분 (재고는 자주 변경됨)
});
}
useInventory를 호출하면 page와 filters의 항목을 받는다. 이는 변화를 감지하는 useEffect와 비슷한 효과를 가진다. useQuery의 queryKey로 변화를 계속 감지한다. 감지 될때마다 queryFn이 실행되는 재실행되는 것이다. 다시 client 페이지로 돌아와서
const { data = initialData, isLoading, isFetching, error } = useInventory(page, filters);
사용자가 필터를 바꾸거나 다른 페이지로 이동할 때, useInventory
훅 내부의 useQuery
는 다음과 같이 동작한다.
queryKey
변경 감지: queryKey
는 ['inventory', page, filters]
로 설정되어 있습니다. 사용자가 페이지를 2로 바꾸면 queryKey
는 ['inventory', 2, filters]
가 된다. TanStack Query는 이 queryKey
가 이전과 달라졌음을 즉시 감지한다. queryKey
에 해당하는 데이터가 캐시에 없다고 판단하고, queryFn
으로 등록된 fetchInventory(2, filters)
함수를 자동으로 실행하여 서버에 2페이지 데이터를 요청한다. useQuery
가 반환하는 isFetching
상태가 true
가 되고, UI는 이를 이용해 사용자에게 "새로고침 중..."과 같은 피드백을 보여준다. useMutation
) 흐름
export function useUpdateInventory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, quantity }: { id: string; quantity: number }) =>
updateInventory({ id, quantity }),
onSuccess: () => {
// 재고 업데이트 성공 시 관련 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['inventory'] });
},
onError: (error: Error) => {
alert(`업데이트 실패: ${error.message}`);
},
});
}
재고 수량을 수정하고 '저장' 버튼을 눌렀을 때의 흐름은 TanStack Query의 진정한 파워를 보여준다.
mutate
함수 호출: '저장' 버튼 클릭 시 useUpdateInventory
훅에서 반환된 mutation.mutate()
함수가 호출된다. useMutation
에 등록된 updateInventory
함수가 실행되어 서버에 PATCH
요청을 보내 데이터베이스의 실제 값을 수정한다. onSuccess
콜백에 있던 queryClient.invalidateQueries({ queryKey: ['inventory'] })
가 실행된다. 이 코드는 TanStack Query에게 "이제 'inventory'와 관련된 데이터는 최신이 아니니, 전부 무효화시켜!" 라고 선언하는 것과 같다. GET
요청: useInventory
훅은 자신이 보여주던 데이터가 무효화되었다는 신호를 받는다. 그리고 화면에 보여지고 있으므로, 데이터를 최신 상태로 유지하기 위해 자동으로 fetchInventory
함수를 다시 실행하여 서버로부터 새로운 GET
요청을 보낸다. isLoading
vs isFetching
, 정확히 알고 쓰기TanStack Query를 사용하다 보면 isLoading
과 isFetching
이라는 두 가지 로딩 상태를 만나게 된다. 둘의 차이를 아는 것은 더 나은 UX를 만드는 데 중요하다.
isLoading
: 오직 첫 로딩 시에만 true
가 된다. 해당 queryKey
에 대한 데이터가 캐시에 전혀 없을 때의 첫 요청을 의미한다. 보통 화면 전체를 덮는 스켈레톤 UI나 로딩 스피너를 보여줄 때 사용한다. isFetching
: 모든 요청 시에 true
가 된다. 첫 로딩, 캐시된 데이터의 백그라운드 업데이트, 수동 새로고침 등 API 요청이 발생하기만 하면 항상 true
가 된다. 보통 화면의 일부에 작은 로딩 인디케이터를 보여줄 때 사용한다. 시나리오 | isLoading | isFetching | 설명 |
---|---|---|---|
1. 재고 페이지 첫 방문 | true | true | 첫 로딩. 전체 화면 로더에 적합. |
2. 페이지 2로 이동 | false | true | 데이터 리페칭. 기존 데이터는 보여주면서 로딩 인디케이터 표시. |
3. 필터 변경 | false | true | 데이터 리페칭. |
4. '새로고침' 버튼 클릭 | false | true | 데이터 리페칭. |
5. 다른 탭에 갔다 돌아옴 | false | true | 백그라운드 자동 리페칭. |
Tanstack query는 단순히 코드를 줄여주는 효과만 있는게 아니다. 데이터 관리 중간관리자가 생긴 느낌이다. "어떻게" 데이터를 가져오고 동기화 할지 고민하는 대신, '무엇을' 보여줄지만 집중할 수 있게 된다.
한 번 사용해보니, 사용하지 않을 이유가 없다. 복잡한 'useEffect'와 수많은 'useEffect'의 늪에서 벗어나 캐싱, 동기화, 상태관리 자동화의 세계로 빠져보자.