개발새발 로그

왜 useState의 set함수는 useEffect의 의존성 배열에 넣어도 무한반복이 안 일어날까? 본문

React

왜 useState의 set함수는 useEffect의 의존성 배열에 넣어도 무한반복이 안 일어날까?

이즈흐 2024. 3. 29. 20:55

useEffect의 의존성 배열의 경고로부터 매일 혼나다가 의문이 하나 생겼다.

 

먼저 아래 코드를 보자

  useEffect(() => {
    setIsEmailDuplicate(true);
    setEmailDuplicateCheckMessage('');
  }, [values.email, setEmailDuplicateCheckMessage, setIsEmailDuplicate]);

실제 내가 사용했던 코드인데

set함수가 useEffect내에 들어가 있고,

set함수를 의존성 배열에 넣어줬다. 

 

왜 set함수를 의존성 배열에 넣었어?

의존성 배열에 넣으라는 eslint 경고가 떴다.

근데 나는 이렇게 생각했다.

 

set함수도 함수니까 의존성 배열에 넣으면 무한 반복 되는거 아니야?

그래서 useCallBack이나 이런걸 써줘야하는 거 아닐까?

 

근데 위 코드를 그대로 사용했는데도 정상적으로 작동했다!

여기서 의문점이 생긴다.

일반 함수랑 set함수가 무엇이 다르길래 무한 반복이 안될까?

일단 함수는 객체다

컴포넌트가 리-렌더링 되면 참조값이 바뀐다.

그러면 참조값이 바뀌기 때문에 useEffect의 의존성 배열에 일반 함수를 넣으면 무한 반복이 생긴다.

 

이게 정말 확실한지 테스트 해봤다.

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

  const increment = () => {
    console.log('Incrementing count');
    setCount(c => c + 1);
  };

  useEffect(() => {
    setCount(c => c + 1);
  }, [increment]);

이 코드를 실행 시켜보면 아래와 같이 eslint 경고가 뜨고, 브라우저에서는 무한 반복 오류가 생긴다.

이 오류 메시지는 React의 useEffect 훅 내에서 사용된 함수 increment 이 컴포넌트가 렌더링될 때마다 새로 생성되어, useEffect 의 의존성 배열(dependency array)이 매 렌더링마다 변경되는 문제를 지적하고 있습니다. 이로 인해 불필요한 렌더링이나 부작용(side effects)의 실행이 발생할 수 있습니다.

 

 

 

그러면 set함수로 바꿔보자

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

  // 사용안함
  const increment = () => {
    console.log('Incrementing count');
    setCount(c => c + 1);
  };

  useEffect(() => {
    setCount(c => c + 1);
  }, [setCount]);

이 코드를 실행하게 되면 정상적으로 작동하게 된다!

 

도대체 무슨 차이일까?

나는 이 부분이 궁금했다.

일반함수랑 set함수가 어떤 차이점이 있길래 다른 결과를 나타내는 걸까?

 

일단 추측했을 때 set함수의 참조값이 바뀌지 않는다는 것이었다.

 

왜냐하면 일반함수는 리-렌더링 후 참조값이 바뀌니까 무한 반복이 일어나는건데

set함수는 리-렌더링 이후에도 참조값이 바뀌지 않는 것이다.

 

이 추측이 맞는지 보기위해 아래와 같이 테스트 해봤다.

import { useEffect, useState } from 'react';

let previousReferenceValue;

export const useStateTest = () => {
  const [count, setCount] = useState(0);

  const increment = () => {
    console.log('Incrementing count');
    setCount(c => c + 1);
  };

  useEffect(() => {
    console.log('increment === increment : ' + (increment === increment)); // 항상 true
  }, []);

  useEffect(() => {
    if (previousReferenceValue) {
      console.log('첫 렌더링 이후 : ' + (previousReferenceValue === increment)); // 첫 번째 렌더링 후 true
    }
    previousReferenceValue = increment;
  });

  return null;
};

