React


FE-Weekly-Pick에서는 최근에 팀 내에서 진행했던 스터디 내용을 정리하는 의미에서, 4회에 걸쳐 자바스크립트 (프론트엔드) 프레임워크를 소개하는 시리즈를 연재할 예정입니다. 금주부터 아래와 같은 목차로 진행되니 많은 관심 부탁드립니다.

  1. Cycle.js
  2. Angular 2
  3. Vue.js
  4. React

목차

  1. 소개
  2. 개발/구동 환경
  3. 아키텍쳐
  4. 컴포넌트
  5. 테스트
  6. 성능

    1. 렌더링
    2. Selector
  7. 기타

    1. redux-saga
    2. normalizr
  8. 결론

소개

React는 Facebook에서 개발해 Facebook과 Instagram, Airbnb 등에서 사용하고 있는 오픈소스 UI 프레임웍이다.

사용자 액션에 따라 DOM을 일일이 다루었던 과거 개발 방식(jQuery와 같은 라이브러리만을 사용하는)과는 달리 개발자가 DOM을 직접 다루지 않고 React가 데이터 상태에 따라 자동으로 UI를 관리하기 때문에, 개발자는 단순히 특정 상태에 대한 뷰의 변화만 구현하면 된다.

React는 다음의 세 가지 특징을 갖고 있다.

  • UI 컴포넌트를 만들기 위한 라이브러리이며 React의 컴포넌트는 트리형태로 구성된다.
  • Virtual DOM을 사용하여 변경된 부분에 대한 최소한의 DOM 처리로 UI를 업데이트하여 애플리케이션의 성능을 향상한다.
  • 부모 컴포넌트에서 하위 컴포넌트로 전달하는 단방향의 단순한 데이터 흐름을 갖고 있어 데이터 추적과 디버깅을 쉽게 해준다.

개발 환경

기본 구현

var HelloReact = React.createClass({
  render: function() {
    return React.DOM.p(null, "Hello" + this.props.message);
  }
});

ReactDOM.render(
  React.createElement(HelloReact, { message: "React" }),
  document.getElementById("container")
);

위 코드는 "Hello React" 라는 문구를 보여주는 간단한 예제다.

React.createClass()는 뷰의 최소 단위인 컴포넌트를 만드는 API이다. 예제 컴포넌트는 DOM.p() API로 <p>Hello React</p> 를 렌더링하도록 구현했으며, ReactDOM.render() 로 컴포넌트를 DOM에 직접 렌더링 한다.

여기서 ReactDOM은 DOM과 React를 연결해주는 역할을 하는 객체로 컴포넌트를 렌더링 하거나 DOM을 직접 탐색하는 데 사용한다.

ReactDOM은 원래 React에 포함되어 있었지만 react-native, react-canvas와 같은 다른 플랫폼의 뷰 모듈과 같은 관계에 위치하도록 0.14버전부터 별도의 모듈로 분리했다. 관련 문서

HelloReact 컴포넌트의 반환 엘리먼트의 형태를 약간 바꿔보자. <p><span>Hello</span><span>React</span></p>와 같이 Hello와 React를 두 개의 span 엘리먼트로 구분하여 표시하고 싶다면 아래와 같이 코드를 수정해야 한다.

var HelloReact = React.createClass({
  render: function() {
    return React.DOM.p(
      null,
      React.DOM.span(null, "Hello"),
      React.DOM.span(nul, this.props.message)
    );
  }
});

그런데, 코드만 보고 어떤 컴포넌트가 어떤 엘리먼트를 반환하는지 파악이 되는가?

JSX

React에서는 UI 표현의 편의를 위해 XML 형태의 구문을 자바스크립트 구문 사이에 사용할 수 있는 JSX 문법을 제공한다. JSX를 사용하면 기존의 어려웠던 중첩 엘리먼트 표현도 보다 쉽게 할 수 있다.

var HelloReact = React.createClass({
  render: function() {
    return (
      <p>
        <span>Hello</span>
        <span>{this.props.message}</span>
      </p>
    );
  }
});

ReactDOM.render(
  <HelloReact message="React" />,
  document.getElementById("container")
);

이 JSX 문법을 사용한 파일은 일반적으로 *.jsx 라는 확장자로 저장하고 배포 전 트랜스파일을 통해 js로 변환하는 과정을 거친다. 개발 환경 구성은 뒤에서 다룬다.

es6 문법

플랫폼이나 개발환경이 이미 구축된 경우 React.createClass()의 사용보다 es6의 class 키워드를 사용하는 것을 권장한다. 이 class키워드는 0.13 버전부터 지원하기 시작했는데 es7의 property initializer 와 함께 사용하면 편리하게 이용할 수 있다.

