이 포스트는 리덕스의 리도 모르는 독자들을 대상으로 작성된 글입니다. 리덕스가 왜 필요한지 알아보고, 리덕스를 편리하게 사용하기 위한 발악을 한번 해보겠습니다.
리덕스 왜 쓸까?
리액트애서 애플리케이션을 만들 때, 기본적으로는 보통 하나의 루트 컴포넌트 (App.js) 에서 상태를 관리합니다. 예를들어서, 투두리스트 프로젝트에서는, 다음과 같은 구조로 상태가 관리되고 있죠.
리액트 프로젝트에서는 대부분의 작업을 할 때 부모 컴포넌트가 중간자 역할을 합니다.
컴포넌트 끼리 직접 소통 하는 방법은 있긴 하지만, 그렇게 하면 코드가 굉장히 많이 꼬여버리기 때문에 절대 권장되지 않는 방식입니다. (ref 를 사용하서 이러한 작업을 할 수 있긴 하죠.)
App 에서는 인풋의 값인 input
값과, 이를 변경하는 onChange
함수와, 새 아이템을 생성하는 onCreate
함수를 props 로 Form 에게 전달해줍니다. Form 은 해당 함수와 값을 받아서 화면에 보여주고, 변경 이벤트가 일어나면 부모에게서 받은 onChange
를 호출하여 App 이 지닌 input
값을 업데이트 하죠.
그렇게 인풋 값을 수정하여 추가 버튼을 누르면, onCreate 를 호출하여 todos 배열을 업데이트 합니다.
todos 배열이 업데이트 되면, 해당 배열이 TodoItemList 컴포넌트한테 전달이 되어 화면에 렌더링 되죠.
이런식으로, App 컴포넌트를 거쳐서 건너건너 필요한 값을 업데이트 하고, 리렌더링 하는 방식으로 프로젝트가 개발됩니다.
이러한 구조는, 부모 컴포넌트에서 모든걸 관리하고 아래로 내려주는 것익 때문에, 매우 직관적이기도 하고, 관리하는 것도 꽤 편합니다. 그런데 문제는 앱의 규모가 커졌을 때 입니다.
보여지는 컴포넌트의 개수가 늘어나고, 다루는 데이터도 늘어나고, 그 데이터를 업데이트 하는 함수들도 늘어나겠죠. 그렇게 가다간 App 의 코드가 엄~~~~~ 청 나게 길어지고 이에 따라 유지보수 하는 것도 힘들 것입니다.
예를 들어 다음과 같은 구조의 프로젝트가 있다고 생각해봅시다.
Root 컴포넌트에서 G 컴포넌트에게 어떠한 값을 전달해 줘야 하는 상황에는 어떻게 해야 할까요?
A 를 거치고 E 를 거치고 G 를 거쳐야 합니다. 아! 근데 이걸 또 코드로 작성해가면서 해야하죠.
// App.js 에서 A 렌더링
<A value={5}>
// A.js 에서 E 렌더링
<E value={this.props.value} />
// B.js 에서 G 렌더링
<G value={this.props.value} />
그러다가 value 라는 이름을 anotherValue 라는 이름으로 바꾸는 일이 발생한다면요? 파일 3개를 열어서 다 수정해줘야하죠.
리덕스를 쓰면, 상태 관리를 컴포넌트 바깥에서 한다!
리덕스에 대한 설명은 여러가지 방식으로 할 수 있겠지만 저는 주로 이런 표현을 합니다. 리덕스를 사용하면 상태값을, 컴포넌트에 종속시키지 않고, 상태 관리를 컴포넌트의 바깥에서 관리 할 수 있게 됩니다.
그림으로 설명하자면 다음과 같은 구조죠.
예를 들어서 B 에서 일어나는 변화가 G 에 반영된다고 가정을 해봅시다.
스토어 설정
리덕스를 프로젝트에 적용하게 되면 이렇게 스토어 라는 녀석이 생깁니다. 스토어 안에는 프로젝트의 상태에 관한 데이터들이 담겨있죠.
컴포넌트의 스토어 구독
G 컴포넌트는 스토어에 구독을 합니다. 구독을 하는 과정에서, 특정 함수가 스토어한테 전달이 됩니다. 그리고 나중에 스토어의 상태값에 변동이 생긴다면 전달 받았던 함수를 호출해줍니다.
스토어에 상태 변경하라고 알려주기
이제 B 컴포넌트에서 어떤 이벤트가 생겨서, 상태를 변화 할 일이 생겼습니다. 이 때 dispatch 라는 함수를 통하여 액션을 스토어한테 던져줍니다. 액션은 상태에 변화를 일으킬 때 참조 할 수 있는 객체입니다. 액션 객체는 필수적으로 type 라는 값을 가지고 있어야 합니다.
예를들어 { type: 'INCREMENT' }
이런 객체를 전달 받게 된다면, 리덕스 스토어는 아~ 상태에 값을 더해야 하는구나~ 하고 액션을 참조하게 됩니다.
추가적으로, 상태값에 2를 더해야 한다면, 이러한 액션 객체를 만들게 됩니다: { type: 'INCREMENT', diff: 2 }
그러면, 나중에 이 diff 값을 참고해서 기존 값에 2를 더하게되겠죠. type 를 제외한 값은 선택적(optional) 인 값입니다. 액션에 대해선 나중에 더 자세히 알아볼게요.
리듀서를 통하여 상태를 변화시키기
액션 객체를 받으면 전달받은 액션의 타입에 따라 어떻게 상태를 업데이트 해야 할지 정의를 해줘야겠죠? 이러한 업데이트 로직을 정의하는 함수를 리듀서라고 부릅니다. 이 함수는 나중에 우리가 직접 구현하게 됩니다. 예를들어 type 이 INCREMENT 라는 액션이 들어오면 숫자를 더해주고, DECREMENT 라는 액션이 들어오면 숫자를 감소시키는 그런 작업을 여기서 하면 되죠.
리듀서 함수는 두가지의 파라미터를 받습니다.
- state: 현재 상태
- action: 액션 객체
그리고, 이 두가지 파라미터를 참조하여, 새로운 상태 객체를 만들어서 이를 반환합니다.
상태가 변화가 생기면, 구독하고 있던 컴포넌트에게 알림
상태에 변화가 생기면, 이전에 컴포넌트가 스토어한테 구독 할 때 전달해줬었던 함수 listener 가 호출됩니다. 이를 통하여 컴포넌트는 새로운 상태를 받게되고, 이에 따라 컴포넌트는 리렌더링을 하게 되죠.
정리
정리하자면 이렇습니다. 기존에는 부모에서 자식의 자식의 자식까지 상태가 흘렀었는데, 리덕스를 사용하면 스토어를 사용하여 상태를 컴포넌트 구조의 바깥에 두고, 스토어를 중간자로 두고 상태를 업데이트 하거나, 새로운 상태를 전달받습니다. 따라서, 여러 컴포넌트를 거쳐서 받아올 필요 없이 아무리 깊숙한 컴포넌트에 있다 하더라도 직속 부모에게서 받아오는 것 처럼 원하는 상태값을 골라서 props 를 편리하게 받아올 수 있죠.
리액트 없이 쓰는 리덕스
리덕스는 리액트에 종속되는 그런 라이브러리가 아닙니다. 물론 리액트에서 쓰기위해 만든거니 궁합은 매우 잘맞죠! 한번, 평범한 HTML 와 JavaScript 환경에서 리덕스를 사용해가면서, 리덕스의 기본 개념을 배워봅시다.
우선, JSBin(https://jsbin.com/) 을 열으세요.
우리는 간단한 카운터를 구현해보겠습니다. HTML 섹션엔 다음과 같이 입력해보세요.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>그냥 평범한 리덕스</title>
</head>
<body>
<h1 id="number">0</h1>
<button id="increment">+</button>
<button id="decrement">-</button>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/3.6.0/redux.js"></script>
</body>
</html>
그럼 이러한 결과가 만들어집니다.
자, 이제 자바스크립트를 작성 할 것입니다! 주석을 아주~ 상세하게 적어놓았으니, 주석도 함께 읽어가면서 자바스크립트를 작성해보세요.
// 편의를 위하여 각 DOM 엘리먼트에 대한 레퍼런스를 만들어줍니다.
const elNumber = document.getElementById('number');
const btnIncrement = document.getElementById('increment');
const btnDecrement = document.getElementById('decrement');
// 액션 타입을 정의해줍니다.
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';
// 액션 객체를 만들어주는 액션 생성 함수
const increment = (diff) => ({ type: INCREMENT, diff: diff });
const decrement = () => ({ type: DECREMENT });
// 초기값을 설정합니다. 상태의 형태는 개발자 마음대로 입니다.
const initialState = {
number: 0
};
/*
이것은 리듀서 함수입니다.
state 와 action 을 파라미터로 받아옵니다.
그리고 그에 따라 다음 상태를 정의 한 다음에 반환해줍니다.
*/
// 여기에 state = initialState 는, 파라미터의 기본값을 지정해줍니다.
const counter = (state = initialState, action) => {
console.log(action);
switch(action.type) {
case INCREMENT:
return {
number: state.number + action.diff
};
case DECREMENT:
return {
number: state.number - 1
};
default:
return state;
}
}
// 스토어를 만들 땐 createStore 에 리듀서 함수를 넣어서 호출합니다.
const { createStore } = Redux;
const store = createStore(counter);
// 상태가 변경 될 때 마다 호출시킬 listener 함수입니다
const render = () => {
elNumber.innerText = store.getState().number;
console.log('내가 실행됨');
}
// 스토어에 구독을하고, 뭔가 변화가 있다면, render 함수를 실행합니다.
store.subscribe(render);
// 초기렌더링을 위하여 직접 실행시켜줍니다.
render();
// 버튼에 이벤트를 달아줍니다.
// 스토어에 변화를 일으키라고 할 때에는 dispatch 함수에 액션 객체를 넣어서 호출합니다.
btnIncrement.addEventListener('click', () => {
store.dispatch(increment(25));
})
btnDecrement.addEventListener('click', () => {
store.dispatch(decrement());
})
이제 버튼들을 눌러보시면 숫자가 변경 될 것입니다. 지금까지 한 작업들을 정리해봅시다.
- 액션타입을 만들어주었습니다.
- 그리고 각 액션타입들을 위한 액션 생성 함수를 만들었습니다. 액션 함수를 만드는 이유는 그때 그때 액션을 만들 때마다 직접
{ 이러한 객체 }
형식으로 객체를 일일히 생성하는 것이 번거롭기 때문에 이를 함수화 한 것입니다. 나중에는 특히, 액션에 다양한 파라미터가 필요해 질 때 유용합니다. - 변화를 일으켜주는 함수, 리듀서를 정의해주었습니다. 이 함수에서는 각 액션타입마다, 액션이 들어오면 어떠한 변화를 일으킬지 정의합니다. 지금의 경우에는 상태 객체에 number 라는 값이 들어져있습니다. 변화를 일으킬 때에는 불변성을 유지시켜주어야 합니다.
- 스토어를 만들었습니다. 스토어를 만들 땐 createStore 를 사용하며 만듭니다. createStore 에는 리듀서가 들어갑니다. (스토어의 초기상태, 그리고 미들웨어도 넣을 수 있습니다.)
- 스토어에 변화가 생길 때 마다 실행시킬 리스너 함수 render 를 만들어주고,
store.subscribe
를 통하여 등록해주었습니다. - 각 버튼의 클릭이벤트에,
store.dispatch
를 사용하여 액션을 넣어주었습니다.
리덕스의 작동 흐름에 대하여 조금 감이 잡히셨나요? 그럼 이제 리액트에서 리덕스를 사용해봅시다!