[React] useCallback의 문제점

2022. 5. 8. 15:30공부내용 공유하기

React 16.8에서 소개되었던 Hook API 중에서는 useRef는 제외한다면 최적화와 관련된 훅이 2개(useMemo, useCallback)가 있다.

 

 

Hooks API Reference – React

A JavaScript library for building user interfaces

ko.reactjs.org

 

그중에서도 useCallback은 함수를 메모이제이션 하기 위해 사용하는 훅이다.

함수를 메모이제이션 하는 목적은 대개 성능의 최적화이고, 특히 리액트에서는 주로 메모이제이션 컴포넌트의 props로 넘기는 이벤트 핸들러 등의 함수가 불필요하게 재 생성되어 불필요한 렌더링이 일어나지 않게끔 사용한다.

그런데 우리가 useCallback을 사용하는 경우를 생각해보면, 빈 의존성 배열로 사용하는 경우는 그렇게 많지 않다.

 

state 혹은 props 값에 의존하는 함수라면? 최적화의 대상이 되는 함수는 대개 내부에서 state 혹은 props의 값을 바탕으로 무엇인가를 해줘야 할 경우가 많다.

여기서 useCallback 훅의 문제점이 나타나는데, useCallback은 해당 함수가 재 생성되어야 하는 의존성과, 함수 내부에서 Closure로 참조 가능한 의존성을 구별해서 받을 수 없다.

 

무슨 말인고 하니, '나는 이 함수를 어떤 상황에서 새로 생성 하겠어' 라는 의도와, '이 함수에서는 이런이런 값을 사용하겠어'가 분리되지 않는다는 뜻이다.

 

위 예시의 handleClick 메소드에서 data를 사용하기 위해서는, 참조 배열(dependancy array)에도 data를 주입해줘야 한다. 만약 최적화를 위해 참조 배열을 비워둔다면, 콘솔에 찍히는 data는 컴포넌트의 마운트 이후 절대 변하지 않을 것이다.

 

작은 예시를 하나 들어보자.

 

 

내가 만약 checkSum 내부에서 number, prefix 값을 모두 사용하고 싶은데, 함수의 재 생성은 prefix에만 의존적이게 하고 싶다면? 커링 형태로 사용하거나 인자를 받는 방법이 있겠지만 현재의 형태로는 불가능하다.

 

2018년에 깃헙 리액트 레포에 올라온 이슈를 보면 이러한 부분들을 지적하고 있다.

콜백 내부의 메소드-편의를 위해 앞으로는 콜백 함수라고 칭하겠다-가 원래 개발자의 의도와는 다르게 너무 자주 무효화되어 실 사용 사례에서 문제가 있다는 내용이다.

 

한 번 읽어보면 좋다.

 

 

useCallback() invalidates too often in practice · Issue #14099 · facebook/react

This is related to #14092, #14066, reactjs/rfcs#83, and some other issues. The problem is that we often want to avoid invalidating a callback (e.g. to preserve shallow equality below or to avoid re...

github.com

 

2018년에 제기된 내용으로 좀 오래 된 내용이기도 하고, 리액트 커뮤니티 측에서도 이러한 사용 사례에서 문제가 될 수 있다고 인지하고 있어 이에 대한 해결책으로 크게 두 가지의 해결 방법을 제시하고 있다.

 

 

Hooks FAQ – React

A JavaScript library for building user interfaces

reactjs.org

 

 

첫 번째는 Context API + useReducer를 사용해서 콜백 함수를 외부로 분리하고, 컴포넌트 내부에서는 dispatch로 해당 콜백 함수를 호출하는 것이다. 쉽게 말해 redux의 dispatch를 생각하면 된다.

 

개별 컴포넌트에서는 주입받은 dispatch를 사용함으로써 개별 콜백 함수의 최적화에 대해 신경 쓰지 않아도 되고(dispatch 함수는 렌더링 시에 바뀌지 않는다) 더불어 Props Drilling도 방지할 수 있다.

 

내가 생각하는 이 방법의 문제점은 다음과 같다.

 

1. 관리 포인트가 늘어난다. 개별 컴포넌트 스코프에서 관리되는게 편한 콜백 함수까지도 외부에서 주입받는 형태로 구성되면서 불필요한 계층구조가 생길 수 있다. 테스트시에 내부 구현을 모킹 하기에는 좀 편해질지도?

 

