본문으로 건너뛰기

안동민 개발노트

본문 시작

useEffect와 데이터 요청

useEffect에서 Fetch API로 데이터를 요청해 상태에 반영하고 의존성 변경과 AbortController로 요청 생명주기를 관리합니다.

비동기 처리 기술을 활용해 리액트 컴포넌트 내부에서 데이터를 가져오는(Data Fetching) 방법을 알아보겠습니다.

리액트에서 컴포넌트가 마운트되거나 업데이트될 때 외부 데이터를 가져와야 하는 경우가 많습니다.

이런 부수 효과(Side Effects)를 처리하기 위해 리액트는 useEffect을 제공합니다.

이 장에서는 useEffect 훅으로 API 호출 같은 비동기 작업을 수행하는 실전 패턴을 다룹니다.

데이터 요청은 idle·loading·success·error 상태로 움직인다

useEffect는 요청을 시작하고 cleanup은 이전 요청을 닫으며, 화면은 현재 상태를 명시적으로 렌더한다.

  1. START
    loading

    전 오류를 비우고 요청 시작

  2. RESOLVE
    success

    최신 응답을 data에 commit

  3. REJECT
    error

    AbortError가 아닌 실패를 표시

  4. CHANGE
    cleanup → loading

    dependency 변경 시 이전 요청 취소


useEffect 훅 복습

useEffect 훅은 함수 컴포넌트 내에서 부수 효과(Side Effects)를 수행할 수 있게 해줍니다.

데이터 페칭, 구독 설정, DOM 직접 조작 등이 부수 효과에 해당합니다.

useEffect는 다음과 같은 형태로 사용됩니다.

useEffect(() => {
  // 부수 효과 코드
  // 이 함수는 컴포넌트가 렌더링된 후에 실행됩니다.

  return () => {
    // 클린업(Clean-up) 함수 (선택 사항)
    // 컴포넌트가 언마운트되거나 다음 효과가 실행되기 전에 실행됩니다.
    // 구독 해제, 타이머 클리어 등 정리 작업을 수행합니다.
  };
}, [dependencies]); // 의존성 배열 (선택 사항)
  • 의존성 배열([dependencies])
    • 배열이 없으면 (또는 빈 배열이 아님) → 모든 렌더링마다 효과가 실행됩니다.
    • 빈 배열([]) → 컴포넌트가 처음 마운트될 때 한 번만 실행되고, 언마운트될 때 클린업 함수가 실행됩니다. (초기 데이터 로딩에 적합)
    • 값들이 있는 배열([dep1, dep2]) → 배열 안의 값들 중 하나라도 변경될 때마다 효과가 다시 실행됩니다.

데이터 페칭의 기본 원리

데이터 페칭은 대표적인 비동기 부수 효과입니다.

useEffect를 사용하여 데이터를 가져올 때의 일반적인 패턴은 다음과 같습니다.

컴포넌트 마운트 시 데이터 로딩: 빈 의존성 배열 []을 사용하여 컴포넌트가 처음 로드될 때 한 번만 데이터를 가져옵니다.

데이터 로딩 상태 관리: 데이터를 가져오는 중인지, 성공했는지, 실패했는지 사용자에게 피드백을 주기 위해 useState를 사용하여 로딩 상태, 에러 상태, 데이터 상태를 관리합니다.

클린업 함수 활용 (옵션): useEffect의 클린업 함수를 사용하여 비동기 요청이 더 이상 필요 없을 때(예: 컴포넌트가 언마운트될 때) 해당 요청을 취소하거나 정리합니다.

이는 메모리 누수나 "컴포넌트가 언마운트된 후 상태 업데이트" 경고를 방지합니다.


실제 예제: 사용자 목록 가져오기

