Search

throttle, debounce 기능을 버튼에 넣어보자

 들어가며

프로젝트를 진행하면서 다양한 이슈들을 접하는데, 버튼이 연속으로 눌리는 문제는 항상 보게 되는 거 같습니다. 해당 이슈로 서버에 요청이 두 번 들어가서 게시물이 2개가 만들어지는 문제도 보게 되었고, 이미 있는 회원이 가입을 하고 또 하고 하는 상황도 보게 되었습니다.
어떻게 해결했나요?
문제가 있던 프로젝트들이 RxSwift 라이브러리를 import해서 사용하고 있었기에 RxSwift가 제공해주는 throttle, debounce 메소드를 사용할 수 있었습니다.
하지만 문제 발생
RxSwift 라이브러리를 사용하지 않는 프로젝트에서 throttle, debounce를 사용해야 하는 경우가 생겼습니다. 그렇다면 직접 해당 기능을 구현해줘야 할 것 같군요.
이번 포스팅에서는 직접 구현한 throttle, debounce 기능을 버튼에 설정해주려고 합니다. throttle, debounce에 대한 설명과 함께 코드를 구현해보도록 하겠습니다.
먼저 throttle, debounce에 대한 간단한 설명이 필요할 거 같아요.

 throttle

위키 백과에서는 스로틀을 엔진에 들어가는 공기의 양을 직접 조절하는 밸브로 설명하고 있습니다. 사전적인 의미와 비슷하게 스로틀은 들어가는 이벤트의 양을 조절합니다.
어떻게? 일정 시간동안 이벤트가 실행되는 것을 막으면서!
먼저, throttle을 설명하기 위해서 RxSwift 코드를 한 번 보겠습니다. throttle 메소드를 가지고 있는 RxSwift에서는 throttle를 이렇게 사용합니다.
backButton.rx.tap.asDriver() .throttle(.seconds(3)) .drive(onNext: { [weak self] in self?.navigationController?.popViewController(animated: true) }) .disposed(by: disposeBag)
Swift
복사
backButton를 Tap하는 이벤트가 발생했을 때 throttle은 throttle의 인수로 들어간 dueTime 동안에 하단 이벤트가 방출되지 않도록 합니다. 위의 예시로 보면 3초동안 네비게이션 컨트롤러를 통해서 스크린이 pop 되지 않도록 하겠군요.
3초라는 시간동안 어떠한 이벤트가 중복으로 들어와도 이벤트를 방출시키지 않다가 3초가 지나는 순간 다시 새로운 이벤트를 방출시키고 또 3초를 기다리는거죠.
위의 이벤트를 그림으로 나타냈습니다. Tap 이벤트가 발생하고 .second(3) 동안은 이벤트가 발생해도 이벤트가 방출되지 않습니다. 하지만 그 이후에 Tap 이벤트가 발생하면 방출이 되는걸 볼 수 있어요.
Throttle은 이런 방식으로 일정 시간동안 이벤트가 실행되는 것을 막습니다.

 debounce

위키 백과를 보면 programming한 debounce의 정의를 내려줍니다.
(programming, by extension) To discard events or signals that should not be processed because they occurred too close together. 이벤트나 신호가 너무 가까이(자주) 발생했기 때문에 처리해서는 안 되는 이벤트, 신호를 폐기합니다.
설명에서 알 수 있듯이 이벤트, 신호가 너무 자주 발생해서 자주 발생한 이벤트, 신호 중에 일부를 버리는걸로 보이네요.
debounce도 RxSwift 메소드로 한 번 볼게요.
backButton.rx.tap.asDriver() .debounce(.seconds(0.3)) .drive(onNext: { [weak self] in self?.navigationController?.popViewController(animated: true) }) .disposed(by: disposeBag)
Swift
복사
이 코드를 봤을때 “throttle과 다를게 뭐야!” 라는 생각이 든 분들도 있을 거 같네요. dueTime으로 들어가는 시간이 가지는 의미가 다릅니다.
throttle은 dueTime“일정시간동안 해당 이벤트가 발생하지 않게 해라”의 의미입니다.
debounce는 dueTime“일정시간동안 안에 발생하는 이벤트 중 마지막 이벤트만 방출해라”의 의미입니다.
“일정시간동안 안에 발생하는 이벤트 중 마지막 이벤트만 방출해라”가 무슨 뜻인가요?
위의 버튼 이벤트를 예시로 들어볼게요.
Tap 이벤트가 발생하고 0.3초 안에 새로운 Tap 이벤트가 발생한다면 그 전에 발생한 Tap 이벤트는 폐기됩니다. 그리고 다시 0.3초를 기다리는거죠. 0.3초동안 아무런 이벤트가 일어나지 않는다면 해당 이벤트를 방출합니다.
위의 이벤트를 그림으로 나타내봤습니다.
.second(0.3)안에 새로운 이벤트가 발생하는 경우, 해당 이벤트를 기점으로 다시 0.3초를 셉니다. 그리고 0.3초 이후에 새로운 이벤트가 발생하지 않으면 해당 이벤트를 방출합니다.
Debounce는 이런 방식으로 일정 시간동안 이벤트가 실행되는 것을 막습니다.
그렇다면 코드로는 어떻게 구현할 수 있을까요?

 throttle 직접 구현해보자

