포스트

Actor 미니프로젝트

시작하게 된 이유

Swift Concurrency를 공부하면서 Actor를 제대로 써보고 싶었다.

단순히 @MainActor를 클래스에 붙이는 것 말고, 별도의 격리 영역을 만들어서 데이터를 처리하는 구조를 직접 만들어보고 싶었다.


미니 프로젝트 목표

Timer로 임의의 데이터를 생성해서 Actor에 흘려보내고, 그 결과를 ViewModel이 받아서 SwiftUI에 표시하는 전체 흐름을 이해해본다.

모델링

미니 프로젝트지만 그냥 러닝 데이터 4가지를 모델로 잡았다. 페이스, 심박수, 거리, 경과 시간. (이건 actor가 가공해서 보낼 데이터이다.)

1
2
3
4
5
6
struct Running {
      let pace: Double
      let heartRate: Int
      let distance: Double
      let elapsedTime: Int
}

이건 시뮬레이터가 방출하는 데이터 이며 곧 만들 actor가 수집할 모델링이다

1
2
3
4
struct CollectedData {
      let heartRate: Int
      let distance: Double
}

시뮬레이터 만들기

여기서 말하는 시뮬레이터는

1초당 심박과 거리를 임의값으로 보내는 일종의 장치이다. 이름만 거창하지 사실 별거 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Simulator {
    private var timer: Timer?
    
    func start() -> AsyncStream<CollectedData> {
        AsyncStream { continuation in
            self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
                continuation.yield(CollectedData(
                    heartRate: Int.random(in: 140...180),
                    distance: Double.random(in: 0.005...0.01)
                ))
            }
            continuation.onTermination = { _ in
                Task { @MainActor in
                    self.timer?.invalidate()
                    self.timer = nil
                }
            }
        }
    }
    
    func stop() {
        timer?.invalidate()
        timer = nil
    }
}

Timer를 만들어서 쓸수밖에 없었던 이유는 1초마다 랜덤값을 보내서 테스트를 해야하기도 하지만, Timer는 아직도 Completion Handler가 있기 때문이다.

그럼 왜 Completion Handler를 고집했는가 하면 바로 위에 있는 AsyncStream을 사용하기 위해서이다.

사실 이건 이전글에서도 다뤘던 패턴이다. 콜백 기반 API를 AsyncStream으로 감싸서 async/await 세계로 연결하는 브릿지 역할을 한다.

Timer는 아직 async/await를 지원하지 않기 때문에 콜백을 고집할 수밖에 없었고, 그 콜백 안에서 continuation.yield로 값을 흘려보내는 방식이다.


Task를 이용한 방식

Timer 방식으로 구현을 했는데, Task를 이용한 방식도 있다는걸 알게 됐다.

Task + Task.sleep을 사용하면 Timer처럼 RunLoop에 의존하지 않고 순수 Swift Concurrency 안에서 해결이 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
final class SimulatorTask {
    
    private var task: Task<Void, Never>?
    
    func start() -> AsyncStream<CollectedData> {
        AsyncStream { continuation in
            task = Task {
                while !Task.isCancelled {
                    try? await Task.sleep(for: .seconds(1))
                    continuation.yield(
                        CollectedData(
                            heartRate: Int.random(in: 140...180),
                            distance: Double.random(in: 0.005...0.01)
                        )
                    )
                }
            }
            continuation.onTermination = { [weak self] _ in
                Task { @MainActor in
                    self?.stop()
                }
            }
        }
    }
    
    func stop() {
        task?.cancel()
        task = nil
    }
}

Task.isCancelled로 취소 여부를 체크하면서 루프를 돌기 때문에 stop()을 호출하면 자연스럽게 종료된다.

Timer 방식과 비교하면

  • Timer → 콜백 기반, RunLoop 의존
  • Task + sleep → 순수 Swift Concurrency, 취소 처리가 깔끔

여기서 한 가지 주의할 점이 있다.

onTermination 클로저는 Sendable 컨텍스트라서 stop()을 그냥 호출하면

1
Call to main actor-isolated instance method 'stop()' in a synchronous nonisolated context

이런 에러가 발생한다. stop()이 MainActor에 격리되어 있기 때문이다.

