기어가더라도 제대로

[UIKit-기초] MVVM 인풋-아웃풋 패턴(with. Combine) 본문

UIKit 기초

[UIKit-기초] MVVM 인풋-아웃풋 패턴(with. Combine)

Damagucci-juice 2025. 2. 11. 20:00

MVVM에서 Input과 Output을 나누어서 명확하게 통신하고 유지보수하기 용이하게 명령과 실행에 대한 호출을 주는 방법

Input, Output 

  • Input: UI의 이벤트나 특정 사건에 대응하는 인터페이스
  • Output: 그러한 처리를 통해서 완료되었다고 알려주는 인터페이스
  • 장점: 어떤 동작이 실행되는지 한눈에 파악 가능
  • 단점: 작은 프로젝트의 경우 보일러 플레이트 코드가 있을 수 있음

단점이라 하긴 어려운데, 저마다 통일된 형태가 있진 않아서 사람마다 구현 방식이 조금 다른 것이 특이점

이번 글에서는 테이블뷰의 행에 랜덤 숫자를 fetch 해와서 보여주는 작은 프로젝트를 구현하면서 해봄

  • 오토레이아웃 편의를 위해 SnapKit을, 객체 선언 편리함을 위해 Then을 사용

 

구현 - VIEWMODEL

  • ViewModel 프로토콜을 선언
    • 구현체에 붙여줘서 통일성을 주기 위함
import Combine

protocol ViewModel {
    associatedtype Input
    associatedtype Output

    var output: PassthroughSubject<Output, Never> { get }
    var cancellables: Set<AnyCancellable> { get }

    /// 뷰모델 단에서 데이터 바인딩을 해주는 함수
    ///
    /// 이 함수를 통해서 VC, VM이 서로 엮인다.
    /// - Parameter input: 호출단에서 Input을 넣어서 Output으로 받음
    /// - Returns: 가지고 있던 output을 반환
    func transform(input: PassthroughSubject<Input, Never>) -> AnyPublisher<Output, Never>
}
  • 뷰모델 구현체 NumbersViewModel 구현
    • 들어오는 Input 명령: 랜덤 숫자를 추가하라는 버튼이 눌림
      • Input의 경우 수동형으로 네이밍 사용
    • 나가는 Output 명령: 성공 실패에 대한 것을 예시로 들었지만 얼마든지 추가 가능
final class NumbersViewModel: ViewModel {

    /// 외부에서 명령을 내릴 때 사용
    enum Input {
        case addRandomNumberTapped
        case viewDidAppear
    }

    /// 명령을 수행하고 결과물을 알릴 때 사용
    enum Output {
        case succeedFetchingNumber
        case failedFetchingNumber
    }

    private(set) var cancellables = Set<AnyCancellable>()
    private(set) var output = PassthroughSubject<Output, Never>()

    private(set) var numbers = [Int]()
}
  • transfer 함수 구현: VC -> VM 으로, 또 VM -> VC로 데이터 바인딩을 해주는 요소
    • event가 switch로 되어있고 또 열거형으로 되어있기 때문에, Input, Output에 변화에 대응하기 쉬움
// MARK: - NumbersViewModel

    func transform(input: PassthroughSubject<Input, Never>) -> AnyPublisher<Output, Never> {
        input.sink { [weak self] event in
            guard let self = self else { return }
            switch event {
            case .addRandomNumberTapped:
                self.handleAddRandomNumber()
            case .viewDidAppear:
                self.handViewDidAppear()
            }
        }
        .store(in: &cancellables)

        return output.eraseToAnyPublisher()
    }
  • handleAddRandomNumber(): 숫자 하나 추가
  • handleViewDidAppear(): 100~1000 사이 숫자 5개 추가
    private func handleAddRandomNumber() {
        let url = "https://www.randomnumberapi.com/api/v1.0/random"
        Task {
            do {
                let (result, _) = try await URLSession.shared.data(from: URL(string: url)!)
                let decoded = try JSONDecoder().decode([Int].self, from: result)
                numbers.append(contentsOf: decoded)

                output.send(.succeedFetchingNumber)
            } catch {
                print(error.localizedDescription)
                output.send(.failedFetchingNumber)
            }
        }
    }
    
    /// 5개 숫자 추가
    private func handViewDidAppear() {
        numbers.removeAll()

        let url = "https://www.randomnumberapi.com/api/v1.0/random?min=100&max=1000&count=5"
        Task {
            do {
                let (result, _) = try await URLSession.shared.data(from: URL(string: url)!)
                let decoded = try JSONDecoder().decode([Int].self, from: result)
                numbers.append(contentsOf: decoded)

                output.send(.succeedFetchingNumber)
            } catch {
                print(error.localizedDescription)
                output.send(.failedFetchingNumber)
            }
        }
    }

 

