Swift

Debouncer 만들기 (No Combine, No RxSwift)

GREEN.1229 2023. 10. 9. 02:53

안녕하세요. 그린입니다🍏

이번 포스팅에서는 로직에서 아주 간단히 사용할 수 있는 Debouncer를 만들어 사용해보겠습니다 🙋🏻

 


No Combine, No RxSwift

Combine이나 RxSwift에서 흔히 접할 수 있는 debounce의 사용과 혼동할 수 있어서 한번 짚고 넘어가볼께요.

 

예시로 애플 공식문서의 Combine 코드를 조금 더 알기 쉽게 변경하여 가져와보겠습니다.

 

let bounces:[(String,TimeInterval)] = [
  ("Black", 0),
  ("Green", 0.25),  // 0.25s interval since last index
  ("Red", 1),     // 0.75s interval since last index
  ("Blue", 1.25),  // 0.25s interval since last index
  ("Orange", 1.5),   // 0.25s interval since last index
  ("Purple", 2)      // 0.5s interval since last index
]

let subject = PassthroughSubject<Int, Never>()
cancellable = subject
  .debounce(for: .seconds(0.5), scheduler: RunLoop.main)
  .sink { color in
      print ("Received color is \(color)")
  }

for bounce in bounces {
  DispatchQueue.main.asyncAfter(deadline: .now() + bounce.1) {
      subject.send(bounce.0)
  }
}

// Received color is Green🟢
// Received color is Orange🟠
// Received color is Purple🟣

 

해당 코드를 살펴보면 bounces로 정해진 값들이 존재하죠.

그리고 PassthroughSubject 타입으로 subject를 만들고 이 subject에 debounce를 0.5초로 걸어줍니다.

그럼 해당 subject에 여러번 send되어도 마지막으로 들어온 값에 대해 0.5초 텀이 있기전에는 무시되게 막아주죠.

 

그 예시를 for문을 통해 구현된것을 스텝을 통해 설명해볼께요!

 

1️⃣ 비동기로 현재 시간에서 들어온 bounce 인자들의 정해진 TimeInterval 수치만큼 실행 지연

2️⃣ 지연된 시간 후 bounce의 Int 인자 방출

3️⃣subject에서 debounce로 0.5초가 걸려있기에 최근 방출된 값으로부터 0.5초가 지나기 전 다른 값이 방출되어 들어오면 로직 미실행

4️⃣ 0.5초동안 다른 방출이 없으면 로직 실행

 

이 순서인데요.

조금 말로하면 헷갈리죠!?

 

차근차근 한번 해당 예시 코드로 어떻게 주고 받는건지 살펴볼까요?

 

 

텍스트보단 흐름을 이해할 수 있도록 정리해봤습니다 🙋🏻

 

핵심은 디스패치큐에서 지연된 시간 후 각 인자들을 실행하는데 다음 인자가 들어올때 이 지연된 시간텀이 디바운스로 걸어둔 0.5초 이내이면 이전 값은 방출되지 않고 0.5초 후 들어오면 이전 값이 방출되는것입니다.

 

뭔가 오늘 핵심이 Combine의 debounce를 말하려는건 아니였는데 어쩌다보니 조금 자세히 말하게 되었네요!

 

그럼 이제 본론으로 넘어가볼까요?


Debouncer 만들어보기

먼저 디바운스를 만들어보기전 오늘 적용할 간단한 코드를 보겠습니다!

 

struct ContentView: View {
  @State var count: Int = 0
  
  var body: some View {
    VStack(spacing: 10) {
      Text("\(count)")
        .font(.title)
        .foregroundColor(.green)
        .bold()
      
      Button(
        action: {
          count += 1
        },
        label: {
          Text("Plus Count")
            .font(.title)
            .foregroundColor(.black)
            .bold()
        }
      )
    }
    .padding()
  }
}

 

간단한 코드를 보시면 버튼이 눌릴때마다 count가 증가하겠죠?

즉 버튼을 빠르게 연타로 여러번 누르면 누른만큼 count가 증가할거에요.

아래와 같이 말이죠!

 

 

그런데 이걸 의도했을수도 있지만 만약 count를 단순히 증가하는게 아니라 버튼이 눌릴때 어떤 비지니스 로직이 있거나 다른 뷰를 띄워줘야한다거나 등 한번만 작동해야 되는 경우가 있을수도 있습니다.

 

예를들어서 회원가입을 하는 버튼을 누른다고 가정해보면 네트워킹이 들어가서 처리를 할텐데 그 소요되는 시간이 짧지만 존재할 수 있죠.

 

그럴때 만약 연타로 누르면 그 소요되서 처리되는 시간보다 짧게 더 많은 요청이 들어갈 수 있어요.

