Combine INPUT/OUTPUT 패턴

준우·2024년 4월 28일
0

Swift 이야기

목록 보기
15/19
post-thumbnail

최근 프로젝트를 진행하면서, Combine 을 사용하여 이벤트 처리를 하기 시작했습니다.
Combine 을 계속 사용하여, MVVM 패턴을 쓰면서 이런 생각이 들었습니다.

"점점 더 이벤트 처리가 많아지면, 어떻게 관리하지??"

그래서 바로 구글에 How to Control Combine in Swift 라고 검색을 한 결과, INPUT/OUTPUT 패턴 이라는 것을 알게 되었습니다.

읽어보니, 간단하면서 이벤트들을 관리하기 편해 보였습니다.

INPUT/OUTPUT 패턴은 View 로부터 받는 입력과 ViewModel 에서 내보내는 동작만 설정하면, 전체 이벤트들을 관리할 수 있는 패턴입니다.

그러면, INPUT/OUTPUT 패턴을 사용한 간단하게 데이터를 추가 / 삭제 해보겠습니다.

ViewModelType

  • ViewModel 에 INPUT, OUTPUT, INPUT -> OUTPUT 으로 바꿀 수 있는 프로토콜을 만듭니다.
protocol ViewModelType {
    associatedtype Input // View -> ViewModel
    associatedtype Output // ViewModel -> View

    func transform(input: AnyPublisher<Input, Never>) -> AnyPublisher<Output, Never>
}

ViewModel

  • Enum Input : View 로부터 들어오는 입력들

      // 뷰로부터 들어오는 입력
      enum Input {
          case createData(Data)
          case deleteData(Data)
      }
  • Enum Output : View 로 나가는 출력들

      // 뷰모델로부터 뷰로 나가는 출력
      enum Output {
          case loadData([Data]) // 전체 데이터 전달
          case createFail(error: Error) // 추가 실패 이벤트
          case deleteFail(error: Error) // 삭제 실패 이벤트
      }
  • Output 변수 : PassthroughSubject 를 사용하여, View 로 이벤트를 전달하는 Subject

      private let output: PassthroughSubject<Output, Never> = .init()
  • Subscriptions 변수 : 구독한 이벤트를 모아놓는 변수

      private var subcriptions = Set<AnyCancellable>()
  • Transform 메서드 : INPUT 이벤트를 입력받아 -> OUTPUT 이벤트를 내보내는 메서드

 @Inject var dataService: DataServiceProtocol // 의존성 주입 받는 서비스

func transform(input: AnyPublisher<Input, Never>) -> AnyPublisher<Output, Never> {
    input.sink { [weak self] event in
        guard let self = self else { return }
        switch event {
        case .createData(let data):
            createData(data: data) // creation data
        case .deleteData(let data):
            deleteData(data: data) // delete data
        }
    }
    .store(in: &subcriptions)

    return output.eraseToAnyPublisher()
}

// 의존성 주입 받은 서비스에세 발생한 추가 이벤트를 전달 받는 메서드
private func createData(data: Data) {
      dataService.createData(data)
          .sink { [weak self] completion in
              // 데이터 추가 실패시
              if case .failure(let error) = completion {
                  self?.output.send(.createFail(error: error))
              }
          } receiveValue: { [weak self] datas in
              self?.output.send(.loadData(datas))
          }
          .store(in: &subcriptions)
  }

// 의존성 주입 받은 서비스에세 발생한 삭제 이벤트를 전달 받는 메서드
  private func deleteData(data: Data) {
      dataService.deleteData(data)
          .sink { [weak self] completion in
              // 데이터 삭제 실패시
              if case .failure(let error) = completion {
                  self?.output.send(.deleteFail(error: error))
              }
          } receiveValue: { [weak self] datas in
              self?.output.send(.loadData(datas))
          }
          .store(in: &subcriptions)
  }

