본문 바로가기
웹/24-StudyWithPnP

[React.js] 7주차 - 성능 최적화: useMemo, useCallback, useContext, React.memo

by 보먀 2024. 11. 26.
728x90
반응형

1. useReducer

 

컴포넌트 내부에 새로운 State 를 생성하는 리액트 훅으로 모든 useState 는 useReducer 로 대체 가능하다.

 

그렇다면 useReducer 와 useState 가 다른 점이 무엇일까? 

-> useState 를 사용하면 컴포넌트 내부에 상태관리 코드를 작성해야 하지만, useReducer 를 사용하면 컴포넌트 내부에 State 생성만 해놓고 실제 State 를 관리하는 코드는 컴포넌트 외부에 작성할 수 있다

 

 

1.1. useReducer 사용하기

 

useReducer 훅의 첫 번째 인자로 상태를 실제로 변화시켜주는 reducer 함수가 들어가고, 두번째 인자로는 현재의 상태(State) 값이 들어간다.

const [state, dispatch] = useReducer(reducer, state);
  • reducer: 상태를 실제로 변화시키는 변환기 역할을 하는 함수로 switch 문으로 작성하는 것이 일반적
  • dispatch: 상태 변화를 요청만 해주는 역할을 하는 함수로 액션 객체를 인수로 가짐
  • 액션 객체: 어떻게 상태를 변화시킬 것인지에 대한 정보를 가지고 있는 객체
import React, { useReducer } from 'react'

// reducer: 상태를 실제로 변환시키는 변환기 역할
function reducer(state, action) {

    switch (action.type) {
        case 'INCREASE': return state + action.data;
        case 'DECREASE': return state - action.data;
        default: return state;
    }
}

const Exam = () => {
  
  // dispatch: 상태변화를 요청해주는 역할
  const [state, dispatch] = useReducer(reducer, 0);

  const onClickPlus = () => {
    // dispatch 안에 액션 객체
    dispatch({ 
        type: "INCREASE",
        data: 1
    })
  }

  const onClickMinus = () => {
    dispatch({
        type: "DECREASE",
        data: 1
    })
  }

  return (
    <div>
        <h1>{state}</h1>
        <button onClick={onClickPlus}>+</button>
        <button onClick={onClickMinus}>-</button>
    </div>
  )
}

export default Exam

 

 

- 기존의 Todo 앱을 useReducer 로 업그레이드 시키기

import Header from "./components/Header";
import Editor from "./components/Editor";
import List from "./components/List";
import { useRef, useReducer } from "react";
import './App.css';

const mockData = [
  {
    id : 0,
    isDone:false,
    content : "React 공부하기",
    date : new Date().getTime(),
  },
  {
    id : 1,
    isDone:false,
    content : "빨래하기",
    date : new Date().getTime(),
  },
  {
    id : 2,
    isDone:false,
    content : "노래 연습하기",
    date : new Date().getTime(),
  },
];

function reducer(state, action){
  switch(action.type) {
    case "CREATE" : return [action.data,...state];
    case "UPDATE" : return state.map((item) => 
        item.id === action.targetId
        ? {...item,isDone: !item.isDone} : item );
    case "DELETE": return state.filter((item)=>item.id !== action.targetId);
    default: return state;
  }
}

function App() {
  
  const [todos,dispatch] = useReducer(reducer,mockData);
  const idRef = useRef(3);

  const onCreate = (content) => {
    dispatch({
      type: "CREATE",
      data: {
        id: idRef.current ++,
        isDone:false,
        content:content,
        date: new Date().getTime(),
      }
    })
  }

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

  const onDelete = (targetId) => {
    dispatch({
      type: "DELETE",
      targetId: targetId,
    })
  }

  return (
    <div className="App">
      <Header/>
      <Editor onCreate={onCreate}/>
      <List todos={todos} onUpdate={onUpdate} onDelete={onDelete}/>
    </div>
  );
}

export default App;

 

 

 

2. 최적화 (Optimization)

 

웹 서비스의 성능을 개선하는 모든 행위

 

 

React App 내부의 최적화 방법

  • 컴포넌트 내부의 불필요한 연산 방지 -> useMemo
  • 컴포넌트 내부의 불필요한 함수 재생성 방지 -> useCallback
  • 컴포넌트의 불필요한 리렌더링 방지 -> React.memo

 

2.1. useMemo

 

"메모이제이션" 기법을 기반으로 불필요한 연산을 최적화 하는 리액트 훅

 

프로그램에서 동일한 연산을 반복적으로 수행해야 할 경우, 매번 새롭게 계산해야 한다면 불필요한 자원이 낭비가 된다. 

