세현's 개발로그

[React] useCallback() 최적화 (feat. 일기장) 본문

React

[React] useCallback() 최적화 (feat. 일기장)

SarahPark 2023. 5. 7. 17:19

◈ useCallback()이란?

useCallback(() => {}, []);

첫 번째 인자 = 콜백함수

두 번째 인자 = 의존성배열(댑스)

댑스 배열 안에 있는 값이 변화하면 memoization 된 콜백함수가 반환된다.

 

useMemo()와 deps가 변하면 memoization 된 것을 반환한다는 점에서 같지만,

useMemo()는 함수의 값을 반환, 함수의 연산량이 많을 때 이전 결과값을 재사용하는 목적이고,

useCallback()은 함수를 반환, 함수가 재생성 되는 것을 방지하기 위함이다.

 

◈ useCallback() 사용 - 1

일기리스트에 있는 목록 중 하나를 삭제하면 DiaryEditor 컴포넌트도 랜더링 된다. 

근데, 일기리스트와 일기작성폼(DiaryEditor)는 연관이 없는데 불필요한 랜더링이 일어난 것을 useCallback을 이용하여 개선해보자.

 

우선 불필요한 랜더링이 일어난 이유는 DiaryEditor 함수는 App.js로부터 onCreate 함수를 prop으로 받고 있다.

(onCreate 함수는 저장하기 버튼을 눌렀을 때 data에 item을 일기리스트에 추가해주는 역할)

 

콘솔을 찍어 DiaryEditor가 몇 번 랜더링 되는 지 확인하면 두 번 랜더링 되는 것을 확인할 수 있다.

1) App.js 컴포넌트에 App함수를 보면 const [data, setData] = useState([]); 에서 data state의 useState 값이 빈 배열로 한 번 랜더링이 일어나고 DiaryEditor도 빈 배열인 상태에서 랜더링이 일어난다.

2) useEffect(() => {getData();}, []); App 컴포넌트가 mount 되는 시점에서 호출한 getData 함수에서 결과를 API에서 가져와 완성된 결과를 setData(initData); setData 함수에 넣어 state값을 바꿔서 rendering이 일어난다.

 

App 컴포넌트가 mount 되자마자 2번 랜더링 되면서 DiaryEditor가 prop으로 전달받는 onCreate 함수도 랜더링이 된 것이다. 비원시형 자료의 비교는 얕은 비교를 하기 때문에 DiaryEditor가 prop으로 전달받는 onCreate 함수가 얕은 비교하여 계속 랜더링 되기 때문에 함수가 재생성되지 않아야 DiaryEditor 함수가 최적화된다. 이를 위해 useCallback 함수를 사용해보자.

 

App.js

1) import { useCallback, useEffect, useMemo, useRef, useState } from "react";

useCallback이 import 되었는지 확인하기

 

2)  const onCreate = useCallback((author, content, emotion) => {
    const created_date = new Date().getTime();
    const newItem = {
      author,
      content,
      emotion,
      created_date,
      id: dataId.current
    };

    dataId.current += 1;
    setData((data) => [newItem, ...data]);
  }, []);

 

첫 번째 인자로 전달할 콜백함수에 DiaryEditor 함수를, 두 번째 인자로는 빈 배열을 전달하여 mount되는 시점을 한 번만 만들고 그 다음부터는 첫 번째 만들어두었던 함수를 재사용할 수 있도록 한다.

이 때 함수형 업데이트를 사용하여 setData에 화살표 함수로 data를 받아서 아이템에 추가한 data를 리턴하는 콜백함수를 전달한다.

 

<함수형 업데이트를 사용하는 이유>

 

DiaryEditor 컴포넌트는 mount 되는 시점(빈배열 상태)이다. DiaryEditor에는 빈 배열로 전달되어 이후 계속 빈 배열 상태인 것이다. deps에 빈 배열을 전달하면 mount되는 시점만 render 되고 리스트는 빈 배열로 전달되기 때문에 새로 일기를 저장하면 있었던 일기들은 지워지고 한 개의 일기만 리스트에 뜨게 된다. 그렇다고 deps에 data값을 넣어주면 또 리스트가 삭제될 때마다 리랜더링이 되는 딜레마에 빠진다.

=> 함수형 업데이트(setState 함수에 함수를 전달하는 것)을 사용하면 된다.

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import "./App.css";
import DiaryEditor from "./DiaryEditor";
import DiaryList from "./DiaryList";

