SwiftUI TCA 네이버페이 워치앱 적용기

전윤찬
NAVER Pay Dev Blog
Published in
34 min readJun 9, 2023

안녕하세요.
네이버파이낸셜 앱개발파트에서 iOS앱을 개발하는 전윤찬입니다.

최근 SwiftUI에서 MVVM을 사용하는 것에 대한 부정적인 내용의 글을 몇 가지 볼 수 있었습니다. 해당 내용을 아주 짧게 요약하면 이미 뷰모델의 기능까지 뷰에서 처리할 수 있도록 SwiftUI에서 지원하고 있는데 이를 뷰모델을 거치게 만들어 불필요한 일을 하게 만들고 선언형 언어인 SwiftUI와 그 동작 방식이 잘 맞지 않는다는 내용입니다.

SwiftUI를 적용해 개발하면서 MVVM 방식을 어떤 방식으로 적용할지 고민하면서 뷰모델개발 시 ObservableObject를 사용하면 데이터의 변화를 바로 View에 반영할 수 있어 SwiftUI와 잘 맞는다고 생각하며 개발하고 있어 위와 같은 문제 제기에 워치앱에 적용한 방식도 동일한 문제가 있는지 확인하고 싶었고 그 대안으로 언급되고 있는 TCA(The Composable Architecture)에 대한 호기심이 생겼습니다.
TCA는 사실 그동안 접하지 못했던 방식이라 자세히 살펴보면서 네이버페이 워치앱에 적용된 MVVM방식과 비슷한 부분이 많은 것을 알게 되었고 추가적으로 개발하면서 답답했던 부분을 더 잘 정리할 수 있는 방식이라고 생각해 이를 적용해 보기로결정했습니다.

앞으로 전통적인 MVVM 사용의 문제와 네이버페이 워치앱에 적용한 ViewState MVVM을 비교해 보고 새롭게 도입한 TCA의 분석과 전환과정을 살펴보겠습니다.

SwiftUI에서 MVVM사용에 대한 비판

아래 링크를 살펴보면 SwiftUI에서 MVVM사용 시 어떤 점이 문제가 되는지 설명하고 있습니다.

위와 같은 이미지로 MVVM 사용의 문제를 표현하고 있는데 SwiftUI 자체로 충분한데 MVVM을 이용하면서 오히려 성능을 저하시키고 있다는 내용으로 공통적으로 언급되는 문제를 아래와 같이 정리할 수 있습니다.

  • View 단독으로 ViewModel의 역할 가능
    기존 MVVM의 ViewModel은 데이터 바인딩을 위해 필요하지만 SwiftUI는 View 자체적으로 데이터 바인딩이 가능한 PropertyWrapper를 지원하기에 ViewModel이 더 이상 필요 없다는 의견으로 아래 코드를 비교해 보면 View에서 간단하게 처리 가능한 내용을 ViewModel을 이용할 때 복잡해지는 예를 확인할 수 있습니다.
  • SwiftUI와 데이터 흐름의 충돌
    선언형 언어인 SwiftUI에서 데이터 흐름은 단방향을 지향하는데 ViewModel은 데이터의 흐름을 양방향으로 만들고 복잡도를 증가시킨다.
    아래 그림 처럼 ViewModel이 View와 연동될 때 명확한 구조가 없어 개발 상황에 따라 데이터를 주고받으면서 서로 강하게 엮이는게 상황이 발생할 수 있다는 논리로 설명하고 있습니다.

위와 같은 문제로 SwiftUI에서 MVVM 사용에 대한 문제 의견을 확인하며 이런 문제가 타당한지 또한 현실적으로 개선 가능한지 고민해 보았습니다.

ViewModel 없이 View에서 모든 기능을 구현하는 것은 짧은 코드에서는 그럴듯해 보이지만 실무에서 복잡도가 높은 화면을 개발할 때도 효율적인지는 의문이 들었습니다
api호출이 여러 부분에서 발생하고 화면 구성을 위해 사용하는 데이터가 더 많아지고 여러 방식의 에러 노출을 해야 하며 유저 이벤트의 진입 점이 많고 복잡한 비지니스 로직이 있는 화면 이라면?? 여전히 이런 것들의 처리를 정리해서 실행해 줄 무언가가 필요하다고 생각했습니다.
만약 View에서 모든 것을 해결한다면 UIKit으로 개발시 ViewController에 대부분의 기능을 구현하는 MVC와 같은 형태가 되면서 View가 너무 복잡해지는 문제가 예상되기 때문입니다.

