iOS/Combine

[Combine] Controlling Timing - Operator 공부 13

Dev_Pingu 2022. 5. 28. 16:47
반응형

안녕하세요 Pingu입니다.🐧

 

지난 글에서는 Combine의 Handling Errors로 분류된 Publisher, Operator에 대해 알아봤습니다. 이름 그대로 에러를 처리하는 역할을 했었어요.

 

이번 글에서는 Controlling Timing으로 분류된 Publisher와 Operator에 대해 알아보도록 하겠습니다.

Controlling Timing

분류된 이름에서 느낄 수 있듯 이번에 알아볼 녀석들은 뭔가 시간에 관련된 것 들입니다.

Controlling Timing에 분류된 Publisher는 아래와 같습니다.

  • MeasureInterval
  • Debounce
  • Delay
  • Throttle
  • Timeout

그리고 이를 활용해서 만들어진 Operator는 아래와 같습니다.

  • measureInterval(using:options:)
  • debounce(for:scheduler:options:)
  • delay(for:tolerance:scheduler:options:)
  • throttle(for:scheduler:latest:)
  • timeout(_:,scheduler:options:customError:)

그럼 하나씩 자세히 알아보도록 할게요!

Measurelnterval

이번 글에서 첫 번째로 알아볼 Publisher는 MeasureInterval입니다. 정의를 보면 Upstream Publisher에서 값들이 얼마만큼의 시간 간격을 두고 전달받는지 알아낼 수 있는 Publisher라고 하네요.

직관적이라 이해가 잘 되는거 같아요.

measureInterval(using:options:)

MeasureInterval Publisher를 활용해서 만든 Operator는 measrueInterval(using:options:)입니다.

정의를 보면 Upstream Publisher에서 전달받은 이벤트 사이의 시간을 측정하는 역할을 한다고 하네요.

 

그리고 매개변수가 2개인데 알아보면 다음과 같습니다.

  • using
    • 이벤트의 시간을 추적할 스케줄러
  • options
    • using에서 사용된 스케줄러에 적용할 옵션

여기서 사용할 수 있는 스케줄러에는 어떤 것들이 있는지 간단하게 알아보도록 할게요.

  • ImmediateScheduler
  • RunLoop
  • DispatchQueue
  • OperationQueue

위와 같이 네 개의 스케줄러를 사용할 수 있는데, 각 스케줄러에 대한 정리는 나중에 다른 글에서 하도록 하겠습니다.😄

 

그럼 일단 다시 Operator로 돌아와서 간단하게 사용해보겠습니다.

var subscriptions = Set<AnyCancellable>()

let intPublisher = PassthroughSubject<Int, Never>()

intPublisher
    .measureInterval(using: DispatchQueue.main, options: nil)
    .sink(receiveCompletion: { print($0) },
          receiveValue: { nanoSecond in
        print("Measure Time: \(floor(Double(nanoSecond.magnitude) / 1_000_000_000.0))초")
    })
    .store(in: &subscriptions)

intPublisher.send(1)
sleep(1)
intPublisher.send(2)
sleep(2)
intPublisher.send(3)

// measureInterval(using:options:) 예제 코드
Measure Time: 0.0초
Measure Time: 1.0초
Measure Time: 2.0초

위와 같이 sleep 메서드를 사용해서 예제를 만들 수 있습니다.

DispatchQueue의 timeInterval 단위가 나노초 이기 때문에 보기 쉽게 초 단위로 바꾸기 위한 연산도 처리해줬어요.

결과를 보면 Upstream에서 받은 값 사이의 시간이 1초, 2초라고 잘 나오는 것을 볼 수 있습니다.

 

끝입니다~

Debounce

다음으로 알아볼 Publisher는 Debounce입니다.

정의를 보면 Publisher가 내보내는 값 사이에 일정 시간을 두고 값을 내보낸다라고 되어 있습니다.

어떻게 동작될까에 대한 건 실제로 구현된 Operator의 예제를 보면 쉽게 이해할 수 있어요.

debounce(for:scheduler:options:)

Debounce Publisher를 활용해서 만들어진 Operator는 debounce(for:scheduler:options:)입니다.

정의를 보면 내보내는 이벤트 사이에 설정한 시간 간격이 지났을 때만 값을 Downstream으로 내려보내는 역할을 한다고 합니다.

아까와는 다르게 dueTime이라는 매개변수가 하나 더 있네요. 이거만 알아보고 갈게용.

  • dueTime
    • 값을 내보내기 전에 기다리는 시간

작동원리는 간단합니다. 값을 받은 이후에 dueTime이 지날 때까지 값을 받지 않을 때만 

 

