Redux 를 통한 React 어플리케이션 상태 관리 :: 3장. Immutable.js 익히기


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

3장. Immutable.js 익히기

Immutable.js는 자바스크립트상에서 불변성의 데이터를 다루는것을 도와줍니다. 3장을 진행하기에 앞서, 우선 자바스크립트에서 객체의 불변성에 대해서 알아보겠습니다.

객체의 불변성

이를 이해하려면, 간단한 자바스크립트 코드들을 실행해보아야합니다. 크롬에서 개발자도구를 열고, 다음 코드를 입력해보세요:

let a = 7;
let b = 7;

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

보시다시피, a와 b의 값은 같습니다. 이를 === 연산자로 비교해보면, 당연히 true 가 반환됩니다. 하지만 object1 과 object2 은, 지니고 있는 값은 같지만, 서로 다른 객체이기때문에 이를 비교하면 false가 반환될것입니다.

object1 === object2
// false

하지만, 이런 코드는 어떨까요?

let object3 = object1

이제 object3 과 object1 을 비교한다면, true 가 반환될거에요.

object1 === object3
// true

그렇다면, 다음 코드를 실행하고 나서 비교를 한다면 어떨까요?

object3.c = 3;

여전히 결과는 true 를 반환할것입니다. 그 이유는 object1과 object3은 서로 같은 객체를 가르키기 때문이죠.

object1 === object3
// true
object1
// Object { a: 1, b: 2, c: 3 }

보시다시피 object1 에도 c 값이 생성이 됐죠?

다른 예제로, 다음 코드는 어떨까요?

let array1 = [0,1,2,3,4];
let array2 = array1;
array2.push(5);

array2 에 5를 삽입하고 array1 과 array2를 비교하면 무엇이 나올까요?

array1 === array2
// true

예상했겠지만 이번에도 여전히 true 입니다.

하지만, 리액트 컴포넌트에서는 state 혹은 상위 컴포넌트에서 전달받은 props 의 값이 변할 때 리렌더링을 해야하는데, 만약에 배열혹은 객체를 직접적으로 수정한다면, 내부의 값이 수정됐을지라도 레퍼런스가 가르키는곳은 같기때문에 똑같은 값으로 인식합니다.

이러한 이슈 때문에, 우리가 지금까지 여러층으로 구성된 객체 혹은 배열을 업데이트 해야 할 때, ... 를 사용해서 기존의 값을 가지고있는 새 객체 혹은 배열을 만들었던 것 입니다.

하지만 이 작업을 하다보면, 간단한걸 하자고 코드가 복잡해질때도있습니다. 예를 들어 수정해야 할 값이 객체의 깊은곳에 위치한다면 다음과 같은 형식으로 해야겠지요:

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
        }
    }
}

우리가 객체의 불변성을 유지 할 필요가 없다면 다음과 같이 간단하게 해도 되는데 말입니다:

object1.d.f.h = 10;

배열을 다룰때도 마찬가지로, 배열안에 있는 값을 수정하려면 수정하려는 원소 위치의 전후를 slice 로 가져와야하는데, 여간 귀찮은 일이 아닙니다.

이런 작업을 간소화 하기 위하여, 페이스북팀이 만든 라이브러리가 바로, 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

어때요? 훨씬 짧아졌고 보기 쉬워졌지요?

이번 장에서는 Immutable.js 의 기본적인것들을 알아보고 우리의 멀티 카운터에 이를 적용해보도록 하겠습니다.

 

3.1 Map

Immutable의 Map 은 객체 대신 사용되는 데이터 구입니다. 이는 JavaScript 에 내장되어있는 Map 과는 다릅니다.

실습 준비

Immutable을 리액트 프로젝트에 설치해서 사용해보기 전에, 먼저 JSBin 에서 CDN을 통하여 불러온 후 간단한 사용법을 먼저 익혀보도록 하겠습니다.

http://jsbin.com/ 에 들어가서, 상단의 Add Library 를 눌러 Immutable 을 추가하세요 (하단부에 있습니다.)

Immutable을 추가하고 나면 html 섹션에 다음 코드가 추가됩니다

<script src="https://cdnjs.cloudflare.com/ajax/libs/immutable/3.7.3/immutable.min.js"></script>

그 다음엔, 상단의 버튼들을 눌러 JavaScript / Console 을 제외한 섹션들을 숨기세요.

사용해보기

