이전 포스팅에서 redux를 간단하면서 의사코드 수준을 구현하면서 설명한적이 있다. 매번 리덕스를 사용할 때마다 action, dispatch, reducer 등등 용어부터 햇갈리면서 그 흐름 역시 파악하기 쉽지 않았는데, 구현 경험을 토대로 좀 더 리덕스에 가까워진 느낌을 받았었다. 이후 리덕스의 사용 시간을 점차 늘려나가면서 코드적으로도 익숙해지고 있는 중이다.
리덕스를 구현해보면서 느낄 수 있었던 점 중 또 하나는 리덕스는 동기적인 흐름이라는 점이다. dispatch 를 통해 실행된 reducer 는 순수함수이기에 동기적이다. 즉, 상태값에 대한 예상된 결과값을 도출하기가 쉽다는 장점이 있다.
하지만 서버와의 연결 등 비동기적으로 상태값을 변경해야할 상황이 많이 있다. 아무리 좋은 원격 상태관리이념이라도 비동기적인 상황을 해결할 수 없다면 의미가 없다고 생각한다. 당연하게도 리덕스는 이러한 상황에 리덕스의 대답을 내놓았는데, 그것이 바로 미들웨어(middleware)이다.
어떻게 미들웨어를 활용하여 비동기적 상황을 처리하는지 살펴보자
기존 Redux 의 한계
어떠한 해결책은 이전 문제점과 한계점에서 시작한다. 리덕스가 미들웨어를 통해 비동기적 상황을 처리하게 된 배경에는, 기존 리덕스의 reducer 함수로는 도저히 비동기적인 상황을 대응할 수 없었기 때문이다. 예시 코드를 살펴보자
function reducer(state, action) {
if (action.type === "ADD_COUNTER") {
return {
...state,
count: action.payload.count,
};
}
if (action.type === "FETCH") {
fetch("").then((res) => {
// 작업이 이루어진다.
// promise로서 resolve 를 처리해주어야 한다.
res;
});
}
// 하지만 그전에 이미 동기함수이기에 state 를 반환한다
return state;
}
// dispatch 를 통해 reducer 를 실행시킨다.
store.dispatch({ type: "FETCH" });
reducer는 순수함수로서 동기적으로 작동한다. dispatch 를 통해 reducer 가 action 을 받게 되면, 위부터 아래로 코드를 읽어나가면서 action.type 에 적합한 조건을 찾는다. 위 코드에는 적혀있지 않지만 "ADD_COUNTER" 타입이 들어있다면, 상태값을 얕은 복사한뒤, count 부분에 데이터를 추가한다. 그리고 이 상태값을 반환하게 된다.
만약 reducer 내부에서 fetch 를 통해 외부에서 데이터를 가져온 다음, 상태값을 업데이트 한다고 가정해보자. 개발자가 원하는 상황은 데이터를 외부에서 가져온 뒤, 그 결과로서의 resolve, reject 에 따라 상태값을 다르게 업데이트하고 반환하고 싶을 것이다. 하지만 reducer 는 동기함수. 데이터를 가져오기 전에 이미 상태를 반환하게 되어, 뒤늦게 데이터를 가져와도 반환된 상태값에 적용시킬 수 없다.
reducer 함수 만으로 이걸 해결할 수 있는 방법은 없다. 그리고 reducer 함수는 순수함수여야 하기에 항상 동기적인 흐름을 가지고 있다. reducer 자체를 변경하지 않고 다른 방법을 강구해야 한다.
Reducer 로 보내기 전에 비동기적인 처리를 끝내자
reducer를 변경하지 않고 비동기 처리를 하기 위해서, 리덕스 개발팀은 앞서서 비동기적으로 얻은 결과에 따라 상태값이 달라진다는 점을 착안하여, reducer 로 action 을 보내기 전에 미리 성공 action, 과 실패 action 을 결정지어놓고 reducer 로 전달하는 방식을 채택한다. 즉 만일 fetch 를 통한 요청이 있다면, 우선 요청을 보내고 그에 대한 결과값으로 새로운 newAction 객체를 생성하여 그 newAction 을 reducer 에 전달하면 reducer 는 그대로 순수함수를 유지하고, 개발자는 비동기적인 처리를 할 수 있게 된다.
이렇게 dispatch 와 reducer 사이 모든 작업들이 이루어지게 하면 되고, 이러한 작업에 미들웨어(middleware) 가 활용이 된다.
사실 미들웨어는 백엔드에서 요청과 응답 사이에서 처리하는 소프트웨어 구성 요소이며 로그인여부, 이미지파일전송 등 여러분야에서 잘 활용되고 있었다. 아마도 리덕스 개발팀도 이러한 점을 착안하여 리덕스에 적용시킨게 아닐까 생각한다.
function middleware(dispatch, action) {
if (action.type === "FETCH") {
fetch().then((res) => {
dispatch("응답");
});
}
console.log(action);
}
미들웨어에게 우선 필요한 인자는 dispatch 와 action 인데, 전달받은 action 의 type 에 따라 비동기로 resolve, reject 를 처리하여 새로운 객체 action 을 만들어서 dispatch 한다. 여기서 dispatch 는 매개변수상 이름이다. 이 부분은 뒤에서 추가 설명하겠다.
미들웨어는 store.dispatch 가 실행될 때 같이 호출되어야 한다. 그리고 최종 action 이 reducer 로 전달되어야 하기에 createStore 함수에 매게변수로 넘겨주도록 하자
export function createStore(reducer, middleware = []) {
let state;
const handler = [];
function dispatch(action) {
// 항상 미들웨어가 호출이 된다.
middleware(dispatch, action);
state = reducer(state, action);
handler.forEach((listener) => {
listener();
});
}
}
미들웨어의 핵심은 새 action 을 reducer 에 전달하는 것이다. 따라서 위치 역시 reducer 에 넘겨지기 전에 먼저 실행되어야 한다.
위까지가 기본적인 미들웨어의 흐름이다. 하지만 여기서 처리할 것이 한개가 아니라면? dispatch 에서 reducer 사이 3~4가지 이상의 처리 작업이 있다면, 지금의 구조로 미들웨어를 올바르게 작동시킬 수 있을까?
미들웨어가 여러개라면 단순히 여러번 실행시키는 것이 아니라, 실행시키는 순번도 중요할 것이며, 모든 미들웨어가 생산한 action 각각을 모두 store.dispatch 하면 안될것이다. 어떤식으로 처리해야할까.
Monkey Patching과 currying
리덕스 개발팀은 이러한 상황에서 여러개의 미들웨어를 연결하기 위하여 monkey patching 과 currying 개념을 이용한다. 지금부터 잠깐 리덕스 홈페이지에서 서술하는 미들웨어 체이닝의 구현 과정에 대해서 살펴보자.
https://redux.js.org/understanding/history-and-design/middleware
우선 상태값을 추적하고 싶어 기존 action 에 log를 작성하는 기능을 추가하고 싶다. 가장 간단한 방법은 매 store.dispatch 를 할 때마다 log 를 호출하여 store.getState 를 통해 상태값에 대해 확인하는 것이다.
const action = addTodo('Use Redux')
console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())
물론 이 방법은 전혀 효율적이지도 않고, 개발자가 원할만한 것은 dispatch 를 할때마다 자동적으로 log 가 출력되는 상황일 것이다. 언제나 자동화에 대해 고려하는게 개발자이니..
그럼 더 간단하게 생각해봐서 함수로 감싸주는 것이다.
function dispatchAndLog(store, action) {
console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())
}
// 함수를 호출한다
dispatchAndLog(store, addToDo('Use Redux')
뭔가 더 간편해진것 같지만, 사실상 log 2줄 안쓰는것일 뿐, 매번 함수를 import 해줘야 하는 것은 편하지 않다.
좀 더 근본적이라면 store.dispatch 자체가 변하는것일테고, 자바스크립트 내에서 이를 가능하게 하는 방법으로 monkey patching 이 있다. 우선 간략하게 monkey patching 에 대해 알아보자.
Monkey Patching
monkey patching 은 다른 개발자가 작성한 코드를 변경하지 않고, 해당 코드의 기능을 변경하거나 추가할 수 있는 기술이다. 마치 선언된 객체를 직접 수정하지 않아도, 외부에서 객체 내부를 변경할 수 있는것과 같다. 장점은 다음과 같다.
- 기존 코드를 수정하지 않고 라이브러리를 확장하고 개선할 수 있다.
- 라이브러리에서 제공하지 않는 기능을 추가하거나 기존 기능을 수정할 수 있다
- 프레임워크나 라이브러리의 버그를 우회할 수 있다.
원본 코드를 건들지 않는다는 점이 유리한 점이다. 아래 코드를 살펴보자
function add(x, y) {
return x + y;
}
// monkey patching 을 통해 기존 함수에 기능 추가
const originalAdd = add; // 기존 함수 저장
add = function (x, y) {
const result = originalAdd(x, y);
console.log(`변경`);
return result;
};
add(2, 3); // '변경'
어떤 다른 개발자가 add 라는 함수를 생성하였다. 이 함수는 말그대로 덧셈함수이다.
현 개발자가 이 함수에 log 를 추가하고 싶었고, 이에 monkey patching 을 활용하여 기존 add 함수에 log 출력을 추가하였다. 이 다음 add 를 호출하게 되면, return 값으로 덧셈결과가 반환되며, 동시에 log 호출이 이루어진다.
주의할 점은 반드시 원본 함수를 다른 변수에 저장해야 하는 점이다. 아니라면 add 호출 시 무한 반복이 이뤄질 수 있다.
위 상황 외에도 monkey patching 은 다양하게 사용된다.
// 기존의 메서드에 기능을 추가하기
const originalFunc = Math.random;
Math.random = function () {
console.log("Math.random()가 호출되었습니다.");
return originalFunc.apply(this, arguments);
};
// 새로운 메서드 추가하기
Array.prototype.average = function () {
let sum = 0;
for (let i = 0; i < this.length; i++) {
sum += this[i];
}
return sum / this.length;
};
// 메서드의 동작 변경하기
const originalSetTimeout = setTimeout;
setTimeout = function (callback, delay) {
console.log(`setTimeout가 ${delay}ms 후 실행됩니다.`);
originalSetTimeout(callback, delay);
};
// 기존 메서드를 저장하고 변경된 메서드에서 기존 메서드를 호출하도록 하여 기능을 추가하거나 변경할 수 있다.
// 새로운 메서드를 prototype에 추가하여 기능을 확장할 수 있다.
이러한 monkey patching 의 특성을 활용하여, 기존 store.dispatch 에 새로운 기능인 log 출력을 첨가해보자
const next = store.dispatch
store.dispatch = function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
기존 dispatch 를 next 에 저장하고, 메서드 dispatch에 새로운 log 출력 기능을 추가했다. 이정도라면 원하는 수준까지 도달한거 같긴 하다. 어느 곳에서라도 action 을 보내면 log 가 남게 된다. 다만 무분별한 monkey patching 은 유지보수에 악영향을 끼칠 수 있으니 조심해야한다.
이제 여기에 새로운 기능을 또 추가해보자. 오류가 발생 시 오류를 던져주는 기능을 만들자. 단, 처음에 생성한 log 를 출력하는 유틸과는 별개로 작성되는것을 목표로 하자. 그렇다면 코드가 이렇게 될 것이다.
function patchStoreToAddLogging(store) {
const next = store.dispatch
store.dispatch = function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}
function patchStoreToAddCrashReporting(store) {
const next = store.dispatch
store.dispatch = function dispatchAndReportErrors(action) {
try {
return next(action)
} catch (err) {
console.error('Caught an exception!', err)
Raven.captureException(err, {
extra: {
action,
state: store.getState()
}
})
throw err
}
}
}
갑자기 함수로 감싸서 뭐지 할 수 있지만, 2개의 기능을 각각 나눠서 모듈화 시키기 위해서 함수로 감싸주고, 인자로 store 를 내려준다. 당연하게도 store.dispatch 에 접근하기 위함이다.
patchStoreToAddLogging(store)
patchStoreToAddCrashReporting(store)
이렇게 함수를 호출하여 각각 store.dispatch 에 monkey patching 을 해줄 수 있다.
이제 문제는 이렇게 기존 dispatch 에 추가하고싶은 기능들은 많을 것이며, 때로는 실행되는 순서가 중요할 수 있다. 즉 오류 리포트가 먼저 실행되고 log 출력이 이루어져야 할 수도 있다는것이다. 이를 체이닝이라 하는데 체이닝을 구현하기 위해서는 여기서 좀 더 수정이 필요하다.
우선 위 두함수 내부에서 이루어지던 monkey patching 을 따로 함수를 만들어서 분리시켜줄 것이다.
먼저 각 함수의 patching 부분을 하나의 함수로 바꾸어서 이 함수를 반환하도록 하자.
function logger(store) {
const next = store.dispatch
// 이전:
// store.dispatch = function dispatchAndLog(action) {
return function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}
이후 함수 applyMiddlewareByMonkeypatching 를 생성하고, store 인자와 middleware들을 전달하자. 이 함수에서는 먼저 middleware 배열을 복사한 다음, 뒤집는다. (뒤집는 이유는 아래에서 설명). 이후 각각의 미들웨어에 patching 을 해준다.
function applyMiddlewareByMonkeypatching(store, middlewares) {
middlewares = middlewares.slice()
middlewares.reverse()
// 각 미들웨어에 store.dispatch 함수를 patching 한다
middlewares.forEach(middleware => (store.dispatch = middleware(store)))
}
이렇게 하면 각 미들웨어들에게 이전처럼 patching 이 이루어진 상태가 된다.
applyMiddlewareByMonkeypatching(store, [logger, crashReporter])
이제 순서가 남았다. logger -> crashReporter 로 이어가기 위해서는, 각각의 미들웨어가 개별적으로 원본 store.dispatch 를 불러내서는 안된다. logger 에서 새로운 action 객체를 crashReporter 에 전달하고 이 역시 새로운 action 객체를 생성하여 이 객체를 store.dispatch 에 전달해야한다. 이 때 사용되는 기법이 ES6 의 currying 이다.
currying 은 여러 인자를 받은 함수를 각 하나의 인자를 받는 중첩된 함수로 표현하는 기법이다. 아래 코드처럼 말이다.
function logger(store) {
return function wrapDispatchToAddLogging(next) {
return function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}
}
기존과의 차이점은 next 에 있다. 이제 next 역시 인자로서 전달된다. 그리고 이 부분이 미들웨어 체이닝에 있어 핵심적인 변화부분이다. store.dispatch 로 정해진 next 함수를 전달하는것이 아닌, 새로운 next 함수를 전달할 수 있는 것이다.
(이 외에도 커링의 장점은 호출 시점을 사용자가 설정할 수 있다는 점에 있다)
우선 보기 편하게 화살표 함수형식으로 변경해보자
const logger = store => next => action => {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
const crashReporter = store => next => action => {
try {
return next(action)
} catch (err) {
console.error('Caught an exception!', err)
Raven.captureException(err, {
extra: {
action,
state: store.getState()
}
})
throw err
}
}
많이 익숙한 형태의 함수형태가 나왔다. 두 미들웨어 모두 getStete() 를 사용해야 하기에 최상위 인자로 store 를 받는다. 여기서 개발자가 처음 store.dispatch 를 통해 dispatch 하였다면 바로 reducer 로 가는것이 아니라, 바로 logger 로 이동해야 한다. 즉, 미들웨어가 존재한다면 store.dispatch 가 미들웨어의 next 로 가도록 설정이 되어 있어야 한다.
바로 이 부분이 아까 위에서 미들웨어의 배열의 순서를 반대로 변경한 이유이다.
function applyMiddleware(store, middlewares) {
middlewares = middlewares.slice()
middlewares.reverse()
let lastdispatch = store.dispatch
middlewares.forEach(middleware => (lastdispatch = middleware(store)(lastdispatch)))
return Object.assign({}, store, { lastdispatch })
}
기존하고의 차이점은 우선 실제 dispatch 를 lastdispatch 에다가 할당해준다. 이후 미들웨어의 배열순서가 reverse 되었으니 배열은 [middleware2, middleware1] 으로 되어 있을 것이다. 각각 반복을 돌면서 해줄 것은, next 체이닝을 걸어주는 것이다.
우선 middleware2 에서 인자 store와 실제 dispatch 인 lastdispatch 를 전달해주자. 이 말은 middleware2 에 action 이 인자로 들어온다면, 내부에서 next(action) 이 이루어지고 이것은 곧 store.dispatch(action) 과 같다는 의미이다.
다음 수식을 보면 기존 store.dispatch 인 lastdispatch 에 middleware2(store)(lastdispatch)를 할당한다.
- 현재 lastdispatch = middleware2(store)(lastdispatch)
이제 middleware1 차례다. 같은 방식으로 middleware1 에도 store, lastdispatch 인자를 전달하자. 엇, 여기서 전달하는 lastdispatch 는 위에 나와있듯이 store.dispatch 가 아니다. 바로 middleware2 를 실행시키는 함수이다. 즉, middleware1이 action 을 받아 next(action) 이 실행되면 이때의 next 가 바로 middleware2 라는 의미이다. 이렇게 체이닝이 생기게 된다.
마지막으로 createStore에서 반환하는 dispatch 함수에 lastdispatch 를 대입하면, 실제 우리가 사용하는 dispatch 함수는 store.dispatch 원본이 아니라, middleware1 을 호출하는 함수가 된다.
우리는 분명 store.dispatch 에 대해서 여러 조작을 하였었고, 실제로 monkey patching 을 통해서 정답에 근접하였지만, 마지막에 커링을 이용한 next 함수의 변화를 통해 기존의 monkey patching 역시 제거할 수 있었다. 생각해보면 알 수 있는 것이 우린 next 함수를 설정한 것이지, store.dispatch 에 직접적으로 추가기능을 넣은 것이 아니다. 위 주황색 마지막 원본 store.dispatch 는 그대로 변화없이 존재한다.
const store = createStore(
reducer,
applyMiddleware(logger, crashReporter)
)
이제 createStore 인자로 applyMiddleware 를 추가하면 된다. 이렇게 미들웨어가 체이닝 되는 과정에 대해서 살펴보았다.
결국 비동기적 작업결과에 순서를 부여했다
미들웨어 구현까지 살펴보았고, 이로서 딱 기본적인 리덕스의 컨셉을 알 수 있었다.
리덕스는 순차적이며, 한 방향의 흐름을 지닌다. dispatch 에서 reducer 로. 이러한 틀을 깨트리지 않으면서도 비동기적 처리를 위해 중간다리에 미들웨어들을 체인처럼 연결하였다.
만약 미들웨어 중에 비동기적 작업을 하는 경우를 생각해보자. monitor 라는 미들웨어가 추가되었다.
const monitor = (store) => (next) => (action) => {
setTimeout(() => {
console.log("monitor: ", action.type);
next(action);
}, 2000);
};
미들웨어의 순서는 logger -> monitor 라고 가정해보자. logger 미들웨어에서는 딱히 전달되는 action 을 새롭게 생성하지는 않는다. 다만 log를 출력할 뿐이다. 미들웨어 체이닝에 의해 monitor 를 next 로 호출하고 action 이 그대로 인자로 전달된다.
monitor 역시 action 을 변경하지는 않는다. 다만 2초 뒤에 next(action) 이 실행될 뿐이다.
근데 이제 상관없다. reducer 함수처럼 밑에 return state 를 하는 것도 아니고, 말 그대로 2초 뒤에 next(action) 이 실행된다. 그리고 여기서 next() 는 store.dispatch 이며 곧 reducer 로 전달된다. 이렇게 비동기 처리도 문제가 없어진다.
만약 logger 가 비동기적으로 작동하며 fetch 를 통해 새롭게 action 을 생성한다고 쳐도, 결국은 순서가 있다. 마치 await 처럼말이다. logger 의 작업이 끝나지 않는 이상 next 가 호출되지 않으니 monitor 로 넘어가질 않는다. reducer 함수를 쓰래기 통이라 가정하고 reducer 함수에 action 을 넣는것을 쓰레기통에 쓰레기를 버리는 것이라면, 이 전 미들웨어들은 쓰레기통에 언제 쓰레기를 넣을지를 자기 맘대로 결정하는 것이라 생각하면 될 것이다.
실제로 redux 의 대표적인 비동기 처리 미들웨어인 redux-thunk, redux-saga 역시 이와 같은 원리로 작업 결과에 따른 새로운 액션 객체를 생성하여 dispatch 한다.
미들웨어는 이해하려 해도 잘 이해가 되지 않았었기에, 이번 기회에 한번 진득하게 공부하면서 이해해보자 다짐했었던 것 같다. 덕분에 머리는 많이 아프지만, 어느정도 흐름에 대해 이해할 수 있었고, 현재 프로젝트에서 사용중인 redux-saga 에 대해서도 좀 더 시야가 넓어졌을거라는 기대가 생긴다.
여러 상태관리 라이브러리들이 많이 나오고 있음에도, 여전히 많은 사용량을 자랑하는 리덕스이니만큼 미들웨어 외에도 좀 더 이해할 부분들이 생긴다면 그때그때 공부도 하고 포스팅도 이어 나가야 겠다.
'Programing > React' 카테고리의 다른 글
신선하지 않은 캐시(Stale)와 SWR(Stale-while-Revalidate) (0) | 2023.05.03 |
---|---|
useEffect을 사용하기 전 생각해봐야 할 상황들 (1) | 2023.03.12 |
[번역] useState 에 await 을 적용시킨 커스텀훅을 구현하기 (0) | 2023.02.07 |
Redux를 간략하게 구현해보자 (0) | 2023.02.01 |
Hook을 올바르게 사용하기 (0) | 2023.01.26 |