useEffect 의존성 배열을 거짓말하면 생기는 문제

useEffect 의존성 배열을 거짓말하면 생기는 문제

한눈에 보기

Effect는 외부 시스템을 현재 props와 state에 동기화한다. 의존성 배열은 원하는 실행 횟수를 적는 옵션이 아니라 Effect가 읽는 반응형 값의 목록이다. 의존성을 숨겨 경고를 없애면 Effect가 오래된 렌더의 값을 계속 사용한다. 실행이 너무 잦다면 배열을 속이지 말고 Effect의 책임과 코드를 바꿔야 한다.

목차

빈 배열로 한 번만 실행하고 싶은 유혹

채팅방에 연결하는 컴포넌트를 만든다고 해 보자.

function ChatRoom({ roomId }: { roomId: string }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();

    return () => connection.disconnect();
  }, []);

  return <h1>{roomId} 채팅방</h1>;
}

개발자는 “연결은 mount할 때 한 번만”이라는 의도로 빈 배열을 넣었다. 첫 화면에서는 동작한다. 그러나 사용자가 general에서 travel 방으로 이동해 prop이 바뀌면 제목은 travel인데 connection은 여전히 general에 남는다.

Effect 안에서 roomId를 읽으면서 의존성에는 없다고 적었기 때문이다.

useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();

  return () => connection.disconnect();
}, [roomId]);

이제 roomId가 바뀔 때 이전 연결을 끊고 새 방에 연결한다.

의존성 배열의 의미

“언제 실행하고 싶은가”를 적는 곳이 아니라 “이 동기화 코드가 어떤 반응형 값을 읽는가”를 선언하는 곳이다.

Effect는 생명주기 이벤트가 아니라 동기화다

컴포넌트는 mount, update, unmount라는 생명주기를 갖지만 Effect는 외부 시스템과의 동기화를 시작하고 중지하는 한 쌍으로 보는 편이 이해하기 쉽다.

sequenceDiagram
    participant R as React
    participant E as Effect
    participant C as Chat Connection
    R->>E: roomId=general setup
    E->>C: general 연결
    R->>E: roomId=travel로 commit
    E->>C: general cleanup
    E->>C: travel setup
    R->>E: unmount
    E->>C: travel cleanup

roomId가 바뀌면 Effect가 “다시 실행”되는 것만 보지 말고, 이전 값에 대한 동기화를 멈추고 새 값에 맞춰 다시 시작한다고 생각한다.

Effect가 적합한 대상은 React 밖의 시스템이다.

렌더 중 계산할 수 있는 값이나 사용자의 특정 클릭 때문에 생긴 작업은 Effect가 아닐 가능성이 높다.

의존성을 빼면 오래된 렌더에 고정된다

1초마다 현재 count를 로그로 남기는 예제다.

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

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count);
    }, 1000);

    return () => clearInterval(id);
  }, []);

  return (
    <button onClick={() => setCount((value) => value + 1)}>
      {count}
    </button>
  );
}

화면 count는 증가해도 interval은 계속 0을 출력한다. 빈 배열인 Effect는 첫 렌더에서 만든 callback을 유지하고, 그 callback은 첫 렌더의 count === 0을 닫아 두었다.

이를 stale closure라고 부른다. 문제는 React가 값을 늦게 갱신해서가 아니라 Effect가 오래된 렌더의 함수를 계속 쓰도록 선언된 것이다.

단순히 [count]를 추가하면 최신 값은 출력된다.

useEffect(() => {
  const id = setInterval(() => {
    console.log(count);
  }, 1000);

  return () => clearInterval(id);
}, [count]);

하지만 count가 바뀔 때마다 interval을 제거하고 다시 만들게 된다. 요구사항이 “count 변경마다 timer를 재시작”이 아니라면 구조를 다르게 해야 한다. 최신 값을 읽기만 하는 목적에는 ref나 사용하는 React 버전의 Effect Event를 검토할 수 있다.

const latestCountRef = useRef(count);

useEffect(() => {
  latestCountRef.current = count;
}, [count]);

