useEffect를 남용하지 말 것

TMT

리액트의 불필요한 리렌더링을 줄이는 구체적인 이유

1. useEffect의 남용과 경쟁 상태(Race Condition) 문제

  • 문제:
    • useEffect는 비동기 작업이 완료되기 전에 다른 작업이 트리거될 경우, 이전 작업이 종료된 후 상태를 덮어쓰는 경쟁 상태를 유발할 수 있습니다.
    • 예: 여러 API 요청 중 마지막 요청만 유효해야 하지만, 이전 요청의 응답이 더 늦게 도착하면 상태가 덮어써집니다.
  • 해결법:
    useEffect(() => {
      let ignore = false;
      const request = async (requestId) => {
        setIsLoading(true);
        await sleep(Math.random() * 3000);
        if (!ignore) {
          setResponse(requestId);
          setIsLoading(false);
        }
      };
      request(counter);
    
      return () => {
        ignore = true;
      };
    }, [counter]);
    • 이유:
      • 클린업 함수를 사용하면 이전 요청의 결과를 무시하도록 설정할 수 있습니다.
      • ignore 플래그를 활용해 현재 요청만 유효하게 처리하며, 이전 요청으로 인해 잘못된 상태 업데이트가 발생하지 않도록 보장합니다.

2. 불필요한 리렌더링 방지

  • 문제:
    • useEffect 안에서 부모 컴포넌트의 상태를 업데이트하면, 자식의 로컬 상태 변경과 부모의 상태 변경이 상호작용하면서 추가적인 렌더링을 유발합니다.
    • 이는 성능 저하뿐만 아니라 코드 복잡성 증가로 이어집니다.
  • 해결법:
    • 이벤트 핸들러에서 상태를 직접 업데이트하도록 변경.
      function handleClick() {
        const newValue = !isOn;
        setIsOn(newValue); // 자식의 상태 업데이트
        onChange(newValue); // 부모의 상태 업데이트
      }
    • 이유:
      • 이벤트 핸들러는 상태 변경의 진입점이므로, 여기에서 필요한 모든 업데이트를 처리하는 것이 직관적이고 효율적입니다.
      • useEffect를 생략하면 불필요한 렌더링을 줄일 수 있습니다.

3. 데이터 흐름 체인 유지

  • 문제:

    • 자식 컴포넌트에서 부모 컴포넌트로 데이터를 전달하려면, 데이터를 다시 상위 상태로 끌어올려야 합니다. 이는 복잡한 데이터 흐름과 디버깅을 어렵게 만듭니다.
    • 예:
      useEffect(() => {
        if (data) {
          onFetched(data);
        }
      }, [onFetched, data]);
      • 자식에서 데이터를 부모로 보내는 코드는 비선언적이며, 유지보수가 어렵습니다.
  • 해결법:

    • 상태를 부모에서 관리하고, 자식은 데이터를 단순히 표현하도록 변경.
      function Parent() {
        const data = useFetchData();
        return <Child data={data} />;
      }
      
      function Child({ data }) {
        return <>{JSON.stringify(data)}</>;
      }
    • 이유:
      • 부모가 상태의 진실의 출처(Source of Truth) 역할을 하며, 데이터 흐름이 단방향으로 유지됩니다.
      • 데이터 흐름이 명확해지므로 디버깅과 유지보수가 용이해지고, 코드 가독성이 높아집니다.

4. 앱 초기화 로직과 useEffect 대체

  • 문제:

    • 앱 초기화 로직을 useEffect에 포함하면, 개발 모드에서 React Strict Mode로 인해 컴포넌트가 여러 번 마운트되면서 불필요한 실행과 클린업 함수 구현이 필요할 수 있습니다.
    • 이는 동시성 모드에서 컴포넌트가 다시 렌더링될 가능성이 높은 상황에서 문제가 됩니다.
  • 해결법:

    • 앱 초기화 로직을 useEffect 대신 렌더링 전에 실행.
      if (typeof window !== "undefined") {
        someOneTimeLogic();
      }
      
      function App() {
        // ...
      }
    • 이유:
      • 초기화 로직이 렌더링 이전에 실행되므로, 리렌더링이나 클린업 함수 구현 부담이 사라집니다.
      • 컴포넌트 생애주기와 상관없이 앱 생애주기 단위로 로직을 처리할 수 있습니다.

5. 언마운트된 컴포넌트에서 상태 업데이트

  • 문제:

    • 기존에는 언마운트된 컴포넌트에서 상태를 업데이트할 경우 경고가 발생했으나, 이를 피하려다 오히려 복잡한 로직(예: useMountedRef)이 추가되었습니다.
    • 예전 방식:
      const useMountedRef = () => {
        const mounted = useRef(true);
        useEffect(() => {
          return () => {
            mounted.current = false;
          };
        }, []);
        return mounted;
      };
      
      if (mountedRef.current) {
        setLoading(false);
      }
  • 해결법:

    • 언마운트 여부를 확인하지 않고 상태를 그대로 업데이트.
      const handleDeleteBill = async (id) => {
        setLoading(true);
        await axios.delete(`/bill/${id}`);
        setLoading(false);
      };
    • 이유:
      • 리액트 팀은 해당 경고를 제거했으며, 상태를 업데이트한다고 해서 실제로 메모리 누수가 발생하지 않습니다.
      • 복잡한 로직을 제거하면 코드가 간결해지고 유지보수성이 높아집니다.
      • 미래에 리액트 상태 보존 기능을 사용해야 할 경우, 기존 복잡한 로직이 오히려 장애물이 될 수 있습니다.

왜 이렇게 작성해야 하는가?

  1. 성능 최적화:
    • 불필요한 렌더링이나 비동기 작업을 줄임으로써 성능을 개선합니다.
  2. 코드의 명확성:
    • 단방향 데이터 흐름 유지로 가독성과 유지보수성이 높아집니다.
  3. 디버깅 용이성:
    • 데이터의 흐름이 명확해지고, 상태 변경의 책임이 분리되므로 문제를 쉽게 추적할 수 있습니다.
  4. 미래 확장성:
    • 리액트 생태계의 최신 기능(예: 상태 보존 메커니즘)을 쉽게 통합할 수 있습니다.
  5. 불필요한 복잡성 제거:
    • 잘못된 경고를 우회하려는 복잡한 로직 대신, 리액트의 기본 동작에 따라 코드를 단순화합니다.
Edit this page