useState vs useEffect, 의존성 배열은 왜 이렇게 헷갈릴까?

Jina·2026년 6월 30일

리액트 처음 배울 때 누구나 한 번쯤 겪는 순간이 있습니다.

"분명 공식문서대로 똑같이 썼는데... 왜 무한루프가 돌지?"
"콘솔에 같은 로그가 100번씩 찍히는데 대체 뭐가 문제야?"

저도 포트폴리오 작업하면서 useEffect 의존성 배열 때문에 몇 시간을 날린 적이 있어요. 분명 작동은 하는데 왜 이렇게 동작하는지 이해를 못 하니까 코드를 고칠 때마다 불안했죠.

이 글에서는 useStateuseEffect의 본질적인 차이부터 시작해서, 의존성 배열이 헷갈리는 진짜 이유와 실전에서 자주 만나는 트러블슈팅 케이스까지 정리해보려고 합니다.


useState 다시 짚고 가기

useState는 한 마디로 정의하면 "리렌더링을 트리거하는 값을 저장하는 훅"입니다.

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>현재 카운트: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

여기서 중요한 포인트는, setCount를 호출하면 단순히 값만 바뀌는 게 아니라 컴포넌트 전체가 다시 렌더링된다는 점이에요. 일반 변수와 결정적으로 다른 부분이죠.

흔한 실수: state를 직접 mutate하기

// ❌ 잘못된 예시
const [todos, setTodos] = useState([]);

function addTodo(newTodo) {
  todos.push(newTodo); // 배열을 직접 수정
  setTodos(todos); // 리액트는 이 변화를 감지하지 못함!
}

리액트는 state가 바뀌었는지를 참조값(reference) 비교로 판단해요. 위 코드는 todos 배열의 참조 자체는 그대로이기 때문에 화면이 갱신되지 않습니다.

// ✅ 올바른 예시
function addTodo(newTodo) {
  setTodos([...todos, newTodo]); // 새로운 배열을 만들어서 전달
}

이 "새로운 참조를 만든다"는 개념이 사실 뒤에서 다룰 의존성 배열 문제와도 직결됩니다.


useEffect는 언제, 왜 쓰는가

useState가 "값을 저장하고 변화를 알리는 훅"이라면, useEffect"렌더링 이후에 실행되는 부수효과(side effect)를 처리하는 훅"입니다.

부수효과란 컴포넌트 렌더링 자체와는 별개로 발생하는 작업들을 말해요. 예를 들면:

  • 서버에서 데이터 가져오기 (fetch)
  • 이벤트 리스너 등록/해제
  • 타이머 설정
  • 외부 라이브러리 연동
  • DOM 직접 조작
import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => setUser(data));
  }, [userId]); // userId가 바뀔 때마다 다시 실행

  if (!user) return <p>로딩중...</p>;
  return <p>{user.name}님 환영합니다</p>;
}

핵심은 useEffect"렌더링 자체"가 아니라 "렌더링이 끝난 후"에 실행된다는 점이에요. 그래서 화면을 그리는 로직과 부수효과 로직을 분리할 수 있게 해줍니다.


의존성 배열, 진짜 정체는?

useEffect의 두 번째 인자로 들어가는 배열, 다들 한 번쯤은 "그냥 이렇게 쓰면 된다더라" 식으로 넘어간 경험 있으실 거예요. 정확히 짚고 가볼게요.

useEffect(() => {
  // 실행할 코드
}, [의존성_배열]);

이 배열은 리액트에게 "이 값들이 바뀌었을 때만 effect를 다시 실행해줘"라고 알려주는 역할을 합니다. 케이스별로 비교해볼게요.

의존성 배열실행 시점
배열 없음 (useEffect(() => {...}))렌더링될 때마다 매번 실행
빈 배열 ([])최초 마운트 시 딱 한 번만 실행
값이 있는 배열 ([count])최초 마운트 + count가 바뀔 때마다 실행

직접 콘솔로 찍어보면 감이 빨리 옵니다.

function Example({ count }) {
  // 1. 매번 실행
  useEffect(() => {
    console.log('렌더링마다 실행됨');
  });

  // 2. 최초 1번만 실행
  useEffect(() => {
    console.log('마운트 시 1번만 실행됨');
  }, []);

  // 3. count가 바뀔 때만 실행
  useEffect(() => {
    console.log('count가 바뀌어서 실행됨:', count);
  }, [count]);
}

