원문보기

React Redux 성능 튜닝 팁

복잡한 React 앱을 작성할 때면 렌더링 성능과 사투를 벌이고 있는 자신을 발견할 것이다. 이 글에서는 성능 병목 현상을 탐지하고 고치는 도구와 테크닉을 전반적으로 다룰 것이다.

문제는 React의 렌더링 프로세스다

컴포넌트가 this.setState()를 호출하면, React는 DOM을 2단계에 걸쳐 재 렌더링한다:

  1. React 내부의 가상 DOM을 재 렌더링한다
  2. 이전 가상 DOM과 현재 가상 DOM을 diff하고 변경점을 계산하여 실제 DOM에 적용한다

첫 번째 단계가 너무 오래 걸리게 되는 경우에 재 렌더링은 느려질 것이다.

pure 컴포넌트의 재 렌더링 피하기

React 렌더링 최적화의 기본 원리는 React에게 DOM 결과에 아무런 변화가 없을 것이기 때문에 다시 렌더링할 필요가 없다고 알게 하는 것이다. React 이러한 신호를 보낼 수 있는 몇 가지 방법이 있다:

같은 엘레멘트의 레퍼런스를 반환하기

render 메서드가 같은 레퍼런스를 반환하면, React는 변화가 없는 것으로 간주할 것이다.

  class MyComponent extends Component {
    text = ""
    renderedElement = null

    _render() {
      return <div>{this.props.text}</div>
    }

    render() {
      if (!this.renderedElement || this.props.text !== this.text) {
        this.text = this.props.text
        this.renderedElement = _render()
      }
      return this.renderedElement
    }
  }

캐싱을 직접 구현하기 싫으면 lodash의 memoize 함수를 이용할 수도 있다.

  import memoize from 'lodash/memoize'

  class MyComponent extends Component {
    _render = memoize((text) => <div>{text}</div>)

    render() {
      return _render(this.props.text)
    }
  }

shouldComponentUpdate에서 false를 리턴하기

React는 재 렌더링 해야하는지를 이 메서드를 통해 확인한다. 이 메서드의 기본 구현은 항상 true를 리턴하는 것이다. React는 shallowCompare 함수를 제공한다. (물론 다른 라이브러리에서도 이런 함수를 제공하고 있다) 이 함수는 두 오브젝트를 비교할 때 탑 레벨의 프로퍼티의 동등성을 확인한다. 이 함수를 사용하면 pure 컴포넌트를 다음과 같이 구현할 수 있다:

  import shallowCompare from 'react-addons-shallow-compare'

  export default class PureComponent extends Component {
    shouldComponentUpdate(nextProps, nextState) {
      return shallowCompare(this, nextProps, nextState);
    }

    render () {...}
  }

재 렌더링을 할지 말지를 결정하는 당신만의 로직을 작성할 수도 있지만, 함수가 매우 빠르다는 것을 확신할 수 있어야 한다. 만약 이 함수가 느리면, 다시 처음으로 돌아가게 된다. 왜냐하면 이 함수는 React가 재 렌더링을 하려 할때마다 호출될 것이기 때문이다.

고차 컴포넌트(Higher Order Components)

고차 컴포넌트를 이용해 이 최적화를 구현하고 재사용할 수 있다. 사실, 우리가 사용할 수 있도록 상당한 양의 일반적인 고차함수를 포함하고 있는 Recompose라는 라이브러리가 이미 존재한다.

  // this component will re-render only when props change
  @pure
  class MyComponent extends Component {
    render() {
        ///...
    }
  }
  // this component will re-render only when prop1/prop2 changes
  // it will not re-render if prop3 changes
  @onlyUpdateForKeys(['prop1', 'prop2'])
  class MyComponent extends Component {
    render() {
        ///...
    }
  }
  // if you don't like ES7 decorators you can use them like this:
  MyComponent = pure(MyComponent)
  MyComponent = onlyUpdateForKeys(['prop1', 'prop2'])(MyComponent)

Redux와 connect()

redux를 사용할 때 우리는 고차 컴포넌트인 connect()를 사용한다. 이 컴포넌트는 스토어로부터 컨텍스트를 얻어오고 상태 변화가 있을 때 mapStateToProps를 호출한다. 커넥티드 컴포넌트는 상태 변화가 있을 때 현재 연관된 값이 실제로 변경되었을 때만 재 렌더링된다(마찬가지로 shallowCompare를 이용한다). 예를 들어:

  // only re-renders when prop1 changes
  connect(state => ({
      prop1: state.prop1
  }))(SomeComponent)

지금 뭔가 ‘걸려들었다’, 만약 mapStateToProps 함수가 어떤 계산을 한다면 connect()의 불필요한 재 렌더링을 유발할 수 있다.

  // this is ok
  connect(state => ({
      hasSomething: this.prop1 === 5 // true===true
  }))(SomeComponent)

  // this is NOT OK
  // computed data는 바뀌지 않은 경우에도 매번 다른 오브젝트다
  connect(state => ({
      computedData: {
          height: state.height,
          width: state.width
      }
  }))(SomeComponent)

reselect로 이슈 해결하기

reselect는 파생상태에 대한 의존을 선언함으로 파생 상태를 캐싱하여 memoize와 비슷하게 우리를 도와줄 수 있다:

  import {createSelector} from 'reselect'
  const selectComputedData = createSelector(
      state => state.height,
      state => state.width,
      (height, width) => ({
          height,
          width
      })
  )
  connect(state => ({
      computedData: selectComputedData(state)
  }))(SomeComponent)

Pure 렌더의 안티패턴

pure 컴포넌트를 사용할 때는 배열과 함수의 사용에 특히 주의해야 한다. 배열과 함수는 새로운 레퍼런스를 생성한다. 따라서 렌더 때마다가 아니라 단 한번만 생성하게 하는 것은 당신에게 달려있다.

Functions

  // 절대 이렇게 하면 안됨
  render() {
    return <MyInput onChange={this.props.update.bind(this)} />;
  }
  // 절대 이렇게 하면 안됨
  render() {
    return <MyInput onChange={() => this.props.update()} />;
  }
  // 대신 이렇게 해라
  onChange() {
      this.props.doUpdate()
  }
  render() {
    return <MyInput onChange={this.onChange}/>;
  }

