
기존의 리액트 패칭 방식
우리가 클라이언트 컴포넌트에서 서버로부터 데이터를 가져올 때
전통적인 리액트(React) 개발 환경에서는 컴포넌트 생명주기를 다루는 useEffect 훅과
상태를 저장하는 useState 훅을 조합하는 것이 가장 일반적인 패턴이었습니다.

하지만 이 방법은 단순히 화면에 데이터를 보여준다는 목적 하나를 달성하기 위해
개발자가 직접 작성하고 관리해야 하는 상용구 코드가 너무 많다는 단점이 존재했습니다.
예를 들어, User 데이터를 가져오는 상황을 가정해봅시다.
import { useState } from 'react'
interface User {
id: string;
name: string;
}
export default function UserProfile({ userId } : { userId: string }) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<Error | null>(null)
// ... (데이터 패칭 로직)
}User 인터페이스 타입을 정의하고, UserProfile 컴포넌트 내부에서 useState로 3가지 상태를 만듭니다.
실제 데이터를 담을 유저 상태, 로딩 중임을 알리는 isLoading 상태, 에러를 담을 error 상태 까지
단 하나의 API를 호출하기 위해 3개의 개별적인 상태 선언 및 타입 선언이 강제됩니다.
프로젝트가 커지고, 화면에서 호출해야 하는 API가 3~4개로 늘어나면
컴포넌트 상단은 온통 상태를 선언하는 것으로 가득차게 되어 코드의 가독성이 크게 떨어졌습니다.
더 큰 문제는 네트워크의 불확실성으로 인해 발생하는 Race Condition 즉 경쟁상태 버그였습니다.
Race Condition이란, 2개 이상의 비동기 네트워크 요청이 응답 순서를 다투면서
개발자가 의도하지 않은 화면을 그려내는 논리적 오류를 의미합니다.
예를 들어, 사용자가 1번 유저 프로필을 클릭했다가 2번 유저 프로필로 이동했다고 가정해보겠습니다.
import { useState, useEffect } from 'react'
interface User {
id: string;
name: string;
}
export default function UserProfile({ userId } : { userId: string }) {
const [user, setUser] = useState<User | null>(null)
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then((data: User) => setUser(data))
}, [userId])
if (!user) return <div>로딩 중...</div>
return <div>안녕하세요, {user.name} 님</div>
}이 코드는 Race Condition에 무방비하게 노출된 위험한 데이터 패칭 방식입니다.
1번 유저를 호출하는 요청과 2번 유저를 호출하는 요청이 거의 동시에 서버로 날아가기 때문입니다.
사용자는 2번 유저의 화면을 보고 있음에도 불구하고, 뒤늦게 도착한 1번 유저의 데이터가 상태를 덮어씌워
화면에 엉뚱한 정보가 표시되는 심각한 오류가 발생하는 것입니다.
이를 방지하기 위해, 컴포넌트가 언마운트 될 때 이전 요청을 무시하는 클린업 함수를 수동으로 작성해야 했습니다.
import { useState, useEffect } from 'react'
interface User {
id: string;
name: string;
}
export default function UserProfile({ userId } : { userId: string }) {
const [user, setUser] = useState<User | null>(null)
useEffect(() => {
let isMounted = true
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then((data: User) => {
if (isMounted) setUser(data)
})
return () => {
isMounted = false
}
}, [userId])
if (!user) return <div>로딩 중...</div>
return <div>안녕하세요, {user.name} 님</div>
}이제 코드는 오류 없이 동작하지만, 비즈니스 로직은 온데간데 없고
오직 리액트의 랜더링 사이클을 방어하기 위한 기계적인 코드로 전락해버렸습니다.
물론, 단순히 데이터 읽어오기라면, React 19에서 새롭게 나온 use 훅으로 Promise를 바로 읽어버리면 됩니다.
하지만, 한번 가져온 데이터를 캐싱하고, 동일한 요청의 중복을 제거하며
포커스가 돌아왔을 때 데이터를 자동으로 갱신하는 등의 진정한 상태 관리 영역에서는
여전히 전용 라이브러리가 압도적인 퍼포먼스를 자랑합니다.
그 피로감을 단번에 해결해줄 해답이 프론트엔드의 비동기 상태관리 표준인 TanStack Query v5입니다.
TanStack Query
이 도구는 애플리케이션의 상태를 모달 창이나 입력 폼 같은 클라이언트 상태와
데이터베이스에 저장되어 언제든 변할 수 있는 서버 상태로 완벽하게 분리합니다.
먼저 이 강력한 도구를 도입하기 위해 터미널에서
@tanstack/react-query와 @tanstack/react-query-devtools를 설치해줍니다.

