본문 바로가기

Swift/RxSwift

[🌱SeSAC] Observable VS Subject, Drive & Relay

728x90

오늘 글은 .. 기승전결이 있기 때문에 .. 차근차근 .. 따라가보자 .. !

 

 

로그인 화면을 만들어보자.

이렇게 이름을 입력하는 텍스트 필드와, 텍스트 필드에 입력하는 값에 따라서 유효성 검사를 하는 경고 레이블, 그리고 버튼으로 UI를 만들 수 있다. 

이 때의 로직은, 텍스트 필드에 8글자 이상 입력해야 경고 레이블이 사라지고 버튼이 활성화 되는 것이다.

 

 

그렇다면 우리는 아래와 같이 코드를 입력할 수 있다.

nameTextField.rx.text // String?
    .orEmpty // String
    .map { $0.count >= 8 } // Bool 
    .bind(to: stepButton.rx.isEnabled, validationLabel.rx.isHidden)
    .disposed(by: disposeBag)
  • 텍스트 필드의 입력값에 따라서 옵셔널 String 형태를 orEmty를 통해 String으로 반환하고, map 함수를 통해서 8글자 이상을 기준으로 Bool형을 반환하면,
  • 이 때의 Bool값을 가지고 stepButton의 활성화 유무와 validationLabel의 보이고 사라지는 것의 유무를 다룰 수 있다.

 

🤔 왜 subscribe가 아니라 bind로 하지?
subscribe와 bind는 서로 다른 개념이 아니다. subscribe에서 좀 더 특화된 기능을 사용하기 위해 만들어진 것이 bind라고 할 수 있는데 ..

이벤트가 발생하고 next, error, complete로 처리가 될텐데 ..
여기서 생각해볼 것은 버튼을 탭하거나, 텍스트 필드에 값을 입력하는 것 등의 UI 관련된 작업이 error, complete가 발생할까?
.. 그렇지 않다 .. !!
그렇기 때문에 우리는 error 이벤트를 핸들링 할 필요가 없다 .. !!!!

-> 그래서 나온 개념이 bind !!!!!
(next만 다룰 수 있는 subscribe라고 생각할 수 있다.)

 

 

이 때, 만약 버튼의 배경색상도 관리하고 싶다고 한다면? 아래와 같이 코드를 추가할 수 있다.

nameTextField.rx.text
    .orEmpty 
    .map { $0.count >= 8 }
    .withUnretained(self)
    .bind  { vc, value in
        let color: UIColor = value ? .systemPink : .lightGray
        vc.stepButton.backgroundColor = color
    }
    .disposed(by: disposeBag)

 

마찬가지로 텍스트 필드에 입력한 글자 수에 따라서 8글자 이상이면 버튼의 배경색은 .systemPink로 지정하고 그렇지 않으면 .lightGray로 지정한다.

 

 

위의 코드를 한번에 작성하면 아래와 같다.

nameTextField.rx.text 
    .orEmpty // String
    .map { $0.count >= 8 } 
    .bind(to: stepButton.rx.isEnabled, validationLabel.rx.isHidden)
    .disposed(by: disposeBag)
    
    
nameTextField.rx.text
    .orEmpty 
    .map { $0.count >= 8 }
    .withUnretained(self)
    .bind  { vc, value in
        let color: UIColor = value ? .systemPink : .lightGray
        vc.stepButton.backgroundColor = color
    }
    .disposed(by: disposeBag)

그런데 .. 우리는 한가지 생각을 할 수 있다 .. 겹치는 코드를 줄일 수 있지 않을까 .. ?

 

 

왜냐하면.

이므로.

 

 

그러면 아래와 같이 중복되는 코드를 하나의 변수로 관리할 수 있을 것이다.

let validation = nameTextField.rx.text 
    .orEmpty // String
    .map { $0.count >= 8 } 


validation
    .bind(to: stepButton.rx.isEnabled, validationLabel.rx.isHidden)
    .disposed(by: disposeBag)

validation
    .withUnretained(self)
    .bind  { vc, value in
        let color: UIColor = value ? .systemPink : .lightGray
        vc.stepButton.backgroundColor = color
    }
    .disposed(by: disposeBag)

 

그러고 이제 우하하 .. ! 끝 .. ! 

이럴 수 있는ㄷㅔ .. 과연 그럴까 ... ?

 

 

아래의 실험을 해보자.

let testA = stepButton.rx.tap
    .map { "안녕하세요." }

testA
    .bind(to: validationLabel.rx.text)
    .disposed(by: disposeBag)