recompose.withHandlers() 혹은 redux.connect()와 같은 고차 컴포넌트 안에서 바인딩하는 것에 의해 컴포넌트 내에서 바인딩이 일어나는 것 역시 피해야한다.

  // recompose
  @withHandlers({
    onChange: props => event => {
      props.update(event.target.value)
    }
  })
  class SomeComponent extends Component {
    render() {
      return <MyInput onChange={this.props.onChange}/>;
    }
  }

  // redux
  @connect(null, (dispatch, ownProps) => {
    onChange: event => {
      dispatch(actions.updateValue(event.target.value))
    }
  })
  class SomeComponent extends Component {
    render() {
      return <MyInput onChange={this.props.onChange}/>;
    }
  }

Arrays

  // 절대 이렇게 하면 안된다. 아이템이 없는 경우에 SubComponent는 매번 렌더링될 것이다!
  render() {
      return <SubComponent items={this.props.items || []}/>
  }
  // 이렇게 재 렌더링을 피할 수 있다
  const EMPTY_ARRAY = []
  render() {
      return <SubComponent items={this.props.items || EMPTY_ARRAY}/>
  }

디버깅

render() 메서드에 console.log()를 추가하는 것 외에, 성능 문제의 원인을 찾는 작업은 주로 두 가지 도구를 사용한다. 다음은 간단히 요약한 것이고, 자세한 정보와 스크린샷은 Benchling의 React performance engineering 파트1, 파트2 블로그 포스트를 통해 얻을 수 있다.

Chrome 개발자 도구 프로파일러

크롬 개발자 도구에서 타임라인 탭을 선택하고 애플리케이션에서 작업을 수행하는 동안 기록한다. 타임 라인은 브라우저가 코드 실행에 소비한 시간과 렌더링 시간을 보여준다. 여기서 렌더링은 브라우저가 DOM을 화면에 렌더링하는 데 걸린 시간을 의미한다. React의 render() 호출은 코드 실행 시간에 포함된다.

Redux를 사용하는 경우, ReduxDevTools의 슬라이더를 사용하여 느린 재 렌더링을 유발하는 것의로 의심되는 액션만을 반복해보라. 한 번에 하나씩 측정하라.

만약 batchUpdates같은 React 함수가 총 시간은 긴데 자체 시간이 짧다는 것을 관찰하게 된다면, 화면이 너무 자주 렌더링되기 때문에 React 컴포넌트에 성능 문제가 있음을 의미한다.

이런 경우에는 ReactPerf를 사용한다.

React Perf

React Perf addon을 설치한다.

  npm install react-addons-perf

프로젝트에 임포트하고 글로벌 컨텍스트에 노출한다:

  import Perf from 'react-addons-perf'
  window.Perf = Perf

프로젝트에 설치한 다음에는 React Perf Chrome Extension을 통해 성능 관리 기능을 호출하거나, 직접 콘솔에서 호출할 수 있다.

과정은 간단하다:

  1. Perf.start()를 호출한다
  2. 애플리케이션 내에서 몇가지 액션을 취한다. 이 내용은 Perf에 의해 기록될 것이다(가능하다면 ReduxDevTools의 슬라이더를 이용하라).
  3. Perf.stop()를 호출한다
  4. 이제 액션이 기록되었고 3가지 유용한 함수를 호출할 수 있다: printInclusive() — 각 컴포넌트가 얼마나 많은 시간을 소모했는지 출력한다 printExclusive() — 각 컴포넌트가 렌더링에 얼마나 많은 시간을 소모했는지 출력한다 (componentsWillMount, componentDidMount, props processing… 등은 포함하지 않음) printWasted() — 실제로 변경되지 않은 컴포넌트를 렌더링하는 데 낭비된 시간을 출력한다(렌더링이 가상 DOM 계층에서만 수행되고 브라우저 DOM에서는 변경이 발생하지 않은 시간). 이 함수는 위에서 기술한 안티패턴의 인스턴스와 pure 컴포넌트여야만 하는 컴포넌트를 보여줄 것이다. printOperations() — 실제 브라우저의 DOM 조작을 출력할 것이다, 브라우저가 너무 많은 시간을 렌더링에 사용한 경우에 편리하다.

최적화를 꼭 해야만 하나?

react-dom은 그 자체로 매우 빠르다. React Perf로 정량화 할 수 있는 문제가 있을 때만 최적화를 수행해야 할 것이다.

예를 들어, 다음과 같은 간단한 컴포넌트를 생각해보자:

  const Label = ({text}) => <div className='label'>{text}</div>

이 컴포넌트에서 text는 아마 절대 변경되지 않을 것이다. 이 컴포넌트를 pure하게 만드는 것은 옳게 느껴지지만 아마도 눈에 띌만한 향상을 일으키진 못할 것이다. shallowCompare를 실행하면 react-dom이 같은 계산을 실행할 것이므로 컴포넌트를 pure하게 만드는 것은 그저 처리 과정을 다른 곳으로 옮긴 것 뿐이다.

다른 말로 하자면, 미래의 문제를 미리 상상하지 말고 react-dom이 충분히 좋게 느껴지지 않을 때까지 기다려야 한다. 보통은 그렇다.

이 글은 welldone-software, Mordern Software Boutique에 의해 작성되었다. Angular, Node, React, .NET, Cordova, Mobile, Cloud의 전문가인 우리에게 연락 바란다.

원문보기

모듈형 리듀서와 셀렉터

최근에 Redux 상태 트리를 캡슐화 하는 방법을 이야기했다. 이전 포스트에서는 combineReducers를 사용할 때의 리듀서와 셀렉터의 비대칭에 대해 알아보았고 Rails-스타일의 프로젝트 조직화에서는 잘 동작하는 접근방법을 알아보았다. 하지만 다른 모듈형 구조(domain-스타일)을 사용하려고 할 때, 이슈가 나타났다.

모듈형 프로젝트 구조란 무엇인가?

이전 시간이 언급했던, Redux FAQ에서 얘기했던 프로젝트를 구조화하는 몇 가지 다른 방법들은 다음과 같다:

  • Rails-스타일: “actions”, “constants”, “reducers”, “containers”, “components”로 분리된 폴더를 두는 스타일
  • Domain-스타일: 피처나 도메인 별로 폴더를 두고 파일 타입에 따라 서브 폴더를 두는 스타일
  • Ducks: 도메인 스타일과 비슷하지만, 액션과 리듀서를 같은 파일에 명시적으로 함께 두는 스타일