간단하게 설명하면previousReferenceValue라는 전역 변수를 선언하고, 

처음 렌더링 이후에 increment의 참조값을 넣어줬다.
그러면 previousReferenceValue에는 increment의 이전 메모리 주소 값이 들어간다.

그리고 다시 렌더링이되어서 previousReferenceValue increment 함수의 메모리 참조 값을 비교하면 아래와 같이 다르다고 나온다.

클릭은 리-렌더링을 해서 메모리참조값 비교가 이후 어떻게 되는지 보여주려고 추가했습니다.

이건 우리가 예상한 결과다

렌더링 이후에는 함수의 참조값이 달라진다.

 

그러면 useState의 set함수는 어떨까? 너무 궁금했다.

let previousReferenceValue;

export const useStateTest = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('increment === increment : ' + (setCount === setCount)); // 항상 true
  }, []);

  useEffect(() => {
    if (previousReferenceValue) {
      console.log('첫 렌더링 이후 : ' + (previousReferenceValue === setCount)); // 첫 번째 렌더링 후 true
    }
    previousReferenceValue = setCount;
  });

  return null;
};

이전과 똑같다고 하고있다!

 

그렇다면 추측이 맞는 것이다.

set함수는 참조 값이 변하지 않고 있다.

 

 

그러면 더 궁금해진다.

set함수는 어떻게 해서 참조값이 변하지 않는 것일까?


일단 챗-GPT한테 물어봤다.

React 내부에서는useState를 사용할 때 생성되는 상태 설정 함수가 특정 컴포넌트 인스턴스에 바인딩되어 있으며, 이 함수는 컴포넌트의 라이프사이클 동안 동일한 참조를 유지합니다.
이는 React의 훅 관리 시스템과 상태 저장 메커니즘이 해당 함수를 재사용하고, 컴포넌트의 상태를 올바르게 관리할 수 있도록 설계되었기 때문입니다.

React의 내부 설계때문인가..?

 

그래서 계속 물어봤다.

setCount와 같은 상태 설정 함수(setter함수)의 참조가 React에서 안정적으로 유지되는 것은 React의 내부 구현과 설계 철학 때문입니다.
이러한 설계는 개발자가 성능 최적화를 직접 관리하는 복잡성을 줄이고, React 애플리케이션의 예측 가능성과 유지 보수성을 향상시키기 위한 것입니다.
React의 불변성 보장
React는 컴포넌트의 상태 관리를 위해 불변성(immutability)을 중요하게 여깁니다. 상태를 직접 변경하지 않고, 상태 변경이 필요할 때마다 새로운 상태를 생성하여 사용하는 것이 불변성의 핵심 원칙입니다. 이 원칙을 따르기 위해, React는 useState 훅을 통해 제공되는 상태 설정 함수의 참조를 변하지 않게 유지합니다. 이를 통해 상태 설정 함수를 안심하고 사용할 수 있으며, 함수의 참조가 변하지 않기 때문에 의존성 배열에 포함시켜도 문제가 발생하지 않습니다.
클로저와 함수 참조
useState에서 반환되는 상태 설정 함수는 클로저를 활용합니다. 클로저는 함수가 생성될 때 그 함수의 스코프에 있는 모든 변수를 "기억"하는 기능을 가지고 있습니다. 이러한 특성 덕분에 상태 설정 함수는 컴포넌트의 재렌더링이 일어나더라도 현재의 상태에 접근할 수 있고, 상태를 올바르게 업데이트할 수 있습니다. React는 이러한 상태 설정 함수를 최적화하여, 함수 자체의 참조가 컴포넌트의 라이프사이클 동안 변하지 않도록 합니다.
성능 최적화
상태 설정 함수의 참조가 변하지 않는 설계는 성능 최적화와 직접적인 관련이 있습니다. 만약 상태 설정 함수의 참조가 컴포넌트의 각 렌더링마다 변한다면, 이 함수를 의존성 배열에 포함하는 모든 useEffect, useCallback, useMemo 등이 불필요하게 재실행될 수 있습니다. 이는 애플리케이션의 성능에 부정적인 영향을 미칠 수 있습니다. 따라서, 상태 설정 함수의 참조를 안정적으로 유지함으로써, React는 불필요한 재계산과 재렌더링을 방지하고 애플리케이션의 성능을 향상시킵니다.