testA
    .bind(to: nameTextField.rx.text)
    .disposed(by: disposeBag)
     
testA
    .bind(to: stepButton.rx.title())
    .disposed(by: disposeBag)

이렇게 코드를 작성하고 .map 코드에 break point를 걸고 코드를 실행하면 ..

버튼을 한번 누를 때마다 3번 break가 걸리는 것을 확인할 수 있다.

 

 

그 말은?!

앞서 공통적인 코드를 합쳐놓은 것은 단순히 코드를 간소화한 것이지, 불필요하게 리소스가 낭비되고 있다는 것을 알 수 있다.

= 메모리 하나에서 관리하고 있는 것이 아니라 구독한 개수만큼의 메모리에서 관리하고 있는 것이다. 

 

 

그럼 결국 의미 없는 코드 간소화인데 ..

일단 왜 이렇게 동작하는가?! 를 살펴보면 .. !!

 

🔥 이것은 Observable의 특성 중 하나로, stream을 공유하지 않는다.

let sampleInt = Observable<Int>.create { observer in
    observer.onNext(Int.random(in: 1...100))
    return Disposables.create()
}

sampleInt.subscribe { value in
    print("sampleInt: \(value)")
}
.disposed(by: disposeBag)

sampleInt.subscribe { value in
    print("sampleInt: \(value)")
}
.disposed(by: disposeBag)

sampleInt.subscribe { value in
    print("sampleInt: \(value)")
}
.disposed(by: disposeBag)

위의 Observable(Custom Observable)을 실행해보자.

그럼 위와 같은 결과가 출력 되는 것을 볼 수 있다.

 

✅ 이것을 통해서 우리가 알 수 있는 것은 !! observable은 독립적인 stream을 갖고 있다는 것이다.

 

 

어떻게 해결할건데.

자, 그럼 이제 해결을 해보자. (일단 저 위의 실험 코드를 수정해보자.)

어떻게? share을 통해서 !!

 

observable에 share()을 통해서 stream을 공유하도록 지정할 수 있다.

let testA = stepButton.rx.tap
    .map { "안녕하세요." } 
    .share() // -> stream을 공유하게 되므로 1번 호출

testA
    .bind(to: validationLabel.rx.text)
    .disposed(by: disposeBag)

testA
    .bind(to: nameTextField.rx.text)
    .disposed(by: disposeBag)

testA
    .bind(to: stepButton.rx.title())
    .disposed(by: disposeBag)

 

그러면, map에 break point를 걸었을 때,

버튼을 탭하면 break가 한번만 호출되는 것을 확인할 수 있다. 

 

그런데 !! 여기서 !!! 이 stream을 공유할 수 있는 것이 어디에 구현되어 있는가?

🔥🔥🔥 바로 !! Subject에 share가 구현되어 있다. 🔥🔥🔥

 

그 말은 즉, subject는 stream을 공유하고 있다는 것이다.

 

이것 역시 실험으로 확인해보자.

let subjectInt = BehaviorSubject(value: 0)
subjectInt.onNext(Int.random(in: 1...100))
// stream을 공유


subjectInt.subscribe { value in
    print("subjectInt: \(value)")
}
.disposed(by: disposeBag)

subjectInt.subscribe { value in
    print("subjectInt: \(value)")
}
.disposed(by: disposeBag)

subjectInt.subscribe { value in
    print("subjectInt: \(value)")
}
.disposed(by: disposeBag)

BehaviorSubject(Subject의 한 종류)로 subject를 하나 만들고 이를 구독해서 이벤트 결과를 출력해보자.

 

위와 같이 같은 결과값이 출력 되는 것을 볼 수 있다.

 

 

그러면 여기까지의 내용을 정리하면 아래와 같다.

Share ?
- 일반적으로 subscribe를 할 때마다 새로운 시퀀스가 생성 된다.
- 하나의 Observable을 subscribe하는 곳이 여러 군데라면 그만큼 호출 되면서 stream이 생기게 된다. 
- 따라서, 여러 subscribe를 하게 될 경우, 불필요한 리소스가 발생할 수 있기 때문에 내부적으로 모든 subscribe가 하나의 subscribe를 공유할 수 있도록 해야 한다. 

- replay와 scope를 통해 버퍼 사이즈와 유지 상태를 결정할 수 있다. 

<예시> 
- 서버통신을 하는 경우, share()가 구현이 되어 있지 않다면 네트워크 요청이 여러 번 일어나게 된다.
- 따라서, 불필요한 콜이나 리소스 낭비가 생기지 않도록 subscribe를 공유할 수 있게 처리한다. 

 

