Redux 를 통한 React 어플리케이션 상태 관리 :: 4장. Ducks 구조와 redux-actions 사용하기


이 튜토리얼은 5개의 포스트로 나뉘어진 이어지는 강좌입니다. 목차를 확인하시려면 여기를 참고하세요.

4장. Ducks 구조와 redux-actions 사용하기

Redux 공식 문서에서는, ActionType, Action, Reducer 이 3가지를 따로 따로 다룹니다. 그러다보니, 하나의 액션을 추가하려면 3개의 다른 파일들을 수정해야하죠. 가끔씩은, 액션생성자를 하나하나 만들고 또 그것들을 dispatch 하는 과정이 귀찮게 느껴질때도 있습니다. 상태관리를 편하게 하자고 리덕스를 사용하는건데 오히려 더 복잡해지는것 같기도 합니다.

4장에서는 리덕스를 최대한 편하게 사용하기 위한 몇가지 팁을 다뤄보도록 하겠습니다.

섹션 4-1와 4-2에선 우선 필요한 개념을 알아보고, 4-3에서 카운터에 적용을 해보도록 하죠.

 

4-1. Ducks 구조

Ducks 구조에는 Reducer 파일 안에 액션타입과 액션생성자 함수를 함께 넣어서 관리하고 이를 ‘모듈’ 이라고 부릅니다.

한번 Ducks 구조를 사용한 예시 모듈을 볼까요?

// widgets.js

// Actions
const LOAD   = 'my-app/widgets/LOAD';
const CREATE = 'my-app/widgets/CREATE';
const UPDATE = 'my-app/widgets/UPDATE';
const REMOVE = 'my-app/widgets/REMOVE';

// Reducer
export default function reducer(state = {}, action = {}) {
  switch (action.type) {
    // do reducer stuff
    default: return state;
  }
}

// Action Creators
export function loadWidgets() {
  return { type: LOAD };
}

export function createWidget(widget) {
  return { type: CREATE, widget };
}

export function updateWidget(widget) {
  return { type: UPDATE, widget };
}

export function removeWidget(widget) {
  return { type: REMOVE, widget };
}

최상단엔 액션타입을 정의하고,

리듀서를 정의하여 export default 로 내보내고,

같은 파일에서 액션생성자도 export 를 통해 내보냅니다.

이 패턴에서 따를 규칙은, 액션 타입을 만들때 npm-module-or-app/reducer/ACTION_TYPE 의 형식으로 만들어야 합니다.

만약에 NPM 모듈을 만드는게 아니라면, reducer/ACTION_TYPE 형식으로만 만들어도 상관없습니다. 이렇게 접두사를 달아주는 이유는 서로다른 리듀서에서 액션이름이 중첩되는것을 방지하기 위함 입니다.

그리고, 리듀서를 만들때는 export default 로 내보내고, 액션생성자는 export 로 내보내주어야합니다.

준수해야 할 규칙은 이게 전부입니다.

곧 우리가 만들었던 카운터를, Ducks 패턴으로 작성해볼텐데요. 그 전에, 액션을 더욱 쉽게 관리 할 수 있 해주는 redux-actions 에 대해서 알아보겠습니다.

 

 

4-2. redux-actions 를 통한 더 쉬운 액션관리

redux-actions 패키지에는 리덕스의 액션들을 관리하기 위한 유용한 createActionhandleActions 가 있습니다. 이번 섹션에서는 이 함수들이 어떤 기능을하는지 알아보도록 하겠습니다.

createAction 을 통한 액션생성 자동화

리덕스에서, 액션을 만들다보면 드는 의문이, 이걸 굳이 하나하나 액션 생성자를 만들어야하나? 입니다.

예를 들어, 우리가 기존에 만들었던 incrementdecrement 코드를 다시 한번 읽어봅시다.

export const increment = (index) => ({
    type: types.INCREMENT,
    index
});

export const decrement = (index) => ({
    type: types.DECREMENT,
    index
});

그냥 파라미터로 전달받은 값을 객체에 넣어주는것 뿐인데 이걸 자동화할수도 있지 않을까요?

createAction을 사용한다면 위 작업을 다음과 같이 자동화 시켜 줄 수 있습니다.

export const increment = createAction(types.INCREMENT);
export const decrement = createAction(types.DECREMENT);

하지만, 이런식으로 하면 그 파라미터의 값이 index 가 될 지 뭐가 될 지 모릅니다. 그렇기 때문에, 파라미터로 전달받은 값을 액션의 payload 값으로 설정해줍니다. 따라서 increment(3) 가 실행된다면, 다음과 같이 객체를 만들어주겠죠.

{
    type: 'INCREMENT',
    payload: 5
}

setColor의 경우엔 어떨까요?

export const setColor = createAction(types.SET_COLOR);
setColor({index: 5, color: '#fff'})
/* 결과:
{
    type: 'SET_COLOR',
    payload: {
        index: 5,
        color: '#fff'
    }
}
*/

어때요? 이해가 가나요? 액션이 갖고있을 수 있는 변수를 payload 로 통일하므로서, 액션을 생성하는것을 자동화 할 수 있게 되는 것이지요. 편리하지만, 단점으로는 코드를 봤을때 해당 액션생성자가 파라미터로 필요한 값이 뭔지 모르기때문에, 그 위에 주석을 작성해주어야 합니다.

switch 문 대신 handleActions 사용하기

리듀서에서 액션의 type 에 따라 다른 작업을 하기 위해서 우리는 switch문을 사용했지요. 하지만 이 방식엔 아주 중요한 결점이 한가지 있습니다. 바로, scope가 리듀서 함수로 설정되어있다는것이지요.

