useMemo & useCallback

1Hoit·2023년 3월 22일
0

React 기초

목록 보기
12/12

들어가기전
컴포넌트는 상태가 변경되거나 부모 컴포넌트가 렌더링이 될 때마다 리렌더링을 하는 구조로 이루어져 있다.
(리렌더링 조건: state가 변경시, props가 변경시)

  • 즉, 만약 부모 컴포넌트의 상태가 변한다면 자식 컴포넌트들도 리렌더링이 된다는 것이다.

하지만 리렌더링이 너무 잦으면 앱의 성능이 나빠진다.
이 문제를 해결하기 위해 함수형 컴포넌트에서는 렌더링 최적화를 위한 Hook인,
useMemo와 useCallback 이 있다.

useMemo

함수 컴포넌트 내부에서 발생하는 연산을 최적화할 수 있는 Hook이다.

  • 특정 값(value)을 재사용하고자 할 때 사용하는 Hook이며
    렌더링 과정에서 특정 값이 변경되었을 때만 연산을 실행하고
    원하는 값이 바뀌지 않았다면 이전에 연산했던 결과를 다시 사용하는 방식이다.
  • 만약 함수 컴포넌트 내부에서 발생하는 연산이 복잡하여 오래 걸린다면 해당 컴포넌트 렌더링을 할 때마다 함수를 계속 호출하여 성능에 악영향을 미치게 된다.

예시) value 를 인자로 받는 Calculator 컴포넌트

//App.js
import React, { useState, useMemo } from "react";
import "./styles.css";
import { add } from "./add";

export default function App() {
  const [name, setName] = useState("");
  const [val1, setVal1] = useState(0);
  const [val2, setVal2] = useState(0);
  // val1, val2 의 값이 바뀌면 렌더링 되고 아니라면 이전의 값을 가져와 사용한다.
  const answer = useMemo(() => add(val1, val2), [val1, val2]);
 
  // const answer = add(val1, val2);
  // 이렇게 작성된다면 add 함수는 렌더링 될때 마다 호출되고 
  //이전 값을 유지하지 못한채 계속해서 연산을 수행하게 된다
  
  return (
    <div>
      <input
        className="name-input"
        placeholder="이름을 입력해주세요"
        value={name}
        type="text"
        onChange={(e) => setName(e.target.value)}
      />
      <input
        className="value-input"
        placeholder="숫자를 입력해주세요"
        value={val1}
        type="number"
        onChange={(e) => setVal1(Number(e.target.value))}
      />
      <input
        className="value-input"
        placeholder="숫자를 입력해주세요"
        value={val2}
        type="number"
        onChange={(e) => setVal2(Number(e.target.value))}
      />
      <div>{answer}</div>
    </div>
  );
}

//add.js
export const add = (num1, num2) => {
  console.log("숫자가 들어옵니다.");
  return Number(num1) + Number(num2);
};

렌더링을 할 때마다 val1, val2 값이 바뀌는 게 아니라고 생각을 해보자
이 값을 어딘가에 저장을 해뒀다가 다시 꺼내서 쓸 수만 있다면 굳이 add 함수를 호출할 필요도 없을 것이다.

useMemo Hook을 사용한다면 이전에 구축된 렌더링과 새로 구축되는 렌더링을 비교해 val1, val2 값이 동일할 경우에는 이전 렌더링의 value값을 그대로 재활용할 수 있게 된다.

  • 사용하는 방법은 위의 코드의 예시와 같다

참고
Memoization
메모이제이션(Memoization)은 알고리즘에서 자주 나오는 개념으로 기존에 수행한 연산의 결과값을 메모리에 저장을 해두고, 동일한 입력이 들어오면 재활용하는 프로그래밍 기법을 말한다.
메모이제이션을 적절히 사용한다면 굳이 중복 연산을 할 필요가 없기 때문에 앱의 성능을 최적화할 수 있다.


useCallback

렌더링 성능을 최적화해야 하는 상황에서 사용한다.

  • useMemo와 마찬가지로 메모이제이션 기법을 이용한 Hook이다.
  • useMemo는 값의 재사용을 위해 사용하는 Hook
  • useCallback은 함수의 재사용을 위해 사용하는 Hook입니다.
  • 종속성 배열 (dependency array)에 넣는 변수는 해당 콜백 함수가 의존하는 변수
  • 종속성 배열이 비어있으면 그 콜백 함수는 변수들에 의존하지 않는 콜백 함수다.

