Published on

React 메모리 누수 경고 해결 - useEffect 클린업

Authors

React를 개발하다 보면 콘솔에 아래와 비슷한 경고를 만나는 일이 잦습니다.

  • Warning: Can't perform a React state update on an unmounted component.

이 메시지는 단순히 “경고”로 끝나지 않습니다. 실제로는 컴포넌트가 화면에서 사라진 뒤에도 비동기 작업이 계속 살아있거나, 이벤트/구독이 해제되지 않아 메모리를 붙잡고 있다는 신호일 가능성이 큽니다. 특히 라우팅 전환이 많은 SPA, 모달/탭 UI, SSE/WebSocket 같은 스트리밍 연결이 있는 앱에서 재현이 쉽습니다.

이 글에서는 useEffect의 클린업(cleanup)을 중심으로, 메모리 누수 경고를 유발하는 대표 패턴과 실전 해결법을 정리합니다.

왜 “unmounted component 업데이트”가 발생할까

React 컴포넌트는 마운트(mount)된 동안에만 상태 업데이트가 의미가 있습니다. 그런데 다음과 같은 작업은 컴포넌트 생명주기와 별개로 계속 진행될 수 있습니다.

  • fetch/axios 요청이 늦게 끝남
  • setTimeout, setInterval이 계속 실행됨
  • addEventListener로 붙인 이벤트가 남아있음
  • WebSocket/SSE 구독이 유지됨
  • 외부 스토어/Observable 구독이 살아있음

이때 컴포넌트가 언마운트(unmount)된 후 작업이 완료되며 setState가 호출되면, React는 “이미 사라진 컴포넌트에 업데이트를 시도했다”고 경고합니다.

핵심은 하나입니다.

  • useEffect에서 시작한 “부수효과(side effect)”는 return () => { ... } 클린업에서 반드시 종료해야 합니다.

기본 원칙: effect에서 만든 것은 cleanup에서 정리한다

useEffect는 다음 형태를 갖습니다.

useEffect(() => {
  // setup: 구독/타이머/요청 시작

  return () => {
    // cleanup: setup에서 만든 것 정리
  };
}, [deps]);

정리 대상은 크게 4가지로 분류하면 빠르게 점검할 수 있습니다.

  1. 타이머: setTimeout, setInterval
  2. 이벤트: addEventListener
  3. 네트워크: fetch 취소, SSE/WebSocket 닫기
  4. 구독: RxJS, store subscribe, observer detach

케이스 1: 타이머 누수 (setInterval, setTimeout)

문제 코드

import { useEffect, useState } from "react";

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

  useEffect(() => {
    const id = window.setInterval(() => {
      setCount((c) => c + 1);
    }, 1000);

    // cleanup 누락
  }, []);

  return <div>count: {count}</div>;
}

컴포넌트가 언마운트되어도 interval이 계속 돌며 상태 업데이트를 시도할 수 있습니다.

해결 코드

