React 렌더링 최적화 - useEffect 오남용 줄이기

seo young park·2023년 2월 12일
3

가끔 컴포넌트에 console을 찍으면, 리렌더링이 많이 발생하여 놀랄 때가 있다. 불필요한 useEffect를 없애고 렌더링을 최적화해보자.

1️⃣ props와 state에 따라 업데이트 되는 값

firstName과 lastName을 입력하면 full name을 보여주는 컴포넌트가 있다. 이 때, fullName이라는 state를 만들면 유저가 입력할 때마다 stale value로 인한 렌더링 1번, updated value로 인한 렌더링 1번, 렌더링이 2번 발생하게 된다.

  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [fullName, setFullName] = useState('');
  
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);

불필요한 state는 없애고 렌더링할 때 fullName을 만들어보자. 입력할 때마다 렌더링은 1번씩만 발생한다.

  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  const fullName = firstName + ' ' + lastName;

코드가 간결해지고 계단식 업데이트나 타이밍 이슈도 방지할 수 있다.
그렇다면 어떤 값을 state로 관리해야할까?

React 공식문서는 아래 질문에 해당하는 경우 state가 아니라고 말한다.

  • 시간이 지나도 변하지 않는가?
  • 부모 컴포넌트로부터 props로 전달되는 값인가?
  • 컴포넌트에 존재하는 state나 props로 계산할 수 있는 값인가?

2️⃣ 비싼 계산 저장하기

todos와 filter라는 두 props를 계산하여 그 값을 사용하는 컴포넌트가 있다.

function TodoList({ todos, filter }) {
  
	const visibleTodos = getFilteredTodos(todos, filter);
  // ...
}

만약
todos 배열의 아이템이 엄청 많다면,
혹은 getFilteredTodos 계산이 엄청 비싸다면,
todos나 filter와 무관한 state의 업데이트로 리렌더링이 발생한다면,

계산을 useMemo로 캐싱할 수 있다.
useMemo는 리렌더링이 발생했을 때, 의존성 배열의 값들이 변화가 없다면 이전 계산 결과를 재사용한다.

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
  const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
  // ...
}

useMemo는 공짜가 아니다

그러나 공식문서는 비싼 계산이 아닌 경우에는 useMemo의 사용을 권장하지 않는다. 예를 들어 수백개의 컴포넌트가 있는 앱이 있다고 가정해보자.
useMemo는 초기렌더링 과정에서 일정 시간을 소요하여 결과값을 캐싱한다.
이 시간을 모두 더하면 적게는 10ms에서 많게는 100ms 이상이 소요될 수 있다.
그리고 리렌더링 상황에서 useMemo로 절약하는 시간은 약2ms ~ 5ms정도다. (위 예시의 경우 Fast 3G 환경에서 2ms로 측정된다.)
유저와 상호작용을 통해 발생 여부도 불확실한 시간을 절약하는 것이
초기 렌더링 지연이라는 필수 비용을 지불할 가치가 있는 지는 고려해볼만하다.

함수 실행 시간 측정

함수가 비싼 계산을 수행하는 지 측정하는 방법은 다음과 같다.

  • console.time
  console.time('filter');
  const visibleTodos = getFilteredTodos(todos, filter);
  console.timeEnd('filter');

함수의 앞뒤로 time과 timeEnd를 사용하면 실행 시간을 측정할 수 있다. mdn에선 비표준이라 권하지 않으나, naming이 가능하여 사용성 측면에서 유리하다.

  • performance.now
  const before = performance.now();
  const visibleTodos = getFilteredTodos(todos, filter);
  const after = performance.now() - before;
  
  console.log(after);

performance.now는 표준이며, DOMHighResTimeStamp에 접근하여 밀리초 단위로 정확한 시간을 반환한다.

  • Chrome CPU Throttling 옵션

의도적으로 느린 네트워크 상황을 만들어 유저 경험을 테스트해보는 것도 좋다.

3️⃣ props에 따라 state를 초기화해야될 때

프로필 페이지에 댓글 기능이 있다고 가정해보자.
userId props가 변화할 때마다 comment state를 초기화해야한다. state와 effect를 사용해서 아래와 같이 구현하면 페이지를 이동할 때마다 이전에 작성한 comment로 1번, 초기화된 comment로 1번 렌더링이 2번씩 발생한다.

export const ProfilePage = ({ userId }) => {
  const [comment, setComment] = useState('');

  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}

일반적으로 React는 동일한 컴포넌트가 동일한 지점에서 렌더링 될 때 상태를 유지한다. 이 때, key를 넘겨주면 명시적으로 각각 다른 컴포넌트가 되어 상태를 공유하지 않는다. 그래서 key가 바뀌면, React는 DOM을 새로 그리고 state를 초기화한다.

const Profile = ({ userId }) => {
    const [comment, setComment] = useState('');
	//...
};

const ProfilePage = ({ userId }) => {

    return (
        <Profile
      		key={userId}
      		userId={userId}
		/>
    );
}

상호작용한 시점의 상태 snapshot

코드가 실행되는동안 상태가 변경되는 것은 중요하지 않다
마치 컴포넌트 외부에 저장한다.

상태가 바뀌면 리렌더링이 발생한다.
React의 store는 상태값을 컴포넌트 외부에 저장한다.
useState를 호출하면, React는 그 render의 상태값을 준다.
모든 렌더링은 자체 이벤드 핸들러가 있다.

https://beta.reactjs.org/learn/you-might-not-need-an-effect
https://www.developerway.com/posts/how-to-use-memo-use-callback
http://vnthf.logdown.com/posts/2016/10/06/javascript

0개의 댓글