useCallback과 React.Memo을 통한 렌더링 최적화

김혜진·2020년 4월 6일
98
post-thumbnail

실제로 사용해 보기 전까지는 이해하기 어려웠던 useCallbackReact.memo에 대해 작성해보려고 한다.

나를 포함한 많은 신입 개발자분들이 리액트 함수형 컴포넌트를 최적화하기 위해 shouldComponentUpdate와 같은 라이프 사이클 메소드를 구현하려 검색하다가 알게 되지 않을까 싶다. 아 참고로 React.memo는 훅스은 아니고 클래스형 컴포넌트에서 사용되던 PureComponent의 함수형 버전이라고 보면 된다.. 언젠가 클래스 컴포넌트가 좋았던 적도 있었던 것 같은데.. 흐려진 기억.. 하하하하

틀린 점들은 가감없이 지적 부탁드립니다.

reference

reference and value
아무튼 이야기하려는 hooks나 memo가 생겨나게 된 배경에는 참조(refence)라는 개념이 있다고 생각한다.

reference 개념을 설명하는 간단명료한 자료를 첨부해서 보자면

  • pass by reference는 한쪽에 커피를 채우면 다른 한쪽에도 동일한 커피가 채워진다. 컵은 2개이나 동일한 내부 값을 공유하고 있는 것이다.
  • 이와 달리 pass by value는 '실제로 새로운 컵이 생겨난 것이기 때문에' 한쪽에 커피를 채워도 다른 한쪽에 동일하게 커피가 채워지지 않는 것을 확인할 수 있다.

전자를 얕은 복사(shallow copy), 후자를 깊은 복사(deep copy)라 한다.

간단히 말하자면 같은 참조 값인가?란 같은 메모리에 할당된 값을 사용하는가?와 동일한 질문인 것 같다.

자바스크립트의 6가지 타입 중 객체를 제외한 원시 값들(number, string, null, undefiend..)은 참조 값이 아닌 값(value) 자체를 새로 할당하므로 자료의 2번째 컵과 동일하게 작동된다. 따라서 값이 변경되면 서로 다른 값을 가지게 된다.

// 원시값
let value = 1;
let value2 = value; // value2에 원시값 1이 '새로' 할당됨.
value2 = 2;

// 따라서 값이 변경되면 서로 다른 값을 가지게 됨
console.log(value); // 1 
console.log(value2); // 2 
value === value2 // false 다른 메모리에 할당되었으므로 false 값을 반환

 
반면 참조 타입인 객체는 자료의 1번째 컵과 동일하게 작동한다. 따라서 하나의 값이 변경되면 다른 하나의 값도 동일하게 변경된다.
더불어 참조 타입은 동일 참조 값이 아니라면 === Strict Equal Operator에서 false를 반환한다.

let obj = {number: 1};
let obj2 = obj; // 같은 메모리를 할당 (같은 참조 값을 가지는 것)
obj2.number = 2;


// 따라서 값이 변경되면 해당 값을 참조하고 있는 값도 변경됨
console.log(obj); // {number: 2} 
console.log(obj2); // {number: 2} 
obj === obj2 // true 같은 참조 값이므로 === true 값을 반환

// 참조 타입은 동일 참조 값이 아닌 경우 다른 값으로 취급된다
let arr = ['hello'];
let arr2 = ['hello'];
arr === arr2 // false 다른 참조 값이므로 === false를 반환
arr[0] === arr2[0] // true string은 원시 타입이므로 

그런데 이 참조 값이 무슨 상관이냐 하면은...

Shallow compare

아래와 같은 React가 리렌더링되는

  1. state 변경이 있을 때
  2. 부모 컴포넌트가 렌더링 될 때
  3. 새로운 props이 들어올 때
  4. shouldComponentUpdate에서 true가 반환될 때
  5. forceUpdate가 실행될 때

... 등등의 여러 상황이 있는데,

1, 2번의 경우 리액트는 얕은 비교를 통해 새로운 값인지 아닌지를 판단한 후 새로운 값인 경우 리렌더링을 하게 된다.

