프론트엔드 화면 대부분은 결국 서버에서 데이터를 받아 뿌리는 일이다. 리액트에서 이걸 처음 시도할 때 초보가 반드시 밟는 지뢰가 있다. 컴포넌트 본문에 fetch를 그냥 적는 것이다. 얼핏 자연스러워 보이는데, 컴포넌트 본문은 화면을 그릴 때마다 처음부터 다시 실행된다. 데이터를 받아 setState를 하면 화면이 다시 그려지고, 그러면 fetch가 또 불리고, 또 상태가 바뀌고. 무한 루프다. 나는 이걸 모르고 개발 서버 콘솔에 요청 로그가 초당 수십 개씩 찍히는 걸 보고서야 뭔가 단단히 잘못됐다는 걸 알았다.


useEffect. 렌더가 끝난 뒤에 부수 작업을 시킨다. 데이터 요청 같은 건 화면을 그리는 일과 분리해서, 렌더가 끝난 다음에 따로 돌려야 한다. 그 자리가 useEffect다. 두 번째 인자로 빈 배열 []을 주면, 이 컴포넌트가 처음 화면에 나타날 때 딱 한 번만 실행된다. 무한 루프를 끊는 열쇠가 바로 이 빈 배열이다.


상태는 세 개로 쪼갠다. 데이터, 로딩, 에러. 네트워크 요청은 즉시 끝나지 않는다. 요청 중인 순간과, 성공한 순간과, 실패한 순간이 다 다르게 화면에 나와야 한다. 그래서 상태를 셋으로 나눈다. 받아온 데이터, 지금 불러오는 중인지, 실패했는지. 실제로 목록을 받아오는 컴포넌트를 통째로 보자.


import { useState, useEffect } from "react";

function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
fetch("https://jsonplaceholder.typicode.com/users")
.then((res) => {
if (!res.ok) throw new Error("요청 실패: " + res.status);
return res.json();
})
.then((data) => setUsers(data))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);

if (loading) return <p>불러오는 중...</p>;
if (error) return <p>에러: {error}</p>;
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}


여기 쓴 주소는 진짜로 열려 있는 연습용 공개 API다. 사용자 목록을 idname이 담긴 배열로 돌려준다. 이제 이 컴포넌트가 화면에서 실제로 어떻게 흘러가는지 순서대로 따라가 보자.


흐름은 로딩에서 시작해 데이터로 끝난다. 처음 렌더될 때 loadingtrue다. 그래서 화면엔 곧바로 불러오는 중...이 뜬다. 그 직후 useEffect가 돌며 fetch를 던진다. 잠시 뒤 응답이 오면 setUsers로 데이터가 채워지고, finally에서 loadingfalse가 된다. 상태가 바뀌었으니 화면이 다시 그려지고, 이번엔 로딩 줄을 건너뛰고 사용자 이름 목록이 ul로 뜬다. 사용자 눈에는 불러오는 중...이 잠깐 보였다가 목록으로 바뀌는 흐름이다. 요청이 실패하면 catcherror를 채우고, 화면은 목록 대신 에러: ...를 보여준다. 세 상태가 각자 자기 화면을 책임지는 구조다.


res.ok를 왜 확인하나. fetch는 404에도 안 울기 때문이다. 여기가 fetch의 악명 높은 함정이다. 서버가 404(없는 주소)나 500(서버 에러)을 돌려줘도, fetch는 그걸 실패로 치지 않는다. 응답을 정상적으로 받았다고 보고 catch로 넘어가지 않는다. fetch가 진짜로 거부하는 건 네트워크 자체가 끊겼을 때뿐이다. 그래서 res.ok로 상태 코드가 정상 범위인지 직접 확인하고, 아니면 손수 throw해서 catch로 흘려보내야 한다. 이걸 빼먹으면 서버가 에러 페이지를 줬는데도 정상인 척 res.json()을 시도하다 엉뚱한 데서 터진다. 나도 이걸 몰라서, 로그인 만료로 서버가 401을 주는데도 화면은 태연히 빈 목록을 그리는 걸 한참 못 잡았다.


떠난 컴포넌트에 값을 넣지 않도록 정리한다. 사용자가 응답이 오기 전에 다른 화면으로 넘어가 버리면, 이 컴포넌트는 이미 사라졌는데 뒤늦게 도착한 응답이 setUsers를 부른다. 없는 컴포넌트의 상태를 건드리는 셈이라 경고가 뜬다. 이걸 막으려면 useEffect가 정리 함수를 반환하게 해서, 떠났는지를 표시로 남긴다.


useEffect(() => {
let ignore = false;
fetch("https://jsonplaceholder.typicode.com/users")
.then((res) => res.json())
.then((data) => {
if (!ignore) setUsers(data);
});
return () => {
ignore = true;
};
}, []);


useEffect가 돌려준 함수는 컴포넌트가 사라질 때 실행된다. 여기서 ignoretrue로 바꿔두면, 나중에 응답이 도착해도 if (!ignore)에 걸려 상태 갱신을 건너뛴다. 사라진 화면에 값을 꽂는 사고를 이 한 줄이 막는다.


useEffect 콜백 자체는 async로 만들지 마라. await가 쓰고 싶어서 useEffect(async () => ...)라고 적기 쉬운데, 그러면 안 된다. async 함수는 항상 프로미스를 반환하는데, useEffect는 반환값을 정리 함수로 오해한다. await를 쓰고 싶으면 안에 async 함수를 따로 정의해서 부르는 식으로 우회한다.


useEffect(() => {
async function load() {
const res = await fetch("https://jsonplaceholder.typicode.com/users");
const data = await res.json();
setUsers(data);
}
load();
}, []);


주소의 값이 바뀌면 다시 불러오게 한다. 빈 배열 []은 처음 한 번만 부른다는 뜻이었다. 그런데 글 상세 화면처럼 id가 바뀔 때마다 새 데이터를 받아와야 하는 경우가 있다. 그럴 땐 그 값을 배열에 넣어준다.


function Post({ id }) {
const [post, setPost] = useState(null);

useEffect(() => {
fetch("https://jsonplaceholder.typicode.com/posts/" + id)
.then((res) => res.json())
.then((data) => setPost(data));
}, [id]);

return post ? <h2>{post.title}</h2> : <p>로딩...</p>;
}


[id]라고 적으면 id가 바뀔 때마다 useEffect가 다시 돌며 그 번호의 글을 새로 받아온다. 여기서 id를 빠뜨리면, 처음 받은 글만 붙박여 목록에서 다른 글을 눌러도 화면이 그대로인 버그가 된다. 이 의존성 배열은 리액트를 처음 배울 때 가장 자주 틀리는 대목이라, 효과 안에서 쓰는 값은 배열에 넣는다는 감각을 일찍 들여두는 게 좋다.


결국 API 연동의 뼈대는 늘 같다. useEffect 안에서 요청을 던지고, 로딩과 에러와 데이터 세 상태로 화면을 나눠 그린다. res.ok로 서버 에러를 걸러내고, 떠난 컴포넌트는 정리 함수로 지킨다. 이 네 가지만 몸에 배면, 어떤 서버를 붙여도 화면이 요청 중에 멈춰 보이거나 빈 채로 방치되는 일은 없다.