TIL 23. 11. 09

오늘 한 일

  • React Hooks - 최적화
    • memo
    • useMemo
    • useCallback

React Hooks - 최적화

애플리케이션을 구성하는 컴포넌트가 많아지고, 자주 리렌더링된다면 최적화를 고려해야 한다. 불필요한 렌더링은 성능 저하로 이어지기 때문. 일반적으로 DOM 요소를 제어하는 것은 비용이 크다.

리렌더링의 발생 조건

  1. 컴포넌트에서 state가 바뀌었을 때
  2. 컴폰넌트가 내려받은 props가 변경되었을 때
  3. 부모 컴포넌트가 리렌더링되었을 때

불필요한 렌더링 같은 비용 발생을 줄이는 작업을 최적화(Optimization)라고 한다. 리액트에서 최적화하는 대표적인 방법이 memo, useCallback, useMemo이다.

  • memo(React.memo) : 컴포넌트를 캐싱
  • useCallback : 함수를 캐싱
  • useMemo : 값을 캐싱

memo

memo를 사용하면 컴포넌트의 props가 변경되지 않은 경우 리렌더링을 건너뛸 수 있다.

const MemoizedComponent = memo(SomeComponent, arePropsEqual?)
  • SomeComponent: 메모화 하려는 컴포넌트.
  • arePropsEqual (optional): 별도의 비교 함수. prop이 이전과 동일한 값이면 리렌더링하지 않고, 다른 값이면 리렌더링

메모이제이션(memoization)은 컴퓨터 프로그램이 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술이다. - 메모이제이션 - wiki

주의사항

  • memo는 컴포넌트에 전달되는 prop이 항상 다른 경우에는 사용할 이유가 없다
  • 차이가 없다면 memo할 필요가 없다
  • 메모화 되었더라도 context가 변경되면 리렌더링 된다

Usage: prop이 변경되지 않았을 때 리렌더링 건너뛰기

React는 일반적으로 부모가 리렌더링될 때마다 컴포넌트를 리렌더링한다다. memo를 사용하면 부모 컴포넌트가 리렌더링될 때 새로운 props가 이전 props와 동일하면 리렌더링하지 않는 컴포넌트를 만들 수 있다.

const Greeting = memo(function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
});

export default Greeting;
export default function MyApp() {
  const [name, setName] = useState('');
  const [address, setAddress] = useState('');
  return (
    <>
      <label>
        Name{': '}
        <input value={name} onChange={e => setName(e.target.value)} />
      </label>
      <label>
        Address{': '}
        <input value={address} onChange={e => setAddress(e.target.value)} />
      </label>
      <Greeting name={name} />
    </>
  );
}

Greeting 컴포넌트는 name이 props 중 하나이기 때문에 name이 변경될 때마다 리렌더링 된다. Greeting 컴포넌트를 memo로 감싸 메모화한다. address는 Greeting 컴포넌트의 prop이 아니기 때문에 address가 변경될 때는 리렌더링되지 않는다. Greeting 컴포넌트의 prop이 변경되지 않았기 때문.

Usage: props 변경 최소화하기

memo를 최대한 활용하려면 prop 변경을 최소화해야 한다. 특히 prop이 객체인 경우, useMemo를 사용하여 부모 컴포넌트가 해당 객체를 매번 생성하지 않도록 한다.

props 변경을 최소화하는 더 나은 방법은 props가 필요한 최소한의 정보만 받고 있는지 확인하는 것. 객체 대신 원시 값을 전달하는 방법이 있다.

메모화된 컴포넌트에 함수를 전달해야 하는 경우, 아예 변경되지 않도록 컴포넌트 외부에서 선언하거나, useCallback을 사용한다.

useMemo

useMemo는 리렌더링 사이의 계산 결과를 캐시할 수 있는 React Hook

const cachedValue = useMemo(calculateValue, dependencies)
  • calculateValue: 캐시하려는 값을 계산하는 함수
  • dependencies: 참조되는 모든 반응형 값들의 목록

초기 렌더링 중에 calculateValue 함수를 호출한 결과를 반환한다. 이후 렌더링에서는 의존성이 이전 렌더링 이후 변경되지 않았다면 동일한 값을 반환하고 변경되었다면 함수를 호출하고 그 결과를 반환하여, 나중에 재사용할 수 있도록 저장한다.

