react 성능 최적화

2021. 6. 25. 09:47React

1. useMemo()

이 함수는 React Hook 중 하나로서 React에서 CPU 소모가 심한 함수들을 캐싱하기 위해 사용된다.

function App() {
    const [count, setCount] = useState(0)
    
    const expFunc = (count)=> {
        waitSync(3000);
        return count * 90;
    }
    const resCount = expFunc(count)
    return (
        <>
            Count: {resCount}
            <input type="text" onChange={(e)=> setCount(e.target.value)} placeholder="Set Count" />
        </>
    )
}

위 코드에 expFunc은 3분후 실행되는 비싼 함수이다. 이 함수는 count를 입력받아 3분을 기다린 후 90을 곱하여 리턴한다. 또한 useState hook에서 count 변수를 받아 expFunc을 실행하는 resCount 또한 확인할 수 있다. 여기서 count는 입력할 때마다 값이 변경되어야 한다.

아무거나 입력할 때마다 앱 컴포넌트가 다시 렌더링 되어 expFunc 함수가 호출 된다. 계속 입력을 하면 대규모 성능 병목 현상을 유발하는 기능이 실행되고 있다는 것을 알게 될 것이다. 각각의 입력마다 렌더링하는 데 최소 3분이 소요된다. 만약 3을 입력하게 되면 expFunc은 3분동안 실행되고 다시 3을 입력하면 또 3분간 실행이 된다. 하지만 이전 입력과 같이 때문에 두번째 입력에서는 다시 실행되어서는 안되고 결과를 어딘가 저장한 후 expFunc이 실행되지 않고 값을 리턴해야 한다.

위와 같은 문제는 useMemo를 통해 expFunc을 최적화 함으로써 해결할 수 있다. useMemo는 아래와 같은 구조를 가진다.

useMemo(()=> func, [input_dependency])

이제, useMemo를 이용하여 위의 코드를 최적화 해보도록 하자:

function App() {
    const [count, setCount] = useState(0)
    
    const expFunc = (count)=> {
        waitSync(3000);
        return count * 90;
    }
    const resCount = useMemo(()=> {
        return expFunc(count)
    }, [count])
    return (
        <>
            Count: {resCount}
            <input type="text" onChange={(e)=> setCount(e.target.value)} placeholder="Set Count" />
        </>
    )
}

2. Web worker

자바스크립트 코드는 싱글 쓰레드에서 동작한다. 동일한 쓰레드에서 오래걸리는 프로세스를 실행하면 UI 렌더링 코드에도 심각한 영향을 미치므로 최선의 방책은 프로세스를 다른 쓰레드로 옮기는 것이다. 이것은 Web worker들이 하는 역활이다. 이것들은 UI 흐름을 방해하지 않고 메인 쓰레드와 동시에 실행할 수 있는 게이트웨이 이다.

React에서 공식적으로 지원하지는 않지만 Web worker를 다양한 방식으로 사용할 수 있다. 그 중 한가지는 아래와 같다:

// webWorker.js
const worker = (self) => {
    function generateBigArray() {
        let arr = []
        arr.length = 1000000
        for (let i = 0; i < arr.length; i++)
            arr[i] = i
        return arr
    }
    function sum(arr) {
        return arr.reduce((e, prev) => e + prev, 0)
    }
    function factorial(num) {
        if (num == 1)
            return 1
        return num * factorial(num - 1)
    }
    self.addEventListener("message", (evt) => {
        const num = evt.data
        const arr = generateBigArray()
        postMessage(sum(arr))
    })
}
export default worker
// App.js
import worker from "./webWorker"
import React, { Component } from 'react';
import './index.css';
class App extends Component {
    constructor() {
        super()
        this.state = {
            result: null
        }
    }
    calc = () => {
        this.webWorker.postMessage(null)
    }
    componentDidMount() {
        let code = worker.toString()
        code = code.substring(code.indexOf("{") + 1, code.lastIndexOf("}"))
        const bb = new Blob([code], { type: "application/javascript" });
        this.webWorker = new Worker(URL.createObjectURL(bb))
        this.webWorker.addEventListener("message", (evt) => {
            const data = evt.data
            this.setState({ result: data })
        })
    }
    render() {
        return ( 
            <div>
                <button onClick = { this.calc }> Sum </button>   
                <h3> Result: { this.state.result }</h3>  
            </div>
        )
    }
}

이 앱은 10만개의 요소가 들어있는 배열의 합을 계산하는데, 만약 메인 쓰레드에서 작업을 했다면 메인쓰레드는 10만개의 요소들을 통과하고 그 합계를 계산할 때까지 다른 작업은 할 수 없을 것이다.