이 포스트에서는 “Ducks”라는 스타일만 다룰 것이다. 왜냐하면 리듀서와 셀렉터에 관련된 차이가 없기 때문이다. 나는 이러한 스타일을 일반적으로 “모듈형”이라고 부른다. 애플리케이션을 별도의 모듈로 쪼개기 때문이다.

Redux FAQ에 많은 링크들이 있지만 개인적으로 Jask Hsu의 (Redux)애플리케이션 구조화 법칙을 가장 좋아한다.

이 글에서 Jack은 프로젝트 구조화에 대해 3가지 법칙을 정립했다:

  1. 피쳐에 따른 조직: 애플리케이션의 각 도메인이나 피처는 자신만의 “모듈” 혹은 폴더를 가지고 있어야 한다. 관련된 액션들, 리듀서들, 컴포넌트들, 셀렉터들은 모두 이 모듈에 위치한다.
  2. 엄격한 모듈 경계 생성: 각각의 모듈은 퍼블링 인터페이스를 명시적으로 정의한다. 탑 레벨의 index.js 파일만이 다른 모듈에 노출시켜야 하는 부분이다. 모듈은 절대로 다른 모듈로부터 “deep imports”(예: import Component from 'modules/foo/compoents/Compoent')를 하지 말아야 한다. 만약 import { Component } from 'modules/foo'가 불가능하다면 컴포넌트는 외부 모듈에서 사용할 수 있는 컴포넌트가 아닌 것이다.
  3. 순환 의존관계 피하기: 모듈 A가 모듈 B에 의존한다면(즉, B의 퍼블릭 인터페이스의 무언가를 임포트한다면), 모듈 B는 A의 어떤 것에도 의존하지 말아야 한다. 이것은 이전에 관련 포스트를 올린 적 있었던 엉클 밥 마틴의 Acyclic Dependencies Principle(ADP)를 말한다.

그래서 문제가 뭐야?

모듈형 프로젝트 구조에서는 리듀서 및 로컬라이즈된 셀렉터가 모듈 내에 있다(지난 포스트의 todos 예제처럼). 그 부분은 양호하다.

리듀서는 todos를 탑레벨 모듈(이 포스트에서는 앱이라고 부를 것이다)에서 임포트하고, 다른 모듈의 리듀서를 combineReducers를 사용해 조합한다. 지금까지는 꽤 괜찮다.

썽크 액션 생성자와 컨테이너 컴포넌트도 todos 모듈에 있다. 그러나 이들은 글로벌화된 셀렉터를 필요로 한다.

그래서 글로벌 셀렉터는 어디에 있을까? 글로벌 셀렉터는 두 가지가 필요하다.

-로컬 셀렉터 -로컬 셀렉터가 바라는 전체 상태 트리의 루트로부터 잘라내진(sliced) 상태 트리

로컬 셀렉터는 todos에 있다. path 정보는 메인 앱 리듀서와 함께 작성된 app 모듈에 있다.

app 모듈이 우리가 필요한 정보의 일부를 갖고 있고, 이미 todos의 리듀서에 의존하고 있기 때문에 글로벌 셀렉터가 app 모듈에 있어야 한다는 것은 꽤 논리적인 것 같다. 문제가 해결되었다. 맞나?

속단하지 말고, 썽크 액션 생성자와 컨테이너는 글로벌 셀렉터가 필요하며, todos 모듈에 있음을 기억하자.

글로벌 셀렉터가 app 모듈에 있다면 todos 모듈은 썽크 액션 생성자와 컨테이너가 사용할 수 있도록 임포트 해야할 필요가 있다. 하지만 app 모듈은 이미 todos에 의존하고 있기 때문에 종속성 싸이클이 생성된다.(app -> todos -> app). Jack의 세 번째 규칙인 Acyclic Dependencies Principle(ADP)를 따르고 있다면 이렇게 할 수 없다.

이제 뭘 해야 할까?

스포일러 경고: 아직 이 문제에 대한 실제 솔루션을 찾아내지 못했다.

내가 생각해내고 실제로 하고 있는 몇 가지 선택지에 대해 말할 수 있다.

ADP를 잊어라

ADP를 잊어버리고 종속성 싸이클을 허용할 수 있다. 문제는 한번 예외를 허용하면 다른 예외들도 쉽게 허용할 수 있다는 것이다. 이는 결국 유지보수가 불가능한 복잡한 스파게티 코드와 함께 끝나게 될 것이다. 모듈식 구조를 통해 우리가 처음부터 피하려고 했던 것이다.

더 실제적으로 보면 빌드/번들링 도구가 의존성 싸이클을 제대로 처리하지 못할 것이다. 나는 웹팩을 사용할 때, 그리고 React Native 패키저를 사용할 때 모두 의존성 싸이클을 만든 적이 있고, 두 경우 모두 정말 행복하지 못했다.

ADP를 잊는 것은 좋은 해답이 아닌 것처럼 보인다.

모듈형 구조를 잊어라

모듈형 구조를 포기하고 Rails 스타일로 돌아갈 수 있다. 어쩌면 모듈형 구조가 실제로 좋은 접근 방법이 아닐 수 있다는 표시일 수도 있다.

그러나 나는 모듈형 구조가 훨씬 행복한 구조라는 것을 알게되었다. 뭔가를 찾는 것이 더 쉽고, 애플리케이션의 동작에 대해 추론하는 것이 더 쉬울 뿐더러, 코드가 더 깨끗해진다. 실제로 뭔가가 어디에 속해야 하는지 생각하기가 더 쉽기 때문이다.

다시, 모듈형 구조의 요점은 큰 스파게티 코드를 피하는 것이다.

모듈형 구조를 잊는 것도 역시 좋은 해답이 아닌 것처럼 보인다.

싸이클을 깨라

“교과서(엉클 밥이 쓴 애자일 소프트웨어 개발: 원칙, 패턴, 관행)”적인 방법은 두 가지 메커니즘 중 하나를 사용하여 싸이클을 깨는 것이다.

  1. 의존성 반전 원리를 적용하라. todos 내에서 app과 todos 양쪽에서 쓰이는 부분을 추출하는 것이다. 다음 섹션에서 살펴볼 것이다.
  2. app과 todos가 모두 의존하는 새 모듈을 만든다.