// es6의 class
class HelloReact extends React.Component {
  // es7의 property initializer
  state = { message: "hello world" };

  constructor() {
    super();
    this.name = "john";
  }

  // es7의 decorator
  @autobind
  greeting() {
    return this.name;
  }

  render() {
    return (
      <p>
        Hello {this.state.message} {this.greeting()}
      </p>
    );
  }
}

당장 class 키워드 사용에 큰 장점은 없지만 추후 class 키워드가 native로 지원될 경우 조금 더 빠른 성능을 지원하게 될 것이고 추가로 es6의 여러 편리한 문법(spread)을 이용하면 코드량을 상당히 줄일 수 있다.

class 키워드는 지금 필요의 경우 this 바인딩을 직접 해야 한다는 것과 mixin을 사용할 수 없다는 이슈가 있지만, this바인딩은 앞으로 추가될 decorator 로 간단하게 해결할 수 있고 mixin은 포럼에서 코드의 복잡도를 증가시킨다는 의견이 많아 HOC(High Order Component) 로 대체될 가능성이 높다. 조심스럽게 예측해보건대 React.createClass()는 추후 사라질 가능성이 있다.

개발 환경 구성

JSX와 es6 문법을 사용하려면 별도의 변환 과정이 필요하다. 이를 위해선 Babel 인터프리터가 필요하며, 이를 사용하려면 webpack과 같은 번들링 도구가 필요하다.

Babel, webpack을 사용하기 위해선 관련된 npm 모듈들을 모두 설치해야 하고 몇몇 설정 파일들을 하나하나 작성해야 한다. 어찌 보면 매우 귀찮은 일인데 다행히도 React에서는 이와 같은 과정을 한 번에 처리해주는 Create React APP이라는 npm 모듈을 제공하고 있다. 모듈 설치 후 create-react-app 명령어 하나로 로컬 서버 실행부터 자동 번들링까지 지원하는 개발 환경을 정말 쉽게 구성할 수 있다.

$ npm install -g create-react-app
$ create-react-app my-app

npm 패키지를 이용해 직접 개발 환경을 만들고 싶다면 패키지 관리 페이지를 참고하길 바란다. React는 npm에 익숙하지 않은 사용자를 위해 초심자용 키트도 제공하고 있다.


React의 구조

React 애플리케이션의 전체 구조는 컴포넌트의 트리 형태이다.

image

props

부모 컴포넌트는 하위 컴포넌트로 데이터를 전달하기 위해 props를 사용하고 있는데, 데이터를 전달받은 하위 컴포넌트에서 this.props라는 구문으로 사용할 수 있다.

'props'는 'Hello React' 예제에서 사용했다.

var HelloReact = React.createClass({
  render: function() {
    return <p>Hello {this.props.message}</p>;
  }
});

ReactDOM.render(
  <HelloReact message="React" />,
  document.getElementById("container")
);

부모로부터 변경된 props를 전달받게 되면 해당 컴포넌트와 모든 자식 컴포넌트들을 다시 렌더링 한다.

state

컴포넌트 자체의 상태를 관리하기 위해 state를 사용한다. state의 데이터를 이용해 하위 컴포넌트에 props로 전달하기도 한다. state를 변경하려면 setState()를 사용해야 한다.

class LikeButton extends React.Component {
  constructor() {
    super();
    this.state = {
      liked: false
    };
    this.handleClick = this.handleClick.bind(this);
  }
  handleClick() {
    this.setState({ liked: !this.state.liked });
  }
  // ...
}

setState() 메서드가 호출될 때에도 해당 컴포넌트와 모든 자식 컴포넌트들을 다시 렌더링 한다.

컴포넌트 생명주기(lifecycle)

컴포넌트가 페이지의 DOM 트리에 실제로 추가될 때 마운트(mount) 된다고 하고 DOM 트리에서 삭제될 때를 언마운트(unmount) 된다고 정의할때 컴포넌트의 마운트와 언마운트 사이에는 다음과 같은 생명주기(lifecycle) 함수들이 동작한다.

  • componentWillMount() - 마운트 직전에 한번 발생
  • componentDidMount() - 마운트 직후 한번 발생
  • componentWillReceiveProps() - 새로운 props를 전달받기 전에 발생
  • componentWillUpdate() - props, state 업데이트 직전에 발생
  • componentDidUpdate() - props, state 업데이트 직후에 발생
  • componentWillUnmount() - 언마운트 직전에 발생

