시작하기 앞서, 해당 코드는 번호 찾기 게임: [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)
// ...
}
flatMapLatest
input.gameStart
이벤트 스트림이 방출하는 이벤트에 대해 flatMapLatest
연산자는 self?.getNumber()
함수를 호출합니다. flatMapLatest
는 이전에 방출한 이벤트를 무시하고 가장 최근의 이벤트에 의해 생성된 Observable만을 구독합니다. 즉, 새로운 게임 시작 이벤트가 발생할 때마다 새로운 숫자를 가져오는 함수를 호출합니다.getNumber()
Observable<Int>
타입을 반환합니다. 즉, 이 함수는 비동기적으로 숫자를 생성하고, 그 결과를 Observable 스트림을 통해 제공합니다. subsrcibe
flatMapLatest
에 의해 반환된 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)
// ...
}
flatMapLatest
flatMapLatest
연산자는 입력된 숫자 (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)
).Subscribe
subscribe(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)
}
}
subscribe
input.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
에 전달합니다.제가 학습한 내용을 요약하여 정리한 것입니다. 내용에 오류가 있을 수 있으며, 어떠한 피드백도 감사히 받겠습니다.
감사합니다.