로컬 Mock API(json-server, http://localhost:4000)를 사용하여 사용자 목록을 가져오는 컴포넌트를 만들어 봅시다.

포트 정책은 4000=Mock API, 4100=별도 백엔드/실시간 서버로 분리하면 트랙 간 충돌을 줄일 수 있습니다.

npx json-server --watch db.json --port 4000

db.json에는 최소 users, posts 리소스를 준비하면 이 절의 예제를 바로 실행할 수 있습니다.

기본 Fetch API 사용 예제

src/components/UserList.js
import React, { useState, useEffect } from 'react';

function UserList() {
  const [users, setUsers] = useState([]);        // 사용자 데이터를 저장할 상태
  const [loading, setLoading] = useState(true);  // 로딩 상태
  const [error, setError] = useState(null);      // 에러 상태

  useEffect(() => {
    // 데이터를 가져오는 비동기 함수 정의 (async/await 사용)
    const fetchUsers = async () => {
      try {
        setLoading(true); // 요청 시작 시 로딩 상태를 true로 설정
        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 형태로 파싱
        setUsers(data || []); // json-server 목록 응답(배열) 반영
      } catch (err) {
        setError(err); // 에러 발생 시 에러 상태 업데이트
      } finally {
        setLoading(false); // 요청 완료(성공/실패 무관) 시 로딩 상태를 false로 설정
      }
    };

    fetchUsers(); // 비동기 함수 호출

    // 클린업 함수는 필요에 따라 추가
    // 여기서는 fetch 요청을 취소하는 기능은 포함하지 않습니다. (AbortController 사용 시 필요)
    return () => {
      console.log('UserList 컴포넌트 클린업');
    };
  }, []); // 빈 의존성 배열: 컴포넌트가 마운트될 때 한 번만 실행

  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에 추가

src/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가 어떤 순서로 바뀌는지 먼저 모델링하면 렌더 분기가 흔들리지 않습니다.

데이터 페칭은 요청 생명주기를 상태 전이로 관리한다

요청 시작, 성공, 실패, 취소를 명시적인 전이로 두면 UI 분기와 재요청 처리가 단순해진다.

  1. 1
    01 Start

    loading=true

  2. 2
    02 Resolve

    data 저장

  3. 3
    03 Reject

    error 저장

  4. 4
    04 Retry/Abort

    다시 요청/취소


async/await 사용 시 주의사항

useEffect 훅에 전달되는 함수는 동기 함수여야 합니다.

따라서 useEffect 콜백 함수를 직접 async로 만들면 안 됩니다.

잘못된 예시
useEffect(async () => { // 🚨 이렇게 직접 async로 만들지 마세요!
  const response = await fetch(...);
  const data = await response.json();
  // ...
}, []);

async 함수는 항상 Promise를 반환하는데, useEffect는 반환된 Promise를 클린업 함수로 간주하지 않습니다.

이는 예상치 못한 동작을 유발할 수 있습니다.

올바른 방법: useEffect 내부에서 비동기 함수를 정의하고, 즉시 호출하는 패턴을 사용합니다.

useEffect(() => {
  const fetchData = async () => { // 🌟 useEffect 내부에서 async 함수를 정의
    // ... 비동기 로직
  };

  fetchData(); // 🌟 정의된 async 함수를 즉시 호출
}, []);

UserList.js 예제에서 사용한 방식이 바로 이 올바른 패턴입니다.

이 패턴을 실제 화면 상태로 연결하면 effect 실행, 요청, cleanup, 렌더 분기가 아래처럼 이어집니다.

useEffect 데이터 페칭 실행 점검 기준

effect 콜백은 동기 함수로 유지하고, 내부 async 함수가 요청과 상태 전환을 담당하게 나누면 cleanup과 의존성 변경을 추적하기 쉽습니다.

  1. 마운트부터 렌더 분기까지

    의존성 배열 기준으로 실행 시점을 고정합니다.

  2. async 함수

    effect 내부에서 정의하고 즉시 호출합니다.

  3. 요청 상태

    loading, error, data를 한 요청 단위로 갱신합니다.

  4. cleanup

    언마운트나 값 변경 시 이전 요청을 정리합니다.

  5. 렌더 선택

    딩, 오류, 성공 화면을 명시적으로 분기합니다.


의존성 배열에 따른 데이터 페칭

의존성 배열은 재요청이 필요한 입력값을 선언한다

URL, id, filter 같은 값이 바뀔 때만 fetch가 다시 실행되도록 dependency를 명확히 둔다.

  1. 1
    01 Deps

    id/filter

  2. 2
    02 Effect

    fetch 시작

  3. 3
    03 Cleanup

    전 요청 정리

  4. 4
    04 Next data

    새 응답 반영

데이터 페칭은 항상 컴포넌트 마운트 시점에만 필요한 것은 아닙니다.

예를 들어, 페이지네이션이나 필터링처럼 특정 값이 변경될 때마다 데이터를 다시 가져와야 하는 경우도 있습니다.

예시: 사용자 ID에 따른 특정 사용자 정보 가져오기
src/components/UserProfileById.js
import React, { useState, useEffect } 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);

  useEffect(() => {
    if (!userId) { // userId가 없는 경우 (예: 초기 렌더링 시점에 파라미터가 아직 없을 때)
      setLoading(false);
      setError(new Error('사용자 ID가 제공되지 않았습니다.'));
      return;
    }

    const fetchUser = async () => {
      try {
        setLoading(true);
        // userId가 변경될 때마다 새로운 URL로 요청
        const response = await fetch(`http://localhost:4000/users/${userId}`);

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err);
      } finally {
        setLoading(false);
      }
    };

    fetchUser();

    return () => {
      // 클린업: 이전 요청이 완료되기 전에 userId가 변경되면
      // 이전 요청의 응답으로 인한 상태 업데이트를 방지할 수 있습니다.
      // (실제 fetch API는 AbortController 필요)
      console.log(`UserProfileById: Clean-up for userId ${userId}`);
    };
  }, [userId]); // 🌟 userId가 변경될 때마다 useEffect 재실행

  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;
라우트 설정 (App.js 또는 관련 라우트 파일)
src/App.js (일부)
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가 의존성 배열에 포함되어 있기 때문입니다.


클린업 함수와 요청 취소 (AbortController)

