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

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

코드 분할

대규모 웹 애플리케이션에서는 애플리케이션 코드를 여러 JS 번들로 분할하여 필요 시 로드하는 것이 바람직한 경우가 많습니다. '코드 분할'이라 불리는 이 전략은 초기에 가져와야 하는 JS 페이로드 크기를 줄여 애플리케이션 성능을 향상시키는 데 도움이 됩니다.

Redux로 코드 분할을 구현하려면 스토어에 리듀서를 동적으로 추가할 수 있어야 합니다. 그러나 Redux는 실제로 단일 루트 리듀서 함수만을 갖고 있습니다. 이 루트 리듀서는 일반적으로 애플리케이션 초기화 시 combineReducers() 또는 유사한 함수를 호출하여 생성됩니다. 더 많은 리듀서를 동적으로 추가하려면 루트 리듀서를 재생성하기 위해 해당 함수를 다시 호출해야 합니다. 아래에서는 이 문제를 해결하기 위한 몇 가지 접근법과 해당 기능을 제공하는 두 라이브러리를 소개합니다.

기본 원리

replaceReducer 사용하기

Redux 스토어는 replaceReducer 함수를 노출하며, 이 함수는 현재 활성화된 루트 리듀서 함수를 새로운 루트 리듀서 함수로 교체합니다. 이를 호출하면 내부 리듀서 함수 참조가 교체되고, 새로 추가된 슬라이스 리듀서가 스스로 초기화할 수 있도록 액션이 디스패치됩니다:

const newRootReducer = combineReducers({
existingSlice: existingSliceReducer,
newSlice: newSliceReducer
})

store.replaceReducer(newRootReducer)

리듀서 주입 접근법

이 섹션에서는 리듀서를 주입하기 위해 수동으로 작성할 수 있는 몇 가지 레시피를 다룹니다.

injectReducer 함수 정의하기

애플리케이션 어디에서든 store.replaceReducer()를 호출할 수 있도록 재사용 가능한 injectReducer() 함수를 정의하는 것이 유용합니다. 이 함수는 기존 슬라이스 리듀서에 대한 참조를 유지하고 스토어 인스턴스에 첨부합니다.

import { createStore } from 'redux'

// Define the Reducers that will always be present in the application
const staticReducers = {
users: usersReducer,
posts: postsReducer
}

// Configure the store
export default function configureStore(initialState) {
const store = createStore(createReducer(), initialState)

// Add a dictionary to keep track of the registered async reducers
store.asyncReducers = {}

// Create an inject reducer function
// This function adds the async reducer, and creates a new combined reducer
store.injectReducer = (key, asyncReducer) => {
store.asyncReducers[key] = asyncReducer
store.replaceReducer(createReducer(store.asyncReducers))
}

// Return the modified store
return store
}

function createReducer(asyncReducers) {
return combineReducers({
...staticReducers,
...asyncReducers
})
}

이제 새로운 리듀서를 스토어에 추가하려면 store.injectReducer를 호출하기만 하면 됩니다.

'리듀서 매니저' 사용하기

다른 접근법은 등록된 모든 리듀서를 추적하고 reduce() 함수를 노출하는 '리듀서 매니저' 객체를 생성하는 것입니다. 다음 예시를 고려해 보세요:

export function createReducerManager(initialReducers) {
// Create an object which maps keys to reducers
const reducers = { ...initialReducers }

// Create the initial combinedReducer
let combinedReducer = combineReducers(reducers)

// An array which is used to delete state keys when reducers are removed
let keysToRemove = []

return {
getReducerMap: () => reducers,

// The root reducer function exposed by this object
// This will be passed to the store
reduce: (state, action) => {
// If any reducers have been removed, clean up their state first
if (keysToRemove.length > 0) {
state = { ...state }
for (let key of keysToRemove) {
delete state[key]
}
keysToRemove = []
}

// Delegate to the combined reducer
return combinedReducer(state, action)
},

// Adds a new reducer with the specified key
add: (key, reducer) => {
if (!key || reducers[key]) {
return
}

// Add the reducer to the reducer mapping
reducers[key] = reducer

// Generate a new combined reducer
combinedReducer = combineReducers(reducers)
},

// Removes a reducer with the specified key
remove: key => {
if (!key || !reducers[key]) {
return
}

// Remove it from the reducer mapping
delete reducers[key]

// Add the key to the list of keys to clean up
keysToRemove.push(key)

// Generate a new combined reducer
combinedReducer = combineReducers(reducers)
}
}
}

const staticReducers = {
users: usersReducer,
posts: postsReducer
}

