Re:act - React의 동작 방식과 최적화

Janek·2023년 4월 24일
0
post-thumbnail

React 동작 방식

컴포넌트

React 앱은 컴포넌트를 통해 작업을 수행한다. 컴포넌트는 React에게 해당 컴포넌트의 출력을 JSX 코드로 반환하는 Function 혹은 Class이다.

내부적으로 stateprops, context를 가지며, propscontext의 변화는 결국 state의 변경으로 이어진다.

state는 컴포넌트 혹은 어플리케이션 전체에 영향을 주는 상태값을 나타내며, 변경된 state를 지닌 컴포넌트는 재평가되고, 재호출 된다. 이 때 전체 함수/객체를 재실행하고 이를 통해 코드를 전부 리빌드한다.

React DOM

React는 컴포넌트들을 관리할 뿐, 웹이나 브라우저와는 관계가 없다. 어떻게 컴포넌트를 다루는지는 알고 있지만 이러한 컴포넌트에 실제 HTML요소가 존재하는지에 대해서는 관여하지 않는다.

React는 컴포넌트 내부의 state에 대한 변경만을 감지한 후 변경된 정보를 React DOM과 같은 인터페이스에 전달한다.

React DOM은 실제 DOM에 대한 작업을 하며 사용자가 직접 접하는 UI에 대한 작업을 담당한다.

DOM 업데이트

React 내부의 state 값이 업데이트 되면 React는 컴포넌트 함수를 다시 호출한다. 업데이트된 state 정보를 토대로 React DOM은 갱신 전후의 상태값의 차이를 인식한 후 React가 컴포넌트 트리를 통해 구성한 가상 스냅샷인 Virtual DOM과 일치하도록 실제 DOM을 조작한다.

즉 컴포넌트가 재호출 된다고 실제 DOM이 업데이트되는 것이 아닌 컴포넌트의 state와 트리, 그리고 현재 state 사이의 차이점을 기반으로 변경이 필요할 때만 업데이트된다.

실제 DOM을 통한 업데이트는 가상 스냅샷 간의 차이점만 반영된다.(실제 변경이 일어난 DOM만 변경된다.)

컴포넌트 호출

DOM의 업데이트 없이 컴포넌트의 호출 및 재평가만 이루어진다면 실제 성능에 미치는 영향은 크지 않을 수 있다.

그러나 부모 컴포넌트의 재평가가 이루어질 경우 모든 자식 컴포넌트들의 재평가 또한 이루어지게 되고(실제 state 값이 바뀌지 않았다 할지라도)

const App = () => {
    console.log('App running...');
    const [showParagraph, setShowParagraph] = useState(false);

    const toggleParagraphHandler = () => {
        setShowParagraph(prevState => !prevState);
    }

    return (
        <div className="app">
            <h1>Hi there!</h1>
            <DemoOutput show={false}/>
            <Button onClick={toggleParagraphHandler}>Toggle Paragraph!</Button>
        </div>
    );
}

export default App;
const DemoOutput = props => {
    console.log('DemoOutput running...');
    return <MyParagraph>{props.show ? 'This is new!' : ''}</MyParagraph>
}

export default DemoOutput;
const MyParagraph = props => {
    console.log('MyParagraph running...');
    return <p>{props.children}</p>
}

export default MyParagraph;

위의 예제에서 부모 컴포넌트인 Appstate값인 showParagraph가 바뀔 경우 자식 컴포넌트인 DemoOutputMyParagraph는 그 값이 변하지 않았다 할지라도 재호출이 일어난다.

실제 DOM은 변경된 값이 없기 때문에 업데이트 되지 않지만, 반복되는 컴포넌트의 재호출은 결과적으로 불필요한 리소스의 낭비가 될 수 있다.(무엇보다 찝찝하다...)

컴포넌트 호출 최적화

React.memo

때문에 불필요한 컴포넌트의 호출을 막기 위해 컴포넌트에서 받는 props의 값에 대한 변경이 있을 경우에만 해당 컴포넌트를 호출하도록 React.memo()를 사용할 수 있다.

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

export default React.memo(DemoOutput);

React.memo()는 입력되는 props의 모든 신규 값을 확인한 후 기존의 props값과 비교하여 변경이 존재할 경우에만 컴포넌트를 호출한다. 당연하게도 해당 컴포넌트의 자식 컴포넌트까지 적용되며, 이를 통해 컴포넌트를 최적화할 수 있다.(Class형 React에서는 사용할 수 없다.)

최적화의 비용

이렇게 최적화가 가능하다면 왜 모든 컴포넌트에 적용하지 않을까? 이는 최적화에도 비용이 발생하기 때문이다.

React.memo()를 사용할 경우 부모 컴포넌트의 state 값이 변경될 때마다 해당 자식 컴포넌트로 이동해 props를 비교하는 작업을 거치게되며, 이 때 기존 props를 저장할 공간과 신규 props와 기존 값을 대조하는 작업이 필요하게 된다. 이러한 작업들은 각각 개별적인 성능 비용을 발생시킨다.

