Share Run TCA 로직 구현

ShareRun을 만들면서 TCA를 활용해 구현하는 방식 중 State를 변경하는 로직과 Action이 발생할 때 상태 변화 및 이팩트를 처리하는 메서드를 정의한다.

@Reducer
struct RunningFeature {
    @ObservableState
    struct RunningState: Equatable {
        var record: RunningRecord?  // 러닝 기록을 저장할 상태
        var isRunning: Bool = false  // 러닝 중인지 여부
        var currentLocation: CLLocationCoordinate2D?  // 현재 위치
        var mapRegion: MKCoordinateRegion?  // 지도에 표시할 영역
        var authorizationStatus: CLAuthorizationStatus = .notDetermined  // 위치 권한 상태
        var heartRate: Int = 0  // 실시간 심박수
        var cadence: Int = 0  // 실시간 케이던스
        var locationHistory: [CLLocationCoordinate2D] = []  // 이동 기록(위치 기록)
        var startTime: Date?  // 러닝 시작 시간
        var endTime: Date?  // 러닝 종료 시간
        var distance: Double = 0.0  // 누적 이동 거리
    }
}

State는 현재 러닝 상태와 관련된 데이터를 관리하고 isRunning이 true일 때 사용자가 런닝 중임을 의미한다. 이와 동일하게 위치정보, 심박수, 케이던스, 지도 등 상태로 관리하도록 설정했다.

enum RunningAction {
    case startRunning  // 러닝 시작
    case stopRunning  // 러닝 종료
    case locationUpdated(CLLocationCoordinate2D)  // 위치 업데이트
    case heartRateUpdated(Int)  // 심박수 업데이트
    case cadenceUpdated(Int)  // 케이던스 업데이트
    case authorizationStatusChanged(CLAuthorizationStatus)  // 권한 변경
    case mapRegionChanged(MKCoordinateRegion)  // 지도 영역 변경
    case saveRunRecord  // 러닝 기록 저장
}

Action은 사용자와 시스템의 이벤트를 정의한다. startRunning은 러닝이 시작됨의 action을 설정하고 locationUpdated 는 새로운 위치 데이터가 들어왔을때 처리 되도록 정의했다.

var body: some ReducerOf<Self> {
    Reduce { state, action in
        switch action {
        case .startRunning:
            // 러닝 시작 시 상태 초기화
            state.isRunning = true
            state.startTime = Date()
            state.locationHistory = []
            state.distance = 0.0

            // 위치와 심박수 데이터를 받아오는 스트림 생성
            let locationStream = locationManager.startUpdatingLocation()
                .map { Action.locationUpdated($0.coordinate) }
                .receive(on: mainQueue)
                .eraseToEffect()
            
            let heartRateStream = healthKitManager.startHeartRateUpdates()
                .map(Action.heartRateUpdated)
                .receive(on: mainQueue)
                .eraseToEffect()
            
            return .merge(locationStream, heartRateStream)  // 위치, 심박수 스트림을 처리
        }
    }
}

Reducer는 액션이 발생하면 상태를 업데이트 하거나 이펙트를 트리거 하도록 설정했다. 예를 들어 startRunning 액션이 발생하면 런닝을 시작하고 위치와 심박수 데이터를 받아오는 스트림을 반환 한다. 이 데이터는 이후 locationUpdated, heartRateUpdated로 다시 처리 된다.

struct RunningEnvironment {
    var locationManager: LocationManager
    var healthKitManager: HealthKitManager
    var mainQueue: AnySchedulerOf<DispatchQueue>
}

TCA는 Environment 를 활용해 외부 시스템 혹은 API 호출을 관리하도록 한다. 이 프로젝트를 진행하면서 RunningEnvironment를 활용해 CLLocationManager, HealthKit 등 데이터를 처리하고 mainQueue를 통해 메인스레드에서 UI업데이트를 수행할 수 있도록 구현했다.

class LocationManager: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()
    private let subject = PassthroughSubject<CLLocation, Never>()
    
    func startUpdatingLocation() -> AnyPublisher<CLLocation, Never> {
        manager.requestWhenInUseAuthorization()
        manager.startUpdatingLocation()
        
        return subject.eraseToAnyPublisher()
    }

    func stopUpdatingLocation() {
        manager.stopUpdatingLocation()
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        if let location = locations.last {
            subject.send(location)
        }
    }
}

LocationManager는 CLLocationManager를 래핑해 위치 데이터를 실시간으로 제공하고 이 내용을 Combine 스트림으로 변환해 리듀서에서 처리할 수 있도록 메서드를 구현하였다.

 

startUpdatingLocation 메서드를 호출하면 CLLocationManager가 위치 업데이트를 시작하고 이를 AnyPublisher로 변환해 리듀서에서 사용할 수 있게 한다.

 

이렇게해서 위치 업데이트가 발생하면 locationManager(_:didUpdateLocations:) 에서 subject를 통해 최신 위치를 방출하도록 구현했다.

 

이렇게 오늘은 Manager를 구현해 메서드를 만들고 State, Action을 정의해 Reducer를 활용해 이 둘의 변화를 담당하는 TCA 구조로 구성을 완료했다 🙂

 

아직은 TCA가 확 다가올 정도로 익숙치 않고 확실히 러닝커브가 좀 높은 것 같지만 핫한 아키텍처인 만큼 TCA 교육자료가 상당히 잘되어있는 것 같다. 꾸준히 공부해서 익숙해져보자!

 

오늘은 여기까지!