export function configureStore(initialState) {
const reducerManager = createReducerManager(staticReducers)

// Create a store with the root reducer function being the one exposed by the manager.
const store = createStore(reducerManager.reduce, initialState)

// Optional: Put the reducer manager on the store so it is easily accessible
store.reducerManager = reducerManager
}

새 리듀서를 추가하려면 이제 store.reducerManager.add("asyncState", asyncReducer)를 호출할 수 있습니다.

리듀서를 제거하려면 이제 store.reducerManager.remove("asyncState")를 호출할 수 있습니다.

Redux Toolkit

Redux Toolkit 2.0에는 리듀서와 미들웨어를 통한 코드 분할을 간소화하도록 설계된 유틸리티가 포함되어 있으며, 지연 로딩된 리듀서와 미들웨어에서 흔히 발생하는 문제인 TypeScript 지원도 견고하게 제공됩니다.

combineSlices

combineSlices 유틸리티는 리듀서 주입을 쉽게 할 수 있도록 설계되었습니다. 또한 여러 슬라이스와 리듀서를 하나의 루트 리듀서로 결합하는 데 사용할 수 있다는 점에서 combineReducers를 대체합니다.

설정 시 슬라이스와 리듀서 맵 세트를 수락하며, 주입 메서드가 첨부된 리듀서 인스턴스를 반환합니다.

참고

combineSlices의 "슬라이스"는 일반적으로 createSlice로 생성되지만, reducerPathreducer 속성을 가진 "슬라이스 형태" 객체라면 무엇이든 가능합니다(즉 RTK Query API 인스턴스도 호환됩니다).

const withUserReducer = rootReducer.inject({
reducerPath: 'user',
reducer: userReducer
})

const withApiReducer = rootReducer.inject(fooApi)

간결성을 위해 이 { reducerPath, reducer } 형태는 본 문서에서 "슬라이스"로 설명됩니다.

슬라이스는 해당 reducerPath에 마운트되며, 리듀서 맵 객체의 항목은 해당 키 아래에 마운트됩니다.

const rootReducer = combineSlices(counterSlice, baseApi, {
user: userSlice.reducer,
auth: authSlice.reducer
})
// is like
const rootReducer = combineReducers({
[counterSlice.reducerPath]: counterSlice.reducer,
[baseApi.reducerPath]: baseApi.reducer,
user: userSlice.reducer,
auth: authSlice.reducer
})
주의

이름 충돌을 피하도록 주의하세요 - 나중에 추가된 키가 이전 키를 덮어쓰게 되며, TypeScript는 이를 처리할 수 없습니다.

슬라이스 주입

슬라이스를 주입하려면 combineSlices에서 반환된 리듀서 인스턴스에서 rootReducer.inject(slice)를 호출해야 합니다. 이렇게 하면 슬라이스가 해당 reducerPath 아래로 주입되며, 슬라이스가 주입되었음을 인지하는 타입의 결합된 리듀서 인스턴스가 반환됩니다.

또는 slice.injectInto(rootReducer)를 호출할 수 있으며, 이는 주입이 완료된 상태를 인지하는 슬라이스 인스턴스를 반환합니다. 각 호출이 유용한 결과를 반환하고 combineSlices가 동일한 reducerPath에 동일한 리듀서 인스턴스를 문제없이 주입할 수 있으므로 두 방법을 모두 사용하는 것도 고려해볼 수 있습니다.

const withCounterSlice = rootReducer.inject(counterSlice)
const injectedCounterSlice = counterSlice.injectInto(rootReducer)

일반적인 리듀서 주입 방식과 combineSlice의 "메타 리듀서" 접근법의 주요 차이점은 combineSlice에서는 replaceReducer가 절대 호출되지 않는다는 점입니다. 스토어에 전달된 리듀서 인스턴스는 변경되지 않습니다.

이로 인해 슬라이스가 주입될 때 액션이 디스패치되지 않으며, 따라서 주입된 슬라이스의 상태가 즉시 스토어 상태에 나타나지 않습니다. 상태는 액션이 디스패치된 후에만 스토어 상태에 표시됩니다.

그러나 셀렉터가 잠재적으로 undefined 상태를 처리해야 하는 문제를 피하기 위해 combineSlices는 유용한 셀렉터 유틸리티를 포함합니다.

지연 로드된 슬라이스 선언

지연 로드된 슬라이스가 추론된 상태 유형에 나타나도록 하기 위해 withLazyLoadedSlices 헬퍼가 제공됩니다. 이는 향후 주입할 슬라이스를 선언할 수 있게 하여 상태 유형에서 선택 사항으로 표시되도록 합니다.

