상세 컨텐츠

본문 제목

[리액터 스타터2] 7장. useReducer와 상태 관리 / 8장. 최적화

23-24/React.js 2

by YUZ 유즈 2023. 12. 29. 10:00

본문

728x90


1. useReducer 이해하기

<실습 준비하기>

 

이전에 작성한 [할 일 관리] 앱을 열고 component 폴더에 TestComp.js라는 임시 컴포넌트를 하나 만든다.

  • TestComp.js 작성
import { useState } from "react";

function TestComp() {
    const [count, setCount] = useState(0);
    
    const onIncrease = () => {
    	setCount(count + 1);
    };
    const onDecrease = () => {
    	setCount(count - 1);
    };
    return (
        <div>
            <h4>테스트 컴포넌트</h4>
            <div>
                <bold>{count}</bold>
            </div>
            <div>
                <button onClick={onIncrease}>+</button>
                <button onClick={onDecrease}>-</button>
            </div>
        </div>
    );
}
export default TestComp;

TestComp를 App 컴포넌트의 자식으로 배치해 페이지에 렌더링 한다.

 

<상태 변화 코드와 분리>

 

상태 변화 코드란 State 값을 변경하는 코드이다. 앞서 만든 TestComp 컴포넌트의 함수 onIncrease와 onDecrease는 각각 변수 count의 값을 늘리거나 줄이므로 상태 변화 코드라 할 수 있다.

 

상태 변화 코드를 컴포넌트에서 분리한다는 말은 컴포넌트 내부에 작성했던 상태 변화 코드를 외부에 작성한다는 뜻이다. 그러나 지금처럼 useState를 이용해 State를 만들면 상태 변화 코드를 분리할 수 없다. 둘 다 컴포넌트 안에서 선언했기 때문이다. useState를 이용해 State를 생성하면 상태 변화 코드는 반드시 컴포넌트 안에 작성해야 한다. 

 

반면 함수 useReducer를 사용하면 상태 변화 코드를 컴포넌트 밖으로 분리할 수 있다. 상태 변화 코드를 분리하려는 이유는 하나의 컴포넌트 안에 너무 많은 상태 변화 코드가 있으면 가독성을 해쳐 유지 보수를 어렵게 만들기 때문이다.

 

<useReducer의 기본 사용법>

 

useReducer는 useState와 더불어 리액트 컴포넌트에서 State를 관리하는 리액트 훅이다. useState와 달리 State를 관리하는 상태 변화 코드를 컴포넌트와 분리할 수 있다. 파일로도 분리가 가능하기 때문에 컴포넌트 내부가 훨씬 간결해진다.

 

useReducer를 이용해 상태 변화 코드를 컴포넌트와 분리해 보자.

  • TestComp.js 수정
    function TestComp() {
        return (
            <div>
                <h4>테스트 컴포넌트</h4>
                <div>
                    <bold>0</bold>
                </div>
                <div>
                    <button>+</button>
                    <button>-</button>
                </div>
            </div>
        );
    }
    export default TestComp;​
    useState로 만든 기능을 모두 제거했다.
  • TestComp.js 수정
import { useReducer } from "react";

function reducer() {}

function TestComp() {
    const [count, dispatch] = useReducer(reducer, 0);
    return (
        <div>
            <h4>테스트 컴포넌트</h4>
            <div>
                <bold>0</bold>
            </div>
            <div>
                <button>+</button>
                <button>-</button>
            </div>
        </div>
    );
}
export default TestComp;

useState를 이용해 만들었던 카운트 기능을 useReducer를 이용해 똑같이 만들었다.

useReducer를 사용하기 위해 react 라이브러리에서 불러오고 새로운 함수 reducer를 컴포넌트 밖에 만든다.

useReducer를 호출하고 2개의 인수를 전달한다. 첫 번째 인수는 함수 reducer이고 두 번째 인수는 State의 초깃값이다. useReducer도 useSate처럼 배열을 반환하는데, 배열의 첫 번째 요소는 State 변수이고 두 번째 요소는 상태 변화를 촉발하는 함수 dispatch이다.

 

[useReducer의 용법]

const [count, dispatch] = useReducer(reducer, 0);

count = state 변수 / dispatch = 상태 변화 촉발 함수 / useReducer(reducer, 0) = 생성자(상태 변화 함수, 초깃값)

 

  • TestComp.js 수정
(...)
function TestComp() {
    const [count, dispatch] = useReducer(reducer, 0);
    return (
        <div>
            <h4>테스트 컴포넌트</h4>
            <div>
                <bold>{count}</bold>
            </div>
            <div>
                <button onClick={() => dispatch({ type: "INCREASE", data: 1 })}>+</button>
                <button onClick={() => dispatch({ type: "DECREASE", data: 1 })}>-</button>
            </div>
        </div>
    );
}
export default TestComp;

