[iOS/RxSwift] 클로저 기반 코드를 RxSwift MVVM 아키텍처로 리팩토링하기

전성훈·2023년 12월 11일
2

iOS/UIKit

목록 보기
3/3
post-thumbnail

주제: 클로저 + MVVM 코드에서 RxSwift + MVVM 코드 리팩토링 하기


시작하기 앞서, 해당 코드는 번호 찾기 게임: [iOS] Unit test & UI Test(코드)를 기반으로 작성되었습니다.

RxSwift의 기본 개념

  • 시작하기 앞서, 해당 문서는 기존에 클로저 기반으로 작성된 FindNumber 애플리케이션 코드를 RxSwift를 활용한 MVVM 아키텍처로 리팩토링하는 것을 목표로 합니다.
  • RxSwift, 즉 Reactive Programming의 핵심은 데이터와 이벤트의 흐름을 시간 축에 따른 스트림으로 처리하는 데에 있습니다. 이는 전통적인 이벤트 처리 방식과 다른 관점을 제공합니다.
  • 코드 설계 시, 단순히 이벤트 발생 시점에 초점을 맞추기보다는, 이벤트들이 흐르는 연속적인 시간 축을 고려하여 설계해야 합니다. 이는 RxSwift에서 PublishSubject, PublishRelay 등의 클래스를 통해 구현됩니다.
  • Input은 다양한 이벤트 스트림(시간 축)을 담당하는 클래스들로 구성됩니다. ViewController에서 이벤트가 발생하면, 해당 이벤트 스트림에 이를 알려 필요한 기능을 실행시키는 방식으로 작동합니다.
  • 이벤트 스트림이 새로운 이벤트를 받게 되면, 이를 Output으로 전달합니다. OutputObserver 또는 Driver를 활용하여 스트림에서 발생하는 이벤트에 따라 특정 행위를 수행하고, 이 결과를 ViewController로 전달합니다.
  • 이렇게 리팩토링된 구조에서는 데이터의 흐름과 사용자의 상호작용이 보다 명확하고 유연하게 처리될 수 있으며, 코드의 가독성과 유지 보수성이 향상됩니다.

RxSwift를 활용하기 위한 기본 틀

ViewModel Porotocl 생성

  • 첫 단계로 ViewModel protocol을 정의합니다. 이는 associatedType을 사용해 InputOutput을 정의하고, 이들을 처리하는 transform 함수를 제공합니다.
protocol ViewModel: class {
	// 입력과 출력을 정의하는 연관 타입 
	associatedtype Input 
	associatedtype Output
	
	// 입력을 받아 출력을 생성하는 메서드 
	func transform(input: Input) -> Output
}

ViewModel 생성

  • Input: 이벤트 스트림을 받기 위한 변수들을 정의합니다.
  • Output: 이벤트 스트림의 결과를 ViewController로 전송합니다.
import Foundation 

import RxSwift
import RxCocoa

final class FindNumberViewModel: ViewModel { 
	struct Input { 
		// 이벤트 스트림 변수들
	}
	
	struct Output { 
		// 결과 출력 변수들 
	}
	
	func transform(input: Input) -> Output { 
		// 변환 로직
	}
}

ViewController 생성

  • ViewModel 인스턴스를 생성하고, 이에 대한 inputoutput을 처리합니다.
import UIKit

import RxSwift
import RxCocoa

final class FindNumberViewController: UIViewController {
	private var viewModel: FindNumberViewModel!
	private let disposeBag = DisposeBag()
	
	override func viewDidLoad() { 
		super.viewDidLoad() 
		
		viewModel = FindNumberViewModel() 
		
		bindViewModel()
	}
	
	private func bindViewModel() { 
		// input처리 
		
		// output처리 
	}
}

RxSwift 적용하기

이벤트 스트림으로 변경할 메서드 파악

  • 기존 FindNumber 코드를 확인해보면 round, bestRecord, currentRecord, targetViewModel의 변수로 설정되어있음을 확인할 수 있습니다.
  • 또한 gameStart(), checkNum(), resetStage(), getNumber() 메서드를 통해 onUpdate, onError 클로저 변수를 호출하는것을 확인할 수 있습니다.
  • 이러한 코드를 봤을 때 ViewController에서 상호작용 하는 값들인 round, bestRecord, currentRecord, targetViewModeloutput으로 설정하고 ViewController에서 호출하는 gameStart(), checkNum(), resetStage() 메서드는 ViewModelinput으로 설정하면 좋을 것 같다고 판단됩니다.

