아마 이게 제일 이해하기 쉬울걸요? React + Redux 플로우의 이해

carrot useless
14 min readJan 28, 2018

--

https://qiita.com/mpyw/items/a816c6380219b1d5a3bf?utm_campaign=popular_items&utm_medium=feed&utm_source=popular_items 를 번역한 문서입니다.

“튜토리얼 클리어했다! React는 어떻게든 이해하긴 했는데, Redux가 잘 모르겠단 말이야…”하는 사람을 위해 썼습니다. 저도 아직 미숙하기 때문에 이해한 것을 확인하는 용도로 내용을 써내려가봤습니다.

React랑 React+Redux의 결정적 차이

  • React는 React 컴포넌트 자신이 개별적으로 상태관리를 한다.
  • React+Redux는 상태관리를 하는 전용 장소(store)에서 상태를 관리하고, React 컴포넌트는 그걸 보여주기만 하는 용도로 쓰인다.

Redux 플로우의 이해

플로우 전체도

Store

상태는 기본적으로 전부 여기서 집중관리 됩니다. 커다란 JSON의 결정체정도의 이미지입니다.

{
value: 0,
}

규모가 클 경우에는 상태를 카테고리별로 분류하는 경우가 일반적입니다.

{
// 세션과 관련된 것
session: {
loggedIn: true,
user: {
id: "114514",
screenName: "@mpyw",
},
},

// 표시중인 타임라인에 관련된 것
timeline: {
type: "home",
statuses: [
{id: 1, screenName: "@mpyw", text: "hello"},
{id: 2, screenName: "@mpyw", text: "bye"},
],
},

// 알림과 관련된 것
notification: [],
}

Action 및 Action Creator

Store 및 Store에 존재하는 State는 아주 신성한 것이라고 할 수 있습니다. React 컴포넌트같은 하등한 것이 직접 접근하려고 하면 안 되는 것이죠. 직접 접근하기 위해 Action이라는 의식을 거쳐야 합니다. 이벤트 드리븐과 같은 개념입니다.

  1. Store에 대해 뭔가 하고 싶은 경우엔 Action 을 발행한다.
  2. Store의 문지기가 Action의 발생을 감지하면, State가 경신된다.

Action은 기본적으로 아래와 같은 포맷을 갖고 있는 오브젝트가 됩니다.

{
type: "액션의 종류를 한번에 식별할 수 있는 문자열 혹은 심볼",
payload: "액션의 실행에 필요한 임의의 데이터",
}

예를 들어 카운터의 값을 2배 늘리는 경우, 아래와 같은 오브젝트가 될 것입니다. 머릿부분에 @@myapp/이라고 Prefix을 붙인건 다른 사람이 쓴 코드와의 충돌을 피하기 위함입니다.

{
type: "@@myapp/ADD_VALUE",
payload: 2,
}

그런데 하나하나 이런 오브젝트를 만드는 걸 수작업으로 하는 것도 정말 괴로운 일이겠죠. 또 "@@myapp/ADD_VALUE"같이 매번 Action명을 문자열로 쓰는 것도 정말 싫습니다. 그래서, 이걸 조금 편하게 하기 위해 상수와 함수를 준비하는게 일반적입니다. 외부 파일이 참고할수도 있으니 제대로 export 해놓는게 좋겠습니다.

export const ADD_VALUE = '@@myapp/ADD_VALUE';
export const addValue = amount => ({type: ADD_VALUE, payload: amount});

Reducer

앞에 ‘Store의 문지기’라고 쓴 적이 있습니다만, 그 개념과 비슷한 역할을 하는 것이 Reducer입니다.

함수형 프로그래밍에서 Reducer라는 용어는 합성곱을 의미합니다만, Redux에 한해서는 아래와 같이 이전 상태와 Action을 합쳐, 새로운 state를 만드는 조작을 말합니다.

import { ADD_VALUE } from './actions';

export default (state = {value: 0}, action) => {
switch (action.type) {
case ADD_VALUE:
return { ...state, value: state.value + action.payload };
default:
return state;
}
}

주의해서 봐야할 것은 바로 두가지 입니다.

  • 초기상태는 Reducer의 디폴트 인수에서 정의된다
  • 상태가 변할 때 전해진 state는 그 자체의 값으로 대체 되는 것이 아니라, 새로운 것이 합성되는 것처럼 쓰여진다.