DataService

  • 데이터를 CRUD 처리하는 객체

DataServiceProtocol

protocol DataServiceProtocol {
    var datas: [Data] { get }
    func createData(_ data: Data) -> AnyPublisher<[Data], Error> // 데이터 추가 메서드
    func deleteData(_ data: Data) -> AnyPublisher<[Data], Error> // 데이터 삭제 메서드
}

DataService

final class DataService: DataServiceProtocol {
    var datas: [Data]

    init(datas: [Data] = []) {
        self.datas = datas
    }

    /// 데이터가 추가된 Data 배열 값 발행 || 여기서는 실패할 가능성이 X이기 때문에 promise(.fail) 안 씀
    func createData(_ data: Data) -> AnyPublisher<[Data], Error> {
        return Future<[Data], Error> { [weak self] promise in
            guard let self = self else { return }
            datas.append(data)
            promise(.success(datas)) // 데이터 추가 성공
        }
        .eraseToAnyPublisher()
    }

    /// 특정 데이터 삭제된 후 Data 배열 값  발행 || 여기서는 실패할 가능성이 X이기 때문에 promise(.fail) 안 씀
    func deleteData(_ data: Data) -> AnyPublisher<[Data], Error> {
        return Future<[Data], Error> { [weak self] promise in
            guard let self = self else { return }
            if let index = self.datas.firstIndex(where: { $0.id == data.id }) {
                datas.remove(at: index)
                promise(.success(datas))
            }
        }
        .eraseToAnyPublisher()
    }
}

View

  • ViewModel 로 데이터를 전달함.

    private let viewModel = ViewModel()
  • View 에서는 ViewModel 로 이벤트를 전달할 Subject 를 생성해야함.

    private let input: PassthroughSubject<ViewModel.Input, Never> = .init()
  • View 에서는 ViewModel 로 이벤트 구독을 관리할 Subscriber 를 생성해야함.

    private var subscriptions = Set<AnyCancellable>()
  • View 에서 ViewModel 의 transform 메서드를 구독(= ViewModel 에서 발생하는 이벤트를 전달받기 위함.)

    private func bind() {
          let output = viewModel.transform(input: input.eraseToAnyPublisher())
    
          output.sink { [weak self] event in
              switch event {
              case .loadData:
                  self?.tableView.reloadData()
              case .createFail(error: let error):
                  print("#### \(error)")
              case .deleteFail(error: let error):
                  print("#### \(error)")
              }
          }
          .store(in: &subscriptions)
      }
  • View 에서 추가 이벤트 발행

    button.addAction(UIAction(handler: { _ in
              let data = Data(title: "Hello World")
              self.input.send(.createData(data)) // Data를 ViewModel에 이벤트 발행
          }), for: .touchUpInside)
  • View 에서 삭제 이벤트 발행

    extension View: UITableViewDelegate {
      func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
          let item = viewModel.dataService.datas[indexPath.row]
          if editingStyle == .delete {
              input.send(.deleteData(item)) // Data를 ViewModel에 이벤트 발행
          }
      }
    }

결론

위와 같은 구조로 코드를 짜본 결과, 데이터를 추가/삭제 하는 이벤트들을 관리하기가 훨씬 편해졌다.
Input, Output 동작 외의 행동들을 방지할 수 있기 때문인 거 같다.

그러나, 관리하기 편하다는 장점들도 있는 한편, 단점도 있다.

간단하거나, 작은 규모의 프로젝트에서는 오버프로그래밍 인 것 같다...
관리해야할 이벤트가 많지 않은 경우에는 더 많은 코드를 사용해 작성하니 낭비라고 생각이 든다.

아무튼, 그러한 단점에도 불구하고, 관리하기가 편하다는 점 때문에 향후 프로젝트를 하거나, 리팩토링 할 때 사용을 고민해 봐야겠다.

전체 코드 주소

[MY GITHUB]

0개의 댓글