결론적으로, setCount와 같은 상태 설정 함수의 안정적인 참조 유지는 React의 설계 철학과 성능 최적화 전략의 일부입니다. 이를 통해 개발자는 상태 관리의 복잡성을 줄이고, 애플리케이션의 성능과 유지 보수성을 향상시킬 수 있습니다.

 

사실 이게 맞는 말인지는 확실하지 않지만 일단 리액트 내부에서 그렇게 처리하고 있다는 뜻이었다.

 

그래서 내부를 뜯어보려고하기 전에 

클로저때문이라는 얘기가 있어서 useState를 간단하게 만들어봤다.

let _value;

export function useStateCustom(init) {
  if (_value === undefined) {
    _value = init;
  }

  const setState = newValue => {
    _value = newValue;
  };

  return [_value, setState];
}

지금 리액트의 useState가 위처럼 간단하게 만들어진다는 것은 아니지만

클로저 때문인지 확인하기 위해 간단하게 useState을 한번 만들어 보았다.

 

그리고 아래와 같이 적용해봤다.

let previousReferenceValue = null;

export const useStateTest = () => {
  const [count, setCount] = useStateCustom(0);

  useEffect(() => {
    console.log('increment === increment : ' + (setCount === setCount));
  }, []);

  useEffect(() => {
    if (previousReferenceValue) {
      console.log('첫 렌더링 이후 : ' + (previousReferenceValue === setCount));
    }
    previousReferenceValue = setCount;
  });

  return null;
};

사실 당연히 false가 뜰 것이라 예상했다.

클로저이기 때문에 해당 함수의 참조값이 유지된다는 것은 들어보지 못했다.

 

 

그러면 리액트 내부의 코드를 뜯어봐야하나?

https://goidle.github.io/react/in-depth-react-hooks_1/

 

React 톺아보기 - 03. Hooks_1 | Deep Dive Magic Code

모든 설명은 v16.12.0 버전 함수형 컴포넌트와 브라우저 환경을 기준으로 합니다. 버전에 따라 코드는 변경될 수 있으며 클래스 컴포넌트는 설명에서 제외됨을 알려 드립니다. 각 포스트의 주제는

goidle.github.io

 

https://velog.io/@jjunyjjuny/React-useState%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8F%99%EC%9E%91%ED%95%A0%EA%B9%8C

 

[ React ] useState는 어떻게 동작할까

useState의 동작 원리에 대해서

velog.io

 

useState가 어떻게 구성되는지 찾아보자

여기로 들어가서 useState를 검색해보자

이렇게 간단하게 구성되어있다. 

뭔가 너무 없지않나? 생각될 것이다. 

그럼 dispatcher을 만드는 resolveDispatcher()로 가보자

또 어디서 dispatcher을 가져오고 에러처리를 하고 있다.

그럼 다시 ReactCurrentDispatcher로 가보자

그냥 객체 뿐이다..

아무 것도 없는 것을 볼 수 있는데 여기서 중요하게 볼 점은 전역으로 설정된 녀석이라는 것이다.

전역으로 설정되었다는 것은 클로저 방식을 이용한다는 것이다.

 

이 녀석이 나중에 외부에서 추가되고 하는 것인데 이후 부분은 다시 공부해서 적어야 될 것 같다..! (꼭 추후에 수정해서 포스팅하겠습니다..!)

 

현재로서는 setState와 state가 전역으로 설정되는 것으로 이해했다..

 

728x90
반응형
LIST