useEffect(() => {
  const id = window.setInterval(() => {
    setCount((c) => c + 1);
  }, 1000);

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

setTimeout도 동일하게 clearTimeout으로 정리합니다.

케이스 2: 이벤트 리스너 누수 (addEventListener)

문제 코드

useEffect(() => {
  const onResize = () => {
    console.log("resize");
  };

  window.addEventListener("resize", onResize);
  // cleanup 누락
}, []);

이벤트 리스너는 전역 객체에 붙기 때문에, 컴포넌트가 사라져도 계속 남아 호출됩니다.

해결 코드

useEffect(() => {
  const onResize = () => {
    console.log("resize");
  };

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

주의할 점은 “같은 함수 레퍼런스”로 제거해야 한다는 것입니다. cleanup에서 익명 함수를 새로 만들면 제거가 되지 않습니다.

케이스 3: fetch/비동기 요청이 늦게 끝나며 setState 호출

가장 흔한 경고의 원인입니다. 라우팅 이동이나 조건부 렌더링으로 컴포넌트가 먼저 사라졌는데, 네트워크 응답이 늦게 와서 setState가 호출됩니다.

문제 코드

import { useEffect, useState } from "react";

type User = { id: string; name: string };

export function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    (async () => {
      const res = await fetch(`/api/users/${userId}`);
      const data = (await res.json()) as User;
      setUser(data);
    })();
  }, [userId]);

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

해결 1: AbortController로 요청 취소

브라우저 fetchAbortController로 취소할 수 있습니다.

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

  (async () => {
    try {
      const res = await fetch(`/api/users/${userId}`, {
        signal: controller.signal,
      });
      const data = (await res.json()) as User;
      setUser(data);
    } catch (err) {
      // abort는 에러로 떨어지므로 구분 처리
      if (err instanceof DOMException && err.name === "AbortError") return;
      throw err;
    }
  })();

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

이 방식의 장점은 “setState 호출을 막는 것”을 넘어 실제 네트워크/파싱 작업 자체를 중단한다는 점입니다.

해결 2: isMounted 플래그(권장도 낮음)

요청을 취소할 수 없는 라이브러리이거나, 취소가 실질적으로 불가능한 경우에만 고려합니다.

useEffect(() => {
  let alive = true;

  (async () => {
    const res = await fetch(`/api/users/${userId}`);
    const data = (await res.json()) as User;

    if (!alive) return;
    setUser(data);
  })();

  return () => {
    alive = false;
  };
}, [userId]);

이 방법은 경고는 줄일 수 있지만, 네트워크 작업이 계속 진행되므로 “진짜 누수/낭비”를 줄이는 데 한계가 있습니다. 가능하면 AbortController가 더 좋습니다.

케이스 4: SSE/WebSocket/스트리밍 구독 누수

SSE나 WebSocket은 연결을 열어두는 구조라서 cleanup 누락 시 메모리 누수와 리소스 누적이 빠르게 발생합니다. 특히 라우팅을 여러 번 오가면 연결이 중첩되며 서버/클라이언트 모두 부담이 커집니다.

SSE 스트리밍이 끊기거나 프록시에서 비정상 종료가 발생하는 경우도 함께 점검해야 합니다. 관련해서는 LLM SSE 스트리밍 499 502 급증과 응답 끊김을 잡는 프록시 튜닝 체크리스트도 같이 보면 원인 분리가 빨라집니다.

SSE 예시(EventSource)

import { useEffect, useState } from "react";

export function LiveCounter() {
  const [value, setValue] = useState(0);

  useEffect(() => {
    const es = new EventSource("/api/stream");

    es.onmessage = (evt) => {
      const next = Number(evt.data);
      setValue(next);
    };

    es.onerror = () => {
      // 필요 시 재연결 전략을 별도로 두되,
      // cleanup에서 close가 되도록 구조를 잡는 것이 중요
      console.warn("sse error");
    };

    return () => {
      es.close();
    };
  }, []);

  return <div>live: {value}</div>;
}

WebSocket 예시

useEffect(() => {
  const ws = new WebSocket("wss://example.com/socket");

  ws.addEventListener("message", (e) => {
    // 메시지 처리
  });

  return () => {
    ws.close(1000, "component unmounted");
  };
}, []);

포인트는 “연결 생성과 종료가 한 쌍”이 되도록 effect 범위를 잡는 것입니다.

의존성 배열과 cleanup의 관계: 자주 생기는 실수

1) deps가 바뀔 때도 cleanup이 실행된다

useEffect는 언마운트 시점뿐 아니라, 의존성이 바뀌어 effect가 재실행되기 직전에도 cleanup을 실행합니다.

  • deps 변경
  • cleanup 실행
  • 새 effect 실행

따라서 userId가 바뀔 때 이전 요청을 취소하는 패턴은 자연스럽고 안전합니다.

2) Strict Mode에서 개발 환경은 effect가 2번 실행될 수 있다

React 18 개발 모드의 Strict Mode는 부수효과 안정성을 점검하기 위해 effect를 “마운트-언마운트-재마운트”처럼 한 번 더 실행하는 동작이 있습니다.