그렇다면 왜 아무것도 없는데 MainActor에 격리되어있을까? 라고 생각한다면 Image 바로 Xcode에 이렇게 설정 되어있기 때문이다.

그래서 Task { @MainActor in } 으로 감싸줘야 한다. Swift 6에서는 이런 경우를 그냥 넘어가지 않고 에러로 잡아주기 때문에 명시적으로 처리해줘야 한다.


Actor를 사용한 Data 처리 센터 만들기

시뮬레이터에서 데이터가 1초마다 들어온다. 근데 만약 여러 센서에서 동시에 데이터가 들어온다면?

GPS, 심박수, 케이던스가 동시에 들어오는 상황을 생각해보니 이걸 class로 처리하면 여러 스레드에서 동시에 접근할 수 있겠다는 생각이 들었다.

그러다 actor가 떠올랐다. actor는 내부적으로 serial하게 처리하기 때문에 동시에 데이터가 들어와도 한 번에 하나씩 처리가 보장된다.

그래서 데이터를 받아서 가공하는 처리 센터를 actor를 사용하여 만들어보기로 했다.


여기서의 포인트는 외부에서 값을 직접 받아 온다기보다는

단순히 값을 처리하는 로직만을 만들어준다.

actor이기 때문에 elapsedTime, totalDistance 같은 내부 상태는 동시에 여러 곳에서 접근해도 serial하게 처리가 보장된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
actor DataCenter {
    
    private var elapsedTime: Int = 0
    private var totalDistance: Double = 0
    
    func process(_ data: CollectedData) -> Running {
        elapsedTime += 1
        totalDistance += data.distance
        
        let pace = totalDistance > 0 ? ((Double(elapsedTime) / 60.0) / totalDistance) : 0
        
        return Running(
            pace: pace,
            heartRate: data.heartRate,
            distance: totalDistance,
            elapsedTime: elapsedTime
        )
    }
    
    func reset() {
        elapsedTime = 0
        totalDistance = 0
    }
}

ViewModel 만들기

이제 ViewModel을 만들어준다. 하지만 이때 ViewModel을 2개를 만드는데

하나는 Class 비교용으로 Simulator에서 값을 직접 받아서 처리한다. (우리가 일반적으로 사용하던 ViewModel)

나머지 하나는 Simulator → DataCenter Actor를 거쳐서 처리된 값을 받아 UI를 업데이트한다.

View에서 두 VM을 동시에 돌리면서 결과를 비교하는 구조다.


Class 비교용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import Observation
import Foundation

@MainActor @Observable
final class RunnerViewModel {

    var running: Running = Running(pace: 0,
                                              heartRate: 0,
                                              distance: 0,
                                              elapsedTime: 0)
    var isRunning: Bool = false

    private let simulator = Simulator()
    private var task: Task<Void, Never>?
    private var elapsedTime: Int = 0
    private var totalDistance: Double = 0

    func start() {
        isRunning = true
        task = Task {
            for await data in simulator.start() {
                logCurrentThread()
                elapsedTime += 1
                totalDistance += data.distance

                let pace = totalDistance > 0 ? ((Double(elapsedTime) / 60.0) / totalDistance) : 0

                running = Running(
                    pace: pace,
                    heartRate: data.heartRate,
                    distance: totalDistance,
                    elapsedTime: elapsedTime
                )
            }
        }
    }

    func stop() {
        task?.cancel()
        task = nil
        simulator.stop()
        isRunning = false
    }

    func reset() {
        elapsedTime = 0
        totalDistance = 0
        running = Running(pace: 0, heartRate: 0, distance: 0, elapsedTime: 0)
    }

    private nonisolated func logCurrentThread() {
        print("RunnerViewModel's Thread: \(Thread.current)")
    }
}

actor와 가장 큰 차이점이라면 이 RunnerViewModel은 데이터를 직접 받아서 계산까지 전부 처리하고 UI에 내보내는 구조다.

즉 데이터 수신, 가공, 상태 관리를 ViewModel 혼자 다 한다.


UI업데이트 용 (for Actor)

위의 ViewModel은 값을 받아서 처리하고 내보내는것을 다하지만