구현 ViewController

  • 뷰컨트롤러가 들고 있어야하는 뷰모델과 그에 대한 설명격인 프로토콜 

 

protocol ViewController {
    associatedtype ViewModel

    var viewModel: ViewModel! { get set }
    var cancellables: Set<AnyCancellable> { get }

    func setupLayout()

    func setupBinding()
}
  • NumbersViewController의 UI 구현
class NumbersViewController: UIViewController, ViewController {

    var viewModel: NumbersViewModel! = NumbersViewModel()
    let input = PassthroughSubject<NumbersViewModel.Input, Never>()
    var cancellables = Set<AnyCancellable>()

    let tableView = UITableView()
    let addNumberButton = UIButton().then {
        $0.setTitle("랜덤 숫자 추가", for: .normal)
        $0.setTitleColor(.black, for: .normal)
    }
}
  • 추가적인 함수 구현
    • viewDidLoad()에서 input의 viewDidAppear 케이스가 발동
    •  
// MARK: - NumbersViewController

override func viewDidLoad() {
    super.viewDidLoad()
    setupLayout()
    setupBinding()
    
    input.send(.viewDidAppear)
}

func setupLayout() {
    view.backgroundColor = .white
    tableView.dataSource = self

    [tableView, addNumberButton].forEach { view.addSubview($0) }

    addNumberButton.snp.makeConstraints { make in
        make.bottom.horizontalEdges.equalToSuperview().inset(16)
        make.height.equalTo(50)
    }

    tableView.snp.makeConstraints { make in
        make.top.horizontalEdges.equalToSuperview()
        make.bottom.equalTo(addNumberButton.snp.top)
    }
}
  • setupBinding() 함수 구현 
    • Input을 넣어줘서 output을 꺼내오고 그것을 가지고 바인딩을 구현함
// MARK: - NumbersViewController

func setupBinding() {
    addNumberButton.addTarget(
        self,
        action: #selector(addNumberButtonTapped),
        for: .touchUpInside
    )

    let output = viewModel.transform(input: input)
    output
        .receive(on: RunLoop.main)
        .sink { [weak self] event in
        guard let self = self else { return }
        switch event {
        case .succeedFetchingNumber:
            self.handleFetchSuccess()
        case .failedFetchingNumber:
            self.handleFetchFail()
        }
    }
    .store(in: &cancellables)
}
  • 그외 핸들링 함수 및 UITableViewDatasource
    • 버튼이 눌리면 input의 addRandomNumberTapped 케이스가 발동
// MARK: - NumbersViewController

@objc
private func addNumberButtonTapped() {
    input.send(.addRandomNumberTapped)
}

private func handleFetchSuccess() {
    tableView.reloadData()
}

private func handleFetchFail() {
    print("Fetch Failed")
}

extension NumbersViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.numbers.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell()
        cell.textLabel?.text = "\(viewModel.numbers[indexPath.row])"
        cell.textLabel?.textColor = .black
        return cell
    }
}

 

구현 모습 

 

결론

  • input -> VM -> output의 흐름이 마치 오디오 믹서와 비슷하다. 

 

전체 코드 

https://github.com/Damagucci-Juice/ExInputOutputMVVM

 

GitHub - Damagucci-Juice/ExInputOutputMVVM

Contribute to Damagucci-Juice/ExInputOutputMVVM development by creating an account on GitHub.

github.com

 

Comments