주의사항

  • useMemo는 성능 최적화를 위해서 사용해야 한다. 그렇지 않다면 stateref 사용을 권장
  • 무분별하게 메모를 사용하는 것은 코드 가독성을 떨어트린다. 또한, 모든 메모화가 효과적인 것이 아니다.

Usage: 비용이 많이 드는 계산 생략하기

React는 컴포넌트가 리렌더링될 때마다 컴포넌트 전체를 다시 실행한다. 반복문 안의 복잡한 연산이나 큰 배열을 필터링하는 등 큰 비용이 드는 계산을 수행할 때, 데이터가 변경되지 않았다면 다시 계산할 필요가 없다.

이전 렌더링 때와 값이 동일하다면, 이미 계산해놓은 값을 사용하는 것이 효율적이다. 계산값을 캐시하려면 컴포넌트 최상단에서 useMemo를 호출하여 해당 값을 감싼다.

import { useMemo } from 'react';

function TodoList({ todos, tab, theme }) {
  const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
  // ...
}
  1. 초기 렌더링 시, 계산 함수 filterTodos의 반환값을 저장한다.
  2. 이후 렌더링 시, 이전 렌더링에서 전달한 의존성과 현재의 의존성을 비교한다.
    1. 변경되지 않았다면 이전에 계산했던 값을 반환
    2. 변경되었다면 계산 함수를 다시 실행하고 새로운 값을 반환.

테마가 변경되었다고 했을 때, todos와 tab이 변경되지 않았기 때문에 filterTodos 함수는 실행되지 않는다.

비용이 많이 드는 계산?

콘솔 로그를 추가해 코드에 소요된 시간을 측정할 수 있다. 전체적으로 기록된 시간이 고비용(예를 들어 1ms 이상)이면 계산을 메모해 두는 것이 좋다. useMemo로 감싸보고, 총 시간이 감소했는지 확인해보면 사용 여부를 결정할 수 있을 것.

  console.time('loop');
  for (let i = 0; i < 1000000; i++) {}
  console.timeEnd('loop'); // loop: 1.350830078125 ms

⚠ 개발 중에 성능을 측정하는 것은 반드시 정확한 결과를 제공하지는 않는다는 점에 유의

React 애플리케이션이 렌더링하는 빈도와 렌더링 비용을 측정하는 도구도 있다. Profiler

Usage: useMemomemo로 리렌더링 건너뛰기

먼저 아래의 코드를 살펴보자.

function TodoList({ todos, filter, theme }) {
  const filterTodos = (todos, filter) => {
    return todos.filter((todo) => todo.status === filter);
  };
  const visibleTodos = filterTodos(todos, filter)
  
  return (
    <div className={theme}>
      <List items={visibleTodos} />	
    </div>
  )
}

TodoList 컴포넌트가 visibleTodos를 List 컴포넌트에 prop으로 전달한다. 이 때, fodos 배열의 크기가 커서 filterTodos 함수 실행이 오래 걸려 List 렌더링이 늦는 상황이라고 하자.

export default function App() 
  const [theme, setTheme] = useState('light');
  const [filter, setFilter] = useState(filters[0]);
  const [todos, setTodos] = useState(initialValue);

  const handleThemeToggleBtn = () =>
    setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));

  return (
    <div>
      <button onClick={handleThemeToggleBtn}>toggle</button>
      <TodoList todos={todos} filter={filter} theme={theme} />
    </div>
  );
}

TodoList 부모 컴포넌트에서 theme의 상태를 변경시킨다. 컴포넌트가 리렌더링되면 React는 모든 자식 컴포넌트를 재귀적으로 리렌더링한다. theme이 변경되면 TodoList 컴포넌트, List 컴포넌트가 순차적으로 리렌더링된다.

List 컴포넌트에 전달되는 visibleTodos의 값은 동일하다. 이전 렌더링과 동일한 prop이면 렌더링 되지 않도록 최적화를 위해 List 컴포넌트를 memo로 감싸준다.

import React from 'react';

function List({ items }) {
    // ...
}

export default React.memo(List);

이제 List 컴포넌트는 prop이 이전 렌더링과 같은 경우에는 리렌더링 되지 않는다. 그런데 여전히 LIst 컴포넌트가 리렌더링된다. 그 이유는 filterTodos 함수가 항상 다른 배열을 생성하기 때문이다.