한번 Map 을 생성해보도록 하겠습니다.

var Map = Immutable.Map;

var data = Map({
  a: 1,
  b: 2
})

위와 같이, Map() 함수 안에 객체를 넣어서 호출하면됩니다.

이번엔, 러 층으로 구성된 객체를 Map으로 만들어보겠습니다.

var Map = Immutable.Map;

var data = Map({
  a: 1,
  b: 2,
  c: Map({
    d: 3,
    e: 4,
    f: 5
  })
})

위와 같이, 객체 내부에있는 객체들도 Map 으로 감싸주어야 합니다.

혹은, 다음과 같이 fromJS 를 사용해도 됩니다.

var fromJS = Immutable.fromJS;

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

fromJS 는 내부의 객체들도 Map으로 변환해줍니다.

자, 이렇게 만든 Map 을 한번 콘솔로 프린트해보세요.

코드를 입력 후 우측 상단의 Run 을 눌러야 실행됩니다.

Map을 그대로 프린트 할 수는 없습니다.

이런것도 할 수 없습니다.

console.log(data.a);
// undefined

Map 으로 만든건 일반 객체가 아니므로 다른 방법으로 다뤄줘야 합니다.

자바스크립트 객체로 변환하기

우선, Map 을 일반 객체로 변형하는 방법은 다음과 같습니다:

data.toJS(); // { a:1, b:2, c: { d: 3, e: 4 } }

toJS() 함수는 Map 을 일반 자바스크립트 객체형태로 변화시켜줍니다.

특정 키의 값 불러오기

그리고, Map 의 특정 키의 값을 불러올땐, 다음과 같습니다:

data.get('a'); // 1

get() 함수는 파라미터로 전달된 key 의 값을 가져옵니다.

깊숙한 값 불러오기

더욱 깊숙히 들어있는 값을 가져와야 할땐 다음과 같이합니다:

data.getIn(['c', 'd']) // 3

값 설정하기

값을 설정할때에는 get 대신에 set 을 사용하면 됩니다.

var newData = data.set('a', 4);

주의: set을 한다고해서, data가 변하는게 아닙니다. 주어진 변화를 적용한 새 Map을 만듭니다.

깊숙한 값 설정하기

깊숙히 들어있는 값을 설정해야할땐 setIn 을 사용하면 됩니다:

var newData = data.setIn(['c', 'd'], 10);

여러개의 값 설정하기

여러개의 값을 동시에 설정 할 때에는 mergeIn 을 사용합니다.

예를 들어서, c.d 와 c.e 의 값을 동시에 바꿔야 하는 경우에는, 다음과 같이 하면 됩니다.

var newData = data.mergeIn(['c'], { d: 10, e: 10 });

이렇게 했을 때, c.f 의 값은 그대로 유지 됩니다.

다음과 같이 해도 좋습니다.

var newData = data.setIn(['c', 'd'], 10);
                  .setIn(['c', 'e'], 10);

만약에 최상위에서 merge를 해야 할땐 다음과 같이 합니다:

var newData = data.merge({ a: 10, b: 10 })

set 을 여러번 할 지, 아니면 merge 를 할진는 그때 그때 상황에 맞춰서 하면 되지만, 성능상으로는 set 을 여러번 하는것이 더 빠릅니다.(링크)

전체 객체를 업데이트 해야 하는 경우에는, merge 를 사용하는 것 보단 다음처럼 하는것이 좋습니다.

var newData = data.set('c', Map({ d: 10, e: 10, f: 10 }))

 

3-2. List

List 는 배열 대신 사용되는 데이터 구조 입니다. 배열과 동일하게, map, filter, sort, push, pop 함수를 내장하고있습니다. 이 내장함수들이 실행되면, 또 다른 List 를 반환한다는점, 꼭 기억하세요.

리액트는 컴포넌트는 Immutable 의 List 와 호환이 되기 때문에, 이를 map 해도 컴포넌트로 렌더링 해 줄 수 있습니다.

생성하기

List 를 생성 할 땐 다음과 같이 합니다.

var List = Immutable.List;

var list = List([0,1,2,3,4]);

만약에 객체들의 배열이라면 다음과 같이 해줘야 추후 getset 등을 할 수 있습니다.

var List = Immutable.List;
var Map = Immutable.Map;
var fromJS = Immutable.fromJS;

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

// or

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

Map 과 마찬가지로 ListtoJS 를 통하여 일반 배열로 변환 할 수 있습니다.

