티스토리 뷰

반응형

리액트로 개발을 하다보면 한번은 아래와 같은 warning메세지를 보게됩니다. 

Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

현재 해당 warning 메세지는 최초 1회만 나타나고, 브라우저를 새로 고침 하기 전에는 다시 나타나지 않으니 코드 수정 후 확인 할 시에 주의가 필요합니다.

해당 에러는 fetch 등 비동기를 실행하는 실행 컨텍스트가 완료 되기 전에 컴포넌트가 unmount가 된 후 setState가 실행이 될 때 발생하게 됩니다. 에러가 발생하는 코드는 아래와 같습니다.

import { useEffect, useState } from "react";
import "./styles.css";

const fakeFetch = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log("fetch done");
      resolve(Math.random());
    }, 2000);
  });
};

const Div = () => {
  const [state, setState] = useState(0);

  useEffect(() => {
    (async function foo() {
      try {
        const val = await fakeFetch();
        setState(val);
      } catch (error) {
        console.log("error", error);
        setState(error.message);
      }
    })();
  }, []);

  return <div style={{ backgroundColor: "red" }}>{state}</div>;
};

export default function App() {
  const [isDivShow, setIsDivShow] = useState(false);

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>
      <button onClick={() => setIsDivShow((prev) => !prev)}>버튼</button>
      {isDivShow && <Div />}
    </div>
  );
}

버튼을 누르면 Div 컴포넌트가 렌더링 되고 fetch 요청이 갑니다. 2초 안에 다시 버튼을 누르면 Div 컴포넌트는 unmount가 됩니다. fetch가 종료 된 후 setState가 실행이됩니다. 하지만 컴포넌트는 이미 unmount 상태이기 때문에 리엑트에서는 warning 메세지가 나타납니다.

https://codesandbox.io/s/memory-leak-warning-ditmy

 

memory leak warning - CodeSandbox

memory leak warning by 냉정 using react, react-dom, react-scripts

codesandbox.io

해결 방법 1 : 트리거 변수를 이용하여 setState 방지

import { useEffect, useState } from "react";
import "./styles.css";

const fakeFetch = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log("fetch done");
      resolve(Math.random());
    }, 2000);
  });
};

const Div = () => {
  const [state, setState] = useState(0);

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

    (async function foo() {
      try {
        const val = await fakeFetch();

        if (isMount) {
          setState(val);
        }
      } catch (error) {
        console.log("error", error);
        setState(error.message);
      }
    })();

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

  return <div style={{ backgroundColor: "red" }}>{state}</div>;
};

export default function App() {
  const [isDivShow, setIsDivShow] = useState(false);

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>
      <button onClick={() => setIsDivShow((prev) => !prev)}>버튼</button>
      {isDivShow && <Div />}
    </div>
  );
}

isMount 변수를 만들어서 clean up 할 때 false로 변경을 하고, setState는 isMount가 true일 때만 실행을 하게 하면, 메모리 누수를 방지 할 수 있습니다. 하지만 fetch를 날리는 로직마다 isMount 변수를 선언하는 것은 어썸하지 못한것 같습니다. 이를 해결하기 위해 custom hook을 사용해서 코드를 수정할 수 있습니다.

 

useIsMount.js

import { useEffect, useRef } from "react";

export default function useIsMount() {
  let isMount = useRef(false);

  useEffect(() => {
    isMount.current = true;

    return () => {
      isMount.current = false;
    };
  }, []);

  return isMount;
}

app.js

import { useEffect, useState } from "react";
import useIsMount from "./useIsMount";
import "./styles.css";

const fakeFetch = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log("fetch done");
      resolve(Math.random());
    }, 2000);
  });
};

const Div = () => {
  const [state, setState] = useState(0);
  const isMount = useIsMount();

  useEffect(() => {
    (async function foo() {
      try {
        const val = await fakeFetch();

        if (isMount.current) {
          setState(val);
        }
      } catch (error) {
        console.log("error", error);
        setState(error.message);
      }
    })();
  }, [isMount]);

  return <div style={{ backgroundColor: "red" }}>{state}</div>;
};

export default function App() {
  const [isDivShow, setIsDivShow] = useState(false);

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>
      <button onClick={() => setIsDivShow((prev) => !prev)}>버튼</button>
      {isDivShow && <Div />}
    </div>
  );
}

https://codesandbox.io/s/trigger-setstate-kyqr8?file=/src/App.js:0-1076 

 

trigger setState - CodeSandbox

trigger setState by 냉정 using react, react-dom, react-scripts

codesandbox.io

 

해결 방법  2 :  cancelable promise 

위의 개념과 컨셉은 비슷합니다. 위 방법과 다른 점은 fetch가 resolve나 reject 되기 전에 트리거를 하는 것 입니다. 이 로직을 수행하기 위해서는 promise함수를 cancelable 하게 감싸주어야 합니다. 아래 코드에서는 wrappedPromise 함수가 그 역할을 하게 됩니다.

import { useEffect, useState } from "react";
import "./styles.css";