생명주기 함수가 동작할 때 각 주기에 해당하는 props와 state 정보를 제공하고 있어 이를 바탕으로 원하는 작업을 진행할 수 있다.

컴포넌트 간 데이터 전달

React는 단방향 데이터 흐름을 갖고 있기 때문에 부모 컴포넌트에서 하위 컴포넌트로 데이터(props)를 전달할 수 있다.

만약 상하 관계가 아닌 컴포넌트 간에 데이터를 전달해야 한다면 어떻게 처리해야 할까? 부모가 같은 컴포넌트 사이라면 부모의 state를 이용할 수 있다. 부모 컴포넌트가 자신의 state를 변경할 수 있는 함수를 props를 통해 하위 컴포넌트에 내려주고 하위 컴포넌트는 해당 함수를 통해 상태를 변경하거나 공유할 수 있다.

image

그렇다면 컴포넌트 간의 거리가 먼 경우는 어떻게 처리해야 할까? props를 재귀적으로 넘겨주면서 처리하면 어찌어찌 가능할 것 같은데...

image

별로 좋은 방법은 아닌 것 같다.

어떻게 하면 이 문제를 해결할 수 있을까? 데이터 공유가 필요한 컴포넌트가 공통된 모델을 바라볼 수 있다면 이런 문제를 해결할 수 있지 않을까? React와 같이 단방향 데이터 흐름을 갖고 있으면 더욱 좋을 것 같다.

이런 고민을 해결할 수 있는 것이 바로 Redux다.


Redux와 React

Redux는 Facebook에서 개발한 단방향 흐름 아케텍처인 Flux의 구현체로, 자바스크립트 애플리케이션을 위한 예측 가능한 상태 컨테이너다.

Redux는 Store, Action creator, Reducer로 구성되어있고,

  1. Store의 데이터로 뷰 렌더링
  2. 뷰에서 발생한 이벤트로 Action(Action creator로 생성)을 생성
  3. Reducer는 생성된 Action으로 Store를 갱신
  4. 갱신된 Store로 뷰 렌더링

과 같은 단방향 흐름을 갖는다.

단방향 흐름 하면 React 아닌가, 둘을 같이 사용해보자. React를 Redux와 같이 사용한다면 다음 그림과 같은 흐름을 보일 것이다.

redux and react data flow

출처 : Getting Started with React, Redux and Immutable: a Test-Driven Tutorial (Part 2)

그림 상으로는 완벽하다. 그런데 Redux를 사용하더라도 최상단 컴포넌트에만 Redux store가 연결되면, 이전과 차이가 없지 않은가? 이전 챕터에서 질문했던 거리가 먼 컴포넌트 간의 데이터 전달 문제는 해결되지 않는다.

with redux

출처: https://css-tricks.com/learning-react-redux/

다행스럽게도 컴포넌트의 깊이와 관계없이 Redux store에 접근할 수 있는 방법을 제공하고 있다. Redux는 react-redux라는 npm 모듈을 이용해 React에서 사용할 수 있는데, react-redux 모듈의 connect라는 메서드를 이용하면 React 컴포넌트에서 Redux store에 접근할 수 있는 스마트(smart) 컴포넌트를 만들 수 있다. 이 때 connect 메서드의 인자로 mapStateToProps mapDispatchToProps 개념의 두 가지 인자를 전달하는데 각각의 역할은 다음과 같다.

  • mapStateToProps: 그림에서 파란색 화살표의 역할을 하는 함수로 컴포넌트에 필요한 값을 store로부터 직접 조회하는 역할을 한다.
  • mapDispatchToProps: 그림에서 녹색 화살표의 역할을 하는 함수로 사용자의 액션에서 발생하는 store의 변화를 구현한다.

Redux 없이 부모 컴포넌트로부터 props만 전달받는 컴포넌트를 덤(dumb) 컴포넌트라고 부른다.

바로 이 스마트 컴포넌트를 이용하면 컴포넌트 간의 데이터 전달 문제를 해결할 수 있다. 데이터 전달을 해야 하는 컴포넌트 모두를 스마트 컴포넌트로 만들면 컴포넌트 간에 Redux store를 통해 데이터를 공유하기 때문에 문제가 해결된다.

import { connect } from "react-redux";

// DUMB COMPONENT
class HelloWorld extends React.Component {
  /* ... */
}

// Store 에서 컴포넌트에 필요한 값을 props으로 조회한다.
const mapStateToProps = state => ({
  name: state.name
});

