본문으로 건너뛰기
비공식 베타 번역

이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →

미들웨어

"Redux 기초" 튜토리얼에서 미들웨어가 동작하는 모습을 보셨을 겁니다. ExpressKoa 같은 서버 사이드 라이브러리를 사용해 보셨다면 미들웨어 개념에 이미 익숙하실 것입니다. 이런 프레임워크에서 미들웨어는 프레임워크가 요청을 받은 시점과 응답을 생성하는 시점 사이에 배치할 수 있는 코드입니다. 예를 들어 Express나 Koa 미들웨어는 CORS 헤더 추가, 로깅, 압축 등을 처리할 수 있습니다. 미들웨어의 가장 큰 장점은 체인 형태로 조합할 수 있다는 점입니다. 여러 독립적인 서드파티 미들웨어를 단일 프로젝트에서 사용할 수 있습니다.

Redux 미들웨어는 Express나 Koa 미들웨어와 해결하는 문제는 다르지만 개념적으로 유사한 방식으로 동작합니다. 액션을 디스패치하는 시점과 리듀서에 도달하는 시점 사이에 서드파티 확장 지점을 제공합니다. 개발자들은 Redux 미들웨어를 로깅, 크래시 리포트, 비동기 API 통신, 라우팅 등에 활용합니다.

이 글은 개념을 깊이 있게 이해하도록 돕는 심층 설명과, 마지막에 미들웨어의 강력함을 보여주는 실용적인 예시들로 구성되어 있습니다. 지루함과 영감을 오가며 번갈아 가며 읽어보시길 권합니다.

미들웨어 이해하기

미들웨어는 비동기 API 호출을 포함해 다양한 용도로 사용될 수 있지만, 그 기원을 이해하는 것이 중요합니다. 로깅과 크래시 리포트를 예시로 미들웨어로 이어지는 사고 과정을 안내해 드리겠습니다.

문제: 로깅

Redux의 장점 중 하나는 상태 변화를 예측 가능하고 투명하게 만든다는 점입니다. 액션이 디스패치될 때마다 새로운 상태가 계산되어 저장됩니다. 상태는 스스로 변할 수 없으며, 반드시 특정 액션의 결과로만 변경됩니다.

앱에서 발생하는 모든 액션과 그 후 계산된 상태를 함께 기록해두면 어떨까요? 문제가 발생했을 때 로그를 확인해 어떤 액션이 상태를 손상시켰는지 파악할 수 있습니다.

Redux에서 이 문제를 어떻게 접근할까요?

시도 #1: 수동 로깅

가장 단순한 해결책은 store.dispatch(action)을 호출할 때마다 직접 액션과 다음 상태를 기록하는 것입니다. 진정한 해결책이라기보다는 문제 이해를 위한 첫걸음입니다.

참고

react-redux나 유사한 바인딩을 사용 중이라면 컴포넌트에서 스토어 인스턴스에 직접 접근하기 어려울 수 있습니다. 다음 몇 문단에서는 명시적으로 스토어를 전달한다고 가정하세요.

할 일 생성 시 다음과 같이 호출한다고 합시다:

store.dispatch(addTodo('Use Redux'))

액션과 상태를 기록하려면 다음과 같이 변경할 수 있습니다:

const action = addTodo('Use Redux')

console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())

원하는 효과는 얻었지만 매번 이렇게 하기는 번거롭습니다.

시도 #2: 디스패치 래핑

로깅 기능을 함수로 추출할 수 있습니다:

function dispatchAndLog(store, action) {
console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())
}

그러면 store.dispatch() 대신 이 함수를 어디서든 사용할 수 있습니다:

dispatchAndLog(store, addTodo('Use Redux'))

여기서 멈출 수 있지만 매번 특별한 함수를 임포트하는 것은 불편합니다.

시도 #3: 디스패치 몽키패칭

스토어 인스턴스의 dispatch 함수를 직접 교체하면 어떨까요? Redux 스토어는 몇 가지 메서드를 가진 평범한 객체이며, 자바스크립트 환경이므로 dispatch 구현을 몽키패칭할 수 있습니다:

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에 적용하고 싶다면 어떻게 해야 할까요?

생각나는 또 다른 유용한 변환은 프로덕션 환경에서 JavaScript 오류를 리포트하는 것입니다. 전역 window.onerror 이벤트는 일부 구형 브라우저에서 스택 정보를 제공하지 않아 신뢰할 수 없습니다. 스택 정보는 오류 발생 원인을 이해하는 데 중요합니다.

액션 디스패치로 인해 오류가 발생할 때마다 Sentry 같은 크래시 리포트 서비스에 스택 트레이스, 오류를 일으킨 액션, 현재 상태를 전송할 수 있다면 매우 유용하지 않을까요? 이렇게 하면 개발 환경에서 오류를 재현하기 훨씬 쉬워집니다.

하지만 로깅과 크래시 리포트를 분리하는 것이 중요합니다. 이상적으로는 서로 다른 모듈로, 가능하면 다른 패키지로 구성하는 것이 좋습니다. 그렇지 않으면 이러한 유틸리티의 생태계를 가질 수 없습니다. (힌트: 여기서 미들웨어 개념이 서서히 드러납니다!)

