리액트(React)로 프로젝트를 진행하며 API 데이터를 불러오거나 컴포넌트 생명주기(Lifecycle)를 다룰 때 가장 먼저 접하게 되는 훅(Hook)이 바로 useEffect입니다.

처음 useEffect를 배울 때는 "그냥 컴포넌트가 화면에 나타날 때 실행하고 싶은 코드를 넣어두는 함수" 정도로 가볍게 생각하곤 했습니다. 하지만 프로젝트 규모가 커지면서 어느 날 브라우저 탭이 갑자기 렉을 먹고 먹통이 되거나, 개발자 도구 콘솔에 수백 개의 API 요청 로그가 쏟아지는 '무한 리렌더링(Infinite Loop)' 참사를 겪게 되었습니다.

오늘은 제가 초보 시절 useEffect를 오용하면서 겪었던 무한 리렌더링의 원인과, 이를 안전하고 올바르게 다루기 위한 의존성 배열(Dependency Array) 관리 노하우를 공유해 보려고 합니다.

1. 내가 마주친 문제: "브라우저 콘솔창이 터져나간 이유"

특정 유저 정보를 서버에서 받아와 상태(State)에 저장하는 흔한 로직을 작성하던 중이었습니다.

별생각 없이 useEffect 내부에서 데이터를 받아와 setState를 호출했는데, 페이지에 진입하자마자 브라우저가 느려지더니 서버로 동일한 API 요청이 초당 수십 번씩 계속 날아가는 현상이 발생했습니다.

TypeScript
 
// ❌ 무한 리렌더링을 유발했던 과거의 위험한 코드
import { useState, useEffect } from 'react';

export default function UserProfile() {
  const [user, setUser] = useState(null);

  // 🚨 두 번째 인자인 의존성 배열(Dependency Array)을 빠뜨림!
  useEffect(() => {
    fetch('/api/user')
      .then((res) => res.json())
      .then((data) => {
        setUser(data); // 상태 변경 -> 컴포넌트 리렌더링 -> useEffect 재실행 -> 무한 반복!
      });
  });

  return <div>{user?.name}</div>;
}

💡 무한 루프가 발생한 원인

useEffect의 두 번째 인자인 의존성 배열([])을 완전히 생략해 버리면, 리액트는 컴포넌트가 리렌더링 될 때마다 useEffect 내부 콜백 함수를 매번 실행합니다.

useEffect 내부에서 setUser로 상태를 바꾸면 ➔ 리액트는 상태가 바뀌었으니 컴포넌트를 리렌더링하고 ➔ 리렌더링 되었으니 useEffect가 또 실행되어 setUser를 호출하는 지옥의 악순환이 만들어진 것입니다.

2. 해결책 1: 빈 의존성 배열([])로 '마운트 시 1회 실행' 보장하기

이 문제를 가장 쉽게 해결하는 방법은 useEffect 두 번째 인자로 빈 배열([])을 넘겨주는 것입니다.

TypeScript
 
// ⭕ 빈 배열 []을 넣어 컴포넌트 마운트 시 최초 1회만 실행되도록 수정
useEffect(() => {
  fetch('/api/user')
    .then((res) => res.json())
    .then((data) => setUser(data));
}, []); // 👈 빈 의존성 배열 추가!

이렇게 설정하면 컴포넌트가 화면에 처음 그려질 때(Mount) 딱 한 번만 API 요청을 수행하므로 안전합니다.

useEffect 두 번째 인자로 빈 배열 [] 이 들어간 코드

3. 해결책 2: 객체/함수를 의존성 배열에 넣을 때 발생하는 함정 피하기

빈 배열을 넣는 것만으로 모든 문제가 해결되지는 않았습니다. 특정 조건(예: 검색어 ID 변경)에 따라 데이터를 다시 불러와야 할 때는 의존성 배열에 변수를 넣어주어야 합니다.

이때 객체(Object)나 함수(Function)를 의존성 배열에 그대로 넣으면 다시 무한 루프가 터지는 함정에 빠질 수 있습니다.

TypeScript
 
