Chapter1. Your First Feature 에서는 간단한 State를 만들어서 해당 num을 increment, decrement하는 Action을 통합해 Reducer로 관리하고 만든 Reducer를 View에서 참조하여 개발하는 형태로 이루어졌습니다.
이번 스텝에서는 Data 통신에 대한 예제를 학습해봅시다.

Side Effect란 API 요청, 파일 시스템 상호작용, 시간 기반 비동기 수행과 같은 외부 세계와의 소통입니다. Side Effect 없이는 우리의 어플리케이션은 사용자에게 실질적인 가치를 제공할 수 없습니다.
또한 Side Effect는 우리 기능중에 가장 복잡한 부분입니다. State 변화는 간단한 과정입니다. (동일한 상태와 동일한 동작으로 같은 결과를 얻는다는게 보장된다는 뜻) 하지만 네트워크 연결, 디스크 권한같은 외부적 요소가 개입된다면 해당 effect가 매번 다른 답이 나올 수 있습니다.
먼저, Redcuer에서 간단하게 효과적인 작업을 수행할 수 없는지 알아보고 이를 해결하기위해 라이브러리에서 어떠한 기능을 제공하는지 알아봅시다!!
struct CounterView: View {
let store: StoreOf<CounterFeature>
var body: some View {
VStack {
Text("\(store.count)")
.font(.largeTitle)
.padding()
.background(Color.black.opacity(0.1))
.cornerRadius(10)
HStack {
Button("-") {
store.send(.decrementButtonTapped)
}
.font(.largeTitle)
.padding()
.background(Color.black.opacity(0.1))
.cornerRadius(10)
Button("+") {
store.send(.incrementButtonTapped)
}
.font(.largeTitle)
.padding()
.background(Color.black.opacity(0.1))
.cornerRadius(10)
}
Button("Fact") {
store.send(.factButtonTapped)
}
.font(.largeTitle)
.padding()
.background(Color.black.opacity(0.1))
.cornerRadius(10)
if store.isLoading {
ProgressView()
} else if let fact = store.fact {
Text(fact)
.font(.largeTitle)
.multilineTextAlignment(.center)
.padding()
}
}
}
}
위의 코드처럼 View에 새로운 State와 Action을 추가해주는 작업을 해봅시다.
@Reducer
struct CounterFeature {
@ObservableState
struct State {
var count = 0
var fact: String?
var isLoading = false
}
enum Action {
case decrementButtonTapped
case factButtonTapped
case incrementButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .decrementButtonTapped:
state.count -= 1
state.fact = nil
return .none
case .factButtonTapped:
state.fact = nil
state.isLoading = true
let (data, _) = try await URLSession.shared
.data(from: URL(string: "http://numbersapi.com/\(state.count)")!)
// 🛑 'async' call in a function that does not support concurrency
// 🛑 Errors thrown from here are not handled
state.fact = String(decoding: data, as: UTF8.self)
state.isLoading = false
return .none
case .incrementButtonTapped:
state.count += 1
state.fact = nil
return .none
}
}
}
}
case .factButtonTapped: 코드를 자세히 보시면
데이터 통시을 한 이후 받은 데이터값을 state.fact에 바로 넣어주려 하고 있습니다.
하지만 이는 오류라고 나오는데요. 왜그럴까요??
Composable Architecture에서는 State의 변화를 Effect를 통해 처리합니다.
Reducer에서 상태를 변경하는 작업을 처리한 후, Effect를 반환하며 Store는 실행되는 비동기 단위를 뜻합니다.
case .factButtonTapped:
state.fact = nil
state.isLoading = true
return .run { [count = state.count] send in
let (data, _) = try await URLSession.shared
.data(from: URL(string: "http://numbersapi.com/\(count)")!)
let fact = String(decoding: data, as: UTF8.self)
state.fact = fact
// 🛑 Mutable capture of 'inout' parameter 'state' is not allowed in
// concurrently-executing code
}
state .run { [count = state.count ] send in
// code
}
와 같은 run Effect로 수정하였습니다. 하지만 네트워크에서 데이터를 가져온 후에는 state.fact를 변경할 수 없습니다. 전송 가능한 클로저는 외부 상태를 캡처할 수 없기 때문에 컴파일러에 의해 엄격하게 적용됩니다. 이는 라이브러리가 mutable state와 effect를 분리하는것을 보여줍니다.
case .factButtonTapped:
state.fact = nil
state.isLoading = true
return .run { [count = state.count] send in
let (data, _) = try await URLSession.shared
.data(from: URL(string: "http://numbersapi.com/\(count)")!)
let fact = String(decoding: data, as: UTF8.self)
await send(.factResponse(fact))
}
case let .factResponse(fact):
state.fact = fact
state.isLoading = false
return .none
위와같이 별도의 factResponse(String) Action을 추가로 정의하고 이에 대한 동작을 설정해줍니다.
Side Effect에는 네트워킹 뿐 아니라 counter도 side effect도 포함됩니다.
enum CancelID { case timer }
case .toggleTimerButtonTapped:
state.isTimerRunning.toggle()
if state.isTimerRunning {
return .run { send in
while true {
try await Task.sleep(for: .seconds(1))
await send(.timerTick)
}
}
.cancellable(id: CancelID.timer)
} else {
return .cancel(id: CancelID.timer)
}
위처럼 Action을 추가해주고 timer를 .run에 작성해줍니다.
버그를 수정하기 위해 "효과 취소"라고 알려진 합성 가능한 아키텍처의 강력한 기능을 활용할 수 있습니다. ID를 제공하여 취소 가능(id:cancelInFlight:) 방법을 사용하여 모든 효과를 취소 가능한 것으로 표시할 수 있으며, 나중에 취소(id:)를 사용하여 해당 효과를 취소할 수 있습니다.