React useEffect와 의존성 배열 완벽 정리! 무한 루프 피하는 꿀팁까지

 

React useEffect와 의존성 배열 완벽 정리! 무한 루프 피하는 꿀팁까지

리액트를 배우다 보면 'useEffect'를 제대로 이해하는 게 정말 중요한데요.

오늘은 이 훅을 완벽하게 마스터하기 위한 핵심 내용들을 정리해보겠습니다.

제가 이번에 집중적으로 학습한 내용은 다음과 같은데요.

첫째로 'useEffect'의 기본 사용법을 확실히 익히고, 둘째로 의존성 배열이 정확히 어떤 역할을 하는지 이해하는 겁니다.

셋째로는 API에서 데이터를 가져와서 state에 저장하는 실전 패턴을 익히고, 마지막으로 로딩이나 에러 처리까지 포함한 완벽한 부작용 처리 방법을 배우는 거죠.

 

useEffect가 뭐길래 이렇게 중요할까?

'useEffect'는 리액트에서 제공하는 훅 중 하나인데요.

리액트 컴포넌트가 '화면에 그려진 후'에 실행할 작업을 정의하는 역할을 합니다.

import {useEffect} from "react";

useEffect(setup, dependencies?)

쉽게 말해서 컴포넌트의 부작용을 관리하는 기능이거든요.

특히 컴포넌트가 처음 마운트될 때는 무조건 한 번은 실행됩니다.

 

그래서 '부작용'이 뭔데?

리액트에서 말하는 '부작용(side effect)'은 화면을 그리는 것 외의 모든 작업을 의미하는데요.

실제 개발하면서 자주 마주치는 부작용들은 이런 것들입니다.

먼저 API를 호출해서 서버 데이터를 가져오는 작업이 있고요.

'localStorage'에 데이터를 저장하거나 불러오는 작업도 부작용입니다.

DOM을 직접 조작하거나 이벤트 리스너를 등록하는 것도 마찬가지고요.

타이머나 인터벌을 설정하는 것도 전부 부작용에 해당합니다.

 

의존성 배열로 실행 타이밍 제어하기

의존성 배열이 진짜 중요한 이유는 'useEffect'가 언제 실행될지를 결정하기 때문인데요.

크게 세 가지 패턴으로 나눌 수 있습니다.

의존성 배열을 아예 안 쓰면 매번 렌더링될 때마다 실행되는데요.

이건 성능상 좋지 않아서 거의 사용하지 않습니다.

// 매번 실행 (비추천)
useEffect(() => {
  console.log("렌더링할 때마다 실행돼요");
});

빈 배열을 넣으면 컴포넌트가 처음 마운트될 때만 실행되거든요.

API 호출이나 초기 설정 작업에 가장 많이 사용하는 패턴입니다.

// 초기 마운트 시에만 실행
useEffect(() => {
  console.log("컴포넌트가 처음 나타날 때 한 번만 실행돼요");
}, []);

특정 값을 배열에 넣으면 그 값이 변경될 때마다 실행되는데요.

state나 props의 변화를 감지해서 처리할 때 유용합니다.

// count가 변경될 때마다 실행
useEffect(() => {
  console.log("count 값이 바뀌었네요!");
}, [count]);

의존성 배열에는 변경될 가능성이 있는 모든 값을 넣어야 하는데요.

state뿐만 아니라 props, 함수, 객체까지 전부 대상이 됩니다.

 

실전 예제 - API로 데이터 가져오기

이제 실제로 'useEffect'를 활용해서 Dog API에서 강아지 이미지를 가져와보겠는데요.

랜덤으로 귀여운 강아지 사진을 보여주는 컴포넌트를 만들어볼게요!

import { useEffect, useState } from "react";

type dataProps = {
  message: string;
  status: "success";
};

export default function DogImage() {
  const [data, setData] = useState<dataProps | null>(null);

  // API에서 데이터 가져오기
  const fetchData = async () => {
    const res = await fetch("https://dog.ceo/api/breeds/image/random");
    const json = await res.json();
    setData(json);
  };

  useEffect(() => {
    fetchData();
  }, []); // 컴포넌트 마운트 시 한 번만 실행

  return (
    <div className="flex flex-col gap-8 items-center">
      <img
        src={data?.message}
        alt="랜덤 강아지 이미지"
        width={300}
        height={300}
        className="object-contain"
      />
      <button onClick={fetchData}>다른 이미지 보기</button>
    </div>
  );
}

버튼을 클릭하면 새로운 이미지를 가져올 수 있지만, 처음 화면이 뜰 때도 자동으로 이미지를 보여주고 싶거든요.

바로 이럴 때 'useEffect'를 사용하는 겁니다.

 

로딩과 에러 처리까지 완벽하게

위 코드도 동작은 하지만 사용자 경험 측면에서 좀 아쉬운데요.

로딩 상태와 에러 처리를 추가해서 더 완성도 있게 만들어보겠습니다.

import { useEffect, useState } from "react";

type dataProps = {
  message: string;
  status: "success";
};