데이터의 흐름 문제는 이미 많이 알려진 문제로 MVVM의 데이터 흐름을 단방향으로 만들어주는 여러 대안들이 나와 있는 상태라 이 부분은 큰 문제라고 생각하지 않았습니다.

결국 View의 복잡도를 줄여주기 위한 아키텍처가 필요하며 이때 선택의 기준으로 데이터의 흐름을 단방향으로 관리하고 좀 더 선언형 언어에 잘 어울리는 형태를 선택하면 위에서 언급한 문제를 잘 해결할 수 있을 것으로 생각했습니다.

워치앱에서 사용한 ViewState MVVM

네이버페이 워치앱을 SwiftUI로 처음 개발하면서 적당한 아키텍처를 찾아봤고 ViewState MVVM이라는 녀석을 참고해 적용하기로 결정했습니다

ViewModel에 State와 Inout이라는 것을 정의해 View에 데이터를 업데이트하고 이벤트를 처리하는 방식으로 ObservableObject를 이용해 ViewModel을 만들고 @Published@StateObject를 이용해 State의 변화를 View에 자동으로 반영하도록 하는 구조로 아래와 같은 데이터 흐름을 가집니다.

ViewState MVVM의 구현

ViewModel은 아래와 같은 Protocol을 이용해 구현합니다.

import Combine

protocol ViewModel: ObservableObject {
associatedtype State
associatedtype Input

var state: State { get }
func trigger(_ input: Input)
}

ObservableObject를 이용해 ViewModel의 변화를 View에서 인지할 수 있도록 하며 State는 상태를 나타내는 프로퍼티를 Input은 이벤트를 정의해 trigger()를 통해 이벤트를 전달받아 처리하는 흐름을 만들어 동작하며 실제 아래와 같이 구현합니다.

import Combine
import SwiftUI
import Swinject

struct CouponState {
// View 바인딩 데이터
var coupons: [CouponViewModel] = []
var errorType: ErrorType?
var reloadCoupons = false
var isLoading = false
}

enum CouponInput {
// 뷰와 연동하는 이벤트
case selected(coupon: Coupon)
case refresh
}

class CouponListViewModel: ViewModel {
@Published var state: CouponState
private var couponsUseCase: PayUsableCouponsGetUseCase?

init(resolver: Resolver) {
couponsUseCase = resolver.resolve(PayUsableCouponsGetUseCase.self)
state = CouponState(coupons: [])
}

func trigger(_ input: CouponInput) {
switch input {
case .selected(let coupon):
// 바코드 노출
case .refresh:
// 쿠폰 리스트 Api호출
executeFetch()
}
}
}

