[React] 다시 한번 useCallback을 파헤쳐보자

sohyeon kim·2022년 8월 4일
18

React & Javascript

목록 보기
39/41

useCallback 또한 메모이제이션 기법으로 컴포넌트 성능을 최적화 시켜주는 도구이다.

앞서 알아본 useMemo을 복습하면 useMemo는 자주 쓰이는 값을 메모이제이션 즉 캐싱해준다. 그리고 그 값이 필요할때 다시 게산을 하는 것이 아닌 useMemo를 통해 캐싱을 한 값을 메모리에서 꺼내와서 재사용한다. useMemo에 인자로 콜백함수를 넣어주면 함수가 리턴하는 값을 메모이제이션 하는 것이다.

useMemo(() => {
	return value;
}. [item])

그렇다면 useCallback은 무엇인가?

useCallback도 똑같다. 대신에 useCallback은 인자로 전달한 콜백 함수 그 자체를 메모이제이션 하는 것이다.

useCallback(() => {
	return value;
}. [item])

예를 들어 calculate 함수를 메모이제이션 하려면 useCallback으로 calculate 함수를 감싸주면 된다. 그리고 calculate 함수가 다시 필요할 때마다 함수를 새로 생성하는 것이 아닌 필요할 때마다 메모리에서 가져와서 재사용하는 것이다.

사실 자바스크립트의 함수는 객체의 한 종류이다. 아래 calculate 라는 함수가 있다고 보면

사실은 calculate 변수에 초록색으로 표시한 함수 객체가 할당이 되는 것이다. 마치 변수에 숫자나 문자열 객체를 할당할 수 있듯이 함수 또한 calculate 변수에 할당할 수 있는 것이다.

리액트에서 함수형 컴포넌트는 말 그대로 함수이다. 그리고 함수형 컴포넌트가 렌더링이 된다는 것은 그 컴포넌트를 나타내는 함수가 다시 호출이 된다는 것이다. 그럼 컴포넌트 내부에 있는 모든 변수들이 초기화된다.

위의 컴포넌트를 보면 calculate 함수를 가지고 있다. 따지고 보면 calculate라는 변수 안에 위 초록색으로 표시한 함수 객체가 할당이 되어있는 것이다. 컴포넌트가 렌더링이 될 때마다 calculate 변수는 다시 초기화되기 때문에 새로 만들어진 함수 객체를 다시 할당받는다. 만약 이 calculate 함수를 useCallback 훅으로 아래처럼 감싸서 메모이제이션을 해주면 컴포넌트가 다시 렌더링이 되더라도 계산기 함수가 초기화되는 것을 막을 수 있다.

즉 컴포넌트가 처음 렌더링 될 때만 이 함수 객체를 만들어서 이 calculate를 초기화해주고 이후 렌더링에 calculate 변수가 새로운 함수 객체를 다시 할당받는 것이 아니라 이전에 이미 할당받은 함수 객체를 계속해서 가지고 있으면서 재 사용하는 것을 말한다.

function Componenet(){
	const calculate = useCallback((num) => {
    	return num + 1;
    }, [item])
    return <div>{value}</div>
}

useCallback의 구조에 대해서 알아보자.

useMemo(() => {
	return value;
}. [item])
  • useCallback은 두 개의 인자를 받는다.

  • 첫 번째 인자로는 메모이제이션 해 줄 콜백함수를 받는다.

  • 두 번째 인자로는 의존성 배열을 받는다.


calculate 함수를 유즈콜백을 해서 메모이제이션 해주면 아래처럼 생겼다.

const calculate = useCallback((num) => {
    return num + 1;
}, [item])
  • calculate 함수를 useCallback 으로 감싸주면된다.

  • 그럼 calculate 함수는 메모이제이션된 함수를 가지고 있게 된다.

  • 메모이제이션 된 calculate 함수는 의존성 배열 내부에 있는 값이 변경되지 않는 이상 다시 초기화 되지 않는다.

  • 만약에 의존성 배열 내부의 값이 변경된다면 calculate 함수는 새로 만들어진 함수 객체로 초기화 되는 것이다.


실습을 통해 알아보자.

import { useState } from "react";

