본문 바로가기

개발/React

[React] useEffect

학습 전 참고하기: [React] React를 위한 JavaScript(3) 비동기

 

이번 실습 단계에서는 React 바깥에 있는 서버에서 데이터를 받아와 화면에 표시해 볼 것이다. 

다만 useEffect를 배우면서 가장 먼저 잡아야 하는 것은 문법보다 언제 사용하는가이다.

React 공식 문서에서도 Effect를 "외부 시스템과 컴포넌트를 동기화하는 수단"으로 설명하고, 단순 계산이나 사용자 이벤트 처리에는 불필요한 Effect를 쓰지 말라고 안내하고 있다.

 

지금까지는 React 내부적으로 다루었다.

State
Props
Event
Component

 

하지만 실제 앱에서는 외부 세계와 연결해야 한다.

React
  ↓
서버 API
데이터베이스
브라우저 API
타이머
외부 라이브러리

컴포넌트를 이런 React 바깥의 시스템과 동기화할 때 useEffect가 등장한다.

이번에는 가장 흔한 사례인 서버 API 요청을 실습할 것이다.

 

Step 1. 우선 fetch() 이해하기

브라우저에는 서버에 HTTP 요청을 보내는 fetch()가 있다.

예를 들어:

fetch("https://jsonplaceholder.typicode.com/users");

서버에 요청하면 바로 데이터가 오는 것이 아니라 시간이 걸린다.

 

그래서 fetch()는 Promise를 반환한다.

 

우선 다음처럼 사용할 수 있다.

fetch("https://jsonplaceholder.typicode.com/users")
  .then((response) => response.json())
  .then((data) => {
    console.log(data);
  });

 

하지만 앞으로는 좀 더 직관적인 async / await를 사용할 것이다.

async function getUsers() {
  const response = await fetch(
    "https://jsonplaceholder.typicode.com/users"
  );

  const data = await response.json();

  console.log(data);
}

 

핵심은

fetch()
↓
서버 응답
↓
response.json()
↓
JavaScript 데이터

이다.

 

Step 2. 그런데 언제 API를 호출해야 할까?

사용자가 버튼을 눌렀을 때 데이터를 가져오는 거라면 쉽다.

function handleClick() {
  // API 요청
}

이건 사용자 이벤트이기 때문에 이벤트 핸들러에서 하면 된다.

하지만 이런 요구사항이라면 어떨까?


사용자 목록 화면이 나타나는 순간 자동으로 서버 데이터를 가져와라.


특정 버튼 클릭 때문이 아니라 컴포넌트가 화면에 나타났기 때문에 실행되어야 한다.

이때 useEffect가 적합하다.

컴포넌트 화면에 나타남
        ↓
Effect 실행
        ↓
서버 데이터 요청

 

 

Step 3. 매우 단순한 useEffect

실습을 위해 App.jsx를 간단하게 만들어보자.

 

src/App.jsx

import { useEffect } from "react";

function App() {
  useEffect(() => {
    console.log("Effect 실행");
  }, []);

  return <h1>사용자 목록</h1>;
}

export default App;

 

구조를 보면

useEffect(() => {
  // 실행할 코드
}, []);

이다.

 

지금 학습 단계에서는 useEffect 뒤의

[]

를


컴포넌트가 처음 화면에 나타날 때 이 Effect를 실행한다.


정도로 이해한다.

정확한 의존성 규칙은 조금 뒤에서 다룰 것이다.

 

Step 4. API 요청 연결하기

이제 실제 서버 데이터를 가져와보자.

 

src/App.jsx

import { useEffect } from "react";

function App() {
  useEffect(() => {
    async function getUsers() {
      const response = await fetch(
        "https://jsonplaceholder.typicode.com/users",
      );

      const data = await response.json();

      console.log(data);
    }

    getUsers();
  }, []);

  return <h1>사용자 목록</h1>;
}

export default App;

웹 브라우저에서 개발자 도구로 Console을 확인해보면

객체 배열이 나오는 것을 볼 수 있다.

 

Step 5. 데이터를 화면에 보여주려면 State가 필요하다

현재 서버로부터 받은 data는 함수 안의 지역 변수이다.

const data = await response.json();

 

그런데 내가 원하는 것은 서버에서 받은 데이터가 화면에 표시되는 것이다.

그러려면 data를 State에 저장해야한다.

 

먼저 data를 저장할 State를 선언하고

const [users, setUsers] = useState([]);

 

data를 state에 저장하면 된다.

setUsers(data);

 

이제 서버에서 받은 데이터를 렌더링하기 위해 map을 사용한다.

return (
  <div>
    <h1>사용자 목록</h1>
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          <h2>{user.name}</h2>
          <p>{user.email}</p>
        </li>
      ))}
    </ul>
  </div>
);
}

 

 

전체 코드:

import { useEffect, useState } from "react";