// `bindActionCreators()`로 단순한 action creator함수를 store 와 직접 연결한다.
const mapDispatchToProps = dispatch =>
  bindActionCreators(
    {
      name: function(state, action) {
        const { type, newName } = action;

        switch (type) {
          case "CHANGE_NAME":
            return newName;
          default:
            return state;
        }
      }
    },
    dispatch
  );

// SMART COMPONENT
const Container = connect(
  mapStateToProps,
  mapDispatchToProps
)(HelloWorld);

이벤트 핸들링

ReactDOM에서 이벤트 바인딩

ReactDOM에서 DOM에 할당할 수 있는 속성을 정의할 때에는 JSX 엘리먼트에 <button class="btn" />와 같이 속성으로 정의하는데 이벤트 핸들러도 마찬가지로 JSX 엘리먼트에 카멜 케이스(camel case)로(<button onClick='...' />) 할당한다.

function onClick(e) {
  //...
}

class Btn extends Component {
  render() {
    return <button onClick={e => onClick(e)} />;
  }
}

이벤트 위임(delegation)

JSX 엘리먼트에 이벤트를 할당하면 React는 이벤트를 해당 엘리먼트에 바인딩 하지 않고 document.body에 위임된 형태로 동작하게 한다. 이벤트 위임의 이점은 왜 이벤트 위임(delegation)을 해야 하는가? 문서를 참고하기 바란다.

통합적인(Synthetic) 이벤트

React의 할당된 이벤트 핸들러에는 브라우저 네이티브 이벤트의 크로스 브라우저 래퍼(wrapper)인 SyntheticEvent의 인스턴스가 전달된다. SyntheticEvent는 네이티브 이벤트와 같은 인터페이스를 갖고 있다. 네이티브 이벤트가 필요하다면 .nativeEvent를 사용할 수도 있다.

SyntheticEvent는 풀링(pooling, 이벤트 객체를 재사용하기 위해 이벤트 객체 pool을 관리) 되기 때문에 이벤트 핸들러 내부에서 비동기로 이벤트 객체에 접근하면 null을 반환하게 된다.

function onClick(event) {
  console.log(event); // => nullified object.
  console.log(event.type); // => "click"
  var eventType = event.type; // => "click"

  setTimeout(function() {
    console.log(event.type); // => null
    console.log(eventType); // => "click"
  }, 0);

  // Won't work. this.state.clickEvent will only contain null values.
  this.setState({ clickEvent: event });

  // You can still export event properties.
  this.setState({ eventType: event.type });
}

비동기로 이벤트 객체에 접근하려면 예제와 같이 변수에 캐싱(var eventType = event.type) 한 후 사용해야 한다.


테스트

테스트 도구

일반적으로 웹 애플리케이션의 테스트는 렌더링 이후의 마크업을 검사하는 형태로 수행한다. 이 개념만 알고 있다면 별도의 도구 없이도 테스트할 수 있다. 먼저 HTML 페이지와 테스트 코드를 준비한다.

<!-- 1. HTML 문서 준비 -->
<script src="service.js"></script>
<script>
  // 2. 테스트 실행
  shouldRenderListProperly();

  function shouldRenderListProperly() {
    // 3. root 엘리먼트 준비
    var root = document.createElement("div");
    root.setAttribute("id", "root");
    document.body.appendChild(root);

    // 4. mock 데이터 준비
    var mockList = ["hello", "world"];

    // 5. 컴포넌트 렌더링
    ReactDOM.render(
      <MyComponent list={mockList} />,
      document.query`Selector`("#root")
    );

    // 6. 결과 확인
    console.assert(document.query`Selector`("ul li").length === 2);
  }
</script>

컴포넌트가 렌더링 될 컨테이너 엘리먼트를 만들고 렌더링 후 마크업을 조사하는 2 ~ 6의 과정을 반복해 테스트 커버리지를 올릴 수 있다. 하지만 이 방법은 생산성은 매우 떨어진다. 이때 사용하는 도구가 karma, jasmine enzyme 이다.

karma는 웹서버를 내장하고 있는 실행 프로그램이다. [설정 파일]을 읽어 필요한 소스가 포함된 HTML 파일을 만들거나 브라우저로 열어 특정 JS 함수를 자동으로 실행해주는 기능을 가지고 있다. 또 6번 결과 항목을 원하는 형태 (console, junit, teamcity)의 포맷으로도 출력하는 것도 가능하다. (CI에 응용할 수 있다)

