Redux 를 통한 React 어플리케이션 상태 관리 :: 2장. 멀티카운터 만들기


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

2장. 멀티카운터 만들기

자, 이제 카운터의 갯수를 늘릴 차례입니다. 그렇게 어려워지진 않습니다. 기존에 우리가 투두리스트를 만들고, 주소록을 만들었던것과 같은 원리로 만들면 됩니다. 각 카운터가 지니고 있는 값을 객체로 만들어서 배열에 여러 카운터의 객체들을 넣는거죠.

그러기 위해선, 우리가 기존에 작성했던 액션, 리듀서들을 많이 뜯어 고쳐야합니다.

그럼, 시작해봅시다!

이번 프로젝트에서 작성할 코드들은 여기서 열람 할 수 있습니다.

 

2-1. Redux 개발자 도구 사용하기

멀티카운터 만들기를 진행하기 전에, 리덕스에서 액션이 dispatch 될 때마다 로그를 보고, 또 그 전 상태로 돌아갈수도 있게 해주는 리덕스 개발자도구에 대해서 알아보겠습니다.

확장 프로그램 설치

우선, 리덕스 개발자도구를 사용하려면 크롬이나 파이어폭스를 사용하셔야합니다. 여기를 눌러 크롬 확장프로그램을 설치하세요. (파이어폭스는 여기)

설치를 하고 나면, 개발자도구를 열었을 때, 개발자도구의 상단 탭의 우측에 Redux 가 나타나게 됩니다.

하지만, 설치를 한다고해서 바로 사용 할 수 있는것은 아닙니다. 프로젝트에서 따로 설정을 해야, 개발자도구에서 리덕스의 스토어에 접근을 할 수 있게 됩니다.

프로젝트에서 개발자도구를 위한 설정하기

src 디렉토리의 index.js 파일을 열어서 스토어를 생성하는 코드를 다음과 같이 수정하세요.

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './containers/App';
import './index.css';

// Redux 관련 불러오기
import { createStore } from 'redux'
import reducers from './reducers';
import { Provider } from 'react-redux';

// 스토어 생성
const store = createStore(reducers, window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__());

ReactDOM.render(
    <Provider store={store}>
        <App />
    </Provider>,
    document.getElementById('root')
);

그러면, 이제 액션이 발생할때마다 차이도 쉽게 확인 할 수 있고, 현재 상태도 조사할수있습니다.

 

 

2-2. Actions 고치기

여러개의 카운터를 동시에 다루기 위해서, 먼저 액션들을 고치겠습니다. 우선 ActionTypes 부터 고치겠습니다. 이번에 카운터를 추가하고, 제거하는 CREATEREMOVE 이 추가되었습니다.

src/actions/ActionTypes.js

/* 
 Action 의 종류들을 선언합니다.
 앞에 export 를 붙임으로서, 나중에 이것들을 불러올 때, 
 import * as types from './ActionTypes' 를 할 수 있어요.
*/

export const CREATE = 'CREATE';
export const REMOVE = 'REMOVE';

export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const SET_COLOR = 'SET_COLOR';

그 다음에, 액션 생성자들을 수정할건데요, 방금 만들었던 CREATE, REMOVE 를 위한 액션생성자를 만들어주고, 기존 액션생성자들은 전체적으로 수정되어야합니다. 해당 액션들이 특정 카운터를 조작하도록 설정해야하므로 index 값이 액션객체에 포함되어야 합니다.

src/actions/index.js

/*
    action 객체를 만드는 액션 생성자들을 선언합니다. (action creators)
    여기서 () => ({}) 은, function() { return { } } 와 동일한 의미입니다.
    scope 이슈와 관계 없이 편의상 사용되었습니다.
*/

import * as types from './ActionTypes';

export const create = (color) => ({
    type: types.CREATE,
    color
});

export const remove = () => ({
    type: types.REMOVE
});

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

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

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

