리액트의 핵심 철학 중 하나는 속성을 통해 부모 컴포넌트에서 자식으로 전달되는 단방향 데이터 흐름이다. 자식이 부모 컴포넌트로 데이터를 전달해야 할 때는 자식이 이용할 콜백 함수를 속성을 통해 전달할 수 있다. 이 단방향 데이터 흐름은 명확하고 이해하기 쉬운 코드를 작성하도록 도움을 준다.

플럭스 아키텍쳐는 장점도 많지만 몇 가지 해결해야 할 과제도 있다. 리액트 애플리케이션은 일반적으로 최상위 컴포넌트가 컨테이너로서 작동하며, 그 아래에 다수의 순수 컴포넌트가 인터페이스 트리의 리프처럼 작동하는 다중 중첩 단계로 성장한다. 상태가 계층의 최상위 수준에 유지되므로 속성을 통해 여러 단계로 전달해야 하는 콜백을 만들어야 하는 경우가 많은데, 이 작업은 반복적이며 오류가 발생할 가능성이 높다.

리액트 라우터의 공동 개발자이자 커뮤니티에서 잘 알려진 개발자인 라이언 플로렌스는 데이터와 콜백을 속성을 통해 다단계로 전달하는 개념을 애플리케이션 드릴링 이라고 비유했다. 여러 중첩 컴포넌트로 애플리케이션을 구성하려면 많은 드릴 작업이 필요하며, 리팩토링이 필요한 경우(컴포넌트를 다른 곳으로 이동) 더 많은 드릴 작업을 다시 해야 한다.

그런데 중첩된 리액트 컴포넌트가 UI를 구성하는 훌륭한 방법이라는 점은 확실하게 해둘 필요가 있다. 리액트는 반응형 렌더링의 개념에 기반을 두고 작동하므로, 컴포넌트의 상태나 속성의 모든 변경에 반응해 DOM을 업데이트한다(가상 DOM 구현을 통해 필요한 최소한의 변경을 계산한다). 즉, 아주 간단한 개념을 바탕으로 개발하면서 부수적으로 탁월한 성능까지 얻을 수 있다.


플럭스 Flux 는 웹 애플리케이션을 개발하기 위한 아키텍처 가이드라인으로서 페이스북에서 만들었다. 리액트 전용은 아니며 리액트의 일부도 아니지만 리액트와 아주 잘 어울린다.

플럭스의 핵심 개념은 애플리케이션에서 단방향 데이터 흐름을 지원하는 것이다. 플럭스는 기본적으로 뷰(컴포넌트)와 액션 action, 스토어 store, 디스패처 dispatcher 의 세 부분으로 구성된다.

flux

스토어 Store

