Combine - DispatchQueue.main VS RunLoop.main

Coden·2022년 11월 10일
5

Combine

목록 보기
1/2
post-thumbnail

공부하면서 정리한 내용이기에 잘못된 내용이 있을 수 있습니다. 댓글 피드백 환영합니다!

RunLoop에 대해 생소하시다면 개발자 소들이님의 'iOS) 런 루프(RunLoop) 이해하기' 글을 먼저 읽어보시길 강력 추천드립니다.

DispatchQueue.main 그리고 RunLoop.main

Apple에서 제공하는 Combine 프레임워크를 사용하다보면 다음과 같은 상황을 마주할 때가 있다.

func receiveOnDispatchQueue() {
    somePublisher
        .receive(on: DispatchQueue.main)
        .sink { _ in
            // ...
        }
        .store(in: &cancellables)
}

func receiveOnRunLoop() {
    somePublisher
        .receive(on: RunLoop.main)
        .sink { _ in
            // ...
        }
        .store(in: &cancellables)
}

sink 내부에는 보통 메인쓰레드에서 동작해야 하는 작업들, 이를테면 UI 업데이트와 같은 로직들을 넣게 되는데, 이 때 중요한건 Scheduler를 지정하는 부분이다.

Scheduler로 DispatchQueue.main을 썼을 때와 RunLoop.main을 썼을 때, 차이가 존재할까? 그냥 둘 중 아무거나 사용해도 상관없는걸까?


비교 테스트

아래의 뷰 및 코드로 두 Scheduler에 차이가 존재하는지 테스트를 진행해보았다.

화면 구성

Label부분에는 숫자 0을 설정하고 버튼을 누르면 1초마다 값이 1씩 올라가도록 만들었다.

DispatchQueue.main 코드

  1. countStore에서 화면에 보여줄 숫자에 대한 데이터를 가지고 있도록 함
  2. 버튼을 누르면 타이머가 동작하고 1초마다 countStore에 1 더한 값을 보냄
  3. countStore에서 값이 방출되면 해당 값을 countLabel에 적용함
//
//  ViewController.swift
//  DispatchQueueRunLoop
//
//  Created by JINHONG AN on 2022/11/01.
//

import UIKit
import Combine

final class ViewController: UIViewController {
    @IBOutlet private weak var countLabel: UILabel!
    @IBOutlet private weak var button: UIButton!
    
    private var countStore = CurrentValueSubject<Int, Never>(0)
    private var cancellables: Set<AnyCancellable> = []

    override func viewDidLoad() {
        super.viewDidLoad()
        
        countStore
            .receive(on: DispatchQueue.main)
            .sink { [weak self] in
                self?.countLabel.text = $0.description
            }
            .store(in: &cancellables)
            
        button.addTarget(self, action: #selector(didTapButton), for: .touchUpInside)
    }

    @objc private func didTapButton() {
        Timer.publish(every: 1, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in
                guard let self = self else { return }
                
                let nextCount = self.countSubject.value + 1
                self.countSubject.send(nextCount)
            }
            .store(in: &cancellables)
    }
}

RunLoop.main 코드

(위의 코드와 Scheduler부분만 다름)

//
//  ViewController.swift
//  DispatchQueueRunLoop
//
//  Created by JINHONG AN on 2022/11/01.
//

import UIKit
import Combine

final class ViewController: UIViewController {
    @IBOutlet private weak var countLabel: UILabel!
    @IBOutlet private weak var button: UIButton!
    
    private var countStore = CurrentValueSubject<Int, Never>(0)
    private var cancellables: Set<AnyCancellable> = []

    override func viewDidLoad() {
        super.viewDidLoad()
        
        countStore
            .receive(on: RunLoop.main) // 위의 코드와 이 부분만 다름
            .sink { [weak self] in
                self?.countLabel.text = $0.description
            }
            .store(in: &cancellables)
            
        button.addTarget(self, action: #selector(didTapButton), for: .touchUpInside)
    }

    @objc private func didTapButton() {
        Timer.publish(every: 1, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in
                guard let self = self else { return }
                
                let nextCount = self.countSubject.value + 1
                self.countSubject.send(nextCount)
            }
            .store(in: &cancellables)
    }
}

결과 비교

DispatchQueue.mainRunLoop.main

둘의 차이는 없어보인다..! 그리고 둘의 차이는 실제로도 거의 없다.


거의 없다고 한 것에 주목해야 한다. 둘은 비슷하지만 분명히 차이가 존재한다.
어떤 차이가 존재하는 것일까???


또다른 테스트

이번에는 스크롤 뷰를 넣고 테스트를 해보았다.
결과는 어떨까? 이전과 같을까?


화면구성

기존과 비슷하지만 Label과 Button이 스크롤 뷰 내부에 들어가도록 만들었다.

DispatchQueue.main, RunLoop.main 코드

코드는 이전과 동일하게 구성하였다.


결과 비교

버튼을 누르고 카운트가 올라갈 때 스크롤 제스쳐(위아래 드래그)를 줘보았다.

DispatchQueue.mainRunLoop.main

차이가 보이는가?!?
RunLoop.main으로 Scheduler를 지정해준 오른쪽은 드래그를 하는 동안 숫자 업데이트가 이루어지지 않았다.
그리고 탭을 놓는 순간 숫자가 한번에 확 바뀌었다. 왜 이렇게 된걸까?


이러한 차이가 발생한 이유

우선은 RunLoop에 대한 이해가 필요하다.

아마도 위와 같은 그림을 많이 보았을 것이다.(출처: Threading Programming Guide - Apple)
간단하게 한줄로 요약하자면 RunLoop는 이벤트 처리 루프로 키보드, 마우스같은 input source 및 Timer 등의 이벤트들을 처리한다.

이 RunLoop에는 사실 5가지 모드가 있다. 애플 공식문서에는 아래와 같이 소개하고 있다.

