6-12. 최적화 3 (컴포넌트 & 함수 재사용하기)

2022. 7. 7. 15:14React/한입 크기로 잘라 먹는 리액트(React.js)

오늘은 개발자모드의 Components의 Highlight updates when components render 기능을 사용해 볼것이다.

이 기능은 어떤 컴포넌트가 행동에 의해서 rerendering하고 있는지 주황색 라인으로 highlight해주는 기능이다.

useEffect와 console을 사용하면 다 알수 있지만, 추후에 개발을 진행하면 10개 20개가 넘는 컴포넌트들을 사용하면서 모든 컴포넌트에 대해 console로 지우는 일은 굉장히 큰 일이 되기 때문에 이 기능이 아주 유용할 것임을 짐작할 수 있다.

 

그리하여 오늘은 어떤 component가 낭비되고 있는지 알아볼 것이다.

현재 일기 List를 삭제해도 DiaryEditor 부분도 rerendering 되고 있는것을 알 수 있다.

 

Component가 rerendering되는 때

- state가 변할 때

- 부모 Component가 rerendering 일어날 때

- 자신이 가진 Prop이 변경되는 경우

 

현재 DiaryEditor는 onCreate라는 함수를 prop으로 받고 있다.

이 함수는 일기 저장하기 버튼을 클릭 했을 때, 데이터에 아이템을 추가하는 기능을 가지고 있다.

 

우선 React.memo의 기능을 사용하기위해 아래와 같이 입력을 할 수도 있지만,

다음과 같이 export default 부분에 적용시켜도 문제없이 실행이 된다.

그럼 이렇게 진행을하고,

useEffect가 언제 일어나는지 console로 체크해 보겠다.

더보기
import React, { useEffect, useRef, useState } from "react";

const DiaryEditor = ({ onCreate }) => {
  useEffect(() => {
    console.log("Diary Editor 렌더");
  });

  const authorInput = useRef();
  const contentInput = useRef();

  const [state, setState] = useState({
    author: "",
    content: "",
    emotion: 1,
  });

  const handleChangeState = (e) => {
    setState({
      ...state,
      [e.target.name]: e.target.value,
    });
  };

  const handleSubmit = () => {
    if (state.author.length < 1) {
      // focus
      authorInput.current.focus();
      return;
    }
    if (state.content.length < 5) {
      contentInput.current.focus();
      // focus
      return;
    }
    onCreate(state.author, state.content, state.emotion);
    alert("저장 성공");
    setState({
      author: "",
      content: "",
      emotion: 1,
    });
  };

  return (
    <div className="DiaryEditor">
      <h2>✨오늘의 일기✨</h2>
      <div>
        <input
          ref={authorInput}
          name="author"
          value={state.author}
          onChange={handleChangeState}
        />
      </div>
      <div>
        <textarea
          ref={contentInput}
          name="content"
          value={state.content}
          onChange={handleChangeState}
        />
      </div>
      <div>
        <span>오늘의 감정점수 : </span>
        <select
          name="emotion"
          value={state.emotion}
          onChange={handleChangeState}
        >
          <option value={1}>1</option>
          <option value={2}>2</option>
          <option value={3}>3</option>
          <option value={4}>4</option>
          <option value={5}>5</option>
        </select>
      </div>
      <div>
        <button onClick={handleSubmit}>일기 저장하기</button>
      </div>
    </div>
  );
};
export default React.memo(DiaryEditor);
import React, { useEffect, useRef, useState } from "react";