ViewModel 구현

Input 설정

final class FindNumberViewModel: ViewModel { 
	// ...
	struct Input { 
		let gameStart: PublishRelay<Void>
		let checkNumber: PublishRelay<Int>
		let resetStage: PublishRelay<Void>
	}
	// ...
}

PublishRelay

  • Subject의 특별한 형태로, ObservableObserver 역할을 모두 수행합니다.
  • ErrorComplete 이벤트를 발생시키지 않아 UI 이벤트 처리에 유용합니다.
  • 초기값을 가지지 않고 구독 시작 이후의 이벤트만 수신합니다.

Output 설정

final class FindNumberViewModel: ViewModel { 
	// ...
	struct Output { 
		// target의 output처리는 테스트 용이한 코드를 위해 설정
		let target: Observable<Int>
		let round: Driver<Int>
		let bestRecord: Driver<Int>
		let currentRecord: Driver<Int>
		let error: Driver<Error>
	}
	// ... 
}
  • 여기서 target을 그냥 변수로 두는것이 아니라 RxSwift를 사용하여 이벤트 스트림의 일부로 Output 구조체 내부에 넣어주는것이 좋으며, 이러한 이유는 테스트 코드를 작성할때 용이하기 때문입니다.
  • targetViewModel 내부에서만 활용되고 ViewController 에서 직접적으로 사용되지 않기 때문에 Driver 대신 Observable<Int> 타입을 사용합니다. 이는 target이 UI와 직접적인 상호작용을 하지 않음을 명시적으로 나타냄으로써, RxSwift의 적용 범위와 목적을 명확히 합니다.

    Driver

    • RxCocoa의 Observable 특수 형태로, UI 작업을 안전하고 쉽게 만듭니다.
    • 메인 스레드에서만 작동하며, 에러를 방출하지 않습니다.

ViewModel의 프로퍼티 선언 및 초기화 함수 구현

  • ViewModel에서 subscribe 메서드를 사용하므로 disposeBag을 선언합니다.
  • round, bestRecord, currentRecordUserDefaults를 통해 최초의 고유 값을 갖고 시작합니다. 이러한 값들을 RxSwift의 Driver로 변환하기 위해서는 이들 값을 관리할 수 있는 BehaviorRelay를 사용해야 합니다. BehaviorRelay는 초기값을 가지고 시작하며, 해당 값의 변화를 지속적으로 관찰할 수 있도록 해줍니다.
  • 반면에 error는 초기값이 필요 없기 때문에, PublishRelay를 사용합니다. PublishRelay는 초기값을 갖지 않으며, 구독 이후에 발생하는 이벤트들만을 전달합니다. 이를 통해 에러 이벤트들을 관리할 수 있습니다.
final class FindNumberViewModel: ViewModel { 
	// 상태 관리를 위한 BehaviorRelay 선언
	private let targetRelay = BehaviorRelay(value: 1)
	private let roundRelay: BehaviorRelay<Int>
	private let bestRecordRelay: BehaviorRelay<Int>
	private let currentRecordRelay: BehaviorRelay<int>
	private let errorRelay = PublishRelay<MyError>()
	
	private let disposeBag = DisposeBag()
	
	var urlSession: URLSessionProtocol!
	var defaults: UserDefaults!
	
	init( 
		urlSession: URLSessionProtocol = URLSession.shared, 
		defaults: UserDefaults = UserDefaults.standard
	) { 
		self.urlSession = urlSession
		self.defaults = defaults
		
		// UserDefaults에서 초기 값 로드 
		let initialRound = defaults.integer(forKey: "Round")
		roundRelay = BehaviorRelay(value: initialRound == 0 ? 1 : initialRound)
		bestRecordRelay = BehaviorRelay(value: defaults.integer(forKey: "BestRecord"))
		currentRecordRelay = BehaviorRelay(value: defaults.integer(forKey: "CurrentRecord"))
	}
	// ...
}
  • viewModel의 각 인스턴스는 자신의 상태(BehaviorRelay를 통한 상태)를 관리하고, 이 상태는 viewModel의 생명 주기 동안 일관성을 유지합니다.
  • transform 메서드는 이 상태를 바탕으로 Input을 받아 처리하고, 적절한 Output을 생성하는 역할을 담당합니다.