이러한 접근법에 대해서 여러번 생각해보았으나 만족할만한 추출법을 발견하지는 못했다.

아이디어 하나는 상태트리 모양에 대한 설명을 빼내는 것이다. 애플리케이션의 메인 리듀서는 리듀서를 결합하는데 이 설명을 사용하고 todos는 이를 사용하여 셀렉터를 글로벌화한다.

그러나 이것은 지나치게 복잡해 보인다. 그래서 아직 이쪽 방향으로 나아가진 않았다. 그러나 의존성 싸이클을 깨는 다른 방법에 대해서는 열려있다.

글로벌 셀렉터를 모듈로 이동하라

내가 고려해본 최종 해결책은 글로벌 셀렉터를 todos 모듈로 옮기는 것이다. 이렇게 하려면 todos 모듈은 상태 트리의 루트에서 로컬 서브-섹션을 알아내야 한다.

앱 리듀서가 이 경로를 생성하기 때문에 서브섹션을 알아내는 방법을 실제로는 가질수 없고, 복제된 형태로만 가질 수 있다.

이를 수행하는 몇 가지 방법이 있다.

모듈이 상태 설치 위치를 제어하게 하라

Jack의 글에서 말하기를:

todos 모듈에게 자신만의 state에 대한 설치 위치를 제어할 수 있도록 하여 이 문제를 해결할 수 있다

이 방법에서 각 모듈은 reducerKey 혹은 moduleName 혹은 이런 비슷한 상수를 정의한다.

상수는 글로벌 셀렉터를 생성하는 데 사용된다.

Globalizing a Selector

import { moduleName } from './constants'
import * as fromTodos from './localSelectors'

export const allTodos = state => fromTodos.allTodos(state[moduleName])

또한 메인 리듀서가 리듀서들을 조합할 때도 사용된다:

Combining Reducers

import { combineReducers } from 'redux'
import { reducer as todosReducer, moduleName } from './todos'

export default combineReducers({
  [moduleName]: todosReducer
})

복제와 함께 살아가기

또다른 선택지는 셀렉터를 조금 복제하는 방법이다. 나는 현재 이 접근법을 사용하고 있지만, Jack의 방법으로 스위칭할 것을 고려중이다.

셀렉터를 리듀서로부터 분리된 파일에 유지하고, 셀렉터 파일에서는 로컬 셀렉터를 이름 지어 익스포트하고, 글로벌 셀렉터를 포함하는 default로 익스포트한다.

다음과 같이 보일 것이다:

Living With Duplication

const localState = state => state.todos // the duplication!

export const allTodos = state => {
  // extract all todos from the local state
}

export default {
  allTodos: state => allTodos(localState(state))
}

실제 세계에서는 이전에 언급했던 Ramda를 이용한다. 조금 더 깨끗해 보일 것이다:

Using ramda

import { compose, prop } from 'ramda'

const localState = prop('todos')

export const allTodos = state => {
  // extract all todos from the local state
}

export default {
  allTodos: compose(allTodos, localState)
}

내 리듀서와 그 테스트에서는 개별 셀렉터를 임포트하고 다른 곳에서는 디폴트를 임포트해서 글로벌 셀렉터를 임포트한다. 지금은 이게 혼동되는지 잘 모르겠지만, 시간이 지난 후에는 따라잡기 어려울 수 있다.

결론

말했다시피 아직 완전히 만족스러운 해결책은 찾지 못했다. 나는 현재 복제를 이용하여 살아가고 있다.

그리고 모듈이 상태 트리에 설치되는 곳을 제어하는 Jack의 접근법으로 전환을 진지하게 고려중이다.

혹시 당신도 이런 문제가 발생했나? 그렇다면 어떻게 해결했는지? 내 방법보다 나은 방법이 있는가?

원문보기

Redux 리듀서와 셀렉터간의 비대칭성

이전 포스트에서 Redux 상태 트리를 캡슐화하는 액션과 리듀서와 셀렉터의 사용에 대해서 이야기 했다. 그 포스트에서는 하나의 탑레벨 리듀서에 대해서 잘 동작한다는 것을 보였으나, 분해된 리듀서를 어떻게 다루어야 하는지에 대해서는 보여주지 못했다. 이제 그에 대해서 이야기하자.

무엇이 문제인가?

리듀서를 작은 조각으로 나누는 몇 가지 방법들이 있다. Mark Erikson은 Redux 문서의 pull request에서 이 선택사항에 대해서 잘 설명했다.

가장 일반적인 접근방법은 Redux의 combineReducers 함수를 이용하여 state를 분리 독립적인 “슬라이스”로 나누는 것이다. 각 슬라이스는 서브-리듀서에 의해 처리된다. 예를 들면:

combineReducers

import calendarReducer from './calendarReducer'
import todosReducer from './todosReducer'
import usersReducer from './usersReducer'

export default combineReducers({
  calendar: calendarReducer,
  todos: todosReducer,
  users: usersReducer
})

이 예제에서는 상태트리 안의 각각의 섹션을 처리하는 리듀서들로 조합된 싱글 탑레벨 리듀서를 만들었다. calendarReducer, todosReducer, usersReducer는 그들만의 권리를 갖고 있다.

우리는 상태 트리의 각 섹션들이 애플리케이션의 나머지 부분으로부터 그 모양을 숨긴 채로 유지하기를 원한다. 어떻게 그렇게 할 수 있을까?

Redux 문서의 FAQ는 이렇게 말하고 있다:

일반적으로 리듀서와 셀렉터를 함께 정의하고 익스포트하기를 제안한다. 리듀서 파일 안애 상태 트리의 실제 모양에 대해서 알아야 하는 코드를 모두 함께 두어서 이를 다른 곳(mapStateToProps 함수들, 비동기 액션 생성자, sagas)에서 재사용하도록 하는 것이다.

셀렉터와 리듀서를 함께 두고 정의한다면 리듀서가 상태 트리의 서브셋에서 동작하는 경우 셀렉터는 뭘 해야 할까?

어떤 선택지가 있는가?