그렇기 때문에 서로 다른 case 에서 let 이나 const 를 통하여 변수를 선언하려고 하다보면 같은 이름이 중첩될시엔 에러가 발생합니다.

그렇다고해서 각 case 마다 함수를 정의하는건 코드를 읽기 힘들어질것이구요..

이 문제를 해결해주는것이 바로 handleActions 입니다. 이 함수를 사용하면 다음과 같은 방식으로 해결 할 수 있습니다.

const reducer = handleActions({
  INCREMENT: (state, action) => ({
    counter: state.counter + action.payload
  }),

  DECREMENT: (state, action) => ({
    counter: state.counter - action.payload
  })
}, { counter: 0 });

첫번째 파라미터로는 액션에 따라 실행 할 함수들을 가지고있는 객체, 두번째 파라미터로는 상태의 기본 값 (initialState) 를 넣어주면 됩니다.

한번, 직접 사용해보고싶지 않나요? 이제, 방금 배운 Duck 구조와 redux-actions 을 저희 카운터에 적용을 해보겠습니다.

 

 

4-3. 적용하기

자, 이제 우리가 배웠던것들을 우리의 프로젝트에 적용을 해봅시다.

모듈 작성

우선, redux-actions 를 설치하세요.

yarn add redux-actions

그 다음, src 디렉토리에 modules 디렉토리를 만들고, 그 안에 index.js 파일을 생성하세요.

src/modules/index.js

먼저, redux-actions 에서 createAction 과 handleActions 를 불러오고 액션타입을 선언하도록 하겠습니다.

추후 immutable 의 Map 과 List 도 필요해질테니 미리 불러오겠습니다.

import { createAction, handleActions } from 'redux-actions';
import { Map, List } from 'immutable';

// 액션 타입 
const CREATE = 'counter/CREATE';
const REMOVE = 'counter/REMOVE';
const INCREMENT = 'counter/INCREMENT';
const DECREMENT = 'counter/DECREMENT';
const SET_COLOR = 'counter/SET_COLOR';

이제 방금 만든 액션타입들을 가지고 createAction 을 통하여 액션생성자들을 만들겠습니다. 이 액션생성자들은 전부 밖으로 내보내주어야 하니 export키워드를 사용하세요.

// 액션 생성자
export const create = createAction(CREATE); // color
export const remove = createAction(REMOVE); 
export const increment = createAction(INCREMENT); // index
export const decrement = createAction(DECREMENT); // index
export const setColor = createAction(SET_COLOR); // { index, color }

해당 액션생성자들이, 어떤 파라미터를 받아야하는지, 주석에 메모로 달아둡니다.

다음, 초기상태를 리듀서 파일에서 복사해오세요.

// 초기 상태를 정의합니다
const initialState = Map({
    counters: List([
        Map({
            color: 'black',
            number: 0
        })
    ])
});

그리고, handleActions 를 통하여 리듀서의 틀을 만들어주겠습니다.

export default handleActions({
    [CREATE]: (state, action) => state,
    [REMOVE]: (state, action) => state,
    [INCREMENT]: (state, action) => state,
    [DECREMENT]: (state, action) => state,
    [SET_COLOR]: (state, action) => state,
}, initialState);

우리의 액션타입에는 접두사가 들어가있기 때문에 그냥 CREATE: 를 하면 안되고, [CREATE]: 로 해주어야합니다.

그 다음에는, 기존에 작성했던 리듀서를 참조하여 하나하나 구현해보세요. SET_COLOR 을 제외한 액션들에서는 파라미터들의 이름이 payload 로 통합되었습니다.

SET_COLOR 의 경우엔 index 와 color 값이 payload 객체 안에 들어가있기에 action.payload.index 이런식으로 작성을 해야하구요

그리고 이전 리듀서에서는 switch 를 사용했기에 counters 라는 공유되는 변수가 있었지만, 이젠 각 함수마다 counters 를 준비해주세요.

export default handleActions({
    [CREATE]: (state, action) => {
        const counters = state.get('counters');

        return state.set('counters', counters.push(
            Map({
                color: action.payload,
                number: 0
            })
        ))
    },

    [REMOVE]: (state, action) => {
        const counters = state.get('counters');

        return state.set('counters', counters.pop())
    },

    [INCREMENT]: (state, action) => {
        const counters = state.get('counters');

        return state.set('counters', counters.update(
            action.payload, 
            (counter) => counter.set('number', counter.get('number') + 1))
        );
    },

    [DECREMENT]: (state, action) => {
        const counters = state.get('counters');

        return state.set('counters', counters.update(
            action.payload, 
            (counter) => counter.set('number', counter.get('number') - 1))
        );
    },

    [SET_COLOR]: (state, action) => {
        const counters = state.get('counters');

        return state.set('counters', counters.update(
            action.payload.index, 
            (counter) => counter.set('color', action.payload.color))
        );
    },
}, initialState);

변화 반영

이제 actions, reducers 디렉토리는 쓸모없어졌으니 삭제하세요.

그리고, src 디렉토리의 index.js 에서, ./reducers 파일을 불러오는 대신에 ./modules 를 불러오세요

src/index.js

import reducers from './modules';

다음, App.jsCounterListContainer.js 컴포넌트에서도 ./actions 대신에 ./modules 를불러오세요.

src/containers/App.js

src/containers/CounterListContainer.js

import * as actions from '../modules';

자, 이제 카운터가 정상적으로 작동하는지 확인하세요.