  1. common - 하나 이상의 run loop 모드를 포함하는 가짜-모드(슈도 모드)
  2. default - 연결 object이외의 input source를 처리하는 모드
  3. event tracking - 마우스 드래깅 loop같이 이벤트를 모달로 추적할 때 설정되는 모드
  4. modalPanel - 저장 또는 열기 패널과 같은 모달 패널에서 입력을 기다릴 때 설정되는 모드
  5. tracking - 컨트롤을 추적하는 동안 설정되는 모드

위의 모드들 중 event trackingmodalPanel은 macOS에만 존재하는 모드이다. 또한 macOS에는 tracking모드가 없다.
즉, iOS에는 common, default, tracking의 세가지 모드만 존재한다.


RunLoop.Mode 테스트

그러면 어떠한 경우에 defaulttracking모드 설정이 되는 걸까?
(common은 여러 모드를 포함하는 슈도-개념이므로 위의 두 모드에 대해서만 테스트 해보았다.)


테스트 뷰 구성

스크롤 뷰 내에 어떤 RunLoop 상태인지를 출력할 레이블 하나만 두었다.


테스트 코드 구성

//
//  ViewController.swift
//  DispatchQueueRunLoop
//
//  Created by JINHONG AN on 2022/11/01.
//

import UIKit
import Combine

final class ViewController: UIViewController {
    @IBOutlet private weak var runloopModeLabel: UILabel!
    private var cancellables: Set<AnyCancellable> = []

    override func viewDidLoad() {
        super.viewDidLoad()
        
        Timer.publish(every: 1, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in
                self?.runloopModeLabel.text = RunLoop.current.currentMode?.rawValue
            }
            .store(in: &cancellables)
    }
}

1초마다 레이블에 현재 메인쓰레드의 메인런루프가 어떤 모드인지 출력되도록 만들었다.


결과

스크롤이 안이루어지고 있을 때 - kCFRunLoopDefaultMode

  • 쓰레드가 기본 상태 또는 유휴 상태이고 이벤트를 대기 중일 때 사용되어야 하는 Run Loop mode
  • RunLoop.Modedefault와 동일

스크롤이 이루어지고 있을 때 - UITrackingRunLoopMode

  • 컨트롤을 추적하는 동안 설정되는 모드
  • RunLoop.Modetracking과 동일

RunLoop.mainRunLoop.Mode

'RunLoop.main Scheduler와 RunLoop.Mode가 무슨 상관이냐!' 하고 생각할 수 있다. 하지만 정말 큰 상관이 있다!!

RunLoop.main Scheduler는 메인 런루프가 default모드일 때만 sink 클로져를 수행하기 때문이다.

즉, RunLoop.main으로 Scheduler를 설정하면 > 사용자가 화면에 이벤트를 발생시키고 있을 때(tracking 모드 일 때)에는 sink 클로저가 수행되지 않다가 > 사용자가 이벤트 발생을 중단하면(default 모드로 되돌아 왔을 때) sink 클로저가 수행된다.


그럼 DispatchQueue.main은?

DispatchQueue.main Scheduler는 Runloop 모드와는 상관없이 항상 동작한다.

DispatchQueue.main Scheduler는 내부적으로 작업을 GCD로 처리하기 때문이다.


내부 구현이 어떻길래?

Schedulers+RunLoop.swiftSchedulers+DispatchQueue.swift
  • RunLoop.main Scheduler는 내부적으로 RunLoop.perform 메소드를 사용하여 작업을 처리한다.
    • 여기에 쓰인 RunLoop.performRunLoop.Modedefault일 때만 동작하기 때문에 위와 같은 결과들이 나왔던 것이다.(stackoverflow에 적혀 있는 내용이며, 이 부분에 대해서는 공식자료를 발견하지 못하였습니다. 혹시 아시는 분이 계시다면 댓글 부탁드리겠습니다.)
  • 이와는 다르게 DispatchQueue.main Scheduler는 GCD로 작업을 처리시키는 것을 볼 수 있다.

만약 RunLoop.main 스케쥴러 내부 구현이 RunLoop.perform() 이 아닌 RunLoop.perform(inModes: block:)으로 구현되어있었다면....?


결론

자 그래서 우리는 중요한 사실 한 가지를 학습했다.
Combine Scheduler인 RunLoop.mainDispatchQueue.main은 구분해서 사용해야 한다는 것을

언제 RunLoop.main을 써야 할지는 고민을 좀 더 해봐야 할 것 같다.
'사용자 터치가 일어나는 중에는 화면 업데이트가 이루어지지 않아야 함' ⬅️ 이런 경우가 언제 있을까?



부록

Timer.publish

위의 코드 중에서 뭔가 낯설어보이는게 있지 않은가??

Timer.publish(every: 1, on: .main, in: .common) // 두번째와 세번째 인자 주목

그렇다. 두번째 인자는 RunLoop이며 세번째 인자는 RunLoop.Mode이다.
어떤 RunLoop에서 어떤 Mode일 때 타이머를 동작(run)시킬지 결정하는 부분이다.

당연하겠지만 세번째 인자를 .default로 주게 되면 사용자 터치이벤트가 진행중일 때에는 타이머가 동작하지 않는다. (publishing이 이루어지지 않음)

코드

스크롤 뷰 내에 레이블과 버튼이 있는 상태

//
//  ViewController.swift
//  DispatchQueueRunLoop
//
//  Created by JINHONG AN on 2022/11/01.
//

import UIKit
import Combine

final class ViewController: UIViewController {
    @IBOutlet private weak var countLabel: UILabel!
    @IBOutlet private weak var button: UIButton!
    