extension CouponListViewModel {
private func executeFetch() {
guard !state.isLoading else { return }

state.isLoading = true
Task {
do {
let couponVMs = try await couponsUseCase.execute()
if couponVMs.isEmpty {
self.state.errorType = WatchError.Coupon.empty
} else {
// 쿠폰 리스트 갱신
self.state.coupons = couponVMs
}
state.isLoading = false
} catch let error {
let errorType = WatchErrorHandler.handleError(error)
state.errorType = errorType
state.isLoading = false
}
}
}

@Published로 선언된 State에 속한 값이 업데이트될 때마다 뷰에 해당 사항이 반영되도록 하는 방식으로 위의 예에서 뷰에서 전달된 .refresh 이벤트를 받으면 executeFetch()를 실행해 결과를 State에 업데이트하는 부분을 확인하실 수 있습니다.
이렇게 구현한 뷰모델은 아래와 같은 방식으로 뷰와 연동하게 됩니다.

struct CouponList: View {
@StateObject var viewModel: CouponListViewModel

var body: some View {
ZStack {
// state의 쿠폰 리스트 연동
CarouselList(viewModel.state.coupons,
id: \.coupon.productOrderNo,
rowContent: { couponVM in
CouponCell(viewModel: couponVM)
}, onSelected: { coupon in
viewModel.trigger(.selected(coupon: coupon))
})

viewModel.state.errorType.map({ errorType in
ErrorView(errorType: errorType)
})

IndicatorView().isHidden(!$viewModel.state.isLoading)
}
.onAppear {
// 최초 노출시 이벤트 전달
viewModel.trigger(.refresh)
}
}
}

@StateObject를 이용해 뷰모델을 구독해 뷰모델의 State와 바인딩을 하며 뷰에서 발생하는 이벤트는 trigger()를 이용해 이를 뷰모델에 전달하며 하나의 단방향 흐름을 만들게 됩니다

위와 같이 ViewState MVVM 방식을 이용해 구현하면 단방향 데이터 흐름을 만들어 관리 할 수 있으며 간단한 구조로 뷰와 뷰모델을 연동할 수 있어 단순하고 매력적인 방식으로 보입니다.

하지만 뷰모델에서 State와 Inout을 관리하는 부분이 복잡해지고 서버 데이터를 연동하는 API호출 부분도 계속 적으로 추가되면서 뷰모델에 로직이 집중되는 아쉬움이 있었습니다.
또한 뷰모델 간 데이터를 공유할 때 @EnvironmentObject을 이용해 약간의 전역적인 방법을 사용하거나 @Binding을 통해서 여러 단계를 거치며 데이터를 전달하는 부분이 처음은 좋아 보이지만 사용할수록 번거롭다는 불만을 가지게 되었습니다.

TCA를 살펴보면서 ViewState MVVM로 워치앱을 개발하면서 가졌던 약간의 불만을 어느 정도 해결할 수 있는 방법을 제시하고 있는 것을 확인했고 이를 일부 적용해 보면서 그 가능성을 확인했습니다. 그리고 지금은 네이버페이 워치앱에 TCA를 전면 도입하는 중입니다.

다음은 TCA의 구조를 살펴보며 이를 도입하게된 과정을 살펴보겠습니다

TCA(The Composable Architecture)

The Composable Architecture란

TCA는 Point-Free에서 SwiftUI와 UIKit을 모두 지원하며 State 관리와 작은 컴포넌트들의 조합해 개발을 확장하며 테스트에 중점을 둔 아키텍처로 Redux의 구조를 참고해 개발해 그 구성 요소가 매우 비슷합니다. 자세한 정보는 github의 내용을 참고하고 여기서는 그 구조와 기본적인 사용법을 확인하도록 합니다

설치 방법

SPM로 라이브러리 추가 저장소 URL: https://github.com/pointfreeco/swift-composable-architecture
TCA구현시 import ComposableArchitecture를 통해서 접근한다

TCA의 데이터 흐름

TCA의 데이터 흐름은 아래 그림과 같이 표현할 수 있습니다.
모든 구성요소의 데이터 흐름이 단방향으로 이동하고 있고 각 구성 요소는 Store로 관리되며 Dependency는 Store의 외부에서 주입해 사용하는 것을 알 수 있습니다.

TCA의 구성 요소

TCA로 구현시 필요한 각 구성 요소들과 간단한 설명 후 코드와 함께 추가적인 설명을 이어가겠습니다

