기어가더라도 제대로
[UIKit-기초] MVVM 인풋-아웃풋 패턴(with. Combine) 본문
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 명령: 성공 실패에 대한 것을 예시로 들었지만 얼마든지 추가 가능
- 들어오는 Input 명령: 랜덤 숫자를 추가하라는 버튼이 눌림
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
'UIKit 기초' 카테고리의 다른 글
[UIKit-기초] Xib 파일에 선언되어있는 UIViewController를 불러오기 (0) | 2025.02.13 |
---|---|
[UIKit-기초] UINavigationBar Cheat Sheet - 2 (0) | 2025.02.08 |
[UIKit-기초] UINavigationBar Cheat Sheet - 1 (0) | 2025.02.07 |
[UIKit-응용] Tab 전환을 모달로 하기(UITabBarController, Modal, Sheet) (0) | 2025.02.04 |
[UIKit-기초] UISegmentedControl - basic (0) | 2025.02.03 |