console.log(list.toJS());

값 읽어오기

n 번째 아이템을 읽어올땐 다음과 같이 하면 됩니다: .get(n)

list.get(0);

0번째 아이템의 value 값을 읽어올 땐 다음과 같이 하면 되지요.

list.getIn([0, 'value']);

아이템 수정하기

n 번째 아이템을 수정해야 할땐 다음과 같이 하면 됩니다.

var newList = list.setIn([0, 'value'], 10);

다른 방법으로는, 이렇게 해도 됩니다.

var newList = list.update(
  1, 
  item => item.set('value', item.get('value') * 5)
)

값을 업데이트 해야 하는데, 기존 값을 참조해야 하는 경우엔 위와 같 update 를 사용하면 편합니다.

만약에 update 를 사용하지 않았더라면, 다음과 같이 해야겠죠?

var newList = list.setIn([1, 'value'], list.getIn([1, 'value']) * 5);

상황에 따라 여러분이 편리한 방식을 선택하시면 되겠습니다.

만약에 내부 값을 수정하는게 아니라 그 아이템 자체를 수정하는거라면 set 을 쓰면 되겠죠?

list.set(0, Map({ value: 10 }))

아이템 추가하기

아이템을 추가 할 땐, push 를 쓰면 됩니다. 이 함수를 쓴다고 해서 Array 처럼 기존 List 를 수정하는건 아니니, 안심하고 사용해도 됩니다.

var newList = list.push(Map({value: 3}))

맨 앞에 넣고 싶다면, unshift 를 사용하세요

var newList = list.unshift(Map({value: 0}))

아이템 제거하기

아이템을 제거 할 땐, delete를 사용하면 됩니다.

var newList = list.delete(1);

이렇게하면 인덱스가 1인 아이템을 제거해주죠.

pop 을 하면 가장 마지막에 있는 아이템을 없애줍니다.

var newList = list.pop();

크기 가져오기

List 의 크기를 가져올땐 size 를 사용합니다.

console.log(list.size);

비어있는지 확인 할 때는 isEmpty() 를 사용하세요.

list.isEmpty()

자, Immutable 에서 가장 많이 사용되는 데이터구조 Map 과 List 에 대해서 배웠습니다. 이 외에도 다른 데이터 구조들이 있는데 이 두개만 알고있어도 리덕스에서 사용하는데에는 충분합니다. 여기서 아직 다루지 못한 함수들도 꽤 있는데 관심이 있다면 공식문서 를 읽어보시길 바랍니다.

이제, 우리가 지금까지 배운것들을 가지고, 리덕스에서 사용해보도록 하겠습니다.

 

3-3. 리덕스에서 사용하기

자, 리덕스에서 Immutable 을 사용하는 방법을 알아보겠습니다.

기존에 만들었던 프로젝트에서 우리가 배웠던것들을 적용해볼게요.

설치

우선, immutable 을 설치하세요.

yarn add immutable

리듀서 수정

이제 리듀서에서 immutable 을 불러오고 사용을 해보겠습니다.

src/reducers/index.js

우선 코드의 상단에서 Map 과 List 를 불러오세요.

import { Map, List } from immutable;

그 다음, initialState 를 Map 과 List 를 사용해서 만드세요.

const initialState = Map({
    counters: List([
        Map({
            color: 'black',
            number: 0
        })
    ])
})

{ } 은 Map 으로, [ ] 은 List 로 하시면 됩니다.

자, 이제 리듀서를 전체적으로 처음부터 작성할것입니다. 걱정하지마세요, immutable과 함께라면 금방 합니다.

// 리듀서 함수를 정의합니다. 
function counter(state = initialState, action) {
    const counters = state.get('counters');

    switch(action.type) {
        // 카운터를 새로 추가합니다
        case types.CREATE:
            return state.set('counters', counters.push(Map({
                color: action.color,
                number: 0
            })))
        // slice 를 이용하여 맨 마지막 카운터를 제외시킵니다
        case types.REMOVE:
            return state.set('counters', counters.pop());

        // action.index 번째 카운터의 number 에 1 을 더합니다.
        case types.INCREMENT:
            return state.set('counters', counters.update(
                action.index, 
                (counter) => counter.set('number', counter.get('number') + 1))
            );

        // action.index 번째 카운터의 number 에 1 을 뺍니다
        case types.DECREMENT:
            return state.set('counters', counters.update(
                action.index, 
                (counter) => counter.set('number', counter.get('number') - 1))
            );

        // action.index 번째 카운터의 색상을 변경합니다
        case types.SET_COLOR:
            return state.set('counters', counters.update(
                action.index, 
                (counter) => counter.set('color', action.color))
            );
        default:
            return state;
    }
};