  • State
    비즈니스 로직을 수행하거나 UI를 그릴 때 필요한 데이터의 집합
  • Action
    사용자로부터 발생하는 이벤트나 노티피케이션등 뷰에서 생길 수 있는 모든 action과 API호출 결과를 나타내는 타입입니다.
  • Dependency
    외부 시스템과 상호 작용하는 유형과 기능을 말하며 대표적으로 API 클라이언트가 있고 UUID나 Date의 초기화등도 포함합니다.
  • Effect
    네트워크 요청, 디스크에서 저장/로드, 타이머 생성, Dependency와 상호 작용과 같은 작업을 수행하며 Reduce()의 리턴값으로 사용됩니다.
  • Reduce
    Action을 전달받아 이를 처리 후 결과를 State의 상태를 변경해 UI를 업데이트하도록 하는 로직을 구현하는 메서드입니다.
    API 요청과 같은 이벤트를 Effect와 Dependency를 이용해 실행하고 Action을 이용해 결과를 다시 전달합니다.
  • Store
    실제로 TCA의 여러 기능을 실행할 수 있는 오브젝트로 사용자 액션을 스토어로 전송하여 Reducer와 Effect를 실행할 수 있도록 하고 스토어의 상태 변화를 관찰하여 UI를 업데이트할 수 있습니다.

위와 같은 구성 요소들을 이용해 TCA를 완성해가며 마치 ViewModel처럼 ReducerProtocol을 이용해 구조화된 하나의 오프젝트로 만들어 줍니다.
그리고 중요한 것은 핵심 로직과 동작을 SwiftUI 뷰에 완전히 분리하여 빌드할 수 있어 개발, 재사용, 테스트를 용이하도록 합니다.

그럼 각 구성요소들이 코드로 어떻게 구현되는지 살펴보겠습니다.

State

State는 View에서 노출하는 UI에 바인딩할 테이터를 정의한 것으로 화면에 노출할 모델이나 api호출 중임을 나타내는 플래그등을 추가해 사용하며 다른 Reducer에서 사용하는 State를 추가해 필요에 따라 데이터 연동에 사용할 수 있습니다.

struct State: Equatable {
var coupons: [CouponModel] = []
var sheetItem: SheetContent?
var errorType: ErrorType?
var isLoading = false
var giftState: GiftReducer.State?
}

Action

View에서 필요한 사용자 이벤트나 Api 호출 후 발생하는 응답등 VIew에서 발생하는 모든 이벤트를 정의한 것으로 Reducer에서 이 Action을 처리하며 다른 Reducer에서 사용하는 Action을 추가해 해당 이벤트 발생 시 이를 처리할 수 있습니다.
다른 Reducer의 Action을 처리할 수 있는 기능은 다른 뷰와 데이터를 연동할 수 있는 강력한 기능으로 이를 이용하면 뷰와 뷰사이에 데이터 전달 없이 Action 이벤트를 전달받아 상호작용 할 수 있습니다.

enum Action: Equatable {
case onAppear
case refresh
case selected(couponModel: CouponModel)
// API응답
case factResponse(TaskResult<[CouponModel]>)
case giftAction(GiftReducer.Action)
}

Dependency

대표적으로 API클라이언트등 의존성을 가지고 있어 외부에서 이를 주입해 사용이 필요한 부분에 사용합니다. 이는 API클라이언트를 완전히 분리해서 구현할 수 있는 장점이 있고 테스트 환경도 쉽게 적용이 가능해 잘 활용하면 기존 뷰모델의 로직 집중의 많은 부분을 정리할 수 있습니다.

DependencyValues에 필요한 타입을 등록하고 DependencyKey프로토콜을 사용해 이를 구현하며 liveValue, previewValue, testValue를 각각 구현하면 테스트등의 상황에서 쉽게 데이터 전환이 가능합니다.

커스텀벨류를 선언해 외부에서 필요한 파라미터를 주입할 수도 있으며 아래 예에서는 SwinjectResolver를 전달해 구현하고 있으며 실제 사용은 Reducer에서 설명하겠습니다.

// Dependency 사용시 선언부
@Dependency(\.couponsClient) var couponsClient
extension DependencyValues {
// Dependency타입 등록
var couponsClient: CouponsClient {
get { self[CouponsClient.self] }
set { self[CouponsClient.self] = newValue }
}
}

struct CouponsClient {
// Api 정의
var fetchCoupons: @Sendable () async -> Result<[CouponViewModel], ErrorTypeValue>
// ...
}

extension CouponsClient: DependencyKey {
static let noop = Self(
fetchCoupons: { .failure(WatchError.Common.failure.toErrorTypeValue()) }
)
static var liveValue: CouponsClient {
// 커스텀 벨류를 사용하고 있어 구현은 생략
return .noop
}
}

extension CouponsClient {
static func live(resolver: Resolver) -> Self {
let couponsFetcher = CouponsFetcher(resolver: resolver)

return CouponsClient(
fetchCoupons: {
// 실제 Api호출
return await couponsFetcher.requestCoupons()
})
}
}

private actor CouponsFetcher {
var resolver: Resolver

init(resolver: Resolver) {
self.resolver = resolver
}

func requestCoupons() async -> Result<[CouponViewModel], ErrorTypeValue> {
}
}

Reducer

ReducerProtocol을 이용해 구현하며 State, Action, Dependency를 선언하고 Reduce, Effect를 활용해 TCA의 데이터 흐름을 만들고 뷰와 연동되는 기본 로직을 실행하며 다른 Reducer와의 Action을 이용해 서로 연동할 수도 있는 강력한 기능을 제공합니다

특별히 Reduce는 전달받은 Action을 처리하고 Effect를 리턴하는 사이클로 뷰와의 연동하는 중요한 기능을 수행하는데 아래 코드를 확인하면서 순차적으로 살펴보겠습니다.

