Environment는 어디갔나?
TCA는 State, Action, Reducer, Environment, View, Store를 기본 구성으로 갖는다.
이전 포스트에 위와 같은 말을 한 적이 있다. 근데, 예제를 설명하면서 State, Action, Reducer, View, Store 객체에 대한 설명은 했지만 Environment 객체는 찾아볼 수 없었다. (의존성이 객체 형식으로 Reducer에 주입된 것이 아니라 코드로 이어붙이기했다.)
이는 TCA가 최신 버전으로 Release 되면서 TCA 아키텍처를 코드로 정의할 때 아키텍처의 일부 구성이 변경되었기 때문이다.
지난 게시글의 예제
화면에 숫자와 이 숫자를 증가할 수 있는 + 버튼, 감소할 수 있는 - 버튼, 더 다양한 행동을 위해 탭 하면 API 호출을 해서 숫자에 관한 무작위 사실을 알림창으로 보여주는 버튼을 갖는 View를 구현한다고 하자.
를 구버전 TCA 사용방식으로 Environment와 Reducer를 작성해보면 아래 코드처럼 정의된다.
Environment: 의존성(Dependency)을 관리하는 환경(Environment)을 정의한다.
이 예제에서 의존성은 'API로부터 랜덤한 숫자 하나를 가져오는 것'이다.
따라서 Network Request로 숫자와 관련된 데이터를 가져온 경우, 정리해서 Effect 타입으로 결과 값을 변환해야 한다. API 결과 값으로 Int를 받아서 Effect<String, ApiError>를 반환하는 의존성 함수를 정의했다. 여기서 String은 Network의 Response를 나타내는 값이다.
Effect는 통상적으로 백그라운드 스레드에서 작업을 처리 (URLSession 처럼) 하게 될 것이다.
우리는 테스트 작성을 위해서 이펙트의 값을 메인 큐에서 받을 방법이 필요하다. AnyScheduler를 사용해서 프로덕션에선 DispatchQueue를 사용하고 테스트 시엔 테스트 스케줄러(메인 큐)를 사용한다.
struct AppEnvironment {
var mainQueue: AnySchedulerOf<DispatchQueue>
var numberFact: (Int) -> Effect<String, ApiError>
}
단순하게 정리하자면, 외부 API 클라이언트에 의존성(Dependency)을 갖게 되는 경우 이러한 의존성을 가지고 있는 타입을 Environment 라고 하는데 이 객체를 별도로 정의해줬다.
Reducer는 아래와 같이 메소드 형식이 아닌 클로저 타입의 변수로 정의했다.
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 let .numberFactResponse(.success(fact)):
state.numberFactAlert = fact
return .none
case .numberFactResponse(.failure):
state.numberFactAlert = "Could not load a number fact :("
return .none
}
}
기존에는 State, Action, Environment, Reducer를 하나의 Store 안에 정의하지 않고 각자 따로 정의했다.
또한 reducer 를 클로저 타입의 변수로 정의해서 해당 reducer에 Environment를 객체 형식으로 주입하여 비동기 처리와 같은 의존성 기능을 가졌다. 그리고 Effect 결과 값에 따른 Action 과정을 다시 진행했다. 그럼 사용할 때 어떻게 사용했냐면 다음처럼 View를 정의할 때 따로 정의한 State, Action, Environment, Reducer를 Store.init 호출시 각 요소를 파라미터로 전달하여 사용했다.
struct AppState {
...
}
struct AppEnvironment {
...
}
let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
...
}
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") }
)
)
)
이 때 우리는 테스트 환경에서도 별도의 의존성을 작성하고 관리할 때 번거로움이 있었고 View 마다 존재하는 하나의 도메인 Store를 정의할 때에도 별도로 의존성을 정의해서 할당해줘야하는데
어차피 하나의 Store에는 정해진 도메인의 기능이 있을터, 그럼 굳이 State, Action, Reducer를 따로 정의해서 할 필요가 있을까?
아키텍처로서 직관적이지 않아 보인다. 또한 의존성이 존재하지 않아도 reducer에는 Environment 타입을 할당해야하는 것일까?
혹은, 작은 모듈인 경우 꼭 Environment 객체를 별도로 작성해서 reducer에 주입해야하는 것인가?
위와 같은 불편함이 따른다.
이제는 TCA가 최신 버전으로 Release 되면서 TCA 공식 문서에서는 이제 ReducerProtocol을 준수하는 아키텍처를 따르도록 권장하고 있다.
이 프로토콜을 따르면 State, Action, Reducer을 기존처럼 별도의 객체로 정의하지 않고, Store 객체를 ‘도메인 로직을 담당할 자연스러운 하나의 공간’ 으로 생각하여 Store 객체 안에서 해당 도메인의 State, Action, Reduce를 정의하게 된다.
이는 Store와의 직접적인 연관성을 부여한다. Store 객체 안에서 Reduce를 정의하기 때문에 기존 클로저 변수 대신 메소드로 사용할 수 있게 된다.
다음처럼 App 단에서 해당 Store를 초기화할 때, 미리 정의한 Store 구조체를 명시함으로써 해당 View의 Store에는 어떤 도메인 Store가 사용되는지도 직관적으로 알 수 있고 의존성을 직접 주입받아 부여하지 않아도 되고 더 간결해졌다.
import ComposableArchitecture
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
FeatureView(
store: Store(initialState: Feature.State()) {
Feature()
}
)
}
}
}
그리고 모든 Store에는 의존성 객체가 필수가 아니다. 도메인에 따라서 의존성 객체가 필요할 수도, 필요하지 않을 수도 있다. 따라서 ReduceProtocol에는 의존성 주입을 필수로 하지 않아도 된다는 장점이 있고, 그로인해 reducer 메소드는 Environment 타입(의존성)으로부터 더 독립적인 구성요소로서 자리하게 된다.
물론, reducer에 간단한 의존성 로직이 필요한 경우 이전 게시글 예제처럼 reducer 함수에
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action {
...
case .numberFactButtonTapped:
return .run { [count = state.count] send in
let (data, _) = try await URLSession.shared.data(
from: URL(string: "http://numbersapi.com/\(count)/trivia")!
)
await send(
.numberFactResponse(String(decoding: data, as: UTF8.self))
)
}
...
}
}
외부 API 코드를 이어붙이기 식으로 '의존성' 코드를 사용할 수 있다.
위 처럼 간단한 로직인 경우 Environment 객체를 정의해서 reducer에 주입하지 않아도 의존성 개념을 사용할 수 있다.
그러면 이제 의존성 객체는 어떻게 활용될까?
간단한 로직이 아닌 경우는 의존성 관리가 불편해진 것 아닌가?
의존성을 갖는 Store나 reducer는 모듈 테스트할 때마다 의존성 기능의 사이드 이펙트를 점검하기 어려워진 것이 아닌가?
생각할 수 있다.
사실 Environment, 정확히 말하면 Dependency(의존성) 객체를 아예 사용하지 않는 것은 아니다.
Environment(의존성)는 어떻게 사용하는가?
ReducerProtocol을 따르는 Store는 의존성을 정의하는 (이전처럼 Environment 구조체를 정의하는 행위) 것이 필수가 아닐 뿐이지 의존성이 주입되어야 한다면 의존성 관리 시스템에 의존성을 등록해서 내가 사용할 Store에 해당 의존성을 편하게 불러올 수 있다.
여기서 의존성 관리 시스템이라 함은 ‘DependencyKey’ 프로토콜이라고 한다.
아래 구버전 Environment 객체를
struct AppEnvironment {
var mainQueue: AnySchedulerOf<DispatchQueue>
var numberFact: (Int) -> Effect<String, ApiError>
}
‘DependencyKey’로 새롭게 등록하는 방법은 다음과 같다.
struct NumberFactClient {
var fetch: (Int) async throws -> String
}
구버전 Environment 객체와 똑같은 기능을 하는 의존성 함수(랜덤한 한개의 숫자를 API로부터 받아와 이를 String 타입으로 변환하는 함수)를 갖는 NumberFactClient 구조체를 정의한다.
이제는 특정한 Store에서 의존성을 주입해야한다면, 의존성 관리 시스템에 의존성을 등록해서 사용해야한다고 했었다.
‘의존성 관리 시스템’은 ‘DependencyKey’ 프로토콜이라고 하였다.
즉, 이 의존성 객체(구조체)에 ‘DependencyKey’ 프로토콜을 채택해야한다.
extension NumberFactClient: DependencyKey {
static let liveValue = Self(
fetch: { number in
let (data,_) =try await URLSession.shared
.data(from: .init(string: "<http://numbersapi.com/\\(number)>")!)
return String(decoding: data, as: UTF8.self) }
)
}
위 과정은 의존성 관리 시스템에 등록할 수 있는 준비를 마친 단계라고 할 수 있다.
의존성 관리 시스템에 등록하는 것은 다음처럼 할 수 있다.
DependencyValues를 확장해 numberFact라는 get, set 프로퍼티를 구현한다.
extension DependencyValues {
var numberFact: NumberFactClient {
get {self[NumberFactClient.self] }
set {self[NumberFactClient.self] = newValue }
}
}
‘랜덤한 한개의 숫자를 API로부터 받아와 이를 String 타입으로 변환하는 의존성’을 프로젝트에 등록을 했다.
이제 Store에서 ‘@Dependency’ 프로퍼티 래퍼 타입으로 필요한 의존성을 의존성 관리 시스템에서 불러올 수 있다.
struct Feature:ReducerProtocol {
struct State { ... }
enum Action { ... }
@Dependency(\\.numberFact)var numberFact
func reduce(into state: inout State, action: Action) -> EffectTask<Action> { ... }
}
그럼 이렇게 실제로 App단에서부터 의존성을 주입받아 부여하지 않아도 된다.
@mainstructMyApp:App {
var body:some Scene {
FeatureView(
store: Store(
initialState: Feature.State()
)
)
}
}
테스트 환경을 구축한다면 TestStore를 만들 때 MockUp Model로 의존성 시스템을 구현해주면 된다.
let store = TestStore(
initialState: Feature.State(),
reducer: Feature()
) {
$0.numberFact.fetch = { "\\($0) is a good number Brent" }
}
마무리
이 모든 과정이 ReducerProtocol 시스템으로 새롭게 변경되었는데, 사용하면서 느낀 장점은 의존성 관리가 편해진 것 뿐만 아니라, 아키텍처를 더 직관적으로 사용할 수 있어 숙련되지 않은 사람들도 학습하고 코드를 이해하는데 더 쉬워진 것 같다. 또한 테스트할 때나 객체별 의존성 추가 시 코드 수정이 간편해진 것 같다.
출처
https://green1229.tistory.com/325
https://gist.github.com/pilgwon/ea05e2207ab68bdd1f49dff97b293b17
'Swift' 카테고리의 다른 글
애플 Text엔진 TextKit (2) | 2024.09.08 |
---|---|
TCA - Binding (0) | 2023.08.17 |
TCA(The Composable Architecture)란? (1) | 2023.07.06 |
Swift 네트워크 추상화 - URL 처리방법 (0) | 2023.05.09 |
객체지향 생활체조 (0) | 2023.05.05 |