// ❌ 객체를 참조 타입으로 넣어 무한 루프가 발생하는 잘못된 예시
export default function SearchList({ options }) { // options가 객체인 경우
  const [list, setList] = useState([]);

  useEffect(() => {
    fetchData(options).then((data) => setList(data));
  }, [options]); // 🚨 부모가 리렌더링될 때마다 options 객체의 참조값이 새로 바뀌어 계속 실행됨!
}

자바스크립트에서 객체나 함수는 리렌더링될 때마다 메모리 주소(참조값)가 매번 새로 생성됩니다. 따라서 내용물이 똑같아도 리액트는 "의존성 값이 바뀌었네?" 하고 useEffect를 또 실행해 버립니다.

💡 해결 방법 (원시 값 전달 또는 useCallback / useMemo 활용)

  • 객체 전체 대신 객체 내부의 원시 값(options.id 등)을 의존성 배열에 넣기
  • 함수를 의존성 배열에 넣어야 할 경우 useCallback으로 감싸 메모이제이션하기
TypeScript
 
// ⭕ 객체 전체가 아닌 원시 값(id)을 의존성 배열에 넣어 안전하게 관리
useEffect(() => {
  fetchData(options.id).then((data) => setList(data));
}, [options.id]); // 👈 객체의 속성값(number, string)을 지정!

의존성 배열 관련 코드

4. 정리 및 깨달은 점

useEffect는 단순히 코드를 실행해 주는 부수 효과 도구가 아닙니다. "컴포넌트의 상태 및 렌더링 주기와 외부 시스템을 동기화하는 도구"입니다.

  1. useEffect 내부에서 상태(state)를 변경한다면 의존성 배열이 제대로 설정되어 있는지 필수 점검할 것
  2. 의존성 배열에 참조 타입(객체, 배열, 함수)을 넣을 때는 참조값 변경에 유의할 것

이 두 가지만 명확히 기억해 두셔도 무한 리렌더링으로 브라우저가 다운되는 불상사를 완벽히 예방할 수 있습니다!

리액트(React)를 활용해 웹 애플리케이션을 개발할 때, 가장 기본이 되면서도 개발자를 가장 흔하게 대혼란에 빠뜨리는 주범은 바로 상태 관리(useState)와 부작용 처리(useEffect)입니다.

문법 자체는 매우 단순해 보이지만, 리액트 내부의 렌더링 라이프사이클과 비동기 업데이트 메커니즘을 명확히 이해하지 못하면 눈에 잘 띄지 않는 치명적인 버그를 만들어내곤 합니다.

오늘은 제가 리액트 프로젝트를 진행하며 직접 경험했던 대표적인 실수 3가지와 이를 해결하며 깨달은 리액트의 핵심 원리를 상세히 정돈해 보려고 합니다.

1. 실수 1: useEffect 내부 비동기 호출 시 '무한 렌더링' 루프 발생

가장 식은땀을 흘렸던 버그는 컴포넌트가 마운트될 때 API 데이터를 가져오도록 작성한 useEffect 코드에서 발생했습니다.

TypeScript
 
// ❌ 무한 렌더링을 유발했던 과거의 코드
function UserProfile() {
  const [userInfo, setUserInfo] = useState<UserInfo | null>(null);

  useEffect(() => {
    fetchUserData().then((data) => {
      setUserInfo(data); // 2. 상태가 변경되어 컴포넌트가 재렌더링됨
    });
  }, [userInfo]); // 1. userInfo 상태를 의존성 배열에 넣음 ➔ 3. 다시 useEffect 실행 (무한 반복!)

  return <div>{userInfo?.name}</div>;
}

💡 원인 및 해결책

useEffect 내부에서 setUserInfo를 통해 상태를 변경하고 있는데, 그 변경되는 userInfo 상태를 의존성 배열(Dependencies)에 넣어둔 것이 원인이었습니다. 상태 변경 ➔ 재렌더링 ➔ useEffect 재실행 ➔ 상태 변경의 무한 루프가 발생하여 서버에 수천 번의 API 요청이 전송되는 참사가 일어났습니다.

마운트 시점에 한 번만 실행되어야 하는 API 요청은 의존성 배열을 빈 배열([])로 설정하여 해결했습니다.

TypeScript
 
