avatar
Published on

useCallback 사용 가이드

Author
  • avatar
    Name
    yceffort

조금 부끄러운 이야기지만, 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

sum1sum2는 동일한 코드 소스를 공유하고 있지만, 리턴하는 오브젝트가 다르다. 따라서 두 개를 비고 하면 false를 리턴한다. 당연한 이야기이지만, object는 오로지 자기 자신만 동일하다.

이와 비슷하게, 리액트 컴포넌트에서도 동일한 소스코드로 다른 함수 인스턴스가 생성되는 경우가 종종 있다.

import React from 'react'

function MyComponent() {
  // handleClick 은 렌더링 될 때 마다 새로 생성된다.
  const handleClick = () => {
    console.log('Clicked!')
  }

  // ...
}

handleClickMyComponent가 새롭게 렌더링 될 때마다, 다른 함수 오브젝트가 된다. 다행히도, 인라인 함수를 만드는 비용은 그다지 비싸지 않아서, 매번 새롭게 만드는 것은 큰 비용이 아니다. 다시 말해, 컴포넌트 내부에 있는 몇개의 인라인 함수정도는 괜찮다.

그러나, 이와 반대로 한가지 함수 인스턴스를 유지해야 하는 경우가 있다.

  1. React.memo() 또는 shouldComponentUpdate) 내부에 있는 컴포넌트가 callback prop을 받을 때
  2. 함수가 다른 hooks 함수의 dependency로 사용될 때 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} />
}

이렇게 하게 되면, handleClickuseCallback()에 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하는 것이다.