이 ViewModel은 단순히 값을 내보내는 역할만 대신한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@MainActor @Observable
class UIUpdateViewModel{
    
    private let simulator = Simulator()
    private let dataCentor = DataCenter()
    private var task: Task<Void, Never>?
    
    var resultData = Running(pace: 0, heartRate: 0, distance: 0, elapsedTime: 0)
    var isRunning: Bool = false

    func transfrom() {
        isRunning = true
        task = Task {
            for await data in simulator.start() {
                logCurrentThread()
                resultData = await dataCentor.process(data)
            }
        }
    }

    func stop() {
        task?.cancel()
        task = nil
        isRunning = false
    }

    private nonisolated func logCurrentThread() {
        print("Actor's Thread: \(Thread.current)")
    }
}

결과 확인하기

그리고 이렇게 코드를 작성했는데 더 명확한 비교를 위해 각각 어느 Thread에서 실행되는지 확인을 해보려 한다.

  • RunnerVM
1
2
3
4
for await data in simulator.start() {
      print("RunnerViewModel's Thread: \(Thread.current)")
      //생략
}

이렇게 하려했으나

1
Class property 'current' is unavailable from asynchronous contexts; Thread.current cannot be used from async contexts.

이렇게 에러가 발생하므로

1
2
3
4
5
6
7
8
for await data in simulator.start() {
      logCurrentThread()
      // 생략
}

private nonisolated func logCurrentThread() {
      print("RunnerViewModel's Thread: \(Thread.current)")
}

이렇게 별도의 함수를 만들어 nonisolated로 빼준다.


  • Actor
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func process(_ data: CollectedData) -> Running {
      print("DataCenter Actor's Thread: \(Thread.current)")
      elapsedTime += 1
      totalDistance += data.distance
      
      let pace = totalDistance > 0 ? ((Double(elapsedTime) / 60.0) / totalDistance) : 0
      
      return Running(
      pace: pace,
      heartRate: data.heartRate,
      distance: totalDistance,
      elapsedTime: elapsedTime
      )
}

여기는 에러가 발생하지 않으므로 그대로 사용이 가능


  • UIUpdateVM
1
2
3
4
5
6
7
8
9
10
11
12
func transfrom() {
      task = Task {
      for await data in simulator.start() {
            logCurrentThread()
            resultData = await dataCentor.process(data)
      }
      }
}

private nonisolated func logCurrentThread() {
      print("UIUpdateVM's Thread: \(Thread.current)")
}

RunnerVM과 똑같은 에러가 발생하므로 동일하게 해준다.


프린트 결과

1
2
3
4
RunnerViewModel's Thread: <_NSMainThread: 0x600001700000>{number = 1, name = main}

UIUpdateVM's Thread: <_NSMainThread: 0x600001700000>{number = 1, name = main}
DataCenter Actor's Thread: <NSThread: 0x600001702d00>{number = 9, name = (null)}

이렇게 Thread가 다르게 실행되는 것을 확인할 수 있었다.

다만 Actor의 핵심은 특정 Thread에서 실행되는 것이 아니라 상태(State)를 안전하게 격리하고 직렬적으로 처리하는 데 있다.

이번 예제에서는 DataCenter Actor가 Main Thread가 아닌 별도의 Thread에서 실행되는 모습이 관찰되었지만, Actor가 항상 백그라운드 Thread에서 동작하는 것을 보장하는 것은 아니다.

중요한 것은 여러 작업이 동시에 접근하더라도 elapsedTime, totalDistance 같은 내부 상태가 안전하게 보호된다는 점이다.


정리

 RunnerViewModel (Class)DataCenter (Actor)UIUpdateViewModel
역할수신 + 가공 + UI 업데이트데이터 가공UI 업데이트
처리 위치MainActorActor IsolationMainActor
상태 관리ViewModel 내부Actor 내부ViewModel 내부
동시 접근 보호MainActor 의존Serial 보장MainActor 의존

이번 미니 프로젝트에서는 Actor를 사용해 데이터 처리 로직을 별도의 격리 영역으로 분리해보았다.

다음 글에서는 동일한 로직을 Class와 Actor로 각각 구현하여 Race Condition이 실제로 발생하는지 실험해보려 한다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.