Published on

5 Ways to Optimize Your React app

Authors

React 는 UI 를 업데이트 하는데 필요한 DOM 작업을 내부적으로 최소화 시킴으로써 어느정도 성능을 보장시켜줍니다. 그러나 개발자가 직접 React 앱을 최적화 시킬 수 있는 방법들도 존재합니다. 이번 포스팅에서는 React 어플리케이션을 최적화 시키는 5 가지 주요 기술들에 대해서 알아봅시다.

1. Using Immutable Data

데이터 불변성(Immutable) 이란 객체가 생성된 이후 그 상태를 변경할 수 없는 opinionated way 를 말합니다. 객체는 참조(reference) 형태로 전달되고 받는데, 이는 객체의 참조를 가지고 있는 어떤 장소에서 객체를 변경하면 참조를 공유하는 모든 곳에서 그 영향을 받기 때문인데 이것이 의도한 동작이 아니라면 문제가 될 수 있습니다.

이 문제는 객체를 불변 객체로 만들어 변경을 방지하거나, 방어적 복사(defensive copy)를 통해 객체를 새롭게 생성하여 변경하도록 할 수 있습니다. ES6 에서는 immutability 를 손쉽게 보장할 수 있는 메서드들(Object.assign, Object.freeze)이 존재하죠. 나아가 깊이 중첩된 객체의 불변성을 지켜주는데 도움이 되는 immer 등의 라이브러리도 활용할 수 있습니다.

React 에서는 직접 state 를 변경하지않고 setState 란 메서드로 state 를 변경하도록 함으로써 immutability 를 보장합니다. 그럼에도 불구하고 개발자들이 흔히 범하기 쉬운 immutability 를 해치는 실수들을 많이 볼 수 있습니다. 이해를 돕기 위해 다음과 같이 user state 를 갖는 컴포넌트가 있다고 가정해보죠.

state = {
  users: [],
}
/** 나쁜 예) 새로운 user 를 추가합니다. */
addNewUser = () => {
  const users = this.state.users
  users.push({
    name: 'David',
    email: 'david@email.com',
  })
  this.setState({ users: users })
}

this.state.user를 참조하는 user 변수를 만들고 data 를 추가한 뒤 this.setState 를 호출해주고 있습니다. 위 코드는 어디가 잘못되었을까요? 다음과 같이 shouldComponentUpdate 메서드가 정의되 있고 user state 가 변경될 때만 re-render 가 이뤄지도록 해준다고 해봅시다.

shouldComponentUpdate(nextProps, nextState) {
  // this.state.users 와 nextState.users 는 동일한 참조(reference)를 가리키므로
  // re-rendering 이 일어나지 않습니다.
  if (this.state.users !== nextState.users) {
    return true;
  }
  return false;
}

이렇게 되면 위에서 user 가 변경되더라도, 동일한 참조를 갖기 때문에 re-render 가 이뤄지지 않습니다. 이러한 문제를 피하기 위해선 직접 statue 를 변경하지 않고 새로운 객체를 할당해주어야 합니다. ES6 의 spread operator 를 활용하면 다음과 같이 간단히 작성할 수 있습니다.

/** 좋은 예) 새로운 user 를 추가합니다 */
addNewUser = () => {
  this.setState((state) => ({
    users: [...this.state.users, { name: 'David', email: 'david@email.com' }],
  }))
}

이렇게 state 의 immutability 를 보장함으로써, 다음과 같은 benefit 을 얻을 수 있습니다.

  • Side-effect 를 최소화 시킬 수 있습니다.
  • 상태의 변경을 추적하기 쉽습니다(데이터가 단방향으로 흐르므로).

2. Function Components and React.PureComponent

함수형 컴포넌트(Function Component)와 퓨어 컴포넌트(React.PureComponent)는 서로 다른 최적화 기능을 제공합니다.