class ThrottleButton: UIButton { // MARK: - property private var workItem: DispatchWorkItem? private var delay: Double = 0 private var callback: (() -> Void)? // MARK: - init deinit { self.removeTarget(self, action: #selector(self.performCallback(_:)), for: .touchUpInside) } // MARK: - func func throttle(delay: Double, callback: @escaping (() -> Void)) { self.delay = delay self.callback = callback self.addTarget(self, action: #selector(self.performCallback(_:)), for: .touchUpInside) } // MARK: - selector @objc private func performCallback(_ sender: UIButton) { if self.workItem == nil { self.callback?() let workItem = DispatchWorkItem(block: { [weak self] in self?.workItem?.cancel() self?.workItem = nil }) self.workItem = workItem DispatchQueue.global().asyncAfter(deadline: .now() + self.delay, execute: workItem) } } }
Swift
복사
throttle 기능을 가지고 있는 ThrottleButton 입니다.
throttle 함수에서는 delay되는 시간(위에서 dueTime)과 callback(실행할 Action)을 받습니다.
실제로 throttle의 기능을 하는건 performCallback 함수입니다.
performCallback에서 일어나는 일을 하나씩 설명해보자면 이렇습니다.
⓵ workItem이 nil인지(delay에 걸려있는지) 확인합니다.
⓶ nil 이라면 if 코드 블럭 내부를 실행합니다. 아니면 ⓵ 과정을 반복합니다.
⓷ callback(사용자가 설정한 action)을 실행합니다.
⓸ workItem에 workItem를 다시 nil로 만들어주는 코드 블록이 들어있는 workItem를 넣어줍니다.
⓹ asyncAfter를 사용해서 delay만큼의 시간이 지난 뒤에 workItem이 실행되도록 합니다.
⓺ delay 초 뒤에 workItem 코드 블럭이 실행되면서 workItem이 다시 nil로 변경됩니다.
⓻ ⓵부터 다시 반복합니다.
보시다시피 특정 시간동안 해당 Action(callback)이 실행되지 못하게 하다가 다시 시간이 지나면 callback이 실행되도록 했습니다. workItem를 써서 특정 시간동안 callback를 호출하지 못하게 막음으로써 throttle를 구현했습니다.

 debounce 직접 구현해보자

class DebounceButton: UIButton { // MARK: - property private var workItem: DispatchWorkItem? private var delay: Double = 0 private var callback: (() -> Void)? // MARK: - init deinit { self.removeTarget(self, action: #selector(self.performCallback(_:)), for: .touchUpInside) } // MARK: - func func debounce(delay: Double, callback: @escaping (() -> Void)) { self.delay = delay self.callback = callback self.addTarget(self, action: #selector(self.performCallback(_:)), for: .touchUpInside) } // MARK: - selector @objc private func performCallback(_ sender: UIButton) { self.workItem?.cancel() let workItem = DispatchWorkItem(block: { [weak self] in self?.callback?() }) self.workItem = workItem DispatchQueue.global().asyncAfter(deadline: .now() + self.delay, execute: workItem) } }
Swift
복사
debounce 기능을 가지고 있는 DebounceButton 입니다.
debounce 함수에서는 delay할 시간(위에서 dueTime)과 callback(실행할 Action)을 받습니다.
실제로 debounce의 기능을 하는건 performCallback 함수입니다.
performCallback에서 일어나는 일을 하나씩 설명해보자면 이렇습니다.
⓵ 실행하고 있던 workItem이 있다면 취소 시킵니다.
⓶ callback를 실행하는 코드블록을 가진 workItem를 생성해서 workItem에 넣어줍니다.
⓷ delay 시간 뒤에 workItem이 실행되도록 합니다.
⓸ 만약 workItem이 실행되기 전에 performCallback이 호출된다면, 즉 새로운 이벤트가 발생한다면 ⓵번 과정에 의해서 workItem은 취소됩니다.
throttle과는 다르게 DispatchWorkItem 내부에 callback 함수를 넣어뒀습니다. throttle은 callback이 호출되고 나서 delay 시간을 재는거지만 debounce는 delay 시간동안에 별 다른 이벤트가 들어오지 않는다면 callback를 호출할 수 있기 때문이죠.

 마치며

항상 throttle, debounce를 버튼이나 텍스트 필드 등에 넣고 싶어도 RxSwift를 import하지 않아서 Combine를 잘 몰라서 사용할 수 없다고 생각했는데, 왜 직접 만드는 방식은 생각을 안하고 있었는지 모르겠네요.
직접 만들줄 아는 개발자가 될 수 있길…

 참고 자료