본문 바로가기
Swift/오류 개발자

RxSwift | 버튼의 동작이 스스로 실행되는 오류 해결하기

by UDDT 2025. 5. 18.

RxCocoa로 buttonTap 이벤트 적용하면 좋은 점

     결론부터 말하자면,

    RxCocoa로 buttonTap 이벤트를 적용할 때는 addTarget이나 @objc를 써줄 필요가 없다.

    

     Rx를 배우기 전 "버튼 Tap 이벤트를 만들어보세요~" 하면

    보통 이렇게 작성해왔다.

import UIKit
import SnapKit

final class MainViewController: BaseViewController {
    private let nextButton = UIButton()

    override func configureUI() {
        super.configureUI()

        [nextButton].forEach {
            view.addSubview($0)
        }

        nextButton.setTitle("다음", for: .normal)
        nextButton.backgroundColor = .blue
        nextButton.addTarget(self, action: #selector(toNext), for: .touchUpInside)
    }

    override func setConstraints() {
        super.setConstraints()
        nextButton.snp.makeConstraints { make in
            make.center.equalToSuperview()
        }
    }
    
    @objc
    private func toNext() {
        let next = UserViewController()
        navigationController?.pushViewController(next, animated: true)
    }
    
}

 

     위 코드처럼 button에 addTarget을 해주고, 

    버튼이 눌렸을 때 action을 objc 메서드로 만들어서 설정해줬다. 

 

    전에도 말했지만 똑똑한 사람들이 만든 것이 Rx..

   버튼 터치 이벤트를 RxCocoa를 사용하여 반응형으로 설계하면,

   앞서 작성한 addTarget이나 @objc 메서드는 필요가 없다.

 

    버튼의 동작(상태)가 바뀌었는지 관찰하고,

   동작이 바뀔 때마다 메서드를 실행해주는 방향으로 설계하기 때문이다.

   (RxCocoa를 사용하면 버튼의 터치 이벤트를 스트림으로 관찰하고, 이벤트가 발생할 때마다 지정한 클로저나 바인딩된 동작이 실행된다.
    즉, 버튼의 동작을 직접 감지하지 않고도, 이벤트가 발생했을 때 반응하는 방식으로 설계할 수 있다) << 이거는 못본거로 치자.

button.rx.tap 

import UIKit
import SnapKit
import RxSwift
import RxCocoa

final class MainViewController: BaseViewController {
    private let nextButton = UIButton()
    
    private var disposeBag = DisposeBag()
    
    override func configureUI() {
        super.configureUI()
        
        [nextButton].forEach {
            view.addSubview($0)
        }
        
        nextButton.setTitle("다음", for: .normal)
        nextButton.backgroundColor = .blue
    }
    
    override func setConstraints() {
        super.setConstraints()
        nextButton.snp.makeConstraints { make in
            make.center.equalToSuperview()
        }
    }
    
    override func bind() {
        super.bind()
        
        nextButton.rx.tap
            .asDriver(onErrorDriveWith: .empty())
            .drive { [weak self] _ in
                self?.toNext()
            }
    }
    
    private func toNext() {
        let next = UserViewController()
        navigationController?.pushViewController(next, animated: true)
    }
}

 

  다음과 같이 rxCocoa를 사용하여 코드를 작성하고, 자신있게 빌드를 했다.

 역시 오류 개발자답게 독특한 현상을 발견할 수 있었다.

트러블 슈팅: 이미 다음 페이지로 넘어가버렸는데요?

 

    바로 실행과 동시에 다음 페이지가 '띵' 하고 나온 것이다.

  고객님 노란색 에러는 덤이에요!

Snapshotting a view (0x101d0f030, _UIButtonBarStackView) that has not been rendered at least once requires afterScreenUpdates:YES.

 

   위 에러는 "나 버튼 못그렸어~"정도로 이해해보자

  그래.. 다음 페이지로 넘어갔으니까 버튼을 못그렸겠지....

 

   물론,

  이렇게 된 이유는 10초정도 후 찾을 수 있었다.

nextButton.rx.tap
    .asDriver(onErrorDriveWith: .empty())
    .drive { [weak self] _ in
        self?.toNext()
    }
//  .disposed(by: disposeBag)     << 얘가 빠짐

   

    바로 이 부분이 문제다.

   위 코드는 disposed(by: disposeBag)이 누락되어 있다.

  근데 disposed가 누락된 것과 toNext가 바로 실행된 것과 어떤 연관관계가 있을까? 

1. disposeBag의 역할

    rx의 데이터는 stream이라고 했고, 구독은 이 stream을 관찰하는 것이다.

  disposeBag은 이 구독(쉽게 생각하면 구독 정보)을 메모리에서 유지하고 적절한 시점에 정리하는 역할을 한다.

 

   따라서 구독한 정보들을 disposeBag에 넣어두지 않으면,

