opendoor_life

'개발자의 성장일기'가 되었으면 좋겠습니다만?

Dev/iOS

RxSwift :: 중복 클릭 방지를 위한 Throttle vs Debounce 차이와 개념, 사용법 알아보기 (iOS 개발)

opendoorlife 2022. 1. 7. 18:52
반응형


보통 앱 화면에서 버튼을 누르면 API 호출이 되는 경우가 잦은데,
종종 다양한 이유로 API 통신이 느려져 유저가 버튼을 연타하는 경우가 생긴다.

마치... 긴박한 티켓팅 같은 상황일 때...
😡 ??? : 아 왜 결제 안돼!!! (결제요청 버튼 타다다다다닥-)    (2) Font.weight 종류


이런 경우 별도의 조치가 없다면, 유저가 버튼을 누른 만큼 API Call이 생길 것이고, 동일한 API가 여러번 호출되면서 예상치 못한 결과를 가져오거나, 서버에 요상한 데이터가 적재될 가능성이 높다.
(특히 결제 요청 버튼의 경우, 한 개의 예약에 대해 결제가 여러 번 되는 최악의 상황을 상상할 수 있다. 벌써 손에서 진땀난다...)

이런 상황을 예방하기 위해!


버튼을 여러번 클릭했을 때
API 중복 호출을 막아주는 별도 조치를 취해줘야하는데

바로! 일정 시간을 조건으로 동작을 필터링 해주는 RxSwift Operators인
Throttle과 Debounce를 통해 예방할 수 있다.


목차

1. Throttle
    (1) 일단 throttle 정의를 살펴보자.
    (2) 파라미터도 살펴보자.
      a. dueTime
      b. scheduler
      c. latest (★★★★★)

2. Debounce

    (1) 일단 debounce 정의를 살펴보자.

 

3. 정리

   (+) 짧게 요약만 보고싶다면 바로 정리 부분으로 가십셔!

 

 

1. Throttle

쓰로틀링의 핵심만 보고 싶다면, lastest 파라미터 설명 부분에 첨부해둔 두 개의 Rx Diagram 이미지를 보면 된다!


(1) 일단 throttle 정의를 살펴보자.

Returns an Observable that emits the first and the latest item emitted by the source Observable during sequential time windows of a specified duration. This operator makes sure that no two elements are emitted in less then dueTime.

지정된 기간의 순차 기간 동안 소스 Observable이 내보낸 첫 번째 항목과 최신 항목을 내보내는 Observable을 반환합니다.
이 연산자는 DueTime보다 짧은 시간에 두 개의 요소가 내보내지지 않도록 합니다.★

오호..!
지정된 기간 안에 수많은 이벤트가 발생해도, 2개 이상 요소가 방출되지 않는다는 것!

 

import RxSwift

extension ObservableType {
    public func throttle(_ dueTime: RxTimeInterval, latest: Bool = true, scheduler: SchedulerType)
        -> Observable<Element> {
        Throttle(source: self.asObservable(), dueTime: dueTime, latest: latest, scheduler: scheduler)
    }
}

extension SharedSequenceConvertibleType {
    public func throttle(_ dueTime: RxTimeInterval, latest: Bool = true)
        -> SharedSequence<SharingStrategy, Element> {
        let source = self.asObservable()
            .throttle(dueTime, latest: latest, scheduler: SharingStrategy.scheduler)

        return SharedSequence(source)
    }
}
 
 

 

(2) 파라미터도 살펴보자.

// 사용예시
throttle(.second(3), lastest: true, scheduler: MainScheduler.instance) // scheduler는 상황에 따라 생략가능

 

 

1. dueTime

- RxTimeInterval 타입이고, 해당 오퍼레이터가 얼마나 지속되어야 하는지에 대한 인자값. 타이머를 생각하면 쉽다!

- Throttling duration for each element

- RxTimeInterval이 DispatchTimeInterval라서, 아래 5가지 타입으로 설정해주면 된다.