2. 리액트의 Context API는 상태 관리 API가 아니라 의존성 주입을 위한 API로 보는 것이 더 적절하다는 시각이 있으며 개인적으로 동의하는 바이다.


React Context가 상태 관리 도구가 아닌 이유

 

React Context for Dependency Injection Not State Management

Dive into the concept that React Context API is primarily a tool for injecting dependencies into a React tree and how we can use that to improve testability, reusability, and maintainability of our …

blog.testdouble.com

 

콜백 함수에서 state에 대한 업데이트가 필요하다고 할 때, 해당 state에 대한 정보는 createContext로 생성된 context 내부의 state에 존재하게 된다. 만약 해당 state가 조금이라도 렌더링에 사용된다면? 싹 다 불필요한 리 렌더링이 생기게 된다.

 

두 번째 방법은 useRef를 사용하는 것이다.

 

In some rare cases you might need to memoize a callback with useCallback but the memoization doesn’t work very well because the inner function has to be re-created too often. If the function you’re memoizing is an event handler and isn’t used during rendering, you can use ref as an instance variable, and save the last committed value into it manually:

In some rare cases??는 아닌 것 같지만... 아무튼 중요한 건 ref를 사용해서 콜백 함수의 주소 값은 고정하되, 의존성 배열에 들어간 값에 대해 클로저로 접근할 수 있게 되는 것이다.

https://reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback

뭐... 딱히 우아한 해결책인것 같지는 않고, 리액트에서도 권장하는 방법은 아니다.

In either case, we don’t recommend this pattern and only show it here for completeness. Instead, it is preferable to avoid passing callbacks deep down.

어쨌거나 이 방법에도 내가 생각하는 문제점은 다음과 같다.

 

1. 콜백 함수가 접근 가능한 인자에 대해서 의존성 배열로 직접 넣어주는 게 너무 싫다. 이 훅이 사용되어 있는 코드를 본 사람이 useCallback과 같은 형태로 구현된 이 훅을 보고 전혀 다른 기능을 한다는 걸 알기 쉬울까? 직관적이지 않은 훅이라는 생각이 들었다. 클로저로 접근 가능했던 값들을 사용하기 위해 다시 배열로 넣어줘야 한다니... 여전히 불편하다.

 

2. useCallback의 메모이제이션 케이스가 너무 잦다는 의견과는 별개로, 분명히 콜백 함수의 재 생성이 필요한 시점들이 있는데, 이 훅에서는 useCallback의 원래 기능을 포기하게 된다. 그렇다고 하나가 아닌 두 개의 Array를 인자로 받아서 각각 [접근 가능한 인자 배열], [의존성 배열] 이런 식으로 받을 생각을 하니 역시 아찔하다.

 

최근 이 문제에 대한 해결책으로 useEvent라는 이름의 훅이 제안되었다.

 

 

 

GitHub - reactjs/rfcs: RFCs for changes to React

RFCs for changes to React. Contribute to reactjs/rfcs development by creating an account on GitHub.

github.com

 

대략적인 구현 모습은 다음과 같다.

https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md#internal-implementation

 

위에서 두 번째로 제시된 해결책에서 구조적으로 크게 바뀐 부분은 없지만, 불필요하게 의존성 배열을 주입받는 부분이 제거되었다.

 

useLayoutEffect(실제 구현에서는 바뀔 가능성이 크다) 훅을 통해 매 렌더링마다 handler의 값을 최신화시켜주는 형태를 사용했다. 훅 사용시에는 클로저를 통한 외부 스코프의 변수 접근이 깔끔하게 가능해졌다.

 

https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md#basic-example

 

급진적인 사람들은 아예 useCallback의 의존성 배열이 비어있으면 내부적으로는 이렇게 동작하게끔 하자(...)는데, 사용 사례가 완전히 다를 수 있는데 그건 너무 섣부른 이야기인 것 같다.

 

위 훅에서 한 가지 아쉬운 점은, 옵셔널하게 배열 하나 넘겨서 함수 자체의 재 생성을 위한 종속성으로 주입해주면 좋겠다는 생각이 들었다.

 

그러면 대부분의 케이스에서 useCallback의 상위 호환으로 사용할 수 있을 것 같은데... 렌더 메소드 등에서는 useCallback을 사용하고 일반적인 이벤트 핸들링을 하는 함수들은 useEvent로 대체할 수 있지 않을까 싶다.

 

 

P.S.

 

RFC가 나온지도 얼마 안 되었는데, useEvent 관련해서 설명해주는 유튜브 영상이 나오다니...