<+> 버튼을 클릭하면 카운트값을 1 늘려야 한다. 따라서 상태 변화가 필요할 때 이를 촉발하는 함수 dispatch를 호출한다. 이때 함수 dispatch에서는 인수로 객체를 전달하는데, 이 객체는 State의 변경 정보를 담고 있다. 이 객체를 다른 표현으로 'action 객체'라고 한다.

 

<+> 버튼을 클릭했을 때, 함수 dispatch는 2개의 프로퍼티로 이루어진 action 객체를 인수로 전달한다. 두 프로퍼티 중 type은 어떤 상황이 발생했는지를 나타낸다. data는 상태 변화에 필요한 값이다. (<-> 클릭 시에도 같은 원리 적용)

 

그러나 아직 버튼을 클릭해도 카운트값이 늘거나 줄지 않는다. 실제 상태 변화는 함수 reducer에서 일어나기 때문이다. dispatch를 호출하면 함수 reducer가 실행되는데, 이 함수가 반환하는 값이 새로운 State값이 된다.

 

정리하면 useReducer가 반환하는 함수 dispatch를 호출하면 useReducer는 함수 reducer를 호출하고, 이 함수가 반환하는 값이 State를 업데이트한다.

  • TestComp.js 수정: reducer 작성
(...)
function reducer(state, action) {
    switch(action.type){
        case "INCREASE":
            return state + action.data;
        case "DECREASE":
            return state - action.data;
        default:
            return state;
    }
}
(...)

함수 reducer에는 2개의 매개변수가 있다. 첫 번째 매개변수 state에는 현재 State의 값이 저장된다. 두 번째 매개변수 action에는 함수 dispatch를 호출하면서 인수로 전달한 action 객체가 저장된다.

함수 reducer가 반환하는 값이 새로운 State값이 된다. action 객체의 type에 따라서 알맞게 처리해 반환한다.

action 객체의 type이 INCREASE도 DECREASE도 아니면 매개변수 state 값을 그대로 반환하여 아무런 상태 변화도 이루어지지 않는다.

 

useReducer는 함수 reducer를 이용해 상태 변화 코드를 컴포넌트 외부로 분리한다. 만약 새로운 상태 변화가 필요하면, 함수 reducer를 다음과 같이 수정해 적절히 대응하면 그만이다.

  • TestComp.js 수정
import { useReducer } from "react";

function reducer(state, action) {
    switch(action.type){
        (...)
        case "INIT":
            return 0;
        default:
            return state;
    }
}

function TestComp() {
    const [count, dispatch] = useReducer(reducer, 0);
    return (
        <div>
            <h4>테스트 컴포넌트</h4>
            <div>
                <bold>{count}</bold>
            </div>
            <div>
                (...)
                <button onClick={() => dispatch({ type: "INIT" })}>0으로 초기화</button>
            </div>
        </div>
    );
}
export default TestComp;

 

2.  [할 일 관리] 앱 업그레이드

 

<useState를 useReducer로 바꾸기>

  • App.js 수정
import { useReducer, useRef } from "react";
(...)
function reducer(state, action) { //상태 변화 코드
    return state;
}

function App() {
  const [todo, dispatch] = useReducer(reducer, mockTodo); 
  const idRef = useRef(3);
  
  const onCreate = (content) => {
  	idRef.current += 1;
  };
  const onUpdate = (targetId) => {
  };
  constonDelete = (targetId) => {
  };
  (...)
}
export default App;

useReducer를 react 라이브러리에서 불러오고 useState 대신 useReducer를 사용할 예정이므로 기존에 작성했던 useState 코드는 모두 삭제한다.

 

앞으로 상태 변화가 필요할 때는 set 함수 대신 상태 변화 촉발 함수인 dispatch를 호출해야 한다. 따라서 App 컴포넌트에서 함수 setTodo를 호출하는 코드도 모두 제거한다.

 

1. Create: 할 일 아이템 추가하기

 

함수 onCreate에서 dispatch를 호출하고, 인수로 할 일 정보를 담은 action 객체를 전달한다.

  • App.js 수정
(...)
function App() {
  (...)
  const onCreate = (content) => {
    dispatch({
      type: "CREATE",
      newItem: {
        id: idRef.current,
        content,
        isDone: false,
        createdDate: new Date().getTime(),
      },
    });
    idRef.current += 1;
  };
  (...)
}
export default App;

새 할 일 아이템을 생성하기 위해 함수 dispatch를 호출한다. 할 일을 추가할 것이므로 type을 CREATE로 설정하고 newItem에는 추가할 할 일 데이터를 설정한다.

 

