Santos의 개발블로그

React Hooks의 커다란 빙산 본문

Language & Framework & Library/React

React Hooks의 커다란 빙산

Santos 2023. 11. 17. 17:31

* 이 글은 Iceberg of React Hooks 번역하였습니다.

 

The Iceberg of React Hooks

React Hooks, unlike Class Components, provide low-level building blocks for optimizing and composing applications with minimal boilerplate.

medium.com

Class Component와는 달리 React Hooks은 application을 최소한의 보일러플레트로 구성 및 최적화하기 위해 low-level 빌딩 블럭을 제공합니다.

 

깊이있는 지식없이는 미묘한 버그나 추상화로 인해 성능 문제가 발생할 수 있고 코드 복잡성이 높아집니다. 

 

저는 공통적인 문제를 증명하고 그것들을 고치기 위해 12개의 케이스를 만들어보았습니다. 또한 권장 사항과 빠른 참조를 위해 React Hooks Radar 및 React Hooks Checklist를 컴파일했습니다.


 

Case Study:Implementing Interval

여기서의 목적은 500ms마다 0부터 증가하는 카운터를 실행시키는 것입니다. 3개의 컨트롤 버튼이 제공됩니다.(Start, Stop, Clear)

 

 

Level 0: Hello World

export default function Level00() {
  console.log('renderLevel00');
  const [count, setCount] = useState(0);
  return (
    <div>
      count => {count}
      <button onClick={() => setCount(count + 1)}>+</button>
      <button onClick={() => setCount(count - 1)}>-</button>
    </div>
  );
}

유저의 클릭으로 인해 증가하거나 감소하는 기능을 가진 올바르게 구현 된 카운터로 굉장히 단순한 구조입니다. 

 

Level 1: setInterval

export default function Level01() {
  console.log('renderLevel01');
  const [count, setCount] = useState(0);
  setInterval(() => {
    setCount(count + 1);
  }, 500);
  return <div>count => {count}</div>;
}

이 코드의 의도는 500ms 마다 카운터가 증가하는 것입니다. 하지만 이 코드는 막대한 리소스에 대한 누수가 있으며 잘못 구현된 코드입니다. 굉장히 쉽게 브라우저 탭이 충돌합니다. Level01 함수는 매번 rendering이 일어날때마다 호출되기 때문에 Component는 새로운 interval을 rendering이 될 때마다 만듭니다.

 

Level 2: useEffect

export default function Level02() {
  console.log('renderLevel02');
  const [count, setCount] = useState(0);
  useEffect(() => {
    setInterval(() => {
      setCount(count + 1);
    }, 500);
  });
  return <div>Level 2: count => {count}</div>;
}

대부분의 side-effect는 useEffect안에서 발생합니다. 이 코드도 마찬가지로 막대한 리소스에 대한 누수가 있으며 잘못 구현된 코드입니다. useEffect의 기본 동작은 매번 rendering 할 때마다 실행됩니다. 그렇기에 time 카운트가 바뀌었을 때 매번 새로운 Interval이 만들어집니다. 

 

Level 3: run only once

export default function Level03() {
  console.log('renderLevel03');
  const [count, setCount] = useState(0);
  useEffect(() => {
    setInterval(() => {
      setCount(count + 1);
    }, 300);
  }, []);
  return <div>count => {count}</div>;
}

useEffect에게 두번째 인자로 []를 던져줌으로써 mount가 된 후 해당 함수는 한번만 호출됩니다. setInterval이 한번 호출됨에도 불구하고, 이 코드는 잘못 구현된 코드입니다. 

 

count는 0에서 1로 증가 할 것이고 그대로 유지됩니다. 화살표 함수는 한번 생성되며, 그때 count는 0이 됩니다. 

 

이 코드는 미묘하게 리소스에 대한 누수가 있고 component가 unmount 되어도, setCount는 계속 호출될 것입니다.

 

Level 4: clean up

useEffect(() => {
    const interval = setInterval(() => {
      setCount(count + 1);
    }, 300);
    return () => clearInterval(interval);
  }, []);

리소스 누수를 막기 위해 hook의 생명주기가 끝나는 시점에 모든 함수가 삭제되었습니다. 이러한 케이스에서 반환된 함수는 component가 unmount된 후에 호출됩니다.

 

그래서 이 코드는 리소스에 누수는 없지만 이전과 같은 이유로 잘못 구현된 코드입니다. 

 

Level 5: use count as dependency

useEffect(() => {
  const interval = setInterval(() => {
    setCount(count + 1);
  }, 500);
  return () => clearInterval(interval);
}, [count]);