const App = () => {
  const [data, setData] = useState([]);
  const dataId = useRef(0);

  const getData = async () => {
    const res = await fetch(
      "https://jsonplaceholder.typicode.com/comments"
    ).then((res) => res.json());

    const initData = res.slice(0, 20).map((it) => {
      return {
        author: it.email,
        content: it.body,
        emotion: Math.floor(Math.random() * 5) + 1,
        created_date: new Date().getTime(),
        id: dataId.current++
      };
    });

    setData(initData);
  };

  useEffect(() => {
    setTimeout(() => {
      getData();
    }, 1500);
  }, []);

  const onCreate = useCallback((author, content, emotion) => {
    const created_date = new Date().getTime();
    const newItem = {
      author,
      content,
      emotion,
      created_date,
      id: dataId.current
    };

    dataId.current += 1;
    setData((data) => [newItem, ...data]);
  }, []);

  const onRemove = (targetId) => {
    const newDiaryList = data.filter((it) => it.id !== targetId);
    setData(newDiaryList);
  };

  const onEdit = (targetId, newContent) => {
    setData(
      data.map((it) =>
        it.id === targetId ? { ...it, content: newContent } : it
      )
    );
  };

  const getDiaryAnalysis = useMemo(() => {
    if (data.length === 0) {
      return { goodcount: 0, badCount: 0, goodRatio: 0 };
    }
    console.log("일기 분석 시작");

    const goodCount = data.filter((it) => it.emotion >= 3).length;
    const badCount = data.length - goodCount;
    const goodRatio = (goodCount / data.length) * 100.0;
    return { goodCount, badCount, goodRatio };
  }, [data.length]);

  const { goodCount, badCount, goodRatio } = getDiaryAnalysis;

  return (
    <div className="App">
      <DiaryEditor onCreate={onCreate} />
      <div>전체 일기 : {data.length}</div>
      <div>기분 좋은 일기 개수 : {goodCount}</div>
      <div>기분 나쁜 일기 개수 : {badCount}</div>
      <div>기분 좋은 일기 비율 : {goodRatio}</div>
      <DiaryList onEdit={onEdit} onRemove={onRemove} diaryList={data} />
    </div>
  );
};
export default App;

◈ useCallback() 사용 - 2

아직 최적화하지 못한 부분이 있다. 바로 item을 하나만 삭제해도 다른 item들이 전부 리랜더링 되는 부분이다. DiaryItem 랜더링을 최적화 하기 위해 useCallback을 사용해보자.

 

  const onRemove = useCallback((targetId) => {
    setData((data) => data.filter((it) => it.id !== targetId));
  }, []);

  const onEdit = useCallback((targetId, newContent) => {
    setData((data) =>
      data.map((it) =>
        it.id === targetId ? { ...it, content: newContent } : it
      )
    );
  }, []);

 

위에서 했던 방식과 마찬가지로 첫 번째 인자에는 콜백함수를 전달하고, 두 번째 인자에는 빈 배열을 전달한다.

이때도 함수형 업데이트를 사용해주었다.

App.js

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import "./App.css";
import DiaryEditor from "./DiaryEditor";
import DiaryList from "./DiaryList";

const App = () => {
  const [data, setData] = useState([]);

  const dataId = useRef(0);

  const getData = async () => {
    const res = await fetch(
      "https://jsonplaceholder.typicode.com/comments"
    ).then((res) => res.json());

    const initData = res.slice(0, 20).map((it) => {
      return {
        author: it.email,
        content: it.body,
        emotion: Math.floor(Math.random() * 5) + 1,
        created_date: new Date().getTime(),
        id: dataId.current++
      };
    });

    setData(initData);
  };

  useEffect(() => {
    setTimeout(() => {
      getData();
    }, 1500);
  }, []);

  const onCreate = useCallback((author, content, emotion) => {
    const created_date = new Date().getTime();
    const newItem = {
      author,
      content,
      emotion,
      created_date,
      id: dataId.current
    };

    dataId.current += 1;
    setData((data) => [newItem, ...data]);
  }, []);

  const onRemove = useCallback((targetId) => {
    setData((data) => data.filter((it) => it.id !== targetId));
  }, []);

  const onEdit = useCallback((targetId, newContent) => {
    setData((data) =>
      data.map((it) =>
        it.id === targetId ? { ...it, content: newContent } : it
      )
    );
  }, []);

  const getDiaryAnalysis = useMemo(() => {
    if (data.length === 0) {
      return { goodcount: 0, badCount: 0, goodRatio: 0 };
    }

    const goodCount = data.filter((it) => it.emotion >= 3).length;
    const badCount = data.length - goodCount;
    const goodRatio = (goodCount / data.length) * 100.0;
    return { goodCount, badCount, goodRatio };
  }, [data.length]);

  const { goodCount, badCount, goodRatio } = getDiaryAnalysis;

  return (
    <div className="App">
      <DiaryEditor onCreate={onCreate} />
      <div>전체 일기 : {data.length}</div>
      <div>기분 좋은 일기 개수 : {goodCount}</div>
      <div>기분 나쁜 일기 개수 : {badCount}</div>
      <div>기분 좋은 일기 비율 : {goodRatio}</div>
      <DiaryList onEdit={onEdit} onRemove={onRemove} diaryList={data} />
    </div>
  );
};
export default App;

 

Comments