이제 함수 reducer에서 action 객체의 type이 CREATE일 때, 새 아이템을 추가하는 상태 변화 코드를 작성한다.

  • App.js 수정
    (...)
    function reducer(state, action) {//상태 변화 코드
      switch(action.type){
        case "CREATE": {
          return [action.newItem, ...state];
        }
        default:
          return state;
      }
    }
    (...)
    switch문에서 action 객체의 type별로 다른 상태 변화 코드를 수행한다. action 객체의 type이 CREATE일 때 동작할 case문을 작성했다. action 객체의 newItem에는 추가할 아이템이 저장되어 있다. 기존 할 일 아이템에 action 객체의 아이템이 추가된 새 배열을 반환한다.

2. Update: 할 일 아이템 수정하기

 

  • App.js 수정
(...)
function App() {
  (...)
  const onUpdate = (targetId) => {
    dispatch({
      type: "UPDATE",
      targetId,
    });
  };
  (...)
}

export default App;

action 객체의 type 프로퍼티에 수정을 의미하는 UPDATE를, targetId 프로퍼티에는 체크 여부로 수정할 아이템의 id를 설정한다.

 

  • App.js 수정
(...)
function reducer(state, action) {//상태 변화 코드
  switch(action.type){
    case "CREATE": {
      return [action.newItem, ...state];
    }
    case "UPDATE": {
      return state.map((it) => it.id === action.targetId?{...it, isDone: !it.isDone,}:it);
    }
    default:
      return state;
  }
}
(...)

action.type이 UPDATE일 때 수행할 상태 변화 코드를 작성한다. map 메서드로 순회하면서 매개변수 state에 저장된 아이템 배열에서 aciton.targetId와 id를 비교해 일치하는 아이템의 isDone을 토글한 새 배열을 반환한다.

 

3. Delete: 할 일 삭제 구현하기

 

  • App.js 수정
(...)
function App() {
  (...)
  const onDelete = (targetId) => {
    dispatch({
      type: "DELETE",
      targetId,
    });
  };
  (...)
}
export default App;

action 객체로 type 프로퍼티는 삭제를 의미하는 DELETE를, targetId 프로퍼티는 삭제할 아이템의 id를 설정한다.

  • App.js 수정
(...)
function reducer(state, action) {//상태 변화 코드
  switch(action.type){
    (...)
    case "DELETE": {
      return state.filter((it) => it.id !== action.targetId);
    }
    default:
      return state;
  }
}
(...)

action.type이 DELETE일 때 수행할 상태 변화 코드를 작성한다. filter 메서드로 id와 targetId가 일치하는 할 일 아이템만 제외한 할 일 배열을 생성해 반환한다.

 


1. 최적화와 메모이제이션

'최적화'란 한마디로 웹 서비스의 성능을 개선하는 기술이다. 또한 프로그래밍에서 불필요하게 낭비되는 연산을 줄여 렌더링의 성능을 높이는 방법이다. 

최적화 방법으로는 코드, 폰트, 이미지 파일의 크기를 줄이는 등 여러 기술이 있지만, 내용이 방대할 뿐만 아니라 일부 방법은 아직 확실한 정답이 없다. 따라서 최적화의 기본이라고 할 수 있는 '리액트의 연산 낭비'를 줄이는 데 초점을 맞추어 살펴볼 것이다.

 

리액트 앱에서 연산 최적화는 대부분 '메모이제이션(Memoization)' 기법을 이용한다. '메모이제이션'이란 말뜻 그대로 '메모하는 방법'이다. 메모이제이션은 특정 입력에 대한 결과를 계산해 메모리 어딘가에 저장했다가, 동일한 요청이 들어오면 저장한 결괏값으르 제공해 빠르게 응답하는 기술이다. 결과적으로 이 기법을 이용하면 불필요한 연산을 줄여 주어 프로그램의 실행 속도를 빠르게 만든다. 알고리즘을 공부하는 사람들은 이 기능을 동적 계획법(Dynamic Programming, 줄여서 DP)이라고 한다.


2. 함수의 불필요한 재호출 방지하기

 

처음으로 살펴볼 리액트 최적화 관련 기능으로는 useMemo가 있다. useMemo는 메모이제이션 기법을 이용해 연산의 결괏값을 기억했다가 필요할 때 사용함으로써 불필요한 함수 호출을 막아주는 리액트 훅이다. 

 

<할 일 분석 기능 추가하기>

 

불필요한 함수 호출이 언제 발생하는지 살펴보기 위해 앞서 만든 [할 일 관리] 앱에 새 기능을 추가한다.

추가할 기능은 TodoList 컴포넌트에서 할 일 아이템을 분석하는 일이다. 이 기능은 추가한 할 일 아이템이 모두 몇 개인지, 완료 아이템과 미완료 아이템은 각각 몇 개인지 검색해 페이지에 렌더링 한다.

  • TodoList.js 수정