반환된 state는 store에 바로 반영 되어 아래와 같이 변화합니다.

{
value: 2,
}

트위터에서처럼 대규모 개발에 Reducer를 미세하게 분할하는 경우 Redux에서 제공하는 combineReducers함수를 이용하여 아래와 같이 씁니다.

import { combineReducers } from 'redux';

const sessionReducer = (state = {loggedIn: false, user: null}, payload) => {
/* 省略 */
};
const timelineReducer = (state = {type: "home", statuses: []}, payload) => {
/* 省略 */
};
const notificationReducer = (state = [], payload) => {
/* 省略 */
};

export default combineReducers({
session: sessionReducer,
timeline: timelineReducer,
notification: notificationReducer,
})

이렇게 하면, Reducer분할에 쓰인 Key가 그대로 State분할에도 쓰입니다. 또한 실제로 각각의 reducer의 정의 자체도 다른 파일로 나누는 것이 일반적입니다.

순수한 Component와 연결된 Component

React의 Component자체는 Redux의 흐름에 타는 것이 불가능 합니다. 흐름에 타기 위해서는 ReactRedux에 의해 제공 되는 connect라고 불리는 함수를 이용하여 아래와 같이 씁니다. 함수판과 클래스판 각각 씁니다.

>>>>>>>> 함수판
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { addValue } from './actions';

const Counter = ({ value, dispatchAddValue }) => (
<div>
Value: {value}
<a href="#" onClick={e => dispatchAddValue(1)}>+1</a>
<a href="#" onClick={e => dispatchAddValue(2)}>+2</a>
</div>
);

export default connect(
state => ({ value: state.value }),
dispatch => ({ dispatchAddValue: amount => dispatch(addValue(amount)) })
)(Counter)
>>>>>>>>> 클래스판
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { addValue } from './actions';

class Counter extends Component {
render() {
const { value, dispatchAddValue } = this.props;
return (
<div>
Value: {value}
<a href="#" onClick={e => dispatchAddValue(1)}>+1</a>
<a href="#" onClick={e => dispatchAddValue(2)}>+2</a>
</div>
);
}
}

export default connect(
state => ({ value: state.value }),
dispatch => ({ dispatchAddValue: amount => dispatch(addValue(amount)) })
)(Counter)

자 여기서 드디어 도식도가 복잡해졌음을 느끼셨을겁니다. 제가 Redux를 공부하면서도 제일 이해에 시간이 많이 걸린 부분도 이 부분입니다. 이해하고 나면 별 것 아니기 때문에 초조해하지 말고 냉정하게 도식도를 바라봅시다.

먼저, Component가 Store로부터 무언가 정보를 받는 경우, 그걸 props를 통해 받습니다. props는 immutable합니다. 다시말해, 상태가 변경될 때마다 새로운 Component가 다시 만들어진다는 의미입니다. 이것을 염두에 둔 후에, connect를 실행하고 있는 주변 코드를 봅시다.

  1. Store가 가진 state를 어떻게 props에 엮을 지 정한다(이 동작을 정의하는 함수는 mapStateToProps라고 불립니다)
  2. Reducer에 action을 알리는 함수 dispatch를 어떻게 props에 엮을 지 정한다(이 동작을 정의하는 함수는 mapDispatchToProps라고 불립니다)
  3. 위에 두가지가 적용된 props를 받을 Component를 정한다
  4. Store와 Reducer를 연결시킬 수 있도록 만들어진 Component가 반환값이 된다

connect(mapStateToProps, mapDispatchToProps)(Component)라고 쓰인걸 보면 좀 독특하다고 생각할 수 있겠지만, 결국 최종적인 반환값은 4번과 같습니다. 아래선 mapStateToPropsmapDispatchToProps에 대해 상세히 설명하도록 하겠습니다.

mapStateToProps

인수로 전달된 state전체를 의미한다는 것에 주의해야 합니다. 카운터의 예를 다시 보면,

{
value: 2,
}

<Counter value={2} />

로 들어가길 바라며 state => ({ value: state.value})라고 썼습니다. 이번 경우에는 다른 프로퍼티가 없기 때문에 state => state라고 써도 동작에는 무리가 없겠지만, 기본적으로 필요한 것만 선별하여 props로 엮는다가 원칙이라고 생각합니다.

mapDispatchToProps

