새벽의 기록

[iOS] TCA Zero-to-Hero #2 - Effect: 비동기 처리와 외부 통신 본문

[iOS]

[iOS] TCA Zero-to-Hero #2 - Effect: 비동기 처리와 외부 통신

OneTen 2025. 12. 21. 00:07

앱은 단순히 더하기 빼기만 하지 않는다. 

API 통신, 타이머, 데이터 저장 같은 복잡한 요구사항과 작업들을 해야 한다. TCA에서는 이를 Effect라고 부른다.

 

목표: 지난번 만든 카운터 앱에 "1초 뒤에 증가하기" 버튼 추가.


Reducer는 순수 함수여야 한다. 즉, 같은 입력(State, Action)이 들어오면 항상 같은 결과가 나와야 한다.

 

하지만 DispatchQueue.main.asyncAfter 같은 건 순수하지 않다.

그래서 TCA는 return .none 대신 return .run { send in ... } 을 사용해서 비동기 작업을 수행한다.

 

import SwiftUI
import ComposableArchitecture

@Reducer
struct CounterFeature {
    @ObservableState
    struct State: Equatable {
        var count = 0
        var isLoading = false
    }
    
    enum Action {
        case incrementButtonTapped  // 증가 버튼 클릭
        case decrementButtonTapped  // 감소 버튼 클릭
        case delayedIncrementButtonTapped // 지연 증가 버튼 클릭
        case incrementResponse // 1초가 지난 후 실제로 카운트를 올리라는 내부 액션
    }
    
    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .incrementButtonTapped:
                state.count += 1
                return .none
                
            case .decrementButtonTapped:
                state.count -= 1
                return .none
                
            case .delayedIncrementButtonTapped:
                state.isLoading = true  // 로딩 시작
                
                return .run { send in
                    // 복잡한 비동기 로직(API 호출, 타이머 등)을 수행.
                    try await Task.sleep(nanoseconds: 1_000_000_000) // 1초 대기
                    
                    // 작업이 끝나면 다시 Action을 날려서 State 변경.
                    await send(.incrementResponse)
                }
                
            case .incrementResponse:
                state.isLoading = false // 로딩 끝
                state.count += 1
                return .none
                
            }
        }
    }
}

struct CounterView: View {
    let store: StoreOf<CounterFeature>
    
    var body: some View {
        VStack {
            
            if store.isLoading {
                ProgressView()
                    .padding()
            } else {
                Text("\(store.count)")
                    .font(.largeTitle)
                    .padding()
            }
            
            HStack {
                Button("-") {
                    store.send(.decrementButtonTapped)
                }
                .font(.largeTitle)
                .padding()
                .background(Color.black.opacity(0.1))
                .cornerRadius(10)
                
                Button("+") {
                    store.send(.incrementButtonTapped)
                }
                .font(.largeTitle)
                .padding()
                .background(Color.black.opacity(0.1))
                .cornerRadius(10)
                
                Button("1초 뒤 증가") {
                    store.send(.delayedIncrementButtonTapped)
                }
                .font(.largeTitle)
                .padding()
                .background(Color.black.opacity(0.1))
                .cornerRadius(10)
            }
        }
    }
}

#Preview {
    CounterView(
        store: Store(initialState: CounterFeature.State()) {
            CounterFeature()
                ._printChanges()
        }
    )
}

 

 

 

 

Comments