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';

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

  • Ethan Choi

    안녕하세요~ 강의 정말정말 너무 잘 보고 있습니다!! react를 와.. 내가 할수 있을까 싶었는데 velopert님 강의 보면서 많이 성장하고 있음을 느낍니다. 한가지 궁금한게 있는데 버튼 중복 이벤트 방지는 어떻게 하나요? 저는 container에서 처리 하려고 하는데(flg값) createAction 메소드를 쓰니깐 이벤트 완료되었는지를 알수가 없더라구요.(아직 미천한 실력이라.. ㅜㅜ) 이전 강의 (memospace 프로젝트)에서는 redux-action 부분을 직접 구현하면서 axios를 사용하니깐 container에서 then으로 처리가능하던데 createAction을 사용할 경우에는 방법이 없나요??

    • 말씀하시는 부분이 비동기 액션일때 해당 되는 것 맞는지요?
      비동기 액션은 이번주중에 작성될 새 강의에서 다루게 될거에요 🙂

      비동기 액션이 아니라면 그냥 액션 실행하기 전에 현재 상태를 확인해서 하면 무시하면 되어요.
      비동기 액션의 경우엔 https://github.com/pburtchaell/redux-promise-middleware 를 사용하면 됩니다

      https://github.com/velopert/redux-pender/blob/master/docs/Comparison.md
      여기서 여러가지 방법으로 비동기 작업을 처리하는 방법을 비교해볼 수 있구요
      조만간 작성될 포스트에서는 제가 만든 redux-pender 라이브러리를 사용해서 비동기작업을 쉽게 처리하는 방법을 알아볼거에요 ^^

  • punisher

    안녕하세요 매번 강의 잘보고있습니다.
    혹시 이번 4장. Ducks 구조와 redux-actions 사용하기에 대한 코드는 git에 올라가있는건 없나요??

  • 김진관

    안녕하세요. 좋은 강의 감사합니다.
    ‘4-3. 적용하기’ 에서 increment(3) 이 실행된다면 아래 객체가 만들어지는 것 아닌가요?
    {
    type: ‘INCREMENT’,
    payload: 3
    }
    오타인 것 같은데 문의 드립니다.