React

React 렌더링 최적화 useCallback, useMemo, React.memo 비교분석

student513 2021. 3. 16. 00:01

1. key에 index를 사용하지 않는 이유

Key란?

React에서 여러 컴포넌트를 동적으로 생성하여 표시할 때 반드시 고유의 key를 주도록 하고 있습니다. Key 속성은 React가 어떤 항목을 변경, 추가 또는 삭제할지 식별하는 것을 돕습니다.

// key를 사용한 배열 렌더링 예시 
const todoItems = todos.map((todo) => <li key={todo.id}> {todo.text} </li> );

Key를 사용하는 이유는?

  • 요소의 변화를 탐지하기 위해 필요합니다.

React는 기본적으로 state 중 변화가 발생할 때만 캐치해서 DOM을 업데이트 합니다. state의 배열에 어떤 요소가 추가되었을 경우, 배열 전체를 리렌더링하기보다는 추가된 요소만을 리렌더링하는 것이 더 효율적입니다.

arr = [ 
    {id: 0, title:"yello", content:"banana"}, 
    {id: 1, title:"red", content:"apple"}, 
    {id: 2, title:"orange", content:"carrot"}, 
]

이 배열의 중간에 {id: 3, title: "green", content:"grape"}가 추가되었다고 가정해봅시다.

arr = [ 
    {id: 3, title: "green", content:"grape"}, 
    {id: 0, title:"yello", content:"banana"}, 
    {id: 1, title:"red", content:"apple"}, 
    {id: 2, title:"orange", content:"carrot"}, 
]
  • Key를 사용한 경우: Key = (id: 0)
    1. 배열에 변화가 감지되면 React는 기존 배열과 추가된 배열을 비교하기 시작합니다.
    2. 기존 배열의 key=(id: 0)이었던 원소와 추가된 배열의 key=(id: 0)이었던 원소를 비교하여 변화가 감지되지 않았으므로 리렌더링이 일어나지 않습니다. id: 1, 2에 대해서도 마찬가지입니다.
    3. key=(id: 3)인 원소는 기존 배열에는 존재하지 않던 원소입니다. 해당 원소에 대해서만 리렌더링을 적용합니다.
  • Key를 사용하지 않은 경우
    1. 기존 배열과 추가된 배열을 처음부터 하나하나씩 비교합니다.
    2. 배열의 모든 구간 arr[0] ~ arr[3]에서 변화가 발생하였으므로 모든 변화에 대한 리렌더링이 발생합니다.

🛑 결국 key는 리렌더링이 필요한 변화 요소를 식별하는 역할을 하는 것입니다.

map의 index를 key로 사용하면 안되는 이유는?

그러나 다음과 같은 방식으로 배열을 element화 하기 위해 map의 index를 key로 사용할 경우 컴포넌트 상의 state 문제나 성능 저하 문제가 발생할 수 있습니다.

todos.map((todo, index) => ( <Todo {...todo} key={index} /> )); }

index 기준으로 Key와 원소가 매칭되었을 때의 상태입니다.

key: 0, {id:0, title: 'hello!', content:'word'}, 
key: 1, {id:1, title: 'myname!', content:'is!'}, 
key: 2, {id:2, title: 'lee!', content:'yong!'}, 
key: 3, {id:3, title: 'jun!', content:'blabla!'}

맨 앞에 원소가 하나 추가되었다고 가정해봅시다.

key: 0, {id:4, title: 'add!', content:'yeah!'}, 
key: 1, {id:0, title: 'hello!', content:'word'}, 
key: 2, {id:1, title: 'myname!', content:'is!'}, 
key: 3, {id:2, title: 'lee!', content:'yong!'}, 
key: 4, {id:3, title: 'jun!', content:'blabla!'}

key는 map에 의해 반환되는 배열의 index를 기준으로 원소들과 매칭이 되기 때문에, 기존의 연결이 모두 끊어지고 배열의 모든 요소에 대해 리렌더링을 실시해야합니다.

index를 사용하지 않고 유일한 식별자를 key로 적용했을 경우에는 다음과 같은 형태의 데이터가 될 것입니다.

key: 4, {id:4, title: 'add!', content:'yeah!'}, 
key: 0, {id:0, title: 'hello!', content:'word'}, 
key: 1, {id:1, title: 'myname!', content:'is!'}, 
key: 2, {id:2, title: 'lee!', content:'yong!'}, 
key: 3, {id:3, title: 'jun!', content:'blabla!'}

이 경우 새로 추가된 key:4의 원소에 대해서만 리렌더링을 실시하게 됩니다.

참고자료

 

Index as a key is an anti-pattern

So many times I have seen developers use the index of an item as its key when they render a list.

robinpokorny.medium.com

 

 

