저는 평일에 퇴근하면 맥주 한 캔과 함께 노트북을 켜고 사이드 프로젝트를 하는 편입니다.
어느 날도 평소처럼 개발을 하다가 팀원이 올린 PR을 리뷰하기 위해 GitHub에 들어갔습니다.
그러다 이 코드를 발견했습니다.
...
useEffect(() => {
const fetchUsers = async () => {
setStatus('loading')
try {
const { data } = await apiClient.get<User[]>('/api/users')
setUsers(data)
setStatus('success')
} catch {
setStatus('error')
}
}
fetchUsers()
}, [])
...

물론 문제없는 코드긴 합니다. 실제로 잘 동작하기도 하고요.
근데 왜 이리 보기 불편하고, 가슴이 답답하고, 뒷목이 땡기고 그럴까요?
useEffect 안에서 API를 호출했다는 이유만으로 키보드 워리어에 빙의해 상대방의 PR에 거침없는 리뷰를 달 필요는 없습니다.
다만 팀원의 창창한 미래를 생각하는 참된 팀원이 되고 싶다면, 저는 사랑이 담긴 리뷰 정도는 허락하겠습니다.
위 코드에 리뷰 몇 줄만 달아보겠습니다.
처음에는 API 하나를 호출했을 뿐입니다.
그런데 위 요구사항을 하나씩 처리하다 보면 어느 순간 useEffect 안에서 API를 호출하는 문제를 넘어섭니다.
이제 우리가 관리하고 있는 것은 단순한 HTTP 요청이 아니라 서버 상태의 생명주기입니다.
React에서 흔히 사용하는 상태를 하나 생각해 보겠습니다.
const [isOpen, setIsOpen] = useState(false)
모달이 열려 있는지는 현재 애플리케이션이 결정합니다.
false라면 닫혀 있고 true라면 열려 있습니다.
이 상태의 주인은 현재 클라이언트입니다.
하지만 API를 통해 가져온 데이터는 조금 다릅니다.
const [users, setUsers] = useState<User[]>([])
현재 users에 데이터가 있다고 해서 그 데이터가 반드시 최신이라는 보장은 없습니다.
데이터의 원본은 브라우저가 아니라 서버에 존재하기 때문입니다.
우리가 Axios를 통해 받아온 데이터는 서버가 가지고 있는 상태를 특정 시점에 가져온 복사본이라고 생각하면 이해하기 쉽습니다.
그 이후에는 얼마든지 변경될 수 있습니다.
다른 사용자가 데이터를 수정할 수도 있고, 다른 브라우저 탭에서 수정할 수도 있고, 서버 내부의 작업으로 데이터가 변경될 수도 있습니다.
즉,
Client
↓ GET /users
Server
↓ Response
Client가 특정 시점의 데이터를 보관함
↓
Server의 데이터가 변경될 수도 있음
클라이언트가 가지고 있는 데이터와 서버의 실제 데이터는 언제든 달라질 수 있습니다.
이런 데이터를 흔히 Server State라고 부릅니다.
그리고 Server State를 다루기 시작하면 Client State에서는 크게 신경 쓰지 않았던 질문이 생깁니다.
"지금 클라이언트가 가지고 있는 데이터가 최신 거 맞나요?"
여기서 자주 생기는 오해가 하나 있습니다.
Axios 대신 TanStack Query를 사용한다?
결론부터 말하면 둘은 비교 대상이 아닙니다.
Axios는 HTTP Client입니다.
const getUsers = async () => {
const { data } = await axios.get<User[]>('/api/users')
return data
}
쉽게 말하면 서버에
"서버야. 이 데이터 좀 가져온나."
라고 요청을 보내고, 그 응답을 받아오는 역할을 합니다.
반면 TanStack Query는 서버에서 가져온 비동기 데이터의 상태와 생명주기를 관리하는 역할을 합니다.
그래서 둘은 같이 사용할 수 있습니다.
const { data, isPending, error } = useQuery({
queryKey: ['users'],
queryFn: getUsers,
})
HTTP 요청을 보내는 방식은 그대로입니다.
여전히 Axios가 서버에 요청을 보내고 있습니다.
달라진 것은 그 요청으로 받아온 데이터를 누가 관리하느냐입니다.
| 역할 | Axios | TanStack Query |
|---|---|---|
| HTTP 요청 전송 | O | X |
| 요청 / 응답 인터셉터 | O | X |
| 비동기 결과 상태 관리 | X | O |
| 캐싱 | X | O |
| 데이터 신선도 관리 | X | O |
| Background Refetch | X | O |
| Query Invalidation | X | O |
| Server State 관리 | X | O |
그래서 TanStack Query는 단순히
useEffect(...)
를
useQuery(...)
로 바꿔서 코드 몇 줄 줄여주는 라이브러리가 아닙니다.
Axios가 서버와 통신하는 역할을 한다면, TanStack Query는 서버에서 가져온 상태를 어떻게 관리할지를 담당합니다.
이 차이를 이해하면 TanStack Query를 왜 쓰는지도 조금 더 명확해집니다.
Server State를 이해할 때 중요한 개념 중 하나가 fresh와 stale입니다.
예를 들어 이런 Query가 있다고 해보겠습니다.
useQuery({
queryKey: ['users'],
queryFn: getUsers,
staleTime: 1000 * 60,
})
여기서는 가져온 데이터를 1분 동안 fresh한 데이터로 취급합니다.
그렇다고 1분이 지나자마자 데이터가 갑자기 사라지는 건 아닙니다.
데이터는 여전히 캐시에 남아 있을 수 있습니다.
다만 TanStack Query는 이제 이 데이터를
"이거 최신 데이터라고 계속 믿어도 되나?"
라고 생각하기 시작합니다.
즉, stale 상태가 됩니다.
쉽게 표현하면 이런 느낌입니다.
fresh
│
│ staleTime 경과
▼
stale
│
│ 조건에 따라 refetch
▼
fresh
여기서 중요한 점이 있습니다.
stale === 데이터 삭제가 아닙니다.
stale은 사용할 수 없는 데이터라는 뜻도 아닙니다.
"이 데이터가 아직 최신인지 다시 확인할 필요가 있을 수 있다" 정도로 이해하면 편합니다.
저도 TanStack Query를 처음 사용할 때 이 부분을 꽤 헷갈렸습니다.
staleTime은 데이터를 캐시에서 삭제하는 시간이 아니라 데이터를 fresh한 상태로 취급하는 시간입니다.
실제로 캐시에 존재하는 데이터가 언제 Garbage Collection 대상이 되는지는 별도의 gcTime과 관련되어 있습니다.
이 부분이 헷갈린다면 TanStack Query 공식 문서의 Important Defaults를 한 번 읽어보는 것을 추천합니다.
직접 상태를 관리하면 보통 이렇게 생각하기 쉽습니다.
데이터가 없음
데이터가 있음
그런데 Server State에서는 그것만으로 부족합니다.
데이터가 없음
데이터가 있음
├── fresh
└── stale
데이터가 존재하는 것과 그 데이터가 최신인 것은 다른 문제이기 때문입니다.
조회만 하는 서비스라면 좋겠지만 현실은 그렇게 호락호락하지 않죠.
이번에는 사용자의 정보를 수정한다고 해보겠습니다.
await updateUser(user)
요청은 성공했고 서버의 사용자 정보도 정상적으로 변경되었습니다.
그러면 끝일까요?
기존에 가져왔던
queryKey: ['users']
데이터는 여전히 수정 이전의 값일 수 있습니다.
서버는 최신인데 내 화면만 과거에 살고 있는 상황이 생기는 겁니다.
직접 상태를 관리하고 있다면 수정된 사용자를 찾아 기존 상태를 바꾸거나, 사용자 목록 API를 다시 호출하는 등의 처리가 필요합니다.
TanStack Query에서는 invalidateQueries를 사용할 수 있습니다.
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: updateUser,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['users'],
})
},
})
여기서 중요한 것은 invalidateQueries 함수 사용법 자체가 아닙니다.
이 코드가 표현하는 생각이 더 중요합니다.
서버의 상태가 변경됐다.
그럼 내가 이전에 받아온 데이터는 이제 최신이라고 확신할 수 없다.
invalidateQueries를 호출하면 해당 Query는 stale 상태가 되고, 현재 화면에서 사용 중인 Query라면 기본적으로 background refetch 대상이 됩니다.
즉,
"API 다시 가져온나."
라기보다는
"이 데이터 이제 최신이라고 믿지 마라."
에 조금 더 가깝습니다.
이런 식으로 생각하면 Query Invalidation이라는 개념도 훨씬 이해하기 쉬워집니다.
만약 여기까지 읽고 이런 생각이 들었다면...

