Today I Learn

RxSwift에서 observe(on:)으로도 UI 업데이트가 즉시 반영되지 않는 이유와 해결법

Goniii 2025. 4. 1. 15:40

문제상황

ViewController에서 컬렉션뷰의 내부 inset을 변경하기 위해(UI 업데이트)

observe(on: MainScheduler.instance) 를 사용했지만 바로 업데이트가 되지 않는 문제가 발생헀다

    // 컬렉션뷰의 bounds와 책 개수에 따라 컬렉션뷰의 셀 인셋 설정
    private func bindCollectionViewInsets() {
        Observable.combineLatest(
            books.map { $0.count }, // 현재 책 개수
            homeView.topView.seriesNumberCollectionView.rx.observe(CGRect.self, "bounds") // 뷰 크기 변화 감지 (옵셔널 타입)
        )
        .compactMap { itemCount, bounds -> UIEdgeInsets? in // bounds가 옵셔널 타입이므로 compactMap 사용
            guard let bounds else { return nil }
            
            let flowLayout = self.homeView.topView.seriesNumberCollectionView.collectionViewLayout as? UICollectionViewFlowLayout
            let itemSize = flowLayout?.itemSize.width ?? 0
            let spacing = flowLayout?.minimumInteritemSpacing ?? 0
            let totalItemWidth = CGFloat(itemCount) * itemSize + CGFloat(itemCount - 1) * spacing
            let horizontalInset = max((bounds.width - totalItemWidth) / 2, 0) // 음수 방지
            return UIEdgeInsets(top: 0, left: horizontalInset, bottom: 0, right: horizontalInset)
        }
        .observe(on: MainScheduler.instance)
        .bind { [weak self] insets in
            guard let self,
                  let flowLayout = self.homeView.topView.seriesNumberCollectionView.collectionViewLayout as? UICollectionViewFlowLayout else { return }
            flowLayout.sectionInset = insets
            self.homeView.topView.seriesNumberCollectionView.collectionViewLayout.invalidateLayout() // 레이아웃 갱신
        }
        .disposed(by: disposeBag)
    }

 

 

하지만 수정 전 observe를 사용하지 않고 DispatchQueue를 사용했을 때는 UI 업데이트가 잘 되었기 때문에 두 차이를 알아보고자 한다

DispatchQueue.main.async {
      self.homeView.topView.seriesNumberCollectionView.collectionViewLayout.invalidateLayout() // 레이아웃 갱신
}

 

Dispatch 사용 X

Dispatch 사용

 

 

.observe(on: MainScheduler.instance)는 어떤 역할을 하는가?

  • Rx에서 실행 흐름을 메인 스레드로 전환하는 역할
  • 즉, .bind {} 내부의 코드가 메인 스레드에서 실행되도록 보장한다
  • 하지만, 비동기적인 UI 변경을 즉시 반영하지는 않는다

 

DispatchQueue.main.async는 어떤 역할을 하는가?

  • 비동기적으로 UI 업데이트를 예약하는 역할
  • 즉, 현재 실행 중인 코드가 끝난 후에도 다음 메인 루프에서 UI가 다시 그려지도록 에약한다

 

observe(on: MainScheduler.instance)를 사용했음에도 즉시 UI가 업데이트되지 않는 경우의 이유는 Rx의 데이터 스트림이 이벤트를 전달하는 순서가 UI 업데이트 주기와 완벽하게 맞지 않을 수 있기 때문이다

특히 레이아웃 업데이트 관련 코드(invalidateLayout())는 메인 스레드에서 실행되더라도 즉시 UI 변경이 반영되지 않을 수 있다

따라서 예시 코드에서 DispatchQueue.main.async를 제거했을 때 문제가 되는 이유는 다음과 같다

  1. Rx는 UI 업데이트를 예약하는 기능이 없음
    • .observe(on: MainScheduler.instance)는 메인 스레드에서 실행되는 것만 보장하지, UI 프레임워크가 바로 반응하도록 강제하지 않음.
    • 하지만 DispatchQueue.main.async를 사용하면 UI 업데이트를 보장할 수 있음.
  2. UICollectionViewFlowLayout의 변경 사항이 즉시 반영되지 않을 수 있음
    • flowLayout.sectionInset = insets 설정 후, invalidateLayout()을 호출해야 적용되는데 이 과정에서 UI 업데이트 주기가 맞지 않으면 반영되지 않을 수 있음
    • DispatchQueue.main.async를 사용하면 UI 루프의 다음 사이클에서 레이아웃 업데이트됨을 보장할 수 있음

따라서 DispatchQueue.main.async 를 사용하여 안전하게 UI 변경을 예약해야 레이아웃이 변경된다

        .bind { [weak self] insets in
            guard let self,
                  let flowLayout = self.homeView.topView.seriesNumberCollectionView.collectionViewLayout as? UICollectionViewFlowLayout else { return }
            DispatchQueue.main.async {  // ✅ UI 변경 예약
                flowLayout.sectionInset = insets
                self.homeView.topView.seriesNumberCollectionView.collectionViewLayout.invalidateLayout() // 레이아웃 갱신
            }
        }

 

 

 

그러나 DispatchQueue.main.async 를 사용하지 않고 observe(on: MainScheduler.instance) 만으로도 UI 업데이트가 가능한 메서드가 있다

 

아래와 같이 DispatchQueue.main.async 없이 observe만을 사용하여 View를 업데이트 했는데도 잘 반영된다. 그 이유는 뭘까

    private func setDataSource() {
        Observable.combineLatest(books, selectedIndex)
            .filter {books, _ in books.count > 0 } // 비어있을 때는 제외
            .observe(on: MainScheduler.instance)
            .bind {[weak self] books, selectedIndex in
                self?.homeView.configure(books: books, index: selectedIndex)
            }
            .disposed(by: disposeBag)
    }

 