리액트 배열의 key 값 존재 이유 · 쾌락코딩

리액트 배열의 key 값 존재 이유 30 Jan 2019 | React 리액트를 사용하다보면, state로 배열을 관리해야 할 경우가 상당히 많다. 예를 들어 서버로부터 게시글 목록을 받아온다면, 아래와 같은 배열 리스

wooooooak.github.io

 

 

리스트와 Key – React

A JavaScript library for building user interfaces

ko.reactjs.org


2. useCallback, React.memo, useMemo

얕은 비교를 통한 리렌더링

React가 리렌더링되는 시점은 다음과 같습니다.

  1. state 변경이 있을 때
  2. 부모 컴포넌트가 렌더링되어 props가 변경될 때
  3. 새로운 props이 들어올 때
  4. shouldComponentUpdate에서 true가 반환될 때
  5. forceUpdate가 실행될 때

이 중에서 1, 2번의 상황이 발생할 경우, state와 props는 얕은 비교를 통해 새로운 값인지 비교하여 리렌더링을 실시합니다.

(이는 state에 pop, push 등의 원본을 변형하는 메소드를 사용하면 안되는 이유이기도 합니다. 메모리가 변경되지 않고 값만 변경되므로 깊은 복사의 상태변화가 발생하여 React의 리렌더링 대상으로 포착되지 않습니다! 🙄)

불필요한 리렌더링이 발생할 가능성

  1. 상위 컴포넌트의 state 변경
  2. 하위 컴포넌트로 넘겨주는 props가 변경됨
  3. value값은 변화가 없더라도, reference값의 변화가 있으므로 얕은 비교를 통해 리렌더링됨

이러한 문제를 useCallback, React.memo를 통해 해결할 수 있습니다.

useCallback을 통한 최적화

useCallback은 상위 컴포넌트에서 하위 컴포넌트에 함수를 props로 넘겨줄 때 사용합니다.

js에서는 함수 또한 객체로 취급되어 메모리에 할당되기 때문에, 리렌더링이 일어날 때마다 새로운 함수가 생성됩니다. 특히 상위 컴포넌트의 props로 함수를 넘겨받는 하위 컴포넌트의 경우, 상위 컴포넌트에서 리렌더링이 발생할 때마다 함수 또한 재생성되므로 불필요한 하위 컴포넌트의 리렌더링을 초래하게 됩니다.

useCallback은 의존성에 포함된 값이 변경되지 않는다면 이전에 생성한 함수 참조 값을 반환해줍니다. 의존성이 변화하지 않는 한, 함수의 재생성을 막아 생성된 함수를 무시하고 기존 함수를 반환하여, 하위 컴포넌트의 불필요한 리렌더링을 방지하는 것입니다.

const memoizedCallback = useCallback(
    () => {doSomething(a, b);}, // inline callbck 
    [a, b], // dependency 
);
  • useCallback은 콜백 함수와 의존성 배열을 인자로 받습니다.
  • 함수 내에서 참조하는 state, props가 있다면 의존성 배열에 추가해줘야합니다.
  • 해당 코드에서는 a, b의 값의 변경이 없는 한 함수가 새로 생성되지 않음을 보장합니다.
  • 새로 생성되지 않는다함은 메모리에 새로 할당되지 않고 동일 참조 값을 사용하게 된다는 것을 의미하고, 이는 최적화된 하위 컴포넌트에서 불필요한 렌더링을 줄일 수 있다는 것을 뜻합니다.
  • dependency가 없을 경우 컴포넌트가 렌더링 되는 최초에 한번만 생성되며 이후에는 동일한 참조 값을 사용하게 됩니다.

다음 코드에서 _onClick함수에 useCallback을 사용하지 않았다면, Root 컴포넌트가 렌더링될 때마다 _onClick함수가 재생성되어 _onClick을 넘겨받은 Child 컴포넌트 또한 렌더링되었을 것입니다.

const Root = () => { 
  const [isClicked, setIsClicked] = useState(false); 
  const _onClick = useCallback(() => { 
      setIsClicked(true); }, 
  []); // dependency가 없으므로 Root component가 렌더링 되는 최초에 한번만 생성되며 이후에는 동일한 참조 값을 사용한다. 
    
  return ( 
      <> 
          <Child onClick={_onClick}/> 
          <Child onClick={_onClick}/> 
          ... 
          <Child onClick={_onClick}/>            
      </> 
  ); 
}; // Root와 Child가 여러번 렌더링 되더라도 onClick props으로 전달되는 _onClick 함수는 한번만 생성되므로 계속해서 동일 참조 값을 가진다. 

const Child = ({onClick}) => { 
  return <button onClick={onClick}>Click Me!</button> 
};