function App() {
  const [number, setNumber] = useState(0);

  // number 에 어떤숫자가 들어있는지 콘솔에서 확인만 해주는 함수
  const someFunction = () => {
    console.log(`someFunc: number : ${number}`);
    return;
  };

  return (
    <div>
      <input
        type="number"
        value={number}
        onChange={(e) => setNumber(e.target.value)}
      />
      <br />
      <button onClick={someFunction}>Call someFunc</button>
    </div>
  );
}

export default App;
  • button 을 누르면 someFunction 함수를 불러준다. 콘솔에는 현재 number에 어떤 숫자가 들어있는지 출력해준다.

자, 여기에 useEffect를 추가해보자.

import { useEffect, useState } from "react";

function App() {
  const [number, setNumber] = useState(0);

  const someFunction = () => {
    console.log(`someFunc: number : ${number}`);
    return;
  };

  // 📌 의존성 배열에 someFunction 넣어줬다..
  useEffect(() => {
    console.log("📌 someFunction 이 변경되었습니다.");
  }, [someFunction]);

  return (
    <div>
      <input
        type="number"
        value={number}
        onChange={(e) => setNumber(e.target.value)}
      />
      <br />
      <button onClick={someFunction}>Call someFunc</button>
    </div>
  );
}

export default App;
  • useEffect 안에 있는 콜백함수는 의존성 배열 안에 있는 someFunction이 바뀔 때만 불려질 것이다.

  • 콘솔에 찍어보자.

  • 새로고침을 하면 당연히 useEffect가 실행된 것을 확인할 수 있다.

여기서 input의 number를 바꿔주는 것은 someFunction 이 바뀌는 것이 아니니까 당연히 useEffect는 불리지 않겠지?

number를 증가시켜주면 콘솔에 'someFuction이 변경되었습니다'가 계속 찍히는 것을 확인할 수 있다. 왜 그럴 끼? someFunction은 그대로인데 왜 useEffect가 불리는 것인가!

리액트 컴포넌트에서 state를 변경할 때마다 컴포넌트는 다시 렌더링이 된다. 함수형 컴포넌트를 사용하고 있으니까 App 컴포넌트가 렌더링이 된다는 것이니까 App이라는 함수가 다시 호출이 되는 것이다.

자바스크립트에서 함수가 호출이 되면 함수 내부에 있는 모든 변수가 초기화가 된다. 즉 someFuction 함수도 하나의 변수인 것이다. 함수도 일종의 객체이기 때문에 마치 number, string 같은 어떤 값을 변수에 할당하듯 someFuction 함수라는 변수에 함수 객체가 할당이 되는 것이다.

그런데 자바스크립트에서 객체를 변수에 할당할 때 그 객체가 변수에 바로 들어가지 않는다. 객체 사이즈가 커서 다른 메모리 공간에 저장이 되고 그 메모리 공간의 주소가 변수 안에 들어간다. 이제 그 변수는 메모리 공간 안에 있는 객체를 참조하고 있는 것이다.

someFuction 함수도 객체이기 때문에 someFuction이라는 변수 안에는 이 객체가 들어있는 공간의 주소가 들어 잇는 것이다. number 가 바뀌어서 App 컴포넌트가 렌더링이 되면 someFuction 함수 안의 함수 객체가 다시 새로 만들어져서 또 다른 메모리 공간 안에 저장이 된다. 그럼 someFuction 변수 안에는 이전과는 다른 메모리 주소가 들어간다. 때문에 useEffect 입장에서는 이전 렌더링과 그다음 렌더링 때의 someFuction 안의 주소값을 비교했을 때 다르다고 인식을 하는 것이다.

다시 정리하자면 someFuction는 함수 객체가 들어있는 메모리의 주소를 가지고 있고 App 컴포넌트가 렌더링이 되어서 someFuction 가 초기화가 된다면 그 안에 있는 함수 객체가 새로 만들어져서 또 다른 메모리 공간에 저장이 되기 때문에 새로 만들어진 공간의 주소가 someFuction 주소 안에 들어가게 된다. 그래서 useEffect의 입장에서는 someFuction 안에 있는 주소가 바뀌었으니 콜백 함수를 호출하는 것이다. 그래서 number를 바뀌어주면 계속해서 useEffect의 가 호출되는 것이었다!


이제 useCallback을 사용해서 App 컴포넌트가 렌더링이 되더라도 sumeFunction이 바뀌지 않도록 해보자.