이 라이브러리가 활동할 수 있도록 애플리케이션 최상단에 QueryClient를 설정하여
전체 컴포넌트 트리에 캐시 저장소를 공급해 주어야 합니다.
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactNode, useState } from 'react'
export default function Providers({ children } : { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}이 때 Next.js App Router 환경에서는 QueryClient가 브라우저 메모리를 사용하므로
반드시 파일 최상단에 'use client' 지시어를 명시한 별도의 Provider 컴포넌트로 분리해야 합니다.
컴포넌트 내부에서 useState를 사용해 새로운 QueryClient 인스턴스를 생성하고
이를 QueryClientProvider 컴포넌트에 client props로 전달하여 자식 컴포넌트들을 감싸줍니다.
이렇게 하면 애플리케이션 전체에 서버 상태 캐시를 공유할 수 있게 됩니다.
이렇게 시스템 셋팅을 마치고 나면, 앞서 보았던 복잡한 코드를 아래와 같이 useQuery 훅으로 바꿀 수 있습니다.
'use client'
import { useQuery } from '@tanstack/react-query'
interface User {
id: string;
name: string;
}
export default function UserProfile({ userId } : { userId: string }) {
const { data: user, isPending, isError, error } = useQuery<User>({
queryKey: ['user', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`)
if (!res.ok) throw new Error("데이터 패칭 실패")
return res.json()
}
})
if (isPending) return <div>로딩 중...</div>
if (isError) return <div>에러: {error.message}</div>
return <div>안녕하세요, {user.name} 님</div>
}useQuery() 내부에 queryKey와 queryFn을 담은 객체를 전달하면 됩니다.
queryKey는 'user'와 userId를 담은 배열로 주문 번호표 역할을 하고
queryFn은 실제 데이터를 가져오는 레시피 역할을 합니다.
useQuery()는 우리에게 데이터 뿐만 아니라 isPending, isError 같은 상태보고서를 실시간으로 제공합니다.
이제 코드가 간결하고 명확하게 변했습니다.
이제 코드를 하나하나 분석해보면, 먼저 useQuery()는 중앙 주방(QueryClientProvider)와 컴포넌트를 연결합니다.
개발자 직접 상태를 추적하지 않아도 useQuery()가 알아서 실시간으로 보고해줍니다.
추가적으로, v5 버젼 이후에는 Next.js의 SSR 및 Suspense와 호환되는 useSuspenseQuery도 제공합니다.

두번째로, queryKey는 데이터의 고유한 식별자 역할을 하는 배열입니다.
이 배열안의 값이 단 하나라도 변경되면, 완전히 새로운 요청으로 인식합니다.

세번째로, queryFn은 실제로 서버에 요청을 보내 데이터를 가져오는 비동기 함수입니다.

TanStack Query는 우리가 fetch API를 쓰던 axios를 쓰던 방식에는 관여하지 않고
오직 이 함수가 성공적으로 완료되는지 에러를 던지는 지에만 관심을 가집니다.
만약 userId가 순식간에 바뀌면, 프레임워크가 알아서 이전 네트워크 요청을 취소하여
앞서 고통받았던 Race Condition을 엔진 레벨에서 원천 차단합니다.
복잡한 Clean-up 함수 없이도 최신의 안전한 데이터를 보장합니다.
또한, queryKey 데이터를 활용해 동일한 데이터를 여러 컴포넌트가 동시에 요구하면
네트워크 낭비 문제도 강력하게 제어할 수 있습니다.
화면의 다른 컴포넌트에서 같은 queryKey로 요청하더라도 서버로 똑같은 API 요청을 보내지 않고
이미 만들어져 보관중인 신선한 캐시 데이터를 즉시 꺼내어 화면에 올려줌으로 네트워크 낭비를 해결합니다.
즉, 랜더링 지연이 전혀 없는 쾌적한 환경을 제어할 수 있습니다.
캐시 라이프사이클
이제 useQuery()가 어떤 기준으로 캐시 메모리를 관리하는지 핵심 라이프사이클을 알아보겠습니다.
소프트웨어 공학에서 생명주기란, 어떤 데이터나 객체가 메모리 상에 생성되고 화면에 그려지다가
결국 브라우저의 메모리에서 완전히 삭제되기까지의 전체 과정을 의미합니다.
즉 그 데이터를 언제까지 믿고 사용할 것인지 언제 다시 서버에 새로운 데이터를 요청할 것인지
언제 메모리에서 영구적으로 삭제할 것인지를 통제하는 것은 다른 문제입니다.
이 라이프사이클을 이해하지 않으면, 캐시가 있다는 걸 알면서도
네트워크 탭에서 왜 자꾸 불필요한 요청이 발생하는지 화면에서 왜 최신으로 갱신되지 않는지 당황하게 됩니다.
TanStack Query의 캐시 데이터는 주로 Fresh, Stale, Inactive의 3가지 상태를 거치게 됩니다.
첫번째는 Fresh 상태입니다. 기술적으로는 서버의 원본 데이터와 클라이언트의 캐시 데이터가 일치한다고 보증됩니다.
만약 손님이 동일한 메뉴를 또 주문하더라도
절대 서버에 새로운 요청을 지시하지 않고 완성되어 있는 캐시 데이터를 응답합니다.
즉, 불필요한 백그라운드 네트워크 요청을 원천 차단하여 서버 부하를 줄이고 네트워크 대역폭을 극으로 절약합니다.
두번째는 Stale 상태입니다. 기술적으로는 데이터가 메모리에 존재하긴 하지만 원본 데이터가 변경되었을지도 모릅니다.
TanStack Query의 진가는 바로 여기서 발휘됩니다.
손님이 Stale 상태의 요리를 주문하면, 손님을 기다리게 하지 않고 일단 Stale된 데이터라도 내어줍니다.
기술적으로는 빈 로딩화면을 보여주는 대신 이전 캐시 데이터를 즉시 랜더링하여 UX 저하를 막는 것입니다.
이와 동시에 백그라운드에서 최신 API 요청으로 똑같은 데이터를 다시 만들라고 지시하는 것입니다.
이것이 백그라운드 리패칭(Background Refetching) 입니다.
잠시 후 새 데이터가 완성되면, 자연스럽게 Stale된 요리를 새 요리로 교체해주어 최신성을 보장합니다.
세번째는 Inactive 상태입니다. 기술적으로는 해당 데이터를 사용하는 컴포넌트가 화면에서 사라진 상태입니다.
이 때 이 요리를 당장 버리지 않고, 창고에 잠시 백업해 둡니다.
사용자가 다시 이전 페이지로 돌아왔을 때 즉각적으로 화면을 보여주기 위해서입니다.
하지만 창고에 영원히 둘수는 없으므로, 이 때부터 가비지 컬렉션(Garbage Collection) 폐기 타이머가 돕니다.
이 타이머가 끝날 때까지 아무도 데이터를 찾지 않으면, 그제야 데이터를 완전히 삭제하여
메모리 누수를 방지합니다.
이 때 개발자는 StaleTime과 gcTime이라는 2가지 핵심 옵션을 통해서 통제합니다.
애플리케이션의 기본 규칙을 셋팅하는 과정을 살펴보겠습니다.
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactNode, useState } from 'react'
export default function Providers({ children } : { children: ReactNode }) {
const [queryClient] = useState(
() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60,
gcTime: 1000 * 60 * 10
}
}
})
)
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClentProvider>
)
}useState() 훅을 통해 새로운 QueryClient 인스턴스를 생성할 때 defaultOptions의 queries 항목에
staleTime과 gcTime을 설정합니다.
여기서 staleTime은 네트워크 요청 횟수를 결정 짓는 가장 중요한 옵션입니다.
아무런 설정을 하지 않았을 때 staleTime의 기본값은 놀랍게도 0이기 때문입니다.
그 아래에 gcTime은 메모리를 정리한다는 본연의 목적에 맞게 gcTime으로 명칭이 확정되었습니다.
기본값은 5분이며, 사용자가 페이지를 나갔다가 다시 돌아왔을 때 서버를 기다릴 필요없이 즉각적입니다.
만약 각자마다 셋팅값이 다르다면, 개별 컴포넌트에서 전역 설정을 덮어쓰는 Override 옵션을 부여해야 합니다.
상품 상세를 불러오는 API 요청을 예시로 들어보겠습니다.
export default function ProductDetail({ productId } : { productId : string }) {
const { data: product, isPending } = useQuery({
queryKey: ['product', productId],
queryFn: async () => {
const res = await fetch(`/api/products/${productId}`)
if (!res.ok) throw new Error('상품 정보를 가져오지 못했습니다.')
return res.json()
},
staleTime: 1000 * 60 * 5
})
// .... (랜더링 코드)
}아래에 staleTime을 별도로 명시하여 이 상품 정보는 변경이 잦지 않으므로 staleTime을 5분으로 설정합니다.
이렇게 개별 query에서 넘기는 옵션은 전역 옵션보다 항상 높은 우선순위를 가집니다.
따라서 전역으로 설정했던 1분이라는 규칙은 무시되고 이 특정 데이터 한해서 5분이라는 유통기한이 강제됩니다.
이렇게 생명주기를 완벽하게 통제할 수 있게 되면 불필요한 로딩 스피너를 없애고
서버 비용을 획기적으로 줄이는 진정한 의미의 최적화를 달성할 수 있습니다.
useQuery()
query는 한국어로 직역하면 질의 혹은 문의라는 뜻을 가지는데
이 질문을 100번, 혹은 1000번 던진다고 해서 구조가 바뀌거나 부서지지 않는 것처럼
컴퓨터 공학에서의 query란 서버의 데이터베이스를 변경하지 않고, 안전하게 순수한 데이터베이스를 읽어옵니다.
개발자들은 안전한 읽기 작업만을 전담하는 도구라는 의미를 담아 그 이름을 명명했으며
이를 사용하기 위해 우리는 파일 최상단에서 아주 간단하게 도구를 불러올 수 있습니다.
import { useQuery } from '@tanstack/react-query'TanStack Query는 과거 v4 이전 버젼까지 아주 오랫동안 ,(콤마)로 인자를 구분하는 다중인자 방식을 지원했습니다.
첫번째 인자로는 쿼리의 이름표를 넣고, 두번째 인자로는 데이터를 가져올 함수를, 3번째로는 캐싱 시간 등 옵션 객체를 넣었습니다.
하지만 이 방식은 프로젝트의 규모가 커질수록 개발자들에게 엄청난 혼란을 야기했습니다.
실제로 옵션만 설정하고 싶은데 2번째 함수 인수를 빼먹거나, 순서를 뒤바꾸면 프레임워크가 이를 인식하지 못해
치명적인 런타임 에러를 뿜어냈고, 타입스크립트 환경에서는 복잡한 인자들의 조합을 추론하느라
컴퓨터의 메모리를 심각하게 소모하는 부작용이 있었습니다.
이러한 혼란을 영구적으로 종식시키기 위해 최신버젼인 v5부터는 모든 인자를 단 하나의 객체로 묶어서 전달하는
단일 객체 형태가 새로운 글로벌 표준으로 완벽하게 강제되었습니다.

이제 각 내부를 구성하는 핵심 구문들을 하나하나 뜯어보겠습니다.
첫번째로, useQuery 뒤에 붙는 꺽새 괄호, 즉 제네릭 문법과 타입스크립트의 결합입니다.

우리는 타입스크립트를 사용하고 있으므로 서버로부터 최종적으로 받아올 데이터의 생김새를
interface로 미리 정의하고, 꺽새 표시로 User 인터페이스를 넘겨주면 데이터 받을 때 User 형식 데이터임을 확신합니다.
덕분에 우리가 나중에 화면에 데이터를 그릴 때 코드 에디터가 자동 완성을 완벽하게 지원해주는 효과를 누립니다.
두번째로, 객체인자의 첫번째 내부 속성인 queryKey를 확인해보겠습니다.

이 queryKey는 캐시 저장소인 중앙 주방에서 이 데이터를 다른 데이터들과 겹치지 않게 식별하기 위한 고유한 키입니다.
v5에서는 이 번호표를 무조건 배열 형태로만 작성해야 하는 엄격한 규칙이 생겼습니다.
단순한 문자열 하나만 필요하더라도 반드시 [ ] 으로 감싸야 합니다.
왜냐하면 프레임워크가 이 배열의 요소들을 직렬화하고 분석하여 캐시를 정교하게 관리하기 때문입니다.
이 배열에는 문자열 뿐만 아니라 객체나 숫자 등 어떤 값이든 넣을 수 있으며
프레임워크는 이 배열 안의 값들이 완벽하게 동일할 때만 같은 데이터로 인식하여 캐시를 공유합니다.
세번째로, 실제 데이터를 가져오는 queryFn입니다.

실제로 서버에 네트워크 요청을 보내서 가져오는 비동기 함수입니다.
여기서 반드시 주의해야 할 점은 이 함수가 반드시 무언가를 반환하는 Promise 형태여야 한다는 것입니다.
오직 이 비동기 함수가 무사히 데이터를 반환하는지 아니면 서버 에러로 인해 예외를 뱉어내는지만 감시합니다.
그리고 그 결과에 맞추어 상태를 업데이트할 뿐입니다.
마지막으로, 이 모든 과정을 거쳐 반환된 결과물을 구조 분해 할당으로 꺼내어 랜더링하는 과정입니다.

단일 객체 형태로 옵션을 전달하고 나면, useQuery 훅은 그에 대한 결과물로
또 다시 유용한 속성들이 듬뿍 담긴 객체를 반환합니다.
우리는 이 반환된 객체에서 구조 분해 할당을 통해 필요한 상태값들만 뽑아서 사용할 수 있습니다.
우아한 로딩과 에러 처리
캐싱된 데이터를 화면에 그릴 때 필연적으로 발생하는 대기 시간인 네트워크 로딩과
예상치 못한 사고인 서버 에러를 사용자에게 매끄럽고 우아하게 보여주는 실전 패턴에 대해 알아보겠습니다.
우리가 프론트엔드 개발을 하면서 많이 작성하는 코드 중 하나가 로딩 스피너를 띄우고
에러 메시지를 띄워주는 조건문일 것입니다.
하지만, 과거의 전통적인 방식이나 이전 도구를 사용할 때는 로딩과 에러 처리가 컴포넌트를 지저분하게 만들었습니다.
다음은 우리가 무심코 작성해오던 과거의 패턴입니다.
'use client'
import { useQuery } from '@tanstack/react-query'
interface UserProfileData {
id: string
name: string
}
export default function UserProfile({ userId } : { userId: string }) {
const { data: user, isLoading, error } = useQuery<UserProfileData>({
queryKey: ['user', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`)
if (!res.ok) throw new Error('에러 발생')
return res.json()
}
})
if (isLoading) return <div className="spinner">로딩 중...</div>
if (isError) return <div className="error">에러 : {error.message}</div>
return <div>안녕하세요, {user.name} 님!</div>
}useQuery()를 사용할 때 isLoading과 isError를 컴포넌트 내부에서 직접 꺼내어 처리하는 모습입니다.
이 방식을 사용하면 비즈니스 로직보다 방어 코드가 먼저 공간을 차지하게 되고
실제 보여주어야 할 핵심 UI는 맨 아래로 밀려나게 됩니다.
이 방식은 프로젝트가 커지고 한 화면에서 여러 개의 API를 호출하게 될 경우
컴포넌트 내부는 온통 isLoading과 isError 조건문으로 도배되어 정작 중요한 비즈니스 로직과 핵심 UI는
저 아래로 밀려나게 됩니다.
더 큰 문제는 에러가 발생했을 때 단순히 Error 문구만 반환해버리면 페이지의 전체 레이아웃이 깨져버릴 수 있습니다.
이러한 문제를 해결하기 위해 TanStack Query v5와 Next.js 16은 완전히 새로운 패러다임을 제시합니다.
먼저, 로딩 상태를 명확히 정의하고 개선해보겠습니다.
v5부터는 데이터를 처음 가져올 때의 상태를 isLoading 대신 isPending 이라는 속성으로 명확하게 구분합니다.
아래 코드는 데이터가 아예 없는 최초 대기 상태를 isPending으로 명확히 잡아냅니다.
'use client'
import { useQuery } from '@tanstack/react-query'
interface UserProfileData {
id: string
name: string
}
export default function UserProfile( { userId } : { userId : string }) {
const { data: user, isPending } = useQuery<UserProfileData>({
queryKey: ['user', userId],
queryFn: async () => fetch(`/api/users/${userId}).then(res => res.json())
})
if (isPending) {
return (
<div className="animate-pulse flex space-x-4 p-4 border rounded-lg">
<div className="rounded-full bg-slate-200 h-10 w-10">
</div>
<div className="flex-1 space-y-4 py-1">
<div className="h-4 bg-slate-200 rounded w-3/4"></div>
<div className="h-4 bg-slate-200 rounded w-1/2"></div>
</div>
</div>
)
}
}이 상태일 때는 스켈레톤(뼈대 UI)를 보여주어 사용자의 심리적 대기 시간을 줄여줍니다.
반면 이미 Stale 데이터가 차려져 있는데 백그라운드에서 새로운 데이터를 가져오고 있는 상태는 isFetching 입니다.
이 때는 이미 화면에 보여줄 이전 데이터가 존재하므로, 화면을 텅빈 스피너로 가려버리면 UX를 해치게 됩니다.
따라서 화면에 데이터가 아예 없을 때만 반응하는 isPending을 사용하여 직관적인 로딩 UI를 구성해야 합니다.
그렇다면 에러는 어떻게 처리해야 할까요?
현대적인 웹 아키텍처에서는 이 처리를 컴포넌트 내부에서 직접 하지 않고
자신을 감싸고 있는 더 거대한 상위 시스템인 Error Boundary로 던져버리는 방식을 사용합니다.
아래 코드를 주목해보세요.
'use client'
import { useQuery } from '@tanstack/react-query'
export default function UserProfile({ userId } : { userId: string }) {
const { data: user, isPending } = useQuery<UserProfileData>({
queryKey: ['user', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`)
if (!res.ok) throw new Error('사용자 정보를 불러오는 중 오류가 발생했습니다.')
return res.json()
},
throwOnError: true
})
if (isPending) return <SkeletonUI />
return <div>안녕하세요, {user.name} 님</div>
}가장 눈여겨 볼 곳은 useQuery 내부의 throwOnError: true 옵션입니다.
v5 이전에는 useErrorBoundary라는 이름으로 불렸던 이 옵션은 비동기 통신 중 에러가 발생했을 때
그 에러를 컴포넌트 내부 상태로 조용히 머물게 하지 않고, 자바스크립트의 예외 처리 매커니즘을 이용해
컴포넌트 트리 바깥으로 던져버리는 역할을 합니다.
이렇게 던져진 에러는 컴포넌트의 랜더링 흐름을 즉시 중단시키며 이 에러를 잡아줄 수 있는
가장 가까운 상위 부모를 향해 날아갑니다.
덕분에 컴포넌트 하단에 지저분한 에러 방어 코드를 단 한줄도 적을 필요가 없어집니다.
밖으로 집어던진 에러는 폴더 경로에 error.tsx 파일을 생성하는 것만으로 에러 바운더리가 작동합니다.
이 파일은 반드시 Client Component 여야 하는데, 자바스크립트 런타임 에러를 브라우저 환경에서
포착해야 하기 때문입니다.
아래 error.tsx 코드를 확인해보세요.
'use client'
import { useEffect } from 'react'
interface ErrorProps {
error : Error & { digest?: string }
reset: () => void
}
export default function ErrorBoundary({ error, reset } : ErrorBoundary) {
useEffect(() => {
console.log('위임 받은 에러 정보:', error)
}, [error])
return (
<div className="flex flex-col items-center justify-center p-8 bg-red-50">
<h2 className="text-lg font-semibold text-red-700 mb-2">
데이터를 불러오는 중 문제가 발생했습니다.
</h2>
<button onClick={reset}>다시 시도</button>
</div>
)
}이 에러 컴포넌트를 잘 보시면, props로 Error 객체와 reset 함수가 선언되어 있습니다.
Error 객체는 아까 하위 컴포넌트에서 던진 그 에러 메시지를 고스란히 담고 있습니다.
더 중요한 것은 reset 함수입니다.
오류가 발생했다고 해서 사용자에게 에러 메시지만 보여주고 끝내면 좋은 서비스가 아닙니다.
이 reset 함수를 버튼에 연결해두면, 사용자가 클릭했을 때 에러 상태를 초기화하고, 실패했던 데이터 패칭을 다시 시도합니다.
사용자가 브라우저 새로고침을 억지로 누르지 않아도 애플리케이션 내부에서 복구흐름을 탈 수 있습니다.
이제 이런 방식을 통해 데이터를 다루는 개별 컴포넌트는 오직 성공 화면에만 집중할 수 있게 됩니다.
useMutation()
Mutation이라는 용어의 의미는 돌연변이 혹은 변화라는 의미입니다.
리액트가 지향하는 함수형 프로그래밍 패러다임에서 Mutation이란 원본 데이터나 상태를 직접 변화시키는 행위입니다.
데이터를 생성, 수정, 삭제하는 작업은 데이터를 영구적으로 변화시키는 중대한 사이드 이펙트를 동반합니다.
즉 원본 데이터를 변화시키는 파괴적이고, 적극적인 개입 형태이기 때문에 useQuery() 와는 완전히 분리하여
별도의 도구인 useMutation()으로 관리하도록 설계된 것입니다.
과거의 리액트 환경이나 처음 이 라이브러리를 접하는 개발자가 이 지시를 내릴 때 자주 접하는 실수는
콜백 함수에 지나치게 의존하여 화면을 통제하려는 명령형 프로그래밍 패턴입니다.
과거에는 아래와 같이 onSuccess, onError 일 때 콜백 함수를 열고, 그 안에서 직접 지시하고는 했습니다.
const mutation = useMutation({
mutationFn: async (newName: string) => {
const res = await fetch(`/api/users/profile', {
method: 'POST',
body: JSON.stringify({ name: newName })
})
if (!res.ok) throw new Error('프로필 업데이트에 실패했습니다.')
return res.json()
},
onSuccess: () => {
alert('성공적으로 변경되었습니다.')
},
onError: (error) => {
alert('에러 발생:' + error.message)
}
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutation.mutate(name)
}하지만 리액트(React)는 기본적으로 상태가 변하면 화면이 그 상태에 맞게 자동으로 다시 그려지는
선언적 패러다임을 지향하므로, 콜백 안에서 직접 명령하는 것은 리액트의 철학과 충돌합니다.
코드가 복잡해질수록 상태 추적을 어렵게 만듭니다.
TanStack v5가 권장하는 해답은 콜백 함수를 UI 컴포넌트에서 과감히 덜어내고
useMutation이 반환하는 순수한 상태들만을 이용하여 화면을 구성하는 것입니다.
아래 코드를 주목해주세요.
'use client'
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
interface UpdateUserPayload {
name: string
}
export default function UserProfileEdit() {
const [name, setName] = useState('')
const { mutate, isPending, isError, error, isSuccess } = useMutation({
mutationFn: async (payload: UpdateUserPayload) => {
const res = await fetch('/api/users/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
if (!res.ok) throw new Error('프로필 업데이트 실패')
return res.json()
}
})
}우선 서버로 보낼 데이터의 타입을 명확히 정의한 뒤 useMutation() 훅을 호출합니다.
여기서 중요한 것이 뮤테이션 함수(mutationFn) 입니다.
뮤테이션 함수는 인자를 전달받아 서버의 데이터를 실질적으로 조작하는 비동기 지시서입니다.
중요한 것은 useQuery와 달리 컴포넌트가 랜더링될 때 자동으로 실행되지 않는다는 것입니다.
지시를 내리려면 수동으로 방아쇠를 당겨주어야 하는데
useMutation()이 반환하는 객체를 구조 분해 할당으로 꺼내오며 가장 핵심은 mutate() 함수입니다.
이것이 바로 방아쇠 역할을 합니다.
아래와 같이 사용자가 제출 버튼을 눌렀을 때 서버로 보낼 데이터를 인자로 넣어서 호출하면 동작이 시작됩니다.
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutate({ name })
}이와 함께 현재 서버가 통신이 진행중인지 알려주는 isPending, 에러가 났는지 알려주는 isError와 Error 객체
그리고 통신이 성공적으로 완료되었음을 알리는 isSuccess 상태를 함께 꺼내옵니다.
아래의 코드에서 살펴보겠습니다.
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 max-w-sm p-4 border rounded">
<div>
<label htmlFor="name" className="block text-sm font-medium">이름 변경</label>
<input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block border border-gray-300 rounded p-2"
disabled={isPending}
/>
</div>
<button
type="submit"
disabled={isPending
className={`p-2 rounded text-white ${isPending ? 'bg-blue-300': 'bg-blue-600'}`}
>
{isPending ? '저장 중' : '저장하기'}
</button>
{isError && (
<div className="text-red-500 text-sm bg-red-50 p-2 border border-red-200">
{error.message}
</div>
)}
{isSuccess && (
<div className="text-green-500 text-sm bg-green-50 p-2 border border-green-200">
성공적으로 저장되었습니다.
</div>
)}
</form>
)우선 위에서 만든 handleSubmit 핸들러를 통해 onSubmit에 연결합니다.
form 내부 태그를 살펴보면 이름 변경을 위한 label과 input 창이 있습니다.
여기서 disabled 속성에 isPending 속성을 연결하여 데이터 요청 중이라면 입력 창 자체를 막는 것입니다.
button 역시 마찬가지로 isPending 사용해 button을 제어하는데, 서버와 통신 중일 때는 버튼을 비활성화하고
배경을 흐리게 바꾸며 텍스트 또한 저장 중으로 표시되게 만듭니다.
이 부분에서 눈부신 활약을 하는 것이 isPending 상태입니다.
홈에서 서버로 데이터 수정 요청이 날아가고, 아직 완료 응답이 오지 않은 대기 시간 동안 isPending은 true가 됩니다.
우리는 이 상태를 입력 창과 제출 버튼의 disabled 속성에 묶어버렸습니다.
이렇게 하면 사용자가 버튼을 클릭하는 순간, 버튼이 비활성화 되며 백엔드 서버를 안전하게 보호할 수 있습니다.
이제 사용자에게 결과를 우아하게 피드백하는 랜더링 부분을 확인해보겠습니다.
상태 기반의 에러 핸들링은 alert 창 대신 화면에 텍스트를 직접 랜더링합니다.
성공 상태 역시 마찬가지로, isSuccess 상태를 활용하여 성공 상태를 직관적으로 피드백을 제공합니다.
이로써 에러 처리 형식이 명령형에서 선언형으로 완전히 탈바꿈했습니다.
추가적으로 useMutation에서 발생하는 폼 제출 에러는 페이지 전체를 고장낸 것이 아니라
사용자의 텍스트가 잘못되었거나 일시적인 통신 지연일 확률이 높으므로, 따라서 에러 바운더리로 에러를 던지는 것이 아닌
위 코드처럼 Form 하단에 인라인으로 힌트를 주어 사용자가 입력을 곧바로 수정할 기회를 주는 것이 좋은 UX 설계입니다.
캐시 무효화 - invalidateQueries
이제 데이터를 성공적으로 수정한 직후 브라우저를 수동적으로 새로고침하지 않아도
화면 곳곳에 있는 과거의 데이터들이 알아서 최신 상태로 바뀌도록 만드는 캐시 무효화에 대해 알아봅니다.
위에서 작성했던 프로필 수정 폼에서 사용자가 이름을 성공적으로 변경하면 데이터베이스에는 새 데이터가 저장됩니다.
하지만 여기서 폼 제출은 분명 성공했는데 정작 화면 최상단에 떠있는 사용자 이름이나, 마이 페이지 텍스트는
여전히 수정하기 전 옛날 이름으로 남아있는 현상입니다.
사용자가 F5 키를 눌러 브라우저를 강제로 새로고침해야만 비로소 최신 이름이 나타납니다.
그 이유는 다른 컴포넌트에서 사용하고 있는 useQuery가 여전히 브라우저 메모리에 저장된
과거의 캐시 데이터를 철석같이 믿고 바라보고 있기 때문입니다.
이제 invalidateQueries에 대해 알아보겠습니다.
컴퓨터 공학의 캐싱 시스템에서 데이터를 개발자가 강제로 지우거나 억지로 다시 받아오도록 명령하는 것은
프레임워크가 세워둔 최적화의 흐름을 깨뜨릴 위험이 있습니다.
대신 브라우저에 저장된 캐시 데이터는 방금 서버의 원본이 수정되었으므로, 유통기한 만료 도장을 찍는 것입니다.
이를 선언적 무효화 방식이라고 합니다. 이 방법이 권장됩니다.
이를 화면에서 사용중이라면, 당장 최신 데이터를 가져와 교체하라는 명령입니다.
화면에서 UI를 조작하기 위해 useMutation에서 onSuccess 콜백함수를 사용하는 명령형 패턴을 지향해야 하지만
이 캐시를 무효화하는 작업은 화면을 직접 건드리는 것이 아니라
프레임워크 내부의 백그라운드 데이터 상태를 서버와 동기화하는 순수한 부수 효과 로직이므로
onSuccess 콜백 내부에서 처리하는 것이 가장 완벽하고 권장되는 패턴입니다.
이 마법을 부리기 위해서는 먼저 애플리케이션 최상단에 만들어두었던 QueryClient 인스턴스에 접근해야 합니다.
컴포넌트 깊은 곳에서 QueryClient에 지시를 내리는 훅이 useQueryClient 훅입니다.
아래 코드를 보세요.
'use client'
import { useStae } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
interface UpdateUserPayload {
name: string
}
export default function UserProfileEdit({ userId }: { userId: string }) {
const [name, setName] = useState('')
const queryClient = useQueryClient()
const { mutate, isPending } = useMutation({
mutationFn: async (payload: UpdateUserPayload) => {
const res = await fetch(`/api/users/${userId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
if (!res.ok) throw new Error('업데이트 실패')
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['user', userId]
})
}
})
}프로필 수정 컴포넌트의 상단에서 useQueryClient()를 호출하여 queryClient 객체를 받아옵니다.
그리고, useMutation에 넣은 단일 객체 내부에 onSuccess 옵션에서 서버 데이터 수정이 완료되면
콜백 함수가 실행되게 하고, queryClient.invalidateQueries()에 단일 객체를 전달해 무효화 대상을 명확히 지정합니다.
우리가 queryKey로 배열을 전달하면 프레임워크 중앙 캐시 저장소에서 이 이름표를 달고 있는 모든 데이터를 찾아내
즉시 Stale 상태로 강등시켜 버립니다.
만약 이 상한 데이터를 바라보는 활성화된 컴포넌트가 존재한다면, 그 컴포넌트들에게 로딩 화면 없이 일단 유지하고
아주 조용하고 빠르게 서버에 Background Refetching을 트리거합니다.
잠시 후 서버로부터 최신 데이터가 도착하면, queryClient는 과거의 낡은 캐시를 최신 캐시로 덮어씌우고
컴포넌트를 자연스럽게 리 랜더링 시킵니다.
사용자의 눈에는 저장 버튼을 누르자마자 화면 곳곳의 정보가 일제히 최신화되는 마법같은 UX로 다가오게 됩니다.
이 강력한 무효화 기능은 배열 안의 텍스트가 완벽하게 일치하는 키 뿐만 아니라
배열의 계층적 특성을 이용해 연관된 여러 캐시를 한번에 날려버리는 똑똑한 필터링 기능도 함께 제공합니다.
예를 들어, 게시판의 전체 목록과 상세 페이지들을 논리적으로 계층화해서 설계했다고 가정해봅시다.

