[SwiftUI / TCA Tutorials] Side Effects 추가하기

박준혁 - Niro·2024년 3월 29일
2
post-thumbnail

안녕하세요 Niro 🚗 입니다!

🔗 [SwiftUI / TCA Tutorials] 첫번째 feature 구현하기

첫번째 튜토리얼을 잘 읽고 오셨나요?
내용이 이어지기 때문에 먼저 읽고 오시면 이해가 잘 될거라 생각합니다.

이번 두번째 튜토리얼은 Side Effect 를 어떻게 관리하고 상태를 어떻게 업데이트 하는지에 대해 알아보고자 합니다!

차근 차근 한단계씩 진행할 예정이니 잘 읽어주세요!

🔗 The Composable Architecture Tutorials

https://pointfreeco.github.io/swift-composable-architecture/main/tutorials/composablearchitecture/01-02-addingsideeffects


1. Side Effect 가 뭐예요..?

애플리케이션 개발에서 중요한 부분 중 하나가 바로 Side effect(부수 효과) 입니다.

외부 서비스와 상호 작용하여 기능을 수행할 수 있도록 해주는 부분이죠. 예를 들어, 네트워크 요청을 보내거나 파일을 저장하는 등의 작업이 여기에 해당합니다.

side effect 는 외부 환경에 따라 그 결과가 달라질 수 있기 때문에 개발 과정에서 가장 복잡한 부분 중 하나입니다. 특히 네트워크 요청과 같은 비동기 작업에서 더욱 두드러진다 생각합니다!

TCA 에서는 side effect 를 처리하기 위해 Effect 라는 개념을 도입하고 있습니다. Effect 는 애플리케이션의 상태를 변경하거나 외부 서비스와 상호 작용하는 데 사용됩니다.

개발자는 비동기 작업을 보다 쉽게 처리하고 애플리케이션의 일관성을 유지할 수 있게 되죠!


struct CounterView: View {
  let store: StoreOf<CounterFeature>
  
  var body: some View {
    VStack {
      Text("\(store.count)")
	  	//...
				
      HStack {
        Button("-") {
          store.send(.decrementButtonTapped)
        }
		//..
        
        Button("+") {
          store.send(.incrementButtonTapped)
        }
		//..
      }
      
      Button("Fact") {
        store.send(.factButtonTapped)
      }
	  //..
			
	  if store.isLoading {
        ProgressView()
      } else if let fact = store.fact {
        Text(fact)
          .font(.largeTitle)
          .multilineTextAlignment(.center)
          .padding()
      }
    }
  }
}

첫번째 튜토리얼에서 만든 CounterFeature 에 새로운 기능을 추가해볼까 합니다. Button 을 누르면 현재 표시된 숫자가 맞는지(Fact) 확인하기위해 네트워크 요청을 하려고 합니다!

먼저 CounterView 하단에 버튼을 추가했고 아직 존재하지 않지만 누르게 되면 .factButtonTapped action 을 전송합니다.

또한 하단에 progressView 를 추가하여 Fact 를 로드하는 동안 표시하고, if-let 구문을 통해 store.fact 에 값이 있으면 Text 로 출력하려고 합니다.

아직 CounterFeature 에 존재하지 않는 isLoadingfact state 를 사용하고 있기 때문에 이제 추가를 해봐야겠죠?


import ComposableArchitecture

@Reducer
struct CounterFeature {
  @ObservableState
  struct State {
    var count = 0
    var fact: String?
    var isLoading = false
  }
  
  enum Action {
    case decrementButtonTapped
    case factButtonTapped
    case incrementButtonTapped
  }
  
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .decrementButtonTapped:
        state.count -= 1
        state.fact = nil
        return .none
        
      case .factButtonTapped:
        state.fact = nil
        state.isLoading = true
        return .none
        
      case .incrementButtonTapped:
        state.count += 1
        state.fact = nil
        return .none
      }
    }
  }
}

Your first feature 에서 보았던 CounterFeature 에 바뀐것들이 보입니다!

Fact Button 의 동작을 정의하고자 Action enum 에 factButtonTapped case 를 추가했고 State 구조체에 fact 를 보여줄 프로퍼티와 progressView 의 상태를 나타날 수있는 isloading 프로퍼티를 추가했습니다.

Reducer 를 통해 전송된 action 에 따른 동작을 body 안에서 구현한 모습도 보입니다.

아직 우리는 Side Effect 에 대한 내용을 아직 정하지 않았으므로 다른 Action 과 동일하게 .none 으로 반환하게 되어있습니다.


import ComposableArchitecture

@Reducer
struct CounterFeature {
//...

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .factButtonTapped:
        state.fact = nil
        state.isLoading = true
        
        let (data, _) = try await URLSession.shared
          .data(from: URL(string: "http://numbersapi.com/\(state.count)")!)
        // 🛑 'async' call in a function that does not support concurrency
        // 🛑 Errors thrown from here are not handled
        
        state.fact = String(decoding: data, as: UTF8.self)
        state.isLoading = false
        
        return .none
       
       //...
      }
    }
  }
}