Action Creator에서 action을 만든다고 해도, 그것으론 아무 일도 일어나지 않습니다. Reducer를 향해 통지를 할 수 있게 만들기 위해서는 만든 action을 dispatch라는 함수에 넘겨줘야만 합니다.

이렇게 하면 모든 Reducer가 실행 됩니다. Reducer에 switch문으로 분기를 나눈 것은 바로 이 때문입니다. Reducer는 관계없는 action을 무시하고, 자기에게 주어진 action만을 처리하도록 되어있어야만 합니다.

Component 쪽에 하나하나 수동으로 dispatch하는 처리를 하지 않아도 되도록, 여기서 action의 생성부터 dispatch의 실행까지 한번에 이뤄질 수 있도록 함수를 정의하여 props에 넘겨주도록 한다는 멋진 존재 의의도 엿볼 수 있습니다.

bindActionCreators

하지만 무려 mapDispatchToProps를 이용하여 위와 같은 코드를 짜는 것에서도 도망칠 수 있습니다. bindActionCreators라는 함수를 제공하기 때문입니다. 이걸 쓰면 아래와 같은 생략이 가능합니다.

import React, { Component } from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { addValue } from './actions';


const Counter = ({ value, addValue }) => (
<div>
Value: {value}
<a href="#" onClick={e => addValue(1)}>+1</a>
<a href="#" onClick={e => addValue(2)}>+2</a>
</div>
);

export default connect(
state => ({ value: state.value }),
dispatch => bindActionCreators({ addValue }, dispatch)
)(Counter)

현재는 bindActionCreators의 실행도 생략할 수 있게끔 되었습니다.

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { addValue } from './actions';


const Counter = ({ value, addValue }) => (
<div>
Value: {value}
<a href="#" onClick={e => addValue(1)}>+1</a>
<a href="#" onClick={e => addValue(2)}>+2</a>
</div>
);

export default connect(
state => ({ value: state.value }),
{ addValue }
)(Counter)

Container

이 항에서는 지금까진 ‘연결된 Component’라고 불렀습니다만, 상황에 따라서 ‘Container’라고 불려야하는 Component도 나옵니다. 아래와 같은 것들이 해당됩니다.

  • 수많은 Component가 리스트 형식으로 모여있는데 가운데 각 요소의 Component를 각각 연결하면 수습이 안 되므로, 대표적인 자식요소를 안고 있는 하나의 부모Component가 connect 되는 경우
<UsersList>
<User />
<User />
<User />
<User />
</UsersList>

대표로서 connect될 부모 ComponentContainer라고 부릅니다. Container는 가독성을 높이기 위해, Component와는 디렉토리를 따로 나누는 경우가 많습니다.

이해도 체크

발전

Redux Saga의 도입

실은 React+Redux만으로는 아직 불편한 경우가 많습니다.

  • Reducer 안에 부작용이 생길 처리를 써선 안 된다

라는 원칙이 있기 때문입니다.

  • 같은 입력에 대해 확률적으로 다른 결과가 나오는 처리
  • 지연처리
  • HTTP 리퀘스트 처리

이런 것들은 기본적으로 Reducer 안에서 쓸 수가 없습니다. 그럼 대체 어디서 써야할까요?

  • Component 안
  • Action Creator 안
  • mapDispatchToProps

“또 안티패턴이니? 그럼 넌 Saga야 ^^”

지금까지 connect 된 Component로부터 action이 dispatch되면 그 Reducer를 향한다, 고만 한정지어 설명했습니다. 여기서 새로운 방법을 제시하는 것이 Saga 입니다.

Saga는 제너레이터 함수이기 때문에, 비동기처리를 간단히 다룰 수 있습니다.

  1. yield take(ACTION_TYPE)으로 지정한 action의 발생을 감시한다
  2. 가져온 action을 구워먹고 삶아먹고 마음대로 할 수 있다
  3. yield put(action)의 결과를 다른 action으로 내보낼 수 있다

기본적으론 이런 것들이 가능합니다. 내보낸 action은 Reducer를 향하게도 할 수 있고 자기 자신의 Saga에게 다시 올 수도 있고 자기 외의 다른 Saga에 보낼 수 있을지도 모릅니다.

redux-saga와 관련해선 이 링크를 참고 바랍니다. https://qiita.com/kuy/items/716affc808ebb3e1e8ac

끝.

--

--