  구독이 바로 해제될 수 있고, 이 때문에 의도와는 다르게 작동할 수 있다.

  disposeBag은 메모리 해제 시 자동으로 정리되므로,

 일반적인 구독은 다 disposeBag에 넣어서 관리해야한다.

2. drive의 작동 방식

    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: onCompleted, onDisposed: onDisposed)
    }

 

   위의 공식문서를 보면 drive는 Disposable한 객체를 return하는데,

 return할 때 subscribe(onNext: )를 하는 것을 볼 수 있다.

 이때 바로 구독이 시작되는 것이다.

의문점

    비적절한 시점에 구독이 실행되거나 해제되는 것도 이해했고, 바로 구독이 시작된다는 것도 이해했다.

  disposeBag으로 관리를 해주지 않을 때 메모리에 버튼이 눌린 상태로 올라가 있다는 것도 알겠다.

  그런데 이건 내가 처음에 버튼을 눌렀을 때 가능한 얘기다.

 

  그런데 왜 버튼을 누르지 않았는데도 실행이 된걸까?

  button의 tap을 구독하고 있는건데?

 

 가설1. 조상님이 눌렀다

    너무 진지해질까봐 헛소리 하나를 넣었다..

 

가설2. 버튼이 생성되는 것 자체를 상태 변화로 감지했다?

    내가 생각해본 가설은 다음과 같다.

   1. 버튼이 만들어지기 전에 구독 시작

   2. 버튼이 만들어지는 것 자체를 상태 변화로 감지했고(사실 tap 이벤트를 구독하는 것이라 말도 안되긴 하지만)

   3. 이에 따라 메서드를 실행했다?

 

    이를 테스트해보기 위해 viewDidAppear 시점에 nextButton.rx.tap을 추가해주었다.

final class MainViewController: BaseViewController {
    private let nextButton = UIButton()

    private var disposeBag = DisposeBag()

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        nextButton.rx.tap
            .asDriver(onErrorDriveWith: .empty())
            .drive { [weak self] _ in
                self?.toNext()
            }
    }

    override func configureUI() {
        super.configureUI()

        [nextButton].forEach {
            view.addSubview($0)
        }

        nextButton.setTitle("다음", for: .normal)
        nextButton.backgroundColor = .blue
    }


    override func setConstraints() {
        super.setConstraints()
        nextButton.snp.makeConstraints { make in
            make.center.equalToSuperview()
        }
    }

    private func toNext() {
        let next = UserViewController()
        navigationController?.pushViewController(next, animated: true)
    }
}

 

     이렇게 하고 빌드를 해보니,

 

 

     버튼이 생성되고 난 후에 다음 화면으로 넘어가는 것을 확인할 수 있었다.

    뭔가 아리까리 하다.

    (서치해보니, UI버튼을 초기화할 때 tap 이벤트가 전송되는 경우도 간혹 있다고는 하는데 그냥 틀린거로 치자....)

 

흥미로운 발견

   이것 저것 테스트해보다가 흥미로운 발견을 하게 되었다.

import UIKit
import SnapKit
import RxSwift
import RxCocoa

final class MainViewController: BaseViewController {
    private let nextButton = UIButton()

    private var disposeBag = DisposeBag()

    override func configureUI() {
        super.configureUI()

        [nextButton].forEach {
            view.addSubview($0)
        }

        nextButton.setTitle("다음", for: .normal)
        nextButton.backgroundColor = .blue
    }

    override func setConstraints() {
        super.setConstraints()
        nextButton.snp.makeConstraints { make in
            make.center.equalToSuperview()
        }
    }

    override func bind() {
        super.bind()

        nextButton.rx.tap
            .asDriver(onErrorDriveWith: .empty())
            .drive(onNext: { [weak self] _ in
                self?.toNext()
            })
    }

    private func toNext() {
        let next = UserViewController()
        navigationController?.pushViewController(next, animated: true)
    }
}

 

    위 코드로 작성하면 disposeBag을 쓰지 않았는데도 다음 페이지로 넘어가지 않게 된다.

  근데 기존의 문제가 생기던 코드와 똑같이 생겼는데 뭐가 달라진거지? 할 수 있을텐데

   

  달라진 부분은 이 부분 밖에 없다.

        // 1. 기존 코드
        nextButton.rx.tap
            .asDriver(onErrorDriveWith: .empty())
            .drive { [weak self] _ in
                self?.toNext()
            }

        // 2. 변경한 코드
        nextButton.rx.tap
            .asDriver(onErrorDriveWith: .empty())
            .drive(onNext: { [weak self] _ in
                self?.toNext()
            })

 

   근데 '달라졌다'라는 말이 무색할 정도로

  두 코드는 같은 코드이다.

 

   1번 코드는 onNext를 생략한 묵시적 코드이고,

   2번 코드는 onNext를 명시해준 코드이다.

 

   여기서 어떤 차이가 생긴걸까?

 

   1번 코드를 실행할 때,

   브레이크 포인트를 찍고 디버깅을 해보면

    public func drive<Result>(_ transformation: (Observable<Element>) -> Result) -> Result {
        MainScheduler.ensureRunningOnMainThread(errorMessage: errorMessage)
        return transformation(self.asObservable())
    }

 

     이 코드로 넘어간다.

    driver를 Observable로 변환하고, MainThread에서 동작하는 것을 보장해준 다음,

    transformation(self.asObservable())을 리턴한다.

 

    여기서 중요한 부분이 있는데,

   만약 클로저 내부에서 어떤 바인딩 객체도 리턴하지 않고 바로 실행코드가 있다면

    

    drive 뒤에 있는 클로저인

            { [weak self] _ in
                self?.toNext()
            }

 

    이 부분을 실행해버린다.

 

    반면에 2번 코드의 경우에는, 