얕은 비교란 간단히 말하자면 객체, 배열, 함수와 같은 참조 타입들을 실제 내부 값까지 비교하지 않고 동일 참조인지(동일한 메모리 값을 사용하는지)를 비교하는 것을 뜻한다.
state에 push, pop, shite.. 등의 원본을 변형하는 메소드를 사용하면 안되는 이유이기도 하다.. immutable의 중요성!!!)

따라서 원시 값들은 서로 다른 참조 값을 가지고 있어도 비교함에 있어 문제가 없지만, (원시 타입 값은 참조 타입과 달리 서로 다른 메모리에 할당되어 있어도 값이 같다면 === Strict Equal Operator에서 true 값을 반환한다.) 객체(object), 배열(array), 함수(function)와 같은 객체들은 같은 참조 값이 아니라면 새로운 값으로 판단하는 것이다.

예를 들어 상위 컴포넌트의 state가 변경되면 > 상위 컴포넌트가 리렌더링 되며 하위 컴포넌트에 넘겨주는 props가 새롭게 생성되고 > props에 참조 타입이 있다면 동일한 값이라도 동일 참조 값이 아니므로 얕은 비교를 통해 새로운 값으로 판단하여 리렌더링을 일으키는 것이다.

즉, 객체나 배열, 함수 등의 props는 상황에 따라 불필요한 리렌더링이 발생할 수 있음을 뜻하는데, 이를 반대로 말하면 객체, 배열, 함수와 같은 참조 타입의 props들을 동일 참조 값으로 사용했을 때에는 리렌더링이 발생하지 않을 수 있다는 뜻이기도 하다.

이제 이야기 할 useCallback, React.memo는 위와 같은 상황을 방지하기 위해 사용되는 것이다. 서론이 길었다..


useCallback

왜 사용하는 것일까

현재 하위 컴포넌트에 전달하는 콜백 함수를 inline 함수로 사용하고 있다거나, 컴포넌트 내에서 함수를 생성하고 있다면 (프로그래밍 구동에는 문제가 없겠지만) 새로운 함수 참조 값을 계속해서 만들고 있는 것, 다시 말해 똑같은 모양의 함수를 계속해서 만들어 메모리에 계속해서 할당하고 있다는 것을 뜻한다. 이전에 작성한 코드들에 인라인 함수를 보이면 으윽 아악.. 하는 소리가 절로 흘러나온다.

의존성에 포함된 값이 변경되지 않는다면 이전에 생성한 함수 참조 값을 반환해주는 것이 useCallback이다. 최적화에 면에서 고마운 친구이다.

어떻게 사용하는 것일까

인라인 콜백과 그것의 의존성 값의 배열을 전달하세요. useCallback은 콜백의 메모이제이션된 버전을 반환할 것입니다. 그 메모이제이션된 버전은 콜백의 의존성이 변경되었을 때에만 변경됩니다. 이것은, 불필요한 렌더링을 방지하기 위해 (예로 shouldComponentUpdate를 사용하여) 참조의 동일성에 의존적인 최적화된 자식 컴포넌트에 콜백을 전달할 때 유용합니다.
리액트 문서 중

useCallback을 어느정도 이해한 지금은 문서 요약을 참 잘해놓았구나! 싶은데, 얼마 전까지만 해도 아 이제 내가 마더텅마저 잃었구나.. 0개 국어구나 하는 자괴감이 들었던 설명이다..

아무튼 어려운 단어들를 쪼개 순서대로 풀어 나열해보자.

위의 설명과 같이 useCallback인라인 콜백과 의존성 값의 배열 이렇게 2개의 인자를 받는다. 상대적으로 자주 사용해서 더 익숙한 useEffect과 유사한 모습이며 구동 방식도 비슷하다.

const memoizedCallback = useCallback(
  () => {doSomething(a, b);}, // inline callbck
  [a, b], // dependency
);

