Redux 성능 향상을 위한 컴포넌트 리팩토링

By Shahzad Aziz

프론트엔드 웹 개발은 빠르게 진화하고 있다. 매일 새로운 모범 사례에 도전하는 새로운 툴과 라이브러리가 발표된다. 신이 나기도 하지만, 역시 따라가기 벅차다. 개발자의 삶을 편안하게 해주는 새로운 도구 중 하나는 인기 오픈소스 상태 컨테이너인 Redux다. 지난 해 Yahoo! Search 팀은 Redux를 사용해서 데이터 분석에 사용되는 레거시 도구를 새로 고쳤다. Redux와 함께 사용하는 역시 매우 인기있는 프론트엔드 컴포넌트 라이브러리인 React를 사용하여 결합했다. Yahoo Search의 규모로 인해 매초마다 수천개의 측정 항목을 데이터 그리드에 저장한다. 우리의 데이터 분석 툴은 우리 Search 팀의 내부 유저가 저장된 데이터에 쿼리를 날려 트래픽과 A/B를 비교하는 테스트를 할 수 있도록 한다. 새로운 도구를 만드는 가장 큰 목적은 속도와 편의성이였다. 처음 시작했을 때부터 우리는 애플리케이션이 복잡해질 것이며 많은 상태를 보유하게 될 것이라는 걸 알았다. 개발과정에서 예상치 못한 성능 병목현상이 발생했다. 우리는 기술 스택에서 기대했던 성능을 달성하기 위해 깊이 파고들었고 리팩토링을 했다. 이 경험을 공유하고 Redux를 사용하는 개발자에게 React 컴포넌트와 상태 구조에 대한 추론을 통해 애플리케이션 성능을 높이고 쉽게 확장할 수 있도록 하려 한다.

Redux는 당신이 애플리케이션의 상태 변경을 예측 가능하게 하고 쉽게 디버그할 수 있기를 바란다. 이를 Flux 패턴의 아이디어를 애플리케이션의 상태를 단일 저장소에 저장하게 확장함으로써 달성한다. 리듀서를 사용하여 논리적으로 상태를 분할하고 책임을 괌리할 수 있게 한다. React 컴포넌트는 Redux 저장소의 변경 사항을 구독한다. 프론트엔드 개발자에게는 매우 매력적인 멋진 디버깅 도구를 상상할 수 있게 한다.

React를 사용하면 컨테이너와 프리젠테이셔널의 두 가지 카테고리로 컴포넌트를 추론하고 생성하는 것이 쉬워진다. 여기서 더 읽어볼 수 있다. 요점은 컨테이너 컴포넌트가 상태에 대해 구독하고 프리젠테이셔널이 전달된 프로퍼티를 이용해 마크업 렌더링을 한다는 것이다. Redux 문서 초반부에서 우리는 컴포넌트 트리 최상위에 컨테이너 컴포넌트를 추가해야 한다는 것을 알 수 있다. 우리 애플리케이션에서 가장 중요한 부분은 대화형 ResultsTable 컴포넌트다. 간결하게 하기 위해서 게시물의 나머지 부분에서는 이 컴포넌트에 집중할 것이다.

React Component Tree

API에서 최적의 성능을 얻기 위해서 백엔드에 많은 수의 간단한 호출을 수행하고 리듀서에서 이 데이터를 결합하고 필터링한다. 이것은 많은 비동기 액션을 통해 데이터 조각을 가져오고 redux-thunk를 사용하여 제어 흐름을 관리한다는 것을 의미한다. 그러나 사용자의 선택이 변경되면 상태에서 가져온 대부분의 항목이 무효화되므로 다시 가져와야 한다. 대체적으로 꽤 잘 동작하지만, 응답이 돌아옴에 따라 상태도 여러번 변경된다는 의미다.

문제점

API에서 큰 성능 향상을 거두는 동안 브라우저의 프레임 그래프는 클라이언트의 성능 병목 현상을 나타내기 시작했다. 컴포넌트 트리의 상위에 있는 MainPage 컴포넌트는 모든 디스패치에 대해 다시 렌더링되었다. 컴포넌트는 렌더링에 비용 많이 드는 작업이 아니여야만 했지만 엄청난 양의 데이터 행이 있었다. 재 렌더링을 위한 델타 시간은 수초 이상 걸렸다.