자, 이제 Web worker로 옮겼다. 메인 쓰레드는 web worker와 원할하게 병렬로 실행될 것이고, 10만개의 요소가 담긴 배열의 합은 계산될 것이다. 결과는 완료되었을 때 전달되며 메인 쓰레드는 결과만 제공하면 된다. 빠르고, 간단하며 성능 또한 뛰어나다.

 

3. Lazy Loading

Lazy loading은 부하를 단축하기 위해 자주 사용되는 최적화 기법 중 하나이다. Lazy Loading은 일부 웹 앱 성능 문제의 위험을 최소화 하는데 도움이 된다.

React에서 component를 lazy load를 이용하기 위해 React.lazy() API를 사용한다.

React.lazy는 React v16.6에 새로 추가된 기능이다. 이것은 React Component를 쉽고 직관적으로 lazy-loading과 코드 스플리팅을 사용하기 위해 제안된 방법이다.

React.lazy 함수는 동적 import를 사용하며 일반 component처럼 렌더링할 수 있게 해준다. - React blog

React.lazy는 동적 import를 사용하여 component를 생성하고 렌더링 하는 걸 쉽게 만들어준다. React.lazy는 파라미터로 함수를 받는다.

React.lazy(()=>{})
// or
function cb () {}
React.lazy(cb)

이 콜백 기능은 반드시 동적 import 구문을 이용하여 컴포넌트 파일을 불러와야 한다.

// MyComponent.js
class MyComponent extends Component{
    render() {
        return <div>MyComponent</div>
    }
}
const MyComponent = React.lazy(()=>{import('./MyComponent.js')})
function AppComponent() {
    return <div><MyComponent /></div>
}
// or
function cb () {
    return import('./MyComponent.js')
}
const MyComponent = React.lazy(cb)
function AppComponent() {
    return <div><MyComponent /></div>
}

React.lazy의 콜백 기능은 import() 호출을 통해 Promise를 반환한다. Promise는 모듈이 성공적으로 불러왔는지 여부를 확인하고 네트워크 오류, 잘못된 경로 확인, 파일 없음 등으로 인해 모듈을 로드하는 동안 오류가 발생했는지를 확인한다.

webpack이 코드를 컴파일하고 번들링할 때 React.lazy()와 import()를 히트할때 별도의 번들을 만든다. 앱은 다음과 같이 될 것이다.

react-app
 dist/
  - index.html
  - main.b1234.js (contains Appcomponent and bootstrap code)
  - mycomponent.bc4567.js (contains MyComponent)
/** index.html **/
<head>
    <div id="root"></div>
    <script src="main.b1234.js"></script>
</head>

이제 앱은 멀티 번들로 분리된다. AppComponent가 렌더링 될 때 mycomponent.bc4567.js 파일은 로드되어 MyComponent가 DOM에 보여진다.

 

4. React.memo()

useMemo와 React.PureComponent와 같이 React.memo()는 함수 컴포넌트를 캐시하는데 사용된다.

function My(props) {
    return (
        <div>
            {props.data}
        </div>
    )
}
function App() {
    const [state, setState] = useState(0)
    return (
        <>
            <button onClick={()=> setState(0)}>Click</button>
            <My data={state} />
        </>
    )
}

App 컴포넌트는 state를 data라는 props로 My 컴포넌트에 넘겨준다. 버튼 엘리먼트의 onClick을 보면 클릭할 때 마다 state 값을 0으로 변환해주는 작업을 한다. 만약 버튼을 계속 누른다면, state 값이 동일함에도 불구하고 My 컴포넌트는 계속 리렌더링이 된다. App 과 My 컴포넌트 하위로 수천개의 컴포넌트들이 존재한다면 엄청난 성능 이슈가 될 것이다.

리렌더링 되는 것을 줄이기 위해서, My 컴포넌트를 memoized 버전으로 리턴하는 React.memo를 이용하여 한번 감싼 후 App에 포함 시켜 줄 것이다.

function My(props) {
    return (
        <div>
            {props.data}
        </div>
    )
}
const MemoedMy = React.memo(My)
function App() {
    const [state, setState] = useState(0)
    return (
        <>
            <button onClick={()=> setState(0)}>Click</button>
            <MemeodMy data={state} />
        </>
    )
}

