RxSwift(6)-RxCocoa(bind, drive, DelegateProxy)

2025. 5. 6. 20:09·반응형프로그래밍

RxCocoa?

iOS, macOS 등 모두에 적용이 가능한 프레임워크로, Cocoa 프레임워크 위에서 관련 기능들을 모두 구현할 수 있습니다.

즉 RxSwift = 반응형 프로그래밍의 기본 개념과 연산자 제공?

RxCocoa = 이를 UIkit 컴포넌트들과 자연스럽게 통합할 수 있게 해줍니다.

RxCocoa는 UIKit 컴포넌트들에 rx 네임스페이스를 제공하여 반응형 확장을 가능하게 합니다. 예를 들어 textField.rx.text나 button.rx.tap과 같은 방식으로 UI 이벤트를 Observable 스트림으로 변환할 수 있습니다.

🔄 반응형 프로그래밍의 기본: subscribe

RxCocoa의 바인딩 개념을 이해하기 전에, 기존 RxSwift의 기본적인 이벤트 구독 방식을 복습해보겠습니다.

observable
    .subscribe(
        onNext: { value in print("값: \(value)") },
        onError: { error in print("에러: \(error)") },
        onCompleted: { print("완료") },
        onDisposed: { print("해제됨") }
    )
    .disposed(by: disposeBag)

 

이 방식의 특징:

  1. 수동 에러 처리: 발생 가능한 모든 에러를 직접 처리해야 함
  2. 스레드 지정 필요: UI 업데이트가 필요한 경우 .observeOn(MainScheduler.instance) 추가 필요
  3. 유연한 이벤트 처리: onNext, onError, onCompleted 등 모든 이벤트 타입 개별 처리 가능

이 방식은 자유도가 높지만, UI 작업에서는 몇 가지 단점이 있습니다:

  • 메인 스레드 전환을 잊기 쉬움 (UI 업데이트 문제 발생)
  • 에러 처리를 누락하면 구독이 종료되어 UI 업데이트가 중단됨
  • 코드가 장황해질 수 있음

이러한 문제를 해결하기 위해 RxCocoa는 두 가지 주요 바인딩 패턴을 제공합니다: bind(to:)와 drive().

🔗 bind(to:): 간결한 단방향 데이터 바인딩

bind(to:) 메서드는 Observable의 값을 Observer(주로 UI 컴포넌트)에 연결하는 간결한 방법을 제공합니다.

public func bind<O: ObserverType>(to observer: O) -> Disposable where O.Element == Element {
    return self.subscribe(observer)
}

이 간단한 구현에서 알 수 있듯이, bind(to:)는 기본적으로 subscribe를 wrapping한 syntactic sugar입니다.

주요특징

 

  • 단방향 데이터 흐름: Observable(Producer) → Observer(Receiver)
  • onNext 이벤트만 처리: 에러나 완료 이벤트는 처리하지 않음
  • 자동 메인 스레드: UI컴포넌트 바인딩 시 자동으로 메인 쓰레드에서 실행

🚗 Driver: UI 작업을 위한 특별한 Observable

public func drive(onNext: ((Element) -> Void)? = nil, onCompleted: (() -> Void)? = nil, onDisposed:(() -> Void)? = nil)
	-> Disposable {
    	MainScheduler.ensureRunningOnMainThread(errorMessage:errorMessage)
        return self.asObservable().subscribe(onNext: onNext, onCompleted: onCompletd, onDisposed: onDisposed)
        }

UI작업에 특화된 Observable의 변형으로 3가지 핵심 특성을 가집니다.

  1. 에러를 방출하지 않음: 에러가 발생하더라도 UI 업데이트 흐름이 중단되지 않음
  2. 항상 메인 스레드에서 실행: UI 업데이트의 스레드 안전성 보장
  3. 리소스 공유: 내부적으로 share(replay: 1, scope: .whileConnected) 적용

바로 이 마지막이 bind와 차이점이다. 

Observable을 Driver로 변환

