
시작하기 앞서, 해당 코드는 번호 찾기 게임: [iOS] Unit test & UI Test(코드)를 기반으로 작성되었습니다.
PublishSubject, PublishRelay 등의 클래스를 통해 구현됩니다.Input은 다양한 이벤트 스트림(시간 축)을 담당하는 클래스들로 구성됩니다. ViewController에서 이벤트가 발생하면, 해당 이벤트 스트림에 이를 알려 필요한 기능을 실행시키는 방식으로 작동합니다.Output으로 전달합니다. Output은 Observer 또는 Driver를 활용하여 스트림에서 발생하는 이벤트에 따라 특정 행위를 수행하고, 이 결과를 ViewController로 전달합니다.ViewModel protocol을 정의합니다. 이는 associatedType을 사용해 Input과 Output을 정의하고, 이들을 처리하는 transform 함수를 제공합니다. protocol ViewModel: class {
// 입력과 출력을 정의하는 연관 타입
associatedtype Input
associatedtype Output
// 입력을 받아 출력을 생성하는 메서드
func transform(input: Input) -> Output
}
Input: 이벤트 스트림을 받기 위한 변수들을 정의합니다. Output: 이벤트 스트림의 결과를 ViewController로 전송합니다. import Foundation
import RxSwift
import RxCocoa
final class FindNumberViewModel: ViewModel {
struct Input {
// 이벤트 스트림 변수들
}
struct Output {
// 결과 출력 변수들
}
func transform(input: Input) -> Output {
// 변환 로직
}
}
ViewModel 인스턴스를 생성하고, 이에 대한 input 과 output을 처리합니다. 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처리
}
}
round, bestRecord, currentRecord, target이 ViewModel의 변수로 설정되어있음을 확인할 수 있습니다. gameStart(), checkNum(), resetStage(), getNumber() 메서드를 통해 onUpdate, onError 클로저 변수를 호출하는것을 확인할 수 있습니다. ViewController에서 상호작용 하는 값들인 round, bestRecord, currentRecord, target 을 ViewModel의 output으로 설정하고 ViewController에서 호출하는 gameStart(), checkNum(), resetStage() 메서드는 ViewModel의 input으로 설정하면 좋을 것 같다고 판단됩니다. final class FindNumberViewModel: ViewModel {
// ...
struct Input {
let gameStart: PublishRelay<Void>
let checkNumber: PublishRelay<Int>
let resetStage: PublishRelay<Void>
}
// ...
}
PublishRelay
Subject의 특별한 형태로,Observable과Observer역할을 모두 수행합니다.Error및Complete이벤트를 발생시키지 않아 UI 이벤트 처리에 유용합니다.- 초기값을 가지지 않고 구독 시작 이후의 이벤트만 수신합니다.
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 구조체 내부에 넣어주는것이 좋으며, 이러한 이유는 테스트 코드를 작성할때 용이하기 때문입니다.target은 ViewModel 내부에서만 활용되고 ViewController 에서 직접적으로 사용되지 않기 때문에 Driver 대신 Observable<Int> 타입을 사용합니다. 이는 target이 UI와 직접적인 상호작용을 하지 않음을 명시적으로 나타냄으로써, RxSwift의 적용 범위와 목적을 명확히 합니다.
Driver
- RxCocoa의
Observable특수 형태로, UI 작업을 안전하고 쉽게 만듭니다.- 메인 스레드에서만 작동하며, 에러를 방출하지 않습니다.
ViewModel에서 subscribe 메서드를 사용하므로 disposeBag을 선언합니다.round, bestRecord, currentRecord는 UserDefaults를 통해 최초의 고유 값을 갖고 시작합니다. 이러한 값들을 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 메서드는 Input을 받아 Output으로 변환하는 과정을 정의합니다. BehaviorRelay에 subscribe 메서드를 호출하여 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)
// ...
}
// ...
}
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 클래스는 삭제해줍니다.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
}
}
// ...
}
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)
// ...
}
flatMapLatestinput.gameStart 이벤트 스트림이 방출하는 이벤트에 대해 flatMapLatest 연산자는 self?.getNumber() 함수를 호출합니다. flatMapLatest는 이전에 방출한 이벤트를 무시하고 가장 최근의 이벤트에 의해 생성된 Observable만을 구독합니다. 즉, 새로운 게임 시작 이벤트가 발생할 때마다 새로운 숫자를 가져오는 함수를 호출합니다.getNumber() Observable<Int> 타입을 반환합니다. 즉, 이 함수는 비동기적으로 숫자를 생성하고, 그 결과를 Observable 스트림을 통해 제공합니다. subsrcibeflatMapLatest에 의해 반환된 Observable에 대한 구독을 설정합니다. 이 구독은 두 가지 가능한 결과를 처리합니다.onNext: 새로운 숫자가 성공적으로 생성되었을 때, targetRelay에 해당 숫자를 전달합니다. (targetRelay.accept(newTarget))onError: 오류가 발생했을 때, 이 오류를 errorRelay에 전달합니다. 이때 오류는 MyError 타입으로 변환됩니다. (errorRelay.accept(myError))disposed(by: disposeBag)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)
// ...
}
flatMapLatestflatMapLatest 연산자는 입력된 숫자 (number)에 대한 처리를 정의합니다. Observale<Int>를 반환합니다. Number 처리하기if number == targetRelay.value: 입력된 숫자가 현재 목표 숫자와 일치하면, getNumber() 메서드를 호출하여 새로운 목표 숫자를 가져옵니다.do(onNext: { _ in ... }): 새로운 목표 숫자가 발생할 때마다, 라운드(roundRelay), 최고 기록(bestRecordRelay), 현재 기록(currentRecordRelay)을 업데이트합니다.catch { error in ... }: 에러가 발생하면, 해당 에러를 MyError로 변환하여 errorRelay에 전달합니다. 에러 발생 시, .empty()를 반환하여 스트림을 종료하지 않습니다.Number 처리하기else: 입력된 숫자가 목표 숫자와 일치하지 않으면, 라운드와 현재 기록을 초기화합니다 (roundRelay.accept(1), currentRecordRelay.accept(0)).Subscribesubscribe(onNext: { [weak self] newTarget in ... })는 새로운 목표 숫자를 targetRelay에 전달합니다.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내부에서 에러 처리를 위해 사용됩니다.
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)
}
}
subscribeinput.resetStage.subscribe(onNext: { [weak self] in ... }): 이 구독은 resetStage 이벤트가 발생할 때마다 호출됩니다. [weak self]는 메모리 누수를 방지하기 위한 약한 참조(weak reference)입니다.self?.resetStage(): 실제로 스테이지를 초기화하는 로직을 실행합니다. 이는 resetStage 메서드를 호출함으로써 이루어집니다.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)를 Observable과 Driver로 변환하여 ViewController에서 사용할 수 있도록 준비하는 과정을 나타냅니다. 이를 통해 ViewModel에서 발생하는 다양한 상태 변화를 구독하고, 이에 반응하여 UI를 업데이트하는 데 사용됩니다.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)
}
}
disposeBag: RxSwift의 메모리 관리를 위해 사용되는 DisposeBag 인스턴스입니다. 구독한 Observable들을 여기에 추가하여, ViewController가 해제될 때 자동으로 구독을 해제하도록 합니다.viewModel: FindNumberViewModel 인스턴스입니다. 이 ViewModel은 ViewController와 데이터 및 이벤트를 주고받는 중심 역할을 합니다.viewDidLoad에서의 처리viewModel = FindNumberViewModel(): ViewModel 인스턴스를 생성합니다.bindData(): ViewController의 UI 컴포넌트와 ViewModel의 입력(Input) 및 출력(Output)을 바인딩하는 메서드를 호출합니다.bindData 메서드Input 생성: gameStart, checkNumber, resetStage라는 PublishRelay 인스턴스를 사용하여 ViewModel의 Input 구조체 인스턴스를 생성합니다.viewDidLoad 이벤트를 gameStart에 바인딩: ViewController가 로드될 때 gameStart 이벤트를 발생시킵니다.firstButton, secondButton, resetButton)의 탭 이벤트를 각각 checkNumber 또는 resetStage에 바인딩합니다. 이때 map 연산자를 사용하여 버튼 탭 이벤트를 해당 버튼에 해당하는 숫자로 변환합니다.ViewModel의 transform 함수 호출: Input을 ViewModel에 전달하여 Output을 생성합니다.Output 바인딩: ViewModel의 Output에서 제공하는 round, bestRecord, currentRecord, error 정보를 ViewController의 UI 컴포넌트에 바인딩합니다. 각각의 값에 대해 map 연산자를 사용하여 표시할 문자열로 변환한 후, drive 연산자를 사용하여 UI 컴포넌트의 속성에 바인딩합니다.error 처리: 에러가 발생하면 showError 메서드를 호출하여 에러 메시지를 표시합니다.Reactive 확장UIViewController에 대한 Reactive 확장을 통해 viewDidLoad 이벤트를 ControlEvent<Void> 형식으로 변환하여 RxSwift의 이벤트 스트림으로 사용할 수 있도록 합니다.Input과 Output을 연관 타입으로 가지며, transform 메서드로 입력을 출력으로 변환합니다. ViewModel에서 BehaviorRelay와 PublishRelay를 사용하여 애플리케이션의 상태를 관리하고, 이벤트 스트림을 처리합니다.ViewController에서는 RxSwift의 바인딩과 Driver를 활용하여 ViewModel의 상태 변화에 반응하고 UI를 업데이트합니다.PublishRelay를 통해 이벤트를 ViewModel에 전달합니다.ViewModel은 이러한 이벤트를 처리하고, 상태 변화를 Output을 통해 ViewController에 전달합니다.제가 학습한 내용을 요약하여 정리한 것입니다. 내용에 오류가 있을 수 있으며, 어떠한 피드백도 감사히 받겠습니다.
감사합니다.