Vanilla 로 구현해보는 상태관리(리덕스)
2022. 07. 15.
#자바스크립트
해당 내용을 필자의 블로그에서 보고 싶다면 클릭
해당 내용의 코드는 github 에 있습니다.
해당 내용을 보기 전에 바닐라 자바스크립트로 리액트 흉내내기 1 - 웹 컴포넌트 글을 보시는 것을 권장합니다.
또한, 리덕스를 구현하기 위해 옵저버 패턴 사용했기 때문에 옵저버 패턴을 학습하시거나 제가 작성한 글을 보시는 것(후에 첨부 업데이트 예정) 을 권장합니다.
리덕스 상태 관리 만들기
리덕스 키워드 간단 설명
자세한 내용은 해당 블로그 를 참고하세요
액션
{ type: 'INCREMENT' } // 인자 추가 { type: "SET_DIFF", data: diff }
- 상태에 어떠한 변화가 필요할 때, 액션을 사용한다.
- 액션은 상태 변화를 알려줄 내용을 정의하는 것이다?
- type 은 필수이고, data 를 넣어 인자로 전달 가능
리듀서
function counter(state, action) { switch (action.type) { case 'INCREASE': return state + 1; case 'DECREASE': return state - 1; default: return state; } }
- 현재의 상태와, 전달 받은 액션을 참고해서 새로운 상태를 반환하는 함수
- INCREASE 액션이 오면 리듀서에서 새로운 상태를 반환한다.
return state + 1
디스패치
dispatch( { type: "INCREASE" } );
- 액션을 발생시키는 함수이다.
- 액션은 변화를 일으킬 때, 어떤 변화를 일으킬 지 정의하는 부분이고
리듀서는 액션에 따른 상태를 변화시킨다.
디스패치는 정의된 액션을 발생시키는 역할이라고 생각하면 된다.
즉, 디스패치가 액션을 발생시키면 리듀서를 통해 새로운 상태를 만들어준다.
스토어
- 한 App의 상태(state) 를 의미한다.
구독
subscribe(() => console.log("액션이 디스패치되면 실행");
- 액션이 디스패치 되었을 때 마다(상태가 변할 때 마다) 호출할 함수(콜백 함수)를 등록하는 기능
- 옵저버 패턴에서 구독하는 기능
옵저버 패턴
리덕스에 들어가기에 앞서 옵저버 패턴에 대해 간단하게 설명하고자 한다
옵저버 패턴(observer pattern)은 객체의 상태 변화를 관찰하는 관찰자들, 즉 옵저버들의 목록을 객체에 등록하여 상태 변화가 있을 때마다 메서드 등을 통해 객체가 직접 목록의 각 옵저버에게 통지하도록 하는 디자인 패턴이다.
→ 즉, 특정 주체(observavle) 가 옵저버들(observer)이 상태가 변할때마다 동작하는 메서드들을 관리하고, 상태가 변할 때마다 주체(Observable)에서 옵저버(observer)가 등록한 기능(메서드)들을 수행하게 하는 것이다.
간단하게 예를 들면
DOM 에서 이벤트를 처리하기 위해 addEventListener 방식도 일종의 옵저버 패턴을 사용하는 것과 같다.
$button1.addEventListener("click", () => console.log("button1 click"); $button2.addEventListener("click", () => console.log("button2 click");
이벤트 등록 함수는 특정 이벤트("click")가 발생했을 때 등록한 콜백 함수( () ⇒ ...) 가 동작하는 방식이다.
이때 $button1, button2 가 각 observer 가 이다. 해당 옵저버들은 상태가 변할 때 수행하는 메서드들을 등록한다. (위 addEventListener 에서 click 이 발생할 때마다 콜백 함수를 등록하는 것)
그리고 특정 주체(observable)가 옵저버들이 등록한 콜백 함수를 관리하고 있다가 상태가 변할 경우 옵저버들이 등록한 메서드들을 수행한다.
⇒ 즉, 상태가 변할 때마다(click 발생) 등록한 콜백 함수(() ⇒ console.log(..)) 를 수행한다.
여기까지 관련 지식들을 간단히 알아봤고 이제 리덕스에 대해 알아보고 적용해보자
리덕스
먼저 실제 리덕스에서 사용하는 방식에 대해 알아보자
실제 리덕스 사용 방법
import { createStore } from "redux"; // 리덕스를 사용하기 위한 정의 /* 리덕스에서 관리 할 상태 정의 */ const initialState = { counter: 0 }; /* 액션 타입 정의 */ const INCREASE = "INCREASE"; const DECREASE = "DECREASE"; /* 액션 생성함수 정의 */ // const increase = () => ({ type: INCREASE }); // const decrease = () => ({ type: DECREASE }); /* 리듀서 만들기 */ // 리듀서는 새로운 상태를 생성하는 함수. function reducer(state = initialState, action) { switch (action.type) { case INCREASE: return { ...state, counter: state.counter + 1 }; case DECREASE: return { ...state, counter: state.counter - 1 }; default: return state; } } // 여기부터 redux 사용 /* 스토어 만들기 */ const store = createStore(reducer); store.subscribe(() => console.log(`상태 변경`)); // 현재 상태 가져옴 console.log(store.getState()); // {counter: 0} // dispatch 와 액션을 통해 현재 상태 변경 store.dispatch({ type: "INCREASE" }); // dispatch 를 호추할 때마다 '상태 변경' 문구 출력 store.dispatch({ type: "INCREASE" }); // dispatch 를 호추할 때마다 '상태 변경' 문구 출력 console.log(store.getState()); // {counter: 2}
- 리덕스를 사용하기 위해 리듀서는 필수로 필요하다.
- 스토어를 만들기 위해 createStore 함수를 이용하여 store 생성했고,
store 는
dispatch 를 이용하여 액션을 발생시키고
getState 를 사용하여 현재 상태를 가져오고
subscribe 를 통해 상태가 변화때마다(액션을 호출할 때마다) 특정 기능을 수행할 수 있게 한다.
즉, store 안에는 getState, dispatch, subscribe 함수가 있어야한다.
createStore 은 store 를 반환하지만 그 기능은 getState, dispatch, subscribe 함수를 제공해야 한다고 위에서 설명했다. 또한 해당 상태를 갖고 있어야 한다. 이 내용을 다시 한번 상기 시키고 구현해보자
redux (createStore) 구현하기
export function createStore(reducer) { let state; // 상태 const listeners = new Set(); // 구독 내용을 관리하는 부분 const getState = () => ({ ...state }); // 현재 상태를 가져오는 기능 const dispatch = (action) => { // 액션을 발생키시는 dispatch 기능 state = reducer(state, action); publish(); }; const subscribe = (fn) => listeners.add(fn); // 구독 기능하는 기능 const publish = () => listeners.forEach((fn) => fn()); // 상태가 변경되면(액션을 호출(dispatch)) 등록된 구독 내용(listeners) 을 수행하귀 위한 기능 return { // 클로저를 통해 store 에서 사용하는 getState, dispatch, subscribe 만 제공 getState, dispatch, subscribe, }; }
- 리덕스는 결국 상태 관리를 위한 라이브러리 이므로 결국 상태가 필요하다.
여기서는
state
변수- 옵저버 패턴의 기능인 구독(subscribe)과 알림(publish) 기능 제공
- 현재 store 의 상태를 가져오기 위한 getState,
액션을 발생시키는 dispatch 기능 제공
- 마지막으로 state 를 누구나 사용하거나 변경하면 안되기 때문에 클로저를 이용하여 private 로 관리구현했다.
여기까지 리덕스에 개념과 필자가 구현한 redux를 바라봤다.
이제 간단한 카운터를 구현하면서 어떻게 사용하는지 알아보자!!
이전에 해당 내용에서 바닐라 자바스크립트로 리액트 흉내내기 1 - 웹 컴포넌트 글 언급한 내용은 최대한 자제하고 변경된 부분만 언급하고자 한다.
카운터 구현하기
구현하고자 하는 Counter 는 아래와 같다.
App 컴포넌트로 구현
- App 은 하나의 컴포넌트(App) 에서 모든 것을 처리하도록 구현했다.
구조
redux.js
파일이 리덕스 관련된 기능
modules/counter.js
파일에는 초기값, 액션 타입, 리듀서 등 리덕스에서 사용하는 기능store.js
에는 리덕스를 이용하여 상태를 생성하는 부분을 정의했다.공통 기능
// core/redux.js export function createStore(reducer) { let state; const listeners = new Set(); const getState = () => ({ ...state }); const dispatch = (action) => { state = reducer(state, action); console.log('update state', state); publish(); }; const subscribe = (fn) => listeners.add(fn); const publish = () => listeners.forEach((fn) => fn()); return { getState, dispatch, subscribe, }; }
- 위에서 설명한 redux 기능 이다.
// modules/counter.js // 액션 타입 정의 const INCREASE = 'INCREASE'; const DECREASE = 'DECREASE'; const SET_DIFF = 'SET_DIFF'; // 액션 생성 함수 export const increase = () => ({ type: INCREASE }); export const decrease = () => ({ type: DECREASE }); export const setDiff = (payload) => ({ type: SET_DIFF, payload }); // 초기화 코드 const initialState = { diff: 1, number: 0, }; // 리듀서 정의 export default function countReducer(state = initialState, action = {}) { switch (action.type) { case INCREASE: return { ...state, number: state.number + state.diff, }; case DECREASE: return { ...state, number: state.number - state.diff, }; case SET_DIFF: return { ...state, diff: action.payload, }; default: return state; } }
- 리덕스에서 사용할 리듀서와 상태 변화를 위한 액션 등을 정의했다.
// store.js import { createStore } from './core/redux.js'; // redux import countReducer from './modules/counter.js'; const counterStore = createStore(countReducer); counterStore.dispatch(); // reudx에서 초기 데이터를 설정하기 위한 요청 export { counterStore };
- 웹 서비스에 필요한 store 를 생성하고 관리하기 위한 공간이다.
- 초반에 dispatch를 해줬는데 초기 데이터를 설정하기 위함이다.
왜냐하면 리듀서에서 존재하지 않은 액션은 default 값으로 빠지는데 state 에 default 파라미터를 설정해서 초기화를 했다.
이제 리덕스를 사용하기 위한 준비를 마쳤으니 컴포넌트에 적용해보자!!
App 컴포넌트
// App.js import { $ } from './utils/util.js'; import Component from './core/Component.js'; import { counterStore } from './store.js'; import { decrease, increase, setDiff } from './modules/counter.js'; // 액션 생성 함수 를 통해 액션 정의 export default class App extends Component { constructor(...rest) { super(...rest); counterStore.subscribe(this.render.bind(this)); // 구독 기능을 통해 상태가 변하면 해당 컴포넌트를 렌더링하게 함.(상태 변하면 DOM 을 다시 그림) } template() { const { diff, number } = counterStore.getState(); return ` <div class="container"> <h1>Counter</h1> <form class="setDiffForm"> <input class="diffInput" type="number" placeholder="1" value="${diff}"/> <button class="diffSubmit" type="submit">diff 설정</button> </form> <h2 class="counter">${number}</h2> <button class="increaseBtn">+1</button> <button class="decreaseBtn">-1</button> </div> `; } componentDidMount() { $('.increaseBtn').addEventListener('click', () => counterStore.dispatch(increase())); $('.decreaseBtn').addEventListener('click', () => counterStore.dispatch(decrease())); $('.setDiffForm').addEventListener('submit', this.handleChangeDiff); } handleChangeDiff(event) { event.preventDefault(); const diff = $('.diffInput')?.value; if (!diff) return; counterStore.dispatch(setDiff(+diff)); } }
- 기존 Component 기능에서 redux를 적용했다.
- 주의할 점은 생성자에서 구독 기능을 등록했다.
자세한 코드는 Github Repo 를 참고하면 된다.
Counter 컴포넌트를 컴포넌트로 분리하여 구현하기
위 공통 영역은 같고 App.js 부분과 components 디렉터리에 각각 필요한 컴포넌트를 생성하였다.
구조
자세한 설명은 App.js 파일과 Counter.js, DiffInput.js 파일에 대해서만 기술하겠다. (나머지는 위 내용과 동일)
App 컴포넌트
// App.js import { $ } from './utils/util.js'; import Component from './core/Component.js'; import Counter from './components/Counter.js'; import InputDiff from './components/InputDiff.js'; import { counterStore } from './store.js'; export default class App extends Component { constructor(...rest) { super(...rest); } template() { return ` <h1>Counter</h1> <section class="diff-form-component"></section> <section class="counter-component"></section> `; } componentDidMount() { const newInput = new InputDiff($('.diff-form-component')); const counter = new Counter($('.counter-component')); counterStore.subscribe(() => { newInput.render(), counter.render(); }); } }
- store 의 구독 기능을 Components(컴포넌트 분리) 부분에서는 componentDidMount 에서 했고,
App 부분(하나의 컴포넌트에서 관리)에서는 constructor 에서 선언했다.
그 이유는 아래와 같다. (개인 적인 의견)
App 부분(하나의 컴포넌트에서 관리)은 컴포넌트가 렌더링 되는 부분이 딱 한 곳이기 때문에 constuctor 에서 사용해도 괜찮다고 생각했다.
하지만, Components(컴포넌트 분리) 부분에서는 constructor 에서 구독 기능을 정의하면 각 컴포넌트를 생성할 때마다 각 컴포넌트의 constructor 에 작성해야 하기 때문에 Components(컴포넌트 분리)의 경우 App 컴포넌트에서 mount 될 때 등록해서 한 곳에서만 관리할 수 있게 하였다.
counter 컴포넌트
// components/Counter.js import Component from '../core/Component.js'; import { decrease, increase } from '../modules/counter.js'; import { counterStore } from '../store.js'; import { $ } from '../utils/util.js'; export default class Counter extends Component { constructor(...rest) { super(...rest); } template() { const { number } = counterStore.getState(); return ` <h2 class="counter">${number}</h2> <button class="increaseBtn">+1</button> <button class="decreaseBtn">-1</button> `; } componentDidMount() { $('.increaseBtn').addEventListener('click', () => counterStore.dispatch(increase())); $('.decreaseBtn').addEventListener('click', () => counterStore.dispatch(decrease())); } }
InputDiff 컴포넌트
// components/InputDiff.js import { $ } from '../utils/util.js'; import Component from '../core/Component.js'; import { counterStore } from '../store.js'; import { setDiff } from '../modules/counter.js'; export default class InputDiff extends Component { constructor(...rest) { super(...rest); } template() { const { diff } = counterStore.getState(); return ` <form class="setDiffForm"> <input class="diffInput" type="number" value="${diff}" /> <button class="diffSubmit" type="submit">diff 설정</button> </form> `; } componentDidMount() { $('.setDiffForm').addEventListener('click', this.handleChangeDiff); } handleChangeDiff(event) { event.preventDefault(); const diff = $('.diffInput')?.value; if (!diff) return; counterStore.dispatch(setDiff(+diff)); } }
자세한 코드는 Github Repo 를 참고하면 된다.
여기까지 리덕스를 간단하게 구현했고 이전에 작성했던 웹 컴포넌트와 같이 사용해 보았다.
해당 내용을 이용하여 적용해보고 싶다면 참고할 수 있게 해당 Repo(폴더)를 만들었다.
앞으로 개선하면서 꾸준하게 업데이트 하고자 하는데 도움이 되었으면 좋겠습니다. (피드백도 언제나 환영입니다.)
해당 내용이 도움이 되었다면 좋겠습니다. 감사합니다!