Transform 메서드 구현

  • transform 메서드는 Input을 받아 Output으로 변환하는 과정을 정의합니다.
  • BehaviorRelaysubscribe 메서드를 호출하여 UserDefaults에 저장된 값을 업데이트합니다. 예를 들어, roundRelay가 새로운 round 값을 받으면, 이 값은 UserDefaults"Round" 키에 저장됩니다.
final class FindNumberViewModel: ViewModel { 
	// ...
	func transform(input: Input) -> Output { 
		// 최초 코드 Set을 의미함
		roundRelay.subscribe { [weak self] value in
			let round = value.element!
			
			self?.defaults.set(
		        round,
		        forKey: "Round"
		    )
		}
		.disposed(by: disposeBag)
		
		bestRecordRelay.subscribe { [weak self] value in
			let bestRecord = value.element! 
			
			self?.defaults.set(
		        bestRecord,
		        forKey: "BestRecord"
		    )
		}
		.disposed(by: disposeBag)
		
		currentRecordRelay.subscribe { [weak self] value in
			let currentRecord = value.element! 
			
		    self?.defaults.set(
		        currentRecord,
		        forKey: "CurrentRecord"
		    )
		}
		.disposed(by: disposeBag)
		
		// ...
	}
	// ...
}

Observable<Data>를 return하는 URLSession 설정

  • gameStart(), checkNumber(), resetStage() 메서드를 리팩토링 하기 앞서, getNumber() 메서드를 먼저 리팩토링해야 합니다. 이 메서드는 원래 @escaping 클로저를 인자로 받던 메서드였지만, 이를 Observable<Int>를 반환하는 메서드로 변경해야 합니다.
  • 이를 위해 URLSession의 확장(extension)에 dataTaskObservable()이라는 Observable<Data>를 반환하는 메서드를 추가합니다.
  • 또한, URLSessionStub 클래스에서도 dataTask() 메서드를 dataTaskObservable() 메서드로 변경해 줍니다.
enum MyError: Error {
    case invalidURL
    case networkError
    case decodingError
}

typealias ResultData = (data: Data, urlResponse: URLResponse)

protocol URLSessionProtocol {
    func dataTaskObservable(with url: URL) -> Observable<ResultData>
}

extension URLSession: URLSessionProtocol {
    func dataTaskObservable(with url: URL) -> Observable<ResultData> {
        return Observable.create { [weak self] observer in
            let task = self?.dataTask(with: url) { data, response, error in
                if let error = error {
                    observer.onError(error)
                } else if let data = data, let response = response {
                    let result = ResultData(data, response)
                    
                    observer.onNext(result)
                } else {
                    observer.onError(MyError.networkError)
                }
                
                observer.onCompleted()
            }
            task?.resume()
            
            return Disposables.create {
                task?.cancel()
            }
        }
    }
}
  • Observable.create를 사용하여 Observable 이벤트를 생성합니다. 이 이벤트는 (data: Data, response: URLResponse) 형식의 ResultData 타입을 반환합니다.
final class URLSessionStub: URLSessionProtocol {
    private let stubbedData: Data?
    private let stubbedResponse: URLResponse?
    private let stubbedError: Error?
    
    init(
        stubbedData: Data? = nil,
        stubbedResponse: URLResponse? = nil,
        stubbedError: Error? = nil
    ) {
        self.stubbedData = stubbedData
        self.stubbedResponse = stubbedResponse
        self.stubbedError = stubbedError
    }
    
    func dataTaskObservable(with url: URL) -> Observable<ResultData> {
        return Observable.create { [weak self] observer in
            if let error = self?.stubbedError {
                observer.onError(error)
            } else if let data = self?.stubbedData, let response = self?.stubbedResponse {
                let result = ResultData(data, response)
                
                observer.onNext(result)
            }
            else {
                observer.onError(MyError.networkError)
            }
            observer.onCompleted()
            return Disposables.create()
        }
    }
}
  • URLSessionStub 클래스도 변경해주고 URLSessionDataTaskStub 클래스는 삭제해줍니다.

getNumber() 리팩토링