물론 그 요청처리를 클라에서 검사를 하거나 서버단에서 검증을 하여 한번만 처리되게 하겠지만, 우선 여러 요청이 들어간다는것은 사실 아주아주 불필요한 요청이겠죠!?

 

그럴때 debounce를 사용하면 좋습니다.

 

그럼 한번 간단히 Debouncer를 구현해볼까요?

 

먼저 Debouncer를 만듭시다 🙋🏻

 


Debouncer 구현

class Debouncer {
  static let shared = Debouncer()
  var task: Task<(), Never>?
  
  func debounce(
    seconds: Double = 2.0,
    handler: @escaping () -> Void
  ) {
    task?.cancel()
    
    task = Task {
      do {
        try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
        await MainActor.run { handler() }
      } catch {
        print("debounce error")
      }
    }
  }
}

 

여러곳에서 공유할 수 있도록 싱글턴 객체로 만듭니다.

그리고 task를 만들어서 공유해줘요.

 

debounce 코드를 보시면 얼마나 텀을 줄지 seconds와 실행할 핸들러 블록을 파라미터로 받습니다.

해당 메서드가 호출되면 기존에 있던 task를 cancel해주고 다시 task를 만들어요.

 

Task를 만들때 먼저 받아온 seconds만큼 Task를 sleep해주고 그 다음 메인액터에서 해당 handler를 실행해줍니다.

 

만약 여기서 에러가 발생하면 catch하여 처리해줘요.

 

그럼 이걸 우리가 만든 뷰에 올려볼까요?


View에 적용하기

struct ContentView: View {
  @State var count: Int = 0
  let debouncer = Debouncer.shared
  
  var body: some View {
    VStack(spacing: 10) {
      Text("\(count)")
        .font(.title)
        .foregroundColor(.green)
        .bold()
      
      Button(
        action: {
          debouncer.debounce {
            count += 1
          }
        },
        label: {
          Text("Plus Count")
            .font(.title)
            .foregroundColor(.black)
            .bold()
        }
      )
    }
    .padding()
  }
}

 

그럼 이제 버튼을 연타로 누를때 어떤 동작을 하는지 볼까요?

 

 

연타하면 지정해둔 2초 텀이 있지 않은 이상 로직이 실행되지 않고 2초 텀이 발생하면 로직이 실행되어 count가 올라갑니다.

 

자 근데 여기서 우리는 catch를 debounce 내부에서 처리해줬기에 에러 처리를 해당 내부에서 가져갔어요.

그런데 만약 이 error를 view 단으로 가져와서 처리하고 싶다면 어떻게 해야 할까요?

 


뷰에서 에러처리하기

class Debouncer {
  static let shared = Debouncer()

  var task: Task<(), Error>?

  func debounce(
    seconds: Double = 2.0,
    handler: @escaping () throws -> Void
  ) async throws {
    task?.cancel()

    task = Task {
      do {
        try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
        try await MainActor.run { try handler() }
      } catch {
        throw error
      }
    }
  }
}

 

아까 코드에서 조금 변형되었습니다.

핸들러 파라미터 시그니처를 변경하면 되죠.

throws할 수 있게 만들고 해당 메서드도 async throws로 변경하면서 내부에서 에러를 외부로 던져줍니다.

 

그럼 뷰에서 적용해볼까요?

 

struct ContentView: View {
  @State var count: Int = 0
  @State var isDisplayError: Bool = false
  let debouncer = Debouncer.shared
  
  var body: some View {
    VStack(spacing: 10) {
      if isDisplayError {
        Text("Error")
          .font(.title)
          .foregroundColor(.red)
          .bold()
      }
      
      Text("\(count)")
        .font(.title)
        .foregroundColor(.green)
        .bold()
      
      Button(
        action: {
          🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻
          Task {
            do {
              try await debouncer.debounce {
                count += 1
              }
            } catch {
              isDisplayError = true
            }
          }
          🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻
        },
        label: {
          Text("Plus Count")
            .font(.title)
            .foregroundColor(.black)
            .bold()
        }
      )
    }
    .padding()
  }
}

 

버튼의 action 코드를 보시면 Task로 만들어졌습니다.

여기서 이제 do catch로 로직을 처리해주는것이죠!

 

실제로 에러가 발생하면 isDisplayError가 true가 되고 Error 텍스트를 노출해주게 되는것입니다 😁

 

아주 간단하고 편리하죠!?

 


마무리

이렇게 debounce를 간단히 구현해봤습니다.

어디서든 로직이 들어가면 편리하게 사용할 수 있을것 같네요 😀

 


참고 레퍼런스

https://developer.apple.com/documentation/combine/fail/debounce(for:scheduler:options:) 

 

debounce(for:scheduler:options:) | Apple Developer Documentation

Publishes elements only after a specified time interval elapses between events.

developer.apple.com