// Type that represents time interval in the context of RxSwift.
public typealias RxTimeInterval = DispatchTimeInterval

public enum DispatchTimeInterval : Equatable {
    case seconds(Int)
    case milliseconds(Int)
    case microseconds(Int)
    case nanoseconds(Int)
    case never
}

 

2. scheduler

어떤 스레드에서 실행할 건가요? 라고 묻는 파라미터다. (Scheduler to run the throttle timers on.)

- SchedulerType 타입이고, ObservableType엔 있지만, SharedSequenceConvertibleType엔 파라미터를 따로 받지 않는 것을 볼 수있다.

 

SharedSequenceConvertibleType의 정의를 보면!

SharingStrategyProtocol를 상속 받는 SharingStrategy라는 associatedtype이 명시되어 있는데,

이미 어떤 스케쥴러에서 실행될지 정해진 타입이라고 생각하면 된다.

그렇기 때문에 SharedSequenceConvertibleType인 경우 scheduler 파라미터를 받지않아도 된다.

public protocol SharedSequenceConvertibleType : ObservableConvertibleType {
    associatedtype SharingStrategy: SharingStrategyProtocol
}

public protocol SharingStrategyProtocol {
    // Scheduled on which all sequence events will be delivered.
    static var scheduler: SchedulerType { get }
}

 

쉽게 Observable과 Driver의 차이를 생각하면 쉬운데, Observable은 MainScheduler와 BackgroundScheduler에서 실행될 수 있지만, SharedSequence인 Driver는 MainScheduler에서만 실행된다.

 

 

3. latest ( ★ ★ ★ ★ ★ )

타이머 끝나기 전에, 맨~~~ 마지막에 발생한 이벤트도 보내줘? 말아? 라고 묻는 파라미터다.

- Bool 타입이고, true가 기본값인 것을 알 수 있다.

- true일 때는 dueTime안에서 벌어진 이벤트들 중에 첫 번째와, 마지막 이벤트를 방출하고, false이면 첫 번째 이벤트만 방출한다.

(Should latest element received in a dueTime wide time window since last element emission be emitted)

- 해당 파라미터에 따라 방출 갯수가 달라질 수 있어서 자세히 다뤄보겠다.

- 아래 그림을 보면, 동일한 이벤트들이 발생했지만! 방출 갯수가 다른 것을 알 수 있다.

 

 

(1) lastest = true (기본 값)

- dueTime(=x) 동안 발생한 이벤트들 중, timer의 시작과 끝에서 최초 이벤트와 dueTime내 발생한 마지막 이벤트가 dueTime이 끝나는 시점에 방출된 것을 확인할 수 있다. (총 2개까지 방출 가능)

- 물론 dueTime 내에 1개의 이벤트만 발생했다면, 최초 이벤트 1개만 방출된다.

- 첫 이벤트가 방출되면서 dueTime(타이머)가 시작되고, throttle에서는 이벤트가 방출될 때마다 타이머가 초기화 되지 않는다. 타이머가 시작되면, 끝이 나야함! (debounce와의 차이)

 

 

예시 코드로 살펴보자. (↗︎ GitHub 자세한 구현 코드는 github에 올려두었다.)

button.rx.tap.asDriver()
  .do(onNext: {
    self.touchCount += 1
  })
  .throttle(.seconds(3), latest: true) // asDriver()로 button의 ControlEvent를 Driver로 변환했기 때문에 scheduler 파라미터가 생략된다.
  .drive(onNext: { _ in
    print("***** :: \(self.touchCount) ")
    self.debugLabel.text! += "→ \(self.touchCount) "
  }).disposed(by: disposeBag)

 

위와 같이 코드를 구성하고, 3초 안에 4번을 클릭했을 때 1번째 클릭과 4번째 클릭 이벤트가 방출된 것을 확인할 수 있다!

throttle latest true 예시 rxswift ios 중복 클릭 방지.mp4
0.08MB