실제로 단 2개의 셀렉터를 위한 선택지가 있다:

  1. 리듀서와 평행적으로 셀렉터를 만들어야 한다. 리듀서들과 마찬가지로 제한적인 서브셋에 대해 동작해야만 한다.

  2. “글로벌” 셀렉터를 만들어야 한다. 즉, 셀렉터는 전체 상태 트리의 루트를 가지고 동작하기를 기대하는 셀렉터를 말한다.

어떤 선택지가 가장 좋은가?

셀렉터를 사용하는 곳을 살펴보자. 지난번에 몇몇 장소를 식별했었다:

  • 컨테이너 컴포넌트의 mapStateToProps 함수들
  • 썽크 액션 생성자들
  • 리듀서 테스트들

추가적으로 셀렉터를 사용할 수 있는 다른 곳들을 생각해봤다:

  • 리듀서 자체. 가끔 리듀서는 상태 (서브)트리의 또다른 부분을 참조할 필요가 있다. 비록 리듀서가 이미 상태 트리의 모양에 결합되어 있다고는 하지만, 이미 정의된 셀렉터를 이용하는 것이 종종 편리할 때가 있다.

셀렉터의 이러한 용도를 살펴본 결과, 글로벌 상태 트리 혹은 로컬의 제한된 상태 트리를 사용하는 것 중 어떤 것이 좋을까?

mapStateToProps는 항상 글로벌 상태 트리를 호출한다. 썽크 액션 생성자의 getState 함수는 글로벌 상태트리를 리턴하는 것 같다. 그러나 리듀서와 리듀서 테스트는 로컬 상태 트리와 함께 작동한다.

50대 50이다. 별로 도움이 되지는 않는다.

둘 다 할 수는 없나?

글로벌 상태와 로컬 상태 양 쪽 모두에 대해 작동하는 셀렉터를 가질 수 있는 방법이 있을까? 몇 가지 방법이 있다.

하이브리드

우선 “하이브리드”라고 부르는 접근 방법이다.

이 접근법에서는 모든 셀렉터는 로컬 섹션과 함께 동작하도록 정의한다.

상위레벨(즉, 메인 리듀서)에서의 셀렉터는 로컬 상태 트리를 갖고 온다. 글로벌 상태가 필요한 셀렉터를 호출하는 경우에는 먼저 탑레벨 셀렉터를 적용하고난 다음 로컬 셀렉터를 적용한다. 결곡 다음과 같이 보일 것이다:

Hybrid selectors

// In todosReducer.js:
const allTodos = state => {
 // get todos from local state
}

// In appReducer.js:
const todosState = state => state.todos

// In a container somewhere:
import { allTodos } from './todosReducer'
import { todosState } from './appReducer'

const mapStateToProps = state => ({
  todos: allTodos(todosState(state))
})

동작은 하겠지만, 끔찍하게 많은 반복적인 코드가 모든 곳에 위치하게 된다. 더 잘 할 수 있지 않을까?

위임

Dan Abramov의 비디오, Redux: Colocating Selectors with Reducers에서는 위임하는 방법을 사용한다.

이 접근법은 하이브리드 방법과 비슷하지만 로컬 셀렉터와 상태를 쪼개는 셀렉터를 조합하여 appReducer 파일에 둔다.

위와 같이 todosReducer.js는 로컬(todos-only) 상태에 대해서 동작하는 allTodos 셀렉터를 익스포트한다. 그 다음 메인 리듀서 파일에서 다음과 같이 한다:

Making a Global Selector

import * as fromTodos from './todosReducer'

export const allTodos = state => fromTodos.allTodos(state.todos)

todos 서브 섹션의 상태 트리를 추출한 다음 로컬-상태버전의 allTodos를 호출하는 글로벌-상태 버전의 allTodos를 정의한다.

리듀서와 리듀서 스펙에서는 todosReducer가 익스포트한 로컬 버전의 allTodos를 임포트한다. 컨테이너와 액션 생성자에서는 메인 리듀서가 익스포트하는 글로벌 버전의 allTodos를 임포트한다.

추가 함수를 정의하는 비용으로, 셀렉터의 두 가지 버전을 갖게된다. 로컬 상태와 동작하는 첫 번째, 그리고 첫 번째 버전을 이용하여 글로벌 상태와 동작하도록 만든 두 번째 버전이다.

이 접근 방식의 장범은 상태 트리의 모양에 대한 모든 지식이 적절한 위치에 캡슐화되어 있다는 것이다.

로컬 버전의 셀렉터는 상태 트리의 일부분을 알고 있고 적절히 결합된다. 그러나 글로벌 트리에 대해서는 모른다.

글로벌 버전의 셀렉터는 지역 상태가 어디에 있는지 알고 있다. 그러나 해당 섹션의 구조나 모양에 대해서는 알지 못한다. 그에 대해서는 로컬 버전의 셀렉터에게 위임한다.

이 캡슐화는 하이브리드 및 위임 접근법 모두에 존재한다. 위임 접근의 장점은 로컬 셀렉터와 상태 자르기용 셀렉터를 반복하지 않아도 된다는 것이다. 사실, 클라이언트 코드는 이러한 구성이 일어난다는 사실조차 알 필요가 없다.

문재 해결. 맞지?

나는 위임 접근법이 좋은 해결책이라고 생각한다. 각 셀렉터의 추가 버전을 작성하는 비용이 들지만 캡슐화와 유연성은 가치가 있다.

상태 트리를 여러번 중첩된 레벨로 분리하면, 그 아래의 모든 셀렉터를 재정의해야하므로 끔찍한 작업이 된다. 어느 시점에서 이건 관리할 수 없게 된다.

추가적으로, Redux FAQ에서는 프로젝트 구조화에 대한 다른 방법들을 이야기하고 있다:

  • Rails-style: “actions”, “constants”, “reducers”, “containers”, “components”로 분리된 폴더를 두는 스타일
  • Domain-style: 피처나 도메인 별로 폴더를 두고 파일 타입에 따라 서브 폴더를 두는 스타일
  • Ducks: 도메인 스타일과 비슷하지만, 액션과 리듀서를 같은 파일에 명시적으로 함께 두는 스타일

Rails 스타일의 접근법을 사용하면 문제가 해결된다고 생각한다. Dan Abramov의 위임 접근법은 위에서 언급했듯이 좋은 방법이다.셀렉터를 리듀서 파일과 별개로 두는 방법을 선택하더라도 이 테크닉을 사용할 수 있다.