jasminedescribe(), it() 메서드를 제공하는 테스트 도구이다. 3 ~ 5번 코드가 테스트마다 포함되어 있으면 테스트의 의도가 희석되고 불필요한 코드량 증가를 일으키는데 beforeEach(), beforeAll() 과 같은 API를 사용하면 중복을 제거하고 깔끔한 테스트를 작성할 수 있다.

enzyme은 React 컴포넌트 테스팅 도구로 컴포넌트의 마크업이 복잡할수록 테스트 코드도 복잡해지기 마련인데 추상화 API를 통해 깔끔하게 마크업을 확인할 수 있는 API를 제공한다.

설명했던 도구를 사용한 테스트 코드이다.

import {mount} from 'enzyme'

describe('component', () => {
  describe('MyComponent', () => {
    let component, props;

    beforeEach(() => {
      props = {
        mockList = ['hello', 'world'];
      };

      component = mount(<MyComponent {...props} />);
    });

    it('should render list properly.', () => {
      expect(component.find('li').length).toBe(2);
    });

    it('...');
    it('...');
  });
});

Shallow Rendering

위의 예제에서 리스트의 항목 하나하나가 특정한 props를 요구하는 React 컴포넌트일 때를 생각해 보자(가칭 ListItem 컴포넌트). 오류 없이 렌더링하기 위해서는 MyComponent를 렌더링할 때 ListItem에 필요한 값까지 mocking 해야 하는 번거로움이 있다.

it('...', () => {
  // 이 mockList를 매 it 구문에서 준비하는 것은 번거롭고 테스트의 의도를 희석시킨다.
  const mockList = [
    {id: 1, name: 'a.txt', size: '900'},
    {id: 2, name: 'b.txt', size: '1222'},
    ...
  ];
});

MyComponent의 기능만을 테스트하고 싶은데, ListItem까지 테스트를 하게 되는 상황이다. 이때 유용한 기능이 바로 Shallow Rendering이다.

Shallow Rendering 된 컴포넌트는 자식 컴포넌트를 가지고는 있지만 실제로 렌더링을 하지 않는다. 따라서 자식 컴포넌트의 props를 mocking 할 필요가 없게 된다. MyComponent를 렌더링한 결과를 보면 다음과 같이 출력된다.

const mockList = ["a", "b"];

// shallow rendering
const component = shallow(<Component mockList={mockList} />);

console.log(component.debug());

// 출력:
//<ul id="comp">
//  <ListItem />
//  <ListItem />
//</ul>

component.find('ListItem').length 를 검사하는 것으로 목록 개수를 맞게 렌더링하는지 테스트할 수 있다. v15.0.0 기준으로 일반 React 컴포넌트에 대해서는 별다른 설정 없이 렌더링할 수 있고, SFC(Stateless Functional Component)의 경우 displayName 값을 적어주어야 컴포넌트 이름으로 탐색할 수 있게 된다.


성능 - 렌더링

React 애플리케이션은 여러 컴포넌트들이 트리 형태로 구성되어 있다. 특정 컴포넌트의 상태가 변경될 때 하위 컴포넌트들이 연쇄적으로 렌더링 되며 운영되는 구조다.

React는 기본적으로 실제로 변경된 부분만을 가려내어 DOM을 조작하도록 하는 최적화된 렌더링 엔진을 가지고 있다. 공식 사이트에 있는 O(n3)의 복잡도를 O(n)으로 줄이려는 방법문서는 상당히 흥미진진한데 기본적인 가이드 문서만 잘 따르면 자연스럽게 이 알고리즘이 적용돼 서비스에 문제가 없는 성능을 보장한다.

오히려 React 공식 문서에는 어설픈 최적화는 디버깅을 어렵게 할 수 있으니 꼭 필요한 곳에서만 최적화를 적용하라고 가이드하고 있다.

그럼 정말 필요한 경우가 어디에 있을까? 사실 어떠한 상황이 그러하다고 딱 집어 말하기는 어렵다. 다만 이 글에서 제시하는 상황을 생각해 보면 어느 정도 판단이 설 것이다.

앞서 React 애플리케이션의 전체 구조는 컴포넌트의 트리라고 이야기했다. 특정 부모 컴포넌트의 상태가 변경될 때 모든 하위 컴포넌트가 렌더링 되는데 이때 몇몇 컴포넌트는 렌더링 되지 않아도 되거나 (A), 어떤 조건에서만 렌더링 되면 되는 경우(B)가 있다. 이 부분이 바로 최적화 포인트이다.

가장 간단한 예를 들어보면 (A)는 마크업만 포함하는 컴포넌트를 들 수 있다. 물론 오버헤드가 크지 않겠지만 앱의 성능에 문제가 있는 경우 비슷한 컴포넌트 여럿에 적용해서 도움이 될 수도 있다.

