Haru's 개발 블로그

[SwiftUI] TCA 정리 본문

IOS/SwiftUI

[SwiftUI] TCA 정리

Haru_29 2023. 3. 1. 23:33

The Composable Architecture(TCA) 뜻


The Composable Architecture(TCA)는 일관되고 이해할 수 있는 방식으로 어플리케이션을 만들기 위해 탄생한 라이브러리입니다.

합성(Composition), 테스팅(Testing) 그리고 인체 공학(Ergonomics)을 염두에 둔 TCA는 SwiftUI, UIKit을 지원하며 모든 애플 플랫폼(iOS, macOS, tvOS, watchOS)에서 사용 가능합니다.

The Composable Architecture 구성 요소


  • 상태(State) 관리 : 간단한 값 타입들로 어플리케이션의 상태를 관리하는 방법, 상태를 공유를 통해 화면에서 일어나는 변화(Mutation)를 다른 화면에서 즉시 관측(Observe)하는 방법을 제공합니다.
  • 합성(Composition) : 기능을 여러 개의 독립된 모듈로 추출하는 방법, 이 모듈을 다시 합쳐서 거대한 기능을 작은 컴포넌트의 집합으로 구성하는 방법을 제공합니다.
  • 사이드 이펙트(Side Effects) : 어플리케이션 바깥세상과 접촉하는 작업을 테스트할 수 있고 이해하기 쉽게 작성하는 방법을 제공합니다.
  • 테스팅(Testing) : 아키텍처 내부의 기능을 테스트하는 방법뿐만 아니라 여러 파트로 구성된 기능의 통합 테스트를 작성하는 방법, 사이드 이펙트가 어플리케이션에 끼치는 영향에 대해 전체 테스트를 작성하는 방법을 제공합니다. 이 테스트 방식은 여러분의 비즈니스 로직이 예상대로 잘 작동하는지에 대한 강한 보증도 제공합니다.
  • 인체 공학(Ergonomics) : 위의 내용을 가능한 한 적은 개념의 간단한 API로 이루는 방법을 제공합니다.

TCA 장점


  1. 단순히 로직을 관측 가능한 객체나 다양한 UI 컴포넌트의 클로저에 흩뿌리는 것보다, 상태 변경을 적용하는 것에 일관된 태도를 가지도록 해줍니다.
  2. 사이드 이펙트를 간결하게 표현하는 방법도 제공합니다.
  3. 추가적인 작업 없이 이펙트가 포함된 로직을 바로 테스트할 수도 있습니다.

TCA 사용법


TCA를 통해 기능을 만들기 위해서는 도메인 구성하는 몇 가지 타입을 정의해야 한다.

  • 상태(State) : 비즈니스 로직을 수행하거나 UI를 그릴 때 필요한 데이터에 대한 설명을 나타내는 타입입니다.
  • 행동(Action): 사용자가 하는 행동이나 노티피케이션 등 어플리케이션에서 생길 수 있는 모든 행동을 나타내는 타입입니다.
  • 환경(Environment) : API 클라이언트나 애널리틱스 클라이언트와 같이 어플리케이션이 필요로 하는 의존성(Dependency)을 가지고 있는 타입입니다.
  • 리듀서(Reducer) : 어떤 행동(Action)이 주어졌을 때 지금 상태(State)를 다음 상태로 변화시키는 방법을 가지고 있는 함수입니다. 또한 리듀서는 실행할 수 있는 이펙트(Effect, 예시: API 리퀘스트)를 반환해야 하며, 보통은 Effect 값을 반환합니다.
  • 스토어(Store) : 실제로 기능이 작동하는 공간입니다. 우리는 사용자 행동(Action)을 보내서 스토어(Store)는 리듀서(Reducer)와 이펙트(Effect)를 실행할 수 있고, 스토어(Store)에서 일어나는 상태(State) 변화를 관측(observe)해서 UI를 업데이트할 수도 있습니다.

TCA 예시


구현 목표

  1. 화면에 숫자와 이 숫자를 증가 시킬 수 있는 + 버튼, 감소시킬 수 있는 - 버튼이 존재
  2. 더 다양한 행동을 위해 탭 하면 API 호출을 해서 숫자에 관한 무작위 사실을 알림창으로 보여주는 버튼도 추가