지연 로드된 슬라이스를 결합된 리듀서 파일로 완전히 가져오지 않으려면 모듈 확장을 사용할 수 있습니다.

// file: reducer.ts
import { combineSlices } from '@reduxjs/toolkit'
import { staticSlice } from './staticSlice'

export interface LazyLoadedSlices {}

export const rootReducer =
combineSlices(staticSlice).withLazyLoadedSlices<LazyLoadedSlices>()

// file: counterSlice.ts
import type { WithSlice } from '@reduxjs/toolkit'
import { createSlice } from '@reduxjs/toolkit'
import { rootReducer } from './reducer'

interface CounterState {
value: number
}

const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 } as CounterState,
reducers: {
increment: state => void state.value++
},
selectors: {
selectValue: state => state.value
}
})

declare module './reducer' {
// WithSlice utility assumes reducer is under slice.reducerPath
export interface LazyLoadedSlices extends WithSlice<typeof counterSlice> {}

// if it's not, just use a normal key
export interface LazyLoadedSlices {
aCounter: CounterState
}
}

const injectedCounterSlice = counterSlice.injectInto(rootReducer)
const injectedACounterSlice = counterSlice.injectInto(rootReducer, {
reducerPath: 'aCounter'
})

셀렉터 유틸리티

inject 외에도 결합된 리듀서 인스턴스에는 셀렉터를 래핑하는 데 사용할 수 있는 .selector 메서드가 있습니다. 이는 상태 객체를 Proxy로 래핑하고 아직 상태에 나타나지 않은 주입된 리듀서들을 위한 초기 상태를 제공합니다.

inject 호출 결과는 셀렉터가 호출될 때 주입된 슬라이스가 항상 정의되어 있음을 인지하는 타입으로 지정됩니다.

const selectCounterValue = (state: RootState) => state.counter?.value // number | undefined

const withCounterSlice = rootReducer.inject(counterSlice)
const selectCounterValue = withCounterSlice.selector(
state => state.counter.value // number - initial state used if not in store
)

슬라이스의 "주입된" 인스턴스도 슬라이스 셀렉터에 동일한 작업을 수행합니다. 전달된 상태에 존재하지 않으면 초기 상태가 제공됩니다.

const injectedCounterSlice = counterSlice.injectInto(rootReducer)

console.log(counterSlice.selectors.selectValue({})) // runtime error
console.log(injectedCounterSlice.selectors.selectValue({})) // 0

일반적인 사용법

combineSlices는 슬라이스가 필요한 시점(즉, 로드된 컴포넌트에서 슬라이스의 셀렉터나 액션이 임포트될 때)에 즉시 주입되도록 설계되었습니다.

이는 일반적인 사용법이 아래 예시와 유사하게 구성됨을 의미합니다.

// file: reducer.ts
import { combineSlices } from '@reduxjs/toolkit'
import { staticSlice } from './staticSlice'

export interface LazyLoadedSlices {}

export const rootReducer =
combineSlices(staticSlice).withLazyLoadedSlices<LazyLoadedSlices>()

// file: store.ts
import { configureStore } from '@reduxjs/toolkit'
import { rootReducer } from './reducer'

export const store = configureStore({ reducer: rootReducer })

// file: counterSlice.ts
import type { WithSlice } from '@reduxjs/toolkit'
import { createSlice } from '@reduxjs/toolkit'
import { rootReducer } from './reducer'

const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: state => void state.value++
},
selectors: {
selectValue: state => state.value
}
})

export const { increment } = counterSlice.actions

declare module './reducer' {
export interface LazyLoadedSlices extends WithSlice<typeof counterSlice> {}
}

const injectedCounterSlice = counterSlice.injectInto(rootReducer)

export const { selectValue } = injectedCounterSlice.selectors

// file: Counter.tsx
// by importing from counterSlice we guarantee
// the injection happens before this component is defined
import { increment, selectValue } from './counterSlice'
import { useAppDispatch, useAppSelector } from './hooks'

export default function Counter() {
const dispatch = usAppDispatch()
const value = useAppSelector(selectValue)
return (
<>
<p>{value}</p>
<button onClick={() => dispatch(increment())}>Increment</button>
</>
)
}

// file: App.tsx
import { Provider } from 'react-redux'
import { store } from './store'

// lazily importing the component means that the code
// doesn't actually get pulled in and executed until the component is rendered.
// this means that the inject call only happens once Counter renders
const Counter = React.lazy(() => import('./Counter'))

function App() {
return (
<Provider store={store}>
<Counter />
</Provider>
)
}

createDynamicMiddleware

createDynamicMiddleware 유틸리티는 스토어 초기화 후 미들웨어 주입을 허용하는 "메타 미들웨어"를 생성합니다.

