안녕하세요 Niro 🚗 입니다!
🔗 [SwiftUI / TCA Tutorials] 첫번째 feature 구현하기
첫번째 튜토리얼을 잘 읽고 오셨나요?
내용이 이어지기 때문에 먼저 읽고 오시면 이해가 잘 될거라 생각합니다.
이번 두번째 튜토리얼은 Side Effect 를 어떻게 관리하고 상태를 어떻게 업데이트 하는지에 대해 알아보고자 합니다!
차근 차근 한단계씩 진행할 예정이니 잘 읽어주세요!
🔗 The Composable Architecture Tutorials
https://pointfreeco.github.io/swift-composable-architecture/main/tutorials/composablearchitecture/01-02-addingsideeffects
애플리케이션 개발에서 중요한 부분 중 하나가 바로 Side effect(부수 효과) 입니다.
외부 서비스와 상호 작용하여 기능을 수행할 수 있도록 해주는 부분이죠. 예를 들어, 네트워크 요청을 보내거나 파일을 저장하는 등의 작업이 여기에 해당합니다.
side effect 는 외부 환경에 따라 그 결과가 달라질 수 있기 때문에 개발 과정에서 가장 복잡한 부분 중 하나입니다. 특히 네트워크 요청과 같은 비동기 작업에서 더욱 두드러진다 생각합니다!
TCA 에서는 side effect 를 처리하기 위해 Effect 라는 개념을 도입하고 있습니다. Effect 는 애플리케이션의 상태를 변경하거나 외부 서비스와 상호 작용하는 데 사용됩니다.
개발자는 비동기 작업을 보다 쉽게 처리하고 애플리케이션의 일관성을 유지할 수 있게 되죠!
struct CounterView: View {
let store: StoreOf<CounterFeature>
var body: some View {
VStack {
Text("\(store.count)")
//...
HStack {
Button("-") {
store.send(.decrementButtonTapped)
}
//..
Button("+") {
store.send(.incrementButtonTapped)
}
//..
}
Button("Fact") {
store.send(.factButtonTapped)
}
//..
if store.isLoading {
ProgressView()
} else if let fact = store.fact {
Text(fact)
.font(.largeTitle)
.multilineTextAlignment(.center)
.padding()
}
}
}
}
첫번째 튜토리얼에서 만든 CounterFeature 에 새로운 기능을 추가해볼까 합니다. Button 을 누르면 현재 표시된 숫자가 맞는지(Fact) 확인하기위해 네트워크 요청을 하려고 합니다!
먼저 CounterView 하단에 버튼을 추가했고 아직 존재하지 않지만 누르게 되면 .factButtonTapped
action 을 전송합니다.
또한 하단에 progressView
를 추가하여 Fact 를 로드하는 동안 표시하고, if-let
구문을 통해 store.fact
에 값이 있으면 Text 로 출력하려고 합니다.
아직 CounterFeature 에 존재하지 않는 isLoading
및 fact
state 를 사용하고 있기 때문에 이제 추가를 해봐야겠죠?
import ComposableArchitecture
@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
return .none
case .incrementButtonTapped:
state.count += 1
state.fact = nil
return .none
}
}
}
}
Your first feature 에서 보았던 CounterFeature 에 바뀐것들이 보입니다!
Fact Button 의 동작을 정의하고자 Action enum 에 factButtonTapped
case 를 추가했고 State 구조체에 fact 를 보여줄 프로퍼티와 progressView 의 상태를 나타날 수있는 isloading
프로퍼티를 추가했습니다.
Reducer 를 통해 전송된 action 에 따른 동작을 body 안에서 구현한 모습도 보입니다.
아직 우리는 Side Effect 에 대한 내용을 아직 정하지 않았으므로 다른 Action 과 동일하게 .none 으로 반환하게 되어있습니다.
import ComposableArchitecture
@Reducer
struct CounterFeature {
//...
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
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
//...
}
}
}
}
자, 이제 Side Effect 를 어떻게 작성할 수 있을까요?
numbersapi.com 라는 곳에서 현재 count 에 대한 fact 를 가져오게 위해 URLSession 을 사용하여 비동기 작업을 수행하려고 하지만.. 비동기 처리를 할 수 없다고 오류가 발생하게 됩니다.
TCA 의 가장 핵심적인 원칙을 위배했기 때문이죠!
TCA 는 상태 변화와 Side Effect 에 대해 분리하여 코드를 단순하고 깔끔하게 유지하는 핵심 원칙을 갖고 있습니다.
그래서 TCA 는 Effect 라는 개념을 도입해서 효과적으로 Side Effect 를 관리하고자 합니다!
위에서 우리는 Side Effect 가 무엇이고 왜 Reducer 에서 직접 수행할 수 없는지에 대해 알아보았으니까 이제 적용하는 방법을 배워야겠죠?
TCA 의 Effect는 리듀서의 일부로서 정의됩니다.
Reducer 가 State 를 변경하고 Action 을 처리한 후에 외부 시스템과 통신하고 데이터를 처리할 수 있는 Effect 를 반환할 수있다는 것을 의미합니다.
즉, Side Effect 로 인해 State 를 변경하던, 내부 Action 으로 인해 State 를 변경하던 Reducer 가 모든 것을 처리한다는 의미입니다.
이제 네트워크 요청을 보내고 해당 정보를 reducer 에게 전달해보겠습니다.
import ComposableArchitecture
@Reducer
struct CounterFeature {
//...
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
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)
}
//...
}
}
}
}
Effect 를 구현하는 가장 기본적인 방법은 static 메서드인 run(priority:operation:catch:fileID:line:)
을 사용하는 것입니다.
이 메서드를 사용하면 네트워크 요청을 수행하고 가져온 데이터를 적절한 형식으로 변환하는 등의 작업을 손쉽게 처리할 수 있습니다.
특히 .run
메서드의 후행 클로저는 numbersapi.com 에서 데이터를 가져와 문자열로 변환하게 되는데 이처럼 우리가 원하는 작업을 처리하고, Reducer 에 필요한 데이터를 제공하는 것으로 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)
state.fact = fact
// 🛑 Mutable capture of 'inout' parameter 'state' is not allowed in
// concurrently-executing code
}
하지만 데이터를 가져온 후 Effect 내에서 직접 state.fact
를 변경할 수 없습니다.
우리는 클로저를 통해서 state 와 action 을 캡쳐하고 있습니다. 클로저 내에서 state 를 변경하기 위해서는 inout 으로 캡쳐를 해야만 합니다.
하지만 escaping Closure 의 경우 나중에 실행될 수 있는 구문으로 state 라는 매개변수가 호출 시점에 메모리에 없을 수있기 때문에 inout 매개변수는 캡쳐를 할 수가 없게 되죠!
즉,
.run
클로저는 탈출 클로저 이기 때문에 inout 으로 State 를 캡쳐할 수가 없게 됩니다.
따라서 우리는 Effect 를 통해 외부 작업을 수행하고 그 결과를 Reducer 에게 보내는 것으로 reducer 와 effect 가 각각의 역할을 가지고 있는 것을 유지할 수 있습니다. 이를 통해 더욱 명확하고 유지보수하기 쉽게 만들 수 있게 되죠!
import ComposableArchitecture
@Reducer
struct CounterFeature {
enum Action {
//...
case factResponse(String)
//...
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
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
}
}
}
state 를 업데이트하도록 reducer 로 다시 보내야하기 때문에 네트워크에서 전달받은 문자열에 관련된 값을 포함하고 있는 factResponse
라는 Action 을 추가해야합니다.
결과적으로 Effect 내에서 비동기 작업을 수행한 후 데이터를 해당 Action 에 전송하게되고 State 를 업데이트 하도록 처리할 수 있게 됩니다.
위에서 Side effect 중 하나인 네트워크 요청 작업에 대해 알아보았습니다. 이번에는 새로운 Side effect 를 관리하는 방법을 알아보기 위해 탭을 하면 1초 반복 타이머가 시작하는 버튼을 하나 추가하여 1초가 지날때 마다 count 가 하나씩 증가하는 기능을 추가해보겠습니다.
struct CounterView: View {
let store: StoreOf<CounterFeature>
var body: some View {
//...
Button(store.isTimerRunning ? "Stop timer" : "Start timer") {
store.send(.toggleTimerButtonTapped)
}
.font(.largeTitle)
.padding()
.background(Color.black.opacity(0.1))
.cornerRadius(10)
//...
}
다음과 같이 isTimerRunning
의 상태에 따라 Text 를 표시하고 탭하게 되면 toggleTimerButtonTapped
Action 을 전송하는 Button 을 만들었습니다.
그러면 다시 CounterFeature 에서 State 와 Action, Action 에 따른 동작을 추가해야겠죠?
import ComposableArchitecture
@Reducer
struct CounterFeature {
@ObservableState
struct State {
//...
var isTimerRunning = false
}
enum Action {
//...
case toggleTimerButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
// ...
case .toggleTimerButtonTapped:
state.isTimerRunning.toggle()
return .run { send in
//...
}
}
}
}
}
isTimerRunning
프로퍼티와 toggleTimerButtonTapped
case 를 추가해주었고 해당 Action 에 따른 state 를 업데이트 시키고 관련 동작을 구현해주어야 합니다.
Timer 동작은 비동기 작업이므로 Effect 를 반환해주는 모습도 보이네요.
앞서 우리는 네트워크 요청 비동기 작업을 Effect 를 통해 관리했습니다. Effect 내에선 외부 변수를 직접적으로 수정하는 것을 허용하지 않았습니다.
위의 Timer 에 대한 Effect 도 마찬가지로 isTimerRunning
을 변경하기 위해선 새로운 Action 이 필요하고 해당 Action 에 대한 동작을 구현해야합니다!
enum Action {
case toggleTimerButtonTapped
case timerTick
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .timerTick:
state.count += 1
state.fact = nil
return .none
case .toggleTimerButtonTapped:
state.isTimerRunning.toggle()
return .run { send in
while true {
try await Task.sleep(for: .seconds(1))
await send(.timerTick)
}
}
}
state 를 변경하기 위해 .timerTick
Action 을 추가하였고 버튼을 누르면 1초 뒤에 해당 Action 을 보내게 됩니다. 결과적으로 1초마다 계속 count 가 증가되는 것을 볼 수가 있습니다.
계속.. count 를 증가되게 만들 수는 없으니 멈추는 코드도 만들어보겠습니다.
import ComposableArchitecture
@Reducer
struct CounterFeature {
struct State { //... }
enum Action { //... }
enum CancelID { case timer }
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .timerTick:
state.count += 1
state.fact = nil
return .none
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)
}
}
}
}
TCA 의 강력한 기능중에 하나인 effect cancellation 를 활용하여 비동기 로직을 멈출 수 있습니다. 식별자를 제공하여 cancellable effect 로 만들 수 있고 ID 를 통해 Effect 를 취소할 수 있게 됩니다.
.cancellable(id:cancelInFlight:)
메서드를 사용하여 ID 를 전달하고 모든 Effect 를 취소 가능한 것으로 표시한 다음 나중에 .cancel(id:)
를 사용하여 해당 Effect 를 취소할 수 있습니다.
결과적으로 비동기 작업 중에 발생하는 예기치 않은 상황이나 사용자의 조작에 따라 효과를 취소하고 상태를 관리할 수 있습니다.
이번 튜토리얼을 통해 Side Effect 를 다루는 방법을 배웠습니다!
단방향 Flow 의 특징을 갖고 있는 TCA 는 외부 서비스로 인해 생기는 변화를 한곳에서 관리하고자 Effect 라는 개념을 도입하였고
비동기 처리 등 모든 State 변화는 Reducer 를 통해서 이루어지게 됩니다!
기존 SwiftUI 단점이였던 양방향 flow 를 해결하는 모습이 굉장히 인상적이네요!
다음 튜토리얼도 준비할테니 기대해주세요~!