↗︎ 시뮬레이터 영상

 

 

하지만... 일정 시간 내에 1번만 API가 호출되었으면 좋겠는걸...? 마지막 이벤트 따위 중요하지 않아...라고 생각하신다면?!

두둔! lastest = false 값을 주면 된다.

 

 

 

 

(2) lastest = false

- dueTime(=x) 동안 발생한 이벤트들 중, 최초 발생한 이벤트만 1개만 방출된다. (원앤온리 1개 방출)

 

 

예시 코드로 살펴보자. (↗︎ GitHub 자세한 구현 코드는 github에 올려두었다.)

button.rx.tap.asDriver()
  .do(onNext: {
    self.touchCount += 1
  })
  .throttle(.seconds(3), latest: false) // asDriver()로 button의 ControlEvent를 Driver로 변환했기 때문에 scheduler 파라미터가 생략된다.
  .drive(onNext: { _ in
    print("***** :: \(self.touchCount) ")
    self.debugLabel.text! += "→ \(self.touchCount) "
  }).disposed(by: disposeBag)
 
 

위와 같이 코드를 구성하고, 3초 안에 4번을 클릭했을 때 1번째 클릭 한 개만 방출된 것을 확인할 수 있다!!!
(클릭하자마자 방출되고 타이머 내에 발생한 다른 이벤트들은 방출하지 않음)

throttle latest false 예시 rxswift ios 중복 클릭 방지.mp4
0.12MB

↗︎ 시뮬레이터 영상

 

 

 

2. Debounce

디바운스의 핵심만 보고 싶다면, 아래 첨부해둔 Rx Diagram 이미지를 보면 된다!


(1) 일단 debounce 정의를 살펴보자.

Ignores elements from an observable sequence which are followed by another element within a specified relative time duration, using the specified scheduler to run throttling timers.

타이머를 실행하기 위해 지정된 스케줄러를 사용하여, 지정된 상대 시간 기간 내에 다른 요소가 뒤따르는 관찰 가능한 시퀀스의 요소를 무시합니다.

 

뭔말이냐면, 이벤트가 새로 발생할 때마다 진행 중이었던 타이머는 무시되고 다시 타이머가 시작된다는 것이다.

즉, 버튼 누를 때마다 타이머가 초기화된다는 것!

 

import RxSwift

extension ObservableType {
    public func debounce(_ dueTime: RxTimeInterval, scheduler: SchedulerType)
        -> Observable<Element> {
            return Debounce(source: self.asObservable(), dueTime: dueTime, scheduler: scheduler)
    }
}

extension SharedSequenceConvertibleType {
    public func debounce(_ dueTime: RxTimeInterval)
        -> SharedSequence<SharingStrategy, Element> {
        let source = self.asObservable()
            .debounce(dueTime, scheduler: SharingStrategy.scheduler)

        return SharedSequence(source)
    }
}
 

(파라미터는 throttle과 동일하기 때문에 윗 설명 참고!)

 

 

 

 

diagram에도 나와있듯, timer로 지정된 X 시간 동안 새로운 이벤트가 발생하면 이전 이벤트는 무시되고, timer는 초기화된다.

throttle과 동일하게 이벤트 시작과 동시에 timer가 작동되지만, timer 내에 다른 이벤트가 없어야 이벤트 발생시점 부터 X시간이 흐른 후 이벤트가 방출될 수 있다. 즉, 유저의 찐 막 이벤트를 파악하기 위한 Operator라고 할 수 있다.

 

예시 코드로 살펴보자. (↗︎ GitHub 자세한 구현 코드는 github에 올려두었다.)

button.rx.tap.asDriver()
  .do(onNext: {
    self.touchCount += 1
  })
  .debounce(.seconds(3)) // asDriver()로 button의 ControlEvent를 Driver로 변환했기 때문에 scheduler 파라미터가 생략된다.
  .drive(onNext: { _ in
    print("***** :: \(self.touchCount) ")
    self.debugLabel.text! += "→ \(self.touchCount) "
  }).disposed(by: disposeBag)
 

