All Articles

[Redux 2탄] Immutable.js, Ducks 파일 구조, redux-actions


Redux 2탄

앞에는 리액트에서 리덕스를 사용하는 방법을 정석대로 배워 보았습니다.

멀티카운터를 만들면서 조금은 불편했습니다. 예를 들어 액션을 만들 때마다 세 가지 파일(액션 타입, 액션 생성 함수, 리듀서)를 수정해야 한다는 점, 전개 연산자(…)와 slice 함수로 배열 내부의 원소를 수정하는 데 가독성이 낮다는 점 등입니다.

2탄에서는 이런 불편한 점을 해결하여 더욱 편하게 리덕스를 사용하는 방법을 알아보겠습니다.


Immutable.js 익히기

Immutable.js는 자바스크립트에서 불변성 데이터를 다룰 수 있도록 도와줍니다. [ 공식 문서(https://facebook.github.io/immutable-js/) ]

객체 불변성이란 밑에 예제를 보고 알아봅시다.

let object1 = { a: 1, b:2 };
let object2 = { a: 1, b:2 };

object1 === object2  //false

let object3 = object1;

object1 === object3  //true

object1과 object3은 같은 객체를 가리키기 때문에 true를 반환합니다.

object3.c = 3;
object1 === object3  //true

object  // Object { a: 1, b: 2, c: 3}

object3에 c를 추가했는데, object1에도 c 값이 생성되었습니다.

이렇게 배열이나 객체를 직접 수정한다면 내부 값을 수정했을지라도 레퍼런스가 가리키는 곳은 같기 때문에 똑같은 값으로 인식합니다.

이런 이슈 때문에 지금까지 여러 층으로 구성된 객체 또는 배열을 업데이트해야 할 때, 전개 연산자(…)를 사용해서 기존 값을 가진 새 객체 또는 배열을 만들었던 것입니다.

하지만 그렇게 작업하다 보면 간단한 변경을 구현하는 데도 코드가 복잡할 때가 있습니다. 예를 들어 수정해야 할 값이 객체의 깊은 곳에 위치한다면 다음 형식으로 해야겠지요.

let object1 = {
  a: 1,
  b: 2,
  c: 3,
  d: {
    e: 4,
    f: {
      g: 5,
      h: 6
    }
  }
};

// h 값을 10으로 업데이트합니다.
let object2 = {
  ...object,
  d: {
    ...object.d,
    f: {
      ...object.d.f,
      h: 10
    }
  }
}

이런 작업들을 간소화하려고 페이스북 팀이 만든 라이브러리가 바로 Immutable.js 입니다. 이 라이브러리를 사용하면 이 코드는 다음 형식으로 작성할 수 있습니다.

let object1 = Map({
  a: 1,
  b: 2,
  c: 3,
  d: Map({
    e: 4,
    f: Map({
      g: 5,
      h: 6
    })
  })
});

let object2 = object1.setIn(['d','f','h'], 10);

object1 === object2;  //false

Map

Immutable의 Map은 객체 대신 사용하는 데이터 구조입니다.

Map을 사용해야 추후 setIn, getIn을 활용할 수 있습니다.

객체 내용을 네트워크에서 받아 오거나 전달받는 객체가 너무 복잡한 상태라면 일일이 그 내부까지 Map으로 만들기 힘들 수가 있습니다. 이때는 fromJS를 사용할 수 있습니다.

const { Map, fromJS } = Immutable;

const data = fromJS({
  a: 1,
  b: 2,
  c: {
    d: 3,
    e: 4,
    f: 5
  }
});

Immutable 객체의 내장 함수

  • 자바 스크립트 객체로 변환

    • Immutable 객체를 일반 객체 형태로 변형하는 방법은 다음과 같습니다.
    const deserialized = data.toJS();
    console.log(deserialized);
    // { a: 1, b: 2, c: { d: 3, e: 4 }}

  • 특정 키의 값 불러오기

    • 특정 키의 값을 불러올 때는 get 함수를 사용합니다.
    data.get('a'); // 1

  • 깊숙이 위치하는 값 불러오기

    • Map 내부에 또 Map이 존재하고, 그 Map 안에 있는 키 값을 불러올 때는 getIn 함수를 사용합니다.
    data.getIn(['c','d']); // 3

  • 값 설정

    • 새 값을 설정할 때는 get 대신 set을 사용합니다.
    const newData = data.set('a', 4);
    
    /*
    set을 한다고 해서 데이터가 실제로 변하는 것은 아닙니다.
    주어진 변화를 적용한 새 Map인 newData를 만드는 것입니다.
    */

  • 깊숙이 위치하는 값 수정

    • 깊숙이 위치하는 값을 수정할 때는 setIn을 사용합니다. 이때 내부에 있는 객체들도 Map 형태여야만 사용할 수 있다는 점에 주의하세요.
    const newData = data.setIn(['c','d'], 10);

  • 여러 값 동시에 설정

    • 값 여러 개를 동시에 설정해야 할 때는 mergeIn을 사용합니다. 예를 들어 c 값과 d 값, c 값과 e 값을 동시에 바꾸어야 할 때는 코드를 다음과 같이 입력합니다.
    const newData = data.mergeIn(['c'], { d: 10, e: 10 });
    
    const newData = data.setIn(['c', 'd'], 10)
                      .setIn(['c', 'e'], 10);

set을 여러 번 할지, 아니면 merge를 할지는 그때그때 상황에 맞추면 되지만, 성능상으로는 set을 여러 번 하는 것이 빠릅니다.

하지만 애초에 오래 걸리는 작업이 아니므로 실제 처리 시간의 차이는 매우 미미합니다. 어떤 방법을 택해도 상관없습니다.



List

List는 Immutable 데이터 구조로 배열 대신 사용합니다.

배열과 동일하게 map, filter, sort, push, pop 함수를 내장하고 있습니다.

이 내장 함수를 실행하면 List 자체를 변경하는 것이 아니라, 새로운 List를 반환한다는 것을 꼭 기억하세요 !

또 리액트 컴포넌트는 List 데이터 구조와 호환되기 때문에 map 함수를 사용하여 데이터가 들어있는 List를 컴포넌트 List로 변환하여 JSX에서 보여 주어도 제대로 렌더링됩니다.

  • 생성
const { List, Map, fromJS } = Immutable;

const list = List([
  Map({ value: 1 }),
  Map({ value: 2 })
]);

// or

const list2 = fromJS([
  { value: 1 },
  { value: 2 }
]);

fromJS를 사용하면 내부 배열은 List로 만들고, 내부 객체는 Map으로 만듭니다.


  • 값 읽어 오기

    // n 번째 원소 값을 get(n)을 사용하여 읽어 옵니다.
    list.get(0);
    
    // 0번째 아이템의 value 값은 다음과 같이 읽어 옵니다.
    list.getIn([0, 'value']);

  • 아이템 수정

    // 원소를 통째로 바꾸고 싶을 경우 - set
    const newList = list.set(0, Map({value: 10}));
    
    // List의 Map 내부 값을 변경하고 싶을 경우 - setIn
    const newList = list.setIn([0, 'value'], 10);
    
    // 기존 값을 참조해야 하는 경우 - update
    const newList = list.update(0, item => item.set('value', item.get('value') * 5));

  • 아이템 추가

    // 이 함수를 사용한다고 해서 Array처럼 기존 List 자체에 아이템을 추가하는 것은 아닙니다. 새 List를 만들어서 반환하므로 안심하고 사용하세요.
    
    const newList = list.push(Map({value: 3}));
    
    // 리스트 맨 뒤가 아니라 맨 앞에 데이터를 추가하고 싶다면 push 대신에 unshift를 사용합니다.
    const newList = list.unshift(Map({value: 0}));

  • 아이템 제거

    // 인덱스가 1인 아이템 제거
    const newList = list.delete(1);
    
    // 마지막 아이템 제거
    const newList = list.pop();

  • List 크기 가져오기

    // 배열 크기는 length를 참조하지만,
    // List에서는 size를 참조해야 합니다.
    console.log(list.size);
    
    // 비어 있는지 확인하고 싶다면, .isEmpty()
    list.isEmpty();


Ducks 파일 구조

리덕스에서 일반적으로 액션 타입, 액션 생성 함수, 리듀서 이렇게 세 종류로 분리하여 관리합니다.

이렇게 파일을 세 종류로 나누어 리덕스 관련 코드를 작성하다 보면 액션을 하나 만들 때마다 파일 세 개를 수정해야 합니다.

이처럼 ‘액션 타입, 액션 생성 함수, 리듀서를 모두 한 파일에서 모듈화하여 관리하면 어떨까?‘라는 아이디어로 만든 파일 구조가 바로 Ducks 파일 구조입니다.

이 파일 구조는 개발자인 erikras(Erik Rasmussen)가 처음으로 제시했는데(https://github.com/erikras/ducks-modular-redux), 많은 리액트 개발자에게 지지를 받았습니다.

예시

// 액션 타입
const CREATE = 'my-app/todos/CREATE';
const REMOVE = 'my-app/todos/REMOVE';
const TOGGLE = 'my-app/todos/TOGGLE';

// 액션 생성 함수
export const create = (todo) => ({
  type: CREATE,
  todo,
});

export const remove = (id) => ({
  type: REMOVE,
  id
});

export const toggle = (id) => ({
  type: TOGGLE,
  id
});

const initialState = {
  //초기 상태
}

// 리듀서
export default function reducer(state = initialState, action) {
  // 리듀서 관련 코드
}

규칙

Ducks 구조에서 지켜야 할 규칙

  • export default를 이용하여 리듀서를 내보내야 합니다.

  • export를 이용하여 액션 생성 함수를 내보내야 합니다.

  • 액션 타입 이름은 npm-module-or-app/reducer/ACTION_TYPE 형식으로 만들어야 합니다. (예: counter/INCREMENT - 맨 앞은 생략 가능)

  • 외부 리듀서에서 모듈의 액션 타입이 필요할 때는 액션 타입을 내보내도 됩니다.



redux-actions를 이용한 더 쉬운 액션 관리

redux-actions 패키지에는 리덕스 액션들을 관리할 때 유용한 createAction과 handleActions 함수가 있습니다.

설치

$ yarn add redux-actions

import { createAction, handleActions } from 'redux-actions'


createAction을 이용한 액션 생성 자동화

리덕스에서 액션을 만들다 보면 모든 액션에서 일일이 액션 생성자를 만드는 것이 번거로울 수 있습니다.

예를 들어 우리가 기존에 만든 increment와 decrement 코드를 다시 한 번 살펴봅시다.

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

이렇게 createAction을 생성할 수 있는데, 파라미터를 넣는 부분이 생략되어 있습니다.

이렇게 만든 함수에 파라미터를 넣어서 호출하면 다음과 같이 payload 키에 파라미터로 받은 값을 넣어 객체를 만들어 줍니다.

increment(3);  // 호출

/* 결과:
  {
    type: 'INCREMENT',
    payload: 3
  }
*/

전달받을 파라미터가 여러 개일 때는 객체를 만들어서 파라미터에 넣어 주세요. 액션을 만들면 파라미터로 전달한 객체를 payload로 설정합니다.

export const setColor = createAction(types.SET_COLOR);

setColor({index: 5, color: '#fff'})
/* 결과:
  {
    type: 'SET_COLOR',
    payload: {
      index: 5,
      color: #fff
    }
  }
*/

이렇듯, 액션이 갖고 있을 수 있는 정보의 이름을 payload 값으로 통일함으로써 액션 생성 함수를 한 줄짜리 코드로 작성했습니다.

어떤 파라미터를 받는지 명시하지 않았기 때문에 헷갈릴 수도 있겠죠? 다음과 같이 createAction의 두 번째 파라미터에 payload 생성 함수를 전달하여 코드상으로 명시해 줄 수 있습니다.

export const setColor = createAction(types.SET_COLOR, ({index, color}) => ({index, color}));


switch 문 대신 handleActions 사용

리듀서에 switch 문을 사용하여 액션 type에 따라 다른 작업을 하도록 했습니다.

이 방식에는 아주 중요한 결점이 하나 있는데, 바로 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});

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