ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Chapter 6: Time Manipulation Operators
    Raywenderlich/Combine: Asynchronous Programming 2020. 7. 27. 17:35

    Version

    Swift 5.3, iOS 14, Xcode 12

     

    타이밍은 매우 중요하다(Timing is everything). 반응형 프로그래밍(reactive programming)의 핵심(core) 개념(idea)은 시간에 따른 비동기(asynchronous) 이벤트(event) 흐름(flow)을 모델링(model)하는 것이다. 이와 관련하여(respect), Combine 프레임 워크(framework)는 시간을 다룰 수 있는 다양한 연산자(operators)를 제공한다. 특히, 시간에 따라(over time) 시퀀스(sequences)가 반응(react)하고, 값을 변환(transform)한다 .

    이 장(chapter) 전체에서 볼 수 있듯이, 일련의 값(sequence of values)들을 시간 차원(time dimension)에서 관리하는 것은 쉽고 간단(straightforward)하다. 이는 Combine과 같은 프레임 워크(framework)를 사용할 때 얻을 수 있는 가장 큰 이점 중 하나이다.

     

    Getting started

    시간(time) 조작(manipulation) 연산자(operators)에 대해 알아보려면, 시간에 따른(over time) 데이터의 흐름(flows)을 시각화(visualizes)하는 animated Xcode Playground를 사용한다. 이 장(chapter)의 프로젝트(projects) 폴더(folder)에서 시작(starter) playground를 찾을 수 있다.

    playground는 여러 페이지(pages)로 나뉜다. 각 페이지(pages)를 사용하여 하나 이상의 관련(related) 연산자(operators)를 연습한다. 또한 예제를 작성하는 데 유용한 몇가지 클래스(classes), 함수(functions), 샘플 데이터(sample data)도 포함되어(ready-made) 있다.

    playground에서 show rendered markup을 설정한 경우, 각 페이지(page) 하단에 클릭하여 다음 페이지로 이동할 수있는 다음(Next) 링크가 있다.

    Note: show rendered markup를 켜거나 끄려면 메뉴(menu)에서 Editor ▸ Show Rendered/Raw Markup을 선택한다.

    왼쪽 사이드 바(left sidebar)의 프로젝트 탐색기(Project navigator) 또는 페이지 상단의 점프 바(jump bar)에서도 원하는 페이지(page)를 선택할 수 있다. Xcode에는 다양한 방법이 있다.

    Xcode 창(window)의 오른쪽 상단(top-right)에 위치한 컨트롤(controls)을 확인한다:

     

    1. Playground pages의 목록(list)을 확인할 수 있도록, 왼쪽 사이드 바(left sidebar) 버튼(button)이 활성화(enabled)되어 있는지 확인한다.
    2. 라이브 뷰(Live View)로 편집기(editor)를 표시한다. 이렇게 하면, 코드에서 작성(build)하는 시퀀스(sequences)의 라이브 뷰(live view)가 표시된다. 여기서 실제 동작이 일어난다. 라이브 뷰(Live View)로 편집기(editor)를 표시하려면, 두 개 원(circles)의 가운데(middle) 버튼(button)을 클릭한다.

    또한 playgrounds는 수동(manually) 또는 자동(automatically)으로 실행(run)될 수 있다. 디버그 영역(Debug area)을 표시하거나 수동(manual) / 자동(automatic) 실행을 위한 playground 구성은 편집기(editor)의 왼쪽 하단에있는 컨트롤(controls)을 사용한다:

     

    1. 왼쪽의 세로(vertical) 화살표(arrow)를 클릭(click)하여, 디버그 영역(Debug area)을 표시(show)하거나 숨긴다(hide). 디버그(debug) 출력은 여기에 표시된다.
    2. 실행(run) 화살표(arrow)를 길게 클릭(Long-click)하면, playground의 자동(automatically) 실행 여부를 선택할 수 있는 메뉴(menu)가 표시된다. 수동(manual) 모드(mode)인 경우, 실행(run) 버튼(button)을 클릭하면 실행 중(Running)과 일시 중지(Paused) 상태(states)가 번갈아 나타난다.

     

    Playground not working?

    때때로 Xcode의 playground가 “작동 중(act up)”이면서 제대로 실행(run)되지 않을 수 있다. 이 경우 Xcode에서 Preferences 대화 상자(dialog)를 열고, Locations 탭(tab)을 선택한다. 아래 스크린샷에서 1로 표시된 빨간 원(red circled) 옆의 Derived Data location 화살표(arrow)를 클릭(click)한다. Finder의 DerivedData 폴더(folder)가 표시된다.

     

    Xcode를 종료하고 DerivedData 폴더(folder)를 휴지통(trash)으로 이동한 다음, Xcode를 다시 시작(launch)한다. 그러면 playground가 제대로 작동할 것이다.

     

    Shifting time

    때때로 시간 여행(time traveling)이 필요할 수 있다. Combine은 과거의 관계(relationship) 문제를 해결하는 데 도움을 줄 수 없지만, 자가 복제(self-cloning) 기능을 사용할 수 있을 때까지 잠시 시간을 정지(freeze)시킬 수 있다.

    가장 기본적인 시간(time) 조작(manipulation) 연산자(operator)는 publisher가 내보낸(emitted) 값을 지연(delays)시켜, 실제보다 나중에 볼 수 있도록 한다.

    delay(for:tolerance:scheduler:options) 연산자(operator)는 전체 값의 시퀀스(sequence)를 시간 전환(time-shifts)한다. 업 스트림(upstream) publisher가 값을 내보낼(emits) 때마다, Scheduler에서 요청한(asked) 시간만큼 값을 지연(delay)한 후 방출(emits)한다.

    playground의 Delay 페이지(page)를 연다. 가장 먼저 확인하는 것은 Combine 프레임 워크(framework)뿐만 아니라 SwiftUI도 가져오고(import) 있다는 것이다. 이 동적인(animated) playground는 SwiftUI와 Combine으로 만들어 졌다. 시간이 있다면 Sources 폴더(folder)의 코드를 살펴 보는 것도 좋다.

    가장 먼저 할 일은 나중에 조정할(tweak) 몇 가지 상수(constants)를 정의(defining)하는 것이다:

    let valuesPerSecond = 1.0 //초당 내보내는 값의 수
    let delayInSeconds = 1.5 //지연 시간

    매초마다 하나의 값을 내보내(emits)는 publisher를 만든 다음, 1.5 초 지연(delay)시키고 두 타임 라인(timelines)을 동시에(simultaneously) 표시하여 비교(compare)한다. 이 페이지(page)의 코드 작성을 완료하면, 상수(constants)를 조정(adjust)하여 타임 라인(timelines)에서 그 결과를 확인할 수 있다.

    다음으로 필요한 publishers를 생성한다:

    let sourcePublisher = PassthroughSubject<Date, Never>()
    //Timer가 내보내는 날짜를 입력하는 간단한 Subject
    //여기서 값의 type은 그다지 중요하지 않다. publisher가 값을 내보내는 경우와 delay될 때의 값에만 집중하면 된다.
    
    let delayedPublisher = sourcePublisher.delay(for: .seconds(delayInSeconds), scheduler: DispatchQueue.main)
    //sourcePublisher가 내보내는 값을 지연시켜 메인 스케줄러에서 내보낸다.
    //scheduler에 관한 것은 17장에서 더 자세하게 배운다. 지금은 값이 메인 큐에서 작동해야 한다는 것을 기억하면 된다.
    
    let subscription = Timer
        .publish(every: 1.0 / valuesPerSecond, on: .main, in: .common)
        //메인 스레드에 초당 1개의 값을 내보내는 타이머를 생성한다.
        .autoconnect() //즉시 시작
        .subscribe(sourcePublisher) //구독
        //sourcePublisher에서 내보낼 값을 가져온다.
    Note: 이 타이머(timer)는 Foundation Timer 클래스(class)의 Combine 확장(extension)이다. RunLoopRunLoop.Mode를 가지고 있으며, 예상대로 DispatchQueue가 아니다. 타이머(timers)에 대한 모든 것을 11장(chapter), "Timers"에서 배우게 될 것이다. 또한, 타이머(timers)는 connectable인 publishers의 한 부류이다. 이는 값을 내보내기(emitting) 전에 연결되어야(connected) 한다는 것을 의미한다. 첫 번째 구독(subscription) 시 즉시(immediately) 연결(connects)되는 autoconnect()를 사용한다.

    이벤트를 시각화(visualize)할 수 있는 두 개의 뷰(views)를 작성해야 한다. playground에 아래의 코드를 추가한다:

    let sourceTimeline = TimelineView(title: "Emitted values (\(valuesPerSecond) per sec.):")
    //timer에서 내보내는 값을 표시할 TimelineView를 생성한다.
    //TimelineView는 SwiftUI view로, Sources/Views.swift 에서 해당 코드를 확인할 수 있다.
    
    let delayedTimeline = TimelineView(title: "Delayed values (with a \(delayInSeconds)s delay):")
    //지연된 값을 표시할 TimelineView를 생성한다.
    
    let view = VStack(spacing: 50) { //SwiftUI의 vertical stack을 생성한다.
        sourceTimeline
        delayedTimeline
    }
    
    PlaygroundPage.current.liveView = UIHostingController(rootView: view.frame(width: 375, height: 600))
    //playground page의 liveView를 설정한다.
    //frame(widht:height:) 수정자를 사용하면, Xcode의 미리보기에 고정된 frame을 설정한다.

     

    이 단계에서는 화면에 두 개의 빈 타임 라인(timelines)이 표시된다. 이제 각 publisher가 내보낸(emitted) 값(values)을 제공(feed)해야 한다. 마지막 코드를 playground에 추가한다:

    sourcePublisher.displayEvents(in: sourceTimeline)
    delayedPublisher.displayEvents(in: delayedTimeline)
    //내보낸 값을 표시한다.

    이 마지막 코드에서는 sourcePublisher와 delayedPublisher를 각각의(respective) 타임 라인(timelines)에 연결(connect)하여 이벤트(events)를 표시한다.

    코드의 변경 사항을 저장하면 Xcode가 playground 코드를 다시 컴파일(recompile)하고 라이브 뷰(Live View) 창(pane)에 표시한다.

     

    두 개의 타임 라인(timelines)이 표시된다. 상단의 타임 라인(timeline)은 타이머(timer)에서 방출된 값(values)이 표시된다. 하단의 타임 라인(timeline)에는 지연된(delayed) 동일한 값(values)이 표시된다. 원 안(circles)의 숫자(numbers)는 실제 값(actual value)이 아니라 방출 된 값의 개수(count of emitted values)를 나타낸다.

    Note: 실제 관찰 가능한 도표(diagram)를 보는 것만으로도, 처음에는 혼란(confuse)스러울 수 있다. 정적(static) 타임 라인(timelines)은 일반적으로 왼쪽으로 값(values)이 정렬(aligned)되어 있다. 그러나 다시 생각해 보면, 지금 보고 있는 애니메이션 도표(animated diagrams)와 마찬가지로 최근(recent) 값일 수록 오른쪽에 위치한다.

     

    Collecting values

    특정 상황에서는 publisher가 지정된(specified) 간격(intervals)으로 내보내(emitted)는 값(values)을 수집(collect)해야 할 수도 있다. 이것은 유용한 버퍼링(buffering)의 한 형태이다. 예를 들어, 짧은 시간 동안 값(values)의 그룹(group)을 평균화(average)하고 이를 출력해야하는 경우가 있을 수 있다.

    하단의 Next 링크를 클릭(clicking)하거나, 프로젝트 탐색기(Project navigator) 또는 점프 바(jump bar)를 선택하여 Collect 페이지로 전환(switch)한다.

    앞선 예제와 같이, 몇 가지 상수(constants)를 정의하면서 시작한다:

    let valuesPerSecond = 1.0 //초당 내보내는 값의 수
    let collectTimeStride = 4 //수집 시간

    다음으로 publishers를 생성한다:

    let sourcePublisher = PassthroughSubject<Date, Never>() //source publisher를 생성한다.
    //timer로 게시되는 값을 내보내는 subject
    
    let collectedPublisher = sourcePublisher
        .collect(.byTime(DispatchQueue.main, .seconds(collectTimeStride)))
        //collectTimeStride 동안에 방출된 값을 수집한다.
        //지정된 scheduler(여기서는 DispatchQueue.main)에 array로 수집된 값의 그룹을 내보낸다.
    Note: 3장(chapter), "Transforming Operators"에서 간단한 숫자의 값을 그룹화(group)하는 collect 연산자(operator)에 대해 배웠던 것을 기억할 것이다. 위에서 사용한 collect는 값을 그룹화(grouping)하기 위한 전략(strategy)을 사용하며, 이 경우에서는 시간별로(by time) 그룹화 된다.

    delay 연산자(operator)와 마찬가지로, Timer를 사용하여 일정한(regular) 간격(intervals)으로 값을 내보낸(emit)다:

    let subscription = Timer
        .publish(every: 1.0 / valuesPerSecond, on: .main, in: .common)
        //메인 스레드에 초당 1개의 값을 내보내는 타이머를 생성한다.
        .autoconnect() //즉시 시작
        .subscribe(sourcePublisher) //구독
        //sourcePublisher에서 내보낼 값을 가져온다.

    다음으로 이전 예제와 같이 타임 라인(timeline) views를 만든다. 그런 다음 playground의 라이브 뷰(live view)를 source timeline과 수집 된 값(collected values)의 타임 라인(timeline)을 표시하는 수직(vertical) 스택(stack)으로 설정한다:

    let sourceTimeline = TimelineView(title: "Emitted values:")
    //timer에서 내보내는 값을 표시할 TimelineView를 생성한다.
    //TimelineView는 SwiftUI view로, Sources/Views.swift 에서 해당 코드를 확인할 수 있다.
    let collectedTimeline = TimelineView(title: "Collected values (every \(collectTimeStride)s):")
    //수집된 값을 표시할 TimelineView를 생성한다.
    
    let view = VStack(spacing: 40) { //SwiftUI의 vertical stack을 생성한다.
        sourceTimeline
        collectedTimeline
    }
    
    PlaygroundPage.current.liveView = UIHostingController(rootView: 
    view.frame(width: 375, height: 600))
    //playground page의 liveView를 설정한다.

     

    마지막으로 두 publishers의 이벤트(events)를 타임 라인(timelines)에 제공(feed)한다:

    sourcePublisher.displayEvents(in: sourceTimeline)
    collectedPublisher.displayEvents(in: collectedTimeline)
    //내보낸 값을 표시한다.

    라이브 뷰(live view)를 확인한다:

     

    Emitted values 타임 라인(timeline)에 일정한(regular) 간격(intervals)으로 방출(emitted)된 값(values)이 표시된다. 그 아래의 Collected values 타임 라인(timeline)에는 4초 마다 단일 값(value)이 표시된다.

    지난 4초 동안 수신된 값이 배열(array)이라고 추측했을 것이다. 코드를 개선(improve)하여 실제로 값이 무엇인지 확인할 수 있다. collectPublisher 객체(object)를 작성한 행(line)으로 돌아가, 바로 아래에 flatMap 연산자(operator)를 추가한다:

    let collectedPublisher = sourcePublisher
        .collect(.byTime(DispatchQueue.main, .seconds(collectTimeStride)))
        //collectTimeStride 동안에 방출된 값을 수집한다.
        //지정된 scheduler(여기서는 DispatchQueue.main)에 array로 수집된 값의 그룹을 내보낸다.
        .flatMap { dates in dates.publisher } //출력이 배열임을 확인하기 위해 추가한다.

    여기서 3장(chapter), "Transforming Operators"에서 학습한 flatMap을 유용하게 사용한다. collect가 수집한(collected) 값의 그룹(group of values)을 내보낼(emits)때마다, flatMap이 이를 다시 개별(individual) 값(values)으로 분해해(breaks it down) 차례대로 즉시(immediately one after the other) 방출(emitted)한다. 이를 위해 일련의 값(sequence of values)을 Publisher로 바꾸는 Collectionpublisher 확장(extension)을 사용하여, 시퀀스(sequence)의 모든 값(all values)을 개별 값(individual values)으로 즉시(immediately) 내보낸(emitting)다.

    타임 라인(timeline)에 미치는 영향을 확인해 본다:

    이제 4초 마다 collect가 시간 간격(interval)동안 수집(collected)된 값의 배열(array of values)을 내보내(emits)는 것을 알 수 있다.

     

    Collecting values (part 2)

    collect(_:options:) 연산자(operator)가 제공하는 두 번째 옵션(option)을 사용하면, 일정한(regular) 간격(intervals)으로 값(values)을 수집(collecting)할 수 있다. 또한 수집(collected)된 값의 개수를 제한(limit)할 수도 있다.

    동일한 Collect 페이지(page)에서, 상단의 collectTimeStride 바로 아래에 새로운 상수(constant)를 추가한다:

    let collectMaxCount = 2 //수집 값의 최대 개수

    그리고 collectPublisher 선언 이후에 새로운 publisher를 생성한다:

    let collectedPublisher2 = sourcePublisher
        .collect(.byTimeOrCount(DispatchQueue.main, .seconds(collectTimeStride), collectMaxCount))
        //collectTimeStride 동안에 방출된 값을 수집한다.
        //지정된 scheduler(여기서는 DispatchQueue.main)에 array로 수집된 값의 그룹을 내보낸다.
        //collectMaxCount 개수만큼 값을 받는다.
        .flatMap { dates in dates.publisher } //출력이 배열임을 확인하기 위해 추가한다.

    이번에는 .byTimeOrCount(Context, Context.SchedulerTimeType.Stride, Int) 변형(variant)을 사용하여 한 번에 최대 collectMaxCount 개수까지 수집(collect)한다.

    capturedTimelinelet view = VStack ...사이에 두 번째 수집(collect) publisher에 대한 새로운 TimelineView를 추가한다:

    let collectedTimeline2 = TimelineView(title: "Collected values (at most \(collectMaxCount) every \(collectTimeStride)s):")
    //수집된 값을 표시할 TimelineView를 생성한다.

    그리고 스택 뷰의 목록(list)에 추가한다. 따라서 view는 다음과 같다:

    let view = VStack(spacing: 40) { //SwiftUI의 vertical stack을 생성한다.
        sourceTimeline
        collectedTimeline
        collectedTimeline2
    }

    마지막으로, playground 끝에 다음을 추가하여, 타임 라인(timeline)에서 발생하는 이벤트가 표시되는지 확인한다:

    collectedPublisher2.displayEvents(in: collectedTimeline2)
    //내보낸 값을 표시한다.

    이제 타임 라인(timeline)을 실행하여 차이점을 확인해 본다:

     

    두 번째 타임 라인(timeline)이 collectMaxCount 상수(constant)로 지정한대로, 한 번에 두 개의 값으로 콜렉션(collection)을 제한(limiting)하고 있음을 알 수 있다. 알아두면 유용한 연산자이다.

     

    Holding off on events

    사용자 인터페이스(user interfaces)를 코딩할 때 텍스트 필드(text fields)를 자주 다루게 된다. Combine을 사용하여 텍스트 필드(text field)의 내용(contents)을 동작에 연결(wiring up)하는 것은 일반적인 작업이다. 예를 들어, 텍스트 필드(text field)에 입력한 내용과 일치하는 항목 목록(list)을 반환(returns)하는 검색(search) URL 요청(request)을 보낼(send) 수 있다.

    하지만 사용자가 한 글자(single letter)를 입력할 때마다 요청(request)을 내보내(send)는 것을 원하지는 않을 것이다. 사용자가 입력을 완료(done)한 경우에만, 해당 텍스트를 선택하는 메커니즘(mechanism)이 필요하다.

    Combine은 이런 경우에 도움을 줄 수 있는 두 가지 연산자(operators)인 debouncethrottle을 제공한다.

     

    Debounce

    playground 페이지(page)를 Debounce로 전환(switch)한다. 디버그 영역(Debug area)이 확장(expanded)되어 있는지 확인한다. View ▸ Debug Area ▸ Activate Console. 디버그 영역에서 debounce에서 방출(emits)되는 값의 출력(printouts)을 볼 수 있다.

    두 개의 publishers를 만드는 것부터 시작한다:

    let subject = PassthroughSubject<String, Never>()
    //문자열을 내보내는 source publisher를 생성한다.
    
    let debounced = subject
        .debounce(for: .seconds(1.0), scheduler: DispatchQueue.main)
        //debounce를 사용하여 subject에서 나오는 값에 대해 1초간 대기한다.
        //그런 다음, 1초 간격으로 방출된 마지막 값을 내보낸다.
        //다시 말해 초당 최대 1개의 값을 내보내게 된다.
        .share()
        //debounced를 여러번 구독할 것이다.
        //결과의 일관성 보장을 위해 share()을 사용한다.
        //모든 구독자에게 동시에 동일한 결과를 보여주는 단일 구독 지점을 debounce 한다.
    Note: share() 연산자(operator)에 대해 더 자세하게 설명하는 것은 이 장(chapter)의 범위(scope)를 벗어난다. 여기서는 동일한 결과를 여러(multiple) subscribers에게 제공해야하는 publisher에 대한 단일(single) 구독(subscription)이 필요한 경우 유용하다는 점만 기억하면 된다. share()에 대한 자세한 내용은 13장(chapter),“Resource Management”를 참고한다.

    다음 몇 가지 예제에서는 일련(set)의 데이터를 사용하여 텍스트 필드(text field)의 사용자 입력을 시뮬레이션(simulate)한다. Sources/Data.swift에 구현되어 있는 다음 코드를 확인해 본다:

    public let typingHelloWorld: [(TimeInterval, String)] = [
        (0.0, "H"),
        (0.1, "He"),
        (0.2, "Hel"),
        (0.3, "Hell"),
        (0.5, "Hello"),
        (0.6, "Hello "),
        (2.0, "Hello W"),
        (2.1, "Hello Wo"),
        (2.2, "Hello Wor"),
        (2.4, "Hello Worl"),
        (2.5, "Hello World")
    ]

    시뮬레이션(simulated)의 사용자는 0.0초에 입력(typing)을 시작하고, 0.6초 후에 일시 중지(pauses)하며 2.0초 부터 다시 입력(typing)을 재개(resumes)한다.

    Note: 디버그(Debug) 영역(area)에 표시되는 시간 값은 1/10초에서 2/10초 정도로 생각할 수 있다(offset). DispatchQueue.asyncAfter()를 사용하여 기본(main) 대기열(queue)에서 값을 내보내므로(emitting), 값 사이의 최소 시간 간격(minimum time interval)이 보장되지만 요청(requested)과 정확히 일치하지 않을 수 있다.

    playground의 Debounce 페이지(page)에서, 이벤트(events)를 시각화(visualize)할 두 개의 타임 라인(timelines)을 작성하여 두 publishers에 연결(wire them up)한다:

    let subjectTimeline = TimelineView(title: "Emitted values")
    let debouncedTimeline = TimelineView(title: "Debounced values")
    //timer에서 내보내는 값을 표시할 TimelineView를 생성한다.
    //TimelineView는 SwiftUI view로, Sources/Views.swift 에서 해당 코드를 확인할 수 있다.
    
    let view = VStack(spacing: 100) { //SwiftUI의 vertical stack을 생성한다.
        subjectTimeline
        debouncedTimeline
    }
    
    PlaygroundPage.current.liveView = UIHostingController(rootView: 
    view.frame(width: 375, height: 600))
    //playground page의 liveView를 설정한다.
    
    subject.displayEvents(in: subjectTimeline)
    debounced.displayEvents(in: debouncedTimeline)
    //내보낸 값을 표시한다.

    이제 화면에 타임 라인(timelines)을 쌓고(stack), 이벤트(event)를 표시하기 위해 publishers에게 연결(connect)하는 이 playground 구조(structure)에 익숙해졌을 것이다.

    이번에는 더 많은 구현을 해야 한다. 각 publisher가 내보낸(emitted) 값(values)과 시작 이후에 publisher가 나타나는(show up) 시간을 출력해야한다. 이렇게하면 무슨 일이 일어나고 있는지 알아내는(figure out) 데 도움이 될 것이다.

    코드를 추가한다:

    let subscription1 = subject
        .sink { string in
            print("+\(deltaTime)s: Subject emitted: \(string)")
            //시간과 값 출력
        }
    
    let subscription2 = debounced
        .sink { string in
            print("+\(deltaTime)s: Debounced emitted: \(string)")
            //시간과 값 출력
        }

    각 구독(subscription)은 시작 이후, 수신(receives)한 값(values)과 시간을 출력한다. deltaTimeSources/DeltaTime.swift에 정의된 동적 전역 변수(dynamic global variable)로, playground를 실행한 이후의 시간 차이를 형식화(formats)한다.

    이제 subject에 데이터를 제공(feed)해야 한다. 이번에는 사용자가 텍스트를 입력하는 것을 시뮬레이션(simulates)하는 미리 작성된 데이터 소스(pre-made data source)를 사용한다. Sources/Data.swift에 정의되어 있으며, 원하는 대로 수정(modify)할 수 있다. 이를 살펴보면 "Hello World"라는 단어를 입력(typing)하는 사용자의 시뮬레이션(simulation)임을 알 수 있다.

    playground의 페이지(page) 끝에 코드를 추가한다:

    subject.feed(with: typingHelloWorld)
    //데이터 세트를 가져와 사전에 정의된 간격으로 subject에 데이터를 내보낸다.

    feed(with:) 메서드(method)는 데이터 집합(data set)을 가져와 사전 정의된 시간 간격(pre-defined time intervals)으로 주어진 subject에 데이터를 보낸다. 시뮬레이션(simulations) 및 목업(mocking) 데이터 입력을 편리하게 구현할 수 있다. 테스트 코드를 작성할 때 유용하게 사용할 수 있다.

    결과는 다음과 같다:

     

    상단(top)에 방출된(emitted) 값(values)이 표시되며, 총 11개의 문자열(strings)이 sourcePublisher로 내보내(pushed)진다. 사용자가 두 단어 사이에서 잠시 멈췄다(paused)는 것을 알 수 있다. 이는 debounce가 캡처(captured)한 입력(input)을 내보낸 시점이다.

    출력이 표시되는 디버그(debug) 영역(area)을 확인해 본다:

    +0.0s: Subject emitted: H
    +0.1s: Subject emitted: He
    +0.2s: Subject emitted: Hel
    +0.3s: Subject emitted: Hell
    +0.5s: Subject emitted: Hello
    +0.6s: Subject emitted: Hello
    +1.6s: Debounced emitted: Hello
    +2.1s: Subject emitted: Hello W
    +2.1s: Subject emitted: Hello Wo
    +2.4s: Subject emitted: Hello Wor
    +2.4s: Subject emitted: Hello Worl
    +2.7s: Subject emitted: Hello World
    +3.7s: Debounced emitted: Hello World

    보다시피, 0.6초에 사용자는 일시 정지(pauses)한 후 2.1초에 입력(typing)을 재개(resumes)한다. 한편, 1초간 일시 정지(pause)를 기다리도록(wait) debounce를 구성했다. 따라서 1.6 초에 최근 수신(received) 값(value)을 내보낸다.

    같은 방식으로 2.7초에 입력(typing)이 끝나고, 1초 후 3.7초에 debounce가 시작(kicks)된다.

    Note: 한 가지 주의해야 할 것은 publisher의 완료(completion)이다. 마지막 값(last value)을 내보낸(emitted) 직후 publisher가 완료(completes)되지만, debounce에 지정된 시간이 경과(elapses)하기 전에 publisher가 완료(completes)되는 경우에는 debounced된 publisher의 마지막 값(last value)이 표시되지 않는다.

     

    Throttle

    debounce와 같은 보류 유형(holding-off pattern)은 매우 유용하며, Combine은 비슷한(relative) throttle(for:scheduler:latest:)를 제공한다. debounce와 매우 유사하지만, 명확한 차이점(differences)이 있기에 두 연산자(operators)가 모두 필요하다.

    playground의 Throttle 페이지(page)로 전환(switch)한다. 이전과 같이 상수(constant)가 필요하다:

    let throttleDelay = 1.0 //지연 시간
    
    let subject = PassthroughSubject<String, Never>()
    //문자열을 내보내는 source publisher를 생성한다.
    
    let throttled = subject
        .throttle(for: .seconds(throttleDelay), scheduler: DispatchQueue.main, latest: false)
        //latest를 false로 설정했기 때문에 1초(throttleDelay) 간격으로, 수신된 첫 번째 값만 방출한다.
        .share()
        //debounce와 마찬가지로, share()를 사용하면 모든 subscribers가 동일한 출력을 동시에 확인할 수 있다.

    이벤트(events)를 시각화(visualize)하는 두 개의 타임 라인(timelines)을 만들고, 두 publishers에 연결(wire them up)한다:

    let subjectTimeline = TimelineView(title: "Emitted values")
    let throttledTimeline = TimelineView(title: "Throttled values")
    //timer에서 내보내는 값을 표시할 TimelineView를 생성한다.
    //TimelineView는 SwiftUI view로, Sources/Views.swift 에서 해당 코드를 확인할 수 있다.
    
    let view = VStack(spacing: 100) { //SwiftUI의 vertical stack을 생성한다.
        subjectTimeline
        throttledTimeline
    }
    
    PlaygroundPage.current.liveView = UIHostingController(rootView: 
    view.frame(width: 375, height: 600))
    //playground page의 liveView를 설정한다.
    
    subject.displayEvents(in: subjectTimeline)
    throttled.displayEvents(in: throttledTimeline)
    //내보낸 값을 표시한다.

    이제 진행 상황(what’s going on)을 이해하기 위해, 각 publisher이 내보낸(emits) 값(values)을 출력(print)하는 코드를 추가한다:

    let subscription1 = subject
        .sink { string in
            print("+\(deltaTime)s: Subject emitted: \(string)")
            //시간과 값 출력
        }
    
    let subscription2 = throttled
        .sink { string in
            print("+\(deltaTime)s: Throttled emitted: \(string)")
            //시간과 값 출력
        }

    소스(source) publisher에게 시뮬레이션(simulated)된 "Hello World" 사용자 입력(user input)을 제공한다. playground 페이지(page)에 다음을 추가한다:

    subject.feed(with: typingHelloWorld)
    //데이터 세트를 가져와 사전에 정의된 간격으로 subject에 데이터를 내보낸다.

    라이브 뷰(live view)에서 무슨 일이 일어나고 있는지 확인할 수 있다:

    참고: https://eunjin3786.tistory.com/80

     

    이전 debounce 출력과 크게 다르지 않은 것 같지만 몇 가지 차이가 있다.

    첫 번째로, 두 개를 비교해 자세히 살펴보면 throttle에서 방출(emitted)되는 값(values)의 타이밍(timing)이 약간 다르다는 것을 알 수 있다.

    두 번째로, 현재 상황을 자세히 확인하려면 디버그(debug) 콘솔(console)을 확인한다:

    +0.0s: Subject emitted: H
    +0.0s: Throttled emitted: H
    +0.1s: Subject emitted: He
    +0.2s: Subject emitted: Hel
    +0.3s: Subject emitted: Hell
    +0.5s: Subject emitted: Hello
    +0.6s: Subject emitted: Hello
    +1.0s: Throttled emitted: He
    +2.2s: Subject emitted: Hello W
    +2.2s: Subject emitted: Hello Wo
    +2.2s: Subject emitted: Hello Wor
    +2.4s: Subject emitted: Hello Worl
    +2.7s: Subject emitted: Hello World
    +3.0s: Throttled emitted: Hello W

    이것은 분명히 debounce와 다르다. 여기 몇 가지 흥미로운 결과를 확인할 수 있다:

    • subject가 첫 번째 값(value)을 내보내(emits)면, throttle은 즉시 이를 전달(relays)한다. 그런 다음 출력(output)을 조절(throttling)한다.
    • 1.0초에서 throttle"He"를 방출(emits)한다. 1초 후에 첫 번째 값(마지막 이후)을 보내도록 요청(asked)했기 때문이다(throttleDelay).
    • 2.2초에 입력(typing)이 다시 시작(resumes)된다. 이때 throttle은 아무것도 방출(emit)하지 않았음을 알 수 있다. 이는 소스(source) publisher부터 새로운 값(value)을 받지(received) 못했기 때문이다.
    • 3.0초에 입력(typing)이 완료(completes)된 후 throttle이 다시 시작(kicks in)되고, 첫 번째 값(value), 즉 2.2초 때의 값(value)을 다시 출력(outputs)한다.

    여기서 debouncethrottle의 근본적인(fundamental) 차이점을 확인할 수 있다.

    • debounce는 수신한(receives) 값(values)이 일시 정지(pause)될 때까지 기다린(waits) 다음, 지정된 시간(specified interval) 후에 최신(latest) 값을 내보낸다(emits).
    • throttle은 지정한 시간(specified interval)을 기다렸다가(waits), 해당 시간 동안 받은 값 중 첫 번째(first) 또는 최신(latest) 값을 내보낸다(emits). 일시 정지(pauses) 여부는 신경 쓰지 않는다.

    latest 매개변수를 true로 변경하면 어떻게되는지 확인하기 위해, publisher를 다음과 같이 변경한다:

    let throttled = subject
        .throttle(for: .seconds(throttleDelay), scheduler: DispatchQueue.main, latest: true)
        //latest를 true로 설정했기 때문에 1초(throttleDelay) 간격으로, 수신된 마지막 값만 방출한다.
        .share()
        //debounce와 마찬가지로, share()를 사용하면 모든 subscribers가 동일한 출력을 동시에 확인할 수 있다.

    디버그 영역에서 출력 결과를 확인한다:

    +0.0s: Subject emitted: H
    +0.0s: Throttled emitted: H
    +0.1s: Subject emitted: He
    +0.2s: Subject emitted: Hel
    +0.3s: Subject emitted: Hell
    +0.5s: Subject emitted: Hello
    +0.6s: Subject emitted: Hello
    +1.0s: Throttled emitted: Hello
    +2.0s: Subject emitted: Hello W
    +2.3s: Subject emitted: Hello Wo
    +2.3s: Subject emitted: Hello Wor
    +2.6s: Subject emitted: Hello Worl
    +2.6s: Subject emitted: Hello World
    +3.0s: Throttled emitted: Hello World

    throttle의 출력(output)은 정확히(precisely) 1.0초와 3.0초에 발생(occurs)하며, 첫 번째(the earliest) 값이 아닌 가장 최근(the latest)의 값을 출력한다. 이전 예제의 debounce 출력(output)과 비교(compare)해 보면 다음과 같다:

    ...
    +1.6s: Debounced emitted: Hello
    ...
    +3.7s: Debounced emitted: Hello World

    출력(output)은 동일하지만, 일시 정지(pause)시 debounce가 지연(delayed)된다.

     

    Timing out

    다음 시간 조작 연산자(time manipulation operators)는 timeout 이다. 주 사용 목적은 시간 초과(timeout) 조건(condition)으로부터 실제 타이머(timer)를 의미적(semantically)으로 구별(distinguish)하는 것이다. 따라서 timeout 연산자(operator)가 실행될 때, publisher를 완료(completes)하거나 사용자가 지정한 오류(error)를 내보(emits)낸다. 두 경우 모두 publisher가 종료(terminates)된다.

    Timeout playground 페이지(page)로 전환(switch)하고, 아래 코드를 추가한다:

    let subject = PassthroughSubject<Void, Never>()
    
    let timedOutSubject = subject.timeout(.seconds(5), scheduler: DispatchQueue.main)
    //timedOutSubject는 업 스트림 publisher가 5초간 어떠한 값도 내보내지 않으면, timeout 된다.
    //이러한 timeout 형식은 publisher가 실패없이 완료되도록 강제된다.

    이제, 이벤트(events)를 작동시키는(trigger) 버튼(button)과 타임 라인(timeline)을 추가해야 한다:

    let timeline = TimelineView(title: "Button taps")
    
    let view = VStack(spacing: 100) { //SwiftUI의 vertical stack을 생성한다.
        Button(action: { subject.send() }) { //SwiftUI로 Button을 생성한다.
            //버튼을 누르면, subject로 새 값을 보낸다.
            //버튼을 누를 때마다 action 클로저가 실행된다.
            Text("Press me within 5 seconds")
            //버튼에 Text를 추가한다.
        }
        timeline
    }
    
    PlaygroundPage.current.liveView = UIHostingController(rootView: 
    view.frame(width: 375, height: 600))
    //playground page의 liveView를 설정한다.
    
    timedOutSubject.displayEvents(in: timeline)
    //내보낸 값을 표시한다.
    Note: Void 값(values)을 내보내(emits)는 subject를 사용하고 있다. 여기서의 이 사용은 완벽하게 적합(legitimate)하다. 어떤 일이 발생했음(happened)을 나타내지만 전달(carry)할 특별한(particular) 값(value)은 없을 때, 단순히 Void를 값(value) 유형(type)을 사용한다. 이는 매우 흔한(common) 경우라, Output 유형(type)이 Void인 경우 매개 변수(parameter)를 사용하지 않는 Subjectsend() 함수(function) 확장(extension)이 있다. 이렇게 하면 subject.send(()) 와 같은 어색한 구문을 쓸 필요가 없다.

    이제 playground 페이지(page)에서 실행(run)되는 것을 지켜보기만 하고, 아무것도 하지 않는다. timeout은 5초 후에 작동(trigger)되고 publisher를 완료(complete)한다.

    이제 다시 실행(run)한다. 이번에는 5초 미만의 간격(intervals)으로 버튼(button)을 계속 누른다. timeout이 시작(kick in)되지 않기 때문에 publisher는 결코 완료(completes)되지 않는다.

     

    물론 대부분의 경우에 publisher가 단순히 완료(completion)되는 것이 원하는 결과는 아닐 것이다. 대신 이 경우에 실패(failure)를 내보내(send)려면 timeout publisher가 있어야, 정확(accurately)하게 조치(action)를 할 수 있다.

    playground 페이지(page)의 상단으로 이동하여, 원하는 오류(error) 유형(type)을 정의한다:

    enum TimeoutError: Error { //사용자 정의 오류 타입
        case timedOut
    }

    다음으로, subject의 정의(definition)를 수정하여 오류(error) 유형(type)을 Never에서 TimeoutError로 변경한다. 코드는 다음과 같아야한다:

    let subject = PassthroughSubject<Void, TimeoutError>()

    이제 timeout에 대한 호출(call)을 수정(modify)해야 한다. 이 연산자(operator)의 완전한(complete) 시그니처(signature)는 timeout(_:scheduler:options:customError:)이다. 여기에 사용자 정의(custom) 오류(error) 유형(type)을 제공(provide)할 수 있다.

    timedOutSubject를 생성하는 행(line)을 수정(modify)한다:

    let timedOutSubject = subject.timeout(.seconds(5), scheduler: DispatchQueue.main, customError: { .timedOut })
    //timeout(_:scheduler:options:customError:)
    //사용자 지정 오류형을 추가해 준다.

    이제 playground를 실행(run)하고 5초 동안 버튼(button)을 누르지 않으면, timedOutSubject에서 오류(failure)가 발생(emits)한다는 것을 알 수 있다.

     

    Measuring time

    마지막으로 시간을 조작(manipulate)하지 않고, 단지 측정(measures)하는 연산자(operator)를 살펴 본다. measureInterval(using:) 연산자(operator)는 publisher가 내보낸(emitted) 두 연속(consecutive) 값(values) 사이에 경과한(elapsed) 시간을 알아내야 할 때 사용한다.

    MeasureInterval playground 페이지(page)로 전환(switch)하고, 두 개의 publishers를 생성한다:

    let subject = PassthroughSubject<String, Never>()
    
    let measureSubject = subject.measureInterval(using: DispatchQueue.main)
    //지정한 스케쥴러에서 측정 값을 내보낸다. 여기서는 메인 큐를 사용한다.

    이제 평소처럼 타임 라인(timelines)을 추가한다:

    let subjectTimeline = TimelineView(title: "Emitted values")
    let measureTimeline = TimelineView(title: "Measured values")
    
    let view = VStack(spacing: 100) {
        subjectTimeline
        measureTimeline
    }
    
    PlaygroundPage.current.liveView = UIHostingController(rootView: 
    view.frame(width: 375, height: 600))
    //playground page의 liveView를 설정한다.
    
    subject.displayEvents(in: subjectTimeline)
    measureSubject.displayEvents(in: measureTimeline)
    //내보낸 값을 표시한다.

    마지막으로 흥미로운 부분을 추가한다. 두 publishers가 내보낸(emitted) 값을 출력한 다음 subject에 제공(feed)한다:

    let subscription1 = subject.sink {
        print("+\(deltaTime)s: Subject emitted: \($0)")
    }
    
    let subscription2 = measureSubject.sink { //DispatchQueue 스케쥴러 구독
        print("+\(deltaTime)s: Measure emitted: \($0)")
    }
    
    subject.feed(with: typingHelloWorld)
    //데이터 세트를 가져와 사전에 정의된 간격으로 subject에 데이터를 내보낸다.

    playground를 실행(run)하고 디버그(debug) 영역(area)을 살펴본다. 여기서 measureInterval(using:)이 무엇을 방출(emits)하는지 볼 수 있다 :

    +0.0s: Subject emitted: H
    +0.0s: Measure emitted: Stride(magnitude: 16818353) +0.1s: Subject emitted: He
    +0.1s: Measure emitted: Stride(magnitude: 87377323) +0.2s: Subject emitted: Hel
    +0.2s: Measure emitted: Stride(magnitude: 111515697) +0.3s: Subject emitted: Hell
    +0.3s: Measure emitted: Stride(magnitude: 105128640) +0.5s: Subject emitted: Hello
    +0.5s: Measure emitted: Stride(magnitude: 228804831) +0.6s: Subject emitted: Hello
    +0.6s: Measure emitted: Stride(magnitude: 104349343) +2.2s: Subject emitted: Hello W
    +2.2s: Measure emitted: Stride(magnitude: 1533804859) +2.2s: Subject emitted: Hello Wo
    +2.2s: Measure emitted: Stride(magnitude: 154602) +2.4s: Subject emitted: Hello Wor
    +2.4s: Measure emitted: Stride(magnitude: 228888306) +2.4s: Subject emitted: Hello Worl
    +2.4s: Measure emitted: Stride(magnitude: 138241) +2.7s: Subject emitted: Hello World
    +2.7s: Measure emitted: Stride(magnitude: 333195273)

    값(values)이 조금 이해하기 힘들어(puzzling) 보인다. 문서(documentation)에 따르면 measureInterval에서 방출(emits)된 값(value)의 유형(type)은 "제공된 스케줄러의 시간 간격(the time interval of the provided scheduler)"이다. DispatchQueue의 경우, TimeInterval은 "나노초 단위로 생성된 이 유형의 DispatchTimeInterval(A DispatchTimeInterval created with the value of this type in nanoseconds)"으로 정의(defined)된다.

    여기에서 출력되는 값은 소스(source) subject로부터 수신(received)된 각 연속 값 사이의(between each consecutive value) 시간(count, 나노초)이다. 여기서 값(values)을 더 읽기 쉽게(readable) 표시하도록 수정할 수 있다. measureSubject에서 값(values)을 출력(prints)하는 코드를 다음과 같이 수정(modify)한다:

    let subscription2 = measureSubject.sink { //DispatchQueue 스케쥴러 구독
        print("+\(deltaTime)s: Measure emitted: \(Double($0.magnitude) / 1_000_000_000.0)")
        //좀 더 읽기 쉽도록 출력을 변경한다.
    }

    이제 초 단위(in seconds)로 출력되는 값(values)을 볼 수 있다.

    하지만 다른 스케줄러(scheduler)를 사용하면 결과가 달라질 수 있다. 여기서는 DispatchQueue 대신 RunLoop를 사용해 본다.

    Note: 17장(chapter), "Schedulers"에서 RunLoopDispatchQueue 스케줄러(schedulers)를 자세히 살펴볼 것이다.

    파일의 맨 위로 돌아가, RunLoop를 사용하는 두 번째 subject를 생성한다:

    let measureSubject2 = subject.measureInterval(using: RunLoop.main)
    //RunLoop 스케쥴러를 사용한다.

    디버그(debug) 출력(output)에만 관심이 있기 때문에, 새로운 타임 라인(timeline) 뷰(view)를 연결(wiring up)할 필요는 없다. 이 세 번째 구독(subscription)을 코드에 추가한다:

    let subscription3 = measureSubject.sink { //RunLoop 스케쥴러 구독
        print("+\(deltaTime)s: Measure2 emitted: \($0)")
    }

    이제 RunLoop 스케줄러(scheduler)의 출력(output)도 초 단위(in seconds)로 직접 표시된다:

    +0.0s: Subject emitted: H
    +0.0s: Measure emitted: 0.016503769
    +0.0s: Measure2 emitted: Stride(magnitude: 0.015684008598327637)
    +0.1s: Subject emitted: He
    +0.1s: Measure emitted: 0.087991755
    +0.1s: Measure2 emitted: Stride(magnitude: 0.08793699741363525)
    +0.2s: Subject emitted: Hel
    +0.2s: Measure emitted: 0.115842671
    +0.2s: Measure2 emitted: Stride(magnitude: 0.11583995819091797)
    ...

    측정(measurement)에 사용하는 스케줄러(scheduler)는 개인 취향에 따라 결정된다. 모든 경우에 DispatchQueue를 사용하는 것이 일반적으로좋다. 하지만 그것 역시 개인적인 선택이다.

     

    Challenge

    Challenge: Data

    새로운 지식을 유용하게 활용하기 위한 과제(challenge)를 해 본다.

    projects/challenge 폴더(folder)에서 starter.playground 를 연다. 일부 코드가 이미 작성되어 있다:

    • 정수(integers)를 내보내(emits)는 subject.
    • subject에 알지 못하는(mysterious) 데이터를 제공(feeds)하는 함수(function) 호출(call).

    이러한 부분들 사이에서의 과제(challenge)는 다음과 같다:

    • 0.5초 단위(batches)로 데이터를 그룹화(group)한다.
    • 그룹화(grouped)된 데이터를 문자열(string)로 변환한다.
    • 제공(feed) 중에 0.9초 이상 일시 정지(pause)된 경우, 👏 이모지(emoji)를 출력한다. 힌트: 이 단계에서 두 번째 publisher를 만들고, 구독(subscription)의 첫 번째 publisher와 병합(merge)한다.
    • 출력한다.
    Note: IntCharacter로 변환(convert)하려면, Character(Unicode.Scalar(value)!) 와 같은 작업을 수행해야 한다.

    이 과제(challenge)를 올바르게 코딩하면, 디버그(Debug) 영역(area)에 문장(sentence)이 출력된다.

     

    Solution

    challenge/final 폴더(folder)에서 이 과제(challenge)의 해결책(solution)을 확인할 수 있다.

    코드는 다음과 같다:

    let strings = subject //string을 내보내는 subject에서 파생된 첫 번째 publisher를 생성한다.
        .collect(.byTime(DispatchQueue.main, .seconds(0.5)))
        //• 0.5초 단위(batches)로 데이터를 그룹화(group)한다.
        //0.5초 단위로 그룹화하려면, .byTime 전략의 collect()를 사용한다.
        .map { array in
            String(array.map { Character(Unicode.Scalar($0)!) })
            //각 정수 값을 유니코드 Scalar에 매핑한 다음 map을 사용하여 전체를 문자열로 변환한다.
        }
        //• 그룹화(grouped)된 데이터를 문자열(string)로 변환한다.
    
    let spaces = subject.measureInterval(using: DispatchQueue.main) //• 두 번째 publisher를 만든다.
        //각 문자 사이의 간격을 측정한다.
        .map { interval in //간격이 0.9초 보다 클 경우 👏 이모지를, 그렇지 않으면 빈 문자열을 매핑한다.
            interval > 0.9 ? "👏" : ""
        }
        //• 제공(feed) 중에 0.9초 이상 일시 정지(pause)된 경우, 👏 이모지(emoji)를 출력한다.
    
    let subscription = strings
        .merge(with: spaces) //• 구독(subscription)의 첫 번째 publisher와 병합(merge)한다.
        //최종 publisher는 strings와 👏 이모지를 병합한 것이다.
        .filter { !$0.isEmpty } //빈 문자열을 걸래낸다.
        .sink {
            print($0)
            //• 출력한다.
        }

    작성한 해결 방법(solution)이 위와 미묘하게 다를수도 있지만, 요구 사항(requirements)을 충족하기만 한다면 문제없다.

    playground를 실행하면, 콘솔(console)에 다음과 같은 출력이 인쇄된다:

    Combine
    👏
    is
    👏
    cool!

    Key points

    이 장(chapter)에서 시간을 다른 관점(angle)에서 바라보았다. 특히 다음과 같은 내용을 학습했다:

    • Combine의 비동기(asynchronous) 이벤트(events) 처리(handling)는 시간 자체(itself)를 조작(manipulating)하는 것으로 확장(extends)된다.
    • 시간 이동(time-traveling) 옵션(options)을 제공(provide)하지 않더라도, 프레임 워크(framework)에는 개별(discrete) 이벤트(events)를 처리(handling)하는 것이 아닌 장기간에 걸쳐(over long periods of time) 작업을 추상화(abstract)할 수 있는 연산자(operators)가 있다.
    • delay 연산자(operator)를 사용하여 시간을 이동(shifted)할 수 있다.
    • 댐(dam)처럼 시간에 따른(over time) 값의 흐름(flow)을 관리(manage)하고, collect를 사용하여 덩어리(chunks) 별로 해제(release)할 수 있다.
    • debouncethrottle을 사용해, 시간의 따른(over time) 개별(individual) 값(values)을 쉽게 선택할 수 있다.
    • 시간이 초과(run out)되지 않도록 하는 것이 timeout 작업이다.
    • measureInterval로 시간을 측정(measured)할 수 있다.
Designed by Tistory.