프론트엔드 데이터 패칭 다시 보기 (01) - React에서 비동기 데이터 처리는 왜 자꾸 복잡해지는가
useEffect 하나로 시작한 데이터 패칭이 왜 로딩, 중복 요청, stale data, 화면 간 불일치 문제로 빠르게 커지는지 서버 상태 관점에서 정리합니다.
용어 보기접기/펼치기
React에서 비동기 데이터 처리가 어려운 이유는 fetch를 한 번 더 써야 해서가 아닙니다. 화면이 직접 소유하지 않는 값을 계속 맞춰 가야 하기 때문입니다.
들어가며
React에서 데이터를 가져오는 코드는 처음에는 대체로 단순합니다. useEffect 안에서 API를 호출하고, 결과를 useState에 넣은 뒤 로딩과 에러 상태만 조금 붙이면 화면은 금방 나옵니다.
문제는 이 방식이 처음 한 화면을 넘기기 시작하면 빠르게 복잡해진다는 점입니다. 같은 데이터를 다른 화면에서도 쓰게 되고, 사용자가 화면을 다시 방문할 수도 있고, 한 번 받아 둔 값을 언제까지 믿어도 되는지도 정해야 합니다. 검색처럼 입력이 연속으로 바뀌는 화면에서는 요청 순서와 응답 순서도 어긋날 수 있습니다.
이 지점에서 많은 글이 바로 React Query나 SWR 같은 도구 이야기로 넘어갑니다. 하지만 도구를 먼저 설명하면, 왜 그런 도구가 필요한지는 오히려 흐려질 때가 많습니다.
이 시리즈의 첫 글에서는 라이브러리보다 먼저 문제 자체를 정리해 보려고 합니다. React에서 비동기 데이터 처리가 왜 자꾸 복잡해지는지, 그리고 왜 이 문제를 로컬 상태와 같은 방식으로 다루면 점점 어긋나기 시작하는지를 서버 상태 관점에서 살펴보겠습니다.
이 글에서는 세 가지를 중심으로 보겠습니다.
- 왜 useEffect + useState가 첫 화면을 넘기면 금방 버거워지는지
- 왜 서버 데이터는 로컬 상태와 다른 질문을 만들기 시작하는지
- 왜 이 문제를 결국 "서버 상태"라는 별도 범주로 봐야 하는지
비동기 데이터 처리가 어려운 이유는 네트워크 호출 자체보다, 화면 바깥에 있는 원본 데이터를 언제 어떻게 신뢰할지를 계속 판단해야 하기 때문입니다.
시작은 늘 단순해 보입니다
아주 기본적인 목록 화면을 예로 들면 보통 이런 코드에서 출발합니다.
import { useEffect, useState } from "react";
type Post = {
id: string;
title: string;
};
export function PostsPage() {
const [posts, setPosts] = useState<Post[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
// 컴포넌트가 사라진 뒤 늦게 도착한 응답이 상태를 덮지 않도록 막습니다.
let cancelled = false;
async function load() {
try {
// 첫 진입 시 API를 호출합니다.
const response = await fetch("/api/posts");
const data = (await response.json()) as Post[];
if (!cancelled) {
// 응답이 유효할 때만 결과를 반영합니다.
setPosts(data);
setIsLoading(false);
}
} catch (err) {
if (!cancelled) {
// 실패도 같은 조건에서만 반영해야 메모리 누수 경고를 줄일 수 있습니다.
setError(err as Error);
setIsLoading(false);
}
}
}
load();
return () => {
cancelled = true;
};
}, []);
if (isLoading) return <p>Loading...</p>;
if (error) return <p>에러가 발생했습니다.</p>;
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}한 화면만 놓고 보면 큰 문제가 없어 보입니다. 필요한 데이터는 들어오고, 로딩과 에러도 처리했습니다. 실제로 작은 프로젝트나 관리 화면 일부는 이 정도로도 충분히 굴러갑니다.
하지만 이 구조는 현재 이 컴포넌트 하나가 지금 한 번 그려지는 상황에만 잘 맞습니다. 다시 방문, 화면 간 공유, 재검증 같은 조건이 붙는 순간 다른 종류의 질문이 생깁니다.
복잡함은 요청 하나가 아니라 조건이 늘어나면서 시작됩니다
비동기 데이터 패칭이 버거워지는 순간은 보통 요청이 많아질 때보다, 같은 데이터를 둘러싼 조건이 많아질 때입니다.
예를 들어 아래 상황은 실제 서비스에서 흔합니다.
- 같은 글 목록을 홈과 검색 결과 화면이 함께 사용합니다.
- 사용자가 상세 화면을 봤다가 목록으로 다시 돌아옵니다.
- 백그라운드에서 데이터가 바뀔 수 있어서, 이전 결과를 언제까지 믿을지 정해야 합니다.
- 검색어가 빠르게 바뀌는 동안 이전 요청과 다음 요청이 겹칩니다.
- 모달이나 사이드 패널처럼 화면 일부만 다시 열릴 때도 데이터를 재사용하고 싶습니다.
이런 조건이 붙기 시작하면 질문도 함께 늘어납니다.
- 이미 가져온 데이터를 다시 요청해야 할까요?
- 지금 가지고 있는 값은 아직 신선한 값일까요?
- 서로 다른 컴포넌트가 같은 데이터를 각자 들고 있으면 어느 쪽이 기준일까요?
- 사용자가 화면을 벗어났다가 돌아오면 이전 값을 보여 줄까요, 다시 가져올까요?
- 요청이 실패했을 때는 마지막 성공 값을 유지할까요, 비울까요?
이 시점부터 문제는 fetch를 어떻게 쓰느냐보다 이 데이터를 누가 관리하느냐로 바뀝니다.
로컬 상태처럼 다루면 자꾸 어긋나는 이유
React의 useState는 컴포넌트가 스스로 소유하는 값에 잘 맞습니다. 입력창의 현재 값, 드롭다운 열림 여부, 탭 선택 상태처럼 화면 안에서 시작해 화면 안에서 끝나는 값은 로컬 상태로 두는 편이 자연스럽습니다.
하지만 서버에서 받아 온 데이터는 성격이 다릅니다.
- 원본은 브라우저가 아니라 서버에 있습니다.
- 지금 내가 들고 있는 값은 복사본에 가깝습니다.
- 시간이 지나면 이 값은 낡을 수 있습니다.
- 다른 화면이나 다른 사용자의 행동 때문에 바뀔 수도 있습니다.
즉 서버 데이터는 내가 소유한 상태라기보다 잠시 들고 있는 상태에 더 가깝습니다.
이 차이가 중요한 이유는 분명합니다. 로컬 상태는 내가 바꾸면 바로 기준이 되지만, 서버 데이터는 내가 들고 있다고 해서 기준이 되지 않습니다. 화면은 결과를 보여 줄 뿐이고, 원본은 여전히 서버 쪽에 있습니다.
그래서 서버 데이터를 단순히 useState에 담아 둔 순간부터, 우리는 값을 저장하는 동시에 아래 문제를 직접 떠안게 됩니다.
- 재요청 시점 결정
- 중복 요청 방지
- stale 여부 판단
- 마지막 성공 값 유지 여부
- 여러 화면 사이의 동기화
- 실패 후 재시도 전략
이 일들은 모두 상태 저장보다 상태 동기화에 가깝습니다.
서버 데이터는 컴포넌트 안에 들어와 있어도 "내 상태"가 아니라, 다시 맞춰야 하는 값에 가깝습니다.
같은 데이터를 여러 번 들고 있으면 기준점이 흐려집니다
문제가 더 선명하게 드러나는 예시는 목록과 상세 화면을 함께 다룰 때입니다.
목록 화면에서 글 제목과 작성자 정보를 보여 주고, 상세 화면에서는 같은 글의 본문과 태그, 수정 시간을 보여 준다고 가정해 보겠습니다. 이때 목록으로 돌아왔을 때도 이전 목록을 즉시 보여 주고 싶고, 상세 화면에서는 최신 수정 시간을 다시 확인하고 싶을 수 있습니다.
이 구조를 각 컴포넌트의 useEffect와 useState만으로 풀면, 같은 글 데이터가 여러 곳에서 각자 다른 시점의 복사본으로 남기 쉽습니다.
- 목록은 오래된 제목을 들고 있고
- 상세는 조금 더 최신 본문을 들고 있고
- 사이드 패널은 또 다른 시점의 요약 정보를 들고 있을 수 있습니다
이 상태가 되면 화면은 잘 그려지고 있어도, 어떤 값이 기준인지 설명하기 어려워집니다.
핵심은 중복 렌더링이 아니라 중복 소유입니다. 같은 데이터를 여러 컴포넌트가 각자 자기 상태처럼 들고 있기 시작하면, 값이 언제 일치해야 하는지와 언제 다시 맞춰야 하는지를 사람이 직접 관리해야 합니다.
로딩 상태도 사실은 데이터 모델의 일부입니다
비동기 데이터 처리를 이야기할 때 로딩 스피너를 먼저 떠올리기 쉽습니다. 하지만 로딩은 단지 UX 장치가 아니라 데이터 모델의 일부이기도 합니다.
예를 들어 아래 세 상황은 모두 "로딩 중"이라고 말할 수 있지만, 실제 의미는 다릅니다.
- 첫 진입이라 아직 아무 데이터도 없습니다.
- 이전 데이터는 있지만, 최신 값을 다시 확인하는 중입니다.
- 검색 조건이 바뀌어서 완전히 다른 결과를 기다리는 중입니다.
이 세 상황을 모두 같은 isLoading 하나로 묶으면 화면은 자꾸 과하게 흔들립니다. 어떤 경우에는 이전 결과를 유지한 채 조용히 다시 가져오고 싶고, 어떤 경우에는 skeleton을 보여 주는 편이 낫고, 어떤 경우에는 아예 이전 결과를 버려야 하기 때문입니다.
즉 로딩 상태는 불리언 하나 붙이면 끝나는 부가 정보가 아닙니다. 현재 이 데이터가 어떤 생명주기에 있는지를 표현하는 일부입니다.
결국 우리는 데이터 본문만 다루는 것이 아니라, 그 데이터의 신선도와 재사용 가능성, 전환 중 상태까지 함께 모델링하게 됩니다.
경쟁 상태와 중복 요청은 금방 실전 문제로 바뀝니다
검색 화면처럼 입력이 연속으로 바뀌는 경우를 생각해 보면 이 문제는 더 노골적으로 드러납니다.
겉보기에는 평범한 검색 코드도, 실제로는 요청 순서와 결과 반영 순서를 따로 관리해야 하는 순간이 금방 옵니다.
import { useEffect, useState } from "react";
type SearchResult = {
id: string;
title: string;
};
export function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
useEffect(() => {
if (!query) {
// 검색어가 비면 이전 결과를 비우고 요청도 중단합니다.
setResults([]);
return;
}
async function search() {
// query가 바뀔 때마다 새 요청이 발생합니다.
const response = await fetch(`/api/search?q=${query}`);
const data = (await response.json()) as SearchResult[];
// 별도 보호 장치가 없으면 늦게 도착한 이전 응답이 최신 결과를 덮을 수 있습니다.
setResults(data);
}
search();
}, [query]);
return (
<>
<input value={query} onChange={(event) => setQuery(event.target.value)} />
<ul>
{results.map((result) => (
<li key={result.id}>{result.title}</li>
))}
</ul>
</>
);
}이 코드는 얼핏 자연스럽지만, 실제로는 아래 문제가 바로 붙습니다.
- 먼저 보낸 rea 요청이 나중에 끝나면서, 더 최신인 react 결과를 덮어쓸 수도 있습니다.
- 같은 검색어로 다시 들어왔을 때도 매번 새 요청을 보냅니다.
- 잠깐 다른 화면으로 갔다 오면 이전 결과를 재사용할 기준이 없습니다.
- 이전 결과를 유지하면서 새 요청을 보낼지, 비우고 기다릴지 정책이 코드 바깥에 없습니다.
이 문제를 하나씩 손으로 막는 것은 가능합니다. AbortController를 쓰고, 별도 캐시 객체를 두고, 디바운스를 붙이고, 마지막 성공 결과를 유지하는 플래그를 만들 수도 있습니다.
하지만 어느 순간부터는 지금 이 화면 하나를 처리하는 코드가 아니라 서버 상태를 관리하는 작은 프레임워크를 직접 쓰고 있는 상태에 가까워집니다.
그래서 핵심은 서버 상태라는 이름을 붙이는 일입니다
여기서 중요한 전환이 하나 생깁니다. 데이터를 그냥 posts, results, user 같은 변수로 보지 않고, 서버 상태라는 별도 범주로 보기 시작하는 순간입니다.
서버 상태라고 부르면 문제의 성격이 더 또렷해집니다.
- 원본은 서버에 있다
- 지금 화면에 있는 값은 캐시된 사본일 수 있다
- 시간이 지나면 낡을 수 있다
- 여러 화면이 함께 참조할 수 있다
- 필요하면 다시 동기화해야 한다
이렇게 보면 React의 로컬 상태와 서버 상태를 같은 방식으로 다루기 어려운 이유도 자연스럽게 설명됩니다.
로컬 상태의 질문은 대개 이 값을 어디에 둘까에 가깝습니다.
반면 서버 상태의 질문은 이 값을 언제 다시 믿고, 언제 다시 가져오고, 누가 공유할까에 가깝습니다.
질문 자체가 다르기 때문에, 좋은 도구도 달라집니다.
이 글에서 아직 일부러 도구 이야기를 덜 한 이유
많은 경우 React Query나 SWR을 배우기 전에 가장 먼저 필요한 것은 API 문법이 아니라 문제 구분입니다.
왜냐하면 도구를 먼저 배우면 아래처럼 이해하기 쉽기 때문입니다.
- useQuery는 fetch를 대신하는 훅이다
- 캐시를 알아서 해 주는 라이브러리다
- 로딩과 에러를 예쁘게 감싸 준다
물론 틀린 말은 아닙니다. 하지만 이 정도 이해만으로는 실제 프로젝트에서 query key를 어떻게 나눌지, 언제 invalidate할지, 어떤 데이터는 prefetch하고 어떤 데이터는 지연시킬지 같은 설계 문제에서 다시 막히게 됩니다.
도구는 문제를 없애 주기보다, 문제를 더 적절한 단위로 다루게 해 줍니다. 그래서 먼저 필요한 것은 이건 단순한 컴포넌트 상태가 아니라 서버 상태를 다루는 문제구나라는 구분입니다.
이 구분이 잡혀야 이후의 캐시, staleTime, background refetch, mutation, hydration 같은 개념도 각각 왜 필요한지 납득할 수 있습니다.
다시 말해 이 글의 목적은 특정 라이브러리를 소개하는 일이 아니라, 왜 그런 라이브러리가 반복해서 등장하는지 설명할 바닥을 먼저 만드는 일에 가깝습니다.
마치며
React에서 비동기 데이터 처리가 자꾸 복잡해지는 이유는 fetch 코드가 길어져서가 아닙니다. 화면 바깥에 있는 원본 데이터를, 여러 화면과 여러 시점 사이에서 계속 맞춰 가야 하기 때문입니다.
처음에는 useEffect + useState로도 충분해 보입니다. 하지만 조건이 늘어나면 우리는 같은 질문을 반복해서 만나게 됩니다. 언제 다시 가져올지, 이미 있는 데이터를 믿어도 되는지, 여러 화면이 함께 쓸 때 기준은 무엇인지 같은 질문입니다.
이 질문들은 로컬 상태보다 서버 상태에 더 가깝습니다. 그래서 비동기 데이터 처리 문제는 네트워크 요청을 한 번 보내는 법보다 서버 상태를 어떤 책임 단위로 다룰 것인가의 문제로 보는 편이 더 정확합니다.
다음 글에서는 바로 그 지점에서 출발해 보려고 합니다. 서버 상태를 다룬다는 말이 정확히 무엇인지, 그리고 왜 캐시와 재검증, 중복 요청 방지, background refetch 같은 개념이 한 묶음으로 따라오는지를 이어서 정리하겠습니다.