(...)
const TodoList = ({ todo, onUpdate, onDelete }) => {
  (...)
  const analyzeTodo = () => {
    const totalCount = todo.length;
    const doneCount = todo.filter((it) => it.isDone).length;
    const notDoneCount = totalCount - doneCount;
    return {
      totalCount,
      doneCount,
      notDoneCount,
    };
  };
  (...)
};
export default TodoList;

TodoList 컴포넌트에 새로운 함수 analyzeTodo를 만든다. 이 함수는 현재 State 변수와 todo의 아이템 총개수를 totalCount, 완료 아이템 개수를 doneCount, 미완료 아이템 개수를 notDoneCount에 각각 저장한 다음 객체에 담아 반환한다.

 

  • TodoList.js 수정
(...)
const TodoList = ({ todo, onUpdate, onDelete }) => {
  (...)
  const analyzeTodo = () => {
  	(...)
  };
  const { totalCount, doneCount, notDoneCount } = analyzeTodo();

  return (
    <div className="TodoList">
      <h4>Todo List 🌱</h4>
      <div>
        <div>총개수: {totalCount}</div>
        <div>완료된 할 일: {doneCount}</div>
        <div>아직 완료하지 못한 할 일: {notDoneCount}</div>
      </div>
      (...)
    </div>
  );
};
export default TodoList;

함수 analyzeTodo를 호출하고 반환 객체를 구조 분해 할당한다.  그리고 totalCount, doneCount, notDoneCount를 각각 렌더링 한다.

 

<문제점 파악하기>

 

할 일 아이템을 분석하는 함수 analyzeTodo는 todo에 저장한 아이템 개수에 비례해 수행할 연산량이 증가한다. 만약 todo에 저장한 아이템 개수가 많아지면 성능상의 문제를 일으킬 가능성이 있다.

연산량을 줄이려면 함수 analyzeTodo를 불필요하게 호출하는 일이 일어나지 않아야 한다. 함수에 불필요한 호출이 있는지 확인하기 위해 함수 analyzeTodo를 호출할 때마다 콘솔에 메시지를 출력하도록 한다.

  • TodoList.js 수정
(...)
const TodoList = ({ todo, onUpdate, onDelete }) => {
  (...)
  const analyzeTodo = () => {
    console.log("analyzeTodo 함수 호출");
    (..)
  };
  (...)
};
export default TodoList;

 

 

 

함수 analyzeTodo 가 얼마난 빈번히 호출되는지 확인하기 위해 TodoLise 컴포넌트의 검색 폼에서 검색어 'react'를 입력한다. 그 결과,  "analyzeTodo 함수 호출"이라는 메시지가 총 6번 콘솔에 출력된다. TodoList 컴포넌트를 처음 마운트할 때 1번, 검색 폼에서 react 5글자를 입력할 때마다 리렌더 되어 총 6번 출력된다.

 

컴포넌트 내부에서 선언한 함수는 렌더링 할 때마다 실행된다. 그 이유는 컴포넌트의 렌더링이란 결국 컴포넌트 함수를 호출하는 작업과 동일하기 때문이다. 

즉, State 변수 search가 업데이트되어 TodoList 컴포넌트가 리렌더 되면, 내부에 선언한 함수 analyzeTodo 또한 다시 호출된다.

 

<useMemo를 이용해 [할 일 관리] 앱 최적화하기>

useMemo로 앞서 만든 함수 analyzeTodo를 불필요하게 다시 호출하지 않도록 해보자.

 

1. useMemo의 기본 사용법

 

useMemo를 사용하면 특정 함수를 호출했을 때 그 함수의 반환값을 기억한다. 그리고 같은 함수를 다시 호출하면 기억해 두었던 값을 반환한다. 따라서 useMemo를 이용하면 함수의 반환값을 다시 구하는 불필요한 연산을 수행하지 않아 성능을 최적화할 수 있다. 

const value = useMemo(callback, deps);

callback = 콜백 함수 / deps = 의존성 배열

 

함수 useMemo를 호출하고 2개의 인수로 콜백 함수와 의존성 배열을 전달한다. 호출된 useMemo는 의존성 배열에 담긴 값이 바뀌면 콜백 함수를 다시 실행하고 결괏값을 반환한다.

 

2. 함수 analyzeTodo의 재호출 방지하기

 

useMemo를 이용해 [할 일 관리] 앱에 추가한 함수 analyzeTodo를 불필요하게 다시 호출하지 않도록 최적화한다.

  • TodoList.js 수정