로깅과 크래시 리포트가 분리된 유틸리티라면 다음과 같은 형태일 것입니다:

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
}
}
}

이 함수들을 별도의 모듈로 배포한다면 나중에 스토어를 패치할 때 사용할 수 있습니다:

patchStoreToAddLogging(store)
patchStoreToAddCrashReporting(store)

그래도 여전히 깔끔하지 않습니다.

시도 #4: 몽키패칭 숨기기

몽키패칭은 핵(hack)입니다. "원하는 메서드를 마음대로 교체한다"는 건 어떤 API일까요? 그 대신 핵심을 파악해 보겠습니다. 이전에는 함수들이 store.dispatch를 교체했습니다. 만약 새 dispatch 함수를 반환한다면 어떨까요?

function logger(store) {
const next = store.dispatch

// Previously:
// 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
}
}

라이브러리 내부에 실제 몽키패칭을 적용하는 헬퍼를 제공할 수 있습니다:

function applyMiddlewareByMonkeypatching(store, middlewares) {
middlewares = middlewares.slice()
middlewares.reverse()

// Transform dispatch function with each middleware.
middlewares.forEach(middleware => (store.dispatch = middleware(store)))
}

이렇게 하면 여러 미들웨어를 다음과 같이 적용할 수 있습니다:

applyMiddlewareByMonkeypatching(store, [logger, crashReporter])

하지만 여전히 몽키패칭입니다.
라이브러리 내부에 숨겼다는 사실이 이 점을 바꾸지는 않습니다.

시도 #5: 몽키패칭 제거하기

애초에 왜 dispatch를 덮어쓸까요? 물론 나중에 호출하기 위함이지만, 또 다른 이유는 각 미들웨어가 이전에 래핑된 store.dispatch에 접근(및 호출)할 수 있게 하기 위함입니다:

function logger(store) {
// Must point to the function returned by the previous middleware:
const next = store.dispatch

return function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}

이는 미들웨어 체이닝의 핵심입니다!

applyMiddlewareByMonkeypatching이 첫 번째 미들웨어 처리 후 즉시 store.dispatch를 할당하지 않으면, store.dispatch는 계속 원본 dispatch 함수를 가리킵니다. 그러면 두 번째 미들웨어도 원본 dispatch 함수에 바인딩됩니다.

하지만 체이닝을 가능하게 하는 다른 방법도 있습니다. 미들웨어가 store 인스턴스에서 읽는 대신 next() 디스패치 함수를 매개변수로 받을 수 있습니다.

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
}
}
}

"더 깊이 들어가야 합니다" 같은 순간이므로 이해하는 데 시간이 걸릴 수 있습니다. 함수 연쇄는 부담스럽게 느껴집니다. 화살표 함수는 이 커링을 보기 좋게 만듭니다:

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
}
}

이것이 바로 Redux 미들웨어의 모습입니다.

이제 미들웨어는 next() 디스패치 함수를 받아서 디스패치 함수를 반환합니다. 이 반환된 함수는 다시 왼쪽에 있는 미들웨어의 next() 역할을 합니다. getState() 같은 일부 스토어 메서드에 접근하는 것은 여전히 유용하므로, store는 최상위 인자로 남아 있습니다.

시도 #6: 미들웨어 적용 (단순 구현)

applyMiddlewareByMonkeypatching() 대신 최종적으로 완전히 래핑된 dispatch() 함수를 먼저 얻고, 이를 사용하는 스토어 사본을 반환하는 applyMiddleware()를 작성할 수 있습니다:

// Warning: Naïve implementation!
// That's *not* Redux API.
function applyMiddleware(store, middlewares) {
middlewares = middlewares.slice()
middlewares.reverse()
let dispatch = store.dispatch
middlewares.forEach(middleware => (dispatch = middleware(store)(dispatch)))
return Object.assign({}, store, { dispatch })
}

Redux에 내장된 applyMiddleware() 구현은 유사하지만 세 가지 중요한 측면에서 다릅니다:

  • 미들웨어에 스토어 API의 일부만 노출합니다: dispatch(action)getState().

  • 현재 미들웨어 내에서 next(action) 대신 store.dispatch(action)을 호출하면, 해당 액션이 현재 미들웨어를 포함한 전체 미들웨어 체인을 다시 통과하도록 보장하기 위해 약간의 기교를 사용합니다. 이는 비동기 미들웨어에 유용합니다. 단, 설정 중에 dispatch를 호출할 때는 아래에 설명된 주의사항이 있습니다.

  • 미들웨어를 한 번만 적용할 수 있도록 보장하기 위해, store 자체가 아닌 createStore()에서 동작합니다. (store, middlewares) => store 대신 (...middlewares) => (createStore) => createStore 시그니처를 사용합니다.

createStore()를 사용하기 전에 함수를 적용하는 것이 번거롭기 때문에, createStore()는 이러한 함수를 지정하기 위한 선택적 마지막 인수를 허용합니다.