위의 코드에서 memoizedCallback에 할당되는 값은 a, b 값이 수정될 때에만 inline callback 이 새로 생성되는 함수이다. 그러니까 함수를 생성하는 함수인 것이다. 이때 의존성 값에 빈 배열을 주면 최초에 생성된 함수를 지속적으로 기억한다.

useEffect에 의존성 값이 있을 경우 componentDidUpdate, 빈 배열일 경우 componentDidMount와 같이 동작하는 것과 유사한 방식으로 이해할 수 있다.

다시 말해 useCallback은 최초에 혹은 특정 조건에서 생성한 함수의 참조를 기억하여 반환해주는 hook이다.

새로 생성되지 않는다함은 메모리에 새로 할당되지 않고 동일 참조 값을 사용하게 된다는 것을 의미하고, 이는 최적화된 하위 컴포넌트에서 불필요한 렌더링을 줄일 수 있다는 것을 뜻한다. 말은 쉽지만 단어의 나열만으로는 쉽게 이해할 수 없다.

적용 코드

useCallback 미적용

  1. 인라인 함수
    아래와 같이 child 컴포넌트의 클릭 이벤트로 콜백 함수를 넘겨주는 상황에서 인라인 함수를 사용하게 된다면 Child컴포넌트가 렌더되는 모든 시점에 새로 메모리에 할당되는 똑같은 모양의 인라인 함수들이 계속해서 재생성될 것이다. 딱 봐도 너무 비효율적이쥬?
const Root = () => {
  return (
    <>
      <Child onClick={() => console.log('callback')}/> // 똑같은
      <Child onClick={() => console.log('callback')}/> // 함수가
      <Child onClick={() => console.log('callback')}/> // 계속해서
      <Child onClick={() => console.log('callback')}/> // 재생성
      <Child onClick={() => console.log('callback')}/> // 심지어 매 렌더마다..
	  ...
    </>
  );
};
// 렌더되는 Child 컴포넌트의 수 만큼 인라인 함수가 생성된다. 
const Child = ({onClick}) => {
  return <button onClick={onClick}>Click Me!</button>
};

 

  1. 로컬 함수
    인라인 함수보다는 훠얼씬 나은 방식이다. 내부 함수를 생성해서 child 컴포넌트에 내려주는 것이다.
    아래의 예시에서는 _onClick 함수를 생성해 마구잡이로 생성되던 인라인 함수를 하나로 묶었다.
const Root = () => {
  const _onClick = () => {
    console.log('callback');
  };
  
  return (
    <>
      <Child onClick={_onClick}/> // 상단에 생성된
      <Child onClick={_onClick}/> // _onClick 함수를
      <Child onClick={_onClick}/> // 참조하고 있음
	  ...
    </>
  );
};

// Child이 여러 번 생성되더라도 onClick props으로 전달되는 _onClick 함수는 한번만 생성된다. 
const Child = ({onClick}) => {
  return <button onClick={onClick}>Click Me!</button>
};

언뜻 보아도 인라인 함수보다는 낫다는 것을 알 수 있다.

허나 내부 함수를 사용하는 것에도 비효율 성이 있었으니... 내부 함수를 감싸고 있는 함수가 리렌더 될 때 내부 함수 역시로 새로 만들어 지게 된다는 것이다. 즉, 예시의 Root 컴포넌트가 리렌더링 되면 _onClick 함수가 새로 생성되는 것이다.

인라인 함수는 함수가 쓰인 곳에서 렌더링이 발생할 때마다 새로 생성되는 것이고,
로컬 함수는 그 함수를 가지고 있는 컴포넌트의 렌더링이 발생 할 때마다 새로 생성된다. 
이 둘의 차이점을 명확히 나누어야 함!!! 

 

useCallback 적용