    private var countSubject = CurrentValueSubject<Int, Never>(0)
    private var cancellables: Set<AnyCancellable> = []

    override func viewDidLoad() {
        super.viewDidLoad()
        
        countSubject
            .receive(on: DispatchQueue.main) // 숫자가 설정되는 부분은 사용자 터치와는 상관 없도록 함
            .sink { [weak self] in
                self?.countLabel.text = $0.description
                print("방출된 값 받았으며 값 설정 완료 \($0)")
            }
            .store(in: &cancellables)
        
        button.addTarget(self, action: #selector(didTapButton), for: .touchUpInside)
    }

    @objc private func didTapButton() {
        Timer.publish(every: 1, on: .main, in: .default) // 이 부분에 주목하자!
            .autoconnect()
            .sink { [weak self] _ in
                guard let self = self else { return }

                let nextCount = self.countSubject.value + 1
                self.countSubject.send(nextCount)
                print("값 전송 \(nextCount)")
            }
            .store(in: &cancellables)
    }
}

결과

사용자 이벤트가 발생하고 있는 중에는 Timer에 의한 publishing이 이루어지지 않는 것을 확인할 수 있다.

RunLoop.main설정에 의한 작업들은 누적되는가? 스킵되는가?

사용자 터치가 발생하여 값의 설정이 미뤄지고 있다면

  • 미뤄지는 동안 쌓이는 작업들은 누적이 되었다가 한번에 쭉 실행이 되는가
  • 아니면 맨 마지막 작업만 살아남다가 사용자 터치가 끝나면 그 하나만 실행되는가

코드

스크롤 뷰 내에 레이블과 버튼이 있는 상태

//
//  ViewController.swift
//  DispatchQueueRunLoop
//
//  Created by JINHONG AN on 2022/11/01.
//

import UIKit
import Combine

final class ViewController: UIViewController {
    @IBOutlet private weak var countLabel: UILabel!
    @IBOutlet private weak var button: UIButton!
    
    private var countSubject = CurrentValueSubject<Int, Never>(0)
    private var cancellables: Set<AnyCancellable> = []

    override func viewDidLoad() {
        super.viewDidLoad()
        
        countSubject
            .receive(on: RunLoop.main) // 스케쥴러는 RunLoop.main으로 설정
            .sink { [weak self] in
                self?.countLabel.text = $0.description

                // 유저 이벤트에 의해 멈춰있다 다시 실행될 때 맨 마지막 클로저만 실행되는 것인지 
                // 아니면 실행 되어야 할 클로저 블록들이 큐처럼 (이벤트 큐에?)쌓여있다가 한번에 순차적으로 쭈루룩 동작하는 것인지 체크
                print("방출된 값 받았으며 값 설정 완료 \($0)")
            }
            .store(in: &cancellables)
        
        button.addTarget(self, action: #selector(didTapButton), for: .touchUpInside)
    }

    @objc private func didTapButton() {
        Timer.publish(every: 1, on: .main, in: .common) // Timer는 언제나 동작하도록 common 설정
            .autoconnect()
            .sink { [weak self] _ in
                guard let self = self else { return }

                let nextCount = self.countSubject.value + 1
                self.countSubject.send(nextCount)
                print("값 전송 \(nextCount)")
            }
            .store(in: &cancellables)
    }
}

결과

Timer에 의한 값 설정은 문제 없이 항상 이루어지고 있으며 터치가 끝나는 순간 밀려있던 UI작업들이 모두 한번에 실행되는 것을 확인할 수 있다.

추가 확인이 필요한 부분
1. 밀리는 클로저 작업들은 어디에 누적되는 것인지
2. UI를 변경하는 코드가 한번에 여러개 실행되는데 실질적인 렌더링은 몇번 이루어지는지


References

profile
iOS 공부중인 Coden

0개의 댓글