주의사항: 설정 중 디스패치

applyMiddleware가 실행되어 미들웨어를 설정하는 동안, store.dispatch 함수는 createStore가 제공하는 기본 버전을 가리킵니다. 이때 디스패치하면 다른 미들웨어가 적용되지 않습니다. 설정 중에 다른 미들웨어와 상호작용할 것으로 예상한다면 실망할 수 있습니다. 이러한 예상치 못한 동작으로 인해, 설정이 완료되기 전에 액션을 디스패치하려고 하면 applyMiddleware가 오류를 발생시킵니다. 대신 공통 객체(예: API 호출 미들웨어의 경우 API 클라이언트 객체)를 통해 직접 통신하거나, 콜백으로 미들웨어 구성이 완료될 때까지 기다려야 합니다.

최종 접근 방식

방금 작성한 이 미들웨어가 주어졌을 때:

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
}
}

다음은 이를 Redux 스토어에 적용하는 방법입니다:

import { createStore, combineReducers, applyMiddleware } from 'redux'

const todoApp = combineReducers(reducers)
const store = createStore(
todoApp,
// applyMiddleware() tells createStore() how to handle middleware
applyMiddleware(logger, crashReporter)
)

이게 전부입니다! 이제 스토어 인스턴스에 디스패치된 모든 액션은 loggercrashReporter를 통과합니다:

// Will flow through both logger and crashReporter middleware!
store.dispatch(addTodo('Use Redux'))

일곱 가지 예시

위 섹션을 읽는 것만으로 머리가 복잡했다면, 이를 작성하는 과정이 얼마나 어려웠을지 상상해 보세요. 이 섹션은 여러분과 저를 위한 휴식 공간이며, 사고의 폭을 넓히는 데 도움이 될 것입니다.

아래의 각 함수는 유효한 Redux 미들웨어입니다. 모두 동일하게 유용하진 않지만, 적어도 동일하게 재미있습니다.

/**
* Logs all actions and states after they are dispatched.
*/
const logger = store => next => action => {
console.group(action.type)
console.info('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
console.groupEnd()
return result
}

/**
* Sends crash reports as state is updated and listeners are notified.
*/
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
}
}

/**
* Schedules actions with { meta: { delay: N } } to be delayed by N milliseconds.
* Makes `dispatch` return a function to cancel the timeout in this case.
*/
const timeoutScheduler = store => next => action => {
if (!action.meta || !action.meta.delay) {
return next(action)
}

const timeoutId = setTimeout(() => next(action), action.meta.delay)

return function cancel() {
clearTimeout(timeoutId)
}
}

/**
* Schedules actions with { meta: { raf: true } } to be dispatched inside a rAF loop
* frame. Makes `dispatch` return a function to remove the action from the queue in
* this case.
*/
const rafScheduler = store => next => {
const queuedActions = []
let frame = null

function loop() {
frame = null
try {
if (queuedActions.length) {
next(queuedActions.shift())
}
} finally {
maybeRaf()
}
}

function maybeRaf() {
if (queuedActions.length && !frame) {
frame = requestAnimationFrame(loop)
}
}

return action => {
if (!action.meta || !action.meta.raf) {
return next(action)
}

queuedActions.push(action)
maybeRaf()

return function cancel() {
queuedActions = queuedActions.filter(a => a !== action)
}
}
}

/**
* Lets you dispatch promises in addition to actions.
* If the promise is resolved, its result will be dispatched as an action.
* The promise is returned from `dispatch` so the caller may handle rejection.
*/
const vanillaPromise = store => next => action => {
if (typeof action.then !== 'function') {
return next(action)
}

return Promise.resolve(action).then(store.dispatch)
}

/**
* Lets you dispatch special actions with a { promise } field.
*
* This middleware will turn them into a single action at the beginning,
* and a single success (or failure) action when the `promise` resolves.
*
* For convenience, `dispatch` will return the promise so the caller can wait.
*/
const readyStatePromise = store => next => action => {
if (!action.promise) {
return next(action)
}

function makeAction(ready, data) {
const newAction = Object.assign({}, action, { ready }, data)
delete newAction.promise
return newAction
}

next(makeAction(false))
return action.promise.then(
result => next(makeAction(true, { result })),
error => next(makeAction(true, { error }))
)
}

/**
* Lets you dispatch a function instead of an action.
* This function will receive `dispatch` and `getState` as arguments.
*
* Useful for early exits (conditions over `getState()`), as well
* as for async control flow (it can `dispatch()` something else).
*
* `dispatch` will return the return value of the dispatched function.
*/
const thunk = store => next => action =>
typeof action === 'function'
? action(store.dispatch, store.getState)
: next(action)

// You can use all of them! (It doesn't mean you should.)
const todoApp = combineReducers(reducers)
const store = createStore(
todoApp,
applyMiddleware(
rafScheduler,
timeoutScheduler,
thunk,
vanillaPromise,
readyStatePromise,
logger,
crashReporter
)
)