final class FindNumberViewModel: ViewModel { 
	// ... 
	private func getNumber() -> Observable<Int> {
	    guard let url = URL(string: "https://www.randomnumberapi.com/api/v1.0/random?min=1&max=3&count=1") else {
	        return Observable.error(MyError.invalidURL)
	    }
	        
	    return urlSession.dataTaskObservable(with: url)
	            .observe(on: MainScheduler.instance)
	            .flatMap { [weak self] data, response -> Observable<Int> in
	                do {
	                    // HTTP 상태 코드 검사
	                    guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
	                        throw MyError.networkError
	                    }
	                    // JSON 디코딩
	                    guard let newTarget = try JSONDecoder().decode([Int].self, from: data).first else {
	                        throw MyError.decodingError
	                    }
	                       
	                    return Observable.just(newTarget)
	                } catch {
	                    return Observable.error(self?.handleError(error) ?? .networkError)
	                }
	            }
	            .catch { [weak self] in
	                Observable.error(self?.handleError($0) ?? .networkError)
	            }
	}
	// ... 
}
  • dataTaskObservable 메서드를 실행한 후, 그 결과를 MainScheduler에서 처리합니다. dataTaskObservable로부터 얻은 ResultData는 HTTP 응답 코드가 200이 아니거나, JSON 디코딩에 실패할 경우 필터링됩니다. 그 후, 처리된 data 결과를 Observable.just(newTarget)를 통해 관찰자(observer)에게 전달합니다.
  • flatMap 내부의 do-catch 구문을 통해 발생한 에러는 handleError 메서드를 이용해 MyError로 변환됩니다. 이와 같은 에러는 RxSwift의 catch 메서드를 통해 처리되며, 이를 통해 Observable이 에러 발생으로 인해 종료되는 것을 방지합니다.
final class FindNumberViewModel: ViewModel { 
	// ...
    private func handleError(_ error: Error) -> MyError {
        if let myError = error as? MyError {
            return myError
        } else if error is DecodingError {
            return .decodingError
        } else {
            return .networkError
        }
    }
    // ...
}

gameStart() 리팩토링

func transform(input: Input) -> Output { 
	// ...
	input.gameStart
		.flatMapLatest { [weak self] in 
			return self?.getNumber() ?? Observable.just(1)
		}
		.subscribe(onNext: { newTarget in 
			targetRelay.accpet(newTarget)
		}, onError: { error in 
			if let myError = error as? MyError { 
				errorRelay.accept(myError)
			}
		})
		.disposed(by: disposeBag)
	// ...
}
  1. flatMapLatest
    • input.gameStart 이벤트 스트림이 방출하는 이벤트에 대해 flatMapLatest 연산자는 self?.getNumber() 함수를 호출합니다.
    • flatMapLatest는 이전에 방출한 이벤트를 무시하고 가장 최근의 이벤트에 의해 생성된 Observable만을 구독합니다. 즉, 새로운 게임 시작 이벤트가 발생할 때마다 새로운 숫자를 가져오는 함수를 호출합니다.
  2. getNumber()
    • 해당 함수는 Observable<Int> 타입을 반환합니다. 즉, 이 함수는 비동기적으로 숫자를 생성하고, 그 결과를 Observable 스트림을 통해 제공합니다.
  3. subsrcibe
    • flatMapLatest에 의해 반환된 Observable에 대한 구독을 설정합니다. 이 구독은 두 가지 가능한 결과를 처리합니다.
    1. onNext: 새로운 숫자가 성공적으로 생성되었을 때, targetRelay에 해당 숫자를 전달합니다. (targetRelay.accept(newTarget))
    2. onError: 오류가 발생했을 때, 이 오류를 errorRelay에 전달합니다. 이때 오류는 MyError 타입으로 변환됩니다. (errorRelay.accept(myError))
  4. disposed(by: disposeBag)
    • 구독이 더 이상 필요하지 않을 때 자동으로 해제되도록 관리합니다.

checkNumber() 리팩토링

