비동기 데이터는 부탁한다고 그 자리에서 툭 떨어지지 않는다. 서버에 요청을 보내고 네트워크를 건너갔다 오는 그 짧지 않은 시간 동안, 화면은 뭐라도 보여줘야 한다. 그래서 비동기 데이터를 다루는 컴포넌트는 한 가지 모습이 아니라 여러 얼굴을 갖는다. 아직 안 온 상태, 잘못된 상태, 잘 온 상태다. 이 세 얼굴을 따로 관리하지 않고 데이터만 덜렁 가져다 쓰면, 첫 렌더에서 아직 없는 데이터를 건드리다 앱이 하얗게 죽는다. 내가 리액트로 API를 처음 붙였을 때 정확히 그렇게 죽었다.


로딩, 에러, 데이터. 비동기는 세 상태다. 서버에서 뭔가를 불러오는 일은 늘 이 세 가지 상태를 오간다. 요청을 보내고 응답을 기다리는 동안이 로딩(loading, 불러오는 중)이다. 응답이 실패로 돌아오면 에러(error)다. 무사히 도착하면 데이터(data)다. 이 셋은 한 순간에 하나만 참이다. 로딩 중이면서 동시에 데이터가 다 와 있을 수는 없다. 그래서 리액트에서는 이 세 상태를 각각 state로 두고, 지금 어느 상태인지에 따라 다른 화면을 그린다. 데이터 하나만 useState에 담아두는 걸로는 부족하다는 게 핵심이다.


useEffect 안에서 fetch로 불러온다. 데이터를 언제 부를지가 문제다. 컴포넌트 본문에서 그냥 fetch를 부르면 렌더할 때마다 요청이 나가버린다. 외부 세계에 요청을 보내는 일은 렌더 흐름 바깥, 즉 useEffect 안에서 해야 한다. 사용자 정보를 불러오는 컴포넌트를 세 상태로 짜보면 이렇다.


function UserProfile({ userId }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`)
.then((res) => {
if (!res.ok) throw new Error("응답이 정상이 아님");
return res.json();
})
.then((json) => setData(json))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [userId]);


상태가 어떻게 흘러가는지 따라가 보자. 처음 렌더에서 loadingtrue, dataerror는 비어 있다. 화면엔 불러오는 중이 뜬다. useEffect가 돌면서 요청이 나가고, 잠시 뒤 응답이 온다. 성공하면 setData로 데이터가 채워지고 finally에서 loadingfalse가 된다. 리렌더가 일어나 이번엔 데이터 화면이 뜬다. 실패하면 catch에서 error에 메시지가 담기고 마찬가지로 loading이 꺼져 에러 화면이 뜬다. 여기서 fetch의 함정 하나. fetch는 404나 500 같은 상태 코드를 실패로 치지 않아 catch로 안 빠진다. 네트워크가 끊기지 않는 한 응답을 성공으로 받는다. 그래서 res.ok를 직접 확인해 아니면 손으로 에러를 던져야 한다.


로딩과 에러를 화면에 반영한다. 세 상태를 화면으로 옮기는 건 이른 반환(early return, 조건에 맞으면 먼저 return하고 아래를 건너뜀)으로 처리하면 깔끔하다.


if (loading) return <p>불러오는 중...</p>;
if (error) return <p>문제가 생겼다: {error}</p>;
return <p>{data.name}님 환영합니다</p>;
}


순서가 중요하다. 로딩과 에러를 먼저 걸러낸 뒤에야 데이터를 쓰는 줄에 도달한다. 이 순서를 안 지키고 data.name부터 읽으면, 아직 datanull인 첫 렌더에서 null의 속성을 읽다가 그 자리에서 터진다. 앞서 말한 하얗게 죽는 사고가 바로 이거다. 세 상태를 나눠둔 값어치가 여기서 드러난다. 데이터를 쓰는 코드에 도달했다는 건 로딩도 끝났고 에러도 없다는 게 이미 보장됐다는 뜻이다.


로딩을 끄는 일은 finally에 맡긴다. 초보가 자주 빠뜨리는 게 실패했을 때 로딩을 못 끄는 것이다. then 안에서만 setLoading(false)를 하면, 에러가 나서 catch로 빠졌을 때 loading이 영원히 true로 남는다. 화면은 불러오는 중에서 멈춰 영영 안 넘어간다. 사용자는 그냥 느린 줄 알고 하염없이 기다린다. 성공하든 실패하든 로딩은 반드시 꺼야 하니, thencatch 둘 다에 넣거나 위 코드처럼 finally에 한 번만 넣는다. finally는 성공이든 실패든 끝에 무조건 한 번 실행되므로 이런 뒷정리에 딱 맞다.


경쟁 상태. 늦게 온 응답이 최신 화면을 덮어쓴다. 이건 눈에 잘 안 보여서 더 고약한 버그다. userId가 1번에서 2번으로 빠르게 바뀌는 상황을 그려보자. 1번 요청이 나가고, 응답이 오기 전에 2번으로 바뀌어 2번 요청이 또 나간다. 그런데 네트워크는 순서를 보장하지 않는다. 2번 응답이 먼저 오고 1번 응답이 뒤늦게 도착할 수 있다. 그러면 화면엔 방금 늦게 온 1번 데이터가 찍힌다. 사용자는 2번을 보고 있는데 1번 정보가 뜨는 것이다. 요청은 다 정상이었는데 순서가 엉켜 최신이 옛것에 덮인다. 막는 방법은 효과의 정리 함수(cleanup, 다음 효과 전에 뒷정리하는 함수)로 이전 요청의 결과를 무시하는 것이다.


useEffect(() => {
let ignore = false;
setLoading(true);
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((json) => {
if (!ignore) setData(json);
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, [userId]);


userId가 바뀌면 리액트는 새 효과를 돌리기 전에 이전 효과의 정리 함수를 먼저 부른다. 그때 ignoretrue로 세워두면, 뒤늦게 도착한 옛 요청은 if (!ignore)에 걸려 setData를 못 한다. 늦게 온 응답이 화면을 건드리지 못하게 막는 것이다. 더 확실히 하려면 AbortController로 오래된 요청 자체를 취소할 수도 있지만, 이 ignore 방식만으로도 화면이 엉키는 건 막는다. 참고로 개발 모드의 StrictMode에서는 효과가 일부러 두 번 실행되어 요청이 두 번 나가는 것처럼 보이는데, 이 정리 함수를 제대로 달아두면 그 중복도 자연스럽게 정리된다.


세 상태로 나눠 생각하는 습관이 먼저다. 비동기 데이터를 다룰 때 코드를 짜기 전에 로딩, 에러, 데이터 이 셋을 먼저 떠올리는 것만으로 절반은 온 셈이다. 대부분의 실전 버그는 이 중 하나를 빠뜨려서 생긴다. 에러 처리를 안 해서 실패가 조용히 묻히거나, 로딩을 안 꺼서 화면이 멈추거나, 경쟁 상태를 몰라 옛 데이터가 뜨거나. 지금은 useState 세 개로 손수 관리했다. 이 세 상태와 그 흐름이 손에 익으면, 어떤 데이터 요청을 만나도 화면이 지금 어느 얼굴을 하고 있어야 하는지가 먼저 보인다.