어때요? 기존 코드보다 훨씬 간결해지고, 읽기도 쉬워졌지요?

그럼 전체코드를 확인해볼까요?

import { Map, List } from 'immutable';
import * as types from '../actions/ActionTypes';

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

// 리듀서 함수를 정의합니다. 
function counter(state = initialState, action) {
    const counters = state.get('counters');

    switch(action.type) {
        // 카운터를 새로 추가합니다
        case types.CREATE:
            return state.set('counters', counters.push(Map({
                color: action.color,
                number: 0
            })))
        // pop 을 사용하여 맨 마지막 카운터를 제거합니
        case types.REMOVE:
            return state.set('counters', counters.pop());

        // action.index 번째 카운터의 number 에 1 을 더합니다.
        case types.INCREMENT:
            return state.set('counters', counters.update(
                action.index, 
                (counter) => counter.set('number', counter.get('number') + 1))
            );

        // action.index 번째 카운터의 number 에 1 을 뺍니다
        case types.DECREMENT:
            return state.set('counters', counters.update(
                action.index, 
                (counter) => counter.set('number', counter.get('number') - 1))
            );

        // action.index 번째 카운터의 색상을 변경합니다
        case types.SET_COLOR:
            return state.set('counters', counters.update(
                action.index, 
                (counter) => counter.set('color', action.color))
            );
        default:
            return state;
    }
};

export default counter;

컴포넌트 수정

리듀서에서 이제 immutable 을 사용하니, 컴포넌트에서도 이를 반영시켜주어야겠죠?

src/containers/CounterListContainer.js

CounterListContainer 컴포넌트의 mapStateToProps 에서, state.counters 대신 state.get('counters')로 수정하세요.

// store 안의 state 값을 props 로 연결해줍니다.
const mapStateToProps = (state) => ({
    counters: state.get('counters')
});

src/components/CounterList.js

다음, CounterList 컴포넌트에서 배열을 매핑하는 과정에서 {...counter}{...counter.toJS()}로 수정하고, propType 도 PropTypes.instanceOf(List) 로 수정하세요.

import React from 'react';
import Counter from './Counter';
import PropTypes from 'prop-types';
import { List } from 'immutable';

import './CounterList.css';

const CounterList = ({counters, onIncrement, onDecrement, onSetColor}) => {

    const counterList = counters.map(
        (counter, i) => (
            <Counter 
                key={i}
                index={i}
                {...counter.toJS()}
                onIncrement={onIncrement}
                onDecrement={onDecrement}
                onSetColor={onSetColor}
            />
        )
    );

    return (
        <div className="CounterList">
            {counterList}
        </div>
    );
};

CounterList.propTypes = {
    counters: PropTypes.instanceOf(List),
    onIncrement: PropTypes.func,
    onDecrement: PropTypes.func,
    onSetColor: PropTypes.func
};

CounterList.defaultProps = {
    counters: [],
    onIncrement: () => console.warn('onIncrement not defined'),
    onDecrement: () => console.warn('onDecrement not defined'),
    onSetColor: () => console.warn('onSetColor not defined')
}

export default CounterList;

이제 다 끝났습니다! 카운터가 제대로 작동하는지 확인해보세요.

여기까지의 코드는 GitHub 에서 확인 하실 수 있습니다.

  • deprecated w

    강의 너무 잘보고 있어요 !! 감사합니다.

    혹시 graphql과 mongoose두 velopert님이 쓰신걸로 자세히 배우고싶은데 올리실 계획이 있으신가요?! ^^!

    • 네! 계획있습니다 ㅎㅎㅎ
      mongoose 는 좀 이전에 쓴 강좌이긴 하지만 https://velopert.com/594 에 있어요.

      시간만 있으면 graphql 도 강의를 제대로 작성해볼 예정이에요. 기다려주세요~

      • deprecated w

        네 감사합니다.

  • DaekyuLee

    강의 잘 보구 있습니다. 오탈자 제보 입니다.
    import { Map, List } from immutable;
    -> import {Map, List} from ‘immutable’;