public func drive(
    onNext: ((Element) -> Void)? = nil,
    onCompleted: (() -> Void)? = nil,
    onDisposed: (() -> Void)? = nil
) -> Disposable

 

   Disposable한 객체를 리턴하기 때문에,

  해당 객체의 onNext가 있을 때만 클로저가 실행되는 것이다.

 

   이는 디버깅 과정에서도 명확한데,

   2번 코드는

 

    public func drive<Result>(_ transformation: (Observable<Element>) -> Result) -> Result {
        MainScheduler.ensureRunningOnMainThread(errorMessage: errorMessage)
        return transformation(self.asObservable())
    }

     

     이 부분으로 넘어가지 않고 바로 BasceVC의 bind()로 넘어간다.

 

    위 부분은 조금 더 공부해봐야할 거 같고.....

 

 송스승과의 스터디

    https://subkyu-ios.tistory.com/

 

subkyu-ios 님의 블로그

subkyu-ios 님의 블로그 입니다.

subkyu-ios.tistory.com

     (이거슨 송스승의 블로그)

    내가 좋아하는 송스승이 주말임에도 불구하고 zep에 들어와서 같이 공부를 해줬다.

   송스승과 대화를 하던 중, 더 디테일한 이유를 찾게 되었는데

 

      이 함수는 Observable<Void>를 파라미터로 받아서 Result를 리턴하는데,

    현재 drive<Result>가 Void이므로 Void를 리턴하게 되었고,

    클로저 안에 subscribe 같은 구독 로직(Void말고 다른거)이 없어서, self?.toNext()를 실행하게 된 것이다.

 

    여기서 한가지 더 재밌는 현상을 발견할 수 있었는데,

  위의 함수에 .disposed(by: disposeBag)을 넣어주면

  Swift가 함수를 다르게 추론한다는 점..!

 

 송스승.. 고맙읍니다...

 

 튜터님의 명해답!

     아침이 되어 튜터님께 질문을 했더니 튜터님께서 코드를 보고 말씀해주셨다.

   "클로저를 실행한거네~"

    public func drive<Result>(_ transformation: (Observable<Element>) -> Result) -> Result {
        MainScheduler.ensureRunningOnMainThread(errorMessage: errorMessage)
        return transformation(self.asObservable())
    }

 

     이 코드를 다시 보면, 파라미터로 받은 transformation이 클로저 타입이다

   그리고 return하면서 파라미터를 호출하여 실행하고 있는데 이때 클로저가 실행되므로

        nextButton.rx.tap
            .asDriver(onErrorDriveWith: .empty())
            .drive { [weak self] _ in       
                self?.toNext()       //여기에 있는 클로저 실행
            }

 

    버튼 입력 없이도 다음 화면으로 넘어가게 된 것이다...

 

 결론

   그냥 까먹지말고 disposeBag에 담아서 잘 관리하자.....

import UIKit
import SnapKit
import RxSwift
import RxCocoa

final class MainViewController: BaseViewController {
    private let nextButton = UIButton()

    private var disposeBag = DisposeBag()

    override func configureUI() {
        super.configureUI()

        [nextButton].forEach {
            view.addSubview($0)
        }

        nextButton.setTitle("다음", for: .normal)
        nextButton.backgroundColor = .blue
    }


    override func setConstraints() {
        super.setConstraints()
        nextButton.snp.makeConstraints { make in
            make.center.equalToSuperview()
        }
    }

    override func bind() {
        super.bind()

        nextButton.rx.tap
            .asDriver(onErrorDriveWith: .empty())
            .drive { [weak self] _ in
                self?.toNext()
            }
            .disposed(by: disposeBag)
    }

    private func toNext() {
        let next = UserViewController()
        navigationController?.pushViewController(next, animated: true)
    }
}

 

최근댓글

최근글

skin by © 2024 ttuttak