[React]useMemo, useCallback 알고 쓰자

hyunwoo Jin·2023년 5월 17일
0
post-thumbnail
post-custom-banner

들어가기 전 혼잣말

이번 글에는 useMemo useCallback 이 뭐하는 놈들인지 어떨 때 사용하게 될 지 공부해보고자 합니다. 아무래도 state는 상태관리를 하기 위해서는 필수적인 요소기 때문에 사실 익숙해 있었습니다. useEffect 역시도 특정 값에 대한 트리거로 사용했었구요. useEffect 도 이 후 트리거성으로 사용하기보다 서버로부터 값을 동기화할때만 사용하는 것이 가장 바람직하다는 것을 알게 되었죠. 물론 트리거로 사용하는 방법이 올바르지 않다는 것은 아닙니다. 프론트엔드 개발에서는 렌더링의 횟수가 잦아지는 것은 성능과 직결되는 문제고 이를 기피해야 합니다. 때문에 불필요하게 트리거 작동이 많은 것은 결국 리렌더링을 야기시키겠다라는 의미겠죠. 정리하자면 hook에는 이러한 기능이 있고 다양하게 활용할 수 있지만 사용을 최소화해야한다. 라는 성능최적화적인 마인드를 탑재하고 본론으로 넘어가봅시다.

useMemo

useMemo는 연산된 값이 컴포넌트 업데이트 시에도 변화가 없을 시 재연산 하지 않고 동일한 값을 사용하기 위한 react hook입니다.useEffect와 동일하게 두개의 인자를 받을 수 있습니다.첫번째 인자로는 콜백함수를 받고 두번째 인자는 의존성배열(dependency Array)을 받습니다.

example

  • before

    const Card = ({ width,height }) => {
    //   const z = useMemo(() => compute(x, y), [x, y]);
      const located = locate(width,height);
      const [ count, setCount ] = useState(0);
    
      const onClick = () => {
        setCount(count + 1)
      }
    
      return (
        <div>
            <button onClick={onClick}>추가</button>
            <span>좌표 : {located}</span>
        </div>
    );
    }

    Card 라는 컴포넌트는 부모컴포넌트로부터 width 와 height 라는 props를 전달받습니다. 그리고 locate라는 함수에서 width,height를 인자로 받아 아주 복~~~잡한 연산을 하고 있습니다.

    • button을 클릭할 경우 setCount가 실행되고 Card가 업데이트됩니다.
    • count이 새로운 값으로 변경되고 located도 다시 함수를 통해 값이 연산될 것입니다.
    • 컴포넌트 생명주기에 대해 알고 계신다면 이는 정말 자연스러운 현상일 것입니다.
    • 하지만 한편으론 불편한 현상일 수 있습니다. locate()는 렌더링에 영향을 줄만큼 아주 복~~~잡한 연산을 하는 함수입니다. count 값의 변화로 인해 전혀 상관없는 located의 값 역시 새로이 연산되고 있었습니다. 이 외에도 다른 state가 존재한다면 located를 위한 복잡한 연산은 더 잦아질 것입니다.

    이를 어떻게 해결할 수 있을까요?

  • after

    import { useMemo } from "react"; // *
    
    const Card = ({ width,height }) => {
    
      const located = useMemo(() => { // *
        locate(width, height);
      }, [width, height]);
    
      const [ count, setCount ] = useState(0);
    
      const onClick = () => {
        setCount(count + 1)
      }
    
      return (
            <div>
                <button onClick={onClick}>추가</button>
                <span>좌표 : {located}</span>
            </div>
        );
    }
    • useMemo 내에서 locate함수를 실행시키도록 변경해주었습니다.
    • 의존성 배열에 width 와 height를 넣어 해당 값의 변화를 감지하게 설정했습니다.
    • 이제 부모 컴포넌트에서 변경된 props(width,height)가 내려오지 않는 이상 컴포넌트가 업데이트되어도 located의 값을 재연산 하지 않게 됩니다.

before 의 코드에서는 located를 위한 복잡한 연산이 진행되기 때문에 상태가 업데이트될 때마다 매번 렌더링이 지연될 것입니다.
한편 after 의 코드에서는 Card 컴포넌트의 최초 렌더링 이 후 props가 변하지 않는 이상 빠르게 렌더링될 것입니다.

🙋‍♂️ 어, 그럼 연산값을 가진 모든 변수를 useMemo로 관리하면 되나요?

  • 그렇지 않습니다. useMemo을 비롯한 hook 역시 캐시에 접근하기 위한 api함수기 때문에 당연히 메모리를 사용합니다. 고로 복잡하고 무거운 연산이 아닌 값에 대해 useMemo를 사용하는 것은 배보다 배꼽이 더 큰 상황이라고 할 수 있습니다.