const DiaryEditor = ({ onCreate }) => {
  useEffect(() => {
    console.log("Diary Editor 렌더");
  });
  ...

근데 여기서는 Diary Editor렌더가 두번 발생한것을 확인할 수 있다.

우선 App.js 파일을 확인하면

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

const App = () => {
  // 전역적으로 Data 관리할 state
  const [data, setData] = useState([]);
  const dataId = useRef(0);
  // data 호출
  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);
  };

  // App component가 mount 되자마자 호출해보자
  // 빈배열 함수를 전달하면 바로 콜백함수를 마운트 되는 시점에 실행되게 된다.
  // getData()로 API 호출
  useEffect(() => {
    getData();
  }, []);

  const onCreate = (author, content, emotion) => {
    const created_date = new Date().getTime();
    const newItem = {
      author,
      content,
      emotion,
      created_date,
      id: dataId.current,
    };
    dataId.current += 1;
    setData([newItem, ...data]); // 새로운 일기를 제일 위로 보내기 위해서
  };

  const onRemove = (targetId) => {
    // filter 기능을 통해 그 부분만 빼고 출력 된다.
    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
      )
    );
  };

  // Memoization
  const getDiaryAnalysis = useMemo(() => {
    const goodCount = data.filter((it) => it.emotion >= 3).length;
    const badCount = data.length - goodCount;
    const goodRatio = (goodCount / data.length) * 100;
    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;

먼저 useState가 빈배열이라 App Component가 한번 rendering하고, DiaryEditor도 한번 rendering한다.

const [data, setData] = useState([]);

Component가 mount된 시점에 호출한 getData()함수에서

  // getData()로 API 호출
  useEffect(() => {
    getData();
  }, []);

결과를 아래와 같이 filtering하고

    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로 전달하여 Data state가 한번 더 바뀌게 된다.

setData(initData);

그래서 App 컴포넌트는 mount되자마자 2번의 rendering된 것이다.

그래서 onCreate함수도 App 컴포넌트가 렌더링되면서 계속 다시 생성되는것이다.

onCreate 함수안에 있는 부분은 똑같지만, 얕은비교때문에 계속 rerendering한다. >> 그래서 onCreate함수때문에 계속 rerendering이 발생한다고 볼 수 있다.

 

일기를 삭제하면 DiaryEditor가 한번더 렌더링 된 것을 알 수 있다.

>> 그래서 onCreate를 리렌더링하지 않으면 최적화 할 수 있다.

 

onCraete함수를 재생성하지 않는 방법은?

useMemo는 사용하면 안된다. - 함수가 아닌 값을 반환하기 때문

https://ko.reactjs.org/docs/hooks-reference.html#usecallback

useCallback

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

메모이제이션된 콜백을 반환합니다.

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

useCallback(fn, deps)은 useMemo(() => fn, deps)와 같습니다.

주의

의존성 값의 배열이 콜백에 인자로 전달되지는 않습니다. 그렇지만 개념적으로는, 이 기법은 콜백 함수가 무엇일지를 표현하는 방법입니다. 콜백 안에서 참조되는 모든 값은 의존성 값의 배열에 나타나야 합니다. 나중에는 충분히 발전된 컴파일러가 이 배열을 자동적으로 생성할 수 있을 것입니다.

eslint-plugin-react-hooks 패키지의 일부로써 exhaustive-deps 규칙을 사용하기를 권장합니다. 그것은 의존성이 바르지 않게 정의되었다면 그에 대해 경고하고 수정하도록 알려줍니다.

그래서 useCallback은 값이 아닌 memoization된 callback함수를 반환해주는 기능을 가지고 있다.

  // useCallback사용
  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([newItem, ...data]); // 새로운 일기를 제일 위로 보내기 위해서
  }, []);

그래서 이렇게 useCallback함수를 사용하면 DiaryEditor 렌더가 출력되지 않는다.

근데, 글 삭제는 정상적으로 되지만 글을 새로 생성하면 새로운 글만 출력되고 이전에 있던 글들은 모두 삭제된다.

이는 아래와 같이 빈배열을 전달해서 그렇다.

dataState가 빈배열이라 그렇다! 그래서 빈 배열에 newItem만 추가해서 그런것이다.

그래서 array에 data를 추가해주어야한다. 그런데 그러면 함수를 재 생성하게 된다.

onCreate가 재생성되지않으면 최신의 data state를 참고할 수 없게 된다.

이러한 상황에는 함수형 업데이트를 사용하면된다.

이전에는 setData(상태변환함수)에 값을 전달해야하며 전달한 값이 새로운 state로 변한다로 배웠다. 근데 여기에다가 함수를 전달해도된다.

그래서 인자로 data를 받아서 화살표함수로 전달 아이템을 추가한 데이터를 리턴하는 콜백함수를 setData에 전달하면된다. 이를 함수형 Update라고 한다.

이렇게 했을때 dependency array를 비워도 항상 최신의 데이터를 인자를 통해 참고할 수 있게 된다.

setData((data) => [newItem, ...data]);
// useCallback사용
  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]); // 새로운 일기를 제일 위로 보내기 위해서
  }, []);

이로인해 onCreate가 정상적으로 실행됨을 알 수 있다.

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

const App = () => {
  // 전역적으로 Data 관리할 state
  const [data, setData] = useState([]);
  const dataId = useRef(0);
  // data 호출
  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);
  };

  // App component가 mount 되자마자 호출해보자
  // 빈배열 함수를 전달하면 바로 콜백함수를 마운트 되는 시점에 실행되게 된다.
  // getData()로 API 호출
  useEffect(() => {
    getData();
  }, []);

  // useCallback사용
  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) => {
    // filter 기능을 통해 그 부분만 빼고 출력 된다.
    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
      )
    );
  };

  // Memoization
  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;
    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;

 

자 그럼 DiaryEditor가 정상적으로 Memoization되었는지 최적화 되었는지 알아보자.

이제 새로고침을하고 콘솔창을 보면 DiaryEditor가 한번만 호출 되었고, 글을 삭제하여도 Rerender되지 않아 호출되지 않았음을 알 수 있다.