위와 같이 변경하면 버튼을 연속적으로 클릭해도 My 컴포넌트는 오직 한번만 렌더링 된 후 다시는 리렌더링 되지 않는다. 이것은 React.memo가 props 값을 memoize 한 후 캐싱된 결과를 리턴하기 때문에 동일한 입력에 대해서는 My 컴포넌트를 실행하지 않기 때문이다.

React.PureComponent가 class component를 위한 거라면 React.memo는 함수형 component를 위한 캐싱 방법이다.

 

5. useCallback()

useMemo와 비슷하지만 차이점은 함수 선언을 memoize 하는데 사용된다는 것이다.

아래와 같은 컴포넌트가 있다고 하자.

function TestComp(props) {
    l('rendering TestComp')
    return (
        <>
            TestComp
            <button onClick={props.func}>Set Count in 'TestComp'</button>
        </>
    )
}
TestComp = React.memo(TestComp)
function App() {
    const [count, setCount] = useState(0)
    return (
        <>
            <button onClick={()=> setCount(count + 1)}>Set Count</button>
            <TestComp func={()=> setCount(count + 1)} />
        </>
    )
}

App 컴포넌트는 useState를 이용하여 count 값을 관리하고 있다. 언제든 setCount를 실행시키면 App 컴포넌트는 리렌더링 된다. App 컴포넌트는 하위로 button 과 TestComp 컴포넌트를 가지고 있다. 만약 Set Count 버튼을 클릭하면 App 컴포넌트는 자식 트리를 포함하여 리렌더링 된다. 여기서 TestComp는 불필요한 렌더링을 막기 위해 memo를 이용하여 memoize 되어 있다. React.memo는 현재와 다음 props를 비교하여 이전 props와 같다면 컴포넌트를 리렌더링 하지 않는다. TestComp는 func props를 함수로 받고 있는데 언제든 App이 리렌더링 될 때 TestComp에게 전달되는 func props가 동일한지 체크한 후 동일하다면 리렌더링 되지 않아야 한다.

하지만 문제는 TestComp는 새로운 인스턴스의 함수를 전달받는다. 어떻게 이런 결과가 나온 것일까? 아래 JSX를 보자.

...
    return (
        <>
            ...
            <TestComp func={()=> setCount(count + 1)} />
        </>
    )
...

화살표 함수 선언이 전달되므로 App 컴포넌트가 리랜더링 할때마다 항상 새 참조로 새로운 함수 선언이 전달된다(메모리 주소 포인터). 따라서 얕은 비교를 하는 React.memo는 다른 결과가 들어왔다고 이해하고 리렌더링을 하도록 실행한다.

여기서 이 문제를 어떻게 해결할 수 있을까? 함수 선언을 컴포넌트 밖에서 해야 할까? 이렇게 된다면 좋겠지만 그럴 경우 setCount 함수를 참조할 수 없게 된다. 여기서 useCallback이 필요한 것이다. useCallback으로 함수와 변경될 기준 값을 같이 전달하면 useCallback은 memoize된 함수를 리턴하고 이 함수 값을 TestComp에 전달하면 된다.

function App() {
    const check = 90
    const [count, setCount] = useState(0)
    const clickHndlr = useCallback(()=> { setCount(check) }, [check]);
    return (
        <>
            <button onClick={()=> setCount(count + 1)}>Set Count</button>
            <TestComp func={clickHndlr} />
        </>
    )
}

여기서 clickHndlr는 dependency 값인 check가 변경되지 않는 한 App 컴포넌트가 리 렌더링 되어도 새로 생성되지 않으므로 Set Count 버튼을 반복해서 클릭해도 TestComp는 다시 리렌더링 되지 않는다. useCallback check 변수값을 확인하여 이전 값과 변경되었다면 새로운 함수를 리턴하고 TestComp React.memo는 새로운 참조가 되었으므로 리렌더링 된다. 만약 동일하다면 useCallback은 아무것도 리턴하지 않고 React.memo는 함수 참조가 이전과 같다고 판단하여 TestComp를 리렌더링 하지 않도록 할 것이다.

 

 

여기에 있는 트릭들이 전부 구현될 필요는 없다. 명심할 것은 미리 최적화를 수행하지 말로 먼저 프로젝트를 개발한 후 필요한 곳에 최적화를 하길 바란다.

'React' 카테고리의 다른 글

[React] useHistory  (0) 2021.07.01
[React] React Query 사용하기  (0) 2021.07.01
<Switch>는 언제 써야 할까?  (0) 2021.06.23
Code Splitting 과 React lazy, Suspense  (0) 2021.06.23
[React] useSelector, useDispatch  (0) 2021.06.23