React 프로젝트를 개발하다 보면 성능 최적화를 위해 useMemo나 useCallback을 사용하는 경우가 많습니다.

특히 코드 리뷰에서 다음과 같은 코드를 자주 볼 수 있습니다.

const handleClick = useCallback(() => {
    setCount(count + 1)
}, [count])

const total = useMemo(() => {
    return price * quantity
}, [price, quantity])

언뜻 보면 성능을 고려한 좋은 코드처럼 보입니다.

하지만 실제로는 필요하지 않은 곳에서 useMemo, useCallback을 사용하는 것이 오히려 코드 복잡도를 높일 수 있습니다.

이번 글에서는 언제 사용해야 하고, 언제 사용하지 않는 것이 좋은지 정리해보겠습니다.


useMemo는 무엇을 하는가?

useMemo는 특정 연산의 결과를 저장해두고 dependency가 변경될 때만 다시 계산합니다.

const filteredList = useMemo(() => {
    return list.filter(item => item.active)
}, [list])

list가 변경되지 않았다면 이전에 계산했던 filteredList를 재사용합니다.

 

따라서 계산 비용이 큰 작업에서는 도움이 될 수 있습니다.

예시)

const result = useMemo(() => {
    return veryLargeArray
        .filter(item => item.enabled)
        .sort((a, b) => b.score - a.score)
        .map(item => expensiveTransform(item))
}, [veryLargeArray])

데이터가 많고 계산 과정이 복잡하다면 useMemo를 사용하는 의미가 있습니다.

 

단순 계산에는 굳이 필요하지 않다

반대로 아래 코드는 최적화 효과가 거의 없습니다.

const totalPrice = useMemo(() => {
    return price * quantity
}, [price, quantity])

곱셈 한 번을 줄이기 위해 React가 dependency를 비교하고 memo 값을 관리하게 됩니다.

이 경우에는 아래처럼 작성하는 것이 코드도 읽기 쉽고 관리하기도 좋습니다.

const totalPrice = price * quantity
계산 비용 <<  memoization 관리 비용

useCallback도 같은 문제를 가진다

useCallback은 함수를 메모이제이션합니다.

const handleClick = useCallback(() => {
    setCount(prev => prev + 1)
}, [])

하지만 일반적인 이벤트 핸들러라면 다음처럼 작성해도 대부분 문제가 없습니다.

const handleClick = () => {
    setCount(prev => prev + 1)
}

React 컴포넌트가 다시 렌더링되면 함수도 다시 생성됩니다.

하지만 대부분의 애플리케이션에서는 함수 하나 생성되는 비용이 성능에 영향을 줄 정도로 크지 않습니다.

 

useCallback이 실제로 필요한 경우

대표적인 경우는 React.memo와 함께 사용할 때입니다.

const Child = React.memo(({ onClick }) => {
    return <button onClick={onClick}>클릭</button>
})

부모 컴포넌트에서 다음처럼 함수를 작성하면

const handleClick = () => {
    console.log('click')
}

return <Child onClick={handleClick} />

부모가 렌더링될 때마다 새로운 함수가 생성됩니다.

그러면 Child 입장에서는 onClick prop이 변경된 것으로 판단될 수 있습니다.

이런 경우에는 useCallback이 의미가 있습니다.

const handleClick = useCallback(() => {
    console.log('click')
}, [])

함수 reference가 유지되기 때문에 불필요한 자식 컴포넌트 렌더링을 줄일 수 있습니다.


 

실무에서 판단하는 기준

저는 React 프로젝트에서 다음 기준으로 판단하는 것이 가장 현실적이라고 봅니다.

1. 계산량이 큰가?

단순 문자열 조합이나 사칙연산이라면 useMemo가 필요하지 않습니다.

const fullName = `${firstName} ${lastName}`

반대로 대량 배열 처리나 복잡한 연산이라면 고려할 수 있습니다.


2. React.memo 컴포넌트에 전달되는 값인가?

memoized component에 object나 function을 전달한다면 reference 유지가 중요할 수 있습니다.

예를 들어

const options = {
    pageSize: 20
}

이 코드는 렌더링될 때마다 새로운 객체를 생성합니다.

필요하다면 다음과 같이 사용할 수 있습니다.

const options = useMemo(() => ({
    pageSize: 20
}), [])

3. 실제 성능 문제가 있는가?

가장 중요한 기준입니다.

성능 문제가 확인되지 않았는데 미리 모든 코드를 최적화하면 오히려 코드가 복잡해집니다.

다음과 같은 순서가 좋습니다.

  1. 먼저 단순하게 구현한다.
  2. 실제 렌더링 문제가 있는지 확인한다.
  3. React DevTools Profiler로 병목을 확인한다.
  4. 필요한 부분만 최적화한다.

측정 후 최적화하는 것이 좋습니다.


오히려 과도한 최적화가 유지보수를 어렵게 만든다

다음과 같은 코드가 프로젝트 전체에 반복되기 시작하면

const value = useMemo(() => {
    return a + b
}, [a, b])

const handleChange = useCallback(() => {
    setValue(...)
}, [...])

개발자는 계속 dependency를 관리해야 합니다.

dependency를 잘못 설정하면 stale closure 같은 버그가 발생할 수도 있습니다.

결과적으로 성능 개선보다 유지보수 비용이 더 커질 수 있습니다.

 


정리

useMemouseCallback은 React에서 유용한 최적화 도구입니다.

하지만 모든 곳에 사용하는 것이 좋은 것은 아닙니다.

실무에서는 다음 기준 정도로 생각하면 됩니다.

  • 단순 계산 → 그냥 계산
  • 일반 이벤트 핸들러 → 일반 함수
  • 무거운 연산 → useMemo 고려
  • React.memo 자식에 함수 전달 → useCallback 고려
  • 실제 렌더링 병목 → Profiler 확인 후 최적화

React 성능 최적화에서 중요한 것은 hook을 많이 사용하는 것이 아니라,

필요한 곳에만 사용하는 것입니다.

반응형

+ Recent posts