TCA - Shared State
안녕하세요. 그린입니다 🍏
이번 포스팅에서는 TCA에서 사용하는 Shared State에 대해 알아보겠습니다 🙋🏻
해당 포스팅에서는 가볍게 어떤것이고 어떻게 사용하는지에 대해서 살펴볼거에요 😀
만약 Shared State에 대해 더 깊게 보시기 위해서는 포인트프리의 Shared State 에피소드를 보시는걸 추천드려요!
그럼, 가볍게 산책하듯이 알아볼까요?
Shared State
TCA의 Shared State는 올해 2월부터 베타 릴리즈를 거쳐 4월쯤 정식으로 나왔기에 그렇게 오래되진 않았어요!
해당 툴을 이용해 여러 기능 간에 상태를 공유할 수 있어 한 기능이 상태를 변경하면 다른 모든 기능이 즉시 해당 변경 사항을 바라볼 수 있게됩니다.
즉, 공유 상태를 뜻하는 Shared State이죠 😃
공유 상태를 저장하는 방법에 유저디폴츠, 파일 시스템 그리고 잠재적으로 SQLite 등과 같은 더 많은 시스템과 같은 외부 시스템에 공유 상태를 유지할 수 있도록 구현해놨습니다.
그럼 우선적으로 올해 2월 26일에 포인트프리 블로그에 베타 발표를 하며 처음 나온 Shared State 포스팅을 통해 알아볼께요 🙋🏻
@Shared property wrapper
가장 중요한 @Shared라는 프로퍼티 래퍼를 알아야 합니다.
해당 프로퍼티 래퍼를 사용하여 전체 앱에서 공유될 상태의 프로퍼티를 지정해줄 수 있어요.
모든 데이터 유형에 사용할 수 있으며 기본값으로 설정할순 없습니다.
예시를 볼까요?
@Reducer
struct ParentFeature {
@ObservableState
struct State {
@Shared var count: Int
// Other properties
}
…
}
해당 피쳐에서 카운트 변수를 공유 상태값으로 만들기 위해서 @Shared 프로퍼티 래퍼를 붙일 수 있습니다.
즉, 다른 피쳐들에서 해당 공유 값을 사용 및 변경할 수 있죠.
보시는것처럼 직접 기본값을 제공할 수 없죠.
해당 피쳐에서 하위로 상태를 전달해줘야 합니다.
아래와 같이 하위 피쳐가 있다고 가정해볼께요.
@Reducer
struct ChildFeature {
@ObservableState
struct State {
@Shared var count: Int
// Other properties
}
…
}
해당 하위 피쳐에서도 공유 값에 대해 접근하고 있습니다.
이때, 상위 피쳐가 하위 피쳐를 상태로 가지고 생성한다면 아래처럼 해당 공유 값을 넘겨서 참조 count를 전달할 수 있습니다.
case .presentButtonTapped:
state.child = ChildFeature.State(count: state.$count)
그럼, 해당 하위 피쳐의 count 변화에 따라 수행되는 mutate들이 즉시 수행되고, 반대로 하위에서 상위로도 동일하게 즉시 수행됩니다.
해당 Shared 타입은 참조 타입을 유지하여 앱의 여러 부분이 동일한 상태를 보고 각각 변경할 수 있도록 동작해요.
기존 타입의 참조 유형은 두가지 이유로 문제가 있었습니다.
1️⃣ 참조 타입은 SwiftUI 뷰 무효화와 어울리지 않는 점
참조 내부의 데이터가 뷰에 변경되었다는 사실을 알리지 않고 변경되어 뷰가 즉시 업데이트되지 않는 경우가 있었습니다.
2️⃣ 참조 타입은 테스트와 어울리지 않는 점
클래스는 Equatable을 쉽게 채택할 수 없고 된다해도 어떻게 변경되는지 철저히 증명하기가 굉장히 까다로웠습니다.
하지만, 이제는 두 가지 문제에 대한 해결책이 존재하죠!
1️⃣ SwiftUI의 새로운 옵저베이션 도구인 back-fort 덕분에 참조 타입은 뷰가 변경될 때 뷰와 적절히 통신해 뷰를 무효화하고 다시 렌더링 할 수 있게 되었습니다.
2️⃣ 프로퍼티 래퍼가 있어 @Shared 테스트를 전적으로 할 수 있게 되었습니다.
Persistence
@Shared 프로퍼티 래퍼는 상태를 전체 앱에 전역적으로 사용할 수 있는 지속성과 함께 사용할 수 있고, 데이터를 외부 시스템에 지속시켜 앱 시작 시마다 사용할 수 있도록도 할 수 있습니다.
앞서, 해당 공유 값엔 기본값을 넣을 수 없다고 했지만 아래와 같이 모든 변경 사항이 자동으로 유지되고 다음에 앱을 시작할 때 사용하게 할 수 있도록 해볼 수 있습니다.
@Reducer
struct ParentFeature {
@ObservableState
struct State {
@Shared(.appStorage("count")) var count = 0
// Other properties
}
…
}
이렇게 .appStorage와 같이 사용하면 기본값을 줄 수 있죠.
아니 명확히는, 지속성 전략이라고 하는데, 이걸 사용할때는 기본값을 제공해야 합니다.
이는 상태에 처음 액세스할 때와 이전에 저장된 상태가 없는 경우에만 사용됩니다.
즉, 상태가 변경되어 존재한다면 그 값으로 반영되죠!
만약 초기화 함수에서 사용해야 한다면 이렇게 사용해볼 수 있습니다 😃
@Reducer
struct ParentFeature {
@ObservableState
struct State {
@Shared count: Int
}
init(count: @autoclosure () -> Int = 0) {
self._count = Shared(wrappedValue: count(), .inMemory("count"))
}
}
이렇게 한다면 위에서 우리가 상위 피쳐에서 명시적으로 전달했던것도 할 필요가 없습니다.
해당 상태에 대한 모든 변경 사항은 즉시 유지되고, count 키 값에 반영하면 @Shared 기능의 상태도 즉시 업데이트되죠!
그런데 사실, 해당 appStore 전략은 제한이 있기에 데이터 종류에 따라 제한됩니다.
기본 자료형인 문자열, 정수, 부울 등과 같은 간단한 데이터에 적합하죠!
(기본적인 Swift를 학습할때를 생각해보면 동일하죠ㅎㅎ)
좀 더 복잡한 타입에 경우엔 .fileStorage 전략이 유리합니다.
@ObservableState
struct State: Equatable {
@Shared(.fileStorage(.syncUps)) var syncUps: IdentifiedArrayOf<SyncUp> = []
}
디스크에 저장하는것이죠.
또한, inMemory 전략도 존재하는데 이는 보관할 값의 종류에 대한 제한은 없지만 실제로 데이터를 지속하진 않아요.
즉, 앱을 재시작하면 지워지는것이죠~
SwiftData의 in-memory와 유사하죠.
마지막으로 직접 지속성 전략을 만들수도 있어요!
PersistentKey 프로토콜을 채택하는 새로운 타입으로 몇가지 요구사항을 구현한다면 @Shared와 함께 지속성 전략을 커스텀하게 구현해 사용할 수 있습니다.
즉, 지속성 전략에 대해 정리하면 이렇습니다.
1️⃣ appStorage, inMemory, fileStorage 기본적으로 구현된 3가지 전략과 커스텀하게 구현할 수 있음
2️⃣ 각 값 유형과 지속기간에 맞는 전략을 택해야 함
어렵지 않죠?
그럼 간단히 주로 어떻게 사용하는지 한번 코드 예시를 볼까요?
// 피쳐 1️⃣
@Reducer
struct Feature1 {
@ObservableState
struct State {
@Shared(.appStorage("count")) var count = 0
}
...
enum Action {
case onAppear
case countChanged(Int)
...
}
public var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .onAppear:
return Effect.publisher {
state.$count.publisher
.map(Action.countChanged)
}
...
}
}
}
}
// 피쳐 2️⃣
@Reducer
struct Feature2 {
@ObservableState
struct State {
@Shared(.appStorage("count")) var count = 0
}
...
enum Action {
case countTapped
...
}
public var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .countTapped:
state.count += 1
...
}
}
}
}
이런식으로 사용할 수 있습니다.
먼저 피쳐 1과 2가 있습니다.
상호 독립적이죠.
둘다 count라는 공유 상태를 가지고 있어요.
이때, 피쳐 2에서 countTapped 액션이 발행되면 count를 1 증가 시켜요.
그럼, 피쳐 1에서도 해당 공유 값이 동기화 되겠죠?
해당 피쳐 1을 보면, onAppear 액션에서 해당 count값을 퍼블리셔로 취급하여 비동기 바인딩을 해주고 있습니다.
즉, 해당 값을 바인딩시켜 변화되면 countChanged 액션을 호출하여 처리되는것이죠.
이렇게, 공유 상태 값을 가지고 실시간 반영 등 적절히 처리해둘 수 있습니다.
Testing
공유 상태는 TCA 기능에서 보관되는 일반 상태와 상당히 다르게 동작해요.
작업이 스토어로 전송될 때뿐 아니라 앱의 모든 부분에서 변경될 수 있으며 참조에 가깝습니다.
일반적으로 참조는 테스트에서 일련의 문제를 일으켜왔어요.
참조는 복사할 수 없기에 작업이 전송되기 전과 후에 변경 사항을 검사할 수 없기 때문이죠.
이러한 이유들로 @Shared 프로퍼티 래퍼는 테스트 중 추가 작업을 수행해 상태의 이전 스냅샷을 보존함으로 참조인 경우에도 공유 상태를 테스트할 수 있습니다.
예시로 아래와 같이 카운터 피쳐가 있습니다.
@Reducer
struct Feature {
struct State: Equatable {
@Shared var count: Int
}
enum Action {
case incrementButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .incrementButtonTapped:
state.count += 1
return .none
}
}
}
}
해당 기능은 이제 비공유 상태를 사용할때와 똑같이 아래처럼 테스트할 수 있죠.
func testIncrement() async {
let store = TestStore(
initialState: Feature.State(count: Shared(0))
) {
Feature()
}
await store.send(.incrementButtonTapped) {
$0.count = 1
}
}
만약 우리가 count가 1이 아닌 2로 잘못 넣으면 곧바로 아래와 같이 테스트 실패를 던져주죠!
즉, @Shared count가 참조 유형이여도 잘 동작합니다.
참조이기때문에 요렇게도 가능하죠.
case .incrementButtonTapped:
return .run { [count = state.$count] _ in
count.wrappedValue += 1
}
근데 이렇게 구현되면 이제 count가 더 이상 리듀서에서 직접 증가하지 않기에 TestStore assertion에서 후행 클로저를 삭제해야 합니다.
func testIncrement() async {
let store = TestStore(
initialState: SimpleFeature.State(count: Shared(0))
) {
SimpleFeature()
}
await store.send(.incrementButtonTapped)
}
대신 이러면 우리는 해당 이펙트 동작의 테스트를 하지 않는것이죠.
그래서 요렇게 돌리면 테스트 실패가 나타나요!
대신 명시적으로 테스트가 끝날 때 공유 count 상태를 명시적으로 나타내주기 위해 assert 메서드를 활용해볼 수 있습니다.
func testIncrement() async {
let store = TestStore(
initialState: SimpleFeature.State(count: Shared(0))
) {
SimpleFeature()
}
await store.send(.incrementButtonTapped)
store.state.$count.assert {
$0 = 1
}
}
이렇게 말이죠 😃
마무리
오랜만에 TCA를 사용하면서 Shared State가 나와서 알아봤습니다 🙋🏻
전역적인 상태는 이전 1.0 버전 이전부터 다들 많이들 원했던것이고 불편했던 부분이였는데요.
물론, 자체적으로 해결하고 있었겠지만 이렇게 오피셜하게 나오니 편하네요 👍