티스토리 뷰

반응형

 

안녕하세요, 개발자 멍구입니다! 🍀
오늘은 iOS 앱에서 자주 필요한 기능 중 하나인 "버튼 중복 클릭 방지", 바로 throttle 기능을 Swift 코드로 직접 구현해본 경험을 공유해보려 해요.

보통 throttle은 Combine에서 제공되지만, Combine 없이도 구현 가능하다면?
코드를 직접 다루며 개념을 이해하면, 좋을 것 같아서 공부 겸 Task 비동기 API를 이용해서 구현해봤어요.

오늘은 Task와 @MainActor, 그리고 async/await 기반으로 Throttler 클래스를 직접 만들어보며, 어떤 원리로 동작하는지 친절하게 풀어드릴게요.

 


throttle이란? 간단 개념 정리

throttle은 지정된 시간 간격 안에서는 한 번만 이벤트를 허용하고, 그 외에는 무시하는 제어 방식이에요. 대표적인 사용 예시는 이런 것들이 있어요:

  • 버튼 연타 방지
  • 빠르게 반복 호출되는 API 요청 차단
  • 입력 폼에서 debounce와 함께 입맛따라 활용

오늘 구현할 throttle은 딱 한 가지 목적!
“버튼을 여러 번 눌러도, 일정 시간(예: 3초) 안에서는 한 번만 동작하게 만들기” 입니다.

 


직접 만든 Throttler 클래스의 구조

이번 예제의 핵심은 이 Throttler 클래스입니다:

final class Throttler {
    enum ThrottleError: Error {
        case timeout
    }
​
    private var timeCheckTask: Task<Void, Error>?
    private let delayInterval: TimeInterval
}
  • delayInterval은 throttle 간격 (ex. 3초)을 의미합니다.
  • timeCheckTask는 이벤트가 발생한 뒤 일정 시간 동안 추가 이벤트가 발생하지 않도록 막는 역할을 해요.
  • 시간이 지나면 ThrottleError.timeout이 발생하게 설계되어 있어요.

왜 Task를 쓰나요?

Task를 사용하면 async/await 문법 안에서 delay 처리를 유연하게 할 수 있어요. 기존의 DispatchQueue보다도 더 Swift스럽게 처리할 수 있어요. async await 방식으로 더욱 깔끔하고 동기코드처럼 작성 가능하게 해주는 놈입니다. 뿐만 아니라 에러처리도 되고, 도중에 취소하기에도 간편해요.

 


Throttler runEvent 함수 설명

이제 본격적인 throttle 실행 로직입니다:

@MainActor
func runEvent(_ handler: @escaping () -> Void) async

여기서 @MainActor는 매우 중요한데요, UI 이벤트 처리나 스레드 안전성을 고려할 때, 메인 스레드에서만 실행되도록 보장해줍니다.

그리고 struct, enum, actor와 다르게 class는 기본적으로 값을 안전하게 전달할 수 있게 해주는 `Sendable`을 따르지 않기 때문에 Task에서 해당 인스턴스를 캡처하려 할 때 컴파일 타임 에러를 발생시킬 수 있습니다.

하지만 해당 클래스나 내부 메서드에 `@MainActor`를 명시하면, 항상 메인 스레드에서 실행된다는 제약 덕분에 컴파일러는 이를 안전하다고 판단하게 되어 `Sendable` 문제를 회피할 수 있습니다.

아래는 핵심 동작 순서입니다:

  1. timeCheckTask가 아직 살아 있고 취소되지 않았다면 → 현재 이벤트는 무시 (18~21행)
  2. timeCheckTask가 없거나 이미 끝났다면 → 새로운 Task를 만들고, 이벤트를 실행할 준비 (22~26행)
  3. Task는 delayInterval만큼 대기 후 timeout 에러를 던짐 (23~25행)
  4. 이 에러를 catch했을때 task를 cancel하고 다시 이벤트를 받을 준비를 함 (33~36행)