실제로 iOS 앱에서 활용하는 방법을 간단히 떠올려보면 텍스트 필드에 활용할 수 있을 거 같아요.

텍스트 필드의 값으로 네트워크 요청을 보내고 싶다고 할 때 텍스트 필드의 값이 바뀔 때마다 네트워크 요청을 하는 것이 아닌 입력 후에 일정 시간이 지나면 입력을 완료했다고 판단하고 그때만 요청하는 거죠.

 

그럼 간단하게 방금 상황을 예제로 만들어볼게요.

var subscriptions = Set<AnyCancellable>()

let operationQueue: OperationQueue = {
    let operationQueue = OperationQueue()

    operationQueue.maxConcurrentOperationCount = 1
    return operationQueue
}()

let textField = PassthroughSubject<String, Never>()
let bounces: [(String, TimeInterval)] = [ // (입력값, 입력 후 기다리는 시간)
    ("www", 0.5),
    (".", 0.5),
    ("p", 1),
    ("ing", 0.5),
    ("u", 0.5),
    (".", 1),
    ("co", 0.5),
    ("m", 1),
]
var requestString: String = ""
textField
    .debounce(for: .seconds(1.0), scheduler: DispatchQueue.main)
    .sink(receiveCompletion: { print($0) },
          receiveValue: { string in
        print("이번에 받은 값: \(string) , Network Request with: \(requestString)")
    })
    .store(in: &subscriptions)

for bounce in bounces {
    operationQueue.addOperation {
        requestString += bounce.0
        textField.send(bounce.0)

        usleep(UInt32(bounce.1 * 1000000))
    }
}

// debounce(for:scheduler:options:) 예제 코드
이번에 받은 값: p , Network Request with: www.p
이번에 받은 값: . , Network Request with: www.pingu.
이번에 받은 값: m , Network Request with: www.pingu.com

위와 같이 예제를 만들 수 있습니다.

bounces라는 배열은 (입력값, 입력 후 기다리는 시간)이라고 가정하고 봐주시면 됩니다!

debounce의 dueTime에 1초라는 시간을 줬기 때문에 값을 받은 이후에 1초 이상 다음 값이 오지 않을 때만 downStream에 최근에 받은 값을 전달하는 거죠.

그래서 값을 보내고 1초 동안 기다렸던 "p", ".", "m"을 입력한 뒤에만 downstream에 전달되어 sink가 동작한 것을 볼 수 있습니다.

 

Delay

다음으로 알아볼 Publisher는 Delay입니다.

이름만 봐도 값을 내려보내기 전에 지연시간을 줄 것 같네요.

실제로 정의도 그러합니다.

delay(for:tolerance:scheduler:options:)

Delay Publisher를 활용해서 만든 Operator는 delay(for:tolerance:scheduler:options:)입니다.

정의는 Publisher의 값을 Downstream으로 보내기 전에 일정 시간만큼 기다리는 역할을 한다고 합니다.

 

이번에도 매개변수를 잠깐 확인하고 가겠습니다.

  • interval
    • Downstream으로 값을 내려보내기 전에 기다릴 시간
  • tolerance
    • 값을 전달할 때 허용할 오차

직관적으로 이해할 수 있는 Operator이니 공식문서의 예제로 간단하고 보고 넘어가겠습니다.

var subscriptions = Set<AnyCancellable>()

let dateFormmater: DateFormatter = {
    let dateFormatter = DateFormatter()
    dateFormatter.dateStyle = .none
    dateFormatter.timeStyle = .long
    return dateFormatter
}()

Timer.publish(every: 1.0, on: .main, in: .default)
    .autoconnect()
    .handleEvents(receiveOutput: { date in
        print("Downstream으로 보낸값(현재시간): \(dateFormmater.string(from: date))")
    })
    .delay(for: .seconds(3), scheduler: RunLoop.main, options: .none)
    .sink(receiveCompletion: { print($0) },
          receiveValue: { value in
        let now = Date()
        print("받은 값: \(dateFormmater.string(from: value)) 보낸시간: \(String(format: "%.2f", now.timeIntervalSince(value))) secs ago", terminator: "\n")
    })
    .store(in: &subscriptions)
    
// delay(for:tolerance:scheduler:options:) 예제 코드
Downstream으로 보낸값(현재시간): 3:45:23 PM GMT+9
Downstream으로 보낸값(현재시간): 3:45:24 PM GMT+9
Downstream으로 보낸값(현재시간): 3:45:25 PM GMT+9
Downstream으로 보낸값(현재시간): 3:45:26 PM GMT+9
받은 값: 3:45:23 PM GMT+9 보낸시간: 3.00 secs ago
Downstream으로 보낸값(현재시간): 3:45:27 PM GMT+9
받은 값: 3:45:24 PM GMT+9 보낸시간: 3.00 secs ago
Downstream으로 보낸값(현재시간): 3:45:28 PM GMT+9
받은 값: 3:45:25 PM GMT+9 보낸시간: 3.00 secs ago