import { useEffect, useState, useCallback } from "react";

function App() {
  const [number, setNumber] = useState(0);

  // 📌
  const someFunction = useCallback(() => {
    console.log(`someFunc: number : ${number}`);
    return;
  }, []);

  useEffect(() => {
    console.log("someFunction 이 변경되었습니다.");
  }, [someFunction]);

  return (
    <div>
      <input
        type="number"
        value={number}
        onChange={(e) => setNumber(e.target.value)}
      />
      <br />
      <button onClick={someFunction}>Call someFunc</button>
    </div>
  );
}

export default App;
  • 의존성 배열에 아무것도 넣어주지 않았으니까 samefunction 안의 함수는 App 컴포넌트가 렌더링이 될 때 만들어져서 메모이제이션이 될 것이다.

  • 그리고 samefunction 안에는 메모이제이션된 함수의 주소가 들어간다.

  • 그다음 렌더링부터는 더 이상 함수 객체를 새로 생성해서 할당하는 것이 아니라 이미 가지고 있던 메모이제이션 된 주소를 samefunction 이 가지고 있으면서 재사용하는 것이다.

  • 아무리 App 컴포넌트가 렌더링이 되더라도 useEffect는 불리지 않을 것이다.

  • 맨 처음 useEffect가 한번 불린다.

  • 버튼을 누르면 number 안에는 0이 들어가 있다

  • number를 올리면 더 이상 useEffect가 불리지 않는 것을 볼 수 있다.

  • 그러나 버튼을 누르면 아직도 number는 0이라고 출력이 된다. 분명 5로 올려줬는데?

  • 왜냐하면 해당 함수는 메모이제이션해 줬을 당시에는 number가 0이었고 그다음 렌더링부터는 이미 메모이지이 션 된 함수를 가져다 쓰는 것이기 때문에 이후에 아무리 number가 5로 바뀌었더라도 메모이제이션된 함수 안에 있는 number에는 계속해서 0이 들어있는 것이다.

  • number 가 업데이트될 때마다 메모이제이션된 함수도 업데이트해주고 싶으면 두 번째 의존성 배열에 number를 넣어준다.

  const someFunction = useCallback(() => {
    console.log(`someFunc: number : ${number}`);
    return;
  }, [number]); // 📌 number 추가 

확인을 해보자.

이제는 배열 안의 넘버가 바뀔 때마다 안의 함수가 갱신될 것이다. 다시 만들어져서 someFunction을 초기화해준다는 의미다.

  • 버튼을 누르면 0이 잘 나왔고

  • input 안에 있는 number를 증가시켜주면 number가 바뀔 때마다 useEffect가 불리는 것을 볼 수 있다.

  • 왜냐하면 [number]가 바뀌면 함수가 다시 생성되어 다시 메모이제이션되고 그렇기 때문에 someFunction 안에는 새로운 함수의 주소가 들어있게 되고 그래서 useEffect 안의 의존성 배열 안에 있는 [someFunction]의 값이 바뀌었으니까 콘솔이 출력이 되는 것이다.

  • 버튼을 다시 누르면 number 안에 6이 들어있는 것을 확인할 수 있다.


더 자세히 알아보기 위해 state를 추가해보자.

import { useEffect, useState, useCallback } from "react";

function App() {
  const [number, setNumber] = useState(0);
  // 📌
  const [toggle, setToggle] = useState(true);

  const someFunction = useCallback(() => {
    console.log(`someFunc: number : ${number}`);
    return;
  }, [number]);

  useEffect(() => {
    console.log("someFunction 이 변경되었습니다.");
  }, [someFunction]);

  return (
    <div>
      <input
        type="number"
        value={number}
        onChange={(e) => setNumber(e.target.value)}
      />
      {/* 📌 toggle이 가지고 있는 값을 버튼 레이블로 그대로 보여줄 것  */}
      <button onClick={() => setToggle(!toggle)}>{toggle.toString()}</button>
      <br />
      <button onClick={someFunction}>Call someFunc</button>
    </div>
  );
}

export default App;

  • number를 올리면 someFunction이 다시 초기화되기 때문에 useEffect가 불린 것을 볼 수 있으나 toggle이 아무리 바뀌어도 someFunction은 메모이제이션된 함수를 사용하기 때문에 useEffect가 불리지 않는다.

  • 왜냐하면 useCallback의 의존성 배열에 number를 넣어줬기 때문이다.