  1. .refresh 액션을 전달 받아 이를 Reduce { }에서 처리
  2. State의 flag를 설정하고 Api를 비동기로 호출하는 .task Effect 리턴
  3. Dependency로 선언한 couponsClient를 이용해 Api 호출하며 .fetchCouponsResponse로 결과 전달
  4. .fetchCouponsResponse에서 Api 응답을 이용해 State 설정
struct CouponsReducer: ReducerProtocol {
struct State: Equatable {
var coupons: [CouponViewModel] = []
var errorState: ErrorReducer.State?
var isLoading = false
}

enum Action {
case refresh
case fetchCouponsResponse(Result<[CouponViewModel], ErrorTypeValue>)
}

// Api클라이언트 선언
@Dependency(\.couponsClient) var couponsClient

var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
struct CouponsCancelId: Hashable {}
switch action {
case .refresh:
guard !state.isLoading else { break }

state.isLoading = true
state.errorState = nil

return .task {
// Api를 호출 후 .fetchCouponsResponse로 결과 전달
.fetchCouponsResponse(await couponsClient.fetchCoupons())
}.cancellable(id: CouponsCancelId.self)
case .fetchCouponsResponse(let result):
if case .success(let couponVMs) = result {
state.coupons = couponVMs
} else if case .failure(let errorType) = result {
state.errorState = .init(errorType: errorType)
}
state.isLoading = false
default:
break
}
return .none
}
}
}

Store

View에서 Reducer 의 State와 Action을 이용하는 타입으로 뷰 생성 시 전달하는 객체입니다. 대표적으로 ViewStoreScope이라는 기능을 제공해 View와 Reducer를 연동하도록 합니다

ViewStore

withViewStore()를 이용해 생성하며 viewStore.state로 State의 변경을 감지해 뷰를 업데이트하고 viewStore.send()로 Reducer에 Action이벤트를 전달할 수 있습니다.
또한 여러 Reducer의 State와 Action이 조합된 경우 특정 Reducer의 데이터를 한정해서 사용할 수 있는 기능도 제공해 불필요한 렌더링을 통한 성능 저하도 방지할 수 있습니다.

Scope

Scope을 이용하면 여러 Reducer가 조합된 Store에서 일부 상태와 액션을 추출하여 다른 Store를 생성할 수 있고 이를 통해 다른 뷰를 생성할수 있습니다.
이렇게 연계된 데이터를 통해 상위 Store로 Action이벤트를 공유할 수 있는 기능이 제공하며 이를 통해 하위뷰와 데이터 연동을 간단하게 처리할 수 있습니다.
Scope의 실제 활용은 아래 TCA의 활용에서 자세히 살펴보겠습니다.


// CouponsView 생성
let store = Store(initialState: CouponsReducer.State(), reducer: CouponsReducer()
.transformDependency(\.self) {
$0.couponsClient = .live(resolver: ServiceAssembler.shared.resolver)
})
CouponsView(store: store)


struct CouponsView: View {
let store: Store<CouponsReducer.State, CouponsReducer.Action>

var body: some View {
// ViewStore 생성
WithViewStore(self.store) { viewStore in
ZStack {
// State 참조
CarouselList(viewStore.state.coupons { couponVM in
CouponCell(viewModel: couponVM)
})

IndicatorView().isHidden(!viewStore.state.isLoading)
}
.onLoad {
// Action 전달
viewStore.send(.refresh)
}
}
}
}

TCA와 ViewState MVVM 비교 선택