자, 이제 Side Effect 를 어떻게 작성할 수 있을까요?

numbersapi.com 라는 곳에서 현재 count 에 대한 fact 를 가져오게 위해 URLSession 을 사용하여 비동기 작업을 수행하려고 하지만.. 비동기 처리를 할 수 없다고 오류가 발생하게 됩니다.

TCA 의 가장 핵심적인 원칙을 위배했기 때문이죠!

TCA 는 상태 변화와 Side Effect 에 대해 분리하여 코드를 단순하고 깔끔하게 유지하는 핵심 원칙을 갖고 있습니다.

그래서 TCA 는 Effect 라는 개념을 도입해서 효과적으로 Side Effect 를 관리하고자 합니다!


2. 네트워크 요청 수행하기

위에서 우리는 Side Effect 가 무엇이고 왜 Reducer 에서 직접 수행할 수 없는지에 대해 알아보았으니까 이제 적용하는 방법을 배워야겠죠?

TCA 의 Effect는 리듀서의 일부로서 정의됩니다.

Reducer 가 State 를 변경하고 Action 을 처리한 후에 외부 시스템과 통신하고 데이터를 처리할 수 있는 Effect 를 반환할 수있다는 것을 의미합니다.

즉, Side Effect 로 인해 State 를 변경하던, 내부 Action 으로 인해 State 를 변경하던 Reducer 가 모든 것을 처리한다는 의미입니다.

이제 네트워크 요청을 보내고 해당 정보를 reducer 에게 전달해보겠습니다.

import ComposableArchitecture

@Reducer
struct CounterFeature {
//...

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .factButtonTapped:
        state.fact = nil
        state.isLoading = true
        
        return .run { [count = state.count] send in
          let (data, _) = try await URLSession.shared
            .data(from: URL(string: "http://numbersapi.com/\(count)")!)
          let fact = String(decoding: data, as: UTF8.self)
        }
       
       //...
      }
    }
  }
}

Effect 를 구현하는 가장 기본적인 방법은 static 메서드인 run(priority:operation:catch:fileID:line:) 을 사용하는 것입니다.

이 메서드를 사용하면 네트워크 요청을 수행하고 가져온 데이터를 적절한 형식으로 변환하는 등의 작업을 손쉽게 처리할 수 있습니다.

특히 .run 메서드의 후행 클로저는 numbersapi.com 에서 데이터를 가져와 문자열로 변환하게 되는데 이처럼 우리가 원하는 작업을 처리하고, Reducer 에 필요한 데이터를 제공하는 것으로 Effect 를 구성할 수 있습니다.


case .factButtonTapped:
	state.fact = nil
	state.isLoading = true
        
	return .run { [count = state.count] send in
       let (data, _) = try await URLSession.shared
            .data(from: URL(string: "http://numbersapi.com/\(count)")!)
       let fact = String(decoding: data, as: UTF8.self)
       state.fact = fact
       // 🛑 Mutable capture of 'inout' parameter 'state' is not allowed in
       //    concurrently-executing code
    }

하지만 데이터를 가져온 후 Effect 내에서 직접 state.fact 를 변경할 수 없습니다.

우리는 클로저를 통해서 state 와 action 을 캡쳐하고 있습니다. 클로저 내에서 state 를 변경하기 위해서는 inout 으로 캡쳐를 해야만 합니다.

하지만 escaping Closure 의 경우 나중에 실행될 수 있는 구문으로 state 라는 매개변수가 호출 시점에 메모리에 없을 수있기 때문에 inout 매개변수는 캡쳐를 할 수가 없게 되죠!

즉,.run 클로저는 탈출 클로저 이기 때문에 inout 으로 State 를 캡쳐할 수가 없게 됩니다.

따라서 우리는 Effect 를 통해 외부 작업을 수행하고 그 결과를 Reducer 에게 보내는 것으로 reducer 와 effect 가 각각의 역할을 가지고 있는 것을 유지할 수 있습니다. 이를 통해 더욱 명확하고 유지보수하기 쉽게 만들 수 있게 되죠!


import ComposableArchitecture

@Reducer
struct CounterFeature {
  enum Action {
	  //...
		case factResponse(String)
		//...
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {  
      case .factButtonTapped:
        state.fact = nil
        state.isLoading = true
        
        return .run { [count = state.count] send in
          let (data, _) = try await URLSession.shared
            .data(from: URL(string: "http://numbersapi.com/\(count)")!)
          let fact = String(decoding: data, as: UTF8.self)
          await send(.factResponse(fact))
        }
              
      case let .factResponse(fact):
        state.fact = fact
        state.isLoading = false
        return .none      
      
	  }
	}
}

state 를 업데이트하도록 reducer 로 다시 보내야하기 때문에 네트워크에서 전달받은 문자열에 관련된 값을 포함하고 있는 factResponse 라는 Action 을 추가해야합니다.

결과적으로 Effect 내에서 비동기 작업을 수행한 후 데이터를 해당 Action 에 전송하게되고 State 를 업데이트 하도록 처리할 수 있게 됩니다.


3. Timer 관리하기