예를들어 increment(3) 으로 만들어진 액션은, index 가 3인 카운터의 값을 1씩 올리는거죠.

자, 이제 리듀서를 수정하겠습니다.

 

 

2-3. Reducers 고치기

이제 리듀서를 고칠 차례입니다.

1장에서 만들었던 리듀서들과 작동방식이 다르므로, reducers 디렉토리 안에 있는 기존에 만들었던 color.js, number.js 를 삭제하고, index.js 파일은 내용을 비우고 새로 작성하세요.

우선 초기상태부터 정의를 해보겠습니다.

src/reducers/index.js

import * as types from '../actions/ActionTypes';

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

상태안에 counters 라는 배열을 만들고, 그 안에 color, number 값을 지니고있는 객체를 만들어서 넣어줬습니다.

리듀서 함수에서 추가 / 삭제 구현

src/reducers/index.js

import * as types from '../actions/ActionTypes';

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

// 리듀서 함수를 정의합니다. 
function counter(state = initialState, action) {
    // 레퍼런스 생성
    const { counters } = state;

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

        default:
            return state;
    }
};

export default counter

컴포넌트의 state 안에있는 배열을 다룰때와 동일하게, 기존 배열에 직접 push() 혹은 pop() 을 하면 안돼고, ... (spread 문법)을 사용하거나, .slice() 함수를 사용하여 배열을 잘라 새로 생성을 해야 합니다.

이 과정에서, state.counters 를 자주 사용해야하므로, 이를 줄여서 사용하여 코드를 줄이기 위해 상단에 레퍼런스를 만들어서 사용하면 코드가 더 깔끔해집니다.

앞으로 계속해서 구현 할 숫자 더하기, 빼기, 그리고 색상변경을 위한 부분들은, 코드가 조금 복잡해 보일수도 있지만, 위 코드의 똑같은 원리입니다.

리듀서 함수에 더하기, 빼기, 색상변경 구현

src/reducers/index.js

import * as types from '../actions/ActionTypes';

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

// 리듀서 함수를 정의합니다. 
function counter(state = initialState, action) {
    // 레퍼런스 생성
    const { counters } = state;

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

        // action.index 번째 카운터의 number 에 1 을 더합니다.
        case types.INCREMENT:
            return {
                counters: [
                    ...counters.slice(0, action.index),
                    {
                        ...counters[action.index],
                        number: counters[action.index].number + 1
                    },
                    ...counters.slice(action.index + 1, counters.length)
                ]
            };

        // action.index 번째 카운터의 number 에 1 을 뺍니다
        case types.DECREMENT:
            return {
                counters: [
                    ...counters.slice(0, action.index),
                    {
                        ...counters[action.index],
                        number: counters[action.index].number - 1
                    },
                    ...counters.slice(action.index + 1, counters.length)
                ]
            };

        // action.index 번째 카운터의 색상을 변경합니다
        case types.SET_COLOR:
            return {
                counters: [
                    ...counters.slice(0, action.index),
                    {
                        ...counters[action.index],
                        color: action.color
                    },
                    ...counters.slice(action.index + 1, counters.length)
                ]
            };
        default:
            return state;
    }
};

export default counter;

INCREMENT 한개만 이해하고나면, 나머지는 복사 붙이기하여 손쉽게 구현 할 수 있습니다.

한번 주석과 함께 살펴볼까요?

        case types.INCREMENT:
            return {
                counters: [
                    ...counters.slice(0, action.index), // 0 ~ action.index 사이의 아이템들을 잘라와서 이 자리에 넣는다
                    {
                        ...counters[action.index], // 기존 값은 유지하면서
                        number: counters[action.index].number + 1 // number 값을 덮어쓴다 
                    },
                    ...counters.slice(action.index + 1, counters.length) // action.index + 1 ~ 마지막까지 잘라온
                ]
            };

DECREMENT, SET_COLOR 도 똑같은 원리로 구현하면 됩니다.