여기까지는 다들 어느 정도 익숙하실 텐데, 진짜 헷갈리는 건 다음 단계부터예요.


왜 헷갈리는가 - 실전 트러블슈팅

케이스 1: 의존성을 빠뜨려서 생기는 stale closure

// ❌ 문제가 있는 코드
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count); // 항상 0만 찍힘!
      setCount(count + 1);
    }, 1000);

    return () => clearInterval(timer);
  }, []); // count를 빈 배열에 넣지 않음
}

이게 바로 "클로저가 오래된 값을 기억한다(stale closure)"는 현상이에요. useEffect는 최초 렌더링 시점에 만들어진 함수를 그대로 기억하고 있어서, count0이던 그 순간의 값을 계속 참조합니다.

// ✅ 해결 방법 1: 의존성 배열에 추가
useEffect(() => {
  const timer = setInterval(() => {
    setCount((prev) => prev + 1); // 함수형 업데이트로 해결하는 것도 방법
  }, 1000);
  return () => clearInterval(timer);
}, []);

함수형 업데이트(setCount(prev => prev + 1))를 쓰면 의존성 배열에 count를 넣지 않아도 항상 최신 값을 기준으로 업데이트할 수 있어요. 이게 실무에서 가장 많이 쓰는 패턴입니다.

케이스 2: 객체/배열을 의존성에 넣었다가 무한루프

// ❌ 무한루프 발생
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const options = { query, limit: 10 }; // 렌더링마다 새로 생성됨

  useEffect(() => {
    fetchResults(options).then(setResults);
  }, [options]); // options는 매번 다른 참조값!
}

options는 렌더링될 때마다 새로운 객체로 만들어집니다. 내용물이 같아도 참조가 다르기 때문에 리액트는 "의존성이 바뀌었다"고 판단해서 effect를 다시 실행하고, 그 안에서 setResults가 호출되어 또 리렌더링이 일어나고... 무한루프에 빠집니다.

// ✅ 해결 방법: 원시값만 의존성에 넣기
useEffect(() => {
  const options = { query, limit: 10 };
  fetchResults(options).then(setResults);
}, [query]); // query라는 원시값만 비교

객체나 배열은 effect 안에서 직접 만들어서 쓰거나, 정말 필요하다면 useMemo로 참조를 고정하는 방법도 있어요.

케이스 3: eslint 경고를 무시하면 안 되는 이유

eslint-plugin-react-hooks를 설치하면 의존성 배열이 빠졌을 때 경고를 띄워줍니다.

React Hook useEffect has a missing dependency: 'userId'.

이 경고가 귀찮다고 그냥 // eslint-disable-next-line으로 덮어버리는 경우를 종종 보는데, 이건 대부분 stale closure 버그를 그대로 묻어두는 거예요. 경고가 뜬다면 "정말 이 값이 effect 안에서 필요 없는가?"를 먼저 의심해보는 게 맞습니다.


정리 - 헷갈리지 않는 나만의 체크리스트

의존성 배열 앞에서 막막해질 때, 저는 이렇게 질문을 던지고 정리합니다.

  1. 이 effect는 무엇에 "반응"해야 하는가? → 그 값들이 곧 의존성이다
  2. effect 안에서 사용하는 모든 외부 변수를 빠짐없이 넣었는가? → 의존성 배열은 거짓말하지 않는다
  3. 객체/배열을 의존성으로 넣고 있지는 않은가? → 원시값으로 쪼개거나 effect 안에서 생성하자
  4. "최신 값"이 필요한데 의존성을 늘리고 싶지 않다면? → 함수형 업데이트(prev => ...)를 고려하자
  5. eslint 경고가 뜬다면 일단 의심하자 → 무시하지 말고 원인을 먼저 파악하자

결국 의존성 배열은 "리액트를 속이는 트릭"이 아니라, "이 effect가 실제로 어떤 값에 의존하고 있는지"를 정직하게 선언하는 자리라는 걸 이해하면 훨씬 덜 헷갈립니다.

profile
즐겁게 코딩하고 공부해요🍀

0개의 댓글