icon
8장 : 비동기 처리 및 데이터 페칭

useEffect를 이용한 데이터 가져오기


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

리액트에서 컴포넌트가 마운트되거나 업데이트될 때 외부에서 데이터를 가져와야 하는 경우가 많습니다. 이러한 "부수 효과(Side Effects)"를 처리하기 위해 리액트는 useEffect을 제공합니다. 이 장에서는 useEffect 훅을 사용하여 API 호출 등의 비동기 작업을 수행하는 방법을 상세히 다루겠습니다.


useEffect 훅 복습

useEffect 훅은 함수 컴포넌트 내에서 부수 효과(Side Effects) 를 수행할 수 있게 해줍니다. 데이터 페칭, 구독 설정, DOM 직접 조작 등이 부수 효과에 해당합니다.

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

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

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

데이터 페칭의 기본 원리

데이터 페칭은 대표적인 비동기 부수 효과입니다. useEffect를 사용하여 데이터를 가져올 때의 일반적인 패턴은 다음과 같습니다.

  1. 컴포넌트 마운트 시 데이터 로딩: 빈 의존성 배열 []을 사용하여 컴포넌트가 처음 로드될 때 한 번만 데이터를 가져옵니다.
  2. 데이터 로딩 상태 관리: 데이터를 가져오는 중인지, 성공했는지, 실패했는지 사용자에게 피드백을 주기 위해 useState를 사용하여 로딩 상태, 에러 상태, 데이터 상태를 관리합니다.
  3. 클린업 함수 활용 (옵션): useEffect의 클린업 함수를 사용하여 비동기 요청이 더 이상 필요 없을 때(예: 컴포넌트가 언마운트될 때) 해당 요청을 취소하거나 정리합니다. 이는 메모리 누수나 "컴포넌트가 언마운트된 후 상태 업데이트" 경고를 방지합니다.

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

JSONPlaceholder API를 사용하여 사용자 목록을 가져오는 컴포넌트를 만들어 봅시다.

기본 Fetch API 사용 예제

src/components/UserList.js
// 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('https://jsonplaceholder.typicode.com/users');

        if (!response.ok) { // HTTP 상태 코드가 200번대가 아니면 에러 발생
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const data = await response.json(); // 응답을 JSON 형태로 파싱
        setUsers(data); // 데이터 상태 업데이트
      } 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.name}</strong> ({user.username})
            <br />
            <small>{user.email}</small>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default UserList;

App.js에 추가

src/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에 추가하고 실행하면, "사용자 정보를 불러오는 중..." 메시지가 잠시 표시된 후, JSONPlaceholder에서 가져온 사용자 목록이 나타나는 것을 볼 수 있습니다. 네트워크 탭에서 실제 API 요청이 발생하는 것을 확인할 수도 있습니다.


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 예제에서 사용한 방식이 바로 이 올바른 패턴입니다.


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

데이터 페칭은 항상 컴포넌트 마운트 시점에만 필요한 것은 아닙니다. 예를 들어, 페이지네이션이나 필터링처럼 특정 값이 변경될 때마다 데이터를 다시 가져와야 하는 경우도 있습니다.

예시: 사용자 ID에 따른 특정 사용자 정보 가져오기

src/components/UserProfileById.js
// 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(`https://jsonplaceholder.typicode.com/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.name}님의 프로필</h2>
      <p><strong>아이디:</strong> {user.username}</p>
      <p><strong>이메일:</strong> {user.email}</p>
      <p><strong>전화번호:</strong> {user.phone}</p>
      <p><strong>웹사이트:</strong> {user.website}</p>
      <p><strong>회사:</strong> {user.company.name}</p>
    </div>
  );
}

export default UserProfileById;

라우트 설정 (App.js 또는 관련 라우트 파일)

src/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:3000/users/1 또는 http://localhost:3000/users/5와 같이 userId를 변경해가며 접속하면, 해당 ID의 사용자 정보가 다시 로드되는 것을 확인할 수 있습니다. 이는 userId가 의존성 배열에 포함되어 있기 때문입니다.


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

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

src/components/CancellableFetch.js
// 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('https://jsonplaceholder.typicode.com/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와 같은 서드파티 라이브러리를 사용하여 더욱 편리하게 데이터를 가져오는 방법과, 데이터 페칭을 위한 커스텀 훅을 만드는 방법을 알아보겠습니다.