- Published on
How Redux Works
- Authors
- Name
- Eunsu Kim
- @eunsukimme
Redux는 어떤 원리로 동작하는 것일까? 그것을 알아보자.
이 글에서는 Redux의 기본 구성요소(store, reducer, action, etc)를 이해하고 있다고 가정한다. 이 구성요소에 대한 자세한 내용은 공식 문서를 참고하자.
먼저, Redux가 어떻게 활용되는지 Redux 공식 문서에 있는 예시 코드를 보자.
Example
import { createStore } from 'redux'
function counterReducer(state = { value: 0 }, action) {
switch (action.type) {
case 'counter/incremented':
return { value: state.value + 1 }
case 'counter/decremented':
return { value: state.value - 1 }
default:
return state
}
}
// store API 는 { subscribe, dispatch, getState } 이다.
let store = createStore(counterReducer)
// subscribe() 의 파라미터로 store의 state가 변경될 때 실행될 listener를 전달할 수 있다.
store.subscribe(() => console.log(store.getState()))
store.dispatch({ type: 'counter/incremented' })
// {value: 1}
store.dispatch({ type: 'counter/incremented' })
// {value: 2}
store.dispatch({ type: 'counter/decremented' })
// {value: 1}
간단하다. reducer를 바탕으로 store를 생성한다. 그리고 store는 현재 state 를 반환하는 getState()
와 action 을 dispatch하는 dispatch()
, 그리고 state가 변경될 때 마다 실행될 함수를 전달하는 subscribe()
를 API로 노출한다.
위 예시는 간단한 counter reducer를 만들고, store를 생성한 뒤 카운터 값이 변경될 때 마다 콘솔에 현재 카운터 값을 로깅하도록 한 것을 보여준다. 만약 이 store가 react-redux와 같은 툴을 통해 UI에 바인딩 되어있다면, subscribe()
에 render()
함수를 전달하여 state가 변경될 때 마다 re-render가 이뤄지도록 할 수 있다. 물론 Redux는 기본적으로 React와 같은 UI 라이브러리와 함께 쓰여야만 하는 도구가 아닌, pub-sub 패턴을 기반한 범용 state 컨테이너이다.
Redux is a predictable state container for JavaScript apps. - Redux Docs
다시 코드를 보면, 핵심 API의 세부 구현은 createStore
에 숨겨져있다. 지금부터 위 예시가 기존과 동일하게 잘 동작할 수 있게끔 createStore
를 직접 구현해보자.
createStore
우리가 노출해야 할 API는 총 3가지 이다.
getState()
- store의 현재 state를 반환한다dispatch()
- action을 reducer에 전달한다subscribe()
- state가 변경되면 실행할 함수를 전달한다
먼저 getState()
함수를 구현해보자. 이때 createStore()
함수 scope에는 현재 reducer와 state가 존재한다. getState()
는 현재 state를 반환하기만 하면 된다.
function createStore(reducer, initialState) {
let currentReducer = reducer
let currentState = initialState
return {
getState() {
return currentState
},
}
}
다음으로 dispatch()
를 구현하자. dispatch()
는 현재 reducer에 현재 state와 파라미터로 받은 action 을 전달하여 다음 state를 현재 state에 저장한다.
function createStore(reducer, initialState) {
let currentReducer = reducer
let currentState = initialState
return {
getState() {
return currentState
},
dispatch(action) {
currentState = currentReducer(currentState, action)
return action
},
}
}
마지막으로 subscribe()
를 구현하자. createStore()
함수 scope에 현재 등록된 listeners를 참조하는 변수를 추가하고, subscribe()
에 전달된 리스너를 저장한다. 이 리스너는 dispatch()
가 호출되면 실행되도록 한다.
function createStore(reducer, initialState) {
let currentReducer = reducer
let currentState = initialState
let listener = () => {}
return {
getState() {
return currentState
},
dispatch(action) {
currentState = currentReducer(currentState, action)
listener() // dispatch()가 호출되면 listener 함수가 실행된다
return action
},
subscribe(newListener) {
listener = newListener
},
}
}
자, 이렇게 해서 아주 간단하면서 실제로 동작하는 createStore
를 만들었다. 맨 처음 소개한 예시코드에서 createStore
를 Redux가 아닌 우리가 구현한 걸로 바꾸어봐도 잘 동작하는 것을 확인할 수 있을 것이다.
Multiple Listeners
만약 여러개의 listeners를 등록하고 싶다면 어떻게 해야할까. 이것도 역시 간단하다. createStore
함수 scope에 listeners를 담는 배열을 정의하고, subscribe
함수 파라미터로 전달된 listener를 배열에 저장한 뒤 dispatch
함수가 호출될 때 마다 이를 순회하여 모든 listener를 실행시켜주면 된다.
function createStore(reducer, initialState) {
let currentReducer = reducer
let currentState = initialState
const listeners = []
return {
getState() {
return currentState
},
dispatch(action) {
currentState = currentReducer(currentState, action)
listeners.forEach((listener) => listener()) // 모든 리스너 실행
return action
},
subscribe(newListener) {
listeners.push(newListener)
},
}
}
Middlewares
Redux에는 dispatch의 행위를 override하여 확장할 수 있는 미들웨어란 개념이 존재한다. 미들웨어는 다음과 같은 형태의 nested function이다.
const anotherExampleMiddleware = (storeAPI) => (next) => (action) => {
// Do something in here, when each action is dispatched
return next(action)
}
미들웨어가 이렇게 생긴 이유에 대해선 이 블로그 포스팅을 참고하자. 이러한 미들웨어를 사용함으로써 우리는 dispatch pipeline을 직접 커스텀할 수 있다. 예를 들어, 아래와 같이 console에 로깅하고 에러가 발생하면 Sentry에 로깅하는 logger를 조합할 수 있다.
import { createStore, applyMiddleware } from 'redux'
const loggerMiddlewares = applyMiddleware(consoleLogger, errorLogger)
const store = createStore(rootReducer, loggerMiddlewares)
자, 그러면 createStore()
가 미들웨어도 전달받을 수 있도록 함으로써 어떻게 미들웨어가 동작하는지 알아보자. 우선 createStore()
의 세 번째 파라미터로 middleware들이 포함된 배열 middlewares
을 받도록 하자.
function createStore(reducer, initialState, middlewares = []) {
/* ... */
}
middlewares
의 각 미들웨어는 storeAPI(getState
, dispatch
)와 앞선 middleware에 의해 커스텀된 dispatch(여기서는 accumulated dispatch라는 의미로 accDispatch
라고 부르겠다)인 next
파라미터를 받아야 한다. 이를 우리 코드에서 전달해주자.
function createStore(reducer, initialState, middlewares = []) {
let currentReducer = reducer
let currentState = initialState
const listeners = []
function getState() {
return currentState
}
const dispatch = (action) => {
currentState = currentReducer(currentState, action)
listeners.forEach((listener) => listener())
return action
}
/** 미들웨어에 의해 조합된 최종 dispatch에 대한 참조 **/
const lastDispatch = middlewares.reduce(
(accDispatch, middleware) => middleware({ getState, dispatch })(accDispatch), // 미들웨어에 storeAPI와 accDispatch를 커링 하여 전달한다
dispatch
)
function subscribe(newListener) {
listeners.push(newListener)
}
return {
getState,
dispatch: lastDispatch, // 최종적으로 반환하는 dispatch는 미들웨어에 의해 조합된 lastDispatch 이다
subscribe,
}
}
여기서 한 가지 주의할 점은, createStore
에 전달할 미들웨어의 순서는 호출 순서와 같을 것이기 때문에, 맨 마지막에있는 미들웨어가 호출하는 dispatch
가 실제 store.dispatch
를 호출할 수 있도록 전달받은 미들웨어의 순서를 바꾸어주자.
const lastDispatch = middlewares
.reverse() // 미들웨어의 순서를 바꾸어서 맨 마지막 미들웨어가 store.dispatch를 받을 수 있도록 한다
.reduce((accDispatch, middleware) => middleware({ getState, dispatch })(accDispatch), dispatch)
이렇게 해서 미들웨어를 처리할 수 있게 되었다. 위에서 작성한 예시 코드에 다음과 같이 logger1
, logger2
를 추가한 뒤 실행해보면 미들웨어가 잘 동작하는 것을 확인할 수 있다
// ... 생략
const logger1 = (storAPI) => (next) => (action) => {
console.log('LOGGER1: dispatching', action)
return next(action)
}
const logger2 = (storAPI) => (next) => (action) => {
console.log('LOGGER2: dispatching', action)
return next(action)
}
// logger1, logger2 를 미들웨어로 전달한다
let store = createStore(counterReducer, undefined, [logger1, logger2])
store.subscribe(() => console.log(store.getState()))
store.dispatch({ type: 'incremented' })
// LOGGER1: dispatching { type: 'incremented' }
// LOGGER2: dispatching { type: 'incremented' }
// {value: 1}
Recap
지금까지 Redux, 정확히는 Redux에서 노출하는 createStore
API가 어떻게 동작하는지 간단히 살펴보았다. 여기서 작성한 전체 코드는 Github에서 확인할 수 있다. 물론 여기서 작성한 코드는 실제 Redux와 완전히 일치하진 않다. 실제로는 createStore()
에 몇 가지 추가 필드가 있으며 optimization이 적용되어 있다. 이는 Redux Github에서 쉽게 확인할 수 있으니 참고하면 좋다.
다음 포스팅에서는 Redux를 UI와 바인딩하여 어떻게 서로가 seamless하게 맞물려 동작하는지 확인하도록 하자.