상태(State) : 화면의 숫자를 정수로 가짐, 알림창을 보여 줄 때 필요한 숫자도 동시에 가짐(알림창에 뜰 필요가 없는 경우 nil로 처리)

struct AppState: Equatable {
	var count = 0
	var numberFactAlert: String?
}

행동(Action) : 증가 버튼이나 감소 버튼을 누르는 행동, 알림창을 닫거나 무작위 사실 API 리퀘스트 결과를 받았을 때 발생하는 행동

enum AppAction: Equatable {
	case factAlertDismissed
	case decrementButtonTapped
	case incrementButtonTapped
	case numberFactButtonTapped
	case numberFactResponse(Result<String, ApiError>)
}

struct ApiError: Error, Equatable {}

환경(Environment) : 의존성(Dependency)을 관리하는 작업을 할 것입니다. 위의 예시를 따라가면 숫자에 관한 사실을 가져오는 경우 네트워크 리퀘스트를 요약해서 Effect값으로 만드는 작업이 존재! 이 작업의 의존성은 Int를 받아서 Effect<String, ApiError> 를 반환하는 함수가 되겠습니다. 여기서 String은 리퀘스트의 리스폰스를 요약한 값입니다. 이펙트는 통산적으로 백그라운드 스레드에서 작업을 처리 할 예정(Ex.URLSession)! 이제 이펙트 값을 메인 큐로 받아와야 할 필요가 있습니다.메인 큐 스케줄러를 사용해야 테스트를 작성할 수 있습니다. AnyScheduler를 사용해서 프로덕션에선 DispatchQueue 를 사용하고 테스트 시엔 테스트 스케줄러를 사용하기!

struct AppEnvironment {
	var mainQueue: AnySchedulerOf<DispatchQueue>
	var numberFact: (Int) -> Effect<String, ApiError>
}

리듀서(Reducer) : ****현재의 상태(State)를 변화시켜서 다음 상태를 만드는 설명과 어떤 이펙트(Effect)가 실행되는지 설명이 필요! , 만약 이펙트가 실행되지 않는 경우는 .none으로 처리하기

let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
	switch action {
	case .factAlertDismissed:
		state.numberFactAlert = nil
		return .none

	case .decrementButtonTapped:
		state.count -= 1
		return .none

	case .incrementButtonTapped:
		state.count += 1
		return .none

	case .numberFactButtonTapped:
		return environment.numberFact(state.count)
			.receive(on: environment.mainQueue)
			.catchToEffect()
			.map(AppAction.numberFactResponse)

	case .numberFactResponse(.success(fact)):
    state.numberFactAlert = fact
    return .none

	case .numberFactResponse(.failure):
		state.numberFactAlert = "Could not load a number fact :{"
		return .none
	}
}

View 정의하기 : Store<AppState, AppAction>가 있으면 모든 상태 변화를 관측하고 UI를 다시 한번 그리는 것이 있으며 사용자 행동을 보내서 상태를 변화할 수도 있습니다!

아래의 예시는.alert View Modifier가 요구하는 대로 숫자에 관한 사실을 구조체로 한 번 감싸서서 Identifiable을 따르게 만들기!

struct Appview: View {
	let store: Store<Appstate, AppAction>

	var body: some View {
		WithViewStore(self.store) { viewStore in
			VStack {
        HStack {
          Button("−") { viewStore.send(.decrementButtonTapped) }
          Text("\\(viewStore.count)")
          Button("+") { viewStore.send(.incrementButtonTapped) }
        }

        Button("Number fact") { viewStore.send(.numberFactButtonTapped) }
      }
      .alert(
        item: viewStore.binding(
          get: { $0.numberFactAlert.map(FactAlert.init(title:)) },
          send: .factAlertDismissed
        ),
        content: { Alert(title: Text($0.title)) }
      )
    }
  }
}

struct FactAlert: Identifiable {
  var title: String
  var id: String { self.title }
}

스토어(Store) : 디펜던시 제공, Api 리퀘스트를 생략하기 위한 문자열을 mock해서 바로 반환 해주는 Effect를 주입해주기!

let appView = AppView {
	store: Store{
		initialState: AppState(),
		reducer: appReducer,
		environment: AppEnvironment(
			mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
			numberFact: { number in Effect(value: "\(number) is a good number Brent") }
		)
	)
)
Comments