먼저 PureComponent(or 커스텀 shouldComponentUpdate 를 정의한 클래스 컴포넌트) 는 상태가 변경될 때 이전 상태와 다음 상태 간의 얕은 비교(shallow comparisoon)을 수행합니다. 얕은 비교는 값(value)을 비교할 땐 동등 비교(===)를 하고 참조(reference)를 비교할 땐 참조하는 객체의 값이 아닌 메모리 상의 객체의 위치를 비교합니다. 이러한 방법으로 PureComponent 는 불필요한 re-render 를 방지할 수 있는데, 대신 위 특징으로 인해 다음과 같은 조건이 반드시 수반되어야 합니다.

  • Component 의 props / state 는 반드시 immutable 이어야 합니다.
  • Props / state 가 nested object 여서는 안됩니다.

Note: PureComponent 의 모든 자식은 반드시 Pure 하거나 함수형 컴포넌트 이어야 합니다.

반면에 함수형 컴포넌트는 lifecycle 메서드를 사용하지 않고, 클래스 컴포넌트와 다르게 instance 를 생성하지 않아도 가능하기 때문에 번들링 사이즈를 더 적게 만들 수 있습니다. 또 함수형 컴포넌트도 React.memo() 라는 HOC 로 wrapping 함으로써 PureComponent 와 동일한 기능을 하도록 만들 수 있습니다. 그러나 현재 React(16.13.0) 버전의 경우 props 의 변경만 memoization 하고 있으며, state 가 변경되면 re-render 가 이뤄집니다. 만약 함수형 컴포넌트 안에서도 shouldComponentUpdate 와 같이 임의의 비교 로직을 활용하고 싶으면, React.memo() 에 두 번째 인자로 비교 함수를 전달해주면 됩니다.

function MyComponent(props) {
  return (/* ... */)
}
function areEqual(prevProps, nextProps) {
  /*
  현재 props 와 다음 prosp 가 동일하다면 true 를 반환하고
  그렇지 않으면 false 를 반환합니다.
  shouldComponentUpdate 와 반환값이 반대여야 합니다.
  */
}
export default React.memo(MyComponent, areEqual);

이외에도 함수형 컴포넌트 내에서 값을 재사용하는 useMemo, 함수를 재사용하는 useCallback hook 을 사용하여 컴포넌트가 re-render 되더라도 값이나 함수를 새로 할당하는 것을 예방할 수 있습니다.

3. Avoid Using Inline Function in the Render Method

Javscripe 에서 함수는 객체이므로, 인라인 함수는 React 의 diff check 알고리즘에 의해 변경이 감지되지 않습니다. 또 JSX element 의 속성값으로 인라인 함수가 주어지게 되면 매 rendering 마다 새로운 function instance 를 생성하게 되고, 이는 garbage collector 의 작업량을 증가시킵니다. 예를 들어 다음과 같이 간단한 Todolist 컴포넌트가 있다고 해보죠.

default class TodoList extends React.Component {
  state = {
    todos: [],
    selectedTodo: null
  }
  render(){
    const { todos } = this.state;
    return (
      todos.map((todo)=>{
        return <Comment onClick={(e)=>{
          this.setState({selectedTodo: todo.id})
        }} todo={todo} key={todo.id}/>
      })
    )
  }
}

onClick 이벤트 핸들러로 인라인 함수를 사용하고 있습니다. 실제로 많은 개발자들이 이런식으로 핸들러를 구현하죠. 그런데 다음과 같이 컴포넌트 내 function 을 정의하고 해당 함수를 props 로 전달하는 것이 좋습니다.

default class TodoList extends React.Component {
  state = {
    todos: [],
    selectedTodo: null
  }
  onTodoClick = (todo) => {
    this.setState({ selectedTodo: todo })
  }
  render(){
    const { todos } = this.state;
    return (
      todos.map((todo)=>{
        return <Comment onClick={this.onTodoClick}
          todo={todo} key={todo.id}/>
      })
    )
  }
}

4. Code Splitting