import { useMemo, useState } from "react";
(...)
const TodoList = ({ todo, onUpdate, onDelete }) => {
  (...)
  const analyzeTodo = useMemo(() => {
    console.log("analyzeTodo 함수 호출");
    const totalCount = todo.length;
    const doneCount = todo.filter((it) => it.isDone).length;
    const notDoneCount = totalCount - doneCount;
    return {
      totalCount,
      doneCount,
      notDoneCount,
    };
  }, [todo]);
  const { totalCount, doneCount, notDoneCount } = analyzeTodo;

  return (
    <div className="TodoList">
      <h4>Todo List 🌱</h4>
      <div>
        <div>총개수: {totalCount}</div>
        <div>완료된 할 일: {doneCount}</div>
        <div>아직 완료하지 못한 할 일: {notDoneCount}</div>
      </div>
      (...)
    </div>
  );
};
export default TodoList;

useMemo를 사용하기 위해 react 라이브러리에서 불러온다. useMemo를 호출하고 첫 번째 인수로 함수 analyzeTodo를 전달하고 두 번째 인수로 todo가 담긴 배열을 전달한다. 이 useMemo는 todo 값이 변할 때만 첫 번째 인수로 전달한 콜백 함수를 호출하여 결과값을 반환한다. useMemo는 함수가 아닌 값을 반환하므로 함수 analyzeTodo에는 값이 저장된다. 따라서 구조분해 할당의 대상을 기존의 analyzeTodo()가 아닌 analyzeTodo로 변경해야 한다.


3. 불필요한 컴포넌트 리렌더 방지하기

두 번째로 살펴볼 리액트의 최적화 기능은 React.memo이다. React.memo를 이용하면 메모이제이션 기법으로 컴포넌트가 불필요하게 리렌더 되는 상황을 방지할 수 있다.

 

<고차 컴포넌트와 횡단 관심사>

1. 고차 컴포넌트

 

HOC는 Higher Order Component의 약자로 우리말로는 고차 컴포넌트라고 한다.

고차 컴포넌트는 컴포넌트의 기능을 다시 사용하기 위한 리액트의 고급 기술로, useMemo, useEffect처럼 use 키워드가 앞에 붙는 리액트 훅과는 다르다.

 

고차 컴포넌트는 인수로 전달된 컴포넌트를 새로운 컴포넌트로 반환하는 함수이다.

다만 고차 컴포넌트는 전달된 컴포넌트를 그대로 반환하는 게 아니라 어떤 기능을 추가해 반환한다. 이렇게 기능을 추가해 반환한 컴포넌트를 '강화된 컴포넌트'라고 한다.

고차 컴포넌트인 withFunc를 이용해 기능A라는 컴포넌트를 감싼 다음, 새 기능이 추가된 강화된 컴포넌트(EnhancedComp)를 반환하는 예.

const EnhancedComp = withFunc(Comp);

 

2. 횡단 관심사(Cross-Cuttiing Concerns)

 

고차 컴포넌트를 이용하면 횡단 관심사 문제를 효율적으로 해결할 수 있어 실무에서 많이 활용한다.

횡단 관심사란 프로그래밍에서 비즈니스 로직과 구분되는 공통 기능을 지칭할 때 사용하는 용어이다. 반면 비지니스 로직은 해당 컴포넌트가 존재하는 핵심 기능을 표현할 때 사용한다. 횡단 관심사 코드는 주로 로깅, 데이터베이스 접속, 인가 등 여러 곳에서 호출해 사용하는 코드들을 말한다.

컴포넌트의 핵심 기능(비즈니스 로직)을 세로로 배치한다고 했을 때, 여러 컴포넌트에서 공통을 사용하는 기능은 가로로 배치하게 된다. 따라서 공통 기능들이 핵심 컴포넌트들을 마치 '횡단'하는 모습이다.

 

모든 컴포넌트가 마운트와 동시에 콘솔에 특정 메시지를 출력하는 기능은 컴포넌트의 핵심 로직은 아니다. 수많은 컴포넌트에서 공통으로 사용하는 '횡단 관심사'에 해당하는 기능이다.

그런데 여러 컴포넌트에서 횡단 관심사 코드를 작성하는 일은 중복 코드를 만드는 주된 요인 중 하나이다. 고차 컴포넌트를 이용하면 횡단 관심사 코드를 함수로 분리할 수 있다.

 

<React.memo를 이용해 [할 일 관리] 앱 최적화하기>

 

1. React.memo 기본 사용법

 

React.memo는 인수로 전달한 컴포넌트를 메모이제이션된 컴포넌트로 만들어 반환한다. 이때 Props가 메모이제이션의 기준이 된다. 즉, React.memo가 반환하는 컴포넌트는 부모 컴포넌트에서 전달된 Props가 변경되지 않는 한 리렌더 되지 않는다.

React.memo를 사용하는 방법은 매우 간단하다. 단지 강화하고 싶은, 즉 메모이제이션을 적용하고 싶은 컴포넌트를 React.memo로 감싸면 된다.

