State Management & Side Effects
Overview
우리 앱은 Redux 패턴을 기반으로 상태를 관리합니다. 이 문서에서는 Redux 에코시스템, 우리가 사용하는 애드온들, 그리고 우리가 앱에 적용한 몇 가지 베스트 프랙티스 및 팁에 대해 설명합니다.
Redux
What is Redux?
Redux 공식 문서에 따르면,
“Redux는 전역 애플리케이션 상태를 관리 및 업데이트하기 위한 패턴이자 라이브러리이며, UI는 'actions'라는 이벤트를 트리거하고, 별도의 업데이트 로직인 'reducers'가 상태를 갱신합니다.”
이 글의 목적은 Redux의 동작 원리를 깊게 설명하는 것이 아니므로, 자세한 내용을 알고 싶다면 공식 문서를 참조하세요.
So why we chose Redux?
- 우리 앱은 중대형 규모의 코드베이스를 가지고 있습니다.
- 앱에는 여러 위치에서 자주 업데이트되는 방대한 상태가 존재합니다.
Redux Essential rules
Redux에는 반드시 지켜야 할 4가지 필수 규칙이 있습니다.
Do Not Mutate State
- 리듀서 내부뿐 아니라 애플리케이션 전체에서 상태 값을 직접 변경하는 것은 항상 피해야 합니다.
Reducers Must Not Have Side Effects
- 리듀서 내부에서는 비동기 로직(AJAX 호출, setTimeout, Promise 등), 랜덤 값 생성(
Date.now(),Math.random()), 외부 변수 수정, 외부 영향을 주는 코드를 실행하면 안 됩니다.
Do Not Put Non-Serializable Values in State or Actions
- Promise, Symbol, Map/Set, 함수, 클래스 인스턴스 등 직렬화 불가능한 값을 Redux store의 상태나 액션에 넣지 마세요.
Only One Redux Store Per App
- 하나의 Redux 앱에는 하나의 Redux store 인스턴스만 존재해야 하며, 앱 전체에서 공유되어야 합니다.
Structuring Reducer Folders by domain
- 모든 reducer는
src/reducers폴더에 위치합니다. - 각 도메인마다 독립된 폴더를 둡니다.
src/
└──reducers/
└──account/ # 도메인 단위 폴더
├──reducerActions/ # 액션 카테고리 별 분류
│ └──index.ts
├──takeActions/
│ └──index.ts
├──sagaActions/
│ └─ ─index.ts
├──types/
│ ├──reducer.state.ts # Initial State 타입 정의
│ └──action.payload.ts # 액션 payload 타입 정의
└──index.ts # Reducer 본체
Naming Model Actions
- 액션 이름은 다음 규칙을 따릅니다:
reducers/${domain}/${ACTION_TYPE}
- 액션은 다음 세 가지 카테고리로 그룹화되어야 합니다:
- reducerActions
- Redux-Saga를 우회하고 직접 reducer를 호출하는 액션
- sagaActions
- Redux-Saga에서 처리하는 사이드 이펙트를 포함한 액션 (Effect Helper에서 감지됨)
- takeActions
yield take로 수동 감지하는 사이드 이펙트 트리거 액션
- reducerActions
Bad:
export enum Actions {
UPDATE_NOTIFICATION_COUNT = 'reducers/notification/UPDATE_NOTIFICATION_COUNT',
GET_AI_ANALYSIS_LIST = 'reducers/aiAnalysis/GET_AI_ANALYSIS_LIST'
TRIGGER_CALL_NUDGE = 'reducers/nudge/TRIGGER_CALL_NUDGE'
}
export const updateNotificationCount = () => ({
type: Actions.UPDATE_NOTIFICATION_COUNT
})
export const getAiAnalysisList = () => ({
type: Actions.GET_AI_ANALYSIS_LIST
})
export const triggerCallNudge = () => ({
type: Actions.TRIGGER_CALL_NUDGE
})
Good:
export enum ReducerActions {
UPDATE_NOTIFICATION_COUNT = 'reducers/notification/UPDATE_NOTIFICATION_COUNT'
}
export enum SagaActions {
GET_AI_ANALYSIS_LIST = 'reducers/aiAnalysis/GET_AI_ANALYSIS_LIST'
}
export enum TakeActions {
TRIGGER_CALL_NUDGE = 'reducers/nudge/TRIGGER_CALL_NUDGE'
}
export const updateNotificationCount = () => ({
type: SagaActions.UPDATE_NOTIFICATION_COUNT
})
export const getAiAnalysisList = () => ({
type: SagaActions.GET_AI_ANALYSIS_LIST
})
export const triggerCallNudge = () => ({
type: TakeActions.TRIGGER_CALL_NUDGE
})
Selector Functions
Basic Selector Concepts
Selector function은 Redux store의 state(또는 그 일부)를 입력으로 받아 해당 state를 기반으로 데이터를 반환하는 함수입니다.
Selector는 일종의 "state에 대한 쿼리" 입니다. 내부 동작보다는 필요한 데이터를 정확히 반환하는 것이 중요합니다.
예시:
// Arrow function, direct lookup
const selectEntities = state => state.entities
// Function declaration, mapping over an array to derive values
function selectItemIds(state) {
return state.items.map(item => item.id)
}
// Function declaration, encapsulating a deep lookup
function selectSomeSpecificField(state) {
return state.some.deeply.nested.field
}
// Arrow function, deriving values from an array
const selectItemsWhoseNamesStartWith = (items, namePrefix) =>
items.filter(item => item.name.startsWith(namePrefix))
useSelector hook 내부에서 selector 함수를 사용할 수 있습니다:
const selectTodos = (state) => state.todos
function TodoList() {
const todos = useSelector(selectTodos)
}
Structuring Selector Folders by domain
- 모든 selector는
src/selectors폴더에 위치합니다. - 각 도메인마다 하나의 폴더를 가집니다.
src/
└──selectors/
└──account/ # 도메인 단위 폴더
└──index.ts # selector 함수들
Naming Selector functions
selector 함수는 Redux 스타일 가이드에 따라 select 로 시작하고 명확한 설명을 포함해야 합니다.
Bad:
getTodoById,pickFilteredTodos,filterVisibleTodos
Good:
selectTodoById,selectFilteredTodos,selectVisibleTodos
Optimizing Selectors with Memoization
useSelector나 mapState로 사용하는 selector는 액션이 디스패치될 때마다 실행됩니다. 만약 selector가 항상 새로운 참조값(예: map()이나 filter()로 생성된 배열)을 반환한다면, 이전 state가 바뀌지 않았음에도 컴포넌트는 리렌더링됩니다. 이는 불필요한 CPU 낭비를 유발합니다.
예를 들어, 아래 코드는 state.todos가 바뀌지 않아도 매번 새로운 배열을 리턴하기 때문에 잘못된 구현입니다:
function TodoList() {
// ❌ WARNING: this _always_ returns a new reference, so it will _always_ re-render!
const completedTodos = useSelector(state =>
state.todos.map(todo => todo.completed)
)
}
또 다른 문제는 selector 내부에서 복잡한 계산을 수행하는 경우입니다. 아래의 예시는 state.data가 변경되지 않았더라도 매 액션마다 비용이 큰 작업이 다시 실행됩니다:
function ExampleComplexComponent() {
const data = useSelector(state => {
const initialData = state.data
const filteredData = expensiveFiltering(initialData)
const sortedData = expensiveSorting(filteredData)
const transformedData = expensiveTransformation(sortedData)
return transformedData
})
}
이러한 문제를 방지하기 위해서는 Reselect을 사용하여 selector를 메모이제이션하는 것이 좋습니다. 메모이제이션된 selector는 동일한 입력에 대해 캐시된 결과를 반환하므로 불필요한 재계산과 리렌더링을 피할 수 있습니다.
단, 모든 selector가 반드시 메모이제이션되어야 하는 것은 아니며, 참조값을 새로 생성하거나 계산 비용이 큰 경우에만 필요합니다.
Reselect
What is Reselect?
Reselect은 메모이제이션(memoization)이 적용된 selector functions를 생성하기 위한 라이브러리입니다.
Terminology
- Selector Function: 하나 이상의 JavaScript 값을 인자로 받아 결과값을 유도하는 함수입니다. Redux와 함께 사용할 때는 일반적으로 첫 번째 인자가 전체 Redux store state입니다.
- Input Selectors: 메모이제이션된 selector를 만들기 위한 빌딩 블록 역할을 하는 기본 selector 함수입니다.
createSelector의 첫 번째 인자로 전달되며, selector 호출 시 모든 인자를 받습니다. 이들은 결과 함수(result function)에 필요한 값을 추출합니다. - Output Selector:
createSelector로 생성된 실제 메모이제이션된 selector입니다. - Result Function: input selector들의 결과값을 받아 최종 결과를 반환하는 함수입니다.
- Dependencies: input selectors와 동일한 개념입니다. output selector가 의존하는 값들을 의미합니다.
const outputSelector = createSelector(
[inputSelector1, inputSelector2, inputSelector3], // `dependencies`와 동일한 의미
resultFunc // 결과 함수
)
Example:
const selectTodosByCategory = createSelector(
[
// 입력 selector를 타입과 함께 정의
(state: RootState) => state.todos,
(state: RootState, category: string) => category
],
// 입력값을 기반으로 최종 결과 계산
(todos, category) => {
return todos.filter(t => t.category === category)
}
)
Best Practices
Reselect을 사용할 때는 불필요한 계산을 줄이고, 상태가 실제로 변경되지 않았을 때 새로운 참조값을 반환하지 않도록 구현하는 것이 핵심입니다.
Reselect은 cascading memoization 전략을 사용합니다. 즉, 먼저 입력값(인자)의 참조가 변경되었는지 === 비교로 판단합니다. 참조가 동일하면 결과 함수는 다시 실행되지 않습니다. 따라서 다음과 같은 최적화가 필요합니다:
- input selectors는 단순하게 유지하세요. 예:
state => state.todos,(state, id) => id. 계산 로직이나 새로운 객체/배열 생성을 포함하면 안 됩니다. - 모든 무거운 계산은 result function 안에서만 수행하세요. 그래야 입력값이 바뀌었을 때만 실행됩니다.
- input selector 내부에서 정렬, 필터링 등의 파생 데이터를 반환하면 memoization이 깨집니다.
Bad:
const selectorBad = createSelector(
[(state: RootState) => someExpensiveComputation(state.todos)],
someOtherCalculation
)
Good:
const selectorGood = createSelector(
[(state: RootState) => state.todos],
todos => someExpensiveComputation(todos)
)
또한, result function이 단순히 입력값을 그대로 반환하는 경우도 피해야 합니다. 이는 메모이제이션의 이점을 전혀 살리지 못합니다:
// BROKEN: 의미 없는 selector — 항상 새로운 참조를 반환함
const brokenSelector = createSelector(
[(state: RootState) => state.todos],
todos => todos
)
Handling Empty Arrays
.filter()와 같은 배열 메서드를 사용할 때, 결과가 비어 있다면 항상 새로운 빈 배열을 반환하면 안 됩니다. 이 경우도 참조가 바뀌기 때문에 React는 리렌더링을 발생시킵니다.
이를 방지하려면, 미리 정의된 공유 EMPTY_ARRAY를 반환하도록 합니다:
const EMPTY_ARRAY: [] = []
const selectCompletedTodos = createSelector(
[(state: RootState) => state.todos],
todos => {
const completedTodos = todos.filter(todo => todo.completed)
return completedTodos.length === 0 ? EMPTY_ARRAY : completedTodos
}
)
또는, 반복되는 패턴을 줄이기 위해 재사용 가능한 유틸리티로 감쌀 수 있습니다:
const EMPTY_ARRAY: [] = []
export const fallbackToEmptyArray = <T>(array: T[]) => {
return array.length === 0 ? EMPTY_ARRAY : array
}
const selectCompletedTodos = createSelector(
[(state: RootState) => state.todos],
todos => fallbackToEmptyArray(todos.filter(todo => todo.completed))
)
이렇게 하면 빈 배열이 연속해서 반환될 경우 항상 동일한 참조를 사용하게 되어, 불필요한 컴포넌트 리렌더링을 방지할 수 있습니다.
Redux Saga
What is Redux Saga
Redux-Saga는 ES6 generator functions를 사용하여 Redux에서 복잡한 비동기 흐름을 명확하고 선언적으로 모델링할 수 있도록 해주는 side-effect 관리 라이브러리입니다. 깊이 중첩된 async/await 또는 Promise 체이닝 대신, Redux-Saga는 동기 코드처럼 보이는 비동기 로직을 작성할 수 있게 해줍니다.
How does it work?
Redux-Saga에서는 Redux 액션을 감지하고, API 호출, 지연된 작업, 조건 분기 등의 side-effect를 관리하는 “saga” 라는 generator 함수를 작성합니다. 이 saga들은 병렬 실행, 레이스 조건 처리, 취소, 재시작 등이 가능하며, 순수 JavaScript 구문과 call, put, take, fork, select 등의 강력한 effect creator를 사용합니다.
컴포넌트나 리듀서와 side-effect를 분리함으로써, Redux-Saga는 비즈니스 로직을 더 모듈화하고 테스트 가능하게 유지할 수 있도록 도와줍니다. 하지만 정확하게 saga를 작성하려면 몇 가지 규칙과 패턴에 대한 이해가 필요합니다.
Structuring Saga Folders by domain
- 모든 saga는
src/sagas폴더에 위치합니다. - 각 도메인은 별도 폴더를 가집니다.
src/
└──sagas/
└──account/ # 도메인 단위 폴더
└──index.ts # selector 함수들
Naming Saga Functions
코드베이스 전반에서 일관성, 가독성, 유지보수성을 높이기 위해 saga 함수와 디스패치된 액션의 이름을 명확하게 매핑합니다.
1. Dispatch Action Creators, Not Raw Action Objects
saga 안에서 put을 사용할 때는 액션 객체를 직접 작성하지 말고, 반드시 액션 생성 함수(action creator) 를 사용하세요. 이는 타입 안정성을 높이고 리팩토링을 쉽게 만들어줍니다.
Bad:
yield put({ type: Actions.GET_USER_PROFILE })
Good:
yield put(getUserProfile())
2. Name Saga Functions Based on Action Creators
특정 액션을 처리하는 saga 함수는 해당 액션 생성 함수의 이름에 Saga 접미사를 붙이는 방식으로 네이밍합니다.
Bad:
takeEvery(SagaActions.GET_USER_PROFILE, fetchUserProfile)
Good:
takeEvery(SagaActions.GET_USER_PROFILE, getUserProfileSaga)
이 네이밍 규칙은 모든 watcher-effect (takeEvery, takeLatest 등)에 적용되어야 합니다. 이를 통해 saga를 추적하고 디버깅하거나 신규 개발자가 코드를 이해하는 데 큰 도움이 됩니다.
Keep Your Sagas Pure
Saga 함수는 리듀서처럼 순수하고 결정적이어야 합니다. 외부에서 API를 직접 호출하거나 Redux state에 직접 접근하는 대신, 반드시 call, select 같은 effect를 사용해야 합니다.
Bad:
// Anti-pattern
const data = api.fetchUser(userId)
Good:
const data = yield call(api.fetchUser, userId)
Use take, takeEvery, takeLatest, takeLeading, and takeMaybe Appropriately
Redux-Saga는 다양한 effect를 제공하여 액션 또는 채널 메시지에 대응하는 방식을 제어할 수 있게 합니다. 적절한 효과를 선택하면 메모리 누수, 퍼포먼스 이슈, race condition을 피할 수 있습니다.
take(pattern)
- Blocking effect입니다 — 해당 액션이 발생할 때까지 saga 실행을 중단합니다.
- 한 번만 처리하며, 반복적으로 사용하려면 루프나
race안에서 써야 합니다.
yield take('USER_LOGGED_IN')
사용 예:
- 특정 액션이 발생할 때까지 대기해야 할 때
- 흐름 제어를 세밀하게 하고 싶을 때
takeMaybe(pattern)
take과 같지만, saga가 이미 cancel되었거나 종료되었을 경우 예외를 발생시키지 않음.finally블록 내부 또는 안전한 취소 처리가 필요한 곳에서 유용합니다.
const action = yield takeMaybe('LOGOUT_CONFIRMED')
사용 예:
finally블록에서SagaCancellationException을 방지하고 싶을 때- 선택적 이벤트를 안전하게 처리하고 싶을 때
takeEvery(pattern, saga, ...args)
- 일치하는 모든 액션에 대해 새로운 saga 인스턴스를 실행합니다.
- 병렬로 실행되며, 이전 작업을 취소하지 않습니다.
yield takeEvery('TRACK_EVENT', trackEventSaga)
사용 예:
- 로깅, 애널리틱스, 병렬 업로드 등 모든 이벤트를 개별 처리할 때
takeLatest(pattern, saga, ...args)
- 가장 마지막 액션에 대해 saga를 실행하고, 이전 실행 중인 saga는 취소합니다.
yield takeLatest('SEARCH_QUERY', searchSaga)
사용 예:
- 검색어 입력처럼, 마지막 결과만 중요할 때
- 중복 요청이나 race condition을 방지하고 싶을 때
takeLeading(pattern, saga, ...args)
- 첫 번째 액션에 대해서만 실행하고, 해당 saga가 종료되기 전까지 이후 액션은 무시합니다.
yield takeLeading('SUBMIT_FORM', submitFormSaga)
사용 예:
- 중복 실행을 방지해야 할 때 (예: 결제, 업로드)
take* with Channels
위에 나온 모든 take 계열 함수(take, takeEvery, takeLatest, takeLeading, takeMaybe)는 채널(channel) 입력도 받을 수 있습니다. 이는 다음과 같은 경우에 사용됩니다:
- 커스텀 이벤트 소스(예: WebSocket, Native bridge 등)를 처리할 때
- 메시지 수동 제어가 필요할 때
const channel = yield actionChannel('SOCKET_MESSAGE')
while (true) {
const message = yield take(channel)
yield call(handleMessage, message)
}
Use fork for Non-blocking Parallel Tasks
fork는 부모 saga의 실행을 차단하지 않고 비동기적으로 자식 saga를 실행합니다. 주로 watcher, 백그라운드 작업 등에 사용됩니다.
yield fork(watchAccountUpdates)
Combine fork and join for Structured Concurrency
fork된 여러 작업이 모두 끝날 때까지 기다리고 싶을 경우 join을 사용하여 병렬 처리된 작업을 안전하게 동기화할 수 있습니다.
const task1 = yield fork(loadUser)
const task2 = yield fork(loadSettings)
yield join(task1)
yield join(task2)
Use spawn for Detached and Crash-safe Parallel Tasks
spawn은 부모 saga와 완전히 독립적인 비동기 작업을 실행합니다. 부모가 실패하거나 종료되어도 영향을 받지 않습니다.
yield spawn(trackAppUptime)
Use try/catch for Error Handling
saga 로직은 항상 try/catch로 감싸 예외 발생 시 복구 로직을 실행하거나 fallback 액션을 디스패치할 수 있어야 합니다.
function* fetchUserSaga(action) {
try {
const user = yield call(api.fetchUser, action.payload.id)
yield put({ type: 'FETCH_USER_SUCCESS', payload: user })
} catch (error) {
yield put({ type: 'FETCH_USER_FAILURE', error })
}
}
Debounce or Throttle Expensive Actions
입력 이벤트처럼 자주 발생할 수 있는 액션에 대해서는 debounce 또는 throttle을 적용해 과도한 saga 호출을 막고 성능을 최적화합니다.
import { debounce } from 'redux-saga/effects'
function* watchSearchInput() {
yield debounce(300, 'SEARCH_INPUT_CHANGED', handleSearch)
}
Use select to Access State
Redux state를 읽을 때는 select를 사용하세요. selector 함수는 모듈화되고 재사용 가능해야 합니다.
const selectUserId = (state) => state.auth.userId
function* someSaga() {
const userId = yield select(selectUserId)
}
Cancel Background Tasks When Needed
사용자가 로그아웃하거나 화면을 이동할 때, 백그라운드에서 실행 중인 saga를 cancel 또는 race를 이용해 종료할 수 있어야 합니다.
function* watchRouteChanges() {
const task = yield fork(someLongTask)
yield take('NAVIGATE_AWAY')
yield cancel(task)
}
Redux Persist
What is Redux Persist
Redux-Persist는 Redux store의 상태를 AsyncStorage(React Native)와 같은 지속 가능한 스토리지에 자동으로 저장하고, 앱이 재시작되거나 다시 로드될 때 해당 상태를 복원(rehydrate)해주는 라이브러리입니다.
이를 통해 사용자의 로그인 세션 유지, 사용자 설정 정보 보존 등의 시나리오를 구현할 수 있습니다.
Only Persist What You Need
Redux store 전체를 저장하면 저장 용량이 커지고 rehydrate 속도가 느려질 수 있습니다. 반드시 복원이 필요한 slice만 선택적으로 저장하세요.
ts
CopyEdit
const persistConfig = {
key: 'root',
storage: AsyncStorage,
whitelist: ['auth', 'settings'] // 필요한 slice만 저장
}
whitelist: 저장할 slice를 명시blacklist: 저장에서 제외할 slice를 명시
불필요한 데이터까지 저장하면 성능 저하로 이어질 수 있습니다.
Use version + migrate for Safe Store Evolution
Redux store의 구조가 변경되는 경우, version과 migrate를 활용해 이전에 저장된 데이터를 새로운 구조로 안전하게 마이그레이션할 수 있습니다.
tsx
CopyEdit
const migrations = {
1: (state: any) => {
const { removedKey, ...restExample } = state.example || {}
return {
...state,
example: restExample, // `removedKey` 키 제거됨
}
},
}
const persistConfig = {
key: 'root',
version: 1,
storage: AsyncStorage,
migrate: createMigrate(migrations, { debug: false }) // 버전 변경 시 마이그레이션 자동 적용
}
Avoid Persisting UI State
모달 열림 여부, 스크롤 위치 등과 같은 일시적 UI 상태는 Redux-Persist를 통해 저장하면 안 됩니다. 이러한 상태는 앱이 재시작되면 초기화되어야 합니다.
Don’t Persist Sensitive Information
비밀번호, 토큰, 개인 정보 등 민감한 데이터는 Redux-Persist로 저장해서는 안 됩니다. 대신 Keychain, SecureStore와 같은 보안 저장소를 사용하는 것이 안전합니다.
References
https://redux.js.org/style-guide/ https://redux.js.org/tutorials/fundamentals/part-1-overview
https://reselect.js.org/usage/best-practices
https://redux-saga.js.org/docs/About
https://redux.js.org/usage/deriving-data-selectors#calculating-derived-data-with-selectors