장점

  • 단방향 데이터 흐름
    TCA의 구조를 보는 순간 현재 페이앱에서 사용하는 MVVM과 비슷한 부분이 많다고 생각했습니다 State와 Action을 두고 이를 각각 Reducer와 ViewModel로 관리하는 구조가 동일하기 때문입니다.
    이들은 데이터 흐름을 단방향으로 진행되도록 유지하고 있어 동일한 수준의 데이터 흐름을 지닌 것으로 판단합니다.
  • 테스트 용이성
    Dependency로 데이터 의존성 주입을 통해 데이터를 분리하고 테스트를 용이하게 한다
    워치앱은 TCA사용 이전에도 Clean Architecture기반으로 API를 구현하고 있고 Swinject를 이용해 API의 UseCase를 등록하고 이를 ViewModel생성시 주입해서 사용하고 테스트의 장점은 동일한 수준으로 생각합니다.
  • 코드의 분리
    Reducer 내부는 Dependency를 이용해 Api클라이언트를 분리해서 구현하고 있어 Action 처리 로직에 집중할 수 있으나 MVVM은 API호출 로직이 내부에 있고 자유롭게 여러 로직 추가가 가능해 내부 복잡도 증가에 항상 신경 써야 합니다.
  • 자유로운 데이터 연동
    Store에서 Reducer와 Scope을 이용하면 필요에 따라 자유롭게 데이터를 조합해 사용 가능하며 이를 이용해 하위 뷰와 편리하게 데이터 연동이 가능합니다

단점

  • 라이브러리에 의존적인 아키텍처
    아키텍처의 개념을 개발 환경이나 언어에 자유롭게 적용하는 것이 아닌 특정 라이브러리를 사용하면 해당 개발 방식에 종속적으로 개발하게 되면서 확장성과 유연성이 떨어지는 문제가 걱정됩니다.
    또한 내부 동작을 잘 알지 못해 문제 발생시 디버깅이 어려워지는 단점도 있습니다.

위 장단점을 확인 후 TCA를 적용하면서 코드 분리와 데이터 연동의 장점이 있고 ViewState MVVM의 단방향 데이터 흐름도 유지할 수 있어 현재 구조를 TCA로 전환하기로 결정했습니다.

TCA적용 후 위에 지적한 단점으로 인해 최악의 경우 다시 이전 MVVM으로 돌아가더라도 개발 환경이 워치앱이라는 제한적이고 비교적 작은 규모의 앱이라 작업에 대한 부담이 적은 것도 큰 이유로 작용했습니다.

ViewState MVVM에서 TCA로 전환

TCA는 여러 작은 단위의 Reducer를 조합해 그 기능을 확장하도록 디자인되었습니다. 이를 통에 같은 방법으로 구조화하고 컴포넌트를 모듈화하여 유지 보수성과 테스트 용이성을 높이는 방법을 제공하고 있습니다.
TCA의 기본 구조를 간단한 코드들로 어느 정도 파악했기에 Reducer와 Store의 Scope을 이용해 자식 뷰와 연동하고 다른 뷰로 랜딩하는 간단한 쿠폰 리스트를 개발하는 과정 통해 이런 장점을 확인해 보겠습니다.

Reducer의 활용

Reducer 내부에서 자식 Reducer를 생성해 이를 활용하기 위해서는 아래와 같은 세 가지가 필요합니다

  • 부모 State 내부의 자식 State 변수
  • 부모 Action 내부의 자식 Action 타입
  • 자식 Reducer를 생성하는 @ReducerBuilder 클로저

위와 같은 요소들을 실제 코드로 어떻게 구현하는지 확인해 보겠습니다.

struct CouponsReducer: ReducerProtocol {
struct childViewState: Equatable {
// 필요한 Reducer의 State를 추가
var bannerState: BannerReducer.State
var giftState: GiftReducer.State?
var barcodeState: BarcodeReducer.State?
var errorState: ErrorReducer.State?
}

struct State: Equatable {
var coupons: [CouponViewModel] = []
var errorState: ErrorReducer.State?
var isLoading = false
var childViewState = childViewState()
}

enum Action {
case refresh
case dismissView
case fetchCouponsResponse(Result<[CouponViewModel], ErrorTypeValue>)

// 연동이 필요한 Action을 추가
case bannerAction(BannerReducer.Action)
case showBarcode(store: BarcodeStore)
case giftAction(GiftReducer.Action)
case errorAction(ErrorReducer.Action)
}

var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
...
}
}
}