반복적인 목록을 렌더링하는 경우 (B) 최적화는 큰 도움이 될 수 있다. 예를 들어 할 일 내용, 마감 시간, 선택 여부를 관리할 수 있는 TODO 목록이 있다고 하자. 3 가지 상태 중 뷰에 나타나는 것은 오직 할 일의 내용과 선택 여부뿐이다. 마감 시간의 변경은 웹 페이지에 나타나지 않아도 된다. 그렇다면 할 일 내용, 선택 여부 두 가지 상태의 변경만 DOM 조작으로 이어지면 된다. 그 조건을 shouldComponentUpdate 에 구현하면 목록의 양에 비례한 엄청난 성능 향상을 이룰 수 있다.

훌륭한 설계자는 조급하게 코드 최적화를 하지 않는다. React의 트리 렌더링 성능 최적화 방식은 이 말에 충실한 계획적 최적화를 가능하게 한다. 개인적으로 React를 선호하는 이유이기도 하다.


성능 - Selector

Redux의 API 명세 중 사용자가 직접 구현하는 mapStateToProps함수는 Store에서 컴포넌트에 필요한 특정 값을 선택하는 데 사용한다. 역할 때문에 Selector라고 부르기도 하는데 이 SelectorStore가 변경될때마다 계속 실행되어 컴포넌트에게 새 상태를 알려준다. 하지만 파라미터가 같을 때도 값을 계산하는 오버헤드가 있다.

Selector는 보통 파라미터가 같으면 반환되는 값도 같은 순수함수의 특성이 있다. 따라서 앞서 언급한 오버헤드를 줄이려는 방법으로 메모이제이션 패턴을 적용할 수 있는데 Reselect는 간단하게 이 기능을 구현할 수 있는 API를 제공한다. 또 만들어진 Selector끼리 조합할 수 있는 기능도 제공해 훨씬 유연한 설계를 할 수 있다.

Store가 다음의 코드와 같은 React 애플리케이션이 있다고 가정한다.

/* STORE */
{
  name: 'john',
  age: 29,
  friends: ['albert', 'kim']
}

/* COMPONENT */
class HelloWorld extends Component {
  render() {
    return <h1>{this.props.greetings}</h1>;
  }
}

function getGreetings({name, age}) {
  // 비용이 소요되는 계산이라 가정
  return `Hello i'm ${name} and ${age}`;
}

const mapStateToProps = state => {
  return {greetings: getGreetings(state)}
};

getGreetings의 템플릿 연산은 name, age가 변경되었을 때만 수행되면 된다. 하지만 지금 상태에서는 friends만 변경되어도 실행된다. Reselect를 통해 리팩토링해 보자.

const getName = state => state.name;
const getAge = state => state.age;

const getGreetings = createSelector(
  // Reselect 에서 아래 함수들을 Input `Selector`라 부른다
  [getName, getAge],

  // Reselect 에서 아래 함수는 Result `Selector`라 부른다
  (name, age) => {
    return `Hello i'm ${state.name} and ${state.age}`;
  }
);

const mapStateToProps = state => ({
  greetings: getGreetings(state);
});

이제 getGreetingsReselect의 API createSelector()를 통해 최적화되었다. 파라미터가 같으면 템플릿 연산을 하지 않고 캐시된 이전 실행의 결과를 반환한다. friends만 변경된 경우 getGreetings의 템플릿 연산은 하지 않는다.

가이드 문서에 따르면 파라미터 비교는 === 연산자로 수행하며 API를 통해 얼마든지 변경할 수 있다. 만약 getGreetings가 프로젝트 전체에서 유틸적인 성격으로 쓰이는 경우 별도의 모듈로 만들면 프로젝트 전체에서 성능 향상 효과를 얻을 수 있다.


기타 - redux-saga

'기타' 섹션에서는 필수는 아니지만, 상황에 따라 유용하게 사용할 수 있는 도구를 소개한다. 간혹 가이드에서 권장하는 방향보다 더 효율적이라 생각하는 방법으로 구현하는 경우가 있는데 이때 해결하기 어려운 문제가 생길 수 있다. 실제 필자가 겪었던 어려움을 해결했던 도구들이라 도움이 될 것이라 믿는다.

React로 게시판 애플리케이션을 만든다고 할 때 Store의 구조를 다음과 같은 두 가지로 설계한다.

데이터를 쌓고 뷰에 노출되는 게시물은 id로 참조