const Root = () => {
  const [isClicked, setIsClicked] = useState(false);
  const _onClick = useCallback(() => {
    setIsClicked(true);
  }, []); // dependency가 없으므로 Root component가 렌더링 되는 최초에 한번만 생성되며 이후에는 동일한 참조 값을 사용한다.
  
  return (
    <>
      <Child onClick={_onClick}/>
      <Child onClick={_onClick}/>
      <Child onClick={_onClick}/>
	  ...
    </>
  );
};

// Root와 Child가 여러번 렌더링 되더라도 onClick props으로 전달되는 _onClick 함수는 한번만 생성되므로 계속해서 동일 참조 값을 가진다. 
const Child = ({onClick}) => {
  return <button onClick={onClick}>Click Me!</button>
};

이렇게!! useCallback을 적용하면 동일 참조 값을 전달하게 된다. ㅎㅎㅎ
그렇다면 지금의 코드에서 _onClick함수를 호출하면 어떻게 될까?
동일 참조 값의 props이 전달되고 있으므로 Child 컴포넌트에 리렌더링이 발생하지 않아야 할 것 같지만!!!! 그렇지 않다...

왜냐하면

useCallback can prevent unnecessary renders between parent and child components.

useCallback은 상하위 컴포넌트 관계에서 상위 컴포넌트가 넘겨주는 props를 핸들링하는 역할을 하는데 하위 컴포넌트가 그 부분을 신경쓰지 않고 상위 컴포넌트의 렌더링 여부에 따라 자동으로 렌더링이 된다면, 다시 말해 useCallback은 홀로 사용한다면 그저 무용지물이 될 뿐인 것이다.

스크롤을 올려 문서 발췌를 다시 읽어보면

참조의 동일성에 의존적인 최적화된 자식 컴포넌트에 콜백을 전달할 때 유용합니다.

라고 한다. 참조 동일성에(?!) 의존적인(!?) 최적화된 자식 컴포넌트란(?!) 무엇인가?


React.memo

왜 사용하는 것일까

useCallback 사용만으로는 하위 컴포넌트의 리렌더를 막을 수 없다! 하위 컴포넌트가 참조 동일성에, 의존적인, 최적화된 Purecomponent!이어야만 비로소 불필요한 리렌더링을 막을 모든 것이 완성된다.

React.memoshouldComponentUpdate라이프 사이클이 기본으로 내장된 함수형 컴포넌트라고 생각하면 된다. 얕은 비교 연산을 통해 동일한 참조 값의 prop이 들어온다면 리렌더링을 방지시킨다.

어떻게 사용하는 것일까

React.memo는 고차 컴포넌트(Higher Order Component)입니다. React.PureComponent와 비슷하지만 class가 아니라 함수 컴포넌트라는 점이 다릅니다.
당신의 함수 컴포넌트가 동일한 props로 동일한 결과를 렌더링해낸다면, React.memo를 호출하고 결과를 메모이징(Memoizing)하도록 래핑하여 경우에 따라 성능 향상을 누릴 수 있습니다. 즉, React는 컴포넌트를 렌더링하지 않고 마지막으로 렌더링된 결과를 재사용합니다.
React.memo는 props 변화에만 영향을 줍니다. React.memo로 감싸진 함수 컴포넌트 구현에 useState 또는 useContext 훅을 사용한다면, 여전히 state나 context가 변할 때 다시 렌더링됩니다.
props가 갖는 복잡한 객체에 대하여 얕은 비교만을 수행하는 것이 기본 동작입니다. 다른 비교 동작을 원한다면, 두 번째 인자로 별도의 비교 함수를 제공하면 됩니다.
리액트 문서 중

정말 간단하다. 고차 컴포넌트이므로 사용 중인 component를 memo로 감싸주기만 하면 된다.
또한 props에 대해 기본으로 제공되는 얕은 비교가 아닌 커스텀을 하고 싶다면 두 번째 인자로 비교 함수를 넣어 사용할 수 있다.