그러나 Domain이나 Ducks 접근법을 사용하기를 원한다면 이 테크닉은 순환 의존이라는 이슈를 발생시킨다.

다음 포스트에서 이 구조화에 대해서 더 많은 시간을 써 볼 것이다.

원문보기

Redux 상태 트리 캡슐화하기

Redux 애플리케이션에서, 대량의 애플리케이션의 데이터는 중앙에 위치하는 “상태 트리”라는 스토어에 저장된다. 상태 트리의 모양과 구조는 애플리케이션의 쉬운 개발과 성능 향상에 큰 영향을 미친다. 시간이 지남에 따라 문제를 해결하기 위해 상태 트리를 리팩토링하는 것은 종종 중요해진다. 어떻게 리팩토링을 안전하게 할 수 있을까?

이전에도 Redux에 대해 몇 번 쓴적이 있다. 입문 혹은 주의환기를 위해서 그 포스트들을 가볍게 복기해보라.

데이터 구조 변경을 안전하게 만들기 위해서는 캡슐화가 가장 일반적으로 쓰이는 도구다. 캡슐화는 data-hiding 혹은 information-hiding이라고도 알려졌다. 캡슐화의 기본 개념은, 데이터 구조에 직접적으로 접근하기보다는 데이터 대신 제공하는 인터페이스에 접근하는 것이다.

이게 바로 우리가 이 포스트에서 알아볼 내용이다.

상태 트리는 어떻게 사용되는가?

상태 트리를 캡슐화하려면, 애플리케이션에서 상태 트리가 어떻게 쓰이고 있는지를 알아야 한다. 대부분의 데이터 구조에는 읽기와 쓰기라는 두 종류의 쓰임이 있다.

Redux 애플리케이션에서도 마찬가지다. Redux는 Flux 아키텍쳐의 구현이기 때문에 단방향 데이터 플로우라는 원칙을 따르기 때문이다. 그래서 상황을 더 쉽게 파악할 수 있다.

쓰기

우선 방정식의 쓰기 부분부터 시작해볼 것이다. Redux에서 가장 명확한 부분이기 때문이다.

상태 트리의 변경은 다양한 액션에 대한 리듀서의 응답에 의해 만들어진다. 변경은 직접적으로 만들어지지 않는다. 리듀서는 원하는 변화를 포함한 새로운 상태 트리를 리턴하고, Redux 스토어는 이 새로운 상테트리를 기억한다.

읽기

상태 트리 읽기는 대부분의 Redux 애플리케이션에서 분산되는 경향이 있다. 각각의 컨테이너 컴포넌트는 상태 트리의 일부분을 읽어서 컴포넌트의 props로 주입하는 기능을 하는 mapStateToProps 함수를 구현한다.

상태 트리의 불명확한 쓰임은 썽크 미들웨어로 동작하는 액션 생성자에서다. 썽크 액션들은 스토어의 getState 함수를 전달하며 액션을 생성할 때 이를 이용해 state에 접근할 수 있다.

상태 트리를 읽을지도 모르는 3번째 장소는 바로 리듀서 테스트들이다. 리듀서에 대한 테스트는 종종 리듀서가 액션을 올바르게 처리했는지를 알기 위해 상태 트리를 읽어야 할 필요가 있다.

그럼 이제부터 뭘 해야하는가?

이제 상태 트리가 어떻게 사용되는 지 알았으니, 뭘 하면 될까?

우선, 쓰기 방면에서는 이미 리듀서와 액션 생성자를 통해서 캡슐화 계층을 생성한다는 것을 알 수 있다. 우리의 애플리케이션은 상태 트리를 직접 다루지 않는다. 그보다는, 리듀서에 의해 처리되는 액션을 디스패치 하는 방법으로 상태 트리를 조작하게 한다. 더이상 캡슐화 할 내용이 없다.

읽기 방면에서는 상대적으로 쓰기 쪽 보다 덜 정의되어 있다. 읽기는 다양한 컨테이너 컴포넌트, 썽크 액션, 테스트 등지에 흩어져있기 때문에 상태 모양을 변경한 뒤 컴포넌트, 테스트, 썽크 액션 등에서 제대로 읽을 수 있도록 업데이트하기 힘들다.

드러난 모범 사례는 셀렉터라고 부르는 추상화 계층을 도입하는 것이다.

셀렉터

셀렉터는 상태(state) 및 추가적인 파라미터를 취하고 어떤 데이터를 반환하는 함수다.

모든 곳에서 state.todos를 사용하는 대신, 모든 todo를 리턴하는 allTodos(state)와 같은 셀렉터를 사용한다. 무의미한 것처럼 보일 수 있지만 상태 트리를 캡슐화하는 것은 중요하다.

일단 셀렉터를 사용하게 되면 나머지 애플리케이션 부분에서 todos가 상태 트리에 어떻게 저장되어 있는지 신경쓰지 않아도 되기 때문에, 필요할 때 훨씬 쉽게 리팩토링할 수 있게 된다.

셀렉터는 상태 트리에 있는 그대로 데이터를 리턴하거나, 혹은 어떤 연산을 수행한 다음 리턴할 수 있다.

Redux 문서에서는 셀렉터를 핵심 개념으로 다루지는 않치만 훌륭한 Reselect라는 라이브러리와 함께 파생 데이터를 계산하는 색션에서 이를 언급하고 있다.

그리고 Dan Abramov의 두 번째 Redux 비디오 시리즈에서, 특히 Redux:Colocating Selectors with Reducers 에서 철저히 다루어진다. 아직 비디오를 시청하지 않았다면, 강력 추천한다. Dan의 첫 번째 비디오 시리즈와 마찬가지로 매우 훌륭하다.

아직 더 남았나?

쓰기 부분에서 리듀서와 액션을 통한 캡슐화, 읽기 부분에서 셀렉터 처리를 통한 캡슐화, 이제 끝인가? 상태 트리를 안전하게 리팩토링할 수 있을까?

대부분의 파트에서는 그렇다, 끝난 것이다. 모든 코드가 액션과 셀렉터를 사용하고 결코 상태 트리에 직접 접근하지 않으면 우리는 상태 트리를 리팩토링해야할 때 무엇을 변경해야하는지 정확하게 알고 있다.