위 코드에서는 delay에 3초를 줬습니다.

즉 Upstream Publisher에서 내려보낸 값이 3초 뒤에야 Downstream에 전달되는 것이죠.

결과를 보면 이해가 쉽습니다.

Downstream에서 받은 값은 현재 시간보다 3초 전의 값인 것을 볼 수 있어요.

Throttle

다음으로 알아볼 Publisher는 Throttle입니다.

정의를 보면 지정된 시간 간격마다 Upstream Publisher가 보낸 가장 최근 값 혹은 가장 첫 번째 값을 Downstream으로 전달하는 Publisher라고 합니다.

아까 알아본 Debounce와 조금 비슷한 부분도 있는데요, 차이점은 이렇습니다.

  • Debounce
    • 값의 수신이 멈추면 일정 시간을 기다린 후 가장 최신 값을 Downstream으로 전달합니다.
  • Throttle
    • 일정 시간을 기다린 뒤 해당 시간 동안 수신한 값 중 가장 첫 번째 값이나 최신 값을 Downstream으로 전달합니다.

실제로 Operator를 사용해보면 이해가 바로 되실 거예요.

throttle(for:scheduler:latest:)

Throttle Publisher를 활용해서 만들어진 Operator는 throttle(for:scheduler:latest:)입니다.

정의를 보면 일정 시간 간격 동안 Upstream Publisher에게 받은 값 중 최신 값 혹은 첫 번째 값을 Downstream으로 전달하는 역할을 한다고 합니다.

매개변수도 정의를 이해했다면 쉽게 이해할 수 있습니다.

  • interval
    • 값을 내려보내기 전에 Upstream Publisher에게 값을 받는 시간 간격입니다.
  • latest
    • 일정 시간 동안 받은 값 중 최신 값을 내려보낼지 첫 번째 값을 내려보낼지 결정하는 Bool 값입니다.

사용해보면 바로 이해가 됩니다.

var subscriptions = Set<AnyCancellable>()

let operationQueue: OperationQueue = {
    let operationQueue = OperationQueue()

    operationQueue.maxConcurrentOperationCount = 1
    return operationQueue
}()

let textField = PassthroughSubject<String, Never>()
let throttles: [(String, TimeInterval)] = [ // (입력값, 입력에 걸리는 시간)
    ("www", 0.5),
    (".", 0.5),
    ("p", 1),
    ("ing", 0.5),
    ("u", 0.5),
    (".", 5),
    ("co", 0.5),
    ("m", 2),
]
var requestString: String = ""
textField
    .throttle(for: .seconds(2), scheduler: DispatchQueue.main, latest: true)
    .sink(receiveCompletion: { print($0) },
          receiveValue: { string in
        print("===Throttle===\n이번시간동안 받은 값중 최신값: \(string), 현재시간: \(Date().description), Network Request with: \(requestString)\n")
    })
    .store(in: &subscriptions)

textField
    .sink(receiveCompletion: { print($0) },
          receiveValue: { string in
        print("===Subject===\n현재시간: \(Date().description), 이번에 내려보낸 값: \(string)")
    })
    .store(in: &subscriptions)

for throttle in throttles {
    operationQueue.addOperation {
        requestString += throttle.0
        textField.send(throttle.0)

        usleep(UInt32(throttle.1 * 1000000))
    }
}

Debounce와의 차이를 알아보기 위해 Debounce 예제 때와 유사하게 텍스트 필드에 값을 입력하는 상황을 예제로 만들어봤습니다.

 

위 코드의 결과는 아래와 같습니다.

// throttle(for:scheduler:latest:) 예제 코드
===Subject===
현재시간: 2022-05-28 07:12:02 +0000, 이번에 내려보낸 값: www
===Throttle===
이번시간동안 받은 값중 최신값: www, 현재시간: 2022-05-28 07:12:02 +0000, Network Request with: www

===Subject===
현재시간: 2022-05-28 07:12:02 +0000, 이번에 내려보낸 값: .
===Subject===
현재시간: 2022-05-28 07:12:03 +0000, 이번에 내려보낸 값: p
===Subject===
현재시간: 2022-05-28 07:12:04 +0000, 이번에 내려보낸 값: ing
===Throttle===
이번시간동안 받은 값중 최신값: ing, 현재시간: 2022-05-28 07:12:04 +0000, Network Request with: www.ping