그래서 useMemo최초로 연산이 실행되었을 때의 결과 값을 메모리에 저장해두었다가 저장되어 있었던 결과값을 돌려주는 방식을 취해 불필요한 연산이 실행되지 않도록 막는다.

 

 

- useMemo 사용하기

 

첫 번째 인자는 콜백함수, 두 번째 인자는 deps 이다. 

deps 로 전달된 값이 바뀔 때만 첫 번째 인자로 전달된 콜백함수가 다시 실행된다. 만약 deps 가 빈 배열이라면 컴포넌트가 최초로 렌더링되었을 때 한번만 콜백함수가 실행된다.  

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

 

useMemo 는 인자로 들어온 콜백함수가 반환한 값을 그대로 다시 반환해 주기 때문에 이렇게 변수에 넣어서 사용할 수 있다.

const memoization = useMemo(() => {}, []);

 

 

- 그렇다면 useMemo 는 왜 필요할까?

 

저번에 만들었던 Todo 앱의 List 컴포넌트에 아래의 코드를 추가해준다면, 이 코드는 List 컴포넌트가 리렌더링될 때마다 계산이 된다. 

List 컴포넌트의 렌더링에 직접적으로 영향을 주는 todos 의 변화가 있지 않더라도 List 컴포넌트의 부모 컴포넌트에 변화가 생기면 List 컴포넌트도 리렌더링 되고, 그 과정에서 getAnalyzeData 에서 불필요한 연산이 반복된다. (todos 에 변화가 없기 때문에 같은 결과값이 나오는 연산을 리렌더링이 일어날 때마다 반복하게 됨)

const getAnalyzeData = () => {
    const totalCount = todos.length;
    const doneCount = todos.filter((todo) => todo.isDone).length;
    const notDoneCount = totalCount - doneCount;

    return {
      totalCount,
      doneCount,
      notDoneCount
    }
  };

  const { totalCount, doneCount, notDoneCount } = getAnalyzeData()

 

그래서 이렇게 계산하기 보다는 useMemo 를 사용해서 계산한 값을 메모이제이션 해주면 불필요한 계산을 막을 수 있다. 

const { totalCount, doneCount, notDoneCount } = useMemo(() => {
    const totalCount = todos.length;
    const doneCount = todos.filter((todo) => todo.isDone).length;
    const notDoneCount = totalCount - doneCount;

    return {
      totalCount,
      doneCount,
      notDoneCount
    }
  }, [todos])

 

 

2.2. React.memo

 

컴포넌트를 인수로 받아, 최적화된 컴포넌트로 만들어 반환한다. 최적화된 컴포넌트는 Props 를 기준으로 메모이제이션이 된다. 

const MemoizedComponent = memo(Component)

 

이게 무슨말이냐 하면,

일반적으로 부모 컴포넌트가 리렌더링이 되면 자식 컴포넌트도 같이 리렌더링 되는데, React.memo 를 사용해서 자식 컴포넌트를 메모이제이션 시켜놓으면 Props 가 전과 같은 경우 부모 컴포넌트가 리렌더링이 되더라도 리렌더링이 되지 않기 때문에 불필요한 리렌더링이 방지되어서 자동으로 최적화가 이루어지게 된다. 

 

이전에 만들었던 Todo 앱의 Header 컴포넌트는 부모 컴포넌트가 리렌더링이 될 때마다 의미없이 리렌더링이 되고 있었으므로, React.memo 를 사용해서 메모이제이션(최적화)을 해놓았다. 

import React, { memo } from 'react'
import './Header.css'

const Header = () => {
  return (
    ...
  )
}

// 최적화된 헤더를 내보냄
export default memo(Header)

 

TodoItem 컴포넌트 역시 Props 로 전달된 값에 변화가 있는 경우에만 렌더링하기 위해 memo 로 감싸서 메모이제이션을 해주었다. 

근데, 이 경우 Header 컴포넌트와 다르게 전달되는 Props 의 값이 같을 때에도 렌더링이 일어나는 것을 확인할 수 있었다. 

import React, { memo, useContext } from 'react'
import './TodoItem.css'
import { TodoDispatchContext } from '../../../section10/src/App';

const TodoItem = ({ id, isDone, content, date}) => {

    const { onUpdate, onDelete } = useContext(TodoDispatchContext);

    const onChangeCheckbox = () => {
        onUpdate(id);
    };

    const onClickDeleteButton = () => {
        onDelete(id);
    };

    return (
        ...
    )
}

export default memo(TodoItem)

 

먼저 사용자가 토글 버튼을 클릭하면 todos 에 변화가 생기고, todos 를 가지고 있는 App 컴포넌트가 리렌더링이 일어난다.