{
  currentContentId: 'id1',
  contents: {
    id1: '게시글 본문...',
    id2: '게시글 본문...',
  }
}

본문을 클라이언트에 쌓고 뷰에 노출되는 본문은 id를 사용하는 방법이다. 예전보다 컴퓨팅 파워가 좋아지는 추세에 따른 설계다. 게시글을 보면 볼수록 데이터가 쌓이는 단점이 있어 잘 사용하지는 않지만, 이후에 언급할 문제에 대해서는 자유롭다.

프로퍼티를 직접 사용

{
  content: "게시글 본문...";
}

간단하게 내용을 곧바로 뷰에 노출하는 형태이다. 일반적으로 최소한의 정보를 유지하는 설계를 선호하기 때문에 이 방식으로 구현한다. 하지만 content를 변경시키는 action이 비동기로 dispatch 될 경우 문제가 발생한다.

사용자가 빠르게 수행할 수 있는 동작에 API 호출을 구현할 경우 응답 순서에 따라 원하지 않는 결과가 발생할 수 있다. 예를 들어 SPA(Single Page Application) 형태의 애플리케이션에서 일관된 사용자 경험을 위해 브라우저의 앞, 뒤 기능을 구현하는 경우가 있다.

content가 실제 변경되는 시점은 API응답이 도착하는 시점 즉 비동기 시점이다. 브라우저의 앞, 뒤, 앞 단축키를 이용해 빠르게 이동한 경우 순서대로 API요청을 할 진 몰라도 응답(앞(1), 뒤(2), 앞(3))의 순서는 네트워크 환경과 같은 여러 요인에 따라 보장되지 않는다. 결국 사용자는 브라우저의 URL과 전혀 관련없는 페이지를 보게 될 수 있다.

이를 해결하기 위해서는 일부 action의 dispatch 순서에 동시성 제어가 필요하다. 이때 redux-saga 도구가 큰 도움이 된다. 사실 saga 패턴은 트랜잭션 처리에서 원자성과 가용성을 트레이드오프로 일련의 서브 트랜젝션을 완전히 성공하거나 또는 중도 실패 시 완전 복구를 보장하기 위한 패턴이다. (자세한 소개는 Saga background을 참고 바란다)

redux-saga는 이 패턴에서 아이디어를 채용해 일련의 action dispatching이 완전히 끝나거나 중도 실패 시 하나도 dispatching 되지 않음 (Store가 변하지 않았음)을 보장하는 기능을 제공한다. 이 기능을 이용하면 앞서 언급했던 요청과 응답의 순서에 따른 문제를 해결할 수 있다.

아래는 TOASTDrive 프로젝트에 사용된 소스를 알기 쉽게 수정한 예제다.

import { takeLatest } from "redux-saga";
import { call, put } from "redux-saga/effects";

/**
 * 함수를 직접 실행하지 않고 call, put과 같은 redux-saga의 API이용하여
 * 동시성 기능을 이용함. (이런 메서드들을 side effect라 부른다)
 */
function* routeToFileList() {
  try {
    // 로딩 이미지 출력을 위한 action
    yield put({ type: "FETCH_FILES_REQUEST" });

    // API호출
    const { data } = yield call(axios.get, url, params);

    // 데이터를 받았음을 알리는 action
    yield put({ type: "FETCH_FILES_SUCCESS", data });
  } finally {
    // 파일목록 받는 과업의 끝을 알리는 action
    yield put({ type: "FETCH_FILDS_DONE" });
  }
}

/**
 * redux-saga는 한 애플리케이션에 main saga 하나를 지원한다
 * Generator의 특성에 따라 이 main은 임베디드 프로그래밍의
 * void main() 처럼 끝나지 않고 계속 실행된다.
 */
function* main() {
  /**
   * takeLatest는 가장 마지막에 시작된 saga에
   * 대해서만 dispatching 을 보장한다.
   */
  yield takeLatest(["ROUTE_TO_FILE"], routeToFileList);
}

이 도구는 현재도 정말 활발하게 유지 보수되고 있다. 그리고 '아직' 질문성 이슈에도 정말 친절하게 답변을 해 주고 있다. 그래서 더욱 더 애착이 가는 도구이다.

그림설명

takeLatest() 에 대해 질문한 이슈에 그림으로 설명해주었다.

takeLatest() 뿐만 아니라, takeEvery(), throttle()과 같은 고급 동시성 제어 기능과 프로세스 블록 없이 saga를 수행하는 fork, 각 saga 간 데이터 통신을 위한 channel 등의 API를 제공한다. 전부가 아니더라도 동시성 제어용 high level 함수만으로도 충분히 가치 있는 도구이다.