🤷‍♂️ 복잡하고 무거운 연산을 판단하는 기준이 뭐에요?

  • 저 역시 기준이 궁금했습니다. 이러한 기준은 공식 문서를 통해 알 수 있었습니다. 공식 문서에서는 1ms 이상을 예시로 하여금 상당한 양의 연산을 판단했습니다.
    console.time('filter array');
    const visibleTodos = filterTodos(todos, tab);
    console.timeEnd('filter array');
  • console.time console.timeEnd 메서드를 통해 코드의 실행 시간을 확인할 수 있습니다.

하지만 여기서 간과하지 말아야할 점은 우리의 시스템이 사용자들의 시스템보다 빠를 수 있다는 것 입니다. 때문에 상대적으로 낮은 성능의 시스템에서 테스트를 거쳐 모든 사용자에게 원활한 서비스를 제공해야 합니다.

이를 위해chrome에서 제공하는 CPU 스로틀링 옵션을 이용할 수 있습니다. 스로틀링 이란 기기의 CPU가 지나치게 과열되었을 때 클럭과 전압의 성능을 낮추어 열을 줄이는 기능을 말합니다. chrome 에서 자체적으로 성능을 제어할 수 있는 기능을 제공하나 봅니다. hook 공부하다가 스로틀링까지;
뿐만 아니라 개발환경에서 측정한다고 정확한 결과가 나오는 것도 아닙니다. 개발 환경에서 strict mode를 사용할 경우 두번의 렌더링이 발생할 수 있고 배포와 다른 결과를 초래할 수 있다고 합니다. 공식 문서에서는 프로덕션 앱 사용을 권장하고 있습니다. 고려해야할 부분이 상당히 많네요..--;
프로덕션 앱은 useMemo를 실전에 도입하게 될 때 좀 더 알아보도록 하겠습니다.

useMemo는 언제 써야하나?

useMemo 은 복잡한 연산의 결괏값을 재사용할 때 사용하는 hook입니다. 하지만 연산비용을 고려하지 않고useMemo를 남용하여 무분별한 재활용의 사례가 실제로 많다고 합니다.useMemo가 적용된 레퍼런스는 재활용을 위해 가비지컬렉션에 포함되지 않게 됩니다. 필자도 useMemo의 용법을 알고 프로젝트에 적용해보려 했으나 생각보다 useMemo 가 도입되기 위한 조건을 만족하는 경우는 극히 드물었습니다. 우선 useMemo 를 도입할만큼의 무겁고 복잡한 연산이 없었습니다. 렌더링 속도에 영향을 줄만큼 복잡한 연산이라면 일반적으로 useEffect를 이용한 비동기 처리를 먼저 고려하게 되기도 하구요.

결론적으로 useMemo는 컴포넌트 내에서 복잡한 연산을 요구하는 결과값을 동기적으로 할당해야하고 다른 값에 의해 불필요한 재연산을 야기하는 경우 에 사용을 고려해 볼 수 있다고 정리할 수 있습니다.

useMemo 리액트 공식 문서

useCallback

이번엔 useCallback에 대해 알아볼 차례입니다.
useMemo는 복잡한 연산의 결과값에 대한 재활욜을 위한 hook이라면 useCallback은 정의된 함수를 재활용하기 위한 hook 이라고 할 수 있습니다. 공식 문서에서는 컴포넌트가 업데이트되는 과정에서 함수 정의를 캐시할 수 있는 hook 이라 말하고 있습니다. useCallback 역시 재활용을 목적으로 하기 때문에 useMemo처럼 도입에 대한 명확한 근거가 필요합니다. 잠깐! 근거를 알아보기 전에 우리는 javascript 의 함수 동등성 에 대해 짚고 넘어가야 합니다.

javascript 함수 동등성?

const add1 = () => console.log(‘ㅎㅇ’);
const add2 = () => console.log(‘ㅎㅇ’);
console.log(add1 === add2) // console : false

