앞서 1주차 스터디에서 앱의 상태를 나타내는 State와 이를 변경하는 Action, 이 Action의 기능을 구현하고 상태를 변경하는 Reducer를 알아봤다.
이번에는 Effect에 대해서 알아볼 예정인데. Action이 반환하는 타입이자, Action을 거친 모든 결과물을 칭한다. 그 중 외부에서 어떤 처리가 일어나 예상하지 못한 결과물을 얻는 Side Effect에 대해서 까지 알아볼 예정이다. 이에 더해 이러한 비동기 작업이나 외부 작용에서 발생하는 Side Effect를 우리 앱의 로직에 통합하는 역할인 Store까지 알아보자!
Effect의 구현과 활용
- Action에 따른 결과가 바로 Effect
Effect는 Reducer의 액션이 반환하는 타입으로 액션을 거친 모든 결과물이라고 말할 수 있다. 그 중 외부에서 처리가 일어나 얻게되는 결과물을 Side Effect라고 한다.
Effect는 외부 시스템과 상호작용하는 작업을 나타내는데, 이를 통해 앱의 State가 변경된다. State를 직접 변경할 때의 Action과 달리, Effect는 비동기적인 작업을 수행하고 그 결과를 Action으로 반환하여 State를 변경하기 위해 사용된다.
즉, Effect는 특정 Action을 실행한 후 그 결과에 따라 새로운 Action을 생성하고, 이를 통해 State를 업데이트하는 역할을 담당한다. 네트워크 호출, 데이터 로딩, 외부 서비스와의 교류 등 다양한 비동기 작업이 Effect로 분류될 수 있다.
Effect의 TCA에서의 역할은 다음과 같다
- 비동기 작업 관리 : 네트워크 요청, 데이터 로딩, 파일 다운로드 등 다양한 비동기 자업을 Effect를 통해 관리할 수 있다.
- Side Effect 분리 : Effect는 순수 함수형 프로그래밍의 원칙에 따라 Side Effect를 배제한다. 이를 통해 코드에서 State 변화를 일으키는 부분과 Side Effect를 다루는 부분을 명확하게 분리함으로써 코드의 가독성과 추론력이 향상되고 테스트와 디버깅 과정이 용이해진다. ( Side Effect와 State의 변화를 일으키는 부분이 어떻게 명확하게 분리하는지 알아보기 )
- 취소 및 에러 핸들링 : Effect는 비동기 작업의 성공, 실패 및 중단을 관리하는데 사용한다. 예를 들어 네트워크 요청 중 발생한 오류를 적절하게 처리하고 State를 업데이트한다. ( 어떻게 적절하게 처리하는지 알아보기 )
- 순서 보장 : TCA의 Effect는 순차적으로 실행되며 그 순서가 보장된다. 이로써 State 변화와 관련된 Side Effect를 적절하게 처리하면서도 예측 가능한 결과를 얻을 수 있다.
순수 함수적인 Effect
순수 함수는 주어진 입력에 대해 항상 동일한 출력을 반환하고, 외부 상태를 변경하지 않으며, Side Effect가 없는 함수를 의미한다. 그렇기 때문에 순수 함수 자체로는 비동기 작업이나, Side Effect를 처리할 수 없다.
그러나 TCA는 이 문제를 해결하기 위해 이 방식을 사용하게 되는데, Effect는 앱의 상태를 직접 변경하지 않고, 비동기 작업을 수행한 후 그 결과를 새로운 Action으로 반환하는 역할을 한다. 이렇게 생성된 Action은 Reducer에서 처리되어 State를 업데이트한다.
네트워크 요청 같은 비동기 작업을 처리하는 경우, Effect가 요청을 수행하고, 결과 데이터 또는 오류 정보 등을 포함하는 새로운 Action을 생성하여 반환한다. 이 Action은 다시 Reducer에서 받아 상태 업데이트 로직을 수행하게 된다.
즉, TCA에서 Effect는 사이드 이펙트와 같은 비동기 작업도 순수 함수적인 방식으로 처리할 수 있도록 설계되어 있다. 그래서 Effect 자체가 순수 함수라기 보다 순수 함수적인 방식으로 Side Effect를 관리하고 제어하는 역할은 한다는 것 .
Effect와 Action
TCA에서 Effect와 Action은 각각 다른 역할을 담당한다.
Action은 사용자 인터페이스에서 발생하는 이벤트를 캡슐화하며, 이는 State 변화를 일으키는데 사용된다. 예를 들어 버튼 클릭, 텍스트 입력 등 사용자 동작이나 타이머 완료, 네트워크 응답 등 시스템 이벤트가 Action으로 처리될 수 있다.
반면에 Effect는 비동기 작업을 처리하고 그 결과를 다시 Action으로 반환하는 역할을 수행한다. Effect는 주로 외부와 상호작용 하기 위해 사용되며, 해당 작업이 완료되면 적절한 Action이 생성되어 State가 업데이트 된다. 또한 TCA의 Effect는 Swift의 Combine 프레임워크를 기반으로 작성되었으며, 비동기 작업을 처리하고 Combine의 Publisher 및 Operator를 활용하여 작업을 조합하고 변환할 수 있다.
struct CounterFeature: Reducer {
struct State: Equatable {/* code */}
enum Action {/* code */}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .incrementButtonTapped:
state.count += 1
return .none
case .decrementButtonTapped:
state.count -= 1
return .none
}
}
}
.none
Effect는 반환해야하는데 아무런 동작을 취하고 싶지 않을 때 .none을 사용한다. 위에 코드를 살펴보면 버튼을 눌렀을 때 어떤 비동기 처리도 필요하지 않기 때문에 .none을 사용한 모습이다.
.send
파라미터로 Action을 받는 메서드로 특정 액션 이후 즉시 추가적인 동기 액션이 필요할 때 사용된다. 주로 자식 컴포넌트에서 부모 컴포넌트로 데이터를 전달할 때 사용한다. 또한, 액션을 전달하는 동시에 애니메이션을 지정할 수 있다.
공식 문서 상에서 로직을 공유하기 위한 목적으로 사용하지 말라고 권장하고 있는데 여러 곳에서 동일한 로직을 사용하기 위해 .send 메서드를 사용하지 말라는 뜻, 이는 코드의 중복이 발생할 수 있어서 TCA의 철학인 단방향 데이터 흐름을 저해할 뿐 만 아니라 코드의 의도를 파악하기 어려울 수도 있기 때문이다.
.run
비동기 작업을 래핑하는 메서드이다. 인자로 비동기 클로저를 받아서 실행하며, 클로저 내부에서 send를 사용해 액션을 시스템에 전달할 수 있다.
//.run method에 들어가는 operation 인자
operation: @escaping @Sendable (_ send: Send<Action>) async throws -> Void
case .aButtonTapped:
return .run { send in
for await event in self.events() {
send(.event(event))
}
}
.run 으로 비동기 작업을 수행하고 send 매개변수를 통해 액션을 전달한다. for await 으로 self.events() 라는 비동기 스트림을 처리한 후 send가 호출되어 해당 액션을 처리하게 되는 것
이 두가지 메서드를 사용해 카운터 앱에 숫자를 통해 해당 숫자에 대한 재미있는 사실을 가져오는 기능을 추가해보면
struct CounterFeature: Reducer {
struct State: Equatable {
var count = 0
// fact 기능 추가를 위해 새롭게 추가한 State
var fact: String?
var isLoadingFact = false
}
enum Action: Equatable {
case incrementButtonTapped
case decrementButtonTapped
// fact 기능 추가를 위해 새롭게 추가한 Action
case factResponse(String)
case getFactButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
/* code */
case .getFactButtonTapped:
state.fact = nil
state.isLoadingFact = true
// .run 메서드가 사용된 부분
// [count = state.count] 구문은 클로저 내부에서 state.count를 count로 사용하겠다는 의미입니다.
return .run { [count = state.count] send in
let (data, _) = try await URLSession.shared.data(
from: URL(string: "url 주소가 들어갈 장소/\\(count)")!
)
let fact = String(decoding: data, as: UTF8.self)
await send(.factResponse(fact))
}
case let .factResponse(fact):
state.fact = fact
state.isLoadingFact = false
return .none
}
}
}
}
Reducer가 왜 순수 함수여야 하는지? Effect는 왜 State에 바로 접근하지 않고 새로운 Action을 만드는지?
Reducer는 순수함수여야 한다.
여기서 말하는 순수한 함수란? 어떤 입력이 주어졌을 때 항상 같은 결과를 내고, 외부 상태나 Side Effect가 없는 함수를 말한다.
그렇다면 왜 순수한 함수여야 할까?
순수 함수를 사용하면 결과를 예측하기 쉽고 테스트가 간편해지는 장점이 있다. 예를 들어 같은 버튼을 눌렀을 때 항상 같은 상태변화가 나타나야 하는데, 네트워크 요청 결과에 따라 상태가 변하면 그 예측 가능성이 떨어질 수 있기 때문에!!!
Reducer 안에서 전달받은 state는 일종의 inout 파라미터다 이는 reducer 함수가 실행되는 동안만 안전하게 수정할 수 있는 지역 변수같은 개념!
만약 네트워크 요청 같은 비동기 작업에서 이 state를 직접 수정하려고 하면, 동시에 여러 작업이 실행되면서 race condition이 발생할 위험이 있다. 이를 막기 위해 비동기 클로저 안에서 mutable하게 state를 캡처하는 것을 허용하지 않는다.
그래서 네트워크 요청과 같은 비동기 작업은 state를 직접 수정하지 않고 결과를 Action으로 만들어 reducer에 보내주게 된다. 이렇게 되면 reducer는 이 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)
state.fact = fact
// 🛑 Mutable capture of 'inout' parameter 'state' is not allowed in
// concurrently-executing code
}
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에서 직접 처리한다.
- 비동기 Effect : 네트워크 요청 같이 시간이 걸리는 작업은 effect 내부에서 실행한 뒤 결과를 새로운 Action으로 보내 reducer에서 다시 상태를 업데이트 한다.
- 이렇게 구현하게 되면 어디서 무슨일이 벌어지는지 더 명확하게 알 수 있게 된다. 예를 들어 요청 결과가 언제 어떻게 들어오는지, 그 결과로 어떤 상태 변화가 일어나는지를 쉽게 추적할 수 있게 된다. 이 방식은 디버깅과 테스트할 때 아주 큰 장점으로 작용
- TCA에서는 상태를 업데이트하는 로직과 Side Effect를 실행하는 로직을 분리해서, 각각의 역할이 명확하도록 설계했다.
그럼 Effect가 외부 의존성을 캡슐화 한다는게 뭔데
TCA에서는 Effect를 “값”으로 취급해서 상태 업데이트와 분리된 순수한 계산으로 캡슐화한다.
즉 reducer 내부에서는 오직 동기적으로 상태를 업데이트하는 코드와, 나중에 실행할 Effect를 설명하는 값만 반환하게 되는 것
예시를 통해 더 알아보자
case .factButtonTapped:
state.fact = nil
state.isLoading = true
// 여기서 .run 클로저는 비동기 작업(네트워크 호출)을 캡슐화한 Effect를 생성합니다.
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)
// 네트워크 응답 후에, 결과 액션을 store에 보내게 됩니다.
await send(.factResponse(fact))
}
이 코드를 보면 현재 Reducer에서는 state.fact 와 state.isLoading을 동기적으로 업데이트한다. 그 다음 네트워크 호출이라는 Effect를 반환하게 되는데
여기서 .run 클로저는 비동기 작업을 캡슐화한 Effect로써 .factResponse 액션을 보내라!! 라는 의미를 갖게 되는 것
이러한 방식을 통해 각 Effect들은 순수한 형태로 캡슐화 되는 것이다.
실제 애플리케이션이나 테스트 환경에서는 TCA의 Store가 이 Effect 값을 관리하고 실행하여,
작업이 완료되면 액션을 자동으로 dispatch 한다.
Effect를 이렇게 캡슐화를 하게 되면서 얻게 되는 장점은?
- 순수성과 결정론성 유지할 수 있다.
이로 인해 Action에 따른 State 변화와 Effect 실행 과정을 명화하게 분리할 수 있게 된다.
Reducer는 상태 업데이트를 순수하게 수행하고, 모든 Side Effect는 Effect라는 값으로 캡슐화가 된다. - 테스트 용이
예를 들어 네트워크 요청 대신 스텁을 주입하고 타이머와 같은 비동기 작업을 TestClock으로 제어할 수 있다. 이렇게 캡슐화 된 Effect 덕분에 비동기 작업도 결정론적으로 테스트할 수 있다. TestStore를 사용하면 reducer가 반환한 Effect를 쉽게 제어하거나 대체할 수 있다. - 구현 가능성과 확장성
이는 복잡한 Effect들을 작은 단위로 분해해 관리할 수 있게 해주는 장점!!
Effect는 다른 Effect와 결합하거나, 취소, 스케줄링 등 다양한 작업을 쉽게 수행할 수 있도록 설계되어있다.
TCA 에서 Test 하는 방식에 대해서 알아보자
먼저 TCA에서는 모든 상태 변화가 reducer 내부에서 명시적으로 정의되게 된다.
State : 앱의 현재 상태를 나타내며, reducer가 액션에 따라 이 상태를 업데이트 한다.
Action : 사용자의 입력이나 외부 이벤트 등으로부터 발생하며, 상태를 변화시키기 위한 단위이다.
Effect : 네트워크 호출, 타이머, 파일 I/O 등 외부 의존성을 캡슐화하여, reducer 내에서 명시적으로 실행되도록 한다.
그렇다면 TCA에서 TestStore를 활용해 테스트 하는 법을 알아보자
그럼 TCA에서 Test를 진행하는데 TestStore가 뭔지 한번 보자
TestStore는 TCA 내에서 테스트용 스토어로 초기 상태(initial state)와 Reducer 그리고 테스트 환경에 맞게 Dependencies를 오버라이드하는 기능을 한번에 설정할 수 있게 해주는 도구라고 생각하면 된다!
이 TestStore는 실제 앱의 스토어와 동일한 방식으로 액션을 처리하고 상태를 업데이트 하면서, 그 변화를 테스트 코드 내에서 명확하게 추적할 수 있도록 한다.
TestStore의 핵심 동작 원리
TestStore는 주로 세가지 단계로 테스트를 진행하게 된다.
먼저 첫번째 초기화 단계에서 초기 State와 Reducer를 받아 내부 스토어를 생성한다.
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
}
이 과정에서 TestStore는 reducer가 실행될 때 사용할 상태와 액션 처리 로직을 결정론적인 방식으로 저장해준다.
그 다음 의존성이 필요하다면 의존성을 오버라이드 한다.
테스트 환경에서 실제 외부 의존성 대신 테스트 전용 모의 객체나 스텁을 주입할 수 있다.
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
} withDependencies: {
$0.continuousClock = TestClock() // 시간을 제어할 수 있는 모의 시계를 주입
}
이를 통해 테스트 중에는 시간이나 네트워크 같은 비결정적인 요소들을 제어할 수 있게 된다.
두번째로 Action send
동기 액션 처리 및 상태 검증을 할 수 있다.
store.send(action)을 호출하면 reducer가 해당 액션을 처리하고, 이때 상태가 업데이트 된다.
테스트 코드는 액션 실행 직후의 예상 상태를 클로저 내 검증한다.
await store.send(.incrementButtonTapped) {
$0.count = 1 // 액션 후 상태가 count 1로 변했는지 확인
}
이처럼 send 메서드는 액션을 디스패치한 직후 바로 상태 업데이트를 비교하게 해주므로, 순차적인 상태 변화를 명확하게 검증할 수 있다.
어떤 액션은 내부에서 비동기 작업을 발생시키는 Effect를 반환하는데 이러한 Effect는 실제 실행은 Store가 관리하지만 TestStore는 추후 이 효과가 올바르게 실행되는지를 검증할 수 있는 일종의 예약 상태로 남긴다.
세번째로 비동기 액션을 수신
reducer가 Effect를 반환한 경우, 나중에 실행되어 발행될 액션을 store.receive(expectedAction)메서드로 기다리고 검증할 수 있다.
await store.receive(.factResponse("0 is a good number.")) {
$0.isLoading = false
$0.fact = "0 is a good number."
}
네트워크 호출이나 기타 비동기 작업에 의해 발생한 결과 액션을 TestStore가 기다리고 실행 후 상태 업데이트가 예상과 일치하는지 비교한다.
이렇게 해서 TestStore는 액션 전송과 수신을 순차적으로 실행하면서 테스트가 단계별로 진행되는 것을 보장한다.
TestStore가 테스트를 수월하게 만드는 이유는??
- 결정론적인 상태 변화 검증
TestStore는 액션 전송 후 Reducer의 결과를 바로 검증할 수 있기 때문에 이 액션을 보낼을 때 State가 이렇게 바뀐다! 라는 점을 확실하게 확인할 수 있게 된다. Reducer가 순수 함수처럼 동작하기 때문에 Action을 보내면 상태 변화가 명시적으로 나타난다. - Effect의 캡슐화와 제어
모든 Side Effect는 Effect 라는 값으로 캡슐화 된다. 따라서 네트워크 요청, 타이머, 또는 다른 비동기 작업은 TestStore 내에서 실행되고, 그 결과로 특정 Action을 생성하게 된다.
Effect가 캡슐화 되어 있기 때문에, TestStore는 의존성을 쉽게 오버라이드 할 수 있다. 예를들어 TestClock을 사용하여 타이머가 작동하는 시간을 결정론적으로 제어하거나, 네트워크 요청을 스텁 함수로 대체하여 항상 동일한 결과를 반환하도록 할 수 있다. 이러한 방식은 테스트의 예측 가능성을 높이고, 외부 환경에 의존하지 않으므로 테스트가 안정적이고 빠르게 실행될 수 있다.
실제 네트워크 과정에 대한 테스트 예시
@Test
func numberFact() async {
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
} withDependencies: {
// 실제 네트워크 호출 대신, 이 스텁 클로저가 호출되어 입력 숫자를 받아 "0 is a good number." 와 같은 문자열을 반환합니다.
$0.numberFact.fetch = { "\\($0) is a good number." }
}
// 여기서 .factButtonTapped 액션을 보내면,
// - 먼저, 상태에서는 fact가 nil로 초기화되고 isLoading이 true가 됩니다.
await store.send(.factButtonTapped) {
$0.isLoading = true
}
// 네트워크 호출(여기서는 스텁 함수 호출)이 내부의 .run 효과로 실행되고,
// 그 결과로 .factResponse 액션이 예약됩니다.
// TestStore는 이 비동기 효과(Effect)의 실행 결과인 .factResponse 액션을 기다리며,
// 1초 이내에 예상한 결과가 나타나는지 검증합니다.
await store.receive(\\.factResponse, timeout: .seconds(1)) {
// .factResponse가 처리된 후 상태에서 isLoading은 false가 되고,
// fact에는 스텁 함수가 반환한 문자열이 반영됩니다.
$0.isLoading = false
$0.fact = "0 is a good number."
}
}
지금 이 테스트의 목적은 실제 네트워크 호출을 차단하고, 테스트에 예측 가능한 결과를 주입하기 위한 과정이다.
위의 코드를 살펴보면,
withDependencies 클로저에서 $0.numberFact.fetch를 오버라이드 한다. 이 때 스텁 함수는 count 값을 받아서 0 is a good number와 같이 결정된 문자열을 반환한다. 결과적으로 reducer에서 네트워크 호출 대신 이 클로저가 호출되어, 항상 같은 문자열이 반환된다라는 것을 알 수 있다.
그 다음 store.send(.factButtonTapped)는 팩트를 가져오는 과정에서 Action이 reducer에 전달되면, 첫번째로 State를 업데이트한다. 여기서는 state.fact를 nil로 재설정하고, state.isLoading을 true로 만든다.
그리고 .run 을 반환하여 비동기 네트워크 호출을 예약한다. isLoading 값이 true로 업데이트 된 것을 확인!!
TestStore가 이 과정을 테스트 하는 방식은?
store.send를 사용해 Action을 보내면, TestStore는 그 시점에서 reducer가 동기적으로 처리한 State 변화를 즉시 확인한다.
reducer에서 반환한 Effect는 TestStore 내부에서 예약 되어있다가. 이후 store.receive를 통해 실행 결과를 기다린다.
그 다음 TestStore는 지정된 타임아웃 내에 정확히 어떤 비동기 액션이 실행되어야 하는지를 추적한다. 결과인 Action (.factResponse)이 발생하면, 그에 따른 State 변화가 올바르게 반영되었는지 검증한다.
오늘은 이렇게 Effect와 Test에 대해서 정리했다. TCA에 대해서 조금씩 조금씩 이해가 되는 것 같아서 좀 재밌다.. 빠이팅..
'◽️ Programming > TCA' 카테고리의 다른 글
SwiftUI TCA - Dependency, Reducer, Effect에 대해서 알아보자!! (0) | 2024.11.21 |
---|---|
Share Run TCA 로직 구현 (3) | 2024.09.30 |
SwiftUI TCA (3) - Binding (0) | 2024.09.23 |
SwiftUI TCA (2) - Effect (0) | 2024.09.15 |
SwiftUI TCA (1) (0) | 2024.09.09 |