JS/React

React - Rendering 최적화

shin96bc 2024. 3. 9. 23:41

Rendering을 최적화하기 위해서 반드시 알아야하는 것

기본적으로 React는 부모 Component에 있는 ‘state’가 state 변경함수에 의해서 변경되었다면 헤당 ‘state’를 사용하는 자식 Component는 물론이고 사용하지 않는 자식 Component까지도 전부 re-rendering된다는 사실을 반드시 숙지해야합니다. (re-rendering 된다는 것은 해당 Component에 있는 모든 함수, 변수, HTML 태그들이 다시 생성된다는 의미이기도 합니다.)

 

re-rendering이 발생했을 때 ‘props’가 변경되었는지 매번 검사해서 변경되었을 때만 re-rendering 해주는 ‘memo()’라는 함수는 ‘props’에 대하여 얕은 비교( ‘=== or !==’ )를 합니다. 그래서 ‘object’, ‘array’, ‘function’등의 경우에는 완전히 동일한 값이라고 해도 re-rendering이 발생으로 인해서 새로 생성되어 주소값이 바뀌게 되면 ‘memo()’는 ‘props’가 변경된 것으로 인식해서 자식 component도 re-rendering을 진행하게 된다는 사실을 반드시 숙지해야합니다.(그래서 ’useMemo()’와 ‘useCallback()’ 등으로 re-rendering될 때 새로 생성되는 것을 막아줘야 합니다.)

 

’useMemo()’와 ‘useCallback()’은 deps 배열에 추가한 값이 변경되었을 때만 새로 생성된다는 사실을 반드시 숙지해야합니다.(만약 내부에서 state를 사용하고 있다면 해당 state가 변경되어도 deps 배열에 들어있는 값이 변경되어 새로 생성 되기 전까지는 내부의 state는 생성될 때 값이 그대로 유지됩니다.)

 

예시 코드

import {useCallback, useMemo, useState} from "react";

export default function RenderingTest() {
    console.log('rendering!!');

    // 일반 변수
    let normalState = 1;

    // state
    const [state, setState] = useState(1)
    const [deps, setDeps] = useState(1);

    // 일반 핸들러
    const normalHandler = () => {
        console.log('normalHandler state = ', state);
    };

    // useCallback으로 생성한 핸들러
    const callbackHandler = useCallback(() => {
        console.log('useCallback state ', state);
    }, [deps]);

    // useMemo로 생성한 bool 값
    const memo = useMemo(() => {
        console.log('memo 생성');
        return state !== 1;
    }, [deps]);

    // state 증가 핸들러
    const onChangeState = () => {
        console.log('state 증가')
        setState(state => state + 1);
    };

    // 일반 변수 증가 핸들러
    const onChangeNormalState = () => {
        normalState += 1;
        console.log('normalState = ', normalState);
    };

    // deps 증가 핸들러
    const onChangeDeps = useCallback(() => {
        console.log('deps 증가')
        setDeps(deps => deps + 1)
    }, []);

    return (
        <div>
            <div>
                <p>{'state !== 1: ' + memo}</p>
                <p>{'normalState: ' + normalState}</p>
                <p>{'state: ' + state}</p>
            </div>
            <div>
                <button type="button" onClick={onChangeState}>state 증가</button>
            </div>
            <div>
                <button type="button" onClick={onChangeNormalState}>일반 변수 증가</button>
            </div>
            <div>
                <button type="button" onClick={onChangeDeps}>deps 증가</button>
            </div>
            <div>
                <button type="button" onClick={callbackHandler}>useCallback console</button>
            </div>
            <div>
                <button type="button" onClick={normalHandler}>normalHandler console</button>
            </div>
        </div>
    );
}

 

Rendering 최적화 하기

적절한 위치에 ‘state’ 를 선언합니다.

  • React는 특정 ‘state’가 변경되면 그 ‘state’가 선언된 Component와 자식 Component들을 모두 re-rendering하기 때문에 적절한 위치에 ‘state’를 선언하는 것이 중요합니다.
  • 기본적으로는 같은 ‘state’를 사용하는 Component들을 잘 구분해놓고 그 중에서 가장 최상위 Component에 선언하는 것이 좋습니다. (가능한 범위 내에서 가장 자식(하위) Component에 선언)

 

‘object’ 타입의 ‘state’는 최대한 분할하여 선언합니다.

  • 만약 모든 ‘state’를 하나의 ‘object’에 담아서 관리하게 되면 해당 state에서 일부 데이터만 사용하는 자식 Component가 존재한다고 하면 불필요한 re-rendering이 발생하는 요인이 됩니다.
  • 그러므로 자식 Component가 사용하지 않는 데이터에 의해서 re-rendering이 되지 않도록 최대한 분할해서 선언해줍니다.
  • ‘object’ 타입의 ‘state’를 분할하게 되면 구조적으로 어떤 ‘state’가 어떤 Component에서 사용되는지 명확하게 보이기 때문에 가독성이 좋아지고, 더 나은 설계를 할 수 있게 됩니다.

 

Inline Function은 최대한 사용을 자제하고, 대신에 ‘useCallback()’을 사용합니다.

  • Inline Function을 사용하게되면 re-rendering과 함께 새로운 function을 생성해서 ‘props’로 전달하게 되기 때문에 자식 Component에 불필요한 re-rendnering이 발생하게 만듭니다.