Dan Abramov는 심지어 캡슐화를 명확히 하기 위해서 셀렉터가 리듀서와 동일한 파일에 있어야 한다고 제안하고 있다. 나는 그렇게 하지는 않았지만, 어떤 이유인지는 확실히 이해한다.

하지만 테스트는? 테스트에서도 셀렉터를 사용하나?

테스팅

이전에 Redux 애플리케이션 테스트하기라는 글을 작성했다. 리듀서를 테스팅하는 엑션에서 Redux 문서에서 가져온 다음 테스트를 보았다.

Reducer Tests

import { expect } from 'chai'
import reducer from 'reducers/todos'
import ActionTypes from 'constants/ActionTypes'

describe('todos reducer', () => {
  it('initializes the state', () => {
    expect(reducer(undefined, {})).toEqual([])
  })

  it('adds a todo', () => {
    expect(reducer([], addTodo('Run the tests'))).toEqual([
      {
        text: 'Run the tests',
        completed: false,
        id: 0
      }
    ])
  })
})

이 테스트에는 상태 트리의 모양을 알고 있는 부분이 몇 군데 있다. 상태 트리 모양과 결합된 모든 부분 중에서 가장 방어하기 쉬운 부분이다. 하지만 더 잘 할 수 있을까?

나는 리듀서 테스트 전략을 조금 발전시켜서, 현재 프로젝트에서는 완전히 상태 트리 모양과 테스트를 완전히 분리하는 실험을 하고 있다.

리듀서에 의해 생성된 초기 상태에 대한 테스트는 const를 정의하는 것에 의해 시작된다.

Initial State

describe('todos reducer', () => {
  const initialState = reducer(undefined, {})

  # ...
})

만약 초기 상태에 대해 중요한 것이 있으면, 셀렉터를 이용해서 몇 가지 테스트를 작성할 것이다. 나는 상태 트리에 대한 테스트는 리듀서 구현 관점이 아니라, 상태 트리에 대한 클라이언트 관점에서 작성해야한다고 확신한다.

Testing the Initial State

# ...

  describe('initial state', () => {
    it('starts with no todos', () => {
      expect(allTodos(initialState)).to.be.empty  
    })
  })

# ...

이 테스트는 todos가 저장되는 방법이나 초기 상태의 모양이 실제로 무엇인지에 대해서 전혀 언급하지 않는다. 단지 상태의 중요한 관측가능한 속성들을 테스트한다.

다양한 액션들을 핸들링하는 테스트를 작성하려면, 다음과 같이 작성할 것이다.

Testing an Action

# ...

  describe('adding a todo', () => {
    const state = initialState
    const action = addTodo('TODO')
    const newState = reducer(state, action)
    const addedTodo = allTodos(newState)[0]

    it('includes the todo in the list', () => {
      expect(addedTodo).to.exist
    })

    it('remembers the name', () => {
      expect(addedTodo.name).to.eq('TODO')
    })

    it('assigns the next available id', () => {
      expect(addedTodo.id).to.eq(0)
    })

    it('marks the todo as initially incomplete', () => {
      expect(addedTodo.complete).to.be.false
    })
  })

# ...

내가 작성한 방식은 다소 장황하다; 하나의 테스트에서 추가된 todo의 모든 속성을 확인하는 것이 좋다. 나는 리듀서가 어떤 책임을 가지고 있는지 명확하게 하기 위한 의도를 전달하려는 목적으로 각 속성 테스트를 it 블럭으로 분리하는 경향이 있다.

나는 const state = …; const action = …; const newState = …; 과 같은 패턴을 유지하는 경향도 있다. 테스트의 규칙적인 구조는 테스트를 읽기 쉽게 만든다.

만약 초기 상태가 아닌 다른 것에 대한 테스트를 작성한다면, 내가 필요한 상태를 얻기 위해 액션을 사용하는 실험을 하고 있다.

예를 들어, 이미 todo가 존재하는 todo list를 테스트하고 싶다면 다음과 같은 방법으로 시작할 것이다:

Starting With Other State

# ...

  const state = reducer(initialState, addTodo('Pre-existing'))

# ...

시작 상태를 올바르게 하기 위해서 하나 이상의 액션이 필요하다면, 액션의 배열과 함께 reduce를 이용한다. 나는 Ramda를 이용한 버전의 reduce를 쓰고 있지만, 다른 선택지들도 있다.

Multiple Actions

# ...
  const state = reduce(reducer, initialState, [
    addTodo('Already complete'),
    completeTodo(0),
    addTodo('Still outstanding')  
  ])
# ...

나는 지금까지 이 분리 개념이 정말 좋았지만, 이 상태 설정 스타일이 미래의 코드 독자들에게도 충분히 이해할만한 것인지는 배심원들에게 달려있다.

중첩된 리듀서는 어떤가?

지금까지 얘기한 모든 것들은 탑레벨 리듀서 하나만 가지고 있을 때만 효과적이다. 셀렉터와 리듀서는 잘 동작하고 테스팅에 대한 접근 방법도 매력적이다.

하지만 대부분의 Redux 애플리케이션에서는 combineReducers를 사용해서 탑레벨 리듀서를 서브 리듀서들로 나누고 있다. 각 서브 리듀서들은 상태 트리에서 잘라낸 작은 부분에 대해서 동작한다. 그러나 셀렉터들은 전체 상태 트리와 함께 작업해야 한다.

이런 리듀서와 셀렉터 사이의 비대칭성을 어떻게 처리해야하는가?

다음 주 게시물을 위해서 대답을 아껴두겠다.

결론

이미 우리가 갖고 이쓴 액션과 리듀서에다가 셀렉터를 추가하여 Redux 상태를 캡슐화하는 것을 강력 추천한다.

한단계 더 나아가서 액션과 셀렉터만 사용해서 리듀서 테스트를 작성하는 것은 아직까지는 괜찮은 접근방법으로 보인다. 리듀서와 셀렉터만 변경하여 상태 트리를 리팩토링할 수 있었기 때문이다. 특히 리듀서 테스트는 아무 것도 변경할 필요가 없었다는 것을 참고하라.

원문보기

Reselect로 React와 Redux 성능 향상시키기

React와 Redux를 함께 사용하면 애플리케이션을 관심사가 분리되도로 구조화하는 최고의 기술 조합이 된다. 그러나 React가 아무리 기존에 비해 엄청난 성능을 보여주더라도 시간이 지나면 더욱 높은 성능이 요구되기 마련이다.