위와 같이 코드를 구성하고, 3번을 클릭했을 때 클릭을 멈춘 후 3초 후에 3번째 클릭 이벤트가 방출되는 것을 볼 수 있다!

debounce 디바운스 예시 rxswift ios 중복 클릭 방지.mp4
0.18MB

↗︎ 시뮬레이터 영상

 

 

3. 정리


throttle

.throttle(.second(X), lastest: true, scheduler: MainScheduler.instance) // 예시


- X초 동안 timer가 작동하고, timer가 작동하는 동안 n개 이벤트가 발생해도 2개를 초과하여 이벤트가 방출되지 않는다.
- lastest는 기본 값이 true인데, true일 때는 1번째와 n번째 이벤트(timer내에 발생한 마지막 이벤트)가 방출되고, false일 때는 최초 1번째 이벤트만 방출된다.
- 이벤트 시작과 동시에 timer가 작동되며, timer가 시작이 되면 초기화 되지 않고 끝이 나야 다음 timer가 작동할 수 있다.
- 버튼에 API 호출 트리거가 있는 경우, lastest = false 파라미터를 통해 유저의 첫 번째 클릭만 이벤트 방출이 되게하여 API 중복 호출을 막는다.
- 무한 스크롤(인피니티 스크롤) 구현할 때에도, 페이지 별 데이터를 가져오기 위해 API를 호출해야하기 때문에, 유저 스크롤 이벤트에 throttle을 사용하면 API Call 비용을 줄일 수 있다.

 

debounce

.debounce(.second(X), lastest: true, scheduler: MainScheduler.instance) // 예시

 

- X초 동안 timer가 작동하고, 1개의 이벤트만 방출된다.

- throttle과 동일하게 이벤트 시작과 동시에 timer가 작동되지만, timer로 지정된 X초 동안 새로운 이벤트가 발생하면 이전 이벤트는 무시되고, timer는 초기화된다.

- throttle은 첫 번째 이벤트가 발생하는 시점에 바로 방출되는 방면, debounce는 timer가 끝나야만 이벤트가 방출된다.

- 연이어서 API 호출될 수 있을 때, 마지막 이벤트를 기준으로 API를 호출시킬 수 있도록 제한이 필요할 때 사용할 수 있다.

 

 

 


 

사실 쓰로틀링과 디바운스는

되게 흔하고 널리 알려진 것 같으면서도, 동시에 간과하기 쉬운 오퍼레이터인 것 같다.

 

며칠 전에 다른 앱을 사용하다가, 글을 쓰고 확인 버튼을 눌렀는데 올라가지 않자 '뭐야 왜 업로드가 안돼!?'하며 연타를 눌렀는데, 같은 글이 여러 개 올라가버리는 일이 있었다.

그러곤 아차! 했다.

 

'이 앱의 개발자는, 내가 이 버튼을 와다다다 연타로 누를 거라 생각치 못했겠지?'

'이 앱의 QA 테스트 기간 동안에 불완전한 API 통신 환경에 대한 고려가 어려웠겠지?'

'내가 이 앱의 개발자였다면, 유저가 이 버튼을 연타를 눌러 버리는 상상을 하고 미리 선 조치를 해두었을까?'

'API 통신이 불완전해 질 수 있는 요인이 정말 다양하기 때문에, 항상 고려를 해야겠다!'

'내가 유저일 때의 행동도 종종 들여다 보고 체크를 해둬야겠다...!'

 

효율적인 API 통신을 위해서도 중요하지만

유저가 내 앱을 쓰며 불편함을 느끼는 것 만큼 가슴아픈 일이 없다.

그래서 자세히 다뤄보고 이해하고 싶어 정리하는 글을 쓰게 되었다.

 

후...!

즐겁군...후후...

 

반응형