Actor 도입으로 Data Race 해결
MVVM + RxSwift로 타이머 앱을 개발하던 중, 타이머의 재개, 중지 이벤트에 대한 결과가 Core Data에는 잘 저장이 되지만, View에는 반영되지 않는 오류를 겪었다.
기존 앱 구조
ViewModel은 Input - Output 패턴을 따르며, Output으로 두 가지 상태를 방출한다
var ongoingTimer: BehaviorRelay<[TimerEntity]> // 실행 중
var recentTimer: BehaviorRelay<[TimerEntity]> // 최근 등록
- ongoingTimer: 현재 실행 중인 타이머 배열
- recentTimer: 최근에 등록된 타이머 배열
이 상태는 ViewController에서 각각 TableView의 각 섹션으로 바인딩된다
문제 원인
타이머 앱은 매초마다 남은 시간을 줄여야 하고 동시에 사용자는 타이머를 중지하거나, 재개, 삭제, 생성할 수 있다
타이머 상태를 바꾸는 작업은 크게 두가지이다
- Tick 로직: 실행 중인 타이머들의 시간을 1초씩 줄임
- 이벤트 처리: 사용자가 타이머를 생성, 중지, 재개, 삭제
Tick, 이벤트 처리 등 타이머의 여러 로직은 Task 비동기 컨텍스트에서 작업 후 Output의 Timer에 반영하는데 이는 서로 다른 스레드에서 동작할 가능성이 높다
따라서 서로 타이밍이 맞지 않는다면 기존의 데이터로 덮어씌어지는 것이었다
// Tick 로직
private func updateTimersByTick() {
var updatedTimers = ongoingTimer.value
// 시간 감소 & 삭제 처리
ongoingTimer.accept(updatedTimers) // ← 상태 덮어쓰기
}
// 타이머 생성
private func createTimer(...) {
Task {
var timers = ongoingTimer.value
...
let updatedOngoing = ongoingTimer.value + [newTimer]
ongoingTimer.accept(updatedOngoing) // ← 상태 덮어쓰기
}
}
두 메서드는 모두 ongoingTimer.value를 읽고, 변경한 새 배열을 다시 accept로 덮어쓴다
즉, 하나의 작업이 진행되는 동안 다른 작업도 accpet하면 서로 변경 사항을 덮어쓰는 상황이다
첫 번째 시도: 직렬 큐로 업데이트
따라서 타이머 업데이트의 순서를 보장하기 위해 직렬큐로 관리하여 기존 데이터로 덮어씌어지는 문제를 해결하고자 했다
타이머 상태를 모두 업데이트 하고 Ouput에 accpet 할 때는 커스텀 직렬큐에서 작업을 진행한다
private let timerUpdateQueue = DispatchQueue(label: "com.clock.timerUpdateQueue")
private func updateTimersByTick() {
...
timerUpdateQueue.async {
self.ongoingTimer.accept(updatedTimers)
}
}
// 타이머 생성
private func createTimer(...) {
Task {
var timers = ongoingTimer.value
...
let updatedOngoing = ongoingTimer.value + [newTimer]
timerUpdateQueue.async {
ongoingTimer.accept(updatedOngoing)
}
}
}
모든 타이머 상태 변경은 timerUpdateQueue에서만 처리하도록 개선하여 타이머 덮어씌어지는 문제를 해결했다고 생각했지만,
직렬큐로 데이터 업데이트의 순서를 보장해도 근본적인 문제는 해결되지 않았다
‘읽은 데이터가 수정되었다’
예를들어, 아래 Tick 로직이 오래 걸리는 작업일 경우, 그 사이에 createTimer()가 같은 메서드가 ongoingTimer.value를 읽고 먼저 업데이트해버리면, Tick 로직은 업데이트 전 데이터로 작업한 후 그 결과가 위에 덮어씌워질 수 있다.
// Tick 로직
private func updateTimersByTick() {
var updatedTimers = ongoingTimer.value
// 시간 감소 & 삭제 처리
timerUpdateQueue.async {
self.ongoingTimer.accept(updatedTimers)
}
}
// 타이머 생성
private func createTimer(...) {
Task {
var timers = ongoingTimer.value
...
let updatedOngoing = ongoingTimer.value + [newTimer]
timerUpdateQueue.async {
ongoingTimer.accept(updatedOngoing)
}
}
}
두 번째 시도: Actor 도입
그래서 상태를 직접 관리하는 actor를 도입했다.
actor는 한 번에 하나의 Task만 접근할 수 있기 때문에 get()을 호출했을 때 이전 작업이 모두 완료된 후의 상태를 보장 받을 수 있다.
private let ongoingTimerActor = TimerStateActor()
private let recentTimerActor = TimerStateActor()
final actor TimerStateActor {
private var timers: [TimerDisplay] = []
func get() -> [TimerDisplay] {
timers
}
func update(_ transform: ([TimerDisplay]) -> [TimerDisplay]) {
timers = transform(timers)
.sorted{ $0.remainingMillisecond < $1.remainingMillisecond }
}
func delete(at index: Int) -> TimerDisplay {
timers.remove(at: index)
}
func set(_ timers: [TimerDisplay]) {
self.timers = timers
.sorted{ $0.remainingMillisecond < $1.remainingMillisecond }
}
func toggleRunnningState(at index: Int) {
timers[index].toggleRunningState()
}
}
모든 상태 변경은 actor 내부에서 순차적으로 수행되고, View에 반영할 시점에 get()을 호출한다
private func updateTimersByTick() {
Task {
await ongoingTimerActor.update { timers in
var updatedTimers = timers
// 시간 감소 처리
return updatedTimers
}
ongoingTimer.accept(await ongoingTimerActor.get())
}
}
더이상 데이터가 덮어씌어지는 문제가 발생하지 않았다
Rx 기반의 비동기 상태 관리에서 발생할 수 있는 Data Race를 직접 겪고 해결해보면서 일관된 상태 관리와 actor의 중요성, 특징에 대해 배울 수 있었다.