본문 바로가기

javascript/react

[React] memo & useCallback

 리액트에서 컴포넌트는 본질적으로 함수이다. 우리가 props라고 칭하는 프로퍼티들은 함수의 인자로 취급된다. 따라서 별다른 hook을 사용하지 않는다면 컴포넌트는 함수와 동일하게 작동한다.

 자바스크립트에서 함수는 객체에 속하며 name, length 등의 프로퍼티를 추가적으로 가진다. 이때 name은 함수의 이름. length는 함수가 가진 파라미터의 개수를 의미한다. 컴포넌트에 대해서도 해당 프로퍼티에 접근할 수 있다.

App 컴포넌트 구조
출력된 결과. name 및 length 프로퍼티에 접근 가능하다.

 

자바스크립트의 함수와 컴포넌트

 우리가 아는 함수는 어떻게 동작했을까? 다음 예시를 보자.

function A() {
    let b = B();
    let c = C();
    ...
    func D() {
        //... some codes
    }
}

 자바스크립트는 특정 스코프 내에서 고유한 렉시컬 환경을 가진다. 스코프에는 블럭, 조건문, 반복문, 함수 등이 포함되므로 프로그램 실행 중 특정 스코프 내에 들어가게 되면 해당 스코프에 대해 고유한 환경이 생성되고, 이 안에서 생성되는 변수나 함수들은 해당 환경 내에서만 유효하게 된다. 

 위 예시에서 함수 A를 실행하게 되면 b는 B 함수의 실행 결과를, c는 C 함수의 실행 결과를 받게 된다. 이때 해당 값들은 함수 A가 실행되면서 발생한 렉시컬 환경 내에 저장되어 고유한 지위를 가진다. 함수 D 역시 함수 A 내에서 생성되었으므로 b와 c가 속한 렉시컬 환경에 등록되게 된다.

 위 과정은 함수 A가 실행될 때마다 발생한다. 즉 A가 실행될 때마다 실행된 함수 A에 대해 고유한 렉시컬 환경이 생기고, 이에 대해 고유한 b, c, D가 저장된다. 동일한 A를 실행하더라도 실행할 때마다 다른 렉시컬 환경이 생기는 것이다.

 이때 리액트의 컴포넌트는 단순한 함수이기 때문에, 언급한 특징을 그대로 가진다. 즉 컴포넌트도 실행될 때마다 서로 다른 렉시컬 환경을 가지고, 이로 인해 서로 다른 변수 및 함수를 가지게 된다. 따라서 컴포넌트가 다시 평가 및 렌더링 될 때마다 컴포넌트 내부에 있는 변수들 역시 변하게 된다. 일반적인 함수와 다른 점은 useState, useReducer, useContext 등 hook에 의해 생성된 변수와 함수들은 변화하지 않음을 보장한다는 것 정도이다.

 그렇기에 특정 컴포넌트가 다시 평가된다는 것은 하위 모든 컴포넌트들 역시 다시 실행되고, 다시 평가된다. 그런데 거의 모습이 변하지 않거나, 절대 다시 렌더링 될 필요가 없는 요소들까지도 굳이 반복적으로 실행하거나 평가할 필요가 있을까? 없다. 따라서 컴포넌트의 의미없는 재실행 및 평가를 막아 최적화하기 위한 방법이 필요하다.

 

내용 기억하기 ( Memoization )

 메모이제이션 기법은 컴퓨터 프로그램이 동일 계산을 반복해야 할 때 이전 값을 메모리에 저장해두고, 반복 수행을 제거하여 프로그램 실행속도를 늘리는 기법이다. 리액트에서는 변수나 함수를 이전과 동일한 값으로 유지하여 컴포넌트의 재실행을 방지하거나, 최적화를 위해 사용될 수 있다.

렌더링 최적화를 위한 방법

리액트에서 렌더링 과정을 최적화하기 위해 사용하는 방법에는 3가지가 존재한다.

  1. React.memo : 컴포넌트(함수) 를 프로퍼티 중심으로 최적화
  2. useCallback : 함수를 dependencies 중심으로 최적화 / useEffect에서 렌더링을 야기하지 않기 위한 목적
  3. useMemo : 값을  dependencies 중심으로 최적화

