useCallback, useEvent
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
을 쓰고 싶을 정도로요. 마법처럼 우리가 만든 모든 함수를 다시 만들지 않을 것 같은 느낌이 드니깐요.
그렇지만 잠시 이성을 찾아보면 메모이제이션에도 비용이 든다는 점을 알게 됩니다. 그리고, 메모이제이션을 해 두었다면 이전 메모와 현재의 메모가 같은지 평가하는 단계도 거치게 됩니다. 이를 생각해보면, 우리는 다음과 같은 공식을 얻을 수 있습니다.
함수를 다시 만드는 비용 > 함수를 메모하는 비용 + 메모된 함수를 비교하는 비용
함수를 다시 만드는 비용이 함수를 메모하는 비용, 메모된 함수를 비교하는 비용보다 클 때에만 useCallback
을 사용하는 것이 옳다는 결론을 얻을 수 있습니다. 그렇지 않다면 우리는 함수를 메모이제이션 할 필요는 없으니까요.
그런데 여기서 중요한 점을 하나 짚고 넘어갑시다. 사실 함수를 비교하고, 메모이제이션 하거나 함수를 새로 만드는 것 모드 큰 비용이 들지 않는 다는 점입니다.
(ref : React Rendering Optimization ref를 꼭 확인해주세요! 많은 것을 배울 수 있었습니다. )
해당 레퍼런스를 통해 참고하면, 만번의 함수 생성이나 만 번의 함수 메모, 비교는 둘다 대략 2ms 정도 결렸고 100번의 경우에는 0ms가 걸렸습니다. 결과적으로 우리는 useCallback
을 사용할때에 - 일반적으로 10개 이하를 callback으로 묶는다고 생각할 때- 위와 같은 공식을 생각할 필요가 없습니다. 위의 공식 중 어떠한 값도 성능상의 유의미한 차이를 만들어내지 못하기 때문입니다.
언제 useCallback
을 사용해야 할까요?
그럼에도 useCallback
을 사용해야만 하는 경우가 있습니다. 불필요한 리액트의 re-rendering
막기 위해 사용할 수 있습니다. 그 전에, 우리는 리액트의 렌더링 조건을 간단히 알아봅시다.
리액트는 부모가 렌더링 되면 자식요소가 모두 렌더링됩니다.
상태가 변하면 렌더링됩니다.
props가 변하면 렌더링됩니다.
context provider가 바뀌면 그 자식요소가 모두 렌더링됩니다.
그리고 위와 같은 상황에서 불필요한 리렌더링을 막기위해 우리는 React.memo
를 주로 사용합니다.
(React.memo
와 관련된 포스트는 여기에서도 있습니다! )
리액트의 컴포넌트는 props
가 다를때 렌더링됩
여기서 우리는, useCallback
을 적용해 리렌더링을 피할 수 있는 적절한 부분을 도출해낼 수 있습니다. (useCallback은 함수에 사용하니까요) 부모요소의 렌더링부분과 props 부분입니다. 일반적으로 우리는 많은 함수들을 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
컴포넌트에도 리렌더링이 일어납니다.
구체적으로 말하면,
input
에 타이핑을 하면,Chat
컴포넌트는 리렌더링 됩니다.- 이 렌더링은
resetText
함수를 다시 작성합니다. onButtonClick
을props
로 받는Button
컴포넌트는 부모 컴포넌트가 렌더링 되었고,props
가 달라졌으므로 리렌더링됩니다.
그러면, 이를 useCallback
과 React.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
컴포넌트는 리렌더링 되지 않습니다.
useCallback의 문제점
그럼에도 불구하고 useCallback
이 가지고 있는 한계점이 있습니다. 우리가 위에서 useCallback
으로 메모한 함수는 디펜던시 어레이가 없었습니다. 그런데, useCallback
이 어떠한 특정 값에 따라 달라지는 함수라면 어떻게 될까요?
const Chat = () => { const [text, setText] = useState(""); const consoleText = useCallback(() => { console.log(Text); }, [Text]); // 이제 useCallback에 디펜던시 어레이가 생겼습니다. // 이 함수는 text가 달라질때마다 다시 생성됩니다. return ( <React.Fragment> <input value={text} onChange={(e) => setText(e.target.value)} /> <Button onButtonClick={consoleText}>버튼</Button> </React.Fragment> ); }; const Button = React.memo(({ onButtonClick }) => { return <button onClick={onButtonClick}>버튼</button>; });
input
에 타이핑만을 하고, 이를 프로파일러를 통해 확인해본 결과 Button
컴포넌트는 계속해서 리렌더링이되고 있습니다. 사실, 이는 당연한 결과입니다.
const [text, setText] = useState(""); const onButtonClick = useCallback(() => { console.log(text); }, [text]); // useCallback의 디펜던시 어레이의 값이 바뀌게 되면, 함수를 재생성합니다.
useCallback
의 디펜던시 어레이에 있는 값을 기반으로 useCallback
은 함수를 재생성할지, 말아야할지 판단하기 때문입니다. 값이 변하지 않았다면 함수를 재생성하지 않았겠지만 우리는 input
의 입력을 통해 text
를 언제나 만들어내고 있습니다. text
가 바뀌었으므로 함수는 상태가 바뀔때마다 재생성되고 이는 결과적으로 memo
를 사용하더라도 props
가 달라졌다고 판단됩니다.
그러면, 극단적으로 useCallback
의 디펜던시를 빼버립시다.
그러면 Button
컴포넌트는 메모됩니다. 그런데, Button
컴포넌트의 onButtonClick
에 문제가 생깁니다. text
의 최신값을 반영하지 못합니다.
useCallback
을 다시 생각해봅시다. useCallback
의 디펜던시 어레이를 비어둠으로써 우리는 함수 재생성을 막았으나, 이는 text
라는 최신의 상태를 반영하지 못합니다. 또 이를 위해 useCallback
의 디펜던시에 text
를 넣는다면 text
가 바뀔때마다 함수를 재생성해 Button
컴포넌트는 리렌더링됩니다.
결과적으로 우리는 함수의 메모리 주소를 고정시켜 Button
컴포넌트의 리렌더링은 막고싶지만, text
의 최신의 상태값은 반영하고 싶습니다. 그러면 이를 어떻게 해결할 수 있을까요?
useEvent
useEvent
는 현시점에 experimental입니다. 하지만, 대부분의 오픈소스들에는 useCallbackRef
등의 이름으로 사용되고 있습니다. 일단 결과를 확인해 봅시다. 이 훅을 쓰면 우리는 위의 문제를 해결할 수 있을까요? 일단 결과를 먼저 확인해 보겠습니다.
const Chat = () => { const [text, setText] = useState(""); const consoleText = useEvent(() => { console.log(Text); }); return ( <React.Fragment> <input value={text} onChange={(e) => setText(e.target.value)} /> <Button onButtonClick={consoleText}>버튼</Button> </React.Fragment> ); }; const Button = React.memo(({ onButtonClick }) => { return <button onClick={onButtonClick}>버튼</button>; });
역시 위와 똑같은, 방법을 통해 type
을 입력할때는 프로파일러로 확인해 보고 추후에 버튼을 클릭해 최신값을 반영하고 있는지 확인해 보았습니다. 둘 모두가 해결됐습니다. 그런데 어떻게 이렇게 가능했을까요?
아래는 radix-ui
에서 사용하고 있는 useCallbackRef
훅입니다.
function uesCallbackRef(callback) { const callbackRef = useRef(callback); React.useEffect(() => { callbackRef.current = callback; }); return React.useMemo( () => (...args) => callbackRef.current?.(...args), [], ); }
(용어의 통일성을 위해, 아래부터 useEvent
로 통일합니다. useEvent
는 이러한 동작을 기반으로 합니다.)
ref
를 생성합니다. 그리고 이 ref는 같은 메모리 공간에서 항상 고유한 ID를 가집니다.- 이를 위해
useEffect
를 실행하고,ref.current
에callback
을 넣습니다. - 그리고 이 과정을 렌더링마다 실행시켜,
ref.current
의callback
을 최신화합니다.
→ 이 과정에서 아무런 리렌더링도 일어나지 않습니다. ref
는 컴포넌트의 리렌더링을 트리거 하지 않으니까요.
- 그리고, 이
ref
을useMemo
로 메모이제이션 합니다.
→ 디펜던시 어레이가 비어져 있어도 괜찮습니다.
결과적으로 이 ref
는 언제나 callback
의 최신값을 반영하지만, 렌더링에 영향을 끼치지 않습니다.
그래서 우리는 위와 같은 useCallback
의 문제를 해결할 수 있습니다.
useEvent 어떻게 사용할까?
위와 같은 useEvent
를 또 어떻게 사용할 수 있을까요? 상태의 최신값을 반영할 수 있지만, 렌더링에 영향을 끼치지 않는 다는 점은 굉장히 유용해 보입니다.
Avoid useEffect
export default function Timer() { const [count, setCount] = useState(0); const [increment, setIncrement] = useState(1); useEffect(() => { const id = setInterval(() => { setCount(c => c + increment); }, 1000); return () => { clearInterval(id); }; }, [increment]); return ( <> <h1> Counter: {count} <button onClick={() => setCount(0)}>Reset</button> </h1> <hr /> <p> Every second, increment by: <button disabled={increment === 0} onClick={() => { setIncrement(i => i - 1); }}>–</button> <b>{increment}</b> <button onClick={() => { setIncrement(i => i + 1); }}>+</button> </p> </> ); }
(ref : https://beta.reactjs.org/learn/separating-events-from-effects )
위의 타이머를 실행시켜보면, 버튼을 클릭했을때 잠시 중단되는 현상을 보입니다. 이는, useEffect
의 디펜던시 어레이에 increment
가 존재하기 때문입니다. 그렇기 때문에, increment
가 달라졌을때 useEffect
가 실행됩니다. useEvent
를 이용해 이벤트를 분리하면 다음과 같이 작성할 수 있습니다.
export default function Timer() { const [count, setCount] = useState(0); const [increment, setIncrement] = useState(1); const onTick = useEvent(() => { setCount(c => c + increment); }); // useEvent를 통해 increment의 최신값 계속해서 반영합니다. useEffect(() => { const id = setInterval(() => { onTick(); }, 1000); return () => { clearInterval(id); }; }, []); // 이 useEffect는 렌더링되면 실행되지만, 디펜던시 어레이에 따라 리렌더링되지 않습니다. return ( <> <h1> Counter: {count} <button onClick={() => setCount(0)}>Reset</button> </h1> <hr /> <p> Every second, increment by: <button disabled={increment === 0} onClick={() => { setIncrement(i => i - 1); }}>–</button> <b>{increment}</b> <button onClick={() => { setIncrement(i => i + 1); }}>+</button> </p> </> ); }
이러한 방식을 통해서, 우리는 useEffect
를 조금 더 잘 사용할 수 있습니다. 위는 간단한 예시지만 만약 다음은 위와 같은 상황을 간략하게 작성한 코드입니다.
useEffect(() => { // v1, v2, v3 중 하나가 바뀌더라도 리렌더링됩니다. // 우리는 v1만을 의도했지만, v2 또는 v3 이 바뀌어도 effect 됩니다. , [v1, v2, v3]} // 이런 상황을 useEvent를 사용해, const callbackFn = useEvent(() => somthing about v3 ) useEffect(() => { callbackFn() , [v1, v2] } // 디펜던시 어레이를 줄여 필요하지 않은 리렌더링을 줄일 수 있습니다. // 하지만 useEvent를 통해 v3의 최신값은 항상 반영합니다. // 그리고 v1, v2 만 변경될때 effect 할 수 있습니다.
이를 통해 우리는 필요하지 않다고 생각하는 effect
를 막을 수 있습니다.
느낀점
이 문서의 작성을 시작할때 아주 쉽게 시작했습니다. 사실, useEvent
를 소개하기 위해 작성하고 있었습니다. 그런데 useEvent
를 설명하려면 useCallback
을 그리고 useCallback
을 소개하려면 React.memo
를 그리고 이를 위해서는 리액트의 렌더링 과정에 대해 꼼꼼히 알고 있어야 했습니다. 그래서 시간이 오래 걸렸습니다. 그래도 덕분에 제가 굉장히 잘못알고 있었던 많은 부분에 대해 알 수 있었습니다.
민망하지만, 이번에 다시 공부해보니 이전에 작성해두었던 React.memo
관련글은 잘못된 내용이 너무 많았습니다. 😅 (그래서 수정중입니다!) 블로그로 누군가와 공유하기를 좋아하지만 잘못된 정보를 제공하면 안되니까, 시간이 걸리더라도 정확한 정보를 전달하기 위해 노력해야겠습니다.