위에서 Side effect 중 하나인 네트워크 요청 작업에 대해 알아보았습니다. 이번에는 새로운 Side effect 를 관리하는 방법을 알아보기 위해 탭을 하면 1초 반복 타이머가 시작하는 버튼을 하나 추가하여 1초가 지날때 마다 count 가 하나씩 증가하는 기능을 추가해보겠습니다.

struct CounterView: View {
  let store: StoreOf<CounterFeature>
  
  var body: some View {
		  //...
      Button(store.isTimerRunning ? "Stop timer" : "Start timer") {
        store.send(.toggleTimerButtonTapped)
      }
      .font(.largeTitle)
      .padding()
      .background(Color.black.opacity(0.1))
      .cornerRadius(10)  
      
      //...
  }

다음과 같이 isTimerRunning 의 상태에 따라 Text 를 표시하고 탭하게 되면 toggleTimerButtonTapped Action 을 전송하는 Button 을 만들었습니다.

그러면 다시 CounterFeature 에서 State 와 Action, Action 에 따른 동작을 추가해야겠죠?

import ComposableArchitecture

@Reducer
struct CounterFeature {
  @ObservableState
  struct State {
		//...
    var isTimerRunning = false
  }
  
  enum Action {
		//...
		case toggleTimerButtonTapped
  }
  
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      // ...
      case .toggleTimerButtonTapped:
        state.isTimerRunning.toggle()
        return .run { send in
	        //...
        }
      }
    }
  }
}

isTimerRunning 프로퍼티와 toggleTimerButtonTapped case 를 추가해주었고 해당 Action 에 따른 state 를 업데이트 시키고 관련 동작을 구현해주어야 합니다.

Timer 동작은 비동기 작업이므로 Effect 를 반환해주는 모습도 보이네요.

앞서 우리는 네트워크 요청 비동기 작업을 Effect 를 통해 관리했습니다. Effect 내에선 외부 변수를 직접적으로 수정하는 것을 허용하지 않았습니다.

위의 Timer 에 대한 Effect 도 마찬가지로 isTimerRunning 을 변경하기 위해선 새로운 Action 이 필요하고 해당 Action 에 대한 동작을 구현해야합니다!


enum Action {
	case toggleTimerButtonTapped
	case timerTick
}

var body: some ReducerOf<Self> {
	Reduce { state, action in
		switch action {

    case .timerTick:
      state.count += 1
      state.fact = nil
      return .none
        
    case .toggleTimerButtonTapped:
      state.isTimerRunning.toggle()
      return .run { send in
        while true {
          try await Task.sleep(for: .seconds(1))
          await send(.timerTick)
        }
      }
    }

state 를 변경하기 위해 .timerTick Action 을 추가하였고 버튼을 누르면 1초 뒤에 해당 Action 을 보내게 됩니다. 결과적으로 1초마다 계속 count 가 증가되는 것을 볼 수가 있습니다.

계속.. count 를 증가되게 만들 수는 없으니 멈추는 코드도 만들어보겠습니다.


import ComposableArchitecture

@Reducer
struct CounterFeature {
	struct State { //... }
	enum Action { //... }
	
	enum CancelID { case timer }
	
	var body: some ReducerOf<Self> {
	Reduce { state, action in
		switch action {

    case .timerTick:
      state.count += 1
      state.fact = nil
      return .none
        
    case .toggleTimerButtonTapped:
      state.isTimerRunning.toggle()
      if state.isTimerRunning {
        return .run { send in
          while true {
            try await Task.sleep(for: .seconds(1))
            await send(.timerTick)
          }
        }
        .cancellable(id: CancelID.timer)
      } else {
        return .cancel(id: CancelID.timer)
      }
    }
  }
}

TCA 의 강력한 기능중에 하나인 effect cancellation 를 활용하여 비동기 로직을 멈출 수 있습니다. 식별자를 제공하여 cancellable effect 로 만들 수 있고 ID 를 통해 Effect 를 취소할 수 있게 됩니다.

.cancellable(id:cancelInFlight:) 메서드를 사용하여 ID 를 전달하고 모든 Effect 를 취소 가능한 것으로 표시한 다음 나중에 .cancel(id:) 를 사용하여 해당 Effect 를 취소할 수 있습니다.

결과적으로 비동기 작업 중에 발생하는 예기치 않은 상황이나 사용자의 조작에 따라 효과를 취소하고 상태를 관리할 수 있습니다.


4. 정리하자면

이번 튜토리얼을 통해 Side Effect 를 다루는 방법을 배웠습니다!

단방향 Flow 의 특징을 갖고 있는 TCA 는 외부 서비스로 인해 생기는 변화를 한곳에서 관리하고자 Effect 라는 개념을 도입하였고

비동기 처리 등 모든 State 변화는 Reducer 를 통해서 이루어지게 됩니다!

기존 SwiftUI 단점이였던 양방향 flow 를 해결하는 모습이 굉장히 인상적이네요!

다음 튜토리얼도 준비할테니 기대해주세요~!

profile
📱iOS Developer, 🍎 Apple Developer Academy @ POSTECH 1st, 💻 DO SOPT 33th iOS Part

0개의 댓글