AbortController는 더 이상 유효하지 않은 요청을 끊는다

컴포넌트가 unmount되거나 검색 조건이 바뀌면 이전 요청을 취소해 늦은 응답이 현재 화면을 덮지 못하게 한다.

  1. AbortController

    Create AbortController signal 준비

  2. signal 전달

    Fetch signal 전달 요청 추적

  3. abort 호출

    Cleanup abort 호출 이전 요청 취소

  4. AbortError 구분

    Catch AbortError 구분 오류 UI 제외

컴포넌트가 언마운트되기 전에 비동기 요청이 완료되지 않은 경우, 해당 요청의 응답이 도착하여 언마운트된 컴포넌트의 상태를 업데이트하려고 하면 React에서 경고를 발생시킵니다 (메모리 누수 가능성).

이를 방지하기 위해 AbortController를 사용하여 비동기 요청을 취소할 수 있습니다.

src/components/CancellableFetch.js
import React, { useState, useEffect } from 'react';

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

  useEffect(() => {
    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();
        setData(json);
      } catch (err) {
        // AbortError는 요청 취소로 인한 에러이므로 특별히 처리할 수 있습니다.
        if (err.name === 'AbortError') {
          console.log('Fetch aborted');
        } else {
          setError(err);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchData();

    // 클린업 함수: 컴포넌트 언마운트 또는 의존성 변경 시 실행
    return () => {
      console.log('클린업: 요청 취소 시도');
      controller.abort(); // 요청 취소!
    };
  }, []); // 빈 배열: 마운트 시 한 번만 실행

  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 컴포넌트를 테스트하려면, 이 컴포넌트를 렌더링하는 부모 컴포넌트를 만들어 일정 시간 후 언마운트되도록 하거나, 다른 페이지로 빠르게 이동하면서 네트워크 탭을 확인해 볼 수 있습니다.

요청이 취소되면 status(canceled)로 표시됩니다.


"useEffect를 이용한 데이터 가져오기"는 여기까지입니다.

이 장에서는 useEffect 훅을 사용하여 리액트 컴포넌트 내부에서 비동기 데이터 페칭을 수행하는 방법을 상세하게 배웠습니다.

특히 async/await와 함께 데이터 로딩, 에러 처리, 그리고 의존성 배열에 따른 재실행, 마지막으로 AbortController를 이용한 요청 취소까지 중요한 패턴들을 익혔습니다.

useEffect는 컴포넌트 생명주기와 밀접하게 관련되어 있어 강력하지만, 잘못 사용하면 무한 루프나 불필요한 재렌더링을 유발할 수 있으므로 주의 깊게 사용해야 합니다.

다음 절에서는 axios와 같은 서드파티 라이브러리를 사용하여 더욱 편리하게 데이터를 가져오는 방법과, 데이터 페칭을 위한 커스텀 훅을 만드는 방법을 알아보겠습니다.


useEffect로 데이터를 가져올 때는 요청 시작보다 의존성 변화와 정리 시점을 더 꼼꼼히 봐야 합니다.

useEffect 데이터 요청 수명

컴포넌트가 렌더링된 뒤 요청을 시작하고, 의존성이 바뀌거나 언마운트될 때 AbortController로 오래된 요청을 정리합니다.

  1. 1
    effect start

    useEffect 내부에서 async 함수를 정의해 호출하고 loading과 error 상태를 초기화합니다.

  2. 2
    dependency

    userId, page, query처럼 요청 결과를 바꾸는 값만 의존성 배열에 넣습니다.

  3. 3
    cleanup

    AbortController나 mounted flag로 언마운트 후 setState 경고와 오래된 응답 반영을 막습니다.

  4. 4
    deps

    의존성 누락으로 오래된 데이터가 보이지 않는지 확인합니다.

  5. 5
    race

    빠르게 값이 바뀔 때 마지막 요청 결과만 반영합니다.

useEffect 훅 복습과 데이터 페칭의 기본 원리를 중심으로 정리한 보조 다이어그램입니다.

응답 순서가 아니라 request ID가 state 쓰기를 결정한다

dependency가 바뀌면 이전 요청을 정리하고, 늦게 도착한 응답은 최신성 gate를 통과할 때만 commit한다.

  1. 1

    dependency snapshot 요청 ID와 기준값을 묶어 이번 요청의 정체를 고정한다.

  2. 2

    cleanup / abort 다음 effect 전에 이전 controller를 취소해 영향 범위를 끊는다.

  3. 3

    commit gate 최신 요청만 loading·data·error 상태를 바꾼다. AbortError는 사용자 오류로 표시하지 않는다.

  4. 처음 한 번만 필요한 데이터

    mount once 빈 배열은 초기 로드에는 맞지만 입력 변화에는 반응하지 않는다. []

  5. 조건이 바뀌는 데이터

    by id userId·query·filter처럼 결과를 바꾸는 값은 dependency에 둔다. [userId]

  6. 매번 새 객체

    unsafe 매 렌더 새 options를 넣으면 요청 폭주와 stale response가 생긴다. [options]