즉 해당 컴포넌트를 재호출하는데 지불하는 비용과 props를 비교하는 성능 비용에 대한 trade off가 발생하는 것이다.

memo의 효율

해당 컴포넌트에서 받는 props의 개수와 컴포넌트의 복잡도, 자식 컴포넌트의 개수 등을 고려해야 한다.

결과적으로memo를 사용하고자 하는 컴포넌트가 컴포넌트 트리에서 상위에 위치해 있고, 자식 컴포넌트의 수가 많을수록 효율적이라고 할 수 있지만, props의 값에 대한 변경이 자주 일어날 수록 그 효과가 미미하다.

props 최적화

useCallback()

기본적으로 React.memo()props의 값에 대한 동일성을 비교하기 때문에, 컴포넌트 재호출시 새롭게 생성되는 참조값에 대해서는 적용되지 않으며, 이는 함수도 동일하게 동작한다.

그러나 useCallback() hook을 통해 props를 통해 받는 함수도 최적화할 수 있다.

useCallback()은 기본적으로 컴포넌트 실행 전반에 걸쳐 함수를 저장하는 기능을 제공해주는 hook으로, props를 통해 받는 함수를 저장하고 컴포넌트 호출시 해당 함수에 대한 재생성을 방지할 수 있다.

function App() {
    console.log('App running...');
    const [showParagraph, setShowParagraph] = useState(false);

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

    return (
        <div className="app">
            <h1>Hi there!</h1>
            <DemoOutput show={false}/>
            <Button onClick={toggleParagraphHandler}>Toggle Paragraph!</Button>
        </div>
    );
}

export default App;

useCallback()은 첫번째 인자로 저장하고자 하는 함수를 받으며, 두번째 인자로 해당 함수에 대한 의존성 배열을 받는다. 이 때 useState()의 set함수와 같이 React에서 변경되지 않음을 보장하는 값은 생략 가능하다.

useCallback의 종속성

Javascript에서 함수는 클로저이다. 즉 함수가 정의되면, 해당 함수에서 사용되는 함수 외부의 값을 저장한 후 해당 함수 호출시 저장된 시점의 값을 사용하게 된다.

일반적인 경우 이는 함수 호출시 정확한 값에 접근할 수 있게 해주는 매우 유용한 기능이지만, useCallback()을 사용할 경우 React에서 해당 함수를 저장한 후 state값이 변하고, 컴포넌트가 다시 호출되어도 재생성하지 않기 때문에 해당 함수는 처음 시점의 변수 값만을 사용하게 된다.

따라서 useCallback()에서 사용되는 외부 state 값은 의존성 배열에 추가하여 state 값에 대한 변경이 일어날 경우 해당 함수를 재생성하여, 정확한 현재 state 값을 사용할 수 있도록 해야한다.

State 최적화

상태 갱신 예약(Scheduled State Change)

React에서 state 변경 함수의 호출을 통해 state의 값이 변경될 경우 즉시 실행되는 것이 아닌, 업데이트에 대한 예약을 걸어둔 후 컴포넌트가 재호출될 때 업데이트 된다.

이러한 작업은 대부분 즉각적이라 할 수 있을 정도로 빠르게 처리되지만, 여러 예약이 동시에 존재할 경우 갱신이 지연될 수 있으며, 결과적으로 변경되지 않은 값을 받게 되는 경우가 발생할 수 있다.

따라서 상태 변경에 대한 최신의 state 값을 받기 위해서는 미완료된 최신의 변경 값을 사용할 수 있도록 함수 형태로 상태 변경 함수를 호출해야 한다.

setSomething(prevState => 'change something');

한개 이상의 state 변경 작업이 어떠한 callback이나 Promise 없이 같은 코드 블럭에 속한다면 React는 하나의 동기화 프로세스에서 같이 실행한다.

useEffect()

useEffectstate 또는 종속된 값이 변경될 때마다 의존성 메커니즘을 통해 내부에 선언된 식을 재실행함으로 컴포넌트 또한 재실행되기 때문에 미완료된 상태 변경 작업이 빠짐없이 실행된다.

따라서 이전 상태에 따른 업데이트를 원할 경우 상태 갱신 예약을, 다른 상태값에 따른 업데이트를 원할 경우 useEffect를 사용하는 편이 좋다.

useMemo()

useMemo는 데이터를 저장하고, 의존성 배열에 추가된 값이 메모리상에서 변경될 때만 해당 데이터를 재정의 하여 사용한다.

const {items} = props;

const sortedList = useMemo(() => {
	return items.sort((a, b) => a - b);
}, [items])

컴포넌트의 재호출시 불필요한 연산이나 데이터 변경을 막아야할 경우 사용할 수 있다.

profile
만들고 나누며, 세상을 이롭게 하고 싶습니다.

0개의 댓글