useEffect(() => {
  const id = setInterval(() => {
    console.log(latestCountRef.current);
  }, 1000);

  return () => clearInterval(id);
}, []);

ref는 반응형 흐름을 벗어나는 도구이므로 화면 렌더가 ref에 의존하게 만들면 안 된다.

cleanup과 setup은 한 쌍이다

Effect는 다음 순서로 동작한다.

  1. 처음 commit 후 setup을 실행한다.
  2. 의존성이 바뀐 commit 후 이전 값의 cleanup을 실행한다.
  3. 새 값으로 setup을 실행한다.
  4. unmount할 때 마지막 cleanup을 실행한다.
useEffect(() => {
  function handleResize() {
    setViewportWidth(window.innerWidth);
  }

  window.addEventListener("resize", handleResize);
  handleResize();

  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []);

cleanup에서 setup이 만든 정확한 listener를 제거해야 한다. 익명 함수를 다시 만들면 같은 함수 참조가 아니어서 제거되지 않는다.

useEffect(() => {
  window.addEventListener("resize", () => updateWidth());

  return () => {
    window.removeEventListener("resize", () => updateWidth());
  };
}, []);

개발 Strict Mode에서는 setup → cleanup → setup 주기를 추가로 실행해 cleanup 누락을 드러낼 수 있다. 개발에서 연결이 두 번 보인다고 guard ref로 두 번째 setup을 막으면 실제 unmount/remount에서 자원 정리가 깨질 수 있다.

좋은 cleanup의 기준

사용자가 setup이 한 번 실행된 경우와 setup → cleanup → setup을 거친 경우의 차이를 느끼지 못해야 한다.

의존성을 추가했더니 무한 반복되는 경우

Effect에서 state를 바꾸고 그 state를 의존성으로 읽으면 반복이 생길 수 있다.

function ProductSearch({ products }: Props) {
  const [visibleProducts, setVisibleProducts] = useState<Product[]>([]);

  useEffect(() => {
    setVisibleProducts(
      products.filter((product) => product.visible),
    );
  }, [products, visibleProducts]);

  return <ProductList products={visibleProducts} />;
}

Effect가 visibleProducts를 변경하면 렌더되고 의존성이 바뀌어 Effect가 다시 실행된다. 하지만 이 예제의 근본 문제는 의존성 배열이 아니라 파생 값을 Effect로 state에 저장한 것이다.

function ProductSearch({ products }: Props) {
  const visibleProducts = products.filter(
    (product) => product.visible,
  );

  return <ProductList products={visibleProducts} />;
}

계산이 실제로 비싸고 참조 안정성이 성능에 필요하다는 측정이 있다면 useMemo를 고려한다.

const visibleProducts = useMemo(
  () => products.filter((product) => product.visible),
  [products],
);

lint 경고를 끄기 전에 “이 Effect가 어떤 외부 시스템을 동기화하는가?”에 답하지 못하면 Effect 자체를 삭제할 가능성이 높다.

객체와 함수 의존성을 줄이는 방법

렌더 중 만든 객체는 매번 새 참조다.

function ChatRoom({ roomId }: Props) {
  const options = {
    serverUrl: "https://chat.example.test",
    roomId,
  };

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]);
}

컴포넌트가 다른 이유로 렌더되어도 options는 새 객체이므로 연결이 끊겼다가 다시 만들어진다. 객체를 Effect 안에서 만들고 실제 반응형 원시 값만 의존성에 둔다.

