[Swift] GCD 기반 Debounce & Throttle

로웬(Rowan)·2024년 5월 17일

Swift

목록 보기
1/1

사용자 입력 이벤트가 발생하면 네트워크 통신으로 입력값을 검색할 때나 버튼의 액션이 여러 번 실행될 때 해당 이벤트를 처리하는 함수가 여러번 호출되지 않도록 최적화 해야한다. 이를 최적화하지 않으면 불필요한 네트워크 요청, 느린 UI 업데이트 또는 높은 CPU 사용이 발생할 수 있다. 그렇다면 짧은 시간 내에 반복적으로 실행되는 코드의 성능 최적화는 어떻게 구현할 수 있을까?

이러한 문제를 해결하기 위해 디바운스(debounce)와 스로틀(throttle)이라는 두 가지 기술을 사용할 수 있다. 이 기술들을 통해 코드가 실행되는 속도를 제어하고 호출 횟수를 줄일 수 있다. 이번 글에서 디바운스와 스로틀이 무엇인지, 어떻게 다른지, Swift로 구현했을 때 코드는 어떤지 정리해보자.


✧ Debounce

Debouncing 이란?

디바운싱(Debouncing)은 반복되는 이벤트의 빈도를 제어하고 일정 시간이 지날 때까지 함수의 실행을 지연시키는 기술이다. 디바운싱은 여러 이벤트가 너무 빨리 실행되는 것을 방지하기 위해 search bar 또는 버튼과 같은 사용자 인터페이스 상호 작용에 일반적으로 사용된다. 이를 활용하면 사용자가 빠르게 입력하는 경우, 입력을 멈추거나 일정 시간 동안 일시 중지할 때까지 해당 이벤트를 처리하는 함수의 실행을 지연시킬 수 있다.

Debounce 만들기

Swift에서는 SerialDispatchQueueDispatchWorkItem을 활용해 디바운스를 구현할 수 있다. delay가 적용된 DispatchWorkItem의 참조를 유지하고 있다가, 새로운 이벤트가 입력되면 기존의 item을 제거하고 새 item을 생성해 함수의 호출을 지연시킬 수 있다.

🔎 Implementation Code

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 프로퍼티를 갖는다. callAsFunctiononCancel을 통해 workItem이 제대로 취소됐는지 확인할 수 있다.

디바운싱 과정은 아래와 같다.

  1. 디바운싱 하려는 함수를 action에 전달
  2. 기존 workItem이 있다면 해당 item을 cancel
  3. 새로운 action으로 workItem 생성(내부에서 action 실행 후 workItem을 nil로 설정)
  4. 생성된 workItem을 DispatchQueue에 전달해 delay 후 실행될 수 있도록 예약

Debounce 사용하기

정의한 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 된다.


✧ Throttle

Throttling 이란?

스로틀은 일반적으로 사용자가 쿼리를 입력하는 동안 발생하는 네트워크 요청 수를 제한하기 위해 Search bar에서 사용된다. 사용자가 빠르게 입력할 때 스로틀 제한이 없다면, 백엔드 서버에 수많은 네트워크 요청이 보내지게 되어 서버의 부하가 증가하고 비용도 너무 많이 증가하게 된다. 또한 클라이언트에서는 최종 검색 결과와는 전혀 관련없는 내용으로 UI를 업데이트 해야하기 때문에 반응성이 좋지 않아질 수 있다. 이러한 동작들은 UX에 좋지 않고 비용측면에서도 불리하다. 이를 해결하기 위해 스로틀을 구현해 사용자 입력이 연속적으로 발생할 때 이벤트 처리 함수를 호출할 때 delay를 주어 호출 횟수를 제한할 수 있다.

Throttle 만들기

스로틀은 함수 호출 딜레이 시간동안 지연시킬 작업을 갱신하기 때문에 구현할 때 SerialDispatchQueueTimer가 필요하다. 여기에 DispatchSourceProtocol을 상속하는 DispatchSourceTimer를 활용해 지정된 시간만큼 함수의 호출을 지연하는 기능을 구현할 수 있다. DispatchSourceTimerDispatchSource class의 타입 메서드 makeTimerSource 메서드를 호출해 생성할 수 있다. timerSource 생성 후 setEventHandler를 호출해 event handler를 설정해 지연 시간 이후 작업을 지정해준다.

🔎 Implementation Code

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 시간동안 전달되는 actiondelayedBlock에 최신화한다.
callAsFunctiononCancel을 통해 작업이 제대로 취소되었는지 확인할 수 있다.
스로틀링 과정은 아래와 같다.

  1. 입력에 대응하는 최초 함수 호출을 위해 isReady를 확인
  2. isReady가 true인 경우 action을 호출 후 timerSource 생성
  3. isReady가 false인 경우 delay 중인 action을 cancel하고 다음 action을 delay 시킴

DispatchSourceTimer의 event handler는 아래와 같다.

  1. delayedBlock이 있는 경우
    • cancelBlock을 nil로 설정
    • delayedBlock 호출
    • 사용자의 입력이 계속될 수 있으므로 timerSource 재생성
  2. delayedBlock이 없는 경우
    - isReady를 true로 바꿔 다음 action을 받을 수 있도록 해줌
    - timerSource 생성 시, repeating을 지정하지 않았으므로 이후 반복은 없음

Throttle 사용하기

정의한 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

  • delay 보다 짧은 간격으로 연속된 입력을 받을 때 최종 input 값으로만 함수를 호출
  • 즉, 유저의 입력이 일정 시간동안 일어나지 않으면 함수 호출

throttle

  • 연속된 입력을 받을 때 일정 시간마다 현재 input으로 함수를 호출
  • 즉, 유저의 입력이 지속되는 동안 지정된 시간 간격마다 함수 호출

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

[참고 문서]

profile
iOS Developer

0개의 댓글