const makeCancelable = (promise) => {
  let hasCanceled = false;

  const wrappedPromise = new Promise((resolve, reject) => {
    promise.then(
      (val) => !hasCanceled && resolve(val),
      (error) => reject(error)
    );
  });

  return {
    promise: wrappedPromise,
    cancel() {
      console.log("cancel");
      hasCanceled = true;
    }
  };
};

const fakeFetch = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log("fetch done");
      resolve(Math.random());
    }, 2000);
  });
};

const Div = () => {
  const [state, setState] = useState(0);

  useEffect(() => {
    let cancelable;

    (async function foo() {
      try {
        cancelable = makeCancelable(fakeFetch());
        const val = await cancelable.promise;
        setState(val);
      } catch (error) {
        console.log("error", error);
        setState(error.message);
      }
    })();

    return () => {
      cancelable.cancel();
    };
  }, []);

  return <div style={{ backgroundColor: "red" }}>{state}</div>;
};

export default function App() {
  const [isDivShow, setIsDivShow] = useState(false);

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>
      <button onClick={() => setIsDivShow((prev) => !prev)}>버튼</button>
      {isDivShow && <Div />}
    </div>
  );
}

promise에 익숙하더라도 약간 혼란스러울 수 있습니다. 중요한 것은 promise가 pandding 상태 일 때, warppedPromise 함수가 return 하는 객체의 cancel 함수를 실행 시키면 초기에 실행한 fakeFetch가 resolve되어도 wrappedPromise함수에서 체이닝한  promise로 인해서 resolve와 reject를 아무것도 실행하지 않기 때문에 계속 pandding 상태로 남아있게 됩니다.

 

따라서 Div 컴포넌트가 unmount 될 때 cancelable.cancel()을 실행하게 되면, await cancelable.promise 이후의 로직은 실행이 되지 않아서 에러를 해결 할 수 있습니다.

 

https://codesandbox.io/s/kaenseulreobeul-dnqmb

 

캔슬러블 - CodeSandbox

캔슬러블 by 냉정 using react, react-dom, react-scripts

codesandbox.io

하지만 만약에 fetch가 resolve되고, setState가 실행되기 전에 unmount를 할 경우에는 어떨게 될까요?

위의 상황에서는 역시 warning메세지가 나타나게 됩니다.

import { useEffect, useState } from "react";
import "./styles.css";

const makeCancelable = (promise) => {
  let hasCanceled = false;

  const wrappedPromise = new Promise((resolve, reject) => {
    promise.then(
      (val) => !hasCanceled && resolve(val),
      (error) => reject(error)
    );
  });

  return {
    wrappedPromise,
    cancel() {
      console.log("cancel");
      hasCanceled = true;
    }
  };
};

const fakeFetch = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log("fetch done");
      resolve(Math.random());
    }, 0);
  });
};

const delay = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("프로미스 이 후, setState 이전");
      resolve();
    }, 2000);
  });
};

const Div = () => {
  const [state, setState] = useState(0);

  useEffect(() => {
    let cancelable;

    (async function foo() {
      try {
        cancelable = makeCancelable(fakeFetch());
        const val = await cancelable.promise;

        await delay();

        setState(() => {
          console.log("setState");
          return val;
        });
      } catch (error) {
        console.log("error", error);
        setState(error.message);
      }
    })();

    return () => {
      cancelable.cancel();
    };
  }, []);

  return <div style={{ backgroundColor: "red" }}>{state}</div>;
};

export default function App() {
  const [isDivShow, setIsDivShow] = useState(false);

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>
      <button onClick={() => setIsDivShow((prev) => !prev)}>버튼</button>
      {isDivShow && <Div />}
    </div>
  );
}

https://codesandbox.io/s/kaenseulreobeul-esji-keiseu-b5nre?file=/src/App.js:0-1695 

 

캔슬러블 엣지 케이스 - CodeSandbox

캔슬러블 엣지 케이스 by 냉정 using react, react-dom, react-scripts

codesandbox.io

fetch가 끝난 이후에 setState는 매우 빠른 속도로 실행될 것이기 때문에 위의 엣지케이스 타이밍에 unmount가 된다는 것은 정말 일어나기 힘들 것입니다. 따라서 fetch를 핸들링 하는 것으로도 warning 메세지가 해결 된다고 생각합니다. 위의 테스트로 warning 메세지의 원인이 비동기에 남아있는 setState 때문임을 좀 더 명확하게 알 수 있습니다.

 

해결 방법 3 : abort controller

abortcontroller는 fetch 요청을 취소할 수 있는 웹 api입니다. 사용방법은 mdn 문서를 참고하면 금방 적용할 수 있을 것 입니다.

abort controller를 사용할 때 주의 할 점은 abort를 할 경우 reject로 반환 된다는 것입니다.따라서 에러 핸들링 부분에 setState가 있다면 당연히 warning 메세지가 나타날 것입니다.

 

https://developer.mozilla.org/ko/docs/Web/API/AbortController

 

AbortController - Web API | MDN

AbortController 인터페이스는 하나 이상의 웹 요청을 취소할 수 있게 해준다.

developer.mozilla.org

 

반응형
댓글