useEffect에 dependency 배열을 제공하면 수명주기가 변경됩니다. 예시에서 useEffect는 mount가 된 후에 한번, count가 변경될 때 매번 호출됩니다. 이전 리소스를 삭제하기 위해 Cleanup 함수는 count가 변경될 때마다 매번 호출됩니다. 

 

이 코드는 버그 없이 잘 작동하지만, 약간의 문제가 있습니다. setInterval은 500ms 마다 생성되고 삭제됩니다. 각 setInterval은 항상 한 번 호출됩니다.

 

Level 6: setTimeout

useEffect(() => {
  const timeout = setTimeout(() => {
    setCount(count + 1);
  }, 500);
  return () => clearTimeout(timeout);
}, [count]);

이 코드나 위에 코드나 잘 동작합니다. useEffect는 count가 변경될 때마다 호출되기 때문에 setTimeout을 사용하는 것은 setInterval를 호출하는 것과 같은 동작을 합니다. 

 

그러나 이 예제는 새로운 setTimeout이 rendering이 발생 할 때마다 매번 만들어지기 때문에 비효율적입니다. 

 

Level 7: functional updates for useState

useEffect(() => {
  const interval = setInterval(() => {
    setCount(c => c + 1);
  }, 500);
  return () => clearInterval(interval);
}, []);

이전 예시에서는 각 count 변경에 대해 useEffect를 실행했습니다. 항상 최신의 현재 값을 가져야했기 때문에 필요했습니다. 

 

useState는 현재 값의 대해 캡쳐하지 않고 이전 state 값을 수정을 하기 위해서 API를 제공합니다. 그렇게 하기 위해서는 setState에 람다를 제공하면 됩니다. 

 

이 코드는 정상적으로 돌아가고 꽤 효율적입니다. component의 생명주기 동안 하나의 setInterval을 사용합니다. clearinterval도 Component가 unmounted 될 때 한번 호출됩니다.

Level 8: local variable

export default function Level08() {
  console.log('renderLevel08');
  const [count, setCount] = useState(0);
  let interval = null;
  const start = () => {
    interval = setInterval(() => {
      setCount(c => c + 1);
    }, 500);
  };
  const stop = () => {
    clearInterval(interval);
  };
  return (
    <div>
      count => {count}
      <button onClick={start}>start</button>
      <button onClick={stop}>stop</button>
    </div>
  );
}

start와 stop 버튼을 추가하였습니다. 이 코드는 stop버튼이 동작하지 않으므로 잘못 구현된 코드입니다. 새로운 참조는 rendering이 될 때마다 생성되므로 stop은 null에 대한 참조를 갖습니다. 

 

level 9: useRef

export default function Level09() {
  console.log('renderLevel09');
  const [count, setCount] = useState(0);
  const intervalRef = useRef(null);
  const start = () => {
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 500);
  };
  const stop = () => {
    clearInterval(intervalRef.current);
  };
  return (
    <div>
      count => {count}
      <button onClick={start}>start</button>
      <button onClick={stop}>stop</button>
    </div>
  );
}

useRef는 변경 가능한 변수가 필요할 때 사용되는 이동 hook입니다. 지역변수와는 다르게 React는 각 rendering 중 동일한 참조가 반환되는지 확인을 합니다. 

 

이 코드는 올바르게 보이지만, 약간의 버그가 존재합니다. 만약 start가 여러번 호출 된다면, setInterval도 여러 번 호출 되어 리소스에 누수를 가져올 것입니다.

 

Level 10: useCallBack

export default function Level10() {
  console.log('renderLevel10');
  const [count, setCount] = useState(0);
  const intervalRef = useRef(null);
  const start = () => {
    if (intervalRef.current !== null) {
      return;
    }
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 500);
  };
  const stop = () => {
    if (intervalRef.current === null) {
      return;
    }
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  };
  return (
    <div>
      count => {count}
      <button onClick={start}>start</button>
      <button onClick={stop}>stop</button>
    </div>
  );
}

리소스 누수를 피하기 위해서 interval이 이미 시작되었다면 호출을 무시하면 됩니다. clearInterval(null)을 호출해도 오류가 발생하지는 않지만 리소스를 한 번만 삭제하는 것이 좋습니다. 

 

이 코드는 리소스 누수가 발생하지 않고 제대로 동작을 하지만 성능적인 문제가 존재합니다. 

 

momoization은 React의 중요한 성능 최적화 도구입니다. React.memo는 얕은 복사를 하고, 참조값이 같다면 rendering을 하지 않습니다. 

 

