useEffect와 데이터 요청
useEffect에서 Fetch API로 데이터를 요청해 상태에 반영하고 의존성 변경과 AbortController로 요청 생명주기를 관리합니다.
비동기 처리 기술을 활용해 리액트 컴포넌트 내부에서 데이터를 가져오는(Data Fetching) 방법을 알아보겠습니다.
리액트에서 컴포넌트가 마운트되거나 업데이트될 때 외부 데이터를 가져와야 하는 경우가 많습니다.
이런 부수 효과(Side Effects)를 처리하기 위해 리액트는 useEffect 훅을 제공합니다.
이 장에서는 useEffect 훅으로 API 호출 같은 비동기 작업을 수행하는 실전 패턴을 다룹니다.
Idle → loading → success | error
화면은 현재 요청의 상태 하나를 렌더하고, 완료 순서가 아니라 최신 요청 여부로 결과를 채택합니다. 요청 시작, 성공, 실패, 취소를 분리하면 서로 모순되는 loading·error 조합과 오래된 응답의 덮어쓰기를 피할 수 있습니다.
아직 요청하지 않은 상태
초기 안내나 요청 행동을 보여줍니다. 시작 이벤트가 발생하면 이전 오류를 비우고 Loading으로 전이합니다.
현재 요청이 진행 중
loading을 켜고 진행 피드백을 렌더합니다. 기존 데이터를 유지할지 비울지는 제품 정책으로 정하되, 이번 요청의 정체성은 고정합니다.
최신 요청이 데이터로 이행
현재 요청의 응답만 data에 반영하고 Loading을 끝냅니다. 입력 변경이나 재시도는 새 요청을 만들어 Loading으로 돌아갑니다.
최신 요청이 실제 오류로 실패
AbortError가 아닌 현재 요청의 실패만 오류 UI로 보냅니다. 재시도하면 오류를 비우고 Loading으로 전이합니다.
취소와 stale completion은 결과 상태가 아니다
cleanup에서 이전 요청을 중단하고, 늦은 완료는 최신성 gate에서 버립니다. 취소를 Error로 표시하거나 오래된 결과로 Success를 만들지 않습니다.
렌더 분기는 상태 계약을 그대로 따른다
Loading은 진행 화면, Error는 실패와 재시도, Success는 데이터 화면을 선택합니다. 요청 코드보다 이 전이표를 먼저 정하면 UI가 흔들리지 않습니다.
요청 시작은 Loading으로, 최신 이행은 Success로, 최신 비취소 실패는 Error로 이동합니다. cleanup과 request identity gate는 상태 전이 바깥에서 오래된 요청의 쓰기를 차단합니다.
useEffect 훅 복습
useEffect 훅은 함수 컴포넌트 내에서 부수 효과(Side Effects)를 수행할 수 있게 해줍니다.
데이터 페칭, 구독 설정, DOM 직접 조작 등이 부수 효과에 해당합니다.
useEffect는 다음과 같은 형태로 사용됩니다.
useEffect(() => {
// 부수 효과 코드
// 이 setup은 컴포넌트가 commit된 뒤 실행됩니다.
return () => {
// 클린업(Clean-up) 함수 (선택 사항)
// 의존성이 바뀐 다음 setup 전이나 언마운트 때 실행됩니다.
// 구독 해제, 타이머 클리어 등 정리 작업을 수행합니다.
};
}, [dependencies]); // 의존성 배열 (선택 사항)-
의존성 배열(
[dependencies])- 배열 인수를 생략하면 → 컴포넌트가 commit될 때마다 효과가 다시 실행됩니다.
- 빈 배열(
[]) → setup이 읽는 반응형 값이 없음을 뜻합니다. 프로덕션에서는 보통 초기 마운트 뒤 setup과 언마운트 cleanup에 대응하지만, 개발 StrictMode는 버그를 찾기 위해 setup → cleanup → setup을 한 번 더 점검하므로 “정확히 한 번” 실행된다는 계약이 아닙니다. - 값들이 있는 배열(
[dep1, dep2]) → React가 각 값을 이전 값과Object.is로 비교합니다. 하나라도 다르면 이전 cleanup을 실행한 뒤 새 값으로 setup을 실행합니다.
Effect 안에서 읽는 props, state, 컴포넌트 본문의 변수와 함수 같은 모든 반응형 값은 의존성에 포함해야 합니다. 의존성 배열은 성능을 임의로 조절하는 목록이 아니라 setup이 동기화하는 입력의 선언입니다.
데이터 페칭의 기본 원리
데이터 페칭은 대표적인 비동기 부수 효과입니다.
useEffect를 사용하여 데이터를 가져올 때의 일반적인 패턴은 다음과 같습니다.
초기 데이터 로딩: setup이 반응형 값을 읽지 않을 때 빈 의존성 배열 []을 사용합니다. 개발 StrictMode의 추가 setup에서도 결과가 안전하도록 cleanup이나 stale-result gate를 함께 설계합니다.
데이터 로딩 상태 관리: 데이터를 가져오는 중인지, 성공했는지, 실패했는지 사용자에게 피드백을 주기 위해 useState를 사용하여 로딩 상태, 에러 상태, 데이터 상태를 관리합니다.
클린업과 최신성 확인: 요청이 더 이상 필요 없을 때는 cleanup에서 취소하고, 늦게 끝난 작업은 현재 요청인지 확인한 뒤 상태에 반영합니다.
React 18부터 언마운트된 컴포넌트의 상태 업데이트 경고는 제거됐습니다. 그러나 cleanup이 없으면 네트워크·구독 같은 자원이 남을 수 있고, 아직 마운트된 컴포넌트에서도 오래된 응답이 최신 화면을 덮는 문제는 그대로 발생할 수 있습니다.
실제 예제: 사용자 목록 가져오기
로컬 Mock API(json-server, http://localhost:4000)를 사용하여 사용자 목록을 가져오는 컴포넌트를 만들어 봅시다.
포트 정책은 4000=Mock API, 4100=별도 백엔드/실시간 서버로 분리하면 트랙 간 충돌을 줄일 수 있습니다.
npx json-server --watch db.json --port 4000db.json에는 최소 users, posts 리소스를 준비하면 이 절의 예제를 바로 실행할 수 있습니다.
기본 Fetch API 사용 예제
import React, { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]); // 사용자 데이터를 저장할 상태
const [loading, setLoading] = useState(true); // 로딩 상태
const [error, setError] = useState(null); // 에러 상태
useEffect(() => {
let active = true; // 이 setup이 소유한 결과만 반영
// 데이터를 가져오는 비동기 함수 정의 (async/await 사용)
const fetchUsers = async () => {
try {
setLoading(true); // 요청 시작 시 로딩 상태를 true로 설정
setError(null);
const response = await fetch('http://localhost:4000/users');
if (!response.ok) { // HTTP 상태 코드가 200번대가 아니면 에러 발생
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json(); // 응답을 JSON 형태로 파싱
if (active) {
setUsers(Array.isArray(data) ? data : []);
}
} catch (err) {
if (active) {
setError(err); // 현재 setup의 에러만 반영
}
} finally {
if (active) {
setLoading(false); // 오래된 setup이 새 loading을 끄지 못하게 함
}
}
};
fetchUsers(); // 비동기 함수 호출
return () => {
active = false;
};
}, []); // 이 setup이 읽는 반응형 값이 없음
if (loading) {
return <div style={{ textAlign: 'center', padding: '20px' }}>사용자 정보를 불러오는 중...</div>;
}
if (error) {
return <div style={{ textAlign: 'center', padding: '20px', color: 'red' }}>오류 발생: {error.message}</div>;
}
return (
<div style={{ maxWidth: '800px', margin: '20px auto', padding: '20px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 5px rgba(0,0,0,0.05)' }}>
<h2 style={{ textAlign: 'center', color: '#2c3e50' }}>사용자 목록</h2>
<ul style={{ listStyle: 'none', padding: 0 }}>
{users.map(user => (
<li
key={user.id}
style={{
padding: '15px',
marginBottom: '10px',
border: '1px solid #eee',
borderRadius: '5px',
backgroundColor: '#fefefe',
boxShadow: '0 1px 3px rgba(0,0,0,0.02)',
}}
>
<strong style={{ color: '#3498db' }}>{user.firstName} {user.lastName}</strong> ({user.username})
<br />
<small>{user.email}</small>
</li>
))}
</ul>
</div>
);
}
export default UserList;App.js에 추가
import React from 'react';
import UserList from './components/UserList'; // UserList 임포트
function App() {
return (
<div className="App">
{/* ... 기존 Navbar, Routes 등 */}
<UserList /> {/* UserList 컴포넌트 추가 */}
</div>
);
}실행 및 확인:
UserList 컴포넌트를 App.js에 추가하고 실행하면, 사용자 정보를 불러오는 중... 메시지가 잠시 표시된 후, 로컬 Mock API에서 가져온 사용자 목록이 나타나는 것을 볼 수 있습니다.
네트워크 탭에서 실제 API 요청이 발생하는 것을 확인할 수도 있습니다.
데이터 페칭 컴포넌트는 요청 코드보다 상태 전환을 정확히 잡는 것이 중요합니다.
loading, error, users가 어떤 순서로 바뀌는지 먼저 모델링하면 렌더 분기가 흔들리지 않습니다.
async/await 사용 시 주의사항
useEffect 훅에 전달되는 함수는 동기 함수여야 합니다.
따라서 useEffect 콜백 함수를 직접 async로 만들면 안 됩니다.
useEffect(async () => { // 🚨 이렇게 직접 async로 만들지 마세요!
const response = await fetch(...);
const data = await response.json();
// ...
}, []);async 함수는 항상 Promise를 반환하지만, Effect setup은 cleanup 함수 또는 undefined만 반환해야 합니다.
Promise는 cleanup 계약을 충족하지 않으므로 setup 자체를 async로 선언하면 안 됩니다.
올바른 방법:
useEffect 내부에서 비동기 함수를 정의하고, 즉시 호출하는 패턴을 사용합니다.
useEffect(() => {
const fetchData = async () => { // 🌟 useEffect 내부에서 async 함수를 정의
// ... 비동기 로직
};
fetchData(); // 🌟 정의된 async 함수를 즉시 호출
}, []);위 UserList.js 예제에서 사용한 방식이 바로 이 올바른 패턴입니다.
이 패턴의 비동기 완료도 앞서 정의한 Loading, Success, Error 상태 전이와 최신 요청 판정을 따라야 합니다.
의존성 배열에 따른 데이터 페칭
데이터 페칭은 항상 컴포넌트 마운트 시점에만 필요한 것은 아닙니다.
예를 들어, 페이지네이션이나 필터링처럼 특정 값이 변경될 때마다 데이터를 다시 가져와야 하는 경우도 있습니다.
Effect가 읽는 userId, query, filter 같은 값은 모두 의존성에 둡니다. 반대로 렌더마다 새로 생성되는 options 객체를 그대로 의존성에 넣으면 값의 내용이 같아도 참조가 달라 Effect가 계속 다시 실행될 수 있으므로, 필요한 원시 값만 의존하게 구조를 단순화합니다.
import React, { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom'; // React Router 사용 예시
function UserProfileById() {
const { userId } = useParams(); // URL 파라미터에서 userId 가져옴
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const latestRequest = useRef(0);
useEffect(() => {
const requestId = ++latestRequest.current;
let active = true;
const isCurrent = () => active && requestId === latestRequest.current;
if (!userId) { // userId가 없는 경우 (예: 초기 렌더링 시점에 파라미터가 아직 없을 때)
setUser(null);
setLoading(false);
setError(new Error('사용자 ID가 제공되지 않았습니다.'));
return () => {
active = false;
};
}
const controller = new AbortController();
const fetchUser = async () => {
try {
setLoading(true);
setError(null);
// userId가 변경될 때마다 새로운 URL로 요청
const response = await fetch(
`http://localhost:4000/users/${userId}`,
{ signal: controller.signal },
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (isCurrent()) {
setUser(data);
}
} catch (err) {
if (err.name !== 'AbortError' && isCurrent()) {
setError(err);
}
} finally {
// 오래된 요청의 finally가 새 요청의 loading을 끄지 못하게 함
if (isCurrent()) {
setLoading(false);
}
}
};
fetchUser();
return () => {
active = false;
controller.abort();
};
}, [userId]); // userId가 바뀌면 이전 cleanup 뒤 새 요청 시작
if (loading) {
return <div style={{ textAlign: 'center', padding: '20px' }}>사용자 정보를 불러오는 중...</div>;
}
if (error) {
return <div style={{ textAlign: 'center', padding: '20px', color: 'red' }}>오류 발생: {error.message}</div>;
}
if (!user) {
return <div style={{ textAlign: 'center', padding: '20px' }}>사용자를 찾을 수 없습니다.</div>;
}
return (
<div style={{ maxWidth: '600px', margin: '20px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 5px rgba(0,0,0,0.05)' }}>
<h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '20px' }}>{user.firstName} {user.lastName}님의 프로필</h2>
<p><strong>아이디:</strong> {user.username}</p>
<p><strong>이메일:</strong> {user.email}</p>
<p><strong>전화번호:</strong> {user.phone}</p>
<p><strong>도메인:</strong> {user.domain || '-'}</p>
<p><strong>회사:</strong> {user.company?.name || '-'}</p>
</div>
);
}
export default UserProfileById;import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import UserProfileById from './components/UserProfileById'; // 새 컴포넌트 임포트
// ... 다른 임포트
function App() {
return (
<BrowserRouter>
{/* ... Navbar 등 */}
<div className="main-content">
<Routes>
{/* ... 다른 라우트들 */}
{/* 동적 라우트 설정 */}
<Route path="/users/:userId" element={<UserProfileById />} />
{/* ... 404 라우트 */}
</Routes>
</div>
</BrowserRouter>
);
}실행 및 확인:
http://localhost:5173/users/1 또는 http://localhost:5173/users/5와 같이 userId를 변경해가며 접속하면, 해당 ID의 사용자 정보가 다시 로드되는 것을 확인할 수 있습니다.
이는 userId가 의존성 배열에 포함되어 있기 때문입니다. 각 setup은 자신이 시작될 때의 userId를 캡처하며, cleanup과 request ID가 그중 최신 요청만 상태를 쓰게 합니다.
클린업 함수와 요청 취소 (AbortController)
Setup → cleanup → latest-wins commit
cleanup은 이전 작업을 멈추고, 요청 ID gate는 이미 완료 중인 오래된 작업의 모든 상태 쓰기를 막습니다. 데이터·오류뿐 아니라 finally의 loading 종료까지 같은 최신성 조건을 통과해야 새 요청의 화면을 보존할 수 있습니다.
-
commit 뒤 setup에서 요청 정체성을 고정
Effect callback은 cleanup 함수나
undefined를 반환하는 동기 setup으로 두고 내부 async 함수를 호출합니다. 현재 dependency snapshot, 새requestId,AbortController를 한 요청으로 묶습니다.[]은 반응형 의존성이 없다는 뜻이며 개발 StrictMode에서는 추가 setup→cleanup→setup 점검이 있으므로 “정확히 한 번” 계약이 아닙니다. -
dependency가 바뀌면 이전 cleanup 뒤 새 setup
React는
Object.is로userId, query, filter 같은 반응형 값을 비교합니다. 누락한 값은 stale 데이터의 원인이 되고, 렌더마다 새로 만든 options 객체는 Effect를 불필요하게 반복시킬 수 있습니다. -
cleanup은 비활성화 후 abort
다음 setup 전이나 unmount 때 이번 요청을 비활성으로 표시하고
abort()를 호출합니다. fetch가AbortError로 거부되면 사용자 오류로 저장하지 않습니다. React 18에서 unmounted setState 경고는 제거됐지만, 네트워크·구독 자원과 stale write 문제는 그대로 남습니다. -
완료 시 active와 최신 request ID를 함께 검사
응답이 먼저 시작된 순서나 도착한 순서가 아니라 “아직 active이고 ID가 latest와 같은가”로
data와error쓰기를 결정합니다. abort보다 먼저 끝난 작업이나 signal을 따르지 않는 후속 처리도 이 gate에서 버립니다. -
finally도 같은 gate를 통과이전 요청의
finally가 무조건setLoading(false)를 실행하면 새 요청이 아직 진행 중인데 로딩 UI를 끌 수 있습니다. 최신 요청일 때만 loading을 끝내야 latest-wins 계약이 완성됩니다.
A 요청 뒤 입력이 B로 변경
A cleanup이 controller를 중단하고 active를 닫습니다. A의 성공·실패·finally가 나중에 이어져도 ID 불일치로 data, error, loading을 모두 쓰지 않습니다.
B 요청만 현재 화면을 소유
B의 ID가 latest와 일치할 때만 성공 또는 실제 오류를 반영하고 loading을 끝냅니다. 그 상태가 Loading, Success, Error 렌더 분기를 선택합니다.
AbortController는 불필요한 fetch 작업을 멈추는 자원 계약이고, active/request ID는 오래된 비동기 완료가 현재 React state를 바꾸지 못하게 하는 쓰기 계약입니다. 둘은 대체 관계가 아닙니다.
React 18은 언마운트된 컴포넌트에 대한 상태 업데이트 경고를 제거했습니다. 해당 업데이트가 곧바로 메모리 누수를 뜻하지는 않았기 때문입니다.
하지만 경고가 사라진 것과 작업을 정리할 필요가 없어진 것은 다릅니다. AbortController로 불필요한 fetch 자원을 중단하고, active 또는 request ID gate로 오래된 완료가 현재 상태를 쓰지 못하게 해야 합니다. 특히 이전 요청의 finally가 새 요청의 loading 상태를 끄지 않도록 같은 gate를 적용합니다.
import React, { useState, useEffect } from 'react';
function CancellableFetch() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let active = true;
const controller = new AbortController(); // AbortController 인스턴스 생성
const signal = controller.signal; // 신호 가져오기
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('http://localhost:4000/posts/1', { signal }); // fetch 옵션에 signal 전달
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (active) {
setData(json);
}
} catch (err) {
// AbortError는 사용자에게 보여줄 요청 실패가 아닙니다.
if (err.name !== 'AbortError' && active) {
setError(err);
}
} finally {
if (active) {
setLoading(false);
}
}
};
fetchData();
// 클린업 함수: 컴포넌트 언마운트 또는 의존성 변경 시 실행
return () => {
active = false; // 모든 후속 상태 쓰기 차단
controller.abort(); // fetch 자원 중단 시도
};
}, []); // 이 setup이 읽는 반응형 값이 없음
if (loading) return <div style={{ textAlign: 'center', padding: '20px' }}>데이터 로딩 중...</div>;
if (error) return <div style={{ textAlign: 'center', padding: '20px', color: 'red' }}>에러: {error.message}</div>;
return (
<div style={{ maxWidth: '600px', margin: '20px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 5px rgba(0,0,0,0.05)' }}>
<h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '20px' }}>게시글 상세</h2>
<h3 style={{ color: '#3498db', marginBottom: '10px' }}>{data.title}</h3>
<p>{data.body}</p>
<p style={{ marginTop: '15px', fontSize: '0.9em', color: '#777' }}>
(이 컴포넌트는 `AbortController`를 사용하여 마운트 해제 시 fetch 요청을 취소합니다.)
</p>
</div>
);
}
export default CancellableFetch;이 CancellableFetch 컴포넌트를 테스트하려면, 이 컴포넌트를 렌더링하는 부모 컴포넌트를 만들어 일정 시간 후 언마운트되도록 하거나, 다른 페이지로 빠르게 이동하면서 네트워크 탭을 확인해 볼 수 있습니다.
요청이 취소되면 브라우저 개발 도구의 네트워크 상태에 취소된 요청으로 표시될 수 있습니다. 표시 문구는 브라우저마다 다를 수 있으며, 애플리케이션은 AbortError와 최신성 gate를 기준으로 동작해야 합니다.
"useEffect를 이용한 데이터 가져오기"는 여기까지입니다.
이 장에서는 useEffect 훅을 사용하여 리액트 컴포넌트 내부에서 비동기 데이터 페칭을 수행하는 방법을 상세하게 배웠습니다.
특히 async/await와 함께 데이터 로딩, 에러 처리, 그리고 의존성 배열에 따른 재실행, 마지막으로 AbortController를 이용한 요청 취소까지 중요한 패턴들을 익혔습니다.
useEffect는 컴포넌트 생명주기와 밀접하게 관련되어 있어 강력하지만, 잘못 사용하면 무한 루프나 불필요한 재렌더링을 유발할 수 있으므로 주의 깊게 사용해야 합니다.
다음 절에서는 axios와 같은 서드파티 라이브러리를 사용하여 더욱 편리하게 데이터를 가져오는 방법과, 데이터 페칭을 위한 커스텀 훅을 만드는 방법을 알아보겠습니다.