배열을 바꾸거나, 이런 작업을 하는게, 그렇게 어려운 작업은 아니지만 간단한 작업 하나 하자고 코드를 필요 이상으로 많이 쓴다는 기분이 드는것은 사실입니다. 이번 멀티 카운터를 만들고 나서 immutable 이란 라이브러리를 사용해볼건데요. 이 라이브러리를 사용하면 위와 비슷한 작업을 더욱 가독성 높고 짧게 구현 할 수 있게 해줍니다.

이렇게 리듀서를 수정하고 나면, 기존 카운터가 제대로 작동하지 않을텐데, 이는 우리가 곧 고쳐나갈 것이니 당황하지 마세요.

 

 

2-4. 프리젠테이셔널 컴포넌트 만들기

이번 섹션에서는, 여러개의 카운터를 다루기 위해서, 카운터 생성/제거를 담당할 Buttons 컴포넌트와 여러개의 카운터를 렌더링해줄 CounterList 를 만들어보겠습니다.

생성/제거버튼 – Buttons 컴포넌트 만들기

이 컴포넌트는 두개의 버튼을 내장하고 있으며, 새 카운터를 생성하는 onCreate 함수, 그리고 맨 마지막 카운터를 제거시킬 onRemove 함수를 props 로 전달받습니다.

src/components/Buttons.js

import React from 'react';
import PropTypes from 'prop-types';

import './Buttons.css';

const Buttons = ({onCreate, onRemove}) => {
    return (
        <div className="Buttons">
            <div className="btn add" onClick={onCreate}>
                생성
            </div>
            <div className="btn remove" onClick={onRemove}>
                제거
            </div>
        </div>
    );
};

Buttons.propTypes = {
    onCreate: PropTypes.func,
    onRemove: PropTypes.func
};

Buttons.defaultProps = {
    onCreate: () => console.warn('onCreate not defined'),
    onRemove: () => console.warn('onRemove not defined')
};

export default Buttons;

src/components/Button.css

.Buttons {
    display: flex;
}

.Buttons .btn {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    height: 3rem;

    color: white;
    font-size: 1.5rem;
    cursor: pointer;
}

.Buttons .add {
    background: #37b24d;
}

.Buttons .add:hover {
    background: #40c057;
}

.Buttons .remove {
    background: #f03e3e;
}

.Buttons .remove:hover {
    background: #fa5252;
}

여러 카운터를 렌더링 – CounterList 컴포넌트 만들기

이제 여러 카운터들을 렌더링해줄 CounterList 컴포넌트를 만들어보겠습니다. 이 컴포넌트는 카운터 객체들의 배열 counters 와, 카운터를 조작하는 onIncrement, onDecrement, onSetColor 함수를 props로 전달받습니다.

countersCounter 컴포넌트 배열로 로 변환하는 과정에선, key 를 배열의 index 로 설정하고, index 값도 컴포넌트에 props로 전달을 해줍니다. 그리고, color 값과 number 값을 각각 설정하는 대신에, {...counter} 으로 객체를 풀어서 한꺼번에 전달해줄수도 있습니다.

src/components/CounterList.js

import React from 'react';
import Counter from './Counter';
import PropTypes from 'prop-types';

import './CounterList.css';

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

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

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