예제1)

// App.js
import { useState } from "react";
import "./styles.css";
import List from "./List";

export default function App() {
  const [input, setInput] = useState(1);
  const [light, setLight] = useState(true);

  const theme = {
    backgroundColor: light ? "White" : "grey",
    color: light ? "grey" : "white"
  };

  //useCallback 을 통한 최적화
    const getItems = useCallback(() => [input + 10, input + 100], [input]);
  
  // 버튼이 클릭되면 상태가 변경되므로 해당 컴포넌트는 리렌더링이 되면서
  // 아래의 함수 또한 호출된다.
  // const getItems = () => {
  //   return [input + 10, input + 100];
  // };

  const handleChange = (event) => {
    if (Number(event.target.value)) {
      setInput(Number(event.target.value));
    }
  };

  return (
    <>
      <div style={theme} className="wall-paper">
        <input
          type="number"
          className="input"
          value={input}
          onChange={handleChange}
        />
        <button
          className={(light ? "light" : "dark") + " button"}
          onClick={() => setLight((prevLight) => !prevLight)}
        >
          {light ? "dark mode" : "light mode"}
        </button>
        <List getItems={getItems} />
      </div>
    </>
  );
}

//List.js
import { useState, useEffect } from "react";

function List({ getItems }) {
  /* Initial state of the items */
  const [items, setItems] = useState([]);

  /* This hook sets the value of items if 
     getItems object changes */
  useEffect(() => {
    console.log("아이템을 가져옵니다.");
    setItems(getItems());
  }, [getItems]);

  /* Maps the items to a list */
  return (
    <div>
      {items.map((item) => (
        <div key={item}>{item}</div>
      ))}
    </div>
  );
}
export default List;

위의 코드로 예시를 들면
input 창에 숫자를 입력하거나 button을 누르면 “아이템을 가져옵니다.”가 콘솔에 출력된다.

  • input 창에 숫자를 입력해서 List컴포넌트가 리렌더링 되게 하는 것은 정상이다.
  • 하지만 버튼을 누를 때도 부모컴포넌트에서 상태 변경이 일어나 앱이 리렌더링 되므로, App 내부의 getItems() 함수도 다시 만들어진다.
    새로 만들어진 함수는 이전의 함수와 참조 비교시 다른 함수이기 때문에 List 구성 요소 내에서 useEffect Hook은 setItems를 호출하고 종속성이 변경됨에 따라 “아이템을 가져옵니다.”를 출력하게 된다.

useCallback과 참조 동등성

React는 JavaScript 언어로 만들어진 오픈소스 라이브러리이기 때문에 기본적으로 JavaScript의 문법을 따라간다.
JavaScript에서 함수는 객체이며 객체는 메모리에 저장할 때 값을 저장하는 게 아니라 값의 주소를 저장하기 때문에, 반환하는 값이 같을 지라도 일치연산자로 비교했을 때 false가 출력된다.
그래서 위에서 이전의 함수와 참조비교를 하여 함수가 의존하는 값들이 바뀌지 않는 한 기존 함수를 계속해서 반환해줄 수 있는 것이다.

function doubleFactory(){
    return (a) => 2 * a;
} 
const double1 = doubleFactory();
const double2 = doubleFactory(); 
double1(8); // 16
double2(8); // 16
double1 === double2;  // false
double1 === double1;  // true
  • double1과 double2는 같은 함수를 할당했음에도 메모리 주소 값이 다르기 때문에 같다고 보지 않는다. JavaScript에서 함수는 객체이기에 두개의 함수는 동일한 코드를 공유하더라도 메모리 주소가 다르기 때문에, 메모리 주소에 의한 참조 비교 시 다른 함수로 본다.
  • React도 같다.
    리렌더링 시 함수를 새로 만들어서 호출을 한다. 새로 만들어 호출된 함수는 기존의 함수와 같은 함수가 아니다.
    그러나 useCallback을 이용해 함수 자체를 저장해서 다시 사용하면 함수의 메모리 주소 값을 저장했다가 다시 사용한다는 것과 같다고 볼 수 있다.
    따라서 React 컴포넌트 함수 내에서 다른 함수의 인자로 넘기거나 자식 컴포넌트의 prop으로 넘길 때 예상치 못한 성능 문제를 막을 수 있는 것이다.