add1add2 는 같은 결과값을 리턴하지만 비교했을 때 불일치로 판단합니다. 원시값이아닌 참조값(객체,배열,함수)을 할당할 경우 해당 식별자는 해당 값의 메모리주소값을 가지기 때문입니다. 고로 add1 === add2 는 각각 다른 메모리영역의 주소값을 비교하게 됩니다. 이를 참조 비교라고 합니다. javascript가 함수의 동등함을 판단하는 기준을 알아야 useCallback을 사용하는 이유에 대해서 더 명확하게 알 수 있습니다.

  • example 1

    import {useState,useEffect} from 'react'
    import axios from 'axios'
    
    const page = ({ regionId }) => {
      const [ roomList, setRoomList ] = useState<RoomType[]>([]);
    
      const getRoomListApi = () => {
        axios.get(`room/${regionId}`)
      }
      useEffect(() => {
        getRoomListApi().then((res) => {
          setRoomList(res.data.room)
        });
      },[getRoomList])
    }

    예시에서 page 컴포넌트에서는 부모컴포넌트로부터 받은 regionID를 api통신 url로 사용하고 있습니다. 그리고 useEffect 의 의존성 배열에 getRoomList 를 넣어주었습니다. 컴포넌트 업데이트 시 regionId 에 의해 getRoomList 가 변경될 경우에만 useEffect 내 콜백함수를 실행 하도록 의도한 것을 볼 수 있습니다.

    여기서 javascript 함수 동등성을 몰랐다면 의도와 다른 동작을 하게 되고 원인을 파악하기 힘들었을 것입니다.

    • 컴포넌트 업데이트 시 useEffectgetRoomList의 이전 값과 현재 값을 비교할 것입니다.
    • 하지만getRoomList가 참조하는 함수는 매번 새로운 메모리 영역을 할당하기 때문에 getRoomList 역시 매번 다른 메모리주소값을 가지게 됩니다.
    • 고로 javascript 함수 동등성에 따라 getRoomList의 전 후를 비교할 시 regionId 의 변화와 관계없이 false로 판단합니다.

    🤷‍♂️그래서 어쨌다고?

    • 결국 useEffect의 콜백함수는 컴포넌트 업데이트 시 매번 실행될 것입니다. regionId 가 변하지 않는 데도 불구하고요.

    🤦‍♂️그러면 의존성 배열에 regionId 를 넣으면 되잖아요?

      useEffect(() => {
        getRoomListApi().then((res) => {
          setRoomList(res.data.room)
        });
      },[regionId])
    • 어..그렇죠! 의존성 배열을 regionId 로 바꾸면 비로소 의도에 맞게 작동할 것입니다. 또 getRoomList보다 더 직관적이기도 하구요.
      하지만! 이제 useCallback을 사용하는 진짜 이유를 알아보겠습니다.
  • example 2

      import {useState,useEffect} from 'react'
      import axios from 'axios'
    
      const page = ({ regionId }) => {
        const [ roomList, setRoomList ] = useState([]);
    
        const getRoomListApi = () => {
          axios.get(`room/${regionId}`)
        }
        useEffect(() => {
          getRoomListApi().then((res) => {
            setRoomList(res.data.room)
          });
        },[regionId]) // regionId 로 변경
      }

    의존성 배열을 regionId로 변경해 콜백함수가 의도에 맞게 동작하게 되었습니다. 여기서 useEffect 를 사용하는 이유는 regionId의 변화에 따라 함수를 실행시켜 메모리 누수를 방지 하기 위함입니다. 우리는 같은 이유로 useCallback 을 이용해 getRoomListApi 도 재활용하고자 합니다.

  • example 3

       import {useCallback,useState,useEffect} from 'react'
       import axios from 'axios'
    
       const page = ({ regionId }) => {
         const [ roomList, setRoomList ] = useState([]);
    
         const getRoomListApi = useCallback(()=> {
           axios.get(`room/${regionId}`)
         },[regionId])
         
         useEffect(() => {
           getRoomListApi().then((res) => {
             setRoomList(res.data.room)
           });
         },[getRoomListApi])
       }

    useCallback을 이용해 getRoomListApi 를 생성했습니다. 의존성 배열을 이용해 컴포넌트가 업데이트될 때 regionId 의 값이 동일할 경우getRoomListApi 를 재활용됩니다. 이로써 불필요한 메모리 사용을 방지할 수 있습니다.

물론 useCallback 도 함수이기 때문에 추가적인 메모리 사용이 있고 메모리 비용을 고려하여 도입해야 합니다. 하지만 공식 문서에서는 useCallback을 남용하는 것에 대해 useMemo에 비해 크게 주의하지 않는데요.

뇌피셜
개인적인 생각은 useCallback은 함수 자체를 캐싱하기 때문에 useCallback이 제 역할을 하지 않더라도 손실이 크지 않아서가 아닐까 생각합니다. 반면에 useMemo의 경우 함수의 호출 결과 를 캐싱하기 때문에 useCallback에 비해 적절하게 사용했느냐에 따라 하이리턴이 발생하지 않을까 싶습니다.

물론 useCallback 역시 무차별적으로 사용되면 코드 가독성을 해칠 수 있습니다.

마무리

useMemouseCallback에 대해 공부해봤습니다. 두 hook 의 주된 사용 이유는 재활용을 위한 성능 최적화를 위해서 입니다. 하지만 이 역시 적절하게 사용하지 않으면 성능에 부정적인 영향을 끼칠 것입니다. 그렇기 때문에 우리는 statehook 이 꼭 필요한 지에 대해 신중하게 고민하고 사용을 최소화해야 한다고 생각합니다.

리액트 공식문서

profile
꾸준함과 전문성
post-custom-banner

0개의 댓글