function ChatRoom({ roomId }: Props) {
  useEffect(() => {
    const options = {
      serverUrl: "https://chat.example.test",
      roomId,
    };

    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
}

컴포넌트 밖으로 옮길 수 있는 상수는 모듈 범위로 옮긴다.

const SERVER_URL = "https://chat.example.test";

함수도 매 렌더마다 새로 만들어진다.

function SearchPage({ query }: Props) {
  function createRequest() {
    return { query, pageSize: 20 };
  }

  useEffect(() => {
    void fetchProducts(createRequest());
  }, [createRequest]);
}

함수를 Effect 안으로 옮기거나 필요한 값만 직접 사용한다.

useEffect(() => {
  const request = { query, pageSize: 20 };
  void fetchProducts(request);
}, [query]);

useCallback은 마지막 수단 중 하나다. 함수가 실제로 props로 전달되며 memoized child에 필요하거나 다른 Hook의 안정적인 dependency여야 할 때 사용한다. 단지 lint를 만족하려고 모든 함수를 감싸면 의존성 추적만 복잡해진다.

이벤트에서 해야 할 일을 Effect로 옮기지 않기

사용자 클릭 때문에 결제 요청을 보내는 로직을 state와 Effect로 우회할 필요가 없다.

function Checkout({ cart }: Props) {
  const [submitted, setSubmitted] = useState(false);

  useEffect(() => {
    if (submitted) {
      void createOrder(cart);
    }
  }, [submitted, cart]);

  return (
    <button onClick={() => setSubmitted(true)}>
      주문하기
    </button>
  );
}

주문 후 theme이나 cart 참조가 바뀌면 의도하지 않은 재실행 가능성이 생긴다. 특정 상호작용 때문에 실행되는 작업은 이벤트 핸들러에 둔다.

function Checkout({ cart }: Props) {
  async function handleCheckout() {
    await createOrder(cart);
    showToast("주문을 접수했습니다.");
  }

  return <button onClick={handleCheckout}>주문하기</button>;
}

Effect는 “이 컴포넌트가 화면에 있고 roomId가 이 값인 동안 채팅 연결을 유지한다”처럼 화면 상태 때문에 외부 시스템과 계속 동기화해야 할 때 적합하다.

질문 위치
사용자가 버튼을 눌러 발생했는가 event handler
컴포넌트가 표시되는 동안 외부 시스템과 맞춰야 하는가 Effect
props와 state만으로 계산할 수 있는가 render
서버 상태 캐시와 요청 생명주기인가 data fetching 도구 검토

이전 state에 의존하면 updater 함수를 사용한다

WebSocket 메시지를 받을 때 기존 messages를 읽으면 Effect 의존성에 messages가 추가되고 메시지마다 재연결할 수 있다.

useEffect(() => {
  const connection = connect(roomId);

  connection.onMessage((message) => {
    setMessages([...messages, message]);
  });

  return () => connection.disconnect();
}, [roomId, messages]);

updater 함수를 쓰면 callback이 messages를 읽지 않는다.

useEffect(() => {
  const connection = connect(roomId);

  connection.onMessage((message) => {
    setMessages((current) => [...current, message]);
  });

  return () => connection.disconnect();
}, [roomId]);

의존성을 억지로 제거한 것이 아니라 코드가 더 이상 해당 반응형 값을 읽지 않도록 바꿨기 때문에 제거할 수 있다. 공식 문서가 말하는 “의존성이 필요하지 않음을 증명한다”는 의미가 여기에 가깝다.

비동기 요청의 경쟁과 취소

검색어가 빠르게 바뀌면 먼저 시작한 요청이 나중 요청보다 늦게 끝날 수 있다.

sequenceDiagram
    participant U as User
    participant A as apple 요청
    participant B as app 요청
    U->>A: query=apple
    U->>B: query=app
    B-->>U: 최신 결과 먼저 도착
    A-->>U: 오래된 결과가 뒤늦게 덮어씀

Effect cleanup에서 이전 요청을 취소한다.

useEffect(() => {
  const controller = new AbortController();

  async function load() {
    try {
      const response = await fetch(
        `/api/products?q=${encodeURIComponent(query)}`,
        { signal: controller.signal },
      );

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      setProducts(await response.json());
    } catch (error) {
      if (!controller.signal.aborted) {
        setError(toDisplayError(error));
      }
    }
  }

  void load();

  return () => controller.abort();
}, [query]);

취소를 지원하지 않는 API라면 cleanup flag로 오래된 결과 반영을 막는다.

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

  void fetchProducts(query).then((result) => {
    if (!ignore) {
      setProducts(result);
    }
  });

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

실제 애플리케이션에서는 캐시, deduplication, SSR, 재시도까지 필요한 경우가 많으므로 TanStack Query나 프레임워크 데이터 계층 같은 전용 도구를 검토한다.

서로 다른 동기화는 Effect를 분리한다

채팅 연결과 방문 분석을 한 Effect에 묶으면 한쪽 의존성 때문에 다른 작업도 반복된다.

useEffect(() => {
  logVisit(roomId);

  const connection = connect(roomId, serverUrl);
  connection.connect();

  return () => connection.disconnect();
}, [roomId, serverUrl]);

serverUrl만 바뀌어 재연결할 때 방문 로그도 다시 전송된다. 서로 독립적인 프로세스로 나눈다.

useEffect(() => {
  logVisit(roomId);
}, [roomId]);

useEffect(() => {
  const connection = connect(roomId, serverUrl);
  connection.connect();

  return () => connection.disconnect();
}, [roomId, serverUrl]);

나누는 기준은 코드 길이나 관심사의 이름이 아니라 setup과 cleanup의 생명주기가 같은가이다. 하나를 삭제해도 다른 동기화가 깨지지 않는다면 별도 Effect일 가능성이 높다.

lint 예외가 필요한 경우

react-hooks/exhaustive-deps 경고는 대부분 실제 버그 가능성을 알려 준다.

useEffect(() => {
  synchronize(userId, locale);
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);

이 주석은 locale 변경에 반응하지 않겠다고 선언하지만 Effect 코드는 locale을 읽는다. 요구사항과 구현이 모순된다.

불가피하게 예외를 둘 때는 다음을 문서화한다.

하지만 library boundary나 안정성이 계약된 값은 linter가 이미 인식하거나 컴포넌트 밖으로 옮겨 표현할 수 있는 경우가 많다. suppression은 마지막 선택이어야 한다.

빈 배열은 “한 번만” 주문이 아니다

Effect가 반응형 값을 읽지 않을 때의 결과다. 값을 읽으면서 빈 배열을 쓰면 React에 거짓말하는 셈이다.

테스트와 리뷰 체크리스트

Effect 테스트는 mount 횟수보다 외부 시스템이 올바르게 동기화되는지 본다.

it("roomId가 바뀌면 이전 연결을 끊고 새 방에 연결한다", () => {
  const { rerender, unmount } = render(
    <ChatRoom roomId="general" />,
  );

  expect(connect).toHaveBeenCalledWith("general");

  rerender(<ChatRoom roomId="travel" />);

  expect(disconnect).toHaveBeenCalledWith("general");
  expect(connect).toHaveBeenCalledWith("travel");

  unmount();
  expect(disconnect).toHaveBeenCalledWith("travel");
});

비동기 검색은 오래된 응답이 최신 결과를 덮지 않는지 검증한다.

it("마지막 검색어의 결과만 표시한다", async () => {
  const user = userEvent.setup();
  render(<ProductSearch />);

  await user.type(screen.getByRole("searchbox"), "apple");
  await user.clear(screen.getByRole("searchbox"));
  await user.type(screen.getByRole("searchbox"), "app");

  resolveRequest("app", ["App result"]);
  resolveRequest("apple", ["Old result"]);

  expect(await screen.findByText("App result")).toBeVisible();
  expect(screen.queryByText("Old result")).not.toBeInTheDocument();
});

리뷰에서는 다음을 묻는다.

정리

Effect 의존성 배열은 실행 횟수를 직접 제어하는 장치가 아니다. Effect가 현재 렌더에서 읽는 반응형 값을 선언해 외부 시스템을 최신 상태와 동기화하는 계약이다.

의존성이 많아 불편하다면 배열에서 제거하기 전에 코드를 바꾼다.

의존성 경고를 없애는 것이 목표가 아니라 Effect 코드와 의존성 목록이 같은 사실을 말하게 만드는 것이 목표다.

관련 노트와 참고 자료