useCallback의 한계?

useCallback만 사용해서는 useMemo에 비해 괄목할 만한 최적화를 느낄 수 없다.
왜냐하면 useCallback은 함수를 호출을 하지 않는 Hook이 아니라,
메모리 어딘가에 있는 함수를 꺼내서 호출하는 Hook이기 때문이다.
따라서 단순히 컴포넌트 내에서 함수를 반복해서 생성하지 않기 위해서 useCallback을 사용하는 것은 큰 의미가 없거나 오히려 손해인 경우도 있다.

  • useCallback 의 적합한 사용시기
    자식 컴포넌트의 props로 함수를 전달해줄 때 이 useCallback을 사용하기가 좋다.

    예시)

//Parent.js
function Parent(){
  const [valueForFirst, setValueForFirst]= useState(null);
  const handleClick = () =>{};
  
  useEffect(()=>{
    setTimeout(()=>{
      setValueForFirst('changed Value')
    },3000);
  }, []);
  
  return (
  <>
  <First value = {valueForFirst}>    //상태를 props로 전달
  <Second onClick = {handleClick}>   //함수를 props로 전달
  </>
  )
}

//First.js
function First({value}){  
    return <div>{value}</div>
}  
    
//Second.js
function Second({onClick}){
  return(
    <div onClick={onClick}>
    {Array.from({length:1000}).map((_,idx)=>{
       <ConsoleTag key={idx+1} order={idx}/>
      }
   )}
  	</div>
  ) 
}
    
//ConsoleTag.js 는 그저 콘솔로그를 찍어주는 용도임
  1. Parent 컴포넌트가 렌더링된 후 useEffect가 실행된다.
  2. useEffect에서 state의 변화가 생겨 Parent 컴포넌트가 리렌더링 된다.
  3. Parent 컴포넌트가 리렌더링되면서 First와 Second 컴포넌트도 리렌더링 된다.
    • First 컴포넌트는 변경된 state 가 전달되고 리렌더링 된다.
      전달받은 state를 First 컴포넌트에서도 사용하므로 리렌더링 되는게 정상이다.

    • Second 컴포넌트는 변경된 state가 전달되지 않고 리렌더링 된다.
      하지만 변경된 정보가 없어 똑같은 정보를 보여주는 리렌더링 되는게 불필요하다.
      또한 Second 컴포넌트가 리렌더링 되면 약 1000개의 컴포넌트들이 리렌더링된다.
      (부모로 부터 변하지 않은 handleClick props로 전달받은 것 같지만 Parent 컴포넌트가 리렌더링 될 때마다 handleClick 함수의 참조값이 달라졌기 때문에 다른 함수가 되버려 props가 달라지기 때문에 리렌더링이 발생한다.)
  • 그렇다면 이렇게 불필요한 리렌더링을 막으려면 함수의 참조값이 바뀌지 않도록 useCallback을 사용하면 된다.
const handleClick = useCallback(()=>{},[]);
  • 하지만 그래도 리렌더링이 된다.
    리액트는 render phase와 commit phase가 있는데
    render phase에서는 리액트 엘리먼트를 가지고 Virtual DOM을 생성하거나 리렌더링 시에는 재조정과정을 하며
    commit phase에서는 변경이 필요한 부분을 Real DOM에 반영하고 변경이 필요없다면 실행되지 않는다.
    즉, useCallback을 사용하면 render phase 과정만 실행되는데 이 과정에서 props를 동일하게 유지하므로 재조정 과정에서 변경이 없기 때문에 commit phase를 막아준다
  • commit phase 까지 막는 방법
export default React.memo(Second); 

위와 같이 React.memo를 사용하면 된다.

  • 만약 객체를 props로 넘겨 준다면 참조값이 달라서 React.memo가 정상 작동하지 않는데 이때는 위에서 언급한 useMemo() Hook을 사용하면 된다.

마무리

  • 성급하게 최적화를 하지말고 최적화가 필요한 곳에서만 하자
profile
프론트엔드 개발자를 꿈꾸는 원호잇!

0개의 댓글