function TodoList({ todos, filter, theme }) {
  const filterTodos = (todos, filter) => {
    return todos.filter((todo) => todo.status === filter);
  };
  const visibleTodos = filterTodos(todos, filter)
  // ...
}

TodoList 컴포넌트가 리렌더링될 때마다 filterTodos 함수가 재실행된다. 배열의 값은 같아보이지만 새로 생성된 배열이므로 참조값이 다르다. 리액트는 해당 값이 바뀌었다고 간주하고 렌더링을 촉발시킨다. 이러한 이유로 memo 최적화가 작동하지 않은 것. 이럴 때 useMemo를 사용한다.

function TodoList({ todos, filter, theme }) {
  const filterTodos = (todos, filter) => {
    return todos.filter((todo) => todo.status === filter);
  };
    const visibleTodos = useMemo(() => {
    return filterTodos(todos, filter);
  }, [todos, filter]);
  
  return (
    <div className={theme}>
      <List items={visibleTodos} />	
    </div>
  )
}

지난 렌더링 이후 todos와 filter 모두 변경되지 않았으므로 visibleTodos 배열도 그대로이다. List 컴포넌트는 visibleTodos 배열이 변경되지 않았기 때문에 리렌더링되지 않는다.

어떤 곳에 useMemo를 써야 할까

  • 인터랙션이 많은 애플리케이션 e.g. 그림 편집기
  • 계산이 눈에 띄게 느리고 의존성이 거의 변하지 않는 경우
  • memo로 감싼 컴포넌트에 prop으로 전달하는 경우, 값이 변경되지 않으면 리렌더링을 건너뛰고 싶을 때

useCallback

useCallback은 리렌더링 사이에 함수 정의를 캐시할 수 있게 해주는 React Hook

const cachedFn = useCallback(fn, dependencies)
  • fn: 캐시하려는 함수 값. 마지막 렌더링 이후 dependencies가 변경되지 않았다면 동일한 함수를 다시 제공. 변경되었다면 새롭게 함수를 할당하고 저장한다.
  • dependencies: fn 코드 내에서 참조된 모든 반응형 값의 배열. 각 의존성을 이전 값과 비교.

useCallback은 의존성이 변경되기 전까지는 리렌더링에 대해 함수를 캐시한다. useMemo를 사용한 최적화는 자식 컴포넌트에 prop으로 전달하는 ‘변수의 값’을 메모화한 것이다. useCallback은 prop으로 전달하는 ‘함수’를 메모화한다.

Usage: 메모된 콜백에서 state 업데이트하기

메모된 콜백의 이전 state를 기반으로 state를 업데이트해야 하는 경우

function TodoList() {
  const [todos, setTodos] = useState([]);

  const handleAddTodo = useCallback((text) => {
    const newTodo = { id: nextId++, text };
    setTodos([...todos, newTodo]);
  }, [todos]);
  // ...

이러한 방식보다 업데이트 함수를 전달하여 의존성을 제거할 수 있다. 메모화된 함수는 가능한 적은 의존성을 가지도록 할 것.

function TodoList() {
  const [todos, setTodos] = useState([]);

  const handleAddTodo = useCallback((text) => {
    const newTodo = { id: nextId++, text };
    setTodos(todos => [...todos, newTodo]);
  }, []);
  // ...

Usage: Effect 내부에서 함수 호출

Effect 내부에서 함수를 호출할 때 모든 반응형 값은 Effect의 의존성 배열에 추가해야 한다. 하지만 함수가 리렌더링될 때마다 새로 생성되므로 Effect가 계속 실행된다.

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  function createOptions() {
    return {
      serverUrl: 'https://localhost:1234',
      roomId: roomId
    };
  }

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions]);

Effect에서 호출해야 하는 함수를 useCallback으로 감싸면 이를 해결할 수 있다.

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  const createOptions = useCallback(() => {
    return {
      serverUrl: 'https://localhost:1234',
      roomId: roomId
    };
  }, [roomId]); 

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions]);

이렇게 하면 roomId가 동일한 경우 createOptions 함수는 변하지 않는다. 하지만 함수 의존성을 없애는 편이 더 낫다. Effect 내부에 선언하면 useCallback을 사용하지 않고 더 간단하다.

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    function createOptions() {
      return {
        serverUrl: 'https://localhost:1234',
        roomId: roomId
      };
    }

    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
  // ...

카테고리:

업데이트:

댓글남기기