// ⭕ 올바르게 수정한 코드
useEffect(() => {
  fetchUserData().then((data) => {
    setUserInfo(data);
  });
}, []); // 빈 배열을 전달하여 마운트 시 단 1회만 실행되도록 보장
useState, useEffect로 작성한 코드


2. 실수 2: setState의 비동기적 특성을 오해하고 '이전 값'을 참조한 문제

카운트 수량을 올리거나 이전 상태를 기반으로 연쇄 계산을 수행할 때 자주 범했던 실수입니다.

TypeScript
 
// ❌ 원하는 대로 스코어가 2씩 증가하지 않는 코드
const handleDoubleScoreUp = () => {
  setScore(score + 1); // 현재 score가 0이라면 0 + 1 = 1 설정 요청
  setScore(score + 1); // 여전히 이전 closure의 score(0)를 참조하여 0 + 1 = 1 설정 요청
};

💡 원인 및 해결책

리액트의 상태 변경 함수(setState)는 즉각적으로 변수의 값을 변경하는 것이 아니라, 성능 최적화를 위해 리액트 렌더링 스케줄러에 업데이트를 예약(Batching)하는 비동기 방식으로 동작합니다. 따라서 연속으로 setState를 호출하면 동기적으로 반영되지 않고 이전 렌더링 시점의 클로저(Closure) 스코프 값을 참조하게 됩니다.

이 문제는 최신 상태 보장을 위해 함수형 업데이트(Functional Update) 방식을 적용하여 해결했습니다.

TypeScript
 
// ⭕ 함수형 업데이트를 적용해 항상 최신 상태를 기반으로 계산하도록 수정
const handleDoubleScoreUp = () => {
  setScore((prevScore) => prevScore + 1);
  setScore((prevScore) => prevScore + 1); // 이전 상태(prevScore)를 인수로 받아 정확히 2가 증가함
};

3. 실수 3: useEffect의 Cleanup(정리) 함수 누락으로 인한 메모리 누수

페이지 상단으로 이동하는 버튼이나 스크롤 프로그레스 바를 만들기 위해 window 객체에 스크롤 이벤트를 등록할 때 발생했던 실수입니다.

TypeScript
 
// ❌ 컴포넌트가 언마운트되어도 이벤트가 남아 메모리 누수가 일어나는 코드
useEffect(() => {
  const handleScroll = () => {
    setScrollPosition(window.scrollY);
  };
  
  window.addEventListener('scroll', handleScroll);
}, []);

💡 원인 및 해결책

useEffect 내에서 타이머(setInterval), Web API 이벤트 리스너(addEventListener), 혹은 WebSocket 연결을 등록해 두고 정리(Cleanup) 함수를 반환하지 않으면, 해당 컴포넌트가 화면에서 사라진(Unmount) 후에도 메모리에 잔재하여 성능 저하와 예기치 않은 동작을 일으킵니다.

useEffect의 return 구문에 리스너를 해제하는 클린업 로직을 추가하여 완벽

히 방지할 수 있었습니다.

TypeScript
 
// ⭕ 클린업 함수를 반환하여 메모리 누수를 방지한 코드
useEffect(() => {
  const handleScroll = () => {
    setScrollPosition(window.scrollY);
  };

  window.addEventListener('scroll', handleScroll);

  // 컴포넌트가 언마운트되거나 재실행되기 전 기존 리스너를 깨끗이 제거
  return () => {
    window.removeEventListener('scroll', handleScroll);
  };
}, []);

useEffect내부의 로그 출력

4. 결론 및 느낀 점

리액트의 useState와 useEffect는 프론트엔드 개발 시 매일 사용하는 기본적인 훅이지만, 그 내부의 '불변성(Immutability)', '클로저(Closure)', '비동기 배칭(Batching)' 원리를 깊이 이해하지 못하면 쉽게 버그에 노출될 수 있습니다.

단순히 라이브러리의 문법만 외우기보다는, 렌더링 주기에 따라 상태가 어떻게 변화하고 이펙트가 언제 실행되는지 지속적으로 관찰하고 디버깅하는 습관이 좋은 리액트 개발자로 성장하는 핵심 열쇠라고 생각합니다.

+ Recent posts