사용자 입력 이벤트가 발생하면 네트워크 통신으로 입력값을 검색할 때나 버튼의 액션이 여러 번 실행될 때 해당 이벤트를 처리하는 함수가 여러번 호출되지 않도록 최적화 해야한다. 이를 최적화하지 않으면 불필요한 네트워크 요청, 느린 UI 업데이트 또는 높은 CPU 사용이 발생할 수 있다. 그렇다면 짧은 시간 내에 반복적으로 실행되는 코드의 성능 최적화는 어떻게 구현할 수 있을까?
이러한 문제를 해결하기 위해 디바운스(debounce)와 스로틀(throttle)이라는 두 가지 기술을 사용할 수 있다. 이 기술들을 통해 코드가 실행되는 속도를 제어하고 호출 횟수를 줄일 수 있다. 이번 글에서 디바운스와 스로틀이 무엇인지, 어떻게 다른지, Swift로 구현했을 때 코드는 어떤지 정리해보자.
디바운싱(Debouncing)은 반복되는 이벤트의 빈도를 제어하고 일정 시간이 지날 때까지 함수의 실행을 지연시키는 기술이다. 디바운싱은 여러 이벤트가 너무 빨리 실행되는 것을 방지하기 위해 search bar 또는 버튼과 같은 사용자 인터페이스 상호 작용에 일반적으로 사용된다. 이를 활용하면 사용자가 빠르게 입력하는 경우, 입력을 멈추거나 일정 시간 동안 일시 중지할 때까지 해당 이벤트를 처리하는 함수의 실행을 지연시킬 수 있다.
Swift에서는 SerialDispatchQueue와 DispatchWorkItem을 활용해 디바운스를 구현할 수 있다. delay가 적용된 DispatchWorkItem의 참조를 유지하고 있다가, 새로운 이벤트가 입력되면 기존의 item을 제거하고 새 item을 생성해 함수의 호출을 지연시킬 수 있다.
final class Debounce {
private var workItem: DispatchWorkItem?
private var cancelBlock: (() -> Void)?
private let queue: DispatchQueue
private let delay: TimeInterval
init(queue: DispatchQueue, delay: TimeInterval) {
self.queue = queue
self.delay = delay
}
func callAsFunction(action: @escaping (() -> Void), onCancel: (() -> Void)?) {
if let workItem = workItem {
workItem.cancel()
self.cancelBlock?()
}
workItem = DispatchWorkItem { [weak self] in
guard let self = self else {
onCancel?()
return
}
self.cancelBlock = nil
action()
self.workItem = nil
}
self.cancelBlock = onCancel
if let workItem = workItem {
queue.asyncAfter(deadline: .now() + delay, execute: workItem)
}
}
}
Debounce 클래스 정의. Debounce 는 지연시킨 함수를 담아둘 DispatchWorkItem을 취소하기 위해 workItem 프로퍼티를 갖는다. callAsFunction의 onCancel을 통해 workItem이 제대로 취소됐는지 확인할 수 있다.
디바운싱 과정은 아래와 같다.
정의한 Debounce의 인스턴스를 생성하고 지연시킬 코드를 action으로 전달해 호출하면 된다. 편의를 위해 예제 코드는 ViewController의 viewDidLoad 내부에서 작성하였으며, print 함수로 action / onCancel 동작을 테스트한다.
// helper
let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "hh:mm - ss.SSSS"
return formatter
}()
func print(_ message: String) {
Swift.print("\(dateFormatter.string(from: Date.now)) \(message)")
}
// ViewController
var debounce: Debounce?
override func viewDidLoad() {
super.viewDidLoad()
// debounce의 참조를 프로퍼티를 이용해 유지하지 않으면 가장 마지막 action이 실행되기 전에
// debounce 인스턴스가 할당 해제되어 작업이 cancel됨
let queue = DispatchQueue(label: "Debounce Queue")
debounce = Debounce(queue: queue, delay: 0.5)
guard let debounce = debounce else { return }
debounce {
print("action 1 done")
} onCancel: {
print("action 1 cancelled")
}
DispatchQueue.global().async {
debounce {
print("action 2 done")
} onCancel: {
print("action 2 cancelled")
}
}
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 0.2) {
debounce {
print("action 3 done")
} onCancel: {
print("action 3 cancelled")
}
}
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 0.3) {
debounce {
print("action 4 done")
} onCancel: {
print("action 4 cancelled")
}
}
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 0.9) {
debounce {
print("action 5 done")
} onCancel: {
print("action 5 cancelled")
}
}
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 1.0) {
debounce {
print("action 6 done")
} onCancel: {
print("action 6 cancelled")
}
}
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 1.2) {
debounce {
print("action 7 done")
} onCancel: {
print("action 7 cancelled")
}
}
}
/*
출력 결과!!
01:58 - 09.9830 action 1 cancelled
01:58 - 10.1930 action 2 cancelled
01:58 - 10.2870 action 3 cancelled
01:58 - 10.7950 action 4 done
01:58 - 10.9970 action 5 cancelled
01:58 - 11.2120 action 6 cancelled
01:58 - 11.7460 action 7 done
호출 이후 0.5초 이상 시간이 경과하면 workItem이 실행되고
그렇지 않다면 cancel되는 것을 확인할 수 있다.
*/
위 예제에서 action 4와 action 5 사이 시간 간격이 지정된 delay보다 크기 때문에 “action 4 done”이 출력된다. action 1, 2, 3, 5, 6의 경우에는 이후 추가되는 작업들이 delay보다 작은 시간 간격으로 추가되기 때문에 모두 cancel 된다.
스로틀은 일반적으로 사용자가 쿼리를 입력하는 동안 발생하는 네트워크 요청 수를 제한하기 위해 Search bar에서 사용된다. 사용자가 빠르게 입력할 때 스로틀 제한이 없다면, 백엔드 서버에 수많은 네트워크 요청이 보내지게 되어 서버의 부하가 증가하고 비용도 너무 많이 증가하게 된다. 또한 클라이언트에서는 최종 검색 결과와는 전혀 관련없는 내용으로 UI를 업데이트 해야하기 때문에 반응성이 좋지 않아질 수 있다. 이러한 동작들은 UX에 좋지 않고 비용측면에서도 불리하다. 이를 해결하기 위해 스로틀을 구현해 사용자 입력이 연속적으로 발생할 때 이벤트 처리 함수를 호출할 때 delay를 주어 호출 횟수를 제한할 수 있다.
스로틀은 함수 호출 딜레이 시간동안 지연시킬 작업을 갱신하기 때문에 구현할 때 SerialDispatchQueue와 Timer가 필요하다. 여기에 DispatchSourceProtocol을 상속하는 DispatchSourceTimer를 활용해 지정된 시간만큼 함수의 호출을 지연하는 기능을 구현할 수 있다. DispatchSourceTimer는 DispatchSource class의 타입 메서드 makeTimerSource 메서드를 호출해 생성할 수 있다. timerSource 생성 후 setEventHandler를 호출해 event handler를 설정해 지연 시간 이후 작업을 지정해준다.
final class Throttle {
private var delayedBlock: (() -> Void)?
private var cancelBlock: (() -> Void)?
private var timer: DispatchSourceTimer?
private var isReady = true
private var hasDelayedBlock: Bool { delayedBlock != nil }
private let queue: DispatchQueue
private let delay: Double
init(queue: DispatchQueue, delay: Double) {
self.queue = queue
self.delay = delay
}
func callAsFunction(action: @escaping () -> Void, onCancel: (() -> Void)?) {
queue.async { [weak self] in
guard let self = self else {
onCancel()
return
}
if self.isReady {
self.isReady = false
action()
self.scheduleTimer()
} else {
self.cancelBlock?()
self.cancelBlock = onCancel
self.delayedBlock = action
}
}
}
private func scheduleTimer() {
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.setEventHandler { [weak self] in
guard let self = self else { return }
if self.hasDelayedBlock {
self.cancelBlock = nil
self.delayedBlock?()
self.delayedBlock = nil
self.scheduleTimer()
} else {
self.isReady = true
}
}
timer.schedule(deadline: .now() + delay)
timer.resume()
self.timer = timer
}
}
Throttle 클래스 정의. delay 시간동안 전달되는 action을 delayedBlock에 최신화한다.
callAsFunction의 onCancel을 통해 작업이 제대로 취소되었는지 확인할 수 있다.
스로틀링 과정은 아래와 같다.
DispatchSourceTimer의 event handler는 아래와 같다.
정의한 Throttle 인스턴스를 생성하고 지연시킬 메서드를 포함해 호출한다. 예제는 Debounce와 동일한 조건으로 작성했다.
// helper
let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "hh:mm - ss.SSSS"
return formatter
}()
func print(_ message: String) {
Swift.print("\(dateFormatter.string(from: Date.now)) \(message)")
}
// ViewController 내부
var throttle: Throttle?
override func viewDidLoad() {
let queue = DispatchQueue(label: "Throttle Queue")
throttle = Throttle(queue: queue, delay: 0.5)
guard let throttle = throttle else { return }
throttle {
print("action 1 done")
} onCancel: {
print("action 1 cancelled")
}
DispatchQueue.global().async {
throttle {
print("action 2 done")
} onCancel: {
print("action 2 cancelled")
}
}
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 0.2) {
throttle {
print("action 3 done")
} onCancel: {
print("action 3 cancelled")
}
}
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 0.3) {
throttle {
print("action 4 done")
} onCancel: {
print("action 4 cancelled")
}
}
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 0.4) {
throttle {
print("action 5 done")
} onCancel: {
print("action 5 cancelled")
}
}
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 0.5) {
throttle {
print("action 6 done")
} onCancel: {
print("action 6 cancelled")
}
}
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 0.7) {
throttle {
print("action 7 done")
} onCancel: {
print("action 7 cancelled")
}
}
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 0.75) {
throttle {
print("action 8 done")
} onCancel: {
print("action 8 cancelled")
}
}
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 0.8) {
throttle {
print("action 9 done")
} onCancel: {
print("action 9 cancelled")
}
}
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 1.3) {
throttle {
print("action 10 done")
} onCancel: {
print("action 10 cancelled")
}
}
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 1.5) {
throttle {
print("action 11 done")
} onCancel: {
print("action 11 cancelled")
}
}
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 2.5) {
throttle {
print("action 12 done")
} onCancel: {
print("action 12 cancelled")
}
}
}
/*
출력 결과!!
02:00 - 10.8080 action 1 done
02:00 - 11.0160 action 2 cancelled
02:00 - 11.1110 action 3 cancelled
02:00 - 11.2160 action 4 cancelled
02:00 - 11.3180 action 5 done
02:00 - 11.5140 action 6 cancelled
02:00 - 11.5590 action 7 cancelled
02:00 - 11.6080 action 8 cancelled
02:00 - 11.8190 action 9 done
02:00 - 12.3130 action 10 cancelled
02:00 - 12.3190 action 11 done
02:00 - 13.4330 action 12 done
action이 입력되는 동안 0.5초 간격을 지켜서
print 함수가 호출되는 것을 확인할 수 있다.
*/
위 예제에서 "action 1 done" 출력 이후 0.5초 delay동안 action 2, 3, 4가 cancel 되는 것을 확인할 수 있다. delay 이후에는 가장 최근에 할당된 action 5가 실행되고 이후로 다시 0.5초 delay가 생긴다. 마찬가지로 action 6, 7, 8은 cancel 되고 delay 이후 action 9가 실행된다. action 11 이후로는 delay보다 큰 간격으로 action이 들어오므로 전부 done을 출력한다.


이번에 알아본 Debounce, Throttle을 검색 기능을 구현할 때 적절히 활용해보면 좋겠다.
비동기 코드기 때문에 다음에는 Swift Concurrency를 활용해서 리팩토링 해봐야겠다.