어떻게 렌더링 성능을 향상시킬 수 있을까? 잘 알려진 메서드는 shouldComponentUpdate 메서드고, 여기서 시작해보았다. 이 방법은 컴포넌트 트리 전체에서 신중하게 구현해야 하는 일반적인 성능 향상 방법이다. 컴포넌트가 원하는 props가 변경되지 않은 경우에는 재 렌더링을 피할수 있도록 필터링할 수 있었다.

shouldComponentUpdate 개선에도 여전히 전체 UI가 갑갑했다. 사용자의 액션에 대한 응답은 지연되었고, 로딩 인디케이터는 한참 후에 나타났고, 목록 자동완성은 엄청 시간이 걸렸고, 아주 작은 사용자 상호작용조차 느리고 무거왔다. 요점은 React 렌더링 성능이 아니었다.

병목현상을 확인하기 위해 React Performance ToolsReact Render Visualizer라는 두 가지 도구를 사용했다. 실험은 여러가지 다른 액션들을 수행한 뒤에 주요 컴포넌트에 대해 렌더 횟수 및 인스턴스 수에 대한 테이블을 만드는 것이었다.

아래는 우리가 만든 테이블 중 하나다. 빈번하게 사용되는 두 가지 액션을 분석했다. 앞서 말한 우리의 메인 테이블 컴포넌트에 대해 얼마나 많은 렌더가 트리거되는지 살펴보았다.

날짜 변경: 사용자는 기록된 데이터를 가져오기 위해 날짜를 변경할 수 있다. 데이터를 가져오려면 모든 메트릭에 대해 병렬적으로 API를 호출하고 Reducer에서 이를 병합했다.

차트 열기: 사용자는 측정 항목을 선택하여 일별로 꺾은선 차트에 표시할 수 있다. 이것은 모달 다이얼로그를 연다.

실험을 통해 상태가 트리를 통과하면서 관련 컴포넌트에 영향을 미치기 전에 반복적으로 많은 것들을 다시 계산해야 한다는 사실이 밝혀졌다. 이것은 비용이 많이 들고 CPU를 많이 사용했다. UI로 제품을 변경하면 React는 1080ms나 되는 시간을 테이블 및 행 컴포넌트에 사용했다. 우리 쓰레드를 바쁘게 만든 것이 공유된 상태의 문제라는 것을 깨닫는 것이 중요했다. React의 가상 DOM의 성능은 좋지만 가상 DOM을 생성하는 것을 최소화하기 위해 노력해야 한다. 이것은 렌더링을 최소화한다는 것을 의미한다.

성능 리팩토링

주된 생각은 컨테이너 컴포넌트를 살펴보고 상태를 보다 균등하게 컴포넌트 트리에 분산시키려고 시도하는 것이었다. 상태에 대해 더 생각해보고 덜 공유하고 덜 파생하게 만들고 싶었다. 가장 필수적인 아이템을 상태에 저장하고 컴포넌트에서 필요한 상태를 계산하고 싶었다.

리팩토링은 두 단계로 나누어 실행했다:

1단계. 컴포넌트 전반에 걸쳐 상태를 구독하는 컨테이너 컴포넌트를 더 추가했다. 배타적인 상태를 소비하는 컴포넌트는 상위에 컨테이너 컴포넌트가 필요하지 않다. 그 컴포넌트들은 배타적인 상태를 구독하는 컨테이너 컴포넌트가 될 수 있었다. 이것은 React가 Redux 액션에 대한 작업을 크게 줄여준다.

잠재적인 컨테이너 컴포넌트들이 있는 React 컴포넌트 트리

우리는 상태가 트리에서 어떻게 활용되고 있는지 식별했다. 가장 중요한 질문은 “배타적인 상태의 컨테이너 컴포넌트가 더 많이 있는지 확인하는 방법은 무엇인가?”였다. 이를 위해 몇 가지 주요 컴포넌트를 분할하여 컨테이너로 포장하거나 컨테이너 자체로 만들어야 했다.

리팩토링 후 React 컴포넌트 트리