React 컴포넌트가 많아질수록 번들의 크기도 커집니다. 번들의 크기가 너무 거대해지는 것을 방지하는 좋은 방법은 코드를 "나누는" 것입니다. 이를 코드 분할(Code Splitting)이라고 하며, 런타임에서 여러 번들을 동적으로 만들고 필요한 코드만을 불러오는 것을 말합니다. 코드 분할은 앱을 "지연 로딩(Lazy Loading)" 할 수 있도록 도와주며 성능적인 측면에서 획기적인 향상을 이루게 해줍니다. Webpack, Rollup, Browserify 등과 같은 번들러들이 기본적으로 지원하고 있습니다.

import()

import() 문법을 활용하면 모듈을 동적으로 import 할 수 있습니다

// before
import { add } from './math'

// after
import('./math').then((math) => {
  console.log(math.add(16, 26))
})

React.lazy

React.lazyimport() 를 활용하여 동적으로 컴포넌트를 렌더링 할 수 있습니다.

// before
import OtherComponent from './OtherComponent'

// after
const OtherComponent = React.lazy(() => import('./OtherComponent'))

위 코드 예제에서 import() 를 호출하는 컴포넌트가 처음 렌더링 될 때, OtherComponent 를 포함한 번들을 자동으로 불러오게 됩니다. React.lazyimport() 를 호출하는 함수를 인자로 받으며, default export 된 모듈을 반환하는 Promise 를 리턴합니다. 이렇게 lazy loading 된 컴포넌트는 Suspense 컴포넌트 하위에서 렌더링 되어야 합니다. fallback props 로 lazy loading 되는 동안 다른 컴포넌트를 보여줄 수도 있습니다.

import React, { Suspense } from 'react'
// lazy loading 을 통해 동적으로 import 합니다.
const OtherComponent = React.lazy(() => import('./OtherComponent'))
// Suspense 컴포넌트의 자식 컴포넌트로 lazy loading 된 컴포넌트를 전달합니다.
function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  )
}

이러한 코드 분할의 best practice 는 바로 라우팅 기반으로 분할을 하는 것입니다. 아래 예시처럼 React-router 라이브러리와 함께 사용할 수 있습니다.

import React, { Suspense, lazy } from 'react'
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'

const Home = lazy(() => import('./routes/Home'))
const About = lazy(() => import('./routes/About'))
// 각 Route 에 lazy loading 된 컴포넌트를 제공합니다.
// 페이지 전환 시 해당 컴포넌트를 동적으로 import 합니다.
const App = () => (
  <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Switch>
        <Route exact path="/" component={Home} />
        <Route path="/about" component={About} />
      </Switch>
    </Suspense>
  </Router>
)

현재 React.lazy 는 서버사이드 렌더링(SSR) 을 지원하지 않습니다. 이를 지원하는 lazy loading 라이브러리로는 대표적으로 Loadable Components가 있습니다.

5. Virtualize Long Lists

List virtualizing 혹은 windowing 은 많은 양의 데이터 리스트를 rendering 할 때 성능을 최적화하기 위해 사용될 수 있습니다. 이 방법은 특정 시간에 전체 리스트에서 특정 부분만을 보여줌으로써, 컴포넌트가 rendering 될 때 필요한 시간 뿐만 아니라 생성되는 DOM 노드의 수도 크게 줄일 수 있습니다.

이 기능을 지원하는 라이브러리로는 대표적으로 react-virtualized, react-window 등이 있습니다.

Recap

지금까지 React 어플리케이션을 최적화 시킬 수 있는 방법들 중 대표적인 5 가지 방법들에 대해서 살펴보았습니다. 이 외에도 서버사이드에서 번들링 된 파일을 전송할 때 Gzip, Brotli 등으로 압축해서 전달하면 초기 로딩 속도를 획기적으로 상승시킬 수 있습니다. 또 변하지 않는 static 한 함수나 변수들을 컴포넌트 바깥에 정의하면 재정의를 피할 수 있죠. React 공식 문서에서는 profiler 를 활용해서 퍼포먼스를 체크해보는 것을 권장하고 있습니다. Profiler 는 저도 아직 제대로 활용해본 적이 없는데, 기회가 되면 profiler 를 활용해서 퍼포먼스를 체크하는 것도 포스팅해보려고 합니다 😀.

References