그럴 수 있죠.

useEffect는 죄가 없습니다.
useEffect는 여전히 React에서 굉장히 중요한 API입니다.
React 공식 문서에서는 useEffect를 컴포넌트를 외부 시스템과 동기화하기 위한 Hook으로 설명합니다.
예를 들어 WebSocket 연결을 생각해볼 수 있습니다.
useEffect(() => {
const socket = connect()
return () => {
socket.disconnect()
}
}, [])
컴포넌트가 마운트되었을 때 외부 시스템과 연결하고, 더 이상 필요하지 않을 때 연결을 끊습니다.
그 밖에도 이런 상황에서 Effect를 사용할 수 있습니다.
addEventListener 같은 브라우저 APIReact 공식 문서의 Synchronizing with Effects에서도 Effect의 역할을 자세히 설명하고 있습니다.
따라서
"API 호출 코드에서
useEffect를 사용하면 안 되는구나!"
라고 이해해버리면 그것도 정답은 아닙니다.
그랬다가는 오히려 팀원들의 사랑이 담긴 리뷰를 받을 수도 있습니다.
실제로 React 공식 문서의 useEffect 데이터 패칭 예제에서도 Effect를 사용해 데이터를 가져오는 방법을 소개합니다.
다만 Effect에서 직접 데이터를 가져오기 시작하면 race condition, cleanup, caching 같은 문제까지 결국 우리가 직접 신경 써야 합니다.
즉,
useEffect가 문제라기보다 서버 상태 관리까지useEffect에게 맡기기 시작하는 것이 문제입니다.
작은 프로젝트나 정말 단순한 요청이라면 useEffect와 Axios만으로도 충분할 수 있습니다.
저도 모든 API 요청에 무조건 TanStack Query를 붙여야 한다고 생각하지 않습니다.
하지만 API를 하나 호출했을 뿐인데 어느 순간
캐싱 → 중복 요청 관리 → 데이터 신선도 → 재요청 → 동기화 → 무효화
까지 직접 구현하고 있다면 한 번 생각해볼 필요가 있습니다.
지금 내가 관리하고 있는 것은 API 요청일까, Server State일까?
요즘은 API 호출 코드 정도는 직접 처음부터 작성할 필요조차 없는 경우가 많습니다.
AI에게
사용자 목록 API 호출해서 화면에 보여줘.
라고 딸깍 한 번 하면 코드가 나옵니다.
useEffect와 Axios를 사용한 코드도 만들어주고,
useEffect(() => {
axios.get('/api/users').then(({ data }) => {
setUsers(data)
})
}, [])
TanStack Query를 사용해달라고 하면 useQuery를 사용하는 코드도 만들어줍니다.
코드를 만드는 것 자체는 이전보다 훨씬 쉬워졌습니다.
그런데 여기서 한 가지는 구분해야 한다고 생각합니다.
코드가 동작한다 ≠ 현재 문제에 적절한 코드다
AI에게 useEffect로 작성해달라고 하면 그렇게 작성합니다.
TanStack Query로 작성해달라고 하면 또 그렇게 작성합니다.
그러면 결국 남는 문제는 하나입니다.
왜 그 방법을 사용했는가?
서버 상태인지 클라이언트 상태인지,
캐싱이 필요한지,
데이터는 언제 stale해지는지,
Mutation 이후 어떤 데이터를 다시 최신 상태로 만들어야 하는지.
이런 판단까지 코드가 대신 해주지는 않습니다.
적어도 아직은 개발자가 고민해야 할 영역이라고 생각합니다.
다시 처음 질문으로 돌아가 보겠습니다.
써도 됩니다.
useEffect에서 Axios를 호출한다고 잘못된 코드가 되는 것은 아닙니다.
정말 단순한 기능이라면 별도의 라이브러리를 추가하지 않고 직접 처리하는 게 오히려 더 나을 수도 있습니다.
하지만 서버에서 데이터를 가져오기 시작하고 요구사항이 늘어나면 이야기가 달라집니다.
API 호출
↓
데이터 저장
↓
캐싱
↓
신선도 판단
↓
Refetch
↓
Mutation
↓
Invalidation
↓
다시 동기화
처음에는 API 하나 호출했을 뿐인데, 정신을 차려보면 서버 상태 관리 시스템 하나를 직접 만들고 있을 수도 있습니다.
TanStack Query를 사용하는 이유는 Axios를 대체하기 위해서도 아니고, useEffect 몇 줄 줄이기 위해서도 아닙니다.
Axios는 서버와 통신합니다.
TanStack Query는 서버에서 가져온 상태의 생명주기를 관리합니다.
그래서 다음에 API 호출 코드를 작성하면서 useEffect를 열게 된다면,
useEffect써도 되나?
라는 고민도 좋지만, 여기서 한 단계만 더 나가보면 좋습니다.
"이건 진짜 API 한 번 호출하고 끝나는 문제인가, 아니면 앞으로 계속 관리해야 하는 Server State인가?"
후자라면 그때부터는 TanStack Query를 고려해볼 만합니다.
"코드가 동작한다 ≠ 현재 문제에 적절한 코드다"
좋은 문구네요! 잘 보고 갑니다!