안녕하세요. 후르륵짭짭입니다.
태풍도 오고 비도 많이 오고 정신없이 바쁘게 보낸거 같습니다.
최근에는 반복적인 일상에서 많이 바뀌어 가는 것 같습니다.
하고 싶은 것도 많아지고 알고 싶은 것도 많아지고 지금 보다 더 긍정적으로 살아보려고 합니다.
그래서 이번에 최근에 공부한 것은 SwiftUI에서 사용하는 Composable Architecture에 대해 알아보려고 합니다.
** Composable Architecture **
https://github.com/pointfreeco/swift-composable-architecture
본격적으로 해당 내용을 정리하기 전에 영어 단어 부터 알아보려고 합니다 ^ ^.
Compostion은 구성, 작곡 이란 뜻을 가지고 있습니다.
Composable Architecture는 구성할 수 있는 구조라는 의미겠죠.
아직은 미숙해서 계속 알아가려고 노력하고 있는데, 분리가 잘돼서 막 구성할수 있는 IOS 아키텍트 입니다.
1. 상태관리 - 다양한 View에서 Struct와 같은 Value Type으로 어떻게 상태관리를 할지
2. 구성 - 커다란 특징을 가진 기능을 작은 구성으로 나눠서 모듈화 시킬수 있을지.
3. 부작용 - 테스팅이 가능하고 이해하기 쉽게 만들 수 있을지. 그래서 부작용 최소화
4. 테스팅 - 단위 테스트 뿐만 아니라 통합 테스팅 까지 하는 방법
5. 인체공학 - 위에 모든 것을 간단하게 성취하는것
이러한 것들에 큰 장점이 있다고 합니다.
사실 아키텍트를 알아간다는 것은 깔끔하게 집을 설계하는 것과 같죠.
MVVM과 클린 아키텍트도 마찮가지이고 TCA도 SwiftUI에 적합하게 만들어진 아키텍트 입니다.
** State Manage **
어떤 App을 만들든지 상태값을 저장하고 있는 Object가 필요합니다.
근데 그 부분을 SwiftUI에서 View Struct에 넣을 수도 있죠!
struct ContentView: View {
@State var name : String
var body: some View {
,,, 생략 ,,
}
}
위와 같이 name이라는 상태값을 View 자체에 넣고 name의 Property가 변경 될 때,
View를 다시 그려주게 해줄 수 있습니다.
이렇게 해서 상태값을 관리 해줄 수도 있고 MVVM 디자인 패턴을 사용한다면 ViewModel에서 관리 해줄 수도 있습니다.
하지만 이럴 경우 상태가 파편화 되는 느낌을 받습니다.
State를 관리 해주는 좋은 친구가 있으면 좋겠다는 생각이 들겠죠.
struct AppState {
var count = 0
var favoritePrimes: [Int] = []
var activityFeed: [Activity] = []
var loggedInUser: User? = nil
struct Activity {
let timestamp: Date
let type: ActivityType
enum ActivityType {
case addedFavoritePrime(Int)
case removedFavoritePrime(Int)
}
}
struct User {
let id: Int
let name: String
let bio: String
}
}
그래서 위와 같이 AppState라는 State를 매니징 해주는 Manager를 만들어 줍니다.
근데 다른 곳에서도 사용할 수도 있지만 Struct라는 Value 타입을 했습니다.
Class라면 reference Type이라서 여기저기 값을 변경해줄 수 있죠.
이런 것을 방지하기 위해서 Struct의 Value 타입으로 State를 관리해줍니다.
그런데 아시다 싶이 Value 타입은 외부에서 변경 해줄 때 var 타입으로 해줘야하고
매개변수로 받은 경우 Local 변수를 만들어줘야 합니다.
그래서 등장한 기능이 Store와 Reducer 입니다.
** Store **
Store는 State를 관리해주는 Wrapper Object 입니다.
즉 상태값이 변경 되면 그것을 View에서 알려줘야하죠.
그런 Observing 기능을 담당하고 있는 녀석입니다.
//AppState를 관리하기 위한 Wrapper Object
//Generic Type으로 하여 어떠한 Object라도 들어갈 수 있도록 함
//Store<AppState> 이런 씩으로
final class Store<Value, Action>: ObservableObject {
let reducer : (inout Value, Action) -> Void
@Published var value : Value
init(value: Value, reducer : @escaping (inout Value, Action) -> Void ) {
self.value = value
self.reducer = reducer
}
func send(_ action : Action) {
self.reducer(&self.value, action)
}
}
위의 코드를 보면 Store는 Reference Type으로 되어 있고 상속을 받지 않습니다.
ObservableObject를 사용하기 위해서 Class로 한 겁니다.
Generic Type으로 Value와 Action을 받고 있네요.
Action은 나중에 설명하고 Value에는 State가 들어가야합니다.
그리고 해당 State가 변경 되는 것을 감지하기 위해 Published라는 옵져빙을 걸고 있습니다.
그러면 실제로 사용하는 곳에서는 어떻게 될까요?
struct ContentView: View {
@ObservedObject var store : Store<AppState,AppAction>
var body: some View {,,, 생략 ,,, }
}
이렇게 사용되는 겁니다.
** Action **
Action은 행동을 이야기 합니다.
구체적인 행동에 대한 정의가 아니라 그냥 행위 자체에 대한 네이밍 입니다.
예를들어 밥 먹다는 행위가 구체적으로 숫가락을 들고 밥에 숫가락을 넣고 이런 구체적인 행위는 Reducer가 정의하고
단순 행위 자체에 대한 이름 밥 먹는다는 행위가 Action이 됩니다.
그래서 아래 처럼 AppAction이라는 Store와 같은 Action의 관리해주는 Wrapper가 있고
세부적으로 나눌 수 있습니다.
//사용자에게 직접적으로 어떤 역할을 하는지 알려줄 수 있다.
enum AppAction {
case counter(CounterAction)
case primeModal(PrimeModalAction)
case favoritePrimes(FavoritePrimeAction)
}
enum CounterAction {
case decrTapped
case incrTapped
}
enum FavoritePrimeAction {
case deleteFavoritePrime(IndexSet)
}
enum PrimeModalAction {
case saveFavoritePrimeTapped
case removeFavoritePrimeTapped
}
** Reducer **
TCA에서 행위에 대한 구체적인 정의를 내려주는 곳이 Reducer 입니다.
가장 난해하고 이해하기 어려운 부분이기도 했는데요.
Store에서 아래와 같이 init 시점에 Reducer를 받습니다.
final class Store<Value, Action>: ObservableObject {
let reducer : (inout Value, Action) -> Void
@Published var value : Value
init(value: Value, reducer : @escaping (inout Value, Action) -> Void ) {
self.value = value
self.reducer = reducer
}
func send(_ action : Action) {
self.reducer(&self.value, action)
}
}
reducer는 Value 타입의 State와 Action을 받습니다.
Value 타입의 값을 변경하기 위해서 주소값을 받는 inout으로 정의가 되어 있고요.
그리고 마지막에 send라는 메소드를 통해서 init 시점 정의 된 reducer를 수행하게 되네요.
//Large Reducer
func appReducer(state : inout AppState , action : AppAction) {
switch action {
case .counter(.decrTapped):
state.count -= 1
case .counter(.incrTapped):
state.count -= 1
,,, 생략 ,,,
}
}
이렇게 Store에 동일하게 받는 appReducer라는 메소드를 만들어 줍니다.
그리고 enum으로 되어 있는 action을 switch 문으로 구분 해줍니다.
그리고 행위에 대해 내려주게 됩니다.
불론 여러개의 Action이 있다면 appReducer에 점점 많아지겠죠.
이것을 방지하기 위해서 appReducer를 작은 단위의 component로 나눈것 방법도 있는데,
다음에 작성하도록 하겠습니다.
** 실제로 사용 **
struct StateManagerApp: App {
var body: some Scene {
WindowGroup {
ContentView(store: Store(value: AppState(), reducer: appReducer))
}
}
}
이렇게 사용하게 되겠죠?
Store에 State를 옵져빙하는 AppState와 행위에 대해 구체적으로 정의한 메소드인 reducer를 넣어주고
contentView에 넣어줍니다.
var body: some View {
VStack(content: {
HStack(content: {
Button(action: {
self.store.send(.counter(.decrTapped))
}, label: {
Text("-")
})
Text("\(self.store.value.count)")
Button(action: {
self.store.send(.counter(.incrTapped))
}, label: {
Text("+")
})
})
,,, 생략 ,,,
}
}
예를들어 위와 같이 있다고 할때, store에 정의한 send 메소드에 Action을 넣어줍니다.
그러면 Reducer 메소드가 수행이 될 것이고 action에 따라 구분해서 행위를 수행하게 될 겁니다.
여기까지가 제가 TCA를 공부한 가장 기초적인 구조입니다.
다음에는 Reducer와 Action에 대한 PullBack 기능과 Reducer를 작은 단위로 나누는 방법 등에 대해서 작성해보려고 합니다.
** 참고 사이트 **
https://www.pointfree.co/collections/composable-architecture
'Design Pattern' 카테고리의 다른 글
SwiftUI) The Composable Architecture - 3(feat: Effect ) (0) | 2023.10.21 |
---|
댓글