조금 부끄러운 이야기지만, 1년이 넘도록 리액트를 쓰면서 아직까지도 useCallback
에 대한 정확한 개념이 서 있지 않다. 그래, useCallback
이 함수를 재사용하는 걸 알겠는데, 그래서 어쩌라고? 라는 나를 위해서 다시한번 정리해본다.
조금 다른 미친 이야기로 넘어와서, 만약 모든 콜백 함수를 memoize 한다면, callback 함수를 사용하는 모든 자식 컴포넌트들이 불필요한 리렌더링을 막을 수 있지 않을까? 당연히 이 글을 읽는 훌륭한 개발자 분들은 말도 안되는 것임을 알 것이고, 이는 성능에 악 영향을, 그리고 컴포넌트를 느리게 한다는 것을 알 것이다.
본론으로 들어가기전에, 함수의 동일성 체크에 대해 알아보자.
function sum() {
return (a, b) => a + b
}
const sum1 = sum()
const sum2 = sum()
sum1(1, 2) // 3
sum2(1, 2) // 3
sum1 === sum2 // false
sum2 === sum2 // true
sum1
과 sum2
는 동일한 코드 소스를 공유하고 있지만, 리턴하는 오브젝트가 다르다. 따라서 두 개를 비고 하면 false
를 리턴한다. 당연한 이야기이지만, object는 오로지 자기 자신만 동일하다.
이와 비슷하게, 리액트 컴포넌트에서도 동일한 소스코드로 다른 함수 인스턴스가 생성되는 경우가 종종 있다.
import React from 'react'
function MyComponent() {
// handleClick 은 렌더링 될 때 마다 새로 생성된다.
const handleClick = () => {
console.log('Clicked!')
}
// ...
}
handleClick
은 MyComponent
가 새롭게 렌더링 될 때마다, 다른 함수 오브젝트가 된다. 다행히도, 인라인 함수를 만드는 비용은 그다지 비싸지 않아서, 매번 새롭게 만드는 것은 큰 비용이 아니다. 다시 말해, 컴포넌트 내부에 있는 몇개의 인라인 함수정도는 괜찮다.
그러나, 이와 반대로 한가지 함수 인스턴스를 유지해야 하는 경우가 있다.
React.memo()
또는 shouldComponentUpdate)
내부에 있는 컴포넌트가 callback prop을 받을 때useEffect(..., [callback])
이러한 경우에 useCallback
이 빛을 발한다. deps
에 같은 값이 주어질 때마다, hook은 렌더링 시에 완전히 같은 함수 인스턴스를 리턴한다.
import React, { useCallback } from 'react'
function MyComponent() {
// handleClick 은 완전히 같은 오브젝트다.
const handleClick = useCallback(() => {
console.log('Clicked!')
}, [])
// ...
}
handleClick
변수는 MyComponent
가 렌더링 될 때마다 같은 콜백 함수를 가진 오브젝트를 갖게 된다.
컴포넌트가 큰 사이즈의 items을 렌더링 한다고 가정해보자.
import React from 'react'
import useSearch from './fetch-items'
function BigList({ term, handleClick }) {
const items = useSearch(term)
const element = (item) => <div onClick={handleClick}>{item}</div>
return <div>{items.map(element)}</div>
}
export default React.memo(BigList)
items
가 정말 많다고 가정하고, 재 렌더링을 방지하지 위하여 React.memo
를 사용했다. 만약 BigList
에서 아이템을 클릭할 때 실행할 핸들러 함수가 필요하다고 가정해보자.
import React, { useCallback } from 'react'
export default function Parent({ term }) {
const handleClick = useCallback(
(item) => {
console.log('You clicked ', item)
},
[term],
)
return <BigList term={term} handleClick={handleClick} />
}
이렇게 하게 되면, handleClick
은 useCallback()
에 memozied 된다. term
의 값이 같은 이상, useCallback()
은 항상 같은 함수 인스턴스를 반환할 것이다.
심지어 Parent
가 리렌더링 되더라도,BigList
의 memoziation이 깨지지 않은 이상 항상 같은 함수를 리턴할 것이다.
이제 다시 앞선 예제로 와보자.
import React, { useCallback } from 'react'
function MyComponent() {
const handleClick = useCallback(() => {
// handle the click event
}, [])
return <MyChild onClick={handleClick} />
}
이 경우는 어떤가? MyComponent
가 렌더링 될 때 마다 useCallback()
훅이 호출 될 것이다. 내부적으로 리액트는 같은 오브젝트 함수를 리턴하기 위해 노력할 것이다. 그렇다고 할지라도, 인라인 함수는 여전히 매번 렌더링 될 때마다 만들어진다. 왜냐하면, MyComponent
는 특별히 memozied 되지 않았기 때문에, 그 자체 만으로도 이미 매번 렌더링을 하게 된다.
useCallback()
이 같은 함수 인스턴스를 보장해주지만, 이는 아무런 이득이 없다. 왜냐하면 최적화의 비용이 최적화 하지 않는 비용보다 더 크기 때문이다. 또한 코드의 복잡성도 증가하게 된다. useCallback()
의 deps에 실제로 memozied할 callback에서 사용하는 것들을 반드시 넣어두어야 한다.
모든 최적화는 복잡성을 증가시킨다. 섣불리 추가된 최적화는 최적화된 코드를 여러번 변경할 수 있기 때문에 위험하다. 성능을 최적화 하기 전에 문제를 프로파일링 해야 한다. useCallback
의 적절한 예시는 메모화된 자식 컴포넌트에 제공되는 콜백 함수를 memozie하는 것이다.