CounterList.propTypes = {
    counters: PropTypes.arrayOf(PropTypes.shape({
        color: PropTypes.string,
        number: PropTypes.number
    })),
    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;

src/components/CounterList.css

.CounterList {
    margin-top: 2rem;
    display: flex;
    justify-content: center;
    flex-wrap: wrap;
}

Counter 컴포넌트 수정하기

프로젝트의 작동방식이 바뀌었으니, Counter 컴포넌트를 수정해주겠습니다. 방금 CounterList 에서 전달한 index 를 각 이벤트가 실행 될 때 함수의 파라미터로 넣어서 실행하게 해주면 됩니다.

src/components/Counter.js

import React from 'react';
import PropTypes from 'prop-types';
import './Counter.css';

const Counter = ({number, color, index, onIncrement, onDecrement, onSetColor}) => {
    return (
        <div 
            className="Counter" 
            onClick={() => onIncrement(index)} 
            onContextMenu={
                (e) => { 
                    e.preventDefault(); 
                    onDecrement(index);
                }
            } 
            onDoubleClick={() => onSetColor(index)}
            style={{backgroundColor: color}}>
                {number}
        </div>
    );
};

Counter.propTypes = {
    index: PropTypes.number,
    number: PropTypes.number,
    color: PropTypes.string,
    onIncrement: PropTypes.func,
    onDecrement: PropTypes.func,
    onSetColor: PropTypes.func
};

Counter.defaultProps = {
    index: 0,
    number: 0,
    color: 'black',
    onIncrement: () => console.warn('onIncrement not defined'),
    onDecrement: () => console.warn('onDecrement not defined'),
    onSetColor: () => console.warn('onSetColor not defined')
};

export default Counter;

자, 프리젠테이셔널 컴포넌트들이 모두 완성되었습니다. 컴포넌트가 어떻게보여줄지 준비가 다 끝났으니, 데이터를 어떻게 넣어줄지 고민을 해봅시다.

 

2-5. 컨테이너 컴포넌트 만들기

우선, 기존의 컨테이너 컴포넌트인 CounterContainer 는 쓸모가 없어졌으니, 삭제를 해주세요.

우리가 이번에 만들 컨테이너 컴포넌트는 CounterListContainer 입니다. 그리고, Buttons 의 경우엔 따로 컨테이너 컴포넌트를 만들어주지 않고, App 컴포넌트를 redux 에 연결시켜서 액션함수들을 연결시켜주고 해당 함수들을 Buttons 컴포넌트에 전달해주도록 하겠습니다.

CounterListContainer 컴포넌트 만들기

우선 CounterListContainer 컴포넌트를 만들어주겠습니다. mapStateToProps 를 통해 props 를 연결해주고, mapDispatchToProps 를 통해 필요한 액션 함수들을 연결시켜주세요.

src/containers/CounterListContainer.js

import CounterList from '../components/CounterList';
import * as actions from '../actions';
import { connect } from 'react-redux';
import { getRandomColor } from '../utils';

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

/* 
    액션 생성자를 사용하여 액션을 생성하고,
    해당 액션을 dispatch 하는 함수를 만들은 후, 이를 props 로 연결해줍니다.
*/

const mapDispatchToProps = (dispatch) => ({
    onIncrement: (index) => dispatch(actions.increment(index)),
    onDecrement: (index) => dispatch(actions.decrement(index)),
    onSetColor: (index) => {
        const color = getRandomColor();
        dispatch(actions.setColor({ index, color}));
    }
})

// 데이터와 함수들이 props 로 붙은 컴포넌트 생성
const CounterListContainer = connect(
    mapStateToProps,
    mapDispatchToProps
)(CounterList);

export default CounterListContainer;

App 컴포넌트 수정하기

다음, App 컴포넌트를 리덕스에 연결시켜주겠습니다. 이 컴포넌트에서는 store에서 필요한 값이 없으니 mapStateToPropsnull 로 설정하고, 버튼을 위한 mapDispatchToProps 를 만드세요.

onCreateonRemove 를 만들고 이를 Button 컴포넌트의 props 로 전달하세요.

import React, {Component} from 'react';
import Buttons from '../components/Buttons';
import CounterListContainer from './CounterListContainer';

import { connect } from 'react-redux';
import * as actions from '../actions';

import { getRandomColor } from '../utils';

class App extends Component {
    render() {
        const { onCreate, onRemove } = this.props;
        return (
            <div className="App">
                <Buttons
                    onCreate={onCreate}
                    onRemove={onRemove}
                />
                <CounterListContainer/>
            </div>
        );
    }
}

// 액션함수 준비
const mapToDispatch = (dispatch) => ({
    onCreate: () => dispatch(actions.create(getRandomColor())),
    onRemove: (index) => dispatch(actions.remove(index))
});

// 리덕스에 연결을 시키고 내보낸다
export default connect(null, mapToDispatch)(App);

이번에는 이미 만들어진 컴포넌트를 불러와서 이를 리덕스에 연결시킨게 아니라 파일 하나에서 컴포넌트를 정의하고 바로 연결해주었습니다. 그럴땐, export 하는 부분에서 connect를 통해 리덕스에 연결을 시키면 되겠습니다.

 

여기까지 진행하시면, 다음과 같이 여러개의 카운터를 관리할수있게 됩니다!

지금까지의 코드는 여기서 열람 할 수 있습니다.

  • 감사합니다 😀

  • ej

    감사합니다!!!!!!!

  • Ethan Choi

    감사합니다~~ 강의 너무 잘 보고 잇어요!! ㅎㅎㅎ 궁금한게 있어서 그러는데 기존에는 components도 class 로 만드셧던데 const변수로 만드는 거랑 class로 만드는거랑 어떤차이가 잇나요?

  • loolaz

    안녕하세요. src/containers/CounterListContainer.js 소스의
    const mapStateToProps = (state) => ({
    counters: state.counters
    });
    코드에서요. 인자로 받은 state에 counters 라는 속성이 있다는 건 어디에 정의되어 있나요?
    저 코드만 보면 어떻게 인자로 받은 state에 counters 속성이 있는걸 알고 counters : state.counters 로 대입해 주는지 잘 모르겠습니다..

    • axisj

      저는 Reducers에 초기설정이 되어 있는 걸로 이해 했습니다.

  • Ethan Choi

    안녕하세요~ 궁금한게 있어서 질문을 남깁니다 ㅎㅎㅎ
    이전강의에서 reducer값을 반환할때 Object.assign함수를 사용하더군요 그런데 이 강의에서는 assgin함수 없이 return을 하는차이점이 뭔가요? ㅎㅎ그리고 react-addons-update의 update함수를 써서 return을 해도 되나요?

    • Chen Cristal

      차이가 별로 없는것 같아요. Object.assign 이나 return 을 써도 state 에 영향을 주지않는 새로운 state객체를 리턴한다는데서는 차이가 없다고 보는데요.. ㅠㅠ

  • 전승제

    최근에 이 글을 보실 분들을 위해 적습니다. 지금 자바스크립트와 웹개발 이 분야가 워낙 변화가 빨라서 두 세달 전 글이라도 달라질 수 있습니다. 이 글의 코드도 그대로 하면 안 돌아가는 부분이 조금 있는 것 같습니다. 다만 위의 깃허브 링크에 있는 코드는 잘 돌아가는게 확인됐습니다. 글을 읽으시고 코드는 깃헙에서 받아서 보시는게 좋을 것 같습니다.

    • julia

      저도 이 글 보고 했는데 제대로 안되길래 깃허브에서 다운받아서 코드를 비교를 해봤는데 제가 오타가 있어서 안됬던거더라구요.. 오타없이 제대로 따라하면 정상 작동됩니다!

  • 좋은 글 감사합니다^^

  • 홍종화

    한번만보는게아니라 한달에 한번씩 그냥 따라하다보면 내가 이만큼 늘었구나 라는게 느껴지게됩니다 감사합니다.

  • Oscar won

    감사합니당 🙂

  • 조부관

    늘 잘 보고 있습니다
    중간에 제가 코드를 잘 따라 치지 못한 부분이 한 군데 있어서
    그 부분을 찾는다고 시간을 많이 보냈네요

    일단은 저대로만 치면 문제 없이 돌아가기는 합니다
    import 시 경로가 안 맞을수도 있는데 그 부분은 다들 수정 할 줄 아시리라 생각합니다.