이제 Bind를 한번 살펴보자.

Bind는 Subscribe의 한 형태로, next 이벤트만 다룬다고 생각할 수 있다.

그리고 Subscribe와 다르게 Main Thread의 작업을 보장한다. 

 

만약 bind를 좀 더 UI에 특화된 형태로 만들고 싶다면?

-> stream을 공유할 수 있게 하면 된다.

-> 이 형태가 바로 !! Drive이다.

 

drive는 bind와 다르게 stream 공유가 가능하다.

drive 코드 내부적으로 share(replay: 1, scope: .whileConnected)가 구현되어 있다.

 

drive를 사용해서 bind 코드를 수정할 수 있다.

        let testA = stepButton.rx.tap
            .map { "안녕하세요." } // -> 3번 호출 (1대1로 대응되므로) / 여기까지는 RxSwift (근데 나는 UI에 특화된 형태로 바꾸고 싶다.)
            .asDriver(onErrorJustReturn: "Error") // Driver로 쓸 수 있도록
//            .share() // -> stream을 공유하게 되므로 1번 호출
        
        testA
//            .bind(to: validationLabel.rx.text)
            .drive(validationLabel.rx.text) // 역할상 subscribe와 같다.
            .disposed(by: disposeBag)
        
        testA
//            .bind(to: nameTextField.rx.text)
            .drive(validationLabel.rx.text)
            .disposed(by: disposeBag)
        
        testA
//            .bind(to: stepButton.rx.title())
            .drive(validationLabel.rx.text)
            .disposed(by: disposeBag)

주석 처리를 통해서 알 수 있는 것처럼 driver의 경우 내부적으로 share 코드가 구현되어 있으므로 따로 share를 명시하지 않아도 된다.

 

 

Relay는 Subject에서 좀 더 UI에 특화된 형태이다.

Relay는 next 이벤트를 accept라는 키워드로 emit할 수 있다. 

 

코드로 나타내면 아래와 같다.

class SubjectViewModel {
    var contactData = [
        Contact(name: "SoKyte", age: 25, number: "01099998888"),
        Contact(name: "HuRee", age: 25, number: "123456789"),
        Contact(name: "Jack", age: 21, number: "876543201")
    ]
    
//    var list = PublishSubject<[Contact]>()
    var list = PublishRelay<[Contact]>()
    
    func fetchData() {
//        list.onNext(contactData)
        list.accept(contactData)
    }
    
    func resetData() {
//        list.onNext([])
        list.accept([])
    }
    
    func newData() {
        let new = Contact(name: "후리방구", age: -4, number: "뿡뿡")
//        list.onNext([new]) // = 을 의미, 기존의 정보는 없어지고 후리방구만 남게 된다.
        list.accept([new])
        contactData.append(new)
    }
    
    func filterData(_ query: String) {
        let filterData = query != "" ? contactData.filter { $0.name.contains(query) } : contactData
//        list.onNext(filterData)
        list.accept(filterData)
    }
}

 

 

Relay는 drive와 짝꿍으로 사용할 수 있는데 .. 위의 로그인 화면을 예시로 생각해보자.

MVVM 구조로 나타냈을 때, 경고 레이블에 들어갈 text를 ViewModel에서 관리할 수 있고, 사용자의 입력 값에 따라서 그 문구를 다르게 표현할 수 있을 것이다.

 

ViewModel.swift

import Foundation

import RxSwift
import RxCocoa

final class ValidationViewModel {
    // validation 문구
    let validText = BehaviorRelay(value: "닉네임은 최소 8자 이상 필요해요") // 초기값이 필요한, UI에 특화된 형태 : Behavior Relay
}

 

그리고 사용자의 인터렉션을 받을 수 있는 Controller를 아래와 같이 작성할 수 있다.

 viewModel.validText
     .asDriver()
     .drive(validationLabel.rx.text)
     .disposed(by: disposeBag)

이렇게 초기값을 validationLabel에 보여줄 수 있고

 

상황에 따라서 viewModel.validText.accep("어쩌구 저쩌구")를 통해서 이벤트를 emit할 수도 있다.

 

그래서 결론적으로 !!

로그인 화면의 bind 로직은 아래와 같다.

 

즉, 내용을 다시 정리해보면 !!

 

Rx의 내용은 아래의 개념으로 나누어진다고 볼 수 있다.

색상별로 짝꿍 !!!