let searchResults = searchBar.rx.text.orEmpty
    .filter { !$0.isEmpty }
    .flatMapLatest { text in
        return apiService.search(query: text)
            .catchErrorJustReturn([])  // 에러 처리
    }
    .asDriver(onErrorJustReturn: [])  // Driver로 변환

 

  • .asDriver(onErrorJustReturn:) 메서드는 Observable을 Driver로 변환하면서 에러가 발생할 경우 제공된 기본값을 반환하도록 설정합니다.
  • .asDriver(onErrorDriveWith:): 에러 발생 시 대체 Driver를 제공합니다
  • .asDriver(onErrorRecover:): 에러 발생 시 복구 작업을 수행하는 함수를 제공합니다

🔍 명확한 비교: bind(to:) vs drive()

1. 호출 대상의 차이

// bind(to:)는 일반 Observable에서 호출
observable.bind(to: observer)

// drive()는 Driver에서만 호출 가능
driver.drive(observer)

2. 에러 처리 방식

// bind(to:)는 에러를 자동으로 처리하지 않음
// 에러 발생 시 구독이 종료되어 UI 업데이트 중단
observable
    .catchError { _ in .just("에러 발생") } // 수동 에러 처리 필요
    .bind(to: label.rx.text)

// drive()는 Observable → Driver 변환 시 에러 처리 방식 지정
observable
    .asDriver(onErrorJustReturn: "에러 발생") // 선제적 에러 처리
    .drive(label.rx.text)

3. 리소스 공유 측면

// bind(to:)는 기본 공유 기능 없음
let sharedObservable = observable.share(replay: 1)
sharedObservable.bind(to: firstLabel.rx.text)
sharedObservable.bind(to: secondLabel.rx.text)

// drive()는 자동으로 리소스 공유
let driver = observable.asDriver(onErrorJustReturn: "")
driver.drive(firstLabel.rx.text)
driver.drive(secondLabel.rx.text) // 원본 Observable은 한 번만 실행

 

 

투두 앱으로 보는 활용

1. 텍스트 필드에 할일을 입력하고 추가 버튼을 누름. 

2. 테이블 뷰에 할 일 목록이 표시됨

3. 할 일을 체크하거나 삭제할 수 있음.

struct Task {
	let title: String
    var isCompleted: Bool = false
    let createdAt = Date()
}
class TodoViewController: UIViewController {
    let taskTextField = UITextField()
    let addButton = UIButton()
    let tableView = UITableView()
    let completedCountLabel = UILabel()
    
    // 데이터 소스
    let tasks = BehaviorRelay<[Task]>(value: [])
    
    private let disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        setupBindings()
    }
    
    private func setupUI() {
        // UI 컴포넌트 설정 코드 (생략)
    }
    
    private func setupBindings() {
        // 여기에 바인딩 코드 구현
    }
}

입력 바인딩: bind(to:)활용

입력 처리는 주로 UI 이벤트를 데이터 흐름으로 변환하는 과정입니다. 이런 경우 bind(to:)가 적합합니다.

private func setupBindings() {
    // 1. 텍스트 필드 값을 기반으로 버튼 활성화 상태 설정
    taskTextField.rx.text.orEmpty
        .map { !$0.isEmpty }
        .bind(to: addButton.rx.isEnabled)
        .disposed(by: disposeBag)
    
    // 2. 버튼 탭 이벤트 처리 - 할 일 추가
    addButton.rx.tap
        .withLatestFrom(taskTextField.rx.text.orEmpty)
        .filter { !$0.isEmpty }
        .subscribe(onNext: { [weak self] text in
            guard let self = self else { return }
            
            // 현재 할 일 목록에 새 할 일 추가
            var currentTasks = self.tasks.value
            let newTask = Task(title: text)
            currentTasks.append(newTask)
            
            // 업데이트된 목록 설정
            self.tasks.accept(currentTasks)
            
            // 텍스트 필드 비우기
            self.taskTextField.text = ""
        })
        .disposed(by: disposeBag)
}

테이블뷰 바인딩: 테이블 뷰에 할일 목록을 바인딩