function App() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    async function getUsers() {
      const response = await fetch(
        "https://jsonplaceholder.typicode.com/users",
      );

      const data = await response.json();

      setUsers(data);
    }

    getUsers();
  }, []);

  return (
    <div>
      <h1>사용자 목록</h1>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            <h2>{user.name}</h2>
            <p>{user.email}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;

 

흐름을 보면

App 처음 렌더링
↓
users = []
↓
화면 표시
↓
useEffect 실행
↓
fetch()
↓
서버 응답
↓
setUsers(data)
↓
users State 변경
↓
App 다시 렌더링

이 구조가 핵심이다.

 

Step 6. Loading State

그런데 문제가 하나 있다.

 

API 요청은 시간이 걸린다.

화면 등장
↓
아직 서버 응답 없음
↓
몇 초 뒤
↓
데이터 도착

 

그동안 사용자에게 아무것도 안 보여주는 것보다 다음과 같이 표시하는 것이 좋다.

불러오는 중...

 

그럼 이제 State를 하나 추가하자.

const [loading, setLoading] = useState(true);

 

데이터 요청이 끝나면

setLoading(false);

한다.

 

그리고

if (loading) {
  return <p>불러오는 중...</p>;
}

와 같이 사용한다.

 

전체 코드:

import { useEffect, useState } from "react";

function App() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function getUsers() {
      const response = await fetch(
        "https://jsonplaceholder.typicode.com/users",
      );

      const data = await response.json();

      setUsers(data);
      setLoading(false);
    }

    getUsers();
  }, []);

  if (loading) {
    return <p>불러오는 중...</p>;
  }

  return (
    <div>
      <h1>사용자 목록</h1>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            <h2>{user.name}</h2>
            <p>{user.email}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;

 

Step 7. 실패하는 경우도 처리해야 한다

서버 요청은 언제든 실패할 수 있다.

따라서 실제 데이터 요청에서는 최소한 다음 세 State는 고려하는 것이 좋다.

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

 

Step 8. try / catch

JavaScript의 try / catch를 사용하여 error State를 관리해보자.

try {
  // 실패할 수 있는 코드
} catch (error) {
  // 실패했을 때
}

 

API 요청에 적용하면:

async function getUsers() {
  try {
    const response = await fetch(
      "https://jsonplaceholder.typicode.com/users",
    );

    const data = await response.json();

    setUsers(data);
  } catch (error) {
    setError("사용자 정보를 불러오지 못했습니다.");
  } finally {
    setLoading(false);
  }
}

finally는 성공하든 실패하든 마지막에 실행되기 때문에

setLoading(false);

를 넣기 좋다.

 

Step 9. HTTP 오류도 확인하기

fetch()에서 알아둘 중요한 점이 하나 있다.

서버가 404나 500 같은 HTTP 오류를 반환하더라도 상황에 따라 단순히 catch로 가지 않을 수 있다.

따라서

if (!response.ok) {
  throw new Error("요청 실패");
}

로 응답을 확인하는 패턴을 사용한다.

 

loading + error까지 포함한 전체 코드:

import { useEffect, useState } from "react";

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

  useEffect(() => {
    async function getUsers() {
      try {
        const response = await fetch(
          "https://jsonplaceholder.typicode.com/users",
        );

        if (!response.ok) {
          throw new Error("요청 실패");
        }

        const data = await response.json();

        setUsers(data);
      } catch (error) {
        setError("사용자 정보를 불러오지 못했습니다.");
      } finally {
        setLoading(false);
      }
    }

    getUsers();
  }, []);

  if (loading) {
    return <p>불러오는 중...</p>;
  }

  if (error) {
    return <p>{error}</p>;
  }

  return (
    <div>
      <h1>사용자 목록</h1>

      <ul>
        {users.map((user) => (
          <li key={user.id}>
            <h2>{user.name}</h2>
            <p>{user.email}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;

 

현재 데이터 흐름은

처음
loading = true
users = []
error = null

           ↓

         API 요청

     ↙ 성공      ↘ 실패

users 저장      error 저장

       ↘        ↙

     loading = false
           ↓
       화면 출력

이다.

 

이 패턴은 굉장히 자주 접하게 될 패턴이다.

 

Step 10. [ ]는 정확히 무엇인가?

useEffect(() => {
  // ...
}, []);

두 번째 인자를 dependency array, 즉 의존성 배열이라고 한다.

 

예를 들어

useEffect(() => {
  console.log("실행");
}, [userId]);

이 Effect는 userId에 의존한다는 뜻으로 userId가 변경되면 이 Effect를 다시 실행한다.

 

정리하면

useEffect(..., [])
→ 컴포넌트가 처음 렌더링 될 때 단 한 번만 실행

useEffect(..., [value])
→ 컴포넌트가 처음 렌더링 될 때 실행된 이후에도 value가 달라질 때마다 다시 실행

정도로 이해하면 현재 학습 단계에서는 충분하다.

 

Step 11. userId가 바뀔 때 서버 데이터 다시 가져오기

이번에는 의존성 배열의 필요성을 느껴보도록 하자.

예를 들어, 다음과 같은 userId에 대한 State와 userId를 통해 특정 사용자에 대한 정보를 가져오는 코드가 있다고 할 때

const [userId, setUserId] = useState(1);
useEffect(() => {
  async function getUser() {
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/users/${userId}`
    );

    const data = await response.json();

    console.log(data);
  }

  getUser();
}, [userId]);

여기서 의존성 배열인

[userId]

가 중요하다.

 

사용자가 userId를

1 → 2 → 3

으로 변경하면

userId = 1
↓
/users/1 요청

userId = 2
↓
Effect 다시 실행
↓
/users/2 요청

이 된다.

 

즉 Effect는:


현재 userId와 서버 데이터를 동기화한다


고 볼 수 있다.

이게 useEffect의 본질에 더 가깝다.

 

Step 12. 이벤트와 Effect도 구별하자

예를 들어 사용자가 버튼을 눌렀을 때 주문 요청을 한다면

function handleBuy() {
  // 주문 API 호출
}

처럼 이벤트 핸들러에 두는 것이 자연스럽다.

 

왜 실행됐는지가 명확하기 때문이다.

사용자가 구매 버튼 → 클릭 구매 요청

 

반면에

컴포넌트가 화면에 나타남 → 사용자 목록 가져오기

처럼 렌더링 자체 때문에 외부 시스템과 연결해야 한다면 Effect이다.

React 공식 문서도 특정 사용자 행동 때문에 실행되는 로직은 이벤트 핸들러에, 컴포넌트가 표시된 것 때문에 실행되는 외부 동기화는 Effect에 두는 식으로 구분한다.

 

Step 13. 개발 중 API 요청이 두 번 보일 수도 있다

React 프로젝트에서 개발하다 보면 Console이나 Network에서 Effect가 두 번 실행된 것처럼 보일 수 있다.

 

개발 환경의 React Strict Mode에서는 Effect의 문제를 찾아내기 위해 추가적인 setup → cleanup 사이클을 실행할 수 있다. production(실제 서비스 환경)에서 그대로 두 번 실행된다는 것이 아니다.

 

따라서,

StrictMode부터 제거하는 습관을 들이기보다는,

Effect는 다시 실행되어도 문제가 없도록 작성해야 한다.


라고 이해하는 편이 좋다.

 

Step 14. API 요청에는 cleanup도 고려해야 한다

현재 입문 단계에서는 조금 이른 내용이지만 핵심만 알아두자.

 

예를 들어

userId = 1 요청 시작

아직 응답 안 옴

사용자가 userId = 2로 변경

userId = 2 요청 시작

하면 먼저 시작한 요청이 나중에 도착하는 문제 등이 생길 수 있다.

공식 React 문서에서도 Effect로 데이터를 가져올 때 오래된 요청 결과가 State를 덮어쓰는 경쟁 조건을 피하도록 cleanup을 구현할 필요가 있다고 설명한다.

입문 단계에서는 다음 형태 정도만 눈에 익혀둔다.

useEffect(() => {
  let ignore = false;

  async function getUsers() {
    const response = await fetch(
      "https://jsonplaceholder.typicode.com/users"
    );

    const data = await response.json();

    if (!ignore) {
      setUsers(data);
    }
  }

  getUsers();

  return () => {
    ignore = true;
  };
}, []);

여기서

return () => {
  ignore = true;
};

부분이 cleanup이다.

 

지금 학습 단계에서는 "Effect에는 필요하다면 정리(cleanup) 작업도 존재한다" 정도로 기억한다.

 

실습

의존성 배열을 이용하여 특정 사용자를 조회하는 간단한 앱을 만들어보자.

src/App.jsx

import { useEffect, useState } from "react";

function App() {
  const [userId, setUserId] = useState(1);
  const [user, setUser] = useState(null);

  useEffect(() => {
    async function getUser() {
      const response = await fetch(
        `https://jsonplaceholder.typicode.com/users/${userId}`,
      );

      const data = await response.json();

      setUser(data);
    }

    getUser();
  }, [userId]);

  return (
    <div>
      <h1>사용자 조회</h1>

      <button onClick={() => setUserId(1)}>사용자 1</button>

      <button onClick={() => setUserId(2)}>사용자 2</button>

      <button onClick={() => setUserId(3)}>사용자 3</button>

      {user && (
        <div>
          <h2>{user.name}</h2>
          <p>{user.email}</p>
        </div>
      )}
    </div>
  );
}

export default App;

버튼을 하나씩 눌러보면 userId가 변경됨에 따라 화면에 표시되는 사용자 정보가 달라지는 것을 볼 수 있다.

즉, 해당 Effect는 userId State에 의존하기 때문에 userId State가 변할 때마다 Effect가 다시 실행된다.

따라서 의존성 배열이 비어있는 경우에는 userId State가 변하더라도 Effect는 다시 실행되지 않는다.