범위
이번 정리에서 볼 흐름.
Action
-> Reducer
-> Effect
-> Dependency
-> Action
-> Reducer
-> State
Dependency는 외부 작업의 구현을 Reducer에서 분리하기 위해 사용한다.
여기서 말하는 외부 작업.
- 시간 지연
- API 요청
- DB 접근
- 파일 저장
- 현재 날짜 읽기
- UUID 생성
이번 예제에서는 가장 작은 단위로 시간 지연만 본다.
버튼 탭
-> 1초 대기
-> count 증가
Dependency 없이 작성한 코드
먼저 Task.sleep을 직접 호출하는 형태.
@Reducer
struct CounterFeature {
@ObservableState
struct State: Equatable {
var count = 0
var isLoading = false
}
enum Action {
case delayedIncrementButtonTapped
case delayedIncrementFinished
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .delayedIncrementButtonTapped:
state.isLoading = true
return .run { send in
try await Task.sleep(for: .seconds(1))
await send(.delayedIncrementFinished)
}
case .delayedIncrementFinished:
state.count += 1
state.isLoading = false
}
return .none
}
}
}
동작은 문제 없다.
delayedIncrementButtonTapped
-> isLoading = true
-> Task.sleep
-> delayedIncrementFinished
-> count += 1
하지만 Reducer 안의 Effect가 구체적인 시간 지연 구현을 직접 알고 있다.
try await Task.sleep(for: .seconds(1))
카운터에서는 큰 문제가 아니지만 이 자리가 API 요청이나 파일 저장으로 바뀌면 Reducer가 외부 구현을 계속 끌고 가게 된다.
Dependency를 사용한 코드
Task.sleep을 직접 호출하지 않고 DelayClient라는 Dependency를 만들어 사용한다.
struct DelayClient {
var wait: @Sendable () async throws -> Void
}
extension DelayClient: DependencyKey {
static let liveValue = Self(
wait: {
try await Task.sleep(for: .seconds(1))
}
)
}
extension DependencyValues {
var delayClient: DelayClient {
get { self[DelayClient.self] }
set { self[DelayClient.self] = newValue }
}
}
DelayClient는 시간 지연 작업을 가진다.
liveValue에는 실제 앱에서 사용할 구현을 넣는다.
DependencyValues에 delayClient를 추가하면 Reducer에서 @Dependency로 꺼내 쓸 수 있다.
@Reducer
struct CounterDependencyFeature {
@ObservableState
struct State: Equatable {
var count = 0
var isLoading = false
}
enum Action {
case delayedIncrementButtonTapped
case delayedIncrementFinished
}
@Dependency(\.delayClient) var delayClient
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .delayedIncrementButtonTapped:
state.isLoading = true
return .run { [delayClient] send in
try await delayClient.wait()
await send(.delayedIncrementFinished)
}
case .delayedIncrementFinished:
state.count += 1
state.isLoading = false
}
return .none
}
}
}
핵심은 이 부분.
@Dependency(\.delayClient) var delayClient
Effect 안에서는 직접 Task.sleep을 호출하지 않는다.
try await delayClient.wait()
Reducer가 Task.sleep이라는 구체 구현을 직접 들고 있지 않게 된다.
역할 분리
현재 예제에서 각 역할.
Action: 지연 증가 버튼을 눌렀다는 이벤트
Reducer: isLoading을 바꾸고 Effect를 반환
Effect: 비동기 작업을 실행
Dependency: 시간 지연 구현을 제공
Reducer가 모든 일을 직접 처리하는 구조가 아니다.
Reducer는 상태 변경과 작업 지시를 담당한다.
Effect는 작업을 실행한다.
Dependency는 외부 구현을 제공한다.
이 기준으로 보면 Dependency는 Reducer를 더 작게 만드는 도구라기보다 Reducer가 알아야 하는 것을 줄이는 도구에 가깝다.
왜 분리하는가
예제만 보면 Task.sleep을 직접 쓰는 코드가 더 짧다.
try await Task.sleep(for: .seconds(1))
그래도 Dependency를 쓰는 이유는 외부 작업이 바뀌었을 때 더 명확해진다.
예를 들어 사용자 정보를 가져오는 작업이라면 Reducer가 네트워크 구현을 알 필요가 없다.
@Dependency(\.userClient) var userClient
Reducer는 필요한 작업만 호출한다.
let user = try await userClient.fetch()
userClient가 실제로 URLSession을 쓰는지 테스트용 값을 반환하는지 실패를 만들어내는지는 Reducer의 관심사가 아니다.
Reducer 입장에서 중요한 것은 작업 결과가 다시 Action으로 들어온다는 점이다.
Effect
-> userClient.fetch()
-> send(.userResponse(user))
-> Reducer
-> State 변경
테스트 관점
Dependency를 분리하면 테스트에서 외부 작업을 바꿔 끼울 수 있다.
시간 지연 예제에서는 실제 1초를 기다리지 않게 만들 수 있다.
let store = TestStore(initialState: CounterDependencyFeature.State()) {
CounterDependencyFeature()
} withDependencies: {
$0.delayClient.wait = {}
}
현재는 TestStore를 자세히 보지 않는다.
여기서는 Dependency의 기준만 잡는다.
실제 환경에서는 실제 Dependency 사용
테스트 환경에서는 테스트용 Dependency 사용
Reducer의 흐름은 그대로 두고 외부 구현만 바꿀 수 있다.
정리
Dependency를 쓰는 이유.
Reducer가 외부 구현을 직접 알지 않게 하기
현재 기준.
State = 화면에 필요한 값
Action = 상태 변경이나 작업 시작의 계기
Reducer = 상태 변경 규칙과 Effect 반환 위치
Effect = 비동기 작업 실행 위치
Dependency = 외부 구현을 주입받는 위치
Effect.run은 비동기 작업을 실행하는 위치다.
Dependency는 그 작업에 필요한 외부 구현을 Reducer 밖에서 가져오는 방식이다.