위의 트리에서 MainPage 컨테이너가 더 이상 임의의 테이블 렌더링을 담당하지 않는 것에 유의하라. ResultsTable에서 배타적인 상태를 가진 ControlsFooter를 추출했다. 우리가 집중한 것은 테이블 관련된 컴포넌트의 재 렌더링을 줄이는 것이었다.

2단계. 파생 상태와 shouldComponentUpdate

상태가 잘 정의되고 플랫한지 확인하는 것이 가장 중요하다. Redux 상태를 데이터베이스로 생각하고 정규화하기 시작하면 더 쉽다. 우리는 제품, 통계, 결과, 차트 데이터, 사용자 선택, UI 상테 등과 같은 엔티티를 많이 가지고 있다. 대부분 키/값의 쌍으로 분류하고 중첩을 방지한다. 각 컨테이너 컴포넌트는 상태를 쿼리하고 데이터를 비정규화한다. 예를 들어, 내비게이션 메뉴 컴포넌트는 제품 ID로 메트릭 목록을 필터링해서 전체 메트릭 상태에서 쉽게 제품 메트릭을 추출할 필요가 있었다.

전체 상태에서 반복적으로 파생되는 모든 프로세스는 최적화할 수 있다. Redux Reselect로 들어가보자. Reselect는 파생 상태를 계산하기 위한 셀렉터를 정의하게 해준다. Reselect를 이용해 정의된 셀렉터는 효율성을 위해 이전 상태를 메모할 수 있고, 계단식으로 연결될 수 있다. 따라서 아래 함수를 이용해 셀렉터를 정의할 수 있다. 그리고 메트릭과 productId에 어떠한 변경이 없는 한, 메모해둔 복사본을 반환할 것이다.

getMetrics(productId, metrics) {
 metrics.find(m => m.productId === productId)
}

우리는 모든 비동기 액션이 해결되었는지 확인하고, 상태를 다시 렌더링하기 전에 필요한 모든 것을 상태에 유지하려고 했다. 그래서 컨테이너 컴포넌트의 shouldComponentUpdate 메서드의 동작을 정의하기 위해 셀렉터를 만들었다(예. 테이블은 측정 항목 및 결과가 모두 로드된 경우에만 렌더링 된다). shouldComponentUpdate에서 모든 것을 직접 할 수도 있지만, 셀렉터를 이용하면 다르게 생각할 수 있음을 느꼈다. 컨테이너는 상태를 예측할 수 있게 되고 렌더링 전에 그 상태에서 대기한다. 렌더링 해야하는 상태는 해당 상태의 서브셋일 수 있었다. 더 언급할 필요 없이 더 성능이 뛰어났다.

결과

리팩토링을 마친 후에 다시 실험을 해보아 모든 변경 사항의 영향을 비교했다. 컴포넌트 트리 전체에 걸친 상태 변화를 개선하고 더 향상되기를 기대했다.

위의 표를 보면 한눈에 리팩토링이 어떤 영향을 끼쳤는지 명확히 알 수 있다. 새로운 결과에서는 날짜 변경이 어떻게 comparisonTable(7)과 controlsHeader(2) 간 렌더 횟수를 분배시키는지 관찰할 수 있다. 더해봤자 9번이다. 이러한 논리적인 리팩토링은 최대 4배까지 성능을 높일 수 있었다. 또한 React가 낭비하는 시간을 쉽게 최적화할 수 있었다. 이는 아주 중요한 개선사항이였으며 올바른 방향으로 나아가는 것이었다.

향후 계획

Redux는 프론트엔트 애플리케이션을 위한 훌륭한 패턴이다. 애플리케이션 상태를 더 잘 판단할 수 있게 해주었고, 하나의 스토어를 사용하는 것이 앱이 복잡해짐에 따라 확장하는 것이나 디버깅하는 것을 쉽게 했다.

앞으로는 Redux State를 위한 immutability를 알아보려고 한다. Redux의 상태 변경에 대해 규칙을 정하고 shouldComponentUpdate에서 shallow comparison를 이용하는 경우를 생각하려고 한다.

원문보기

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 상태를 캡슐화하는 것을 강력 추천한다.

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