// 3. 테이블 뷰에 할 일 목록 바인딩
tasks
    .bind(to: tableView.rx.items(cellIdentifier: "TaskCell", cellType: TaskCell.self)) { row, task, cell in
        cell.textLabel?.text = task.title
        
        // 완료 상태 표시
        cell.accessoryType = task.isCompleted ? .checkmark : .none
        
        // 셀 탭 이벤트 처리 - 할 일 완료 상태 토글
        cell.selectionStyle = .none
        
        // 셀 탭을 처리하기 위해 rx.tapGesture() 사용 (RxGesture 라이브러리 필요)
        cell.rx.tapGesture()
            .when(.recognized)
            .subscribe(onNext: { [weak self] _ in
                guard let self = self else { return }
                
                // 탭된 할 일의 완료 상태 토글
                var updatedTasks = self.tasks.value
                updatedTasks[row].isCompleted = !updatedTasks[row].isCompleted
                self.tasks.accept(updatedTasks)
            })
            .disposed(by: cell.disposeBag) // 셀에 DisposeBag 속성 필요
    })
    .disposed(by: disposeBag)

테이블 뷰 바인딩은 일반적으로 에러가 발생하지 않는다(behaviorRelay)

완료된 할 일 카운터: Driver

// 4. 완료된 할 일 개수 표시 - Driver 패턴 사용
tasks
    .map { tasks -> String in
        let completedCount = tasks.filter { $0.isCompleted }.count
        let totalCount = tasks.count
        return "완료: \(completedCount) / \(totalCount)"
    }
    .asDriver(onErrorJustReturn: "완료: 0 / 0")
    .drive(completedCountLabel.rx.text)
    .disposed(by: disposeBag)

UI레이블 업데이트가 항상 메인스레드에서 처리되어야함을 명확히 하고 에러 발생시 기본값을 제공하기 위해 Driver사용


DelegateProxy

DelegateProxy는 Apple의 delegate 기반 API와 RxSwift의 Observable 패턴을 연결하는 다리 역할을 하는 메커니즘입니다. 이 패턴을 통해 delegate 메서드 호출을 Observable 이벤트 스트림으로 변환할 수 있습니다

핵심 동작 원리
1. RxCocoa는 각 UIKit 컴포넌트(UITableView)에 맞는 delegateProxy 제공
2. DelegateProxy는 해당 컴포넌트의 델리게이트를 구현하고 메서드 호출을 Observabel로 방출
3. 개발자는 rx 네임스페이스를 통해 이를 구독
class MyViewController: UIViewController, UITableViewDelegate {
    
    @IBOutlet weak var tableView: UITableView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.delegate = self
    }
    
    // Delegate 메서드 구현
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let selectedItem = items[indexPath.row]
        performSegue(withIdentifier: "showDetail", sender: selectedItem)
        tableView.deselectRow(at: indexPath, animated: true)
    }
}

원래는 이렇게 따로 delegate를 설정하고 필수 메서드를 구현햇다. 하지만 

class MyViewController: UIViewController {
    
    @IBOutlet weak var tableView: UITableView!
    private let disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Rx 확장을 통해 셀 선택 이벤트를 Observable로 구독
        tableView.rx.itemSelected
            .subscribe(onNext: { [weak self] indexPath in
                let selectedItem = self?.items[indexPath.row]
                self?.performSegue(withIdentifier: "showDetail", sender: selectedItem)
                self?.tableView.deselectRow(at: indexPath, animated: true)
            })
            .disposed(by: disposeBag)
    }
}

이렇게 작성이 가능하다는 뜻이다.

직접 DelegateProxy 구현하기

예시로 널리널리 퍼져있는 위치관련 작업을 해보겠다. CLLocationManager! 

import CoreLocation
import RxSwift
import RxCocoa

// 1. HasDelegate 프로토콜 구현
extension CLLocationManager: HasDelegate {
    public typealias Delegate = CLLocationManagerDelegate
}

// 2. DelegateProxy 클래스 구현
class RxCLLocationManagerDelegateProxy: DelegateProxy<CLLocationManager, CLLocationManagerDelegate>, DelegateProxyType, CLLocationManagerDelegate {
    
    // 현재 관리 중인 locationManager에 대한 약한 참조
    public weak private(set) var locationManager: CLLocationManager?
    
    // 초기화 메서드 
    public init(locationManager: ParentObject) {
        self.locationManager = locationManager
        super.init(parentObject: locationManager, delegateProxy: RxCLLocationManagerDelegateProxy.self)
    }
    