const memoizedComp = React.memo(Comp);

Comp = 메모이제이션하려는 컴포넌트

 

2. Header 컴포넌트의 리렌더 방지하기

 

[할 일 관리] 앱의 Header 컴포넌트는 부모 컴포넌트인 App에서 아무런 Props도 받지 않는다. 단지 오늘 날짜를 표시하는 아주 단순한 기능만 한다. 이 컴포넌트는 어떤 상황에서도 리렌더 할 필요가 없다. 따라서 리렌더 발생 여부를 확인할 수 있는 출력 코드를 추가하면 불필요한 리렌더가 일어난 것을 확인할 수 있다 

이 문제를 다음과 같이 React.memo를 이용해 해결할 수 있다.

  • Header.js 수정
import React from "react";
(...)
const Header = () => {
    console.log("Header 업데이트"); //Header 컴포넌트 호출, 리렌더될 때마다 콘솔에 출력
    return (
        <div className="Header">
            <h3>오늘은 📅</h3>
            <h1>{new Date().toDateString()}</h1>
        </div>
    );
};
export default React.memo(Header); //Header 컴포넌트에 메모이제이션을 적용해 내보냄.

 

 

3. TodoItem 컴포넌트 리렌더 방지하기

 

이번에는 낱낱의 할 일 아이템을 담당하는 TodoItem 컴포넌트에서 불필요한 렌더링이 일어나는지 확인하고 최적화해 보자.

TodoItem 컴포넌트는 사용자가 등록한 할 일 아이템의 개수만큼 렌더링 한다. 따라서 할 일 아이템이 수십 개에서 수백 개 이상 등록할 경우, 불필요한 렌더링이 발생하면 치명적인 성능 문제를 야기하게 된다.

 

할 일 아이템인 TodoItem 컴포넌트는 개별 아이템 체크박스에서 완료/미완료를 토글할 때가 아니면 리렌더 할 필요가 없다. 따라서 React.memo를 이용해 불필요한 TodoItem 컴포넌트의 리렌더를 방지한다.

  • TodoItem.js 수정
import React from "react";
(...)
const TodoItem = ({ id, content, isDone, createdDate, onUpdate, onDelete }) => {
  console.log(`${id} TodoItem 업데이트`);
  (...)
};
export default React.memo(TodoItem);

수정 결과 TodoList의 검색 폼에서 검색을 해도 TodoItem 컴포넌트는 리렌더 되지 않는다. 

하지만 새 아이템을 추가하면 리렌더 된다.

이는 React.memo는 Props를 변경하지 않으면 컴포넌트를 리렌더 하지 않는다는 점 때문이다.

할 일 아이템을 추가한 결과, todo가 업데이트되어 App 컴포넌트가 리렌더 되어 TodoItem에 전달되는 Props도 변경되어 리렌더 된 것이다.

 

TodoItem은 Props로 id, content 등과 같이 원시 자료형에 해당하는 값뿐만 아니라, onUpdate, onDelete와 같이 객체 자료형에 해당하는 함수도 받는다. 이 함수들은 App 컴포넌트에서 생성되어 Props로 전달된다.

따라서 App 컴포넌트를 리렌더 하면 onUpdate, onCreate등의 함수도 전부 다시 생성된다. 2장에서 동등 비교 연산자 ===로 객체 자료형을 비교할 때는 해당 객체의 참좃값을 기준으로 한다고 하였다. 

 

그렇기 때문에 App 컴포넌트를 리렌더하면 함수 onUpdate와 onDelete가 다시 만들어지는데, 이때 함수는 새롭게 선언한 것과 마찬가지로 참조값이 변경된다. 따라서 이 함수를 Props로 받는 컴포넌트는 React.memo를 적용했다고 하더라도 다시 렌더링 된다.

 

이런 문제를 해결하기 위해 컴포넌트를 리렌더해돌 함수를 다시 생성하지 않도록 만들어 주는 리액트 훅 useCallback을 사용한다.


4. 불필요한 함수 재생성 방지하기

useCallback은 컴포넌트가 리렌더 될 때 내부에 작성된 함수를 다시 생성하지 않도록 메모이제이션하는 리액트 훅이다.

 

<useCallback을 이용해 [할 일 관리] 앱 최적화하기>

 

1. useCallback의 기본 사용법

const memoizedFunc = useCallback(func, deps)

func = 콜백 함수 / deps = 의존성 배열

 

useCallback은 의존성 배열에 담긴 값이 바뀌면 첫 번째 인수로 전달한 콜백 함수를 다시 만들어 반환한다. 만약 첫 번째 인수로 전달한 콜백 함수를 어떤 경우에도 다시 생성되지 않게 하려면 의존성 배열을 빈 배열로 전달하면 된다.

 

2. useCallback과 함수형 업데이트

 