App 함수(컴포넌트)가 다시 호출이 되면  내부에 있던 onCreate, onUpdate, onDelete 함수들이 새롭게 만들어지게 된다. 

 

함수는 객체 타입이기 때문에 새롭게 생성된 함수들이 이전에 존재하던 함수와 정확하게 같은 동작을 한다고 하더라도 다른 주소값을 가지는데 (객체는 참조형), 그래서 App 컴포넌트에서 TodoItem 컴포넌트로 전달되는 onUpdate, onDelete 함수 역시 매번 다른 함수로 인식되게 된다. 

 

React.memo 는 전달받은 Props 에 따라 리렌더링 유무를 결정하는데, 매번 새롭게 생성된 함수가 전달이 되기 때문에 전달 받은 Props 가 같지 않다고 인식되어 계속해서 리렌더링이 일어나는 것이다. 

(기본적으로 memo 는 과거와 현재의 Props 를 얕은 비교로 비교하기 때문에 객체 타입의 값은 무조건 서로 다른 값이라고 인식함)

 

 

- 그렇다면 어떻게 리렌더링을 막을 수 있을까?

 

1. memo 커스텀해서 사용하기

 

memo 메서드에 두 번째 인자로 콜백함수를 넣어주면, memo 는 props 가 바뀌었는지 스스로 판단하는 것이 아니라 콜백함수에 의지해서 판단하게 된다. 객체 타입인 onUpdate, onDelete 를 제외한 값만 비교해서 다르다면 Props 가 다르다고 판단하게 하고 그 외의 경우에는 같다고 판단하도록 만든다. 

 

하지만, 이 경우 onUpdate, onDelete 와 같은 함수의 재생성을 막는 것이 아니라 재생성된 함수를 같도록 인식하게 하는 것이므로 useCallback 을 사용하는 것을 권장한다. 

export default memo(TodoItem, (prevpros, nextprops) => {
    if(prevpros.id !== nextprops.id) return false;
    if(prevpros.isDone !== nextprops.isDone) return false;
    if(prevpros.content !== nextprops.content) return false;
    if(prevpros.date !== nextprops.date) return false;

    return true;
});

 

2) useCallback 사용하기

2.3 절에서 더 알아보도록 하자

 

 

2.3. useCallback 

 

 

- useCallback 사용하기

 

useCallback 은 첫 번째 인자로 메모이제이션 할 함수를 두 번째 인자로는 deps 를 넣어준다. 

useMemo 와 비슷하게 deps 에 값이 바뀔 때 함수를 재생성하고, deps 가 빈배열이라면 컴포넌트가 최초로 마운트될 때만 함수를 생성하고 더 이상 함수를 생성하지 않는다.

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

 

useCallback 은 첫 번째 인자로 넣어준 콜백함수를 그대로 반환해주기 때문에 아래와 같이 사용할 수 있다.

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

 

 

이제 위에서 TodoItem 을 useCallback 으로 최적화 시켜보자.

onCreate, onUpdate, onDelete 를 useCallback 안에 넣고, deps 를 빈배열로 넣어주면 이 함수들은 App 컴포넌트가 마운트될 때 생성되고 더 이상 재생성되지 않는다. 그래서 memo 가 Props 를 받고 다른 함수라고 인식할 일이 없기 때문에 최적화를 시킬 수 있게 된다. 

const onCreate = useCallback((content) => {
    dispatch({
      type: "CREATE",
      data: {
        id: idRef.current++,
        isDone: false,
        content: content,
        date: new Date().getTime(),
      }
    })
  }, []);

  const onUpdate = useCallback((targetId) => { // 토글 변경
    dispatch({
      type: 'UPDATE',
      targetId: targetId
    });
  }, []);

  const onDelete = useCallback((targetId) => { // 삭제
    dispatch({
      type: 'DELETE',
      targetId: targetId
    })
  }, []);

 

 

 

3. React Context 

리액트 컨텍스트는 컴포넌트 간의 데이터를 전달하는 또 다른 방법으로 기존의 Props 가 가지고 있던 단점을 해결하기 위해 존재한다. 

기존의 Props 는 부모 -> 자식으로만 데이터를 전달할 수 있기 때문props drilling 의 문제가 있었는데, 리액트 컨텍스트로 이 문제를 해결할 수 있다.

 

 

- React Context 사용하기

 

컨텍스트는 createContext() 로 만들 수 있다. 컨텍스트는 컴포넌트 밖에서 만들어야 하는데, 컴포넌트 안에서 만들게 되면 컴포넌트가 렌더링될 때마다 새롭게 컨텍스트가 생성되기 때문이다.

 

