이번 장에서는 TCA에서의 비동기 처리를 어떻게 관리하는지 알아보기 전에 Swift Concurrency, 즉 동시성 프로그래밍에 대해 알아보겠습니다. Combine 프레임워크와 달리 동시성 프로그래밍은 Task의 병렬 처리를 위해 등장했습니다. Combine 역시 비동기적 작업을 진행할 수 있지만 Publisher와 Subscriber의 존재를 요구하며 데이터의 흐름에 반응하기 위해 사용한다는 점에서 차이가 있습니다. 동시성 프로그래밍은 작업 자체에 대한 비동기적 처리를 지원하기 때문입니다.

이번 장을 매끄럽게 진행하기 위해선 Swift가 제공하는 비동기 처리 API에 대한 이해가 필요합니다. 다만 그 내용이 많고, 이미 그 내용을 알고 있는 분들에게는 크게 새롭지 않을 수 있기 때문에 본문에서는 제외하였습니다. 이해를 위한 간략한 정리가 이번 장의 부록에 준비되어 있으니 필요하신 분들께서는 먼저 읽고 오시면 도움이 될 것입니다.


6.1 TCA와 비동기 처리

본격적으로 TCA에서 어떻게 비동기 처리를 할 수 있는지 살펴보겠습니다. 앞선 장에서 설명하였듯, Reduce는 단 하나의 타입, Effect<Action>를 통해 Application의 상태를 제어합니다. 덕분에 우리는 간편하고 일관적인 방식으로 Application의 상태를 정의하고 수정할 수 있습니다.

하나의 Effect<Action> 는 하나의 Reducer 내부에서 State를 변형하고 관리할 수 있으며 때에 따라 외부 환경에서의 EffectApplication에 피드백할 수 있습니다. 후자의 경우를 TCA에서는 Side Effect로 정의합니다.

6.1.1 .run(priority:operation:catch:fileID:line:)

Side Effect를 처리하는 메서드는 Effect의 타입 메서드인 .run(priority:operation:catch:fileID:line:) 메서드 입니다. 설명의 편의를 위해 Reducer의 내부를 정의하고 있는 예시 코드를 먼저 보겠습니다.

var body: some ReducerOf<Self> {
	Reduce { state, action in
		switch action {
		case .onViewAppearing:
			return .none
		case .onServerRequestButtonTapped:
			return .run { send in
				// 1️⃣ 비동기 코드 실행
			} catch: {
				// 2️⃣ non-cancellation 에 대한 에러 처리
			}
		}
}

예시의 각주 1️⃣에서 개발자는 Side Effect를 실행할 수 있습니다. 원한다면 코드 블록의 비동기 처리에 대한 priority를 설정해 줄 수 있습니다. 또한 send 라는 로컬 상수가 전달값이 되며, 이 로컬 상수를 통해 Side Effect에서 받아온 결과를 애플리케이션의 Action 및 Reduce 의 흐름으로 피드백할 수 있습니다. non-cancellation 로직은 catch 블록에서 처리할 수도 있습니다. 이제 각 역할에 대해 자세하게 알아보겠습니다.

6.1.2 .run의 역할 살펴보기

위 예시 코드를 아래의 코드로 수정해서 더 자세하게 상황을 설정해 보겠습니다. 아래 코드는 Image 타입을 가져오기 위해 비동기 작업을 수행하는 requestImages() 메서드를 .run(priority:operation:catch:fileID:line:)의 클로저 블록에서 호출하고 있습니다. 해당 작업이 실패할 수 있기 때문에 catch 구문을 열어서 어떤 에러가 발생했는지 확인할 수 있고, 작업이 성공적으로 마무리되면 send를 통해 .requestResponse 라는 Action으로 .run(priority:operation:catch:fileID:line:)의 결과를 피드백합니다. 결과는 .requestResponse 케이스의 연관 값으로 전달합니다.

/* code */

case .requestButtonTapped:
		// Main Thread
    return .run { send in
				// Task Thread
        let fetchedImage = try await requestImages()
        await send(.requestResponse(fetchedImage))
    } catch: { error, send in
        print(error)
    }
    
case let .requestResponse(image):
    state.image = image
    return .none

/* code */

private func requestImages() async throws -> Image {
    Task { try! await Task.sleep(for: .seconds(1)) }
    return Image(systemName: "checkmark.circle.fill")
}

.run(priority:operation:catch:fileID:line:)operation { } 클로저 내부의 작업은 작업을 위한 새로운 스레드에서 처리되며, 각 비동기 작업의 결과는 main 스레드로 다시 돌아와야 합니다.

6.1.3 MainActor, send

.run(priority:operation:catch:fileID:line:) 클로저의 내부로 전달되는 sendMainActor 로서 Send<Action> 의 인스턴스입니다. Reducer가 state에 Side Effect의 결과를 피드백하기 위해서는 각 작업이 main 스레드에서 일어나야 하므로, send 구조체로 우리는 Action을 호출할 수 있습니다. 언급하였듯, actor 는 다른 스레드의 작업이 정지될 수 있음을 표지하기 위해 await 키워드와 함께 호출되고 있습니다. 위의 예시를 다시 한번 살펴보면 await send(.requestResponse(fetchedImage)) 이 코드는 state의 변형을 위해 정의된 MainActor 인스턴스 sendfetchedImage 라는 값을 Application의 주된 흐름에 돌려주는 것입니다.

위 예시 코드에서 .reqeustResponse는 케이스의 연관 값으로 Image 타입을 요구하고 있으며, 비동기 처리로 얻어낸 Image를 .requestResponse 에 피드백하여 main 스레드의 흐름으로 다시 편입시킬 수 있습니다. 그런데 언뜻 보면 이 과정은 매우 번거롭고 불편해 보입니다. 그럼에도 불구하고 이러한 방식으로 해야하는 이유에 대해 이어서 설명하겠습니다.

6.1.4 TCA 비동기 처리의 맥락 이해하기