useCallback을 통해 하위 컴포넌트의 props가 바뀌지 않게 했다면, 리렌더링을 방지하여 컴포넌트의 리렌더링 성능 최적화를 해줄 수 있는 React.memo 라는 함수에 대해서 알아보겠습니다.

React.memo를 이용한 최적화

React는 먼저 컴포넌트를 렌더링(rendering) 한 뒤, 이전 렌더된 결과와 비교하여 DOM 업데이트를 결정합니다. 만약 렌더링 결과가 이전과 다르다면, React는 DOM을 업데이트합니다. 다음 렌더링 결과와 이전 결과의 비교는 빠릅니다만, 어떤 상황에서는 이 과정의 속도를 좀 더 높일 수 있습니다.

컴퍼넌트가 React.memo()로 래핑 될 때, React는 컴퍼넌트를 렌더링하고 결과를 메모이징(Memoizing)합니다. 그리고 다음 렌더링이 일어날 때 props가 같다면, React는 메모이징(Memoizing)된 내용을 재사용합니다.

메모이징 한 결과를 재사용 함으로써, React에서 리렌더링을 할 때 가상 DOM에서 달라진 부분을 확인하지 않아 성능상의 이점을 누릴 수 있습니다.

React.memo를 사용해야하는 경우

  1. Pure Functional Component에서
  2. Rendering이 자주일어날 경우
  3. re-rendering이 되는 동안에도 계속 같은 props값이 전달될 경우
  4. UI element의 양이 많은 컴포넌트의 경우

최종 적용 코드

const Root = () => { 
	const [isClicked, setIsClicked] = useState(false); 
    const _onClick = useCallback(() => { setIsClicked(true); }, []); 
    return ( 
    	<> 
        	<Child onClick={_onClick}/> 
            <Child onClick={_onClick}/> 
            <Child onClick={_onClick}/>
            ... 
        </> 
    ); 
}; 

const Child = React.memo(({onClick}) => { 
	return <button onClick={onClick}>Click Me!</button> 
    }
);

3. useMemo를 이용한 최적화

function MyComponent({ x, y }) { 
    const z = compute(x, y) 
    return <div>{z}</div> 
}

MyComponent 컴포넌트 내의 compute함수가 만약 복잡한 연산을 수행하기 때문에 결과값을 리턴하는데 오랜시간이 걸린다면 컴포넌트가 리렌더링될 때마다 함수가 호출되므로, UI 지연을 경험하게 될 것입니다.

만약 compute 함수에 넘겨줄 x, y의 값이 이전과 동일하다면 컴포넌트가 리렌더링되더라도 연산을 다시 할 필요는 없을 것입니다.

이때 useMemo를 사용합니다.

function MyComponent({ x, y }) { const z = useMemo(() => compute(x, y), [x, y]) return <div>{z}</div> }

의존성 배열에 추가된 x와 y 값이 이 전에 랜더링했을 때와 동일할 경우, 이 전 랜더링 때 저장해두었던 결과값을 재활용합니다.

하지만, x와 y 값이 이 전에 랜더링했을 때와 달라졌을 경우, () => compute(x, y) 함수를 호출하여 결과값을 새롭게 구해 z에 할당해줍니다.


참조

얕은 비교란?

  • pass by reference. 같은 메모리에 할당된 값을 사용하는지에 대한 것입니다.
  • js의 원시자료형 number, string, null, undefiend.. 등은 pass by value를 이용하는 반면, 객체, 배열, 함수는 pass by reference를 이용합니다.

memoization이란?

  • 기존에 수행한 연산의 결과값을 어딘가에 저장해두고 동일한 입력이 들어오면 재활용하는 프로그래밍 기법을 말합니다.
  • memoization을 절적히 적용하면 중복 연산을 피할 수 있기 때문에 메모리를 조금 더 쓰더라도 애플리케이션의 성능을 최적화할 수 있습니다.

출처

 

 

React ❤️ Immutable.js – 리액트의 불변함, 그리고 컴포넌트에서 Immutable.js 사용하기 | VELOPERT.LOG

이 포스트는 React 에서는 불변함 (Immutability) 를 지키며 상태 관리를 하는 것을 매우 편하게 해주는 라이브러리 Immutable.js 에 대해서 알아보겠습니다. 서론 리액트를 사용하신다면, Immutability 라는

velopert.com

 

 

18. useCallback 를 사용하여 함수 재사용하기 · GitBook

18. useCallback 을 사용하여 함수 재사용하기 useCallback 은 우리가 지난 시간에 배웠던 useMemo 와 비슷한 Hook 입니다. useMemo 는 특정 결과값을 재사용 할 때 사용하는 반면, useCallback 은 특정 함수를 새

react.vlpt.us

 

 

 

React Hooks: useMemo 사용법

Engineering Blog by Dale Seo

www.daleseo.com