만약  momized component에 start와 stop이 전달 되었다면 각 rendering 후 에 새로운 참조가 반환 되기 때문에 전체 최적화는 실패일 것입니다

 

Level 11: useCallback

export default function Level11() {
  console.log('renderLevel11');
  const [count, setCount] = useState(0);
  const intervalRef = useRef(null);
  const start = useCallback(() => {
    if (intervalRef.current !== null) {
      return;
    }
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 500);
  }, []);
  const stop = useCallback(() => {
    if (intervalRef.current === null) {
      return;
    }
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  }, []);
return (
    <div>
      count => {count}
      <button onClick={start}>start</button>
      <button onClick={stop}>stop</button>
    </div>
  );
}

React.memo가 제대로 작동하도록 하려면, 필요한 것은 useCallback hook을 사용하는 momoize 함수입니다. 이렇게 하면 각 rendering 후에 동일한 참조가 제공됩니다. 

 

이 코드는 리소스 누수가 발생하지 않고 제대로 구현된 코드이며, 성는에도 문제가 없습니다. 그러나 코드가 복잡하다는 문제점이 있습니다. 

 

Level 12: custom hook

function useCounter(initialValue, ms) {
  const [count, setCount] = useState(initialValue);
  const intervalRef = useRef(null);
  const start = useCallback(() => {
    if (intervalRef.current !== null) {
      return;
    }
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, ms);
  }, []);
  const stop = useCallback(() => {
    if (intervalRef.current === null) {
      return;
    }
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  }, []);
  const reset = useCallback(() => {
    setCount(0);
  }, []);
  return { count, start, stop, reset };
}

코드를 명확하게 하기 위해 useCounter 커스텀 hook 내부에서 복잡성에 대한 캡슐화하고 API(count, start, stop, reset)만을 노출해야 합니다.

export default function Level12() {
  console.log('renderLevel12');
  const { count, start, stop, reset } = useCounter(0, 500);
  return (
    <div>
      count => {count}
      <button onClick={start}>start</button>
      <button onClick={stop}>stop</button>
      <button onClick={reset}>reset</button>
    </div>
  );
}

React Hooks radar

모든 React hooks는 동일하지만, 일부 hooks는 다른 hooks보다 더 동일합니다.

✅ Green

Green hook은 React application의 주요 빌딩 블럭입니다. 그것들은 어디에서든지 안전하게 사용할 수 있습니다. 

1. useReducer

2. useState

3. useContext

 

🌕 Yellow

Yellow hooks는 유용한 성능 최적화를 할 수 있도록 제공합니다. 입력과 생애주기에 대한 관리는 주의해서 수행해야 합니다. 

1. useCallback

2. useMemo

 

🔴 Red

Red hooks는 side-effect가 잘 나타나는 수정과 관련된 부분과 상호작용을 합니다. 대부분 안전하지 않고 매우 주의깊게 사용되어져야 합니다. non-trivial use-cases를 위해 커스텀 hooks으로 만들어 사용하는 것을 추천드립니다. 

1. useRef

2. useEffect

3. useLayoutEffect

 

React Hooks checklist

1. Hook 규칙을 따르세요. 

2. 메인 rendering 함수에서는 어떠한 side-effect도 만들어서는 안됩니다. 

3. 사용된 모든 리소스는 삭제되어야 합니다. 

4. hooks 에서 동알한 값을 읽고 쓰는 것을 방지하려면 useState에서 함수로 업데이트를 하거나 useReducer를 사용하는 것을 추천드립니다. 

5. render 함수내에서 mutable 변수를 사용하지 마세요. useRef를 사용하세요.

6. userRef에 저장한 항목이 component 보다 수명주기가 짧다면 꼭 리소스가 삭제될 때 값 설정을 해제하세요.

7. infinite recursion과 자원 낭비를 조심하세요.

8. 성능을 향상시키고 싶다면 함수와 객체를 memoize 하세요 

9. input dependency를 올바르게 사용하세요.

  •  9-1 undefined => 항상 render
  •  9-2 [a,b] => a 또는 b가 바뀌었을 때 render
  •  9-3 [] => 한번만 render

10. non-trivial use-cases를 위해 커스텀 hook를 사용하세요. 

 

긴 글 읽어주셔서 감사합니다. 


< 참고자료 >

 

[사이트] #medium

medium.com/@sdolidze/the-iceberg-of-react-hooks-af0b588f43fb

 

The Iceberg of React Hooks

React Hooks, unlike Class Components, provide low-level building blocks for optimizing and composing applications with minimal boilerplate.

medium.com

<React> The iceberg of React Hooks end

Comments