    // 프록시 등록을 위한 필수 메서드
    static func registerKnownImplementations() {
        self.register { RxCLLocationManagerDelegateProxy(locationManager: $0) }
    }
    
    // 기존 delegate를 저장하기 위한 메서드 (선택사항)
    static func currentDelegate(for object: CLLocationManager) -> CLLocationManagerDelegate? {
        return object.delegate
    }
    
    static func setCurrentDelegate(_ delegate: CLLocationManagerDelegate?, to object: CLLocationManager) {
        object.delegate = delegate
    }
}

HasDelegate 프로토콜

public protocol HasDelegate {
    associatedtype Delegate
}

DeleagteProxy 패턴의 기본 구성 요소이다. 이 프로토콜을 구현함으로써 특정클래스가 delegate 패턴을 사용함을 RxCocoa에 알려주는 역할입니다.

 구냥 단순 delegate타입을 연결하는 역할이라고 보면 될거 같다. 각 UIKit 컴포넌트는 자신의 delegate타입을 명시해야한다.

자 1번을 보자. CLLocationManager와 CLLocationManagerDeleagte 사이의 관계를 RxCocoa에 명시적으로 알려줍니다. 이 연결이 있어야 RxCocoa가 올바른 타입의 프록시를 생성할수 있습니다. 

‼️이 확장이 없다면 RxCocoa는 CLLocationManager의 deleagte 타입이 무엇인지 알수 없으며 따라서 delegate method호출을 Observable로 변환할 수 없다.

DelegateProxyType 프로토콜

public protocol DelegateProxyType: AnyObject {
    associatedtype ParentObject: AnyObject
    associatedtype Delegate
    
    static func registerKnownImplementations()
    static func currentDelegate(for object: ParentObject) -> Delegate?
    static func setCurrentDelegate(_ delegate: Delegate?, to object: ParentObject)
    
    // 기타 메서드...
}

delegateproxy생성과 관리를 담당하는 프로토콜입니다.

여기서 ParentObject는 delgate를 가지는 원본 객체의 타입을 나타냅니다. 예를 들어, CLLocationManager를 확장할 때 ParentObject는 CLLocationManger 타입이 됩니다.

ParentObject역할: delegate 패턴을 사용하는 원본객체의 타입을 나타내고, DelegateProxy가 어떤 객체의 delegate를 관리할지 지정할지 대상을 지정하고 원본 객체의 생명주기와 DelegateProxy 생명주기를 연결합니다.

‼️ DelegaateProxy 클래스의 생성자에서 ParentObject타입의 매개변수를 받는 이유가 이것이다. 여기서 locationmanager는 ParentObject타입, 즉 CLLocationmanager타입의 인스턴스입니다. 이 인스턴스는 proxy가 관리할 원본 객체인것.

DelegateProxy class

실제 프록시 동작을 구현하는 추상기본 클래스. 이 클래스는 delegate호출을 감지하고 Observable 이벤트로 변환한느 핵심 로직을 포함.

open class DelegateProxy<P: AnyObject, D>: DelegateProxyBase, DelegateProxyType {
    public typealias ParentObject = P
    public typealias Delegate = D
    
    // 구현 내용...
}

 

delegateProxy 객체는 수신된 모든 데이터를 전용 Observable로 표시할 가짜 Delegate개체라고 봐도 된다.

자 이제 좀더 내부 구현을 분석해보자

1. 원본 객체 참조 

public weak private(set) var locationManager: CLLocationManager?

외부에서 읽기만 가능하고 쓰기 불가능하게 하였다. = 필요할 때 원본 객체에 접근하기 위해.

2. 생성자 init

ParentObject 타입(CLLocationmanager)의 인스턴스를 받고 로컬 참조를 설정합니다. 그 후 기본 클래스 초기화를 수행합니다. 

3. proxy 등록

RxCocoa가 CLLocationManger인스턴스에 맞는 프록시를 설정 할 수 있게 합니다. 

$0은 등록된 팩터리함수에 전달되는 CLLocationManger인스턴스입니다. 이 메서드가 없으면 RxCocoa는 프록시를 어떻게 생성해야할 지 모릅니다. 