export default function DogImage() {
  const [data, setData] = useState<dataProps | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // API에서 데이터 가져오기
  const fetchData = async () => {
    try {
      setData(null);
      setLoading(true);
      setError(null);
      const res = await fetch("https://dog.ceo/api/breeds/image/random");
      if (!res.ok) throw new Error("Fetch failed");
      const json: dataProps = await res.json();
      setData(json);
    } catch (err) {
      setError("이미지를 불러올 수 없어요 😢");
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchData();
  }, []); // 컴포넌트 마운트 시 한 번만 실행

  return (
    <div className="flex flex-col gap-8 items-center">
      <div className="flex items-center justify-center w-[300px] h-[300px]">
        {loading && <p>🐕 이미지 불러오는 중...</p>}
        {error && <p className="text-red-500">{error}</p>}
        {data && (
          <img
            src={data.message}
            alt="랜덤 강아지 이미지"
            width={300}
            height={300}
            className="w-full h-full object-contain"
          />
        )}
      </div>
      <button onClick={fetchData}>다른 이미지 보기</button>
    </div>
  );
}

 

fetch 에러 처리의 숨은 포인트

여기서 중요한 부분이 하나 있는데요.

바로 if (!res.ok) throw new Error("Fetch failed"); 이 코드입니다.

'fetch()' 함수는 HTTP 요청을 보내고 응답을 받아오는데요.

특이한 점은 404나 500 같은 에러 상태여도 Promise가 reject되지 않는다는 겁니다.

그래서 'res.ok'를 체크해야 하는데요.

이 값은 HTTP 상태 코드가 200번대일 때만 'true'가 됩니다.

'!res.ok'로 실패를 감지하면 에러를 직접 던져주고요.

그러면 catch 블록으로 넘어가서 에러 메시지를 표시하게 됩니다.

 

의존성 배열의 함정 - 무한 루프 조심!

의존성 배열을 잘못 설정하면 무한 루프에 빠질 수 있는데요.

제가 실제로 겪었던 실수를 공유해드리겠습니다.

 

무한 루프가 발생하는 잘못된 예시

useEffect(() => {
  fetchData();
}, [data]); // ❌ 절대 이렇게 하지 마세요!

왜 문제가 되는지 설명드리면요.

'fetchData' 안에서 'setData'를 호출하면 state가 변경됩니다.

그러면 리렌더링이 일어나고 의존성 배열의 'data'가 변경됐다고 판단하거든요.

다시 'useEffect'가 실행되고, 또 'setData'가 호출되고... 무한 반복입니다.

 

올바른 해결 방법

useEffect(() => {
  fetchData();
}, []); // ✅ 마운트 시에만 실행

초기 데이터 로딩은 빈 배열로 처리하는 게 정석인데요.

컴포넌트가 처음 나타날 때 한 번만 실행되니까 안전합니다.

 

함수를 의존성에 넣어야 할 때

함수를 의존성 배열에 넣으면 또 다른 문제가 생기는데요.

컴포넌트가 리렌더링될 때마다 함수가 새로 생성되기 때문입니다.

이럴 때는 'useCallback'을 사용해서 함수를 메모이제이션하면 되는데요.

함수가 불필요하게 재생성되는 것을 막아줍니다.

const fetchData = useCallback(async () => {
  const res = await fetch("/api/data");
  setData(await res.json());
}, []); // 이 함수는 재생성되지 않아요

useEffect(() => {
  fetchData();
}, [fetchData]); // 이제 안전하게 의존성에 넣을 수 있어요

 

실전에서 자주 쓰는 useEffect 패턴들

제가 실무에서 자주 사용하는 패턴들을 추가로 소개해드릴게요.

이런 패턴들을 익혀두면 대부분의 상황에서 활용할 수 있습니다.

 

클린업 함수로 메모리 누수 방지하기

타이머나 이벤트 리스너를 설정했다면 반드시 정리해야 하는데요.

'useEffect'에서 반환하는 함수가 클린업 역할을 합니다.

useEffect(() => {
  const timer = setTimeout(() => {
    console.log("3초 후 실행!");
  }, 3000);

  // 컴포넌트가 언마운트될 때 타이머 정리
  return () => clearTimeout(timer);
}, []);

 

디바운싱으로 API 호출 최적화하기

검색어 입력할 때마다 API를 호출하면 서버에 부담이 되는데요.

디바운싱을 적용하면 입력이 끝난 후에만 API를 호출합니다.

useEffect(() => {
  const delayTimer = setTimeout(() => {
    if (searchTerm) {
      searchAPI(searchTerm);
    }
  }, 500); // 0.5초 대기

  return () => clearTimeout(delayTimer);
}, [searchTerm]);

 

 

핵심 정리

오늘 배운 내용을 간단히 정리하면 이렇게 되는데요.

'useEffect'의 실행 타이밍은 의존성 배열로 완벽하게 제어할 수 있습니다.

API 데이터 가져오기는 빈 배열 '[]'로 마운트 시 한 번만 실행하는 게 기본이고요.

의존성 배열을 잘못 설정하면 무한 루프에 빠질 수 있으니 항상 주의해야 합니다.

특히 state를 의존성에 넣을 때는 정말 필요한지 다시 한번 생각해보시고요.

함수를 의존성에 넣어야 한다면 'useCallback'으로 메모이제이션하는 것도 잊지 마세요!