실전예제

//app

import { useState } from "react";
import Box from "./Box";

function App() {
  const [size, setSizes] = useState(100);

  // CSS를 담은 오브젝트를 반환
  const createBoxStyle = () => {
    return {
      backgroundColor: "pink",
      width: `${size}px`,
      height: `${size}px`,
    };
  };

  return (
    <div>
      <input
        type="number"
        value={size}
        onChange={(e) => setSizes(e.target.value)}
      />
		// props로 createBoxStyle 내려감
        <Box createBoxStyle={createBoxStyle}></Box>
    </div>
  );
}

export default App;
//box

import { useEffect, useState } from "react";


// box 컴포넌트가 마운팅이 되면 createBoxStyle 함수를 prop으로 전달됨
const Box = ({ createBoxStyle }) => {
  const [style, setStyle] = useState({});

  // 1. box 가 맨처음 렌더링 될 때 
  // 2. props으로 받은 createBoxStyle 변경이 될때 불리게 됨 
  useEffect(() => {
    console.log("박스 키우기");
    setStyle(createBoxStyle());
  }, [createBoxStyle]);

  return <div style={style}></div>;
};
export default Box;

박스의 크기를 키우면 콘솔에 '박스 키우기'가 콘솔에 찍히는 것을 확인할 수 있다.

  • 왜냐하면 App 컴포넌트 안에서 size를 변경시켜주면 App 컴포넌트가 랜더링이되고 변수가 초기화되면서 createBoxStyle도 초기화되기 때문에 다시 새로 할당된 함수 객체의 주소를 가지고 있게 된다.

  • 그리고 createBoxStyle 함수는 Box 컴포넌트로 전달이 되니까 Box 컴포넌트 안의 useEffect 안의 createBoxStyle 이 바뀌었다고 인식을 한다. 그리고 콘솔이 불리는 것!!

  • 당연히 setStyle 덕분에 박스의 크기도 달라지게 되는 것이다.


App 컴포넌트에 state를 하나 더 추가해보자.

import { useState } from "react";
import Box from "./Box";

function App() {
  const [size, setSizes] = useState(100);
  // 📌 
  const [isDark, setIsDark] = useState(false);

  const createBoxStyle = () => {
    return {
      backgroundColor: "pink",
      width: `${size}px`,
      height: `${size}px`,
    };
  };

  return (
     // 📌 
    <div
      style={{
        background: isDark ? "black" : "white",
      }}
    >
      <input
        type="number"
        value={size}
        onChange={(e) => setSizes(e.target.value)}
      />
 		// 📌 
      <button onClick={() => setIsDark(!isDark)}>Chagne Theme</button>
      <Box createBoxStyle={createBoxStyle}></Box>
    </div>
  );
}

export default App;

  • size를 바꾸면 '박스 키우기' 호출된다.

  • 그런데 Chagne Theme 을 눌러도 '박스 키우기' 호출된다.

Q. Theme 를 바꾸는 것은 박스가 커지는 것과 관련이 전혀 없는데? 왜 useEffect가 불리는 것일까?

이유는 isDark가 변화가 있으니까 다시 렌더링이 되면서 createBoxStyle이 초기화되어서 그렇다.

  • 이것을 막아주려면 createBoxStyle이 사이즈가 바뀌었을 때만 초기화 되도록! 즉 useMemo 쓰면 되다

  • createBoxStyle 이를 useMemo로 감싸주고 의존성 배열에는 size 넣어주자.

  const createBoxStyle = useCallback(() => {
    return {
      backgroundColor: "pink",
      width: `${size}px`,
      height: `${size}px`,
    };
  }, [size]);

  • size 바뀌면 '박스 키우기'가 출력된다.

  • 테마 바꾸면 '박스 키우기' 출력 안 된다.

이유는 size가 변경되지 않는 이상 메모와이즈된 createBoxStyle이 함수를 재사용하기 때문이다. 그래서 박스 컴포넌트 안에 있는 useEffect도 불리진 않는 것 !!!

profile
slow but sure

1개의 댓글

comment-user-thumbnail
2024년 1월 19일

리액트 공식문서만 읽어보다가 보니 이해가 너무 잘됩니다 감사합니다.

답글 달기