const memoizedComponent = React.memo(
  (props) => (
  /* props를 사용하여 렌더링 */
  (prev, next) => {
  /*
  nextProp가 prevProps와 동일한 값을 가지면 true를 반환하고, 그렇지 않다면 false를 반환
  */
);
function MyComponent(props) {
  /* props를 사용하여 렌더링 */
}
function areEqual(prevProps, nextProps) {
  /*
  nextProp가 prevProps와 동일한 값을 가지면 true를 반환하고, 그렇지 않다면 false를 반환
  */
}
export default React.memo(MyComponent, areEqual);

첫 번째 예시처럼 memo 내부에 코드를 작성해도 되고,
두 번째 예시처럼 컴포넌트와 비교 함수를 각각 별도로 작성해서 memo로 감싸 사용해도 된다.

이때 비교 연산으로 들어가는 2번째 인자 예제의areEqual 함수는 동일한가?를 연산하는 함수이므로 props가 동일하면 true를 다르면 false를 반환한다. 즉, React.memo의 비교 함수는 false인 경우에 리렌더링을 발생시키는 것이다.

함수명은 변경되어도 문제 없으나 어째든 동일한지를 연산하고 동일하지 않을 때(false값을 반환할 때) 렌더링이 발생한다.

유의할 사항을 shouldComponentUpdate와 유사한 역할이지만 정반대로 동작한다는 것..!

이전에 면접 볼 때 둘이 헷갈려서 횡설수설한 적이 있었는데 areEqual과 shouldComponentUpdate를 해석해보면 쉽게 답이 나온다..ㅎ

따라서 useCallbackReact.memo를 사용하여 렌더 최적화를 하는 최종적인 모습은 아래와 같다.

최종 적용 코드

const Root = () => {
  const [isClicked, setIsClicked] = useState(false);
  const _onClick = useCallback(() => {
    setIsClicked(true);
  }, []);
  
  return (
    <>
      <Child onClick={_onClick}/>
      <Child onClick={_onClick}/>
      <Child onClick={_onClick}/>
	  ...
    </>
  );
};

const Child = React.memo(({onClick}) => {
  return <button onClick={onClick}>Click Me!</button>
});

두둥..

번외

useCallback을 사용하는데 렌더링이 더 발생하는 상황?

얼마 전에 사내에서 하우스 키핑 기간을 가졌는데 특정 컴포넌트에 useCallbackReact.memo를 제대로 썼음에도 불구하고 희한하게 useCallback을 사용하면 상위 컴포넌트의 렌더링이 2배로 더 생기는 현상이 있었다. 퇴근하고 이렇게 저렇게 다 찾아봤었는데 StrictMode와 연관이 있다는 것을 알게 됨!!! (함께 찾아 준 동료에게 감사를🙏)
Double-render function components with Hooks in DEV in StrictMode

무려 댄 아브라모브님의 pr이다. 개발 환경에서만 적용되는 StrictMode에서 개발 환경 속도를 높이고 hooks의 오용을 방지하기위해 더블 렌더링을 발생시키는 것 같다. 따라서 실제 프로덕션 환경에서는 2배의 렌더링이 없었겠쥬?

useState에서 비슷한 현상이 발생한 이슈도 볼 수 있다.

퀴이즈 타임- map으로 하위 컴포넌트를 생성하고 인자 값을 받는 콜백 함수를 내려줘야 할 때에는 어떻게 최적화 할 수 있을까?

스터디때 받은 문제로 누군가는 10분 내외로 풀었으나 나는 주말동안 머리를 싸맸었다는 슬픈 뒷 이야기...

const Root = () => {
  const [buttons, setButtons] = useState([1, 2]);

  const _onButtonClick = useCallback(() => {
    setButtons([1, 2, 3]);
  }, []);

  const _onClick = (() => {
    /* 클릭하는 버튼이 console에 찍히도록*/
  });

  return (
    <>
      <button onClick={_onButtonClick}>버튼</button>
      {buttons.map((button, index) => (
        <Child key={index} button={button} onClick={_onClick} />
      ))}
    </>
  );
};

const Child = ({ button, onClick }) => {

  return <button onClick={onClick}>{button}</button>;
};

 

당시 제출했던 코드

const Root = () => {
  const [buttons, setButtons] = useState([1, 2]);

  const _onButtonClick = () => {
    setButtons([1, 2, 3]);
  };

  const _onClick = useCallback((button) => {
    console.log(button);
  }, []);

  return (
    <>
      <button onClick={_onButtonClick}>버튼</button>
      {buttons.map((button, index) => (
        <Child key={index} button={button} onClick={_onClick} />
      ))}
    </>
  );
};

const Child = React.memo(({ button, onClick }) => {
  const handleClick = useCallback(() => {
    onClick(button);
  }, []);

  return <button onClick={handleClick}>{button}</button>;
});

끝까지 읽어주신 감사한 모든 분들.. 퀴즈도 꼭 풀어보시고.. 얼마나 걸렸는지 알려주세요.. 궁금..
암튼 기초와 같은 것임에도 글로 정리하려니 쉽지가 않다. 흠 다음엔 diffing algorithm을 공부해보아야겠어유


참고 자료
Hooks API Reference - React
React.PureComponent

profile
꿈꿀 수 있는 개발자가 되고 싶습니다

20개의 댓글

comment-user-thumbnail
2020년 4월 14일

const _onClick = useCallback((number) => () => {
console.log(number);
} ,[]);
이런식으로하고

밑에 child 에는

const Child = memo(({ button, onClick }) => {

return {button};
});

이런식으로 하면 되나요?ㅎㅎ

2개의 답글
comment-user-thumbnail
2020년 4월 16일
// 원시값
let value = 1;
let value2 = value; // value2에 원시값 1이 '새로' 할당됨.
value2 = 2;

// 따라서 값이 변경되면 서로 다른 값을 가지게 됨
console.log(value); // 1 
console.log(value2); // 2 
value === value2 // true 다른 메모리에 할당되어도 === true 값을 반환

value===value2 // false
아닌가요??

1개의 답글
comment-user-thumbnail
2020년 4월 23일

좋은 포스팅 감사드립니다.

다만 내용에서 약간 오해할 수 있게 쓰인 부분이 있어서 보충을 하자면,

다시 말해 useCallback은 최초에 혹은 특정 조건에서 생성한 함수를 기억하여 불필요하게 매번 새로 콜백 함수를 생성하지 않도록 하는 hook이다.

새로 생성되지 않는다함은 메모리에 새로 할당되지 않고 동일 참조 값을 사용하게 된다는 것을 의미하고, 이는 최적화된 하위 컴포넌트에서 불필요한 렌더링을 줄일 수 있다는 것을 뜻한다.

라고 본문에 쓰여져 있는데요.

사실 현재 예제에 쓰인 함수는
useCallback으로 감싸든, 안 감싸든 무조건 생성됩니다.

새로 생성되지 않아서 동일 참조 값을 사용하는 게 아니라
매 렌더마다 항상 함수의 생성까지는 되는데,
의존성이 바뀌지 않았다면 useCallback은 인자로 들어온, 생성된 함수를 무시하고 기존 함수를 반환합니다.

따라서 함수 생성을 줄여주는 효과는 없습니다.
온전히 Child 컴포넌트에 넘겨주는 props가 바뀌지 않게 하는 방법이라고 보시면 될 것 같습니다.

https://ko.reactjs.org/docs/hooks-faq.html#are-hooks-slow-because-of-creating-functions-in-render

해당 내용도 이 부분과 비슷한 내용을 다룹니다.
hooks에 넘겨주는 함수 자체는 기본적으로 항상 렌더링마다 생성되는 게 맞고, 그게 퍼포먼스적인 문제를 크게 일으키지는 않는다고 이야기하고 있으며, useCallback은 단순히 함수의 참조를 유지해 불필요한 리렌더링을 막을 수 있는 기법으로 소개하고 있습니다. (useCallback이 함수의 생성을 막진 않습니다)

4개의 답글
comment-user-thumbnail
2020년 11월 12일

잘읽고갑니다!

답글 달기