이때 cleanup이 제대로 없으면 다음 문제가 빨리 드러납니다.

  • 이벤트 리스너가 2개씩 붙음
  • WebSocket이 2개 열림
  • interval이 중복 실행

즉, 개발 환경에서 경고/중복 실행이 보인다면 “Strict Mode 탓”으로 넘기기보다 cleanup 누락을 먼저 의심하는 게 맞습니다.

실전 패턴: 재사용 가능한 useEffect 템플릿

비동기 요청을 안전하게 처리하는 기본 템플릿을 하나 만들어두면, 팀 전체에서 경고를 크게 줄일 수 있습니다.

import { useEffect, useState } from "react";

type LoadState<T> =
  | { status: "idle" | "loading"; data: null; error: null }
  | { status: "success"; data: T; error: null }
  | { status: "error"; data: null; error: Error };

export function useFetchJson<T>(url: string | null) {
  const [state, setState] = useState<LoadState<T>>({
    status: "idle",
    data: null,
    error: null,
  });

  useEffect(() => {
    if (!url) return;

    const controller = new AbortController();
    setState({ status: "loading", data: null, error: null });

    (async () => {
      try {
        const res = await fetch(url, { signal: controller.signal });
        if (!res.ok) throw new Error(`http ${res.status}`);
        const data = (await res.json()) as T;
        setState({ status: "success", data, error: null });
      } catch (err) {
        if (err instanceof DOMException && err.name === "AbortError") return;
        setState({ status: "error", data: null, error: err as Error });
      }
    })();

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

  return state;
}

이 훅을 사용하면 컴포넌트는 “요청 취소”를 직접 신경 쓰지 않아도 됩니다.

type User = { id: string; name: string };

export function UserProfile({ userId }: { userId: string }) {
  const { status, data, error } = useFetchJson<User>(`/api/users/${userId}`);

  if (status === "loading") return <div>loading</div>;
  if (status === "error") return <div>error: {error.message}</div>;
  if (status !== "success") return <div>idle</div>;

  return <div>{data.name}</div>;
}

디버깅 체크리스트: 경고를 봤을 때 어디부터 볼까

  1. effect 안에서 만든 자원 확인
    • 타이머 id를 저장했는가
    • 이벤트 핸들러 레퍼런스를 유지했는가
    • 소켓/SSE 인스턴스를 닫는가
  2. 네트워크 요청 취소 가능 여부 확인
    • fetchAbortController
    • axios면 cancel/abort 지원 버전 확인
  3. 의존성 배열 점검
    • deps 누락으로 stale closure가 생겨 불필요한 업데이트가 반복되는지
  4. 개발 환경 Strict Mode에서 중복 실행되는지 확인
    • cleanup이 제대로면 중복 실행되어도 최종적으로 1개만 남아야 정상

추가로, 타입/런타임 오류가 섞여 경고 원인 파악이 어려울 때는 타입을 더 엄격히 잡아 “잘못된 상태 흐름”을 줄이는 것도 도움이 됩니다. TS에서 조건 분기 후 타입 좁히기가 기대대로 되지 않는다면 TS 5.7 - satisfies로 타입 좁히기 실패 해결 같은 패턴도 참고할 만합니다.

정리

React 메모리 누수 경고의 대부분은 useEffect에서 시작한 부수효과를 종료하지 않은 데서 발생합니다. 해결 전략은 단순합니다.

  • 타이머는 clearInterval/clearTimeout
  • 이벤트는 removeEventListener
  • 네트워크는 가능하면 AbortController로 취소
  • 스트리밍/SSE/WebSocket은 close로 연결 종료
  • deps 변경 시에도 cleanup이 실행된다는 점을 활용해 “이전 작업 정리”를 기본값으로 만들기

이 원칙만 팀 컨벤션으로 고정해도, “언마운트 이후 setState” 경고와 누수성 버그는 체감될 정도로 줄어듭니다.