Redux 패턴 도입 배경
이전 프로젝트에서 단방향 흐름인 MVI 패턴으로 구현하여 오류 발생 시 디버깅에 큰 장점을 누릴 수 있었지만 View별로 정의된 State 정의로 인해 State 간 상태 공유 관리가 생각보다 까다롭게 느껴졌습니다. 이에 단방향의 흐름을 유지하면서 한 곳에서 상태를 관리해줄 수 있는 방법이 없을까 하는 생각에 구글링을 하던 중 React 진영에서 상태를 한 곳(Store)에서 관리해주는 아키텍처인 Redux가 Swift에서도 구현가능하다는 정보를 접하여 이번에 새로 시작하려는 프로젝트부터 Redux 패턴을 도입하게 되었습니다.
상태(State)란?
우선 Redux에 대해 알아보기 전에 State란 무엇인지부터 살펴보겠습니다.
Redux 공식문서를 확인해보시면 State의 정의는 다음과 같이 작성되어 있습니다.
This state can include server responses and cached data, as well as locally created data that has not yet been persisted to the server. UI state is also increasing in complexity, as we need to manage active routes, selected tabs, spinners, pagination controls, and so on. - Redux 공식문서
위 글의 첫문장을 해석해보자면 'State란 서버 응답, 캐시 데이터, 로컬에서 생성되었지만 서버에 저장되지 않은 데이터'를 의미한다고 합니다. 여기에 추가적으로 '화면전환에 사용되는 Route(Swift에선 Navigation), 선택된 탭, 스피너, 페이지네이션 제어 등 각종 UI 상태'도 포함된다고 합니다.
Redux란?
자 이제 본격적으로 Redux에 대해 살펴보겠습니다.
Redux 탄생배경
Redux는 2011년부터 Angular, Ember, Backbone 등 Javascript MVC 프레인워크들에서 시작된 양방향 데이터 바인딩으로 인한 발생하는 예측불가능한 상태 관리를 해결하기 위해 Meta가(구 Facebook) 2014년 단방향 데이터 흐름을 가진 Flux 아키텍처를 발표하게 됩니다. 그러다가 이 아키텍처를 더욱 발전시켜 2015년에 Redux 라이브러리를 발표하게 됩니다. - Redux 공식 문서
Flux 아키텍처란?
그렇다면 Flux 아키텍처란 무엇일까요?
Flux 아키텍처는 아래 이미지에서 보실 수 있듯이 Dispatcher -> Store -> View 순서의 단방향 데이터 흐름을 가지며, View에서 발생한 이벤트 즉, Action을 통해서만 상태(State)가 변화되는 아키텍처를 의미합니다.
각 Flux 아키텍처 요소에 대해 간략히 설명해보자면 다음과 같습니다.
- Action: 사용자가 일으키는 모든 이벤트를 의미합니다.
- Dispatcher: Flux 아키텍처에서 모든 데이터 흐름을 관리하는 일종의 허브 역할을 합니다. Action이 발생하면 Dispatcher를 통해 Store에 접근합니다.
- Store: 현재 어플리케이션의 상태를 저장합니다. 상태 변경은 오직 Action을 통해 Dispatcher를 거처야만 가능합니다.
- View: 상태를 Store에서 가져와 사용자에게 보여주고, 사용자로부터 이벤트를 받습니다.
그렇다면 Redux 아키텍처란?
그렇다면 이 Flux 아키텍처를 기반으로 탄생한 Redux 아키텍처란 무엇일까요?
사실 Redux는 Flux 아키텍처를 기반으로 탄생하였기 때문에 Flux 아키텍처와 거의 유사한 구조를 보이고 있습니다.
하지만 단 하나 Flux 아키텍처와 다른 점이 있는데 그건 바로 Flux 아키텍처에서 사용되는 개념인 Dispatcher가 Redux에선 Reducer로 정의된다는 것입니다.
바로 Redux 구조부터 살펴보겠습니다.
Redux의 각 요소에 대해 설명하면 다음과 같습니다.
- Action : View 단에서 발생한 일을 Action type으로 정의.
- Dispatch : Action을 Store에 전달하여 Reducer 함수를 실행하게 하는 것.
- Reducer : 새로운 상태를 반환하는 함수.
- Store : 단일 객체에 상태가 저장 되는 곳.
- Middleware : Action이 Dispatch 되고 Reducer 함수가 실행 되기 전에 특정 작업을 진행할 수 있도록 도와주는 함수.
위 구성요소들을 살펴보면 Flux 아키텍처와 거의 동일하다는 사실을 알 수 있습니다.
Redux 구현
Redux 공식문서에 따르면 구현에 있어 크게 다음 3가지 원칙이 존재한다고 합니다.
- Single source of truth : 애플리케이션의 모든 상태는 하나의 저장소 안에 하나의 객체 트리 구조에 저장됩니다.
- State is read-only : 상태를 변화시키는 유일한 방법은 무슨 일이 벌어지는지를 묘사하는 Action 통해서만 가능하다.
- Changes are made with pure functions : Action에 의해 상태 트리가 어떻게 변화하는지 지정하기 위해 Reducer를 순수 함수를 작성해야합니다.
자 이제 그러면 본격적으로 Swift에서 Redux 패턴을 구현해보도록 하겠습니다.
Redux 구현은 아래 사이트에서 보여주는 Redux 구현 패턴을 기반하였습니다.
https://www.kodeco.com/22096649-getting-a-redux-vibe-into-swiftui
State
우선 가장 먼저 앱 상태를 나타내는 State부터 구현해보겠습니다.
아래 코드를 보시면 앱에 대한 전체 State는 구조체를 통해 전역으로 정의해주고 그 내부에 세부 State들을 정의하여 앱에 대한 전체 상태들을 한 곳에서 관리하도록 해주었습니다.
struct AppState {
var tabState = TabState()
var navigationState = NavigationState()
//...
struct TabState {
//...
}
struct NavigationState {
//...
}
}
Action
그 다음 Action을 구현해보겠습니다.
아래 코드를 보시면 State를 정의할 때와 마찬가지로 앱에 대한 전체 Action는 열거형을 통해 전역적으로 정의해주고, 그 내부 세부 Action들을 정의하여 앱에 대한 전체 액션들을 한 곳에 관리하도록 해주었습니다.
enum AppAction {
case navigationAction(NavigationAction)
case loginAction(LoginAction)
enum NavigationAction {
//...
}
enum LoginAction {
//...
}
}
Reducer
그 다음 Reducer를 구현해보겠습니다.
Redux 패턴 정의에 의하면 외부 함수 호출 없이 오직 순수 함수로만 정의되어야만 하므로 아래와 같이 정의해줍니다.
아래 코드를 살펴보시면 순수 함수 구현을 위해 State값의 복사본인 mutatingState를 새로 정의하여 새로운 상태를 반환해줍니다.
typealias Reducer<State, Action> = (State, Action) -> State
let appReducer: Reducer<AppState, AppAction> = { state, action in
var mutatingState = state
switch action {
//...
}
return mutatingState
}
Middleware
그 다음 Middleware를 구현해보겠습니다.
아래 코드를 보시면 형태는 Reducer 정의와 거의 유사하다는 것을 알 수 있습니다.
다만 한 부분이 다른데 바로 반환값이 Combine이 사용되었다는 것입니다.
이는 Middleware의 경우, Redux에서 정의하고 있는 Side Effects 즉, 비동기 API 호출, Logging, 충톨 리포팅(Crash Reporting) 등과 같은 작업을 수행하는 부분이기 때문에 비동기 처리가 필요한 본래 Middleware의 개념에 맞추어 반환값을 Combine으로 해주었습니다.
typealias Middleware<State, Action> = (State, Action) -> AnyPublisher<Action, Never>
let appMiddleware: Middleware<AppState, AppAction> = { state, action in
switch action {
//...
}
return Empty().eraseToAnyPublisher()
}
Store
마지막으로 Store를 구현해보겠습니다.
아래 코드를 보시면 Store에서 State가 변화할 때마다 View가 업데이트되어야 하므로 Store는 ObservableObject 프로토콜을 준수하고, State에 대한 변수 state를 선언해준 뒤 @Published 프로퍼티 래퍼로 정의해줍니다.
그리고 State, Reducer, Middleware은 Store에 속한 요소이므로 이니셜라이저를 통해 각 요소에 대해 초기값을 입력받습니다.
typealias AppStore = Store<AppState, AppAction>
final class Store<State, Action>: ObservableObject {
@Published private(set) var state: State
private let reducer: Reducer<State, Action>
private let middlewares: [Middleware<State, Action>]
private let queue = DispatchQueue(label: "serialQueue", qos: .userInitiated)
private var subscriptions: Set<AnyCancellable> = []
init(
initial: State,
reducer: @escaping Reducer<State, Action>,
middlewares: [Middleware<State, Action>] = []
) {
self.state = initial
self.reducer = reducer
self.middlewares = middlewares
}
// dispatch 함수 구현
}
그 다음 Store 내부에 사용자로부터 Action을 받아 State를 업데이트하기 위해 dispatch 함수를 정의해줍니다.
아래 코드를 보시면 Action을 실행하는 큐 queue가 동기적으로(Synchronously) 정의되어 있는 것을 보실 수 있는데요.
이는 각 액션들이 도착하는 순서대로 실행되고, 각 액션이 실행될 때마다 상태(State)가 최신으로 유지되도록 보장해줍니다.
이는 멀티스레딩 환경에서 상태 일관성을 보장해줍니다.
func dispatch(_ action: Action) {
queue.sync { [weak self] in
guard let self else { return }
dispatch(state, action)
}
}
private func dispatch(_ currentState: State, _ action: Action) {
let newState = reducer(currentState, action)
middlewares.forEach { middleware in
let publisher = middleware(newState, action)
publisher
.receive(on: DispatchQueue.main)
.sink(receiveValue: dispatch)
.store(in: &subscriptions)
}
state = newState
}
Store 생성 및 적용
자 이제 실제로 Store를 생성하여 적용해보도록 하겠습니다.
먼저 View의 최상단으로 이동하여 Store를 아래와 같이 정의해줍니다.
private let store = AppStore(
initial: AppState(),
reducer: appReducer,
middlewares: [appMiddleware]
)
그런 다음 아래 코드와 같이 .environmentObject() modifier를 통해 View 전역적으로 공유해줍니다.
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(store)
}
}
최상단의 View에서 Store 생성 및 적용에 대한 전체 코드는 다음과 같습니다.
@main
struct App: App {
private let store = AppStore(
initial: AppState(),
reducer: appReducer,
middlewares: [appMiddleware]
)
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(store)
}
}
}
참고 사이트
https://redux.js.org/ (Redux 공식 사이트)
https://hasensprung.tistory.com/147 (Redux 도입)
https://www.theteams.kr/teams/2664/post/67906 (Redux 적용 사례)
'TIL' 카테고리의 다른 글
[TIL] 결제 시스템 구조 간단 정리(개념 정리) (0) | 2024.08.04 |
---|---|
[TIL] 열거형 Equatable 프로토콜 준수로 대소 비교 구현(feat. Alert 문제 해결) (0) | 2024.07.19 |
[TIL] SwiftUI에서 네트워크 단절 대응 구현기 (0) | 2024.06.25 |
[TIL] @AppStorage Custom Property Wrapper로 전환기 (0) | 2024.06.17 |
[TIL] 여러 Destination이 있을 때 화면 관리법 (0) | 2024.06.16 |