
Intro
오늘날 리액트 개발의 데이터 패칭에서 react-query를 많이 사용합니다.
항상 도구의 사용에 앞서 도구가 어떤 역할을 수행하는지 자세히 이해해볼 필요가 있다고 생각합니다. 도구의 니즈를 구체화하는 것이죠.
이번 시간에는 리액트에서 제공하는 API(useEffect, useState)를 이용해서 데이터 패칭을 해보면서 어떤 기능이 추가로 필요한지 고민해보겠습니다.
이번 글에서는 아래 코드를 바탕으로 설명을 진행하겠습니다. 완벽해보이시나요? 아래 코드에는 5개의 버그가 존재합니다.
function Bookmarks({ category }) {
const [data, setData] = useState([])
const [error, setError] = useState()
useEffect(() => {
fetch(`${endpoint}/${category}`)
.then(res => res.json())
.then(d => setData(d))
.catch(e => setError(e))
}, [category])
}
문제1 race-conditions

가장 첫 번째 문제는 race conditions문제입니다.
현재 버전의 코드에서는 category가 변경될 때 마다 데이터를 요청하고 있습니다. 예를 들어서 카테고리를 "book"에서 "movies"로 변경했다고 가정해보겠습니다. 하지만 네트워크 요청은 첫 요청의 응답이 두번째 요청의 응답보다 빨리 온다는 보장을 할 수 없습니다.
그래서 첫번째 요청이었던 "book"의 응답값이 ui에 보여질 수 있습니다.
race-condition 문제는 아래처럼 수정할 수 있습니다.
function Bookmarks({ category }) {
const [data, setData] = useState([])
const [error, setError] = useState()
useEffect(() => {
let ignore = false
fetch(`${endpoint}/${category}`)
.then(res => res.json())
.then(d => {
if (!ignore) {
setData(d)
}
.catch(e => {
if (!ignore) {
setError(e)
}
})
return () => {
ignore = true
}
}, [category])
// 데이터와 에러 상태에 따른 JSX 반환
}
useEffect는 category가 변경되면 클린업 함수가 실행되고 ignore변수를 통해서 상태 업데이트를 제어할 수 있습니다.
이제 문제 상황으로 돌아와서 "movies"의 요청이 먼저 도착해도 ignore 변수가 true이기 때문에 상태가 업데이트 되지 않습니다.
문제2 로딩 상태
로딩 UI 표현을 위한 로딩 상태가 빠졌네요. isLoading상태를 추가해보겠습니다.
function Bookmarks({ category }) {
const [isLoading, setIsLoading] = useState(true)
const [data, setData] = useState([])
const [error, setError] = useState()
useEffect(() => {
let ignore = false
setIsLoading(true)
fetch(`${endpoint}/${category}`)
.then(res => res.json())
.then(d => {
if (!ignore) {
setData(d)
}
})
.catch(e => {
if (!ignore) {
setError(e)
}
})
.finally(() => {
if (!ignore) {
setIsLoading(false)
}
})
return () => {
ignore = true
}
}, [category])
// Return JSX based on data and error state
}
문제3. Empty State
기존 코드에서는 data를 빈 배열([])로 초기화했습니다.
하지만 "아직 조회되지 않음"과 "조회 결과 아무것도 없음"을 구분할 필요가 있습니다. 그래서 이 경우에는 undefined로 초기화하는 것이 더 나은 선택입니다.
function Bookmarks({ category }) {
const [isLoading, setIsLoading] = useState(true)
const [data, setData] = useState()
const [error, setError] = useState()
useEffect(() => {
let ignore = false
setIsLoading(true)
fetch(`${endpoint}/${category}`)
.then(res => res.json())
.then(d => {
if (!ignore) {
setData(d)
}
})
.catch(e => {
if (!ignore) {
setError(e)
}
})
.finally(() => {
if (!ignore) {
setIsLoading(false)
}
})
return () => {
ignore = true
}
}, [category])
// Return JSX based on data and error state
}
문제4. category가 바뀌어도 Data와 Error가 초기화되지 않는다
data와 error 상태는 서로 독립적인 상태입니다. 따라서 현재 코드에서는 category가 바뀌어도 data와 error가 응답값에 맞게 동기화되지 않습니다.
예를 들어 첫 번째 요청에서 category=vegetable일 때는 요청에 실패하고
두번째 요청에서 category=fruit일 때 요청에 성공했습니다. 이 경우 각각의 상태는 다음과 같습니다.
data: fruitData // 두번째 요청의 응답값
error: vegetableFetchError // 첫번째 요청의 에러 응답
만약 아래와 같이 에러UI를 처리하고 있다면 새로 조회한 fruit에 대한 응답을 렌더링하지 못하게 됩니다.
if(error) reutrn <ErrorFallback/>
return (
<div>
<ul>
{data.map(item => (
<li key={item.id}>{item.name}</div>
))}
</ul>
</div>
)
문제의 원인은 data와 error상태는 한번의 요청에서 함께 변경되어야 하는 의존적인 상태라는 점입니다.
이를 해결하기 위해 요청마다 data와 error 상태를 업데이트하는 로직이 필요합니다.
// reset-state
function Bookmarks({ category }) {
const [isLoading, setIsLoading] = useState(true)
const [data, setData] = useState()
const [error, setError] = useState()
useEffect(() => {
let ignore = false
setIsLoading(true)
fetch(`${endpoint}/${category}`)
.then(res => res.json())
.then(d => {
if (!ignore) {
setData(d)
setError(undefined)
}
.catch(e => {
if (!ignore) {
setError(e)
setData(undefined)
}
})
.finally(() => {
if (!ignore) {
setIsLoading(false)
}
})
return () => {
ignore = true
}
}, [category])
}
문제5. StricMode에서 두 번 시행된다
이 문제를 엄밀히 말하면 버그라기 보다는 개발 단계에서 귀찮은 부분이긴 합니다.
리액트에서 useEffect는 외부 시스템과 컴포넌트의 동기화를 목적으로 설계되었고 effect함수와 클린업 함수의 실행이 반복 실행되었을 때 문제가 없어야함을 기대합니다. 아래 예시를 참고해주세요
useEffect(()=>{
// ✅ 구독
const unsubscribe = subscribe();
// ✅ 구독해지
return ()=> unsubscribe()
},[])
useEffect(() => {
const handler = () => cosnole.log('click');
// ✅ 핸들러 등록
document.addEventListener('click', handler);
// ✅ 핸들러 제거
return () => {
document.removeEventListener('click', handler);
}
}, [popoverRef]);
StricMode에서 이팩트 두 번 호출 됨에 따라 컴포넌트 mount시 1회 호출되어야하는 서버 요청에 대해서도 개발환경에서 두 번 실행되는 상황을 경험하게 됩니다. useRef를 활용해 isMounted라는 값을 관리하면 개발 환경에서 발생하는 이런 이슈를 피할수 있지만 번거로운 부분이긴 합니다.
마무리
직접 데이터 패칭을 구현해보면서 단순히 컴포넌트에서 서버 데이터를 가져오는 것인데 useEffect를 이용해 직접 비동기 상태를 처리할 때 신경써야 하는 것들이 많다는 것을 알게 됐습니다. react-query의 역할은 바로 이 부분입니다. 비동기 상태 관리자!
지금까지 저희가 일일이 신경썼던 부분들은 react-query문법에서 아래처럼 표현할 수 있습니다.
function Bookmarks({ category }) {
const { isLoading, data, error } = useQuery({
queryKey: ['bookmarks', category],
queryFn: () =>
fetch(`${endpoint}/${category}`).then((res) => {
if (!res.ok) {
throw new Error('Failed to fetch')
}
return res.json()
}),
})
}
위 react-query 코드를 통해 해결 된 부분을 정리해보겠습니다.
- 경쟁 상태(race-condition)에 대해 신경쓰지 않아도 됩니다.
- loading, data,error 상태 처리를 직접 하지 않아도 됩니다.
- empty 상태를 명확히 구분할 수 있습니다. 더불어 placeholderData 옵션을 통해서 쿼리 요청 전에 값을 초기화할 수 있습니다.
- 이전 쿼리로 인해 data,error 상태가 최근 요청과 동기화되지 않는 문제가 자동으로 해결됩니다.
- 중복 요청이 중복제거 됩니다.(서로 다른 컴포넌트에서 동일한 쿼리를 요청할 때 1회 요청합니다)
이외에 본문에서 직접 다루지 않은 캐싱, retry로직, 프리패칭, 네트워크 중복 요청 제거(In-flight request deduping), 페이지네이션 등 비동기 데이터를 처리할 때 신경써야 하는 시나리오들을 react-query에 위임할 수 있습니다.
참고
https://react.dev/reference/react/StrictMode#fixing-bugs-found-by-re-running-effects-in-development
<StrictMode> – React
The library for web and native user interfaces
react.dev
https://tkdodo.eu/blog/why-you-want-react-query
Why You Want React Query
Let's take a look at why you'd want a library like React Query, even if you don't need all the extra features it provides...
tkdodo.eu
'react' 카테고리의 다른 글
| 리액트의 동시성에 따른 tearing이슈 (0) | 2026.01.12 |
|---|---|
| 왜 내 코드의 Effect가 실행됐을까? (0) | 2025.12.29 |
| 함수 컴포넌트 시대에 돌아보는 class 문법 (0) | 2025.12.24 |
| 함수 컴포넌트와 클로저 (0) | 2025.12.23 |
| React에서 상태를 초기화하는 기준 (0) | 2025.12.22 |