import { createDynamicMiddleware, configureStore } from '@reduxjs/toolkit'
import logger from 'redux-logger'
import reducer from './reducer'

const dynamicMiddleware = createDynamicMiddleware()

const store = configureStore({
reducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(dynamicMiddleware.middleware)
})

dynamicMiddleware.addMiddleware(logger)

addMiddleware

addMiddleware는 미들웨어 인스턴스를 동적 미들웨어 인스턴스가 처리하는 미들웨어 체인에 추가합니다. 미들웨어는 주입 순서대로 적용되며 함수 참조로 저장됩니다(동일한 미들웨어는 주입 횟수에 관계없이 한 번만 적용됨).

참고

주입된 모든 미들웨어는 원본 동적 미들웨어 인스턴스 내부 에 포함된다는 점을 기억하는 것이 중요합니다.

import { createDynamicMiddleware, configureStore } from '@reduxjs/toolkit'
import logger from 'redux-logger'
import reducer from './reducer'

const dynamicMiddleware = createDynamicMiddleware()

const store = configureStore({
reducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(dynamicMiddleware.middleware)
})

dynamicMiddleware.addMiddleware(logger)

// middleware chain is now [thunk, logger]

순서를 더 세밀하게 제어하려면 여러 인스턴스를 사용할 수 있습니다.

import { createDynamicMiddleware, configureStore } from '@reduxjs/toolkit'
import logger from 'redux-logger'
import reducer from './reducer'

const beforeMiddleware = createDynamicMiddleware()
const afterMiddleware = createDynamicMiddleware()

const store = configureStore({
reducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware()
.prepend(beforeMiddleware.middleware)
.concat(afterMiddleware.middleware)
})

beforeMiddleware.addMiddleware(logger)
afterMiddleware.addMiddleware(logger)

// middleware chain is now [logger, thunk, logger]

withMiddleware

withMiddleware는 포함된 미들웨어를 추가하도록 하는 액션 생성자이며, 디스패치 시 추가된 확장 기능이 포함된 사전 타이핑된 dispatch 버전을 반환합니다.

const listenerDispatch = store.dispatch(
withMiddleware(listenerMiddleware.middleware)
)

const unsubscribe = listenerDispatch(addListener({ actionCreator, effect }))
// ^? () => void

이 기능은 주로 React가 아닌 환경에서 유용합니다. React를 사용할 때는 React 통합을 활용하는 것이 더 실용적입니다.

React 통합

@reduxjs/toolkit/react 엔트리 포인트에서 가져올 경우, 동적 미들웨어 인스턴스에 몇 가지 추가 메서드가 부착됩니다.

createDispatchWithMiddlewareHook

이 메서드는 addMiddleware를 호출하고, 주입된 미들웨어를 인식하도록 타이핑된 useDispatch 버전을 반환합니다.

import { createDynamicMiddleware } from '@reduxjs/toolkit/react'

const dynamicMiddleware = createDynamicMiddleware()

const useListenerDispatch = dynamicMiddleware.createDispatchWithMiddlewareHook(
listenerMiddleware.middleware
)

function Component() {
const dispatch = useListenerDispatch()

useEffect(() => {
const unsubscribe = dispatch(addListener({ actionCreator, effect }))
return unsubscribe
}, [dispatch])
}
주의

미들웨어는 createDispatchWithMiddlewareHook이 호출될 때 주입되며, useDispatch 훅이 호출될 때가 아닙니다.

createDispatchWithMiddlewareHookFactory

이 메서드는 React 컨텍스트 인스턴스를 받아 해당 컨텍스트를 사용하는 createDispatchWithMiddlewareHook 인스턴스를 생성합니다(커스텀 컨텍스트 제공 참조).

import { createContext } from 'react'
import { createDynamicMiddleware } from '@reduxjs/toolkit/react'
import type { ReactReduxContextValue } from 'react-redux'

const context = createContext<ReactReduxContextValue | null>(null)

const dynamicMiddleware = createDynamicMiddleware()

const createDispatchWithMiddlewareHook =
dynamicMiddleware.createDispatchWithMiddlewareHookFactory(context)

const useListenerDispatch = createDispatchWithMiddlewareHook(
listenerMiddleware.middleware
)

function Component() {
const dispatch = useListenerDispatch()

useEffect(() => {
const unsubscribe = dispatch(addListener({ actionCreator, effect }))
return unsubscribe
}, [dispatch])
}

서드파티 라이브러리 및 프레임워크

위 기능을 자동으로 추가하는 데 도움이 되는 몇 가지 우수한 외부 라이브러리가 있습니다: