Store와 @MainActor, Task 관리

moonazn·2026년 1월 6일

Swift

목록 보기
11/11

Store와 @MainActor, Task 관리

1. 배경

현재 Clean Architecture + MVI(Presentation Layer) 패턴을 적용하여 코드를 작성하던 중, Container 역할을 수행하는 Store의 스레드 안전성에 대하여 생각해볼 여지가 생겼다.

protocol Store: AnyObject, ObservableObject {
    associatedtype Intent
    associatedtype Action
    associatedtype State

    var state: State { get set }

    func action(intent: Intent) -> AsyncStream<Action>
    func reduce(state: State, action: Action) -> State
}

extension Store {
    @MainActor
    func send(intent: Intent) async {
        let actions = action(intent: intent)
        for await action in actions {
            state = reduce(state: state, action: action)
        }
    }
}
  • Store 내부의 send 메서드 자체에서 action, reduce 함수를 모두 호출하는 형식임.
  • Store 내부에서 네트워크 호출, 비동기 작업 등이 수행되는데 Store 전체 또는 send 메서드 자체를 @MainActor로 고정해도 되는지 고민
    • 무거운 작업까지 메인 스레드에서 실행되어 UI가 블로킹되지 않을지에 대한 우려
    • Store를 non-actor로 둘 경우 상태 변경이 여러 스레드에서 발생할 수 있고, SwiftUI와의 thread-safety를 보장하기 어려울 것 같음.
  • 기존 작업을 취소할 수 있도록 Task를 관리하는 방식이 필요
    • 한 Intent에 대하여 비동기 작업이 실행되는 중에 동일 Intent가 연속으로 들어올 수 있기 때문

2. 내용

1) Store에 MainActor를 붙여도 되는가

Store의 역할: 상태 변경을 안전하게 메인 스레드에서 일관되게 수행하는 것

  • 네트워크 호출은 메인 스레드를 블로킹하지 않음.
    • await 구간 동안 메인 스레드는 대기만 할 뿐 점유되지 않는다.
    • 완료 시점에만 다시 깨어나서 상태를 갱신한다.

-> 네트워크/비동기 IO는 @MainActor 내부에서 실행해도 안전하다.
단, CPU-heavy/동기 루프/큰 파싱/대규모 컬렉션 변환과 같은 작업들은 반드시 도메인 계층 또는 백그라운드 Task로 옮겨야 한다.

2) Task를 Store 프로퍼티로 관리하는 전략

  • 의도

    • 동일 Intent가 반복 호출될 수 있음.
    • 이전 비동기 작업은 취소되어야 함.
    • Store 제거 시 Task도 정리되어야 함.
  • 예시 코드

    @MainActor
    final class ExampleStore: Store {
    
      private var task: Task<Void, Never>?
    
      func action(intent: Intent) -> AsyncStream<Action> {
          AsyncStream { continuation in
    
                task?.cancel()   // 🔹 기존 작업 취소
    
              let newTask = Task {
                  do {
                      let model = try await useCase.execute()
                      continuation.yield(.didLoad(model))
                  } catch is CancellationError {
                      // 취소는 조용히 무시
                  } catch {
                      continuation.yield(.failed(error))
                  }
    
                  continuation.finish()
              }
    
                task = newTask
    
              continuation.onTermination = { _ in
                  newTask.cancel()
              }
          }
      }
    
      deinit {
          task?.cancel()
      }
    }

3) 장점

  1. 상태 변경은 항상 MainActor에서만 발생하여 thread-safety를 확보할 수 있으며 race condition을 방지할 수 있다.
  2. 무거운 작업은 UI와 분리된다.
  3. Task 관리가 명확해진다.
    • 동일 Intent 재시작 -> 이전 작업 자동 취소
    • View lifecycle 종료 -> Task 자동 취소
    • Store 해제 시 -> Task 정리됨

4) MainActor를 아예 붙이지 않는 경우

  • 현재 Store는 ObservableObject를 채택하고 있고, state를 @Published로 쓰고 있음.
  • SwiftUI 규칙: ObservableObject의 프로퍼티 변경은 메인 스레드에서 해야 한다.
    • 어길 시 Publishing changes from background threads is not allowed …
  • 이를 타입 시스템 차원에서 보장하지 못하게 되고 async/await + 여러 Task에서 Store를 부를수록 데이터 레이스/타이밍 버그/경고가 생길 가능성이 커지므로 주의가 필요하다.
profile
개발 공뷰

0개의 댓글