func transform(input: Input) -> Output { 
	// ...
        input.checkNumber
            .flatMapLatest { [weak self] number -> Observable<Int> in
                guard let self = self else { return .just(1) }

                if number == targetRelay.value {
                    // 번호가 일치하면 새로운 번호를 가져오고 라운드 및 기록을 업데이트
                    return self.getNumber()
                        .do(onNext: { _ in
                            let newBestRecord = max(self.bestRecordRelay.value, self.currentRecordRelay.value + 1)
                            self.bestRecordRelay.accept(newBestRecord)
                            self.roundRelay.accept(self.roundRelay.value + 1)
                            self.currentRecordRelay.accept(self.currentRecordRelay.value + 1)
                        })
                        .catch { error in
                            if let myError = error as? MyError {
                                self.errorRelay.accept(myError)
                            }
                            return .empty()
                        }
                } else {
                    // 번호가 일치하지 않으면 라운드 및 기록 초기화
                    roundRelay.accept(1)
                    currentRecordRelay.accept(0)
                    return .empty()
                }
            }
            .subscribe(onNext: { [weak self] newTarget in
                self?.targetRelay.accept(newTarget)
            })            
            .disposed(by: disposeBag)
	// ...
}
  1. flatMapLatest
    • flatMapLatest 연산자는 입력된 숫자 (number)에 대한 처리를 정의합니다.
    • 이는 각 숫자가 입력될 때마다 호출되며, Observale<Int>를 반환합니다.
  2. 맞는 Number 처리하기
    • if number == targetRelay.value: 입력된 숫자가 현재 목표 숫자와 일치하면, getNumber() 메서드를 호출하여 새로운 목표 숫자를 가져옵니다.
    • do(onNext: { _ in ... }): 새로운 목표 숫자가 발생할 때마다, 라운드(roundRelay), 최고 기록(bestRecordRelay), 현재 기록(currentRecordRelay)을 업데이트합니다.
    • catch { error in ... }: 에러가 발생하면, 해당 에러를 MyError로 변환하여 errorRelay에 전달합니다. 에러 발생 시, .empty()를 반환하여 스트림을 종료하지 않습니다.
  3. 틀린 Number 처리하기
    • else: 입력된 숫자가 목표 숫자와 일치하지 않으면, 라운드와 현재 기록을 초기화합니다 (roundRelay.accept(1), currentRecordRelay.accept(0)).
  4. Subscribe
    • subscribe(onNext: { [weak self] newTarget in ... })는 새로운 목표 숫자를 targetRelay에 전달합니다.
  5. disposed(by: disposeBag)
    • 구독이 더 이상 필요하지 않을 때 자동으로 해제되도록 관리합니다.
  • 위 코드를 자세히 보면 input.checkNumber는 사용자가 입력한 숫자를 처리하고 게임의 상태를 업데이트하는 역할을 수행합니다. 숫자를 처리하는 부분에서 if-else로 처리한 부분을 조금 더 명확하고 간결하게 표현하기 위해 내부 private func로 리팩토링 할 수 있습니다.
final class FindNumberViewModel: ViewModel { 
	func transform(input: Input) -> Output { 
		// ...
		input.checkNumber 
			.flatMapLatest { [weak self] number -> Observable<Int> in 
				guard let self = self else { return .just(1) }
				
				return self.prograssNumber(number)
			}
			.subscribe(onNext: { [weak self] in 
				self?.targetRelay.accept(newTarget)
			})
			.disposed(by: disposeBag)
		// ...
	}
	
	private func processNumber(_ number: Int) -> Observable<Int> {
        if number == targetRelay.value {
            return updateGameForCorrectNumber()
        } else {
            resetGameForIncorrectNumber()
            return .empty()
        }
    }
    
    private func updateGameForCorrectNumber() -> Observable<Int> {
        return getNumber()
            .do(onNext: { [weak self] _ in
                self?.updateGameRecords()
            })
            .catch { [weak self] error in
                self?.handleErrorAndReturnEmpty(error) ?? .empty()
            }
    }
    
    private func resetGameForIncorrectNumber() {
        roundRelay.accept(1)
        currentRecordRelay.accept(0)
    }
    
    private func updateGameRecords() {
        let newBestRecord = max(self.bestRecordRelay.value, self.currentRecordRelay.value + 1)
        bestRecordRelay.accept(newBestRecord)
        roundRelay.accept(self.roundRelay.value + 1)
        currentRecordRelay.accept(self.currentRecordRelay.value + 1)
    }
    