쿠폰 리스트는 아래와 같은 뷰를 활용해 구현하고 있습니다.

  • 상시 노출하는 BannerView
  • 쿠폰선택시 노출하는 BarcodeView
  • 쿠폰중 선물 선택시 노출하는 GiftView
  • 오류 발생시 노출하는 WatchErrorView

이때 필요한 Reducer를 구성하기 위해 각각의 State를 추가하며 당장 노출이 필요 없는 뷰의 State는 옵셔널로 선언합니다.

자식 뷰에서 발생하는 Action(버튼 선택, Api 응답)의 활용이 필요하면 이를 추가하고 이렇게 추가한 Action은 이후 Reduce 메서드를 통해 전달받을 수 있습니다.

struct CouponsReducer: ReducerProtocol {
struct childViewState: Equatable {
...
}
struct State: Equatable {
...
}
enum Action {
...
}

@Dependency(\.couponsClient) var couponsClient

var body: some ReducerProtocol<State, Action> {
Scope(state: \.childViewState.bannerState, action: /Action.bannerAction) {
// BannerView 노출에 사용
BannerReducer()
}
Reduce { state, action in
struct CouponsCancelId: Hashable {}
switch action {
case .refresh:
guard !state.isLoading else { break }

return .task {
.fetchCouponsResponse(await couponsClient.fetchCoupons())
}.cancellable(id: CouponsCancelId.self)

case .selected(let couponViewModel):
if couponViewModel.isGift {
// 선물받은 쿠폰 선택시 선물받기 giftState를 생성
state.childViewState.giftState = .init(coupon: couponViewModel.coupon)
} else {
// 쿠폰 선택시 바코드 barcodeState를 생성
state.childViewState.barcodeState = .init(code: couponViewModel.barcode)
}

case dismissView:
state.childViewState.giftState = nil
state.childViewState.barcodeState = nil

case .fetchCouponsResponse(let result):
if case .success(let couponVMs) = result {
state.coupons = couponVMs
} else if case .failure(let errorType) = result {
// 오류 발생시 errorState를 생성
state.childViewState.errorState = .init(errorType: errorType)
}

case let .giftAction(.fetchAcceptResponse(result)):
if case .success(let isAccept) = result,
isAccept {
// GiftView에서 선물 받기 성공시 해당 이벤트를 받아 새로고침
return .send(.refresh)
}

case let .giftAction(.fetchCancelResponse(result)):
if case .success(let cancelled) = result,
cancelled {
// GiftView에서 선물 받기 취소 성공시 해당 이벤트를 받아 새로고침
return .send(.refresh)
}

case .errorAction(.buttonTapped(let buttonType)):
if case .retry = buttonType {
// 쿠폰 리스트
return .send(.refresh)
}
}
return .none
}
.ifLet(\.childViewState.giftState, action: /Action.giftAction) {
// giftState를 설정하면 이를 감지해 GiftReducer를 생성
GiftReducer()
.transformDependency(\.self) {
$0.giftClient = .live(resolver: ServiceAssembler.shared.resolver)
}
}
.ifLet(\.childViewState.barcodeState, action: /Action.barcodeAction) {
// barcodeState를 설정하면 이를 감지해 BarcodeReducer를 생성
BarcodeReducer()
}
.ifLet(\.childViewState.errorState, action: /Action.errorAction) {
// errorState를 설정하면 이를 감지해 BarcodeReducer를 생성
ErrorReducer()
}
}
}

BannerReducer를 생성하는 Scope()은 자식 State를 Key Value방식으로 접근해 해당 값을 얻고 Action에 추가한 타입을 전달합니다. 그리고@ReducerBuilder 클로저 내부에서 이들을 활용해 자식 Reducer를 생성합니다.
나머지 Reducer를 만드는 .ifLet은 이름에서 유추할 수 있는데 State가 옵셔널일때 사용하며 State에 값이 추가되는 시점 예를 들면 쿠폰 선택이나 Error발생시 동작하게 되며 비슷한 기능의 .ifCaseLet(), .forEach()도 지원하고 있습니다.