React가 실행하는 가장 값비싼 오퍼레이션 중 하나는 바로 렌더링 싸이클이다. 컴포넌트가 인풋 컨트롤의 변경을 탐지했을때, 렌더 사이클은 시작된다.

React를 처음 접했을때, 우리는 렌더 사이클에 대해서 아무런 걱정도 하지 않았다. 그러나 UI가 커지고 복잡해짐면서 그에대한 걱정을 시작할 필요가 있었다. React는 불필요한 렌더링을 예방할 수 있도록 렌더 싸이클을 중간에 가로챌 수 있는 툴을 제안한다. shouldComponentUpdate 라이프사이클 이벤트를 이용하여 true false를 반환하면 된다. 이것은 이전 props와 새 props의 동등성을 비교하여 동등할때 false를 리턴하는 PureRenderMixin의 기반이 된다.

이것은 Immutable 데이터셋과 결합하여 상당한 성능 향상을 제공한다. 유감스럽게도 지금은 여기까지다.

다음 문제를 고려해보자: 3가지 타입의 입력을 받는 쇼핑 카트를 만들고 있다.

  • 카트 안의 아이템들
  • 각 아이템들의 양
  • 적용된 세금(시/도를 기준으로 함)

문제는 어떤 입력이 수정되더라도(새 아이템 추가, 수량 변경 혹은 현재 선택된 아이템), 모든 것이 재계산되고 재 렌더링 되야 할 필요가 있다. 아마 당신들도 카트에 수백개의 아이템이 있을 경우에 이것이 문제가 될 수 있다는 것을 알 것이다. 세금의 비율을 변경하는 것도 역시 재계산을 시작하게 한다. 하지만 정상적이라면 그렇지 않아야 한다. 물건값 합계와 세금 합계는 서로 독립적으로 업데이트되야 한다. 이 문제를 어떻게 해결할 수 있는지 알아보자.

Reselect 구조대

Reselect는 memoized selector를 만들기 위한 라이브러리다. 셀렉터란 Redux state를 잘라내어 React 컴포넌트에게 전달하는 기능을 하는 함수다. 메모이제이션을 사용하면, 파생 데이터 때문에 발생하는 불필요한 재렌더링과 재계산을 예방할 수 있어 애플리케이션의 속도를 올려준다.

다음 예제를 살펴보자:

만약 수백 수천개의 아이템이 있을 경우, 세율 변경만으로도 카트안의 모든 아이템을 재렌더링할 것이다. 검색을 구현한다면? 사용자가 카트를 검색할 때마다 세금을 계속 재계산해야만 할까? 이런 비용소모적인 오퍼레이션은 메모이즈드 셀렉터를 이용함으로써 방지할 수 있다. 메모이즈드 셀렉터를 쓰면, 상태 트리가 큰 경우에도 스테이트가 변경될 때마다 값비싼 계산을 해야한다는 걱정을 할 필요가 없게된다. 또한 개별 컴포넌트로 쪼갤 수 있는 유연함도 얻게된다.

Reselect를 사용한 간단한 셀렉터를 보자:

위의 예제에서, 카트 아이템을 획득하는 함수를 2개로 쪼갰다. 첫 번째 함수(3번째 줄)는 카트의 모든 아이템을 얻어오고, 두 번째 함수는 메모이즈드 셀렉터를 나타낸다. Reselect는 메모이즈드 셀렉터를 만들어주는 createSelector API를 노출하고 있다. 이것이 의미하는 바는 getItemsWithTotals가 처음 실행될 때는 계산된다는 것이다. 만약 다시 한번 같은 함수가 불렸는데 입력(getItems의 결과)이 변경되지 않았다면, items와 합계에 대해 캐시된 계산 결과를 리턴할 것이다. 만약 아이템이 수정되었다면( 아이템이 추가되거나, 수량이 변경되거나, getItems의 결과에 영향을 주는 어떤 일이든), 함수는 다시 실행될 것이다.

이것은 재렌더링되야만 하는, 상태에 의해 재계산되야만 하는 컴포넌트에 대해 완전한 최적화를 허용하는 강력한 개념이다. getItems에 대해 더 이상 고민하지 않아도 된다는 뜻이다 - 그리고 getItems 때문에 각 아이템을 다시 계산하는 전체 비용도 - 상태를 실제로 변경하지 않는 작업이 수행될 때에는.

모든 파생 데이터에 대해서 이러한 셀렉터를 생성해도 된다. 파생 데이터에는 소계, 세금 합계, 최종 합계 등이 포함된다.

셀렉터 사용하기

컴포넌트 중 하나에서 셀렉터를 적절히 사용하여 어떻게 getItemsWithTotals 셀렉터의 이점을 취할 수 있는지 알아보자:

오직 카트안의 아이템만 이해하는 어떠한 컴포넌트가 있다. 이것은 합계나 소계 등을 구분할 필요가 없기 때문에 괜찮은 접근 방법이다. 재사용에 가장 유용한 컴포넌트는 아니지만 매우 성능 좋은 컴포넌트다. 자체적으로 관련이 없는 변경사항(예. 세금 계산법 변경)으로 인한 추가적인 재렌더링이 일어나지 않는다.

이 방법을 카트의 나머지 부분에 적용하면 소계, 합계 및 세금 계산에 대대 각각 책임을 갖는 컴포넌트를 만들게 된다.

어플리케이션 초기에 이런 최적화를 수행하면 성능 문제를 해결해야할만한 작업이 줄어들게 된다. 가능한 빨리 reselect를 사용하는 것을 추천한다. 셀렉터를 컴포넌트 밖으로 옮기는 주요 이점 중 하나는 다른 자바스크립트 합수들 처럼 파생된 데이터를 계산하는 함수를 쉽게 테스트할 수 있다는 것을 의미한다. Redux의 상태를 모킹한 다음 제공된 상태를 기반으로 하는 기대값을 확인하면 된다.

이 개념을 더 많이 구현한 데모는 다음을 참고하라: https://github.com/neilff/react-redux-performance

React & Redux Resources

React에 대한 더 자세한 내용은 비디오나 webinar를 이용하라. 모범 사례에 대한 맞춤 교육을 문의하면 좋을 것이다. 우리 사이트에도 redux에 대해서 더 설명하고 있다.