기타 - normalizr

Store는 가능한 얇게 설계하는 것이 좋다. 만약 아래와 같이 author 라는 모델이 depth가 이는 경우 articles Reducer는 author 수정을 위해 3-depth 이상의 값을 수정하기 위한 복잡한 구현을 할 수 밖에 없다.

/* STORE */
{
  articles: [{
    id: 1,
    title: 'Some Article',
    author: {
      id: 1,
      name: 'Dan'
    }
  }]
}

/* COMPONENT */
const articles = (state, action) {
  const {type} = action;

  switch (type) {
    case 'UPDATE_AUTHOR':
      const {id, newName} = action;
      // 모든 article을 순회하며 author를 찾아야 함
      for (const article of state) {
        if (article.author.id === id) {
          article.author.name = newName;
          return {...state};
        }
      }
      return state;
    default:
      return state;
  }
}

하지만 아래와 같이 Store를 최적화 할 경우 Reducer를 정말 간편하게 구현할 수 있다.

/* STORE */
{
  articles: {
    '1': {
      id: 1,
      title: 'Some Article',
      author: 1
    }
  },
  authors: {
    '1': {
      id: 1,
      name: 'Dan'
    }
  }
}

/* COMPONENT */
const articles = (state, action) => {
  /* articles 값만 신경쓰면 된다. */
};

const author = (state, action) => {
  const {type} = action;

  switch (type) {
    case 'UPDATE_AUTHOR':
      const {id, newName} = action;
      if (state[id]) {
        return {...state, [id]: {name: newName}};
      }
    default:
      return state;
  }
};

따라서 가능하면 도메인 모델을 정의해 Store 가 깊어지지 않도록 관리하는 것이 중요하다. 그런데 간혹 외부 API를 연동하는 경우 그 사이트의 정책에 따라 데이터의 형태가 복잡한 경우가 있다.

// API 응답 결과
{
  id: 1,
  title: 'Some Article',
  author: {
    id: 7,
    name: 'Dan'
  },
  contributors: [{
    id: 10,
    name: 'Abe'
  }, {
    id: 15,
    name: 'Fred'
  }]
}

이 경우 앞서 언급했던 이상적인 Store설계에 데이터를 반영하기 위해서는 선처리를 따로 해 주어야 하는데, normalizr는 스키마 정의를 통해 이를 쉽게 정규화할 수 있는 기능을 제공한다. 예제 코드를 실행하면 함수가 만들어지는데, 이 함수에 JSON응답 데이터를 파라미터로 실행하면 바로 정규화 된 데이터를 받을 수 있다.

const article = new Schema("articles");
const user = new Schema("users");

article.define({
  author: user,
  contributors: arrayOf(user)
});

const result = normalize(response, article);

console.log(result);

// {
//   result: 1,                    // <--- Note object is referenced by ID
//   entities: {
//     articles: {
//       1: {
//         author: 7,              // <--- Same happens for references to
//         contributors: [10, 15]  // <--- other entities in the schema
//         ...}
//     },
//     users: {
//       7: { ... },
//       10: { ... },
//       15: { ... }
//     }
//   }
// }

정규화 연산에 대한 오버헤드를 트레이드오프로 Reducer에서 다루기 쉽게 데이터를 만들 수 있다.


결론

지금까지 React의 기본 내용부터 성능 최적화까지 다뤘다. React는 웹 애플리케이션 개발의 가장 큰 걸림돌인 성능 문제를 Virtual DOM을 통해 쉽게 해결할 수 있는 설루션을 제공한다. 특히 JSX 문법을 이용하면 기존 HTML 마크업을 작성하듯 컴포넌트를 만들 수 있다는 점은 좋은 장점이다.

특히 비슷한 웹 애플리케이션 프레임웍 중 사용자층이 가장 두텁고 오래되었다. 복잡하고 어려운 일을 간단하게 풀어낼 수 있는 수 많은 도구가 있다. 잘 선택해 사용한다면 전문 지식이 없어도 고 품질의 애플리케이션을 개발할 수 있다. 그리고 '기타' 항목에서 소개한 도구들 모두 React의 개발자 Dan Abramov가 기여하고 또 권장하고 있다.

현재 TOASTDrive 웹 사이트는 React로 개발되고 있다. 프로젝트에 도입을 고려하고 있거나 궁금한 사항이 있다면 언제든지 dl_javascript@nhn.com 으로 문의 바란다.


김민형, FE Development Lab2016.10.21Back to list