.observe(on: MainScheduler.instance) 가 UI 업데이트를 보장하는 경우

위 메서드에서는 UI가 정삭적으로 변경된다

그 이유는 Rx의 UI 바인딩 특성 + configure(books:index:) 메서드의 역할 때문이다

configure(books:index:)가 UI 프레임워크의 메인 루프와 충돌이 없는 방식으로 동작하기 때문!

 

이 코드와 invalidateLayout() 코드의 차이점

  • invalidateLayout() 은 UIKit의 레이아웃 처리 시스템을 직접 변경하는 메서드이다
    • 이 작업은 UI 프레임워크의 다음 렌더링 루프에서 반영될 수 있고, 즉시 반영되지 않을 수도 있다
    • 그래서 DispatchQueue.main.async로 UI 업데이트를 강제 예약해서 업데이트해줘야 된다
  • configure(books:index:) 메서드는 뷰의 상태를 변경하는 일반적인 UI 업데이트 작업이다
    • 예를들어, UILabel.text = “새로운 값” 같은 작업은 메인 스레드에서 실행되기만 하면 즉시 UI가 변경된다
    • UITableView.reloadData() 같은 메서드도 메인 스레드에서 실행되면 바로 적용된다

 

즉, 단순한 UI 업데이트 작업은 observe(on: MainScheduler.instance)만으로도 UI가 변경될 수 있지만,

UI 레이아웃 변경과 관련된 작업은 실행 타이밍이 중요해서 DispatchQueue.main.async 가 필요할 수 있다

 

invalidateLayout() 같은 UI 레이아웃 변경 작업의 실행 타이밍이 중요한 이유

UIKit에서는 레이아웃 계산과 렌더링이 일정한 주기에 따라 수행되기 때문에 레이아웃 변경 작업을 실행하는 타이밍이 매우 중요하다. 실행 타이밍을 잘못 맞추면 UI가 즉시 반영되지 않거나, 애니메이션이 부자연스럽게 동작하거나, UI가 깨지는 문제도 발생할 수 있다

 

iOS UI 업데이트 주기 (RunLoop, Layout Cycle)

iOS의 UI 업데이트는 메인 스레드의 RunLoop와 UIKit의 Layout Cycle을 따라 진행된다

RunLoop: iOS의 이벤트 처리 사이클

  • RunLoop는 UIKit의 메인 이벤트 루프로 화면 그리기, 및 이벤트 처리를 주기적으로 수행하는 구조
  • RunLoop가 돌아갈 때, UIKit이 정해진 시점에서만 레이아웃을 갱신 및 그리기 작업을 실행
  • 이 과정에서 layoutSubviews(), updateConstraints(), draw() 같은 작업들이 처리됨

 

iOS의 업데이트 사이클

  1. Event Handling 단계 - 터치, 제스처, 네트워크 응답 등을 처리
  2. Layout Pass 단계 - 뷰들의 위치와 크기를 결정(layoutSubviews())
  3. Display Pass 단계 - 화면을 다시 그림(draw())
  4. Rendering 단계 - GPU가 화면에 그리기 완료

이 중에서 2번 Layout Pass 단계에서 invalidateLayout()이 실행된다

invalidateLayout()은 현재의 레이아웃을 ‘무효화’하고 이후의 레이아웃을 재계산을 요청하는 역할을 한다

collectionView.collectionViewLayout.invalidateLayout()

하지만 UIKit에서 Layout Pass 단계를 끝낸 상태라면 invalidateLayout을 호출해도 곧바로 반영되지 않을 수 있다

→ UIKit의 레이아웃 시스템은 성능 최적화를 위해 “필요할 때만” 다시 계산하도록 설계되어 있다

invalidateLayout()를 호출한다고 해서 자동으로 다음 RunLoop에서 레이아웃이 강제로 재계산되는 것이 아니다

 

그럼 layoutIfNeeded()를 사용해서 강제 업데이트하면 되지 않나?

UIKit은 자동으로 UI 업데이트를 감지하지 못할 수 있으므로, 강제로 다시 그리도록 지시해보자

  • layoutIfNeeded() : 즉시 Layout Pass를 수행하여 레이아웃을 즉각 반영
        .bind { [weak self] insets in
            guard let self,
                  let flowLayout = self.homeView.topView.seriesNumberCollectionView.collectionViewLayout as? UICollectionViewFlowLayout else { return }
            flowLayout.sectionInset = insets
            self.homeView.topView.seriesNumberCollectionView.collectionViewLayout.invalidateLayout() // 레이아웃 갱신
	    self.homeView.layoutIfNeeded()
        }
        .disposed(by: disposeBag)

→ 잘 반영되는 것을 확인 할 수 있다

 

DispatchQueue.main.async를 사용하는 이유

  • DispatchQueue.main.async 는 현재 “RunLoop가 끝난 후, 다음 UI 업데이트 주기에 실행되도록 예약” 하는 역할을 한다
DispatchQueue.main.async {
    flowLayout.sectionInset = insets
    self.collectionView.collectionViewLayout.invalidateLayout()
}
  • invalidateLayout()을 호출하는 시점이 이미 Layout Pass가 끝난 이후일 경우, 즉시 반영되지 않을 수도 있음.
  • DispatchQueue.main.async를 사용하면 다음 RunLoop에서 실행을 예약할 수 있어서 UI 업데이트가 확실하게 반영된다