OTTER-LOG

useCallback을 잘 사용하자

useCallback을 잘 사용하자
by otter2023년 2월 23일에 최종수정되었습니다.
잘못된 내용이 있으면 댓글을 달아주세요.

useCallback

useCallback 은 함수를 메모이제이션해주는 리액트의 훅입니다. (사실, useMemo 도 함수를 메모이제이션 할 수 있습니다. ) 공식문서에 따르면 다음과 같습니다.

인라인 콜백과 그것의 의존성 값의 배열을 전달하세요. useCallback은 콜백의 메모이제이션된 버전을 반환할 것입니다. 그 메모이제이션된 버전은 콜백의 의존성이 변경되었을 때에만 변경됩니다. 이것은, 불필요한 렌더링을 방지하기 위해 (예로 shouldComponentUpdate를 사용하여) 참조의 동일성에 의존적인 최적화된 자식 컴포넌트에 콜백을 전달할 때 유용합니다.

(ref : https://ko.reactjs.org/docs/hooks-reference.html#usecallback )

const memoizedCallback = useCallback( () => { doSomething(a, b); }, [a, b], ); // 함수를 메모이제이션 할 수 있다. // 의미론적으로 좋지 않은 것 같지만 useMemo(() => fn, deps) 로도 똑같이 할 수 있다.

그런데 우리는 이 useCallback 을 통해서 어떤 결과를 이끌어 낼 수 있을까요? 함수를 메모이제이션 한다는 것은 언제나 좋아 보이기만 합니다. 마치 모든 함수에 useCallback 을 쓰고 싶을 정도로요. 마법처럼 우리가 만든 모든 함수를 다시 만들지 않을 것 같은 느낌이 드니깐요.

얼마전 저에게 일어났던 문제가 이 부분에 좋은 예시가 될 것 같습니다. 저는, Context API 를 이용하던 중 여러개의 함수를 useCallbackuseMemo 로 감싸 내려주었습니다. 이를 통해 Context API 의 리렌더링 문제를 해결할 수 있을 것이라 생각했기 때문입니다. 그런데 이를 프로파일러로 확인해보니, 렌더링적인 측면의 개선은 없고 오히려 미세하게 성능이 안좋아졌음을 확인할 수 있었습니다.

const onToggleOpen = React.useCallback(() => { dispatch({ type: 'TOGGLE_OPTIONS' }); }, []); const openOptions = React.useCallback(() => { dispatch({ type: 'OPEN_OPTIONS' }); }, []); const closeOptions = React.useCallback(() => { dispatch({ type: 'CLOSE_OPTIONS' }); }, []); ... // 이런 함수가 몇개 더있었습니다. 😂

  • useCallback 을 모두 제거해, 그냥 함수로 내려주었을때의 측정 값

  • useCallback 을 사용해, 메모이제이션해 내려주었을 때의 측정 값

미세하지만 오히려 useCallback 으로 메모했을때, 성능이 저하되었습니다. (그런데 이부분은, 몇번의 프로파일링을 기록한 것이라 불명확하다고 생각합니다.) 다만, 렌더링이 되는 컴포넌트는 달라지지 않았음을 확인할 수 있습니다.

위의 예시를 통해 보면, 메모이제이션에도 비용이 든다는 점을 알게 됩니다. 오히려, 미세하지만 성능이 떨어졌음을 통해서요.구체적으로 메모이제이션을 한다는 것은 이전 메모와 현재의 메모가 같은지 평가하는 비용과 메모를 하는 비용이 들 수 있을 것 같습니다. 여기서 몇가지의 비용을 알게되었습니다. 그리고 이를 통해 생각해보면, 우리는 다음과 같은 공식을 얻을 수 있습니다.

함수를 다시 만드는 비용 > 함수를 메모하는 비용 + 메모된 함수를 비교하는 비용

그리고 함수를 다시 만드는 비용이 함수를 메모하는 비용, 메모된 함수를 비교하는 비용보다 클 때에만 useCallback 을 사용하는 것이 옳다는 결론을 얻을 수 있습니다. 그렇지 않다면 우리는 함수를 메모이제이션 할 필요는 없으니까요.

그런데 여기서 중요한 점을 하나 짚고 넘어갑시다. 사실 함수를 비교하고, 메모이제이션 하거나 함수를 새로 만드는 것 모드 큰 비용이 들지 않는 다는 점입니다.

(ref : React Rendering Optimization ref를 꼭 확인해주세요! 많은 것을 배울 수 있었습니다. )

해당 레퍼런스를 통해 참고하면, 만번의 함수 생성이나 만 번의 함수 메모, 비교는 둘다 대략 2ms 정도 결렸고 100번의 경우에는 0ms가 걸렸습니다. 결과적으로 우리는 useCallback 을 사용할때에 - 일반적으로 10개 이하를 callback으로 묶는다고 생각할 때- 위와 같은 공식을 생각할 필요가 없습니다. 위의 공식 중 어떠한 값도 성능상의 유의미한 차이를 만들어내지 못하기 때문입니다.


언제 useCallback 을 사용해야 할까요?

그럼에도 useCallback 을 사용해야만 하는 경우가 있습니다. 불필요한 리액트의 re-rendering 막기 위해 사용할 수 있습니다. 그 전에, 우리는 리액트의 렌더링 조건을 간단히 알아봅시다.

리액트는 부모가 렌더링 되면 자식요소가 모두 렌더링됩니다.
상태가 변하면 렌더링됩니다.
props가 변하면 렌더링됩니다.
context provider가 바뀌면 그 자식요소가 모두 렌더링됩니다.

그리고 위와 같은 상황에서 불필요한 리렌더링을 막기위해 우리는 React.memo 를 주로 사용합니다.

여기서 우리는, useCallback 을 적용해 리렌더링을 피할 수 있는 적절한 부분을 도출해낼 수 있습니다. 부모요소의 렌더링부분과 props와 context api 부분입니다. 일반적으로 우리는 많은 함수들을 props 로 넘깁니다. 다음과 같은 상황이 있다고 생각해봅시다.

const Chat = () => { const [text, setText] = useState(""); const resetText = () => { setText('') // input의 text를 초기화 합니다. }; return ( <React.Fragment> <input value={text} onChange={(e) => setText(e.target.value)} /> <Button onButtonClick={onButtonClick}>버튼</Button> </React.Fragment> ); }; const Button = ({ onButtonClick }) => { return <button onClick={onButtonClick}>버튼</button>; };

위와 같은 코드가 있습니다. 이 코드를 실행시켜보면 input 에 타이핑을 할때마다, button 컴포넌트에도 리렌더링이 일어납니다.

구체적으로 말하면,

  1. input 에 타이핑을 하면, Chat 컴포넌트는 리렌더링 됩니다.
  2. 이 렌더링은 resetText 함수를 다시 작성합니다.
  3. onButtonClickprops 로 받는 Button 컴포넌트는 부모 컴포넌트가 렌더링 되었고, props 가 달라졌으므로 리렌더링됩니다.

그러면, 이를 useCallbackReact.memo 를 통해 해결해볼 수 있을 것 같습니다. button 컴포넌트에 전달되는 resetText 만 고정시켜 둔다면 매번 동일한 props 를 가진다고 할 수 있으니까요.

const Chat = () => { const [text, setText] = useState(""); const resetText = useCallback(() => { setText(""); }, []); return ( <React.Fragment> <input value={text} onChange={(e) => setText(e.target.value)} /> <Button onButtonClick={resetText}>버튼</Button> </React.Fragment> ); }; const Button = React.memo(({ onButtonClick }) => { return <button onClick={onButtonClick}>버튼</button>; });

위와 같이 useCallback 을 통해 props 의 동일성을 보장해줄 수 있고, 이를 얕은 평가로 대체하기 위해 React.memo 를 사용하면 이제 input - 부모 컴포넌트가 - 달라진다고 하더라도, button 컴포넌트는 리렌더링 되지 않습니다.

Ref