.giftAction, .errorAction Action을 처리하는 부분을 확인하시면 아래 그림과 같이 자식 뷰의 Action을 직접 처리하면서 데이터를 공유하거나 Completion Handler를 이용할 필요가 없어 연동 구조를 매우 단순하게 구현할 수 있습니다.

Scope을 이용한 View 연동

CouponsView에서 Store의 Scope을 통해 각 Reducer에 접근하며 필요한 시점에 자유롭게 State에 값을 추가해 해당하는 자식 뷰를 노출할 수 있습니다. 이를 통해 각 View가 필요로 하는 데이터를 관리하고, 상태 변화에 따라 자동으로 뷰를 노출하는 UI를 구현할 수 있습니다.

import ComposableArchitecture

struct CouponsView: View {
let store: CouponsStore
@ObservedObject var viewStore: CouponsViewStore

init(store: CouponsStore) {
self.store = store
// viewStore 생성( WithViewStore()가 아닌 이런식의 변수로 선언해 사용 가능
self.viewStore = ViewStore(store)
}

var body: some View {
ZStack {
VStack {
// bannerView 노출
BannerView(
store: store.scope(state: \.childViewState.bannerState,
action: CouponsReducer.AppAction.bannerAction)
)

CarouselList(viewStore.state.coupons { couponVM in
CouponCell(viewModel: couponVM)
}, onSelected: { couponVM in
viewStore.send(.selected(couponViewModel: couponVM))
})
}

// errorState에 값이 있으면 scope을 이용해 WatchErrorView 노출
IfLetStore(
store.scope(
state: \.childViewState.errorState,
action: CouponsReducer.Action.errorAction
),
then: { errorStore in
WatchErrorView(store: errorStore)
})
}
.onLoad {
viewStore.send(.refresh)
}
.sheet(item: viewStore.binding(
get: \.childViewState.giftState,
send: .dismissView), content: { _ in
// giftState에 값이 있으면 scope을 이용해 GiftView로 화면 전환
IfLetStore(
store.scope(
state: \.childViewState.giftState,
action: GiftReducer.Action.giftAction
),
then: { giftStore in
GiftView(store: giftStore)
})
})
}
}

WatchErrorView를 확인해 보면 store.scope을 실행할 때 errorStateaerrorAction을 이용해 errorStore를 얻고 이것으로 뷰를 생성하는 것을 확인할 수 있습니다.
뷰를 생성할 때 필요한 데이터는 Reducer의 State에 설정하면서 실제 뷰의 구조는 매우 단순하게 구현할 수 있게 되고 다른 뷰와 연동을 위해 추가적인 구현없이 발생되는 Action을 처리할 수 있게됩니다.

Scope 이용시 옵셔널로 선언된 State는 IfLetStore()를 통해서 접근하고 ForEachStore(), SwitchStore()등의 다른 방법도 이용할 수 있습니다.

위와 같이 앱을 SwiftUI로 구현시 TCA를 활용하는 방법을 간단하게 확인했으며 아래의 튜토리얼과 개발 가이드를 참고하시면 더욱 자세한 내용을 확인하실 수 있습니다.

마치며

지금까지 MVVM과의 비교를 통해 네이버페이 워치앱에 TCA를 적용한 과정과 간단한 개발 가이드를 작성해 봤습니다.

TCA를 도입하면서 간단한 사용 예제는 있었지만 아직 본격적으로 사용하는 곳은 별로 없다는 인상을 받아 이 구조를 계속적으로 유지하면서 발전시킬 수 있을지에 대한 확신은 없었지만 SwiftUI에서 뷰를 매우 효율적으로 컨트롤할 수 있는 방법임을 확인했고 새로운 기능들이 빠르게 추가되고 있어 앞으로의 발전을 기대하고 있습니다.

추가적으로 State의 local로직 처리와 Action 종류에 따라 그 처리를 세분화하고 TCA의 구조에 어울리는 Coordinator 패턴을 고민하고 있으며 이런 것들을 잘 정리해 다시 한번 소개해 드릴 수 있기를 바랍니다.

--

--