이 구조를 통해 이벤트 → 기다림 → 이벤트 차단 해제라는 흐름이 깔끔하게 구현돼요.

 


throttler 실행으로 전체 실행 흐름 보기

실제 사용할 때는 아래처럼 간단하게 호출할 수 있습니다:

let throttler = Throttler(delayInterval: 3.0)

Task {
    await throttler.runEvent {
        print("🔥 첫 번째 이벤트 실행!")
    }
}

이후 시간 간격을 달리해 이벤트를 실행해볼게요:

Task {
    try await Task.sleep(for: .seconds(2.5))
    await throttler.runEvent {
        print("🔥 두 번째 이벤트 실행!")
    }
}

Task {
    try await Task.sleep(for: .seconds(3.5))
    await throttler.runEvent {
        print("🔥 세 번째 이벤트 실행!")
    }
}

 

 


Throttle event 예상 결과는?

🤔 throttle event ready at (현재 시각)
🔥 첫 번째 이벤트 실행!
🧱 event blocked : (2.5초 시점)
🫠 cancelled task at error : timeout
🔥 세 번째 이벤트 실행!
  • 첫 번째 이벤트는 바로 실행
  • 2.5초 후의 두번째 이벤트는 무시됨 (3초가 안 지남)
  • 3.5초가 지난 후 실행된 세번째 이벤트는 성공

이렇게 중복 호출, 중복 이벤트를 방지해주는 throttle 이벤트를 간이로 만들어봤습니다.

 


마무리하며

Swift의 Task, @MainActor, async/await만으로도 throttle 기능을 꽤 심플하게 구현할 수 있답니다. Combine 없이도 가능하다는 점에서, 더 유연한 커스터마이징이 필요할 때 매우 유용하겠죠?

👉🏻 UI에 민감한 동작을 다루고 있다면, throttle은 필수입니다!
👉🏻 직접 구현해보며 개념까지 익혀두면 실무에서 훨씬 쉽게 활용할 수 있어요.

다음에는 비슷한 개념의 debounce도 구현해보는 글로 찾아뵐게요 🙂 궁금한 점이나 피드백은 언제든지 환영입니다!
아래 Throttler 전체 코드도 남길게요. 많은 고려없이 후다닥 만들어서 부족한게 많을테니, 피드백 부탁드려요.

import Foundation

final class Throttler {
    /// 지정된 time interval이 지난 경우, 발생할 에러
    enum ThrottleError: Error {
        case timeout
    }

    private var timeCheckTask: Task<Void, Error>?
    private let delayInterval: TimeInterval

    init(delayInterval: TimeInterval) {
        self.delayInterval = delayInterval
    }

    @MainActor
    func runEvent(_ handler: @escaping () -> Void) async {
        if let timeCheckTask,
           !timeCheckTask.isCancelled {
            debugPrint("🧱 event blocked : at \(Date())")
            return
        } else {
            self.timeCheckTask = Task<Void, Error> {
                try await Task.sleep(for: .seconds(delayInterval))

                throw ThrottleError.timeout
            }
        }

        do {
            handler()
            try await timeCheckTask?.value
        } catch {
            timeCheckTask?.cancel()
            debugPrint("🫠 cancelled task at error : \(error) at \(Date())")
        }
    }
}

let throttler = Throttler(delayInterval: 3.0)

print("🤔 throttle event ready at \(Date())")

Task {
    await throttler.runEvent {
        print("🔥 fisrt event fired at \(Date())")
    }
}

Task {
    try await Task.sleep(for: .seconds(2.5))

    await throttler.runEvent {
        print("🔥 second event fired at \(Date())")
    }
}

Task {
    try await Task.sleep(for: .seconds(3.5))

    await throttler.runEvent {
        print("🔥 third event fired at \(Date())")
    }
}

 

반응형
댓글
반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/07   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
글 보관함