앞에서 언급했듯이, 플럭스의 핵심은 데이터를 애플리케이션의 각 컴포넌트와 밀접하게 관리하는 것이다. 데이터는 컴포넌트와 완전히 분리되지만 데이터가 변경될 때마다 다시 렌더링할 수 있도록 알림을 받는다. 스토어가 하는 일이 바로 이것이다. 스토어는 애플리케이션의 모든 상태(데이터와 UI 상태를 포함)를 유지하며 상태가 변경되면 이벤트를 발송 dispatch 한다. 뷰(리액트 컴포넌트(는 필요한 데이터를 포함하는 스토어를 구독하며 데이터가 변경되면 자신을 다시 렌더링한다.

스토어는 완전히 폐쇄된 블랙박스라는 중요한 특징을 갖고 있다. 데이터에 접근하기 위한 공용 접근자 메서드(getter)를 제공하지만, 뷰는 물론이고 플럭스의 다른 부분에서도 스토어의 데이터를 변경, 갱신, 삽입할 수 없다. 스토어 자체만 데이터를 변경할 수 있다.

MVC 패러다임의 모델 model 과 비슷한 측면이 있지만, 스토어는 접근자 메서드만 제공한다는 점이 역시 가장 큰 차이다. 즉, 외부에서는 스토어의 값을 변경할 수 없다.

액션 Action

액션은 쉽게 말하자면 ‘앱에서 일어나는 일’ 이라고 할 수 있다. 액션은 애플리케이션의 거의 모든 부분에서 생성될 수 있으며, 사용자 상호작용(예: 버튼 클릭, 댓글 달기, 검색 결과 요청 등)에서 주로 생성되지만 AJAX 요청, 타이머, 웹소켓 이벤트 등을 통해서도 생성된다.

모든 액션은 타입(고유한 이름)과 선택적인 페이로드를 포함한다. 스토어는 액션이 발송되면 자신의 데이터를 업데이트한다.

flux-action

이로써 플럭스의 중요한 부분을 거의 모두 소개했다. 리액트 컴포넌트가 액션을 생성하면(예: 사용자가 텍스트 필드에 이름을 입력) 액션이 스토어로 전달되며, 이 특정 액션과 관계가 있는 스토어가 자신의 데이터를 업데이트하고 변경 이벤트를 발송한다. 그러면 마지막으로 뷰가 해당 스토어의 이벤트에 반응해 최신 데이터로 자신을 다시 렌더링한다.

디스패처 Dispatcher

디스패처는 액션을 스토어로 전달하는 과정을 조율하고 스토어의 액션 핸들러가 올바른 순서로 실행되도록 관리 한다.

디스패처는 플럭스 아키텍쳐에서 핵심적인 역할을 하지만 개발자는 디스패처가 하는 일에 대해 거의 고려할 필요가 없다. 디스패처로 사용할 인스턴스를 만들기만 하면 디스패처 구현이 모든 작업을 알아서 처리한다.


예제

작성된 예제는 깃허브에서 확인할 수 있습니다(reduce 적용한 것이라 아래 예제의 확장이라고 보면 된다).

플럭스의 액션과 스토어를 설명하는 방법으로 은행계좌를 이용하는 에제를 살펴보겠다. 은행계좌는 아래와 같이 거래 transaction 와 잔고 balance 라는 두 가지 상태로 정의된다.

  • 잔고를 초기화하는 최초 거래
거래 금액 잔고
은행계좌 개설 $0 $0
  • 모든 거래가 잔고를 업데이트한다.
거래 금액 잔고
은행계좌 개설 $0 $0
입금 $200 $200
출금 ($50) $150
입금 $100 $250
    $250

이러한 거래를 통해 고객은 은행과 상호작용하고 계좌의 상태를 수정한다. 이러한 프로세스를 플럭스 애플리케이션에서 재현해보자. 플럭스 용어로 말하자면 왼쪽에 나오는 거래가 액션에 해당하며 오른쪽에 나오는 잔고가 스토어에 저장할 값 이다.

애플리케이션의 상수

계좌 개설, 계좌 입금, 계좌 출금의 세 가지 작업을 하는 액션을 앱 전체에서 고유하게 식별할 수 있는 상수가 필요하다. 이를 constants.js 파일에 정의한다.

// constants.js
export default {
    CREATED_ACCOUNT: 'CREATED_ACCOUNT',
    WITHDREW_FROM_ACCOUNT: 'WITHDREW_FROM_ACCOUNT',
    DEPOSITED_INTO_ACCOUNT: 'DEPOSITED_INTO_ACCOUNT'
};

디스패처

디스패처는 개발자가 크게 고려할 필요가 없다. 다음과 같이 간단하게 플럭스 디스패처의 인스턴스를 생성하기만 해도 된다.

import {Dispatcher} from 'flux';
export default new Dispatcher();

표준 디스패처를 확장해 원하는 작업을 하는 것도 가능하다. 다음과 같이 발생한 모든 액션을 로깅하면 디스패처의 역할을 이해하는 데 도움이 된다.

// AppDispatcher.js
import {Dispatcher} form 'flux';

class AppDispatcher extends Dispatcher {
    dispatch(action = {}) {
        console.log("Dispatched", action);
        super.dispatch(action);
    }
}

export default new AppDispatcher();

액션 생성자

먼저 액션을 생성하는 함수를 정의해야 한다. 플럭스 애플리케이션에서 액션은 타입과 선택적 페이로드를 포함하는 객체 다.

// BacnkActions.js
import AppDispatcher from './AppDispatcher';
import bankConstants from './constants';

let BankActions = {
    // 빈 값으로 계좌를 개설한다.
    createAccount() {
        AppDispatcher.dispatch({
            type: bankConstants.CREATED_ACCOUNT,
            amount: 0
        });
    },

    // amount는 입금할 금액이다.
    depositIntoAccount(amount) {
        AppDispatcher.dispatch({
            type: bankConstants.DEPOSITED_INTO_ACCOUNT,
            amount: amount
        });
    },

    // amount는 출금할 금액이다.
    withdrawFromAccount(amount) {
        AppDispatcher.dispatch({
            type: bankConstants.WITHDREW_FROM_ACCOUNT,
            amount: amount
        });
    }
};

export default BankActions;

스토어

플럭스 애플리케이션에서 스토어는 소유한 상태를 저장하며 자신을 디스패처에 등록한다. 액션이 발송될 때마다 모든 스토어가 호출되며 해당 액션과 연관되는지 여부를 결정할 수 있다. 연관되는 경우 스토어는 내부 상태를 변경하고 이벤트를 생성해 스토어가 변경되는 것을 뷰에 알린다.

import {EventEmitter} from 'fbemitter';
import AppDispatcher from './AppDispatcher';
import bankConstants from './constants';

const CHANGE_EVENT = 'change';
let __emitter = new EventEmitter();
let balance = 0;

let BankBalanceStore = {
    getState() {
        return balance;
    },

    addListener(callback) {
        return __emitter.addListener(CHANGE_EVENT, callback);
    }
};

BankBalanceStore.dispatchToken = AppDispatcher.register((action) => {
    switch (action.type) {
        case bankConstants.CREATED_ACCOUNT:
            balance = 0;
            __emitter.emit(CHANGE_EVENT);
            break;
        case bankConstants.DEPOSITED_INTO_ACCOUNT:
            balance = balance + action.amount;
            __emitter.emit(CHANGE_EVENT);
            break;
        case bankConstants.WITHDREW_FROM_ACCOUNT:
            balance = balance - action.amount;
            __emitter.emit(CHANGE_EVENT);
            break;
    }
});

export default BankBalanceStore;

UI 컴포넌트

App.js 파일은 스토어와 액션을 모두 임포트하며, 스토어에 의해 제어되는 잔고를 표시하고 사용자가 출금이나 입금 버튼을 클릭할 때마다 액션 생성자를 호출한다.

먼저 스토어부터 단계적으로 접근해보자. 클래스 생성자는 잔고 키를 포함하는 로컬 상태를 정의하고 BankBalanceStore.getState()에서 이 키의 값을 얻는다. 그 다음은 LifeCycle 메서드인 componentDidMount와 componentWillUnmount를 이용해 BankBalanceStore의 변경을 수신하는 작업을 관리한다. 스토어가 변경될 때마다 handleStoreChange 메서드가 호출되고 컴포넌트의 상태가 업데이트된다. 또한 상태가 변경되면 컴포넌트도 다시 렌더링된다.


import React, {Component} from 'react';
import {render} from 'react-dom';
import BankBalanceStore from './BankBalanceStore';
import BankActions from './BankActions';

class App extends Component {
    constructor() {
        super(...arguments);
        BankActions.createAccount();
        this.state = {
            balance: BankBalanceStore.getState()
        }
    }

    componentDidMount() {
        this.storeSubcription = BankBalanceStore.addListener(data => this.handleStoreChange(data));
    }

    componentWillUnmount() {
        this.storeSubcription.remove();
    }

    handleStoreChange() {
        this.setState({balance: BankBalanceStore.getState()});
    }

    deposit() {
        BankActions.depositIntoAccount(Number(this.refs.amount.value));
        this.refs.amount.value = '';
    }

    withdraw() {
        BankActions.withdrawFromAccount(Number(this.refs.amount.value));
        this.refs.amount.value = '';
    }

    render() {
        return (
            <div>
                <header>Flux Bank</header>
                <h1>Your balance is ${(this.state.balance).toFixed(2)}</h1>
                <div className='atm'>
                    <input type='text' placeholder='Enter Amount' ref='amount' />
                    <br />
                    <button onClick={this.withdraw.bind(this)}>Withdraw</button>
                    <button onClick={this.deposit.bind(this)}>Deposit</button>
                </div>
            </div>
        );
    }
}

render(<App />, document.getElementById('root'));

더보기

Pro-react

What is Flux?