위 방법들은 현 시점에는 메모이제이션 기법을 통해 구현된다. 이때 해당 기법은 상태 변화를 감지할 때 메모리에 저장되어 있는 이전 상태와 새로운 상태를 비교하는데, 이 과정 역시 어느 정도 시간을 소모하므로, 위 언급한 최적화 방법을 남용하면 오히려 성능이 떨어질 수 있다는 점을 주의해야 한다.

React.memo

컴포넌트의 실행 여부를 프로퍼티를 중심으로  계산하기 위한 함수이다. 컴포넌트가 가진 프로퍼티로 전달되는 값이 이전과 같다면 해당 컴포넌트가 변하지 않은 것으로 간주하고, 메모리에 저장된 이전 결과를 재사용한다.

const DemoOutput = (props) => {
    console.log("Demo Running");
    return (
    <p>{props.show ? 'This is new!' : ""}</p>
    );
};

export default React.memo(DemoOutput);

단순히 함수 컴포넌트를 React.memo로 감싸주면 된다.

React.memo을 적용하더라도 useState, useReducer 및 useContext로 인한 렌더링과는 별개로 작동하므로, 기존의 컴포넌트에 프로퍼티 검사 과정을 추가하는 것이라 생각하면 편하다.

프로퍼티들은 얕은 비교를 수행하기 때문에, 복잡한 비교가 필요한 경우 두번째 인자로 비교 함수를 전달할 수 있다.

function areEqual(prev, next) {
	// some codes
};
// prev와 next의 값이 같다면 true, 아니면 false

 

useCallback

함수를 전달된 dependencies 기반으로 업데이트한다. 만약 dependencies가 달라지지 않았다면 기존에 메모리에 저장되어 있던 콜백을 반환한다.

const toggleParagraphHandler = useCallback(() => {
    setShowParagraph(prev => !prev);
  },[]);

첫번째 인자로 콜백을, 두번째 인자로 dependencies을 받는다.

 리액트 컴포넌트는 함수이므로 실행될 때마다 다른 렉시컬 환경을 가지기 때문에 변수와 함수를 새로 생성한다. 만약 State로 관리되지 않는 변수나 함수가 useEffect 등의 특정 hook에 등록되어 있다면 어떨까? 컴포넌트가 실행될 때 마다 useEffect 에는 새로운 dependency가 전달되기 때문에 결과적으로 useEffect가 무한히 재실행될 수 있다.

update 깊이가 최대에 도달했다는 메시지. CPU가 혹사당하는 소리가 난다.

 이런 상황에서는 이들이 변하지 않는다는 것을 보장해줄 필요가 있다. 만약 컴포넌트를 처음 실행했을 때 해당 변수와 함수를 다른 공간에 저장해두고, 이후 재실행할 때마다 특정 장소에서 해당 값들을 불러와 사용한다면 항상 동일한 값들을 사용할 수 있다. 위와 같이 useEffect와 콜백이 관련되어 있는 경우, useCallback을 고려할 수 있다. 

이외 콜백이 거의 변하지 않는데 자주 평가하고 싶지 않은 경우에 대해 사용할 수 있다. 이 경우에는 프로파일러를 통해 성능을 측정한 후 사용을 고려하자.

useMemo

특정한 값을 전달된 dependencies을 기반으로 업데이트하기 위한 hook이다. 계산에 상당한 자원을 소모하는 경우, 사용을 고려할 수 있다. useMemo로 전달된 함수는 렌더링 중 실행되므로, 렌더링 중 하지 않는 행동은 다른 hook을 이용하여 구현해야 한다. 공식문서는 차후 메모이제이션 이외의 기법을 사용할 수도 있으므로 값을 보장하는 목적으로 사용하지 않는 것을 권장하고 있다. 

const value = useMemo(() => {
    //some time-waste codes...
    return something;
}. [...dependencies]);

코드 예시
초기 실행. memo 메시지가 뜬다.
이후. memo 메시지가 출력되지 않는다.

요약

리액트의 렌더링 성능 향상을 위한 방법에는 React.memo, useCallback, useMemo 등이 있다. 현재 세가지 방법 모두 메모이제이션 기법을 이용하는데, 해당 기법을 적용하면 변화 감지를 위한 검사 시간이 추가되기 때문에 남용시 성능이 기존보다 떨어질 수도 있다. 따라서 프로파일러를 통해 실행 전후 성능을 비교하여 향상된 경우 위 방법의 사용을 고려해야 한다.