===Subject===
현재시간: 2022-05-28 07:12:04 +0000, 이번에 내려보낸 값: u
===Subject===
현재시간: 2022-05-28 07:12:05 +0000, 이번에 내려보낸 값: .
===Throttle===
이번시간동안 받은 값중 최신값: ., 현재시간: 2022-05-28 07:12:06 +0000, Network Request with: www.pingu.

===Subject===
현재시간: 2022-05-28 07:12:10 +0000, 이번에 내려보낸 값: co
===Throttle===
이번시간동안 받은 값중 최신값: co, 현재시간: 2022-05-28 07:12:10 +0000, Network Request with: www.pingu.co

===Subject===
현재시간: 2022-05-28 07:12:10 +0000, 이번에 내려보낸 값: m
===Throttle===
이번시간동안 받은 값중 최신값: m, 현재시간: 2022-05-28 07:12:12 +0000, Network Request with: www.pingu.com

결과를 보면 첫 번째 값을 내려보낸 이후부터는 2초 동안 받은 값 중 가장 최신 값만 downstream으로 전달하는 것을 볼 수 있습니다.

Debounce와의 차이가 보이시나용?

 

Debounce는 Downstream에 값을 내려보낸 뒤 일정 시간을 기다린 뒤 최신 값을 내려보냈다면, Throttle은 그냥 일정 시간이 지났을 때 Upstream에서 받은 값이 있다면 최신 값 혹은 첫 번째 값을 내려보내게 됩니다.

 

throttle이 작동되는 시간 동안 Upstream에서 받은 값이 없는 경우가 있을 수 있습니다. 위 예제에서 값을 내려보낸 뒤에 5초 동안 아무것도 안 보내는 구간이 있는데요, 그땐 throttle이 작동되지 않고 그냥 다음 주기로 넘어가게 되는 것을 볼 수 있어요.

 

정리하자면, throttle을 사용하면 일정 시간 동안 Upstream Publisher에게 받은 값 중 최신 값 또는 첫 번째 값을 Downstream에 전달할 수 있다! 입니다.

Timeout

이번 글에서 마지막으로 알아볼 Publisher는 Timeout입니다.

정의를 보면 정해진 시간 동안 Upstream에서 값을 받지 못하면 finish 해버리는 Publisher입니다.

간단한 거 같으니 바로 Operator로 넘어가 볼게요!

timeout(_:scheduler:options:customError:)

Timeout Publisher를 활용해서 만든 Operator는 timeout(_:scheduler:options:customError:)입니다.

정의를 보면 정해진 시간 동안 Upstream Publisher에게 값을 받지 못하면 Publisher를 완료하거나 에러를 발생시키는 역할을 한다고 합니다.

매개변수를 간단하게 살펴볼게요.

  • interval
    • 값을 전달받지 않아도 되는 최대 시간입니다.
  • customError
    • 일정 시간동안 값을 받지 못했을 때 실행될 클로저입니다. Failure 타입을 내려보낼 수도 있습니다.

딱히 어려운 건 없으니 간단하게 사용해볼게요.

var subscriptions = Set<AnyCancellable>()

struct TimeOutError: Error { }

let intPublisher = PassthroughSubject<Int, TimeOutError>()

intPublisher
    .timeout(.seconds(2), scheduler: DispatchQueue.main, customError: {
        return TimeOutError()
    })
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print($0) })
    .store(in: &subscriptions)

intPublisher.send(1)
intPublisher.send(2)

DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
    intPublisher.send(3)
}

// timeout(_:scheduler:options:customError:) 예제 코드
1
2
failure(__lldb_expr_185.(unknown context at $10f0c663c).(unknown context at $10f0c6644).(unknown context at $10f0c664c).TimeOutError())

위와 같이 2초 안에 값을 전달받지 못하면 에러를 발생시키게 만들고, 2.5초 뒤에 값을 내보내도록 만들었습니다.

결과를 보면 1, 2는 Downstream에 전달됐지만 2.5초 뒤에 전달되는 3의 경우엔 전달되지 못하고 그전에 에러가 발생한 것을 볼 수 있어요.

 

그리고 만약 customError 클로저에서 에러를 반환하지 않으면 그냥 finished 됩니다!

 

 

이렇게 Combine의 Publisher와 Operator 중에서 Controlling Timing으로 분류된 것들에 대해 알아봤습니다. 시간에 관련되다 보니 아주 유용할 거 같네요.

 

다음 글에서는 Encoding and Decoding으로 분류된 것들에 대해 알아보도록 하겠습니다.

 

이번 글의 전체 코드는 여기에서 볼 수 있습니다.

 

감사합니다.

반응형