...
	<ChildComponent onClick={() => setState(state + 1)} />
...

 

  • Inline Function 대신에 ‘useCallback()’과 ‘state’ 변경함수의 함수형 업데이트 방법을 사용하는 것이 좋습니다.
...
const [state, setState] = useState(1);

// useCallback을 deps 배열을 비워두고 사용하면 딱 한번만 생성돱나다.
// 그래서 state 변경함수에 값을 넘겨주는 일반적인 방식으로 state를 변경하면
// 해당 핸들러를 몇번 호출하든 state의 값은 계속 2일 것입니다.
// 그 이유는 이 핸들러가 생성될 때의 state 값이 그대로 함수 스코프영역 안에 갖히기 때문에
// 함수 스코프영역 안에서 state의 값은 처음 생성될 때의 state값인 1로 계속 유지되기 때문입니다.
const onClickHandler = useCallback(() => {
		// 핸들러를 몇번 호출하던 state는 2
		setState(state + 1);
}, []);

// 그렇다면 useCallback으로 state를 동적으로 변경할 수 없는 것 아닌가 생각할 수 있겠지만
// 위의 문제는 state 변경함수의 함수형 업데이트 방법을 사용하면 현재 state의 값을
// 인자값으로 받아와서 사용하기 때문에 함수 스코프영역 안에서도 state를 동적으로 변경해주는 것이 가능합니다.
const onClickHandler = useCallback(() => {
		// 핸들러를 호출하는 만큼 state 증가
		setState(state => state + 1);
}, []);

return <ChildComponent onClick={onClickHandler} />;
...

 

‘memo()’를 사용해서 자식 Component가 불필요한 ‘state’ 변경에 의해서 re-rendering 되지 않도록 해줍니다.

  • ‘memo()’로 자식 Component를 감싸지 않으면 부모 Component의 어떤 ‘state’가 변경되더라도 같이 re-rendering되기 때문에 성능이 저하될 수 있습니다.
  • 요즘은 하드웨어의 성능도 많이 좋아지고, 웹 브라우저의 최적화 또한 매우 잘 되어 있어서 실제로 그렇게 까지 성능 저하가 느껴지진 않습니다. 하지만 모두가 좋은 환경에서 우리의 애플리케이션을 사용하는건 아닐테니 가능한한 최적화하는 것이 좋겠습니다.
  • 이 부분을 체감하고 싶다면 react-native에서 테스트 해보신다면 좀 더 확실히 체감하실 수 있을 겁니다.

 

Api 통신을 통해 가져온 데이터 혹은 무거운 작업을 하는 함수들은 ‘useMemo()’를 사용해서 Memoization해서 사용합니다.(또는 어떤 이유에서 특정 ‘state’가 변경될 때만 재생성되어야 하는 데이터 혹은 함수를 감싸서 사용합니다.)

  • react로 애플리케이션을 만들다보면 ‘state’가 복잡하게 엮이거나 해서 불필요한 re-rendering이 발생하지 않도록 해야할 때 사용합니다.
  • 그리고 외부 라이브러리들을 쓰다보면 react의 re-rendering 때문에 문제가 되는 경우가 자주 있는데 그럴 때 필요할 때만 re-rendering 되도록 세팅하는데 사용합니다.
  • 단, ‘useMemo()’는 반드시 필요한 부분에서만 사용하도록 해야합니다. 만약 ‘useMemo()’로 모든 것들을 감싸서 사용한다면 ‘useMemo()’가 ‘props’를 비교하는데 불필요한 자원을 사용하게 되어서 성능이 오히려 저하될 수 있습니다.

 

Component를 매핑할 때에는 ‘key’값으로 ‘index’값을 사용하는 대신에 고유한 값을 사용합니다.

  • 보통 ‘map()’ 함수를 사용해서 배열을 순회하며 같은 Component가 여러개 생성되는 경우에 key값을 사용하여 매핑하게 됩니다.
  • 여기서 ‘key’값을 부여할 때 ‘map()’ 함수가 두번째 파라미터 값으로 제공되는 1씩 증가하는 index값을 사용하게 되면 배열에 중간부분에 요소가 삭제되거나 삽입될 때 그 중간 이후에 위치한 모든 요소들의 ‘index’가 전부 변경됩니다.
  • 공식문서에는 ‘index’값을 ‘key’값으로 지정하면 성능이 저하되거나 ‘state’와 관련된 문제가 발생할 수 있으니 사용을 지양하라고 명시되어 있습니다. 실제로 react는 ‘key’값이 동일한 경우 동일한 DOM Element를 보여주기 때문에 예상한 것과 다른 결과가 화면에 표시될 수 있습니다.
  • 그러므로 아래의 3가지의 경우를 제외하고는 고유한 값을 ‘key’값으로 부여하도록 합니다.
    • 배열과 각 요소가 수정, 삭제, 추가되는 일이 없고, 단순히 보여주는 기능만을 담당하는 경우
    • 정말 고유한 값이 존재하지 않는 경우
    • 정렬 혹은 필터 요소가 존재하지 않는 경우