useCallback을 이용해 [할 일 관리] 앱을 최적화하기 전에 유의할 사항이 있다. useCallback의 첫 번째 인수로 전달한 콜백 함수에서 State 변수에 접근하는 경우, 문제가 발생할 수 있다. 

const onCreate = useCallback(() => {
	setState([newItem, ...state]);
}, [])

이 코드에서 useCallback으로 전달한 의존성 배열이 빈 배열이므로, 함수 onCreate는 처음 생성된 후에는 컴포넌트가 리렌더 되어도 다시 생성되지 않는다.

이 경우 useCallback에서 전달한 콜백 함수에서 State 변수에 접근하면 컴포넌트를 마운트 할 때의 값, 즉 State의 초깃값이 반환된다. 따라서 마운트할 때의 State 값만 사용할 수 있다.

 

이렇듯 useCallback으로 래핑된 함수 onCreate는 State의 변화를 추적하지 못하므로 자칫 의도치 않은 동작을 야기할 수 있다. 

const onCreate = useCallback(() => {
	setState([newItem, ...state]);
}, [state])

그렇다고  이렇게 의존성 배열에 state 변수를 전달하면 결국 이를 업데이트할 때마다 함수 onCreate를 계속 재생성하므로 useCallback을 적용한 의미가 사라진다.

 

이때는 setState의 인수로 콜백 함수를 전달하는 리액트의 '함수형 업데이트' 기능을 사용하면 된다.