게시글 작성 뮤테이션이 성공했을 때 queryKey 배열의 가장 상위 단어인 posts 단어로 시작하는 모든 게시글 상세 캐시를
찾아내어 모조리 유통기한을 만료 시킵니다.
이 때 현재 사용자가 보고 있는 내용이 게시글 목록이라면, 해당 쿼리만 서버로 즉시 재요청하여 화면에 새 글을 띄워주고
당장 보지 않고 있는 상세 페이지 캐시들은 상한 채로 놔두었다가 나중에 사용자가 그 페이지에 진입할 때 다시 가져옵니다.
최고급 UX 패턴 - 낙관적 업데이트
기존 동기화 방식은 아주 미세한 틈이 존재하는데
사용자가 저장 버튼을 누르고, 서버가 응답을 준 뒤 다시 데이터를 가져오는 그 짧은 찰나의 시간에
사용자는 여전히 로딩 스피너를 보거나 멈춰있는 화면을 마주해야 한다는 점입니다.
사용자의 의도와 화면의 반응 사이에서 발생하는 이러한 인지적 마찰을 줄이고
사용자의 사고 방식과 지성의 흐름이 끊기지 않도록 돕는 훌륭한 인터페이스 기법이 낙관적 업데이트입니다.
서버의 응답이 오기도 전에 화면의 응답이 먼저 성공한 것처럼 반영하여 쾌적한 UX를 제공하는 것입니다.
서버의 요청이 높은 확률로 성공할 것이라고 희망적으로 가정하고
서버의 대답을 듣기도 전에 미리 브라우저의 화면을 성공한 것처럼 변형하는 기법을 가리킵니다.
만약 서버 요청이 성공하면, 사용자는 기다림을 느끼지 못하고 화면을 마주하지만
서버가 요청이 실패해도 미리 업데이트하면 화면을 롤백시키면 됩니다.
이제 이 원리를 코드로 구현해보겠습니다.
가장 먼저 해야할 일은 서버 측 로직을 준비하는 것입니다.
todo-actions.ts 코드를 작성합니다.
'use server'
import { revalidatePath } from "next/cache"
interface UpdatedTodo {
id: number;
text: string;
completed: boolean;
}
export async function updateTodoAction(updatedTodo: UpdatedTodo) {
const res = await fetch(`https://api.example.com/todos/${updatedTodo.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedTodo)
})
if (!res.ok) {
throw new Error("서버 업데이트에 실패했습니다.")
}
revalidatePath('/todos')
return await res.json()
}여기서 한 가지 디테일을 잡고 넘어가면, 순수 서버 액션 로직에서는 성공여부를 { success:false, message: '에러' } 와 같은
객체를 반환하는 패턴도 많이 쓰입니다.
하지만 지금은 TanStack의 에러 바운더리를 명확히 트리거하여 onError 콜백을 실행시키기 위해
의도적으로 에러를 던지고 있습니다.
그리고, Next.js 서버 상태도 캐시 무효화를 해야 하므로, revalidatePath()를 호출하여 서버 캐시까지 완벽히 동기화합니다.
이제 서버 액션을 불러와 실제 화면을 그릴 클라이언트 컴포넌트를 설계하겠습니다.
사용자의 클릭 이벤트를 처리하고, 상태를 관리해야 하므로, 파일 최상단에는 반드시 'use client'를 선언해야 합니다.
TodoItem.tsx 컴포넌트를 설계하겠습니다.
'use client'
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { updateTodoAction } from "@/actions/todo-actions"
interface Todo {
id: number
text: string
completed: boolean
}
export default function TodoItem({ todo }: { todo: Todo }) {
const queryClient = useQueryClient()
const { mutate } = useMutation<Todo, Error, Todo, { previousTodos: Todo[] | undefined }>({
mutationFn: updateTodoAction,
onMutate: async (newTodo: Todo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
queryClient.setQueryData<Todo[]>(['todos'], (old) => {
if (!old) return [newTodo]
return old.map(t => (t.id === newTodo.id ? newTodo : t))
})
return { previousTodos }
},
onError: (err, newTodo, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos)
}
alert('업데이트에 실패하여 이전 상태로 되돌립니다.')
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
})
return (
<li className="flex items-center gap-4 p-3 border-b transition-colors">
<input
type="checkbox"
checked={todo.completed}
onChange={() => mutate({ ...todo, completed: !todo.completed })}
className="h-5 w-5 cursor-pointer accent-blue-600"
/>
<span
className={todo.completed ? 'line-through text-gray-400' : 'text-gray-800'}>
{todo.text}
</span>
</li>
)
}여기서 useMutation의 다소 길어보이는 제네릭 부분에 대해 짚고 넘어가겠습니다.
우리의 롤백 전략이 원활하게 작동하도록 돕는 안전한 명세서입니다.
낙관적 업데이트는 필연적으로 실패를 대비해야 하므로
실패했을 때 원래 상태로 돌아가려면 과거의 데이터를 어딘가에 보관했다가
에러 발생 시 꺼내써야 합니다. 이 보관함을 TanStack Query에서는 context라고 부릅니다.
useMutation에 정의된 제네릭 부분은 각각 반환타입, 에러타입, 서버로 보낼 데이터 타입, context 타입을 의미합니다.
이렇게 과거 데이터 보관함을 정의해두면, 이후에 과거 데이터를 안전하게 다루고
에러 시 그 데이터를 꺼내 복구할 때 타입스크립트가 코드를 든든하게 보호해줍니다.
물론 최신에서는 onMutate에서 반환하는 값을 보고 타입을 자동으로 똑똑하게 추론해주기도 해서
실무에서는 생략하기도 합니다.
그리고, 낙관적 업데이트의 핵심인 onMutate 부분을 살펴보면
먼저 cancelQueries()를 통해 백그라운드 갱신을 멈춥니다.
사용자가 여러번 클릭할 때 여러 요청이 서로 먼저 요청하려고 다투다 데이터가 엉키는 현상을 막아줍니다.
그 후 getQueryData로 현재 상태를 보관함에 보관하고
곧바로 setQueryData를 통해 화면을 성공 상태로 갱신해버립니다.
이 때 초기 데이터가 없을 때 빈 배열을 반환해 버리면 업데이트하려는 item 마저 화면에서 사라질 수 있으므로
논리적 완벽함을 위해 데이터가 없을 경우를 대비한 방어 코드를 작성하여 안정성을 한층 더 높였습니다.
하지만 요청이 실패했을 때 필요한 롤백 전략과 최종 동기화 작업도 작성해주어야 합니다.
onError는 서버에서 에러가 났을 때 실행되며, 아까 보관함에 담아두었던 데이터를 다시 캐시에 집어넣어
화면을 원래대로 되돌려줍니다. 이제 앞서 useMutation()에서 지정한 제네릭 덕분에 context에서
예전 데이터를 꺼내 쓸 수 있습니다.
onSettled는 모든 상황이 종료된 후 실행되는 마무리 단계로, invalidateQueries()를 통해
서버의 최신 원본 데이터를 다시 가져와 클라이언트의 캐시를 동기화해줍니다.
서버의 invalidatePath()와 클라이언트의 invalidateQueries()를 통해 양쪽에서 톱니바퀴처럼 맞물려야
완벽한 동기화가 이루어집니다.
이제 이 로직을 UI에 연결해보겠습니다.
input 태그를 보시면 자바스크립트의 스프레드 구문을 사용해 기존 속성을 복사하고
오직 완료 여부만 반전시켜 서버로 전송하고 있습니다.
클릭과 동시에 화면에 체크 상태는 즉시 바뀌고 사용자는 로딩 스피너를 마주할 틈 없이 자연스러운 전환을 경험합니다.