들어가는 말
화면 진입시 여러가지 API요청을 병렬적으로 수행했야하는 과제가 있습니다. TCA에서 비동기는 Effect의 Operation인 run 함수를 통해서 비동기 함수를 실행할 수 있습니다. 이를 통해서 서버를 통해 정보를 가져올 수 있습니다. 또한 run Operation 안쪽에 있는 send를 통해서 다른 Action을 호출 할 수 있습니다.
var body: some Reducer<State, Action> {
Reduce { state, action in
// code...
case .numberFactButtonTapped:
state.isNumberFactRequestInFlight = true
state.numberFact = nil
// Return an effect that fetches a number fact from the API and returns the
// value back to the reducer's `numberFactResponse` action.
return .run { [count = state.count] send in
await send(.numberFactResponse(Result { try await self.factClient.fetch(count) }))
}
}
// code...
}
글 개요
susu프로젝트에서 병렬적으로 Network통신을 해야하는 작업이 있었습니다. 실제로 보여줄 아이템 리스트는 "헤더 섹션에 보여줄 아이템", "인기 투표 아이템", "투표 아이템" 이었습니다. 각각의 아이템들은 서로 연관성이 없기 때문에 reduce Action을 분리해도 됩니다.
그렇기에 비동기로 실행할 Action을 분리하면 아래와 같은 코드가 만들어 집니다.
enum AsyncAction: Equatable {
case getInitialVoteItems // initial상태의 투표 아이템을 불러옵니다.
case getVoteItems // 더이상 보여줄 아이템이 없다면 새 아이템을 불러옵니다.
case getPopularVoteItems // 인기 투표 아이템을 불러옵니다.
case getVoteHeaderSectionItems // 헤더 섹션 아이템을 가져 옵니다.
}
이를 실제 화면에 매핑하면 다음과 같습니다.
서로 다른 action으로 분리하여 다른 동작을 취하게 만들면 다음과 같은 코드가 완성됩니다. run 이후에 실행되는 send(.inner) code는 비동기를 통해 불러온 코드들을 업데이트 하는 것이라 생각하시면 됩니다. 이렇게 초기에 불러올 action을 분리하였습니다.
Reduce { state, action in
switch action {
case .getInitialVoteItems:
return .run { send in
let response = try await network.getVoteItems(param)
await send(.inner(.updateVoteItems(response)))
}
case .getPopularVoteItems:
return .run { send in
let items = try await network.getPopularItems()
await send(.inner(.updatePopularItems(items)))
}
case .getVoteHeaderSectionItems:
return .run { send in
let response = try await network.getVoteCategory()
await send(.inner(.updateVoteHeaderCategory(response)))
}
}
}
이 세개의 Action을 호출하기 위해서는 reducer에서는 다음과 같은 작업을 거치면 됩니다. `.concatenate` 와 `.merge`를 활용하여 적절하게 Reducer Action을 호출하는 것 입니다. isLoading을 통해서 만약 비동기 테스크가 진행되는 상태라면 뷰를 표시하지 않습니다.
case let .onAppear(isAppear):
if state.isOnAppear {
return .none
}
state.isOnAppear = isAppear
return .concatenate(
.send(.isLoading(true)),
.merge(
.send(.getPopularVoteItems),
.send(.getInitialVoteItems),
.send(.getVoteHeaderSectionItems)
),
.send(.isLoading(false))
)
하지만 예상과는 다르게 데이터가 불러오지도 않았는데 isLoading이 바로 false로 바뀌어 버리는 문제가 발생했습니다. 또한 한번에 view가 update되는 것을 기대했지만, 제각각의 run loop tick 에서 업데이트가 되었습니다. 즉 View가 몇번이고 바뀌는 버그가 생긴 것 입니다.
이를 실제 타이밍에 맞게 생각해 보면 다음과 같은 비동기 흐름이 진행됩니다. 이렇게 Property업데이트가 서로 다른 RunLoopTick에서 발생하게 된다면, 사용자 입장에서 불편함을 느끼게 됩니다.
그러면 어떻게 마지막으로 받은 Property와 RunLoopTick을 통해서 업데이트를 할 수 있을까요?
언제 어떻게 action이 실행되는지 조사해보기
실제로 Reducer send(action) 함수가 불릴 때 순서가 어떻게 작동할 지에 대해서 고민해보았습니다. 실제 코드를 작동시키면 다음과 같은 결과가 발생했습니다. merge에서 작업이 모두 끝나고 isLoading이 실행되는것이 아님을 확인했습니다.
확인해 보니 return .run { send in ...} 에 들어갈 때 task가 완료되는 것 처럼 보였습니다.
DataRace 없이 단일 RunLoopTick에 맞춰서 View를 업데이트 해보기
일단 Send는 @mainActor의 function이기 때문에 DataRace를 걱정할 필요가 없습니다. 우리가 호출하는 Reducer의 Action은 항상 MainThread에서 실행 합니다. 그렇기 때문에 mutating function을 할 때 state 의 Property가 자연스럽게 isolated 됩니다. 즉 state는 Actor처럼 작동 합니다. (하지만 Reducer Action을 MainThread에서 실행하지 않을 수도 있는데, 이럴경우 TCA모듈에서 런타임 에러를 발생시킵니다.)
이럴 경우isolated 환경이 갖춰질 경우 DataRace가 발생하지 않을 것이라는 생각을 했씁니다. async호출의 앞과 뒤에 TaskManager에 관한 연산을 붙이는 방식을 하게 된다면, TaskManager는 dataRace가 발생하지 않으며 TaskCount가 증가할 때 마다 작업이 끝났는지 동기적으로 확인할 수 있게 됩니다.
이를 코드로 나타내면 다음과 같습니다. 주의 TCA State에 storedProperty에만 적용 가능합니다. (isolated Enviorment가 아닌 다른 곳에서 사용하면 Data Race가 나타날 수 있습니다.)
public struct TaskManager: Equatable {
private let originalTaskCount: Int
private var taskCount: Int
public init(taskCount: Int) {
self.taskCount = taskCount
originalTaskCount = taskCount
}
public mutating func taskWillRun() {
taskCount -= 1
}
public mutating func taskDidFinish() {
taskCount += 1
}
public mutating func isEndOfTask() -> Bool {
originalTaskCount == taskCount
}
public mutating func isRunningTask() -> Bool {
!(originalTaskCount == taskCount)
}
}
그리고 async하게 동작하는 action의 앞과 뒤에 send Task Action을 붙여줍니다. 그리고 Task Action을 처리할 때 만약 모든 작업이 끝나게 된다면 isLoading을 false로 바꿔 줍니다. throttle을 isLoading 에 단 이유는 다음과 같습니다.
만약 너무 빠르게 데이터가 오고가서 한개의 클로저에서 task(.willRun), task(.didfinished)가 바로 실행되는 경우가 있을 때 0.2초의 딜레이 동안 데이터를 기다렸다가 isLoading을 통해서 업데이트 하고자 했습니다. 이를 통해서 첫번째 데이터 통신 이후 0.2초 내에 데이터가 도달한다면, 모든 데이터를 한번에 업데이트 가능하게 기능을 수정했습니다.
Reduce { state, action in
switch action {
case .getInitialVoteItems:
return .run { send in
await send(.task(.willRun)) // ✅
let response = try await network.getVoteItems(param)
await send(.inner(.updateVoteItems(response)))
await send(.task(.didFinish)) // ✅
}
case .getPopularVoteItems:
return .run { send in
await send(.task(.willRun)) // ✅
let items = try await network.getPopularItems()
await send(.inner(.updatePopularItems(items)))
await send(.task(.didFinish)) // ✅
}
case .getVoteHeaderSectionItems:
return .run { send in
await send(.task(.willRun)) // ✅
let response = try await network.getVoteCategory()
await send(.inner(.updateVoteHeaderCategory(response)))
await send(.task(.didFinish)) // ✅
}
}
// TaskAction
case let .task(taskState):
switch taskState {
case .willRun: // 비동기 함수를 실행한다면
state.taskManager.taskWillRun()
return .none
case .didFinish: // 비동기 함수가 끝났을 때
state.taskManager.taskDidFinish()
// ✅ 만약 비동기 함수가 끝났을 때 현재 Task가 끝났다면 isLoading을 해제합니다.
return state.taskManager.isRunningTask() ?
.none :
.send(.isLoading(false)).throttle(id: CancelID.checkIsLoading, for: 0.2, scheduler: RunLoop.main, latest: true)
}
}
보일러 플레이트 코드 줄여보기
모든 비동기에 들어가는 함수 마다 send(.task(...)에 관한 코드를 적재하는 것이 에러를 발생시킬 가능성을 높힙니다. 또한 에러가 발생한 경우 프로그래머가 찾기도 정말 힘듭니다. 모든 .run{} 블럭에서 해결하는 것 보다 일관된 코드를 만들어서 해결하면 좋을 것이라 생각했습니다. 따라서 TCA의 Run을 보고 다음과 같은 코드를 만들어 보았습니다.
startOperation과 endOperation을 callback으로 받아서 매핑하였습니다. 이를 통해서 run operation이 실행되기전과 실행된 후에 동작을 일일이 지정할 필요 없이 새로운 function을 통해 해결했습니다.
public extension ComposableArchitecture.Effect {
static func runWithStartFinishAction(
priority: TaskPriority? = nil,
operation: @escaping @Sendable (Send<Action>) async throws -> Void,
startOperation: @escaping @Sendable (Send<Action>) async throws -> Void,
endOperation: @escaping @Sendable (Send<Action>) async throws -> Void,
catch handler: (@Sendable (_ error: Error, _ send: Send<Action>) async -> Void)? = nil
) -> Effect<Action> {
let currentOperation: @Sendable (Send<Action>) async throws -> Void = { send in
try await startOperation(send)
try await operation(send)
try await endOperation(send)
}
return .run(priority: priority, operation: currentOperation, catch: handler)
}
}
다시 이를 VoteReducer에서만 사용할 수 있게 묶으면, function을 만들 수 있습니다. 앞으로 runWithVoteTaskManager를 실행할 경우 자동적으로 send(.task(...))이 run operation 시작과 종단점에서 실행될 것입니다.
private func runWithVoteTaskManager(
priority: TaskPriority? = nil,
operation: @escaping @Sendable (Send<Action>) async throws -> Void,
catch handler: (@Sendable (_ error: Error, _ send: Send<Action>) async -> Void)? = nil
) -> Effect<Action> {
let startOperation: @Sendable (Send<Action>) async throws -> Void = { send in
await send(.task(.willRun))
}
let endOperation: @Sendable (Send<Action>) async throws -> Void = { send in
await send(.task(.didFinish))
}
return .runWithStartFinishAction(
priority: priority,
operation: operation,
startOperation: startOperation,
endOperation: endOperation,
catch: handler
)
}
그리고 async action의 Reduce는 다음과 같이 작성됩니다. 훨 깔끔해진 모습을 볼 수 있습니다.
switch action {
case .getInitialVoteItems:
return runWithVoteTaskManager { send in
let response = try await network.getVoteItems(param)
await send(.inner(.updateVoteItems(response)))
}
case .getPopularVoteItems:
return runWithVoteTaskManager { send in
let items = try await network.getPopularItems()
await send(.inner(.updatePopularItems(items)))
}
case .getVoteHeaderSectionItems:
return runWithVoteTaskManager { send in
let response = try await network.getVoteCategory()
await send(.inner(.updateVoteHeaderCategory(response)))
}
}
}
코드를 적용하고 난 후에 실제 동작 화면입니다.
전체 코드는 여기서 확인할 수 있습니다.
'프로젝트 > 수수-경조사비 관리 서비스' 카테고리의 다른 글
[수수] iOS에서 에러 Log를 Discord + FireBase로 전달(Discord를 통한 Log 저장 방식 공유) (2) | 2024.09.26 |
---|---|
[수수] iOS 4개월치 회고, 열길 물속은 알아도 한길 사람속은 모른다. (6) | 2024.09.08 |
[SUSU] SwiftUI로 Custom Apple Login Button 만들기 (0) | 2024.06.28 |
[SUSU] 수수앱에서 Navigation 방식을 정의하기 part 3(TCA With Navigation) (0) | 2024.06.15 |
[SUSU] 수수앱에서 Navigation 방식을 정의하기 part 2 (TCA With Navigation) (1) | 2024.06.15 |