const onCreate = useCallback(() => {
	setState((state => [newItem, ...state]);
}, []);

setState에서 콜백 함수를 전달하면 함수형 업데이트를 사용할 수 있는데, 이 함수는 항상 최신 State 값을 매개변수로 저장한다. 그리고 콜백 함수가 반환한 값은 새로운 State 값이 되어 업데이트된다. 

따라서 useCallback을 사용하면서 setState로 최신 state 값을 추적하려면 함수형 업데이트 기능을 이용해야 한다.

 

3. useCallback을 이용해 TodoItem 컴포넌트의 리렌더 방지하기

 

  • App.js 수정
import { useCallback, useReducer, useRef } from "react";

(...)

  const onUpdate = useCallback((targetId) => {
    dispatch({
      type: "UPDATE",
      targetId,
    });
  }, []);

  const onDelete = useCallback((targetId) => {
    dispatch({
      type: "DELETE",
      targetId,
    });
  }, []);
(...)

useReducer가 반환하는 함수 dispatch는 함수 reducer를 호출하는데, 이 reducer는 항상 최신 state를 인수로 받는다. 따라서 State 관리 도구로 useState가 아닌 useReducer를 이용할 때는 함수형 업데이트를 사용하지 않아도 된다. 따라서 TodoItem 컴포넌트에 함수로 전달되는 Props인 onUpdate와 onDelete만 다시 생성하지 않도록 useCallback을 이용해 최적화한다.

 

<최적화할 때 유의할 점>

  1. 최적화는 항상 마지막에 한다. : 최적화 이후에는 만든 기능을 수정하거나 확장하기 어렵기 때문에 보통 프로젝트 개발이 끝나고 가장 마지막에 최적화한다. 
  2. 모든 것을 최적화할 필요는 없다. : 리액트 앱에 있는 모든 컴포넌트의 아주 사소한 연산이나 리렌더까지 다 찾아내어 최적화할 필요는 없다. 최적화는 일반적으로 부하가 많으리라 예상되거나, 복잡하고 비싼 연산을 수행하거나, 리스트처럼 컴포넌트가 반복적으로 나타날 것이 예상되는 지점을 대상으로 진행한다.
  3. 컴포넌트 구조를 잘 설계했는지 다시 한번 돌아본다. : 컴포넌트를 기능이나 역할 단위로 잘 분리했는지 먼저 확인한 다음 최적화한다.
  4. 최적화는 여기서 끝나지 않는다. : 여기서는 리액트 앱의 연산 최적화만 다루었지만 최적화 기술은 매우 다양하다. 폰트 최적화, 번들 사이즈 최적화 등과 같이 추가로 공부해야 할 기술은 매우 많으며 시간이 지남에 따라 계속 발전하고 바뀐다.

 


 


Quiz

1. (__ __ __)란 State 값을 변경하는 코드이다.

2. (__________)는 useState와 더불어 리액트 컴포넌트에서 State를 관리하는 리액트 훅이다. 다만 useState와 달리 State를 관리하는 상태 변화 코드를 컴포넌트와 분리할 수 있다. 

3.  상태 변화가 필요할 때 이를 촉발하는 함수 (_______)를 호출한다. 이때 이 함수는 인수로 객체를 전달하는데, 이 객체는 State의 변경 정보를 담고 있다. 이 객체를 다른 표현으로 (_____)객체라고 한다.

4. (___)는 프로그래밍에서 불필요하게 낭비되는 연산을 줄여 렌더링의 성능을 높이는 방법이다. 

5. (______)은 특정 입력에 대한 결과를 계산해 메모리 어딘가에 저장했다가, 동일한 요청이 들어오면 저장한 결괏값으르 제공해 빠르게 응답하는 기술이다. 

6. (_______)는 메모이제이션 기법을 이용해 연산의 결괏값을 기억했다가 필요할 때 사용함으로써 불필요한 함수 호출을 막아주는 리액트 훅이다. 

7.  (__________)는 메모이제이션 기법으로 컴포넌트가 불필요하게 리렌더 되는 상황을 방지한다.

8.  (__ ____)는 컴포넌트의 기능을 다시 사용하기 위한 리액트의 고급 기술로, 인수로 전달된 컴포넌트에 기능을 추가해 새로운 컴포넌트로 반환하는 함수이다.

9. (__ ___)란 프로그래밍에서 비즈니스 로직과 구분되는 공통 기능을 지칭할 때 사용하는 용어이다. 

 


1. 아래 코드를 useReducer를 이용해 상태 변화 코드를 컴포넌트와 분리해 수정해 보세요.

import { useState } from "react";

function TestComp() {
    const [count, setCount] = useState(0);
    
    const onIncrease = () => {
    	setCount(count + 1);
    };
    const onDecrease = () => {
    	setCount(count - 1);
    };
    return (
        <div>
            <h4>테스트 컴포넌트</h4>
            <div>
                <bold>{count}</bold>
            </div>
            <div>
                <button onClick={onIncrease}>+</button>
                <button onClick={onDecrease}>-</button>
            </div>
        </div>
    );
}
export default TestComp;

 

2. 다음과 같이 작성한 함수 analyzeTodo가 불필요하게 리렌더 되지 않도록 수정해 보세요.

import { useMemo, useState } from "react";
(...)
const TodoList = ({ todo, onUpdate, onDelete }) => {
  (...)
  const analyzeTodo = () => { 
    console.log("analyzeTodo 함수 호출");
    const totalCount = todo.length;
    const doneCount = todo.filter((it) => it.isDone).length;
    const notDoneCount = totalCount - doneCount;
    return {
      totalCount,
      doneCount,
      notDoneCount,
    };
  };
  const { totalCount, doneCount, notDoneCount } = analyzeTodo(); 

  return (
    <div className="TodoList">
      <h4>Todo List 🌱</h4>
      <div>
        <div>총개수: {totalCount}</div>
        <div>완료된 할 일: {doneCount}</div>
        <div>아직 완료하지 못한 할 일: {notDoneCount}</div>
      </div>
      (...)
    </div>
  );
};
export default TodoList;

 상태 변화 코드 / useReducer / dispatch / action / 최적화 / 메모이제이션 / useMemo / React.memo / 고차 컴포넌트 / 횡단 관심사

 


1.

import { useReducer } from "react";

function reducer(state, action) {
    switch(action.type){
        case "INCREASE":
            return state + action.data;
        case "DECREASE":
            return state - action.data;
        default:
            return state;
    }
}

function TestComp() {
    const [count, dispatch] = useReducer(reducer, 0);
    return (
        <div>
            <h4>테스트 컴포넌트</h4>
            <div>
                <bold>{count}</bold>
            </div>
            <div>
                <button onClick={() => dispatch({ type: "INCREASE", data: 1 })}>+</button>
                <button onClick={() => dispatch({ type: "DECREASE", data: 1 })}>-</button>
            </div>
        </div>
    );
}
export default TestComp;

 

2. useMemo를 사용한다.

 

import { useMemo, useState } from "react";
(...)
const TodoList = ({ todo, onUpdate, onDelete }) => {
  (...)
  const analyzeTodo = useMemo(() => { //수정
    console.log("analyzeTodo 함수 호출");
    const totalCount = todo.length;
    const doneCount = todo.filter((it) => it.isDone).length;
    const notDoneCount = totalCount - doneCount;
    return {
      totalCount,
      doneCount,
      notDoneCount,
    };
  }, [todo]); //수정
  const { totalCount, doneCount, notDoneCount } = analyzeTodo; //수정

  return (
    <div className="TodoList">
      <h4>Todo List 🌱</h4>
      <div>
        <div>총개수: {totalCount}</div>
        <div>완료된 할 일: {doneCount}</div>
        <div>아직 완료하지 못한 할 일: {notDoneCount}</div>
      </div>
      (...)
    </div>
  );
};
export default TodoList;

출처: 이정환, 『한 입 크기로 잘라먹는 리액트』, 프로그래밍인사이트(2023),  p344-383.

Editor: minyong

 

728x90

관련글 더보기