    private func handleErrorAndReturnEmpty(_ error: Error) -> Observable<Int> {
        if let myError = error as? MyError {
            errorRelay.accept(myError)
        }
        return .empty()
    }
}
  • processNumber(_:)
    • 사용자가 입력한 숫자를 처리하는 메서드입니다. 숫자가 목표 값과 일치하면 updateGameForCorrectNumber()를 호출하고, 그렇지 않으면 resetGameForIncorrectNumber()를 호출합니다.
  • updateGameForCorrectNumber()
    • 사용자가 올바른 숫자를 입력했을 때의 처리를 담당합니다. 새로운 번호를 가져오고, 게임 라운드와 기록을 업데이트합니다.
  • resetGameForIncorrectNumber()
    • 사용자가 틀린 숫자를 입력했을 때 게임을 초기화합니다.
  • updateGameRecords()
    • 게임 기록을 업데이트하는 메서드입니다. 최고 기록, 현재 기록, 라운드를 업데이트합니다.
  • handleErrorAndReturnEmpty(_:)
    • 에러를 처리하고 빈 Observable을 반환합니다. 이는 flatMap 내부에서 에러 처리를 위해 사용됩니다.
  • 이러한 리팩토링은 코드의 각 부분이 명확한 역할을 수행하게 하며, 유지보수 및 이해하기 쉬운 구조를 만듭니다.

resetStage() 리팩토링

final class FindNumberViewModel: ViewModel { 
	func transform(input: Input) -> Output { 
		// ...
		input.resetStage
			.subscribe(onNext: { [weak self] in 
				self?.resetStage()
			})
			.disposed(by: disposeBag)
		// ...
	}
	
	private func resetStage() { 
		roundRelay.accept(1)
		bestRecordRelay.accept(0)
		currentRecordRelay.accept(0)
	}
}
  1. subscribe
    • input.resetStage.subscribe(onNext: { [weak self] in ... }): 이 구독은 resetStage 이벤트가 발생할 때마다 호출됩니다. [weak self]는 메모리 누수를 방지하기 위한 약한 참조(weak reference)입니다.
    • self?.resetStage(): 실제로 스테이지를 초기화하는 로직을 실행합니다. 이는 resetStage 메서드를 호출함으로써 이루어집니다.

Output 생성

func transform(input: Input) -> Output { 
	// ...
	let target = targetRelay.asObservable()
	let round = roundRelay.asDriver(onErrorJustReturn: 1)
	let bestRecord = bestRecordRelay.asDriver(onErrorJustReturn: 0)
	let currentRecord = currentRecordRelay.asDriver(onErrorJustReturn: 0)
	let error = errorRelay.asDriver(onErrorDriveWith: .empty())
	
	return Output(
		target: target,
		round: round, 
		bestRecord: bestRecord, 
		currentRecord, currentRecord,
		error: error
	)
}
  • Input을 받아 처리한 후 그 결과를 Output으로 변환하는 과정을 의미합니다.
  • ViewModel 내부의 상태(relay)를 ObservableDriver로 변환하여 ViewController에서 사용할 수 있도록 준비하는 과정을 나타냅니다. 이를 통해 ViewModel에서 발생하는 다양한 상태 변화를 구독하고, 이에 반응하여 UI를 업데이트하는 데 사용됩니다.

ViewController 구현

import UIKit 

import RxSwift
import RxCocoa

final class FindNumberViewController: UIViewController { 
	// ...
	private let disposeBag = DisposeBag()
	// ...
	override func viewDidLoad() { 
		super.viewDidLoad() 
		
		viewModel = FindNumberViewModel() 
		
		bindData()
	}
	
	private func bindData() { 
		let input = FindNumberViewModel.Input(
			gameStart: PublishRelay<Void>(), 
			checkNumber: PublishRelay<Int>(),
			resetStage: PublishRelay<Void>()
		)
		
		self.rx.viewDidLoad
			.map { _ in }
			.bind(to: input.gameStart)
			.disposed(by: disposeBag)
		
		firstButton.rx.tap 
			.map { 1 }
			.bind(to: input.checkNumber)
			.disposed(by: disposeBag)
		
		secondButton.rx.tap
			.map { 2 }
			.bind(to: input.checkNumber)
			.disposed(by: disposeBag)
		
		resetButton.rx.tap
			.bind(to: input.resetStage)
			.disposed(by: disposeBag)
		
		let output = viewModel.transform(input: input)
		
        output.round
            .map { "스테이지: \($0)"}
            .drive(stageLabel.rx.text)
            .disposed(by: disposeBag)
        
        output.bestRecord
            .map { "최대: \($0)번 연속 성공!"}
            .drive(bestRecordLabel.rx.text)
            .disposed(by: disposeBag)
        
        output.currentRecord
            .map { "현재: \($0)번 연속 성공!"}
            .drive(currentRecordLabel.rx.text)
            .disposed(by: disposeBag)
        
        output.error
            .drive(onNext: { [weak self] error in
                self?.showError(error.localizedDescription)
            })
            .disposed(by: disposeBag)
	}
}