리액트 컨텍스트는 Provider 라는 것을 제공하는데, Provider 로 감싼 하위 컴포넌트들은 Provider 가 제공하는 컨텍스트에 접근해서 컨텍스트의 데이터를 자유롭게 사용할 수 있다

import './App.css'
import Header from './components/Header'
import Editor from './components/Editor'
import List from './components/List'
import { useRef, useReducer, useCallback, createContext, useMemo } from 'react'

export const TodoContext = createContext(); 

function App() {
  
  // 생략

  const onCreate = useCallback((content) => {
    dispatch({
      type: "CREATE",
      data: {
        id: idRef.current++,
        isDone: false,
        content: content,
        date: new Date().getTime(),
      }
    })
  }, []);

  const onUpdate = useCallback((targetId) => { // 토글 변경
    dispatch({
      type: 'UPDATE',
      targetId: targetId
    });
  }, []);

  const onDelete = useCallback((targetId) => { // 삭제
    dispatch({
      type: 'DELETE',
      targetId: targetId
    })
  }, []);

  return (
    <div className='App'>
      <Header />
        <TodoContext.Provider value={{
            todos,
            onCreate,
            onUpdate,
            onDelete
        }}>
          <Editor />
          <List />
        </TodoContext.Provider>
    </div>
  )
}

export default App

 

 

이 코드를 개발자 모드로 키고 보면, 전에 useCallback 과 memo 를 통해 최적화 시켜놓았던 TodoItem 의 최적화가 풀려있는 것을 확인할 수 있는데, 왜 풀린걸까?

-> todos 가 변경되면 TodoContext.Provider 가 받는 Props 가 바뀌고 TodoContext.Provider 역시 컴포넌트이기 때문에 리렌더링이 발생 + TodoContext.Provider 의 자식 컴포넌트들도 리렌더링이 발생하는 상황이 된다. 

 

그런데 전에 TodoItem 에서 memo 를 사용해 받는 Props 가 바뀌지 않으면 리렌더링이 되지 않도록 만들어 놓았는데, 왜 리렌더링이 발생한걸까?

-> App 컴포넌트의 상태가 변경되면 TodoContext.Provider 컴포넌트에게 value Props 로 전달하는 객체 자체가 다시 생성이 된다. 때문에 TodoContext.Provider 의 자식 컴포넌트들은 새롭게 생성된 Props 를 받아서 리렌더링이 발생하는 것이다. 

 

 

이 문제는 컨텍스트를 분리해서 사용함으로써 해결할 수 있다. 

  • todos 와 같이 변경될 수 있는 값 -> TodoStateContext
  • 변경되지 않는 값 -> TodoDispatchContext
import './App.css'
import Header from './components/Header'
import Editor from './components/Editor'
import List from './components/List'
import { useRef, useReducer, useCallback, createContext, useMemo } from 'react'

// 생략

export const TodoStateContext = createContext();
export const TodoDispatchContext = createContext();

function App() {
  
  const [todos, dispatch] = useReducer(reducer, mockData);

  const idRef = useRef(3);

  const onCreate = useCallback((content) => {
    dispatch({
      type: "CREATE",
      data: {
        id: idRef.current++,
        isDone: false,
        content: content,
        date: new Date().getTime(),
      }
    })
  }, []);

  const onUpdate = useCallback((targetId) => { // 토글 변경
    dispatch({
      type: 'UPDATE',
      targetId: targetId
    });
  }, []);

  const onDelete = useCallback((targetId) => { // 삭제
    dispatch({
      type: 'DELETE',
      targetId: targetId
    })
  }, []);

  const memoizedDispatch = useMemo(() => {
    return { onCreate, onUpdate, onDelete };
  }, []); // 앱 컴포넌트 마운트 이후에 다시는 재생성되지 않도록

  return (
    <div className='App'>
      <Header />
      <TodoStateContext.Provider value={todos}>
        <TodoDispatchContext.Provider value={memoizedDispatch}>
          <Editor />
          <List />
        </TodoDispatchContext.Provider>
      </TodoStateContext.Provider>
        
    </div>
  )
}

export default App

 

 

※ 추가1 -  최적화는 언제/어떻게하면 좋을까?

 

기능 구현 -> 최적화

 

최적화는 프로젝트의 기능을 거의 완성한 상태에서 진행하는 것이 좋다. (최적화를 시키고 개발을 하다보면 최적화가 풀려버리는 경우가 많기 때문)

또 모든 것에 최적화를 적용하기 보다는 연산 / 컴포넌트 / 함수에 최적화를 적용하는 것이 좋다. 

 

 

728x90
반응형