4. 기존 delegate 호환성 유지

5. Reactive 확장 구현

extension Reactive where Base: CLLocationManager {
    // 1. delegate 프록시 접근자
    public var delegate: DelegateProxy<CLLocationManager, CLLocationManagerDelegate> {
        return RxCLLocationManagerDelegateProxy.proxy(for: base)
    }
    
    // 2. 위치 업데이트 Observable
    var didUpdateLocations: Observable<[CLLocation]> {
        return delegate.methodInvoked(#selector(CLLocationManagerDelegate.locationManager(_:didUpdateLocations:)))
            .map { parameters in
                return parameters[1] as! [CLLocation]
            }
    }
}

1. delegate 프록시 접근자 

  • 현재 CLLocationmanager인스턴스에 대한 프록시를 가져옵니다. 
  • base: Reactive 확장의 Base타입 인스턴스입니다. 
  • proxy(for:) 지정된 객체에 대한 프록시를 가져오거나 생성합니다.

2. 위치업데이트 Observable

  • methodInvoked: delegate 메서드 호출을 감지하는 핵심 메서드입니다.
  • #selector: 감지할 Objective-C 메서드 선택자를 지정합니다. 
  • map: 파라미터 매개변수를 실제 데이터 타입으로 변환

DelegateProxy 내부 동작 원리 심층 분석

1. 프록시 등록 및 생성 과정

CLLocationManger인스턴스 생성 -> rx.delegate wjqrms -> RxCLLocationManagerDeleagteProxy.proxy(for:)호출

-> 등록된 구현에서 프록시 찾거나 생성 -> CLLocationManager.delegate = 프록시 인스턴스

2. 메서드 호출 감지 및 전달 매커니즘

CLLocationManger에서 위치 업뎃 발생 -> CLLocationManagerDelegate.locationManager(_:didUpdateLocations:)호출

-> RxCLLocationManagerDelegateProxy가 호출 감지 -> PublishSubject에 매개변수 배열 전달 -> methodInvoked(#selector())구독자에게 이벤트 전달 -> map연산자로 [CLLocation]추출 -> 최종 Observable 구독자에게 데이터 전달

 

참고자료

https://github.com/fimuxd/RxSwift/blob/master/Lectures/13_Intermediate%20RxCocoa/Ch13.Intermediate%20RxCocoa.md 

 

RxSwift/Lectures/13_Intermediate RxCocoa/Ch13.Intermediate RxCocoa.md at master · fimuxd/RxSwift

RxSwift를 스터디하는 공간. Contribute to fimuxd/RxSwift development by creating an account on GitHub.

github.com

 

 

'반응형프로그래밍' 카테고리의 다른 글

RxSwift(8)-에러처리  (0) 2025.05.08
RxSwift(7)-BehaviorRelay  (0) 2025.05.06
RxSwift(5)-TimeBasedOperators  (0) 2025.05.04
RxSwift(4)-Combining Operators  (0) 2025.05.02
RxSwift(3)-Filtering Operators & TransForming Operators  (0) 2025.04.29
'반응형프로그래밍' 카테고리의 다른 글
  • RxSwift(8)-에러처리
  • RxSwift(7)-BehaviorRelay
  • RxSwift(5)-TimeBasedOperators
  • RxSwift(4)-Combining Operators
2료일
2료일
좌충우돌 모든것을 다 정리하려고 노력하는 J가 되려고 하는 세미개발자의 블로그입니다. 편하게 보고 가세요
  • 2료일
    GPT에게서 살아남기
    2료일
  • 전체
    오늘
    어제
    • 분류 전체보기 (120)
      • SWIFT개발 (29)
      • 알고리즘 (25)
      • Design (6)
      • ARkit (1)
      • 면접준비 (30)
      • UIkit (2)
      • Vapor-Server with swift (3)
      • 디자인패턴 (5)
      • 반응형프로그래밍 (12)
      • CS (3)
      • 도서관 (1)
  • 인기 글

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
2료일
RxSwift(6)-RxCocoa(bind, drive, DelegateProxy)
상단으로

티스토리툴바