extension Reactive where Base: UIViewController { 
	var viewDidLoad: ControlEvent<Void> { 
		let source = methodInvoked(#selector(Base.viewDidLoad)).map { _ in }
		return ControlEvent(events: source)
	}
}
  1. 변수 선언
    • disposeBag: RxSwift의 메모리 관리를 위해 사용되는 DisposeBag 인스턴스입니다. 구독한 Observable들을 여기에 추가하여, ViewController가 해제될 때 자동으로 구독을 해제하도록 합니다.
    • viewModel: FindNumberViewModel 인스턴스입니다. 이 ViewModelViewController와 데이터 및 이벤트를 주고받는 중심 역할을 합니다.
  2. viewDidLoad에서의 처리
    • viewModel = FindNumberViewModel(): ViewModel 인스턴스를 생성합니다.
    • bindData(): ViewController의 UI 컴포넌트와 ViewModel의 입력(Input) 및 출력(Output)을 바인딩하는 메서드를 호출합니다.
  3. bindData 메서드
    • Input 생성: gameStart, checkNumber, resetStage라는 PublishRelay 인스턴스를 사용하여 ViewModelInput 구조체 인스턴스를 생성합니다.
    • viewDidLoad 이벤트를 gameStart에 바인딩: ViewController가 로드될 때 gameStart 이벤트를 발생시킵니다.
    • 버튼 탭 이벤트 바인딩: 각 버튼(firstButton, secondButton, resetButton)의 탭 이벤트를 각각 checkNumber 또는 resetStage에 바인딩합니다. 이때 map 연산자를 사용하여 버튼 탭 이벤트를 해당 버튼에 해당하는 숫자로 변환합니다.
    • ViewModeltransform 함수 호출: InputViewModel에 전달하여 Output을 생성합니다.
    • Output 바인딩: ViewModelOutput에서 제공하는 round, bestRecord, currentRecord, error 정보를 ViewController의 UI 컴포넌트에 바인딩합니다. 각각의 값에 대해 map 연산자를 사용하여 표시할 문자열로 변환한 후, drive 연산자를 사용하여 UI 컴포넌트의 속성에 바인딩합니다.
    • error 처리: 에러가 발생하면 showError 메서드를 호출하여 에러 메시지를 표시합니다.
  4. Reactive 확장
    • UIViewController에 대한 Reactive 확장을 통해 viewDidLoad 이벤트를 ControlEvent<Void> 형식으로 변환하여 RxSwift의 이벤트 스트림으로 사용할 수 있도록 합니다.

결론

  1. ViewModel Protocol 정의
    • InputOutput을 연관 타입으로 가지며, transform 메서드로 입력을 출력으로 변환합니다.
  2. ViewModel 및 ViewController 구현
    • ViewModel에서 BehaviorRelayPublishRelay를 사용하여 애플리케이션의 상태를 관리하고, 이벤트 스트림을 처리합니다.
    • ViewController에서는 RxSwift의 바인딩과 Driver를 활용하여 ViewModel의 상태 변화에 반응하고 UI를 업데이트합니다.
  3. 이벤트 스트림 처리
    • 각 UI 컴포넌트(예: 버튼)는 PublishRelay를 통해 이벤트를 ViewModel에 전달합니다.
    • ViewModel은 이러한 이벤트를 처리하고, 상태 변화를 Output을 통해 ViewController에 전달합니다.
  4. 코드의 명확성 및 유지 보수성 향상
    • RxSwift를 사용함으로써 데이터 및 이벤트의 흐름이 명확해집니다.
    • 각 부분의 역할이 분명해지고, 테스트와 유지 보수가 용이해집니다.

출처(참고문헌)

원본 코드

제가 학습한 내용을 요약하여 정리한 것입니다. 내용에 오류가 있을 수 있으며, 어떠한 피드백도 감사히 받겠습니다.

감사합니다.

0개의 댓글