VesselWheel

[트러블 슈팅] [런 잇] 디바운스(Debounce)와 Combine을 활용한 Throttle 적용하기 그리고 Caching을 통한 디버깅 본문

Xcode Study

[트러블 슈팅] [런 잇] 디바운스(Debounce)와 Combine을 활용한 Throttle 적용하기 그리고 Caching을 통한 디버깅

JasonYang 2024. 4. 1. 14:39

0. xcode intruments의 leaks를 활용한 디버깅 하기

더보기

startRequest:traits:auditToken:useBackgroundURL:requestPriority:callbackQueue:finished:networkActivity:error:]_block_invoke_2+0xc4"

 

디버깅 문구를 생자로 찾아내기엔 스택오버플로우나, 공식문서에서는 찾기 어려웠다.

그래서 Chat GPT의 도움을 받았다. 

이 문구는 프로그래밍 중에 발생한 오류나 버그를 찾아내기 위한 디버깅 과정에서 볼 수 있는 로그의 일부입니다. 구체적으로 설명하자면, 이 로그는 특정 함수나 메소드의 호출 스택 중 하나를 나타내며, 여기서는 어떤 작업을 시작하기 위한 요청(startRequest)이 이루어지는 과정에서 콜백 함수(_block_invoke_2)가 호출되었음을 의미합니다. +0xc4는 해당 함수 내에서 오류가 발생한 위치를 상대적인 주소로 나타낸 것입니다.

  • traits: 요청을 처리할 때 필요한 특성이나 속성을 의미합니다.
  • auditToken: 보안이나 인증과 관련된 토큰일 수 있으며, 요청이 유효한지 확인하는 데 사용될 수 있습니다.
  • useBackgroundURL: 요청이 백그라운드에서 처리되어야 하는지 여부를 나타냅니다.
  • requestPriority: 요청의 우선 순위를 지정합니다.
  • callbackQueue: 콜백 함수가 실행될 큐(queue)를 지정합니다.
  • finished: 요청이 완료되었을 때 실행될 콜백 함수를 의미합니다.
  • networkActivity: 네트워크 활동과 관련된 정보를 나타냅니다.
  • error: 오류가 발생했을 경우, 오류에 대한 정보를 담고 있습니다.

로그를 통해 어떤 네트워크 요청이 처리 과정 오류를 만났다는 것을 있습니다. 디버깅을 위해서는 +0xc4 위치에서 발생한 구체적 오류 내용을 조사해야 하며, 원인을 파악하여 적절한 수정을 진행해야 합니다.

기존에 콘솔창에도 나온 버그 문제를 leaks를 통해서 다시한번 되돌아보았다.

편의시설 버튼을 연속 탭했을 때 발생하는 버그, 편의시설 탐색 기능이 정상 작동하지 않는다.

1초 안에 50번 초과의 query 요청을 할 수 없다는 것으로 이것은 MapKit의 서버에서 query 요청수를 제한시켜 놓은 것으로, 만약 유료 지도 API를 사용했다면 비용 상승의 효과로 이어진다. 

따라서, 사용자의 비정상적인 사용일지라도 개발자는 해당 문제를 미리 식별하여 조치해야한다. 

해결책은 디바운스와 쓰로틀링, 그리고 캐싱 처리

 

 


디바운싱, Debounce 이란? 

  • 연속적으로 발생한 이벤트를 하나로 처리하는 방식이다.
  • 주로 처음이나 마지막으로 실행된 함수만을 실행한다.
  • 성능상의 문제를 위해 사용한다.

 

쓰로틀링, Throttling 이란?

  • 스로틀링은 출력을 조절한다는 의미로 이벤트를 일정주기마다 발생하도록 하는 기술
  • 100ms 를 준다면 이벤트는 100ms 동안 최대 한번만 발생하게 됨
  • 즉 마지막 함수가 호출된 후 일정시간이 지나기전에 다시 호출되지 않도록함
  • 연이어 발생한 이벤트에 대해, 일정한 delay 포함시켜 연속적으로 발생한 이벤트는 무시하는 방식을 뜻한다. 
  • , delay 시간동안 호출된 함수는 무시한다.

https://medium.com/@manikantasirumalla5/demystifying-debounce-and-throttle-in-combine-framework-75539c87b15e

 

Demystifying Debounce and Throttle in Combine Framework

Combine, Apple’s framework for handling asynchronous and event-driven code, provides a range of operators to manipulate and control the…

medium.com


1. 편의시설 버튼에 Debounce 적용하기 

현재 <런 잇> 앱에서 지도뷰의 우측 편의시설 탐색 버튼은 MKLocalSearch.Request를 통해 query를 요청하고 있다. 

더보기
    func getAnnotations(forQuery query: String, category: String) {
        closeModal()
        //TODO: 기존의 같은 카테고리의 어노테이션 제거
        let allAnnotations = self.mapView.annotations
        for annotation in allAnnotations {
            if let customAnnotation = annotation as? CustomAnnotation, customAnnotation.category != category {
                self.mapView.removeAnnotation(annotation)
            }
        }
        // mapView.overlays 배열에서 currentCircle를 제외하고 모두 제거
        let overlaysToRemove = mapView.overlays.filter { $0 !== currentCircle }
        mapView.removeOverlays(overlaysToRemove)

        //TODO: 사용자의 현재 위치를 가져오기
        guard let currentLocation = self.mapView.userLocation.location else {
            print("Failed to get user location")
            return
        }
        
        //TODO: MKLocalSearch.Request() 활용하여 자연어로 파라미터로 주입된 query를 검색을 정의하고, 지역을 사용자 현재 위치 기준 500m로 설정
        let searchRequest = MKLocalSearch.Request()
        searchRequest.naturalLanguageQuery = query
        searchRequest.region = MKCoordinateRegion(center: currentLocation.coordinate, latitudinalMeters: 500, longitudinalMeters: 500)
        
        //MKLocalSearch를 활용, 검색하여 mapItems을 응답값으로 호출
        let search = MKLocalSearch(request: searchRequest)
        search.start { [weak self] (response, error) in
            guard let response = response else {
                print("Search error: \(error?.localizedDescription ?? "Unknown error")")
                return
            }
            
            // 50미터 이내의 결과만 필터링
            let filteredMapItems = response.mapItems.filter { mapItem in
                let distance = currentLocation.distance(from: mapItem.placemark.location!)
                return distance <= 150
            }
            
            //검색 결과를 캐시에 저장
            self?.searchResultsCache[query] = filteredMapItems
            
            // 검색된 mapItems의 어노테이션을 추가
            self?.addAnnotationsToMap(mapItems: response.mapItems, category: category)
        }

    }
    
    private func addAnnotationsToMap(mapItems: [MKMapItem], category: String) {
        DispatchQueue.main.async {
            // 현재 맵에 있는 모든 어노테이션을 호출
            let existingAnnotations = self.mapView.annotations.compactMap { $0 as? CustomAnnotation }
            
            // 새로운 어노테이션을 추가하기 전에, 이미 존재하는 어노테이션인지 확인
            for item in mapItems {
                let newItemCoordinate = item.placemark.coordinate
                
                // 현재 맵에 동일한 위치의 어노테이션이 있는지 확인
                let isExisting = existingAnnotations.contains(where: { existingAnnotation in
                    existingAnnotation.coordinate.latitude == newItemCoordinate.latitude && existingAnnotation.coordinate.longitude == newItemCoordinate.longitude
                })
                
                // 동일한 위치의 어노테이션이 없을 경우에만 새 어노테이션을 추가
                if !isExisting {
                    let annotation = CustomAnnotation()
                    annotation.coordinate = newItemCoordinate
                    annotation.title = item.name
                    annotation.mapItem = item
                    annotation.category = category
                    self.mapView.addAnnotation(annotation)
                }
            }
        }
    }

 

getAnnotation 매소드를 통해 편의시설 버튼에서 편의시설을 검색하고 서버에서 요청한다. 

    @objc func presenthealthyEatingOptionsAnnotations() {
        generator.impactOccurred()
        
        annotationsDebounceTimer?.cancel() // 이전에 예약된 작업이 있다면 취소
        annotationsDebounceTimer = DispatchWorkItem { [weak self] in
            let healthyEatingOptions = ["샐러디", "subway"]
            let category = "건강식"
            
            for healthyEatingOption in healthyEatingOptions {
                self?.getAnnotations(forQuery: healthyEatingOption, category: category)
            }
        }
        
        // 디바운스 타이머 예약. 여기서는 버튼을 누른 후 1초가 지나면 실행
        if let annotationsDebounceTimer = annotationsDebounceTimer {
            DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: annotationsDebounceTimer)
        }
    }

DispatchWorkItem 타입으로 선언하여  현재는 버튼 실행 시 debounce를 통해서 현재 시간 기준 1초가 지나면 실행되게 한다. 

1초 안으로 연속 탭한 경우는 실행되지 않도록 구현했다. 

    // 디바운스를 위한 변수 선언
    var annotationsDebounceTimer: DispatchWorkItem?

 

https://developer.apple.com/documentation/dispatch/dispatchworkitem

 

DispatchWorkItem | Apple Developer Documentation

The work you want to perform, encapsulated in a way that lets you attach a completion handle or execution dependencies.

developer.apple.com

 


 

2. Combine 프레임워크를 사용하여 버튼 클릭 이벤트에 대해 쓰로틀(throttle)을 적용하기

쓰로틀은 지정된 시간 동안에 첫 번째 이벤트만을 전달하고, 그 시간 동안에 발생하는 나머지 이벤트들을 무시하는 방식으로 작동한다.

이는 빠른 연속 클릭과 같은 상황에서 유용하게 사용될 수 있다.

import Combine
// 이벤트 발생을 위한 PassthroughSubject 선언
var convenienceStoreButtonPressed = PassthroughSubject<Void, Never>()

// 구독을 관리할 AnyCancellable 객체
var cancellables = Set<AnyCancellable>()


이벤트를 발생시키기 위한 `PassthroughSubject`와 구독을 관리할 `AnyCancellable` 객체를 선언

@objc func presentConvenienceStoreAnnotations() {
    // PassthroughSubject에 이벤트를 발생시킵니다.
    convenienceStoreButtonPressed.send()
}

func setupConvenienceStoreButtonThrottle() {
    convenienceStoreButtonPressed
        // 0.5초 동안의 쓰로틀 적용
        .throttle(for: .seconds(0.5), scheduler: RunLoop.main, latest: false)
        // 받은 이벤트에 대한 처리
        .sink { [weak self] _ in
            let convenienceStores = ["GS25", "CU", "세븐일레븐", "이마트24", "미니스톱"]
            let category = "편의점"

            for convenienceStore in convenienceStores {
                self?.getAnnotations(forQuery: convenienceStore, category: category)
            }
        }
        // 구독을 관리할 cancellables에 추가
        .store(in: &cancellables)
}

다음으로, `presentConvenienceStoreAnnotations` 함수가 호출될 때마다 `convenienceStoreButtonPressed`에 이벤트를 발생시키도록 수정하고, 쓰로틀을 적용하여 이벤트를 처리


`setupConvenienceStoreButtonThrottle` 함수는 앱이 시작할 때나 적절한 초기화 지점에서 호출되어야 한다. 

즉, setupConvenienceStoreButtonThrottle() 함수는 컴포넌트의 초기화 단계(: viewDidLoad에서)에서 번만 호출되어야 한다.

=>뷰가 시작될 때, setupConvenienceStoreButtonThrottle() 매소드로 이벤트를 발생시키고 나서, 

    @objc func presentConvenienceStoreAnnotations() {
        // PassthroughSubject에 이벤트를 발생시킵니다.
        print("Button pressed")
        convenienceStoreButtonPressed.send()
        
    }

presentConvenienceStoreAnnotations() 버튼을 누르면,

발생시켜놨던 이벤트인 convenienceStoreButtonPressed를 .send()해 놓으면, setupConvenienceStoreButtonThrottle()매소드에 있던 convenienceStoreButtonPressed의 .throttle -> .sink -> .store 단계로 실행된다.

             // 1초 동안의 쓰로틀 적용
            .throttle(for: .seconds(1), scheduler: RunLoop.main, latest: false)
            // 받은 이벤트에 대한 처리
            .sink { [weak self] _ in
                print("Throttle event received")
                let convenienceStores = ["GS25", "CU", "세븐일레븐", "이마트24", "미니스톱"]
                let category = "편의점"

                for convenienceStore in convenienceStores {
                    self?.getAnnotations(forQuery: convenienceStore, category: category)
                }
            }
            // 구독을 관리할 cancellables에 추가
            .store(in: &cancellables)

 

 

이 함수는 `convenienceStoreButtonPressed`에 의해 발생되는 이벤트를 쓰로틀링하며, 1초 동안 첫 번째 이벤트만을 처리

이 방식으로, 사용자가 버튼을 빠르게 여러 번 클릭하더라도, 지정된 시간 동안에 첫 번째 클릭만이 처리되어 불필요한 작업을 방지할 수 있다.

Combine을 사용한 쓰로틀링은 특히 비동기 이벤트 처리에 있어 강력한 도구가 될 수 있으며, UI 이벤트 처리를 더욱 효율적으로 만들어 준다.

 

 


쓰로틀과 디바운스의 차이 

 쓰로틀(Throttle)과 디바운스(Debounce)는 비슷한 문제를 해결하기 위한 두 가지 다른 접근 방식입니다. 둘 다 빠른 연속 이벤트 발생 시 이를 제어하기 위해 사용되지만, 작동 방식에는 차이가 있다.

- 쓰로틀(Throttle)은 지정된 시간 동안 첫 번째 이벤트를 전달하고, 그 시간 동안 발생하는 나머지 이벤트들을 무시한다. 

 

즉, 지정된 시간 간격으로 이벤트를 전달한다. 이 방식은 일정 시간 간격으로 이벤트를 처리해야 할 때 유용하다.

- 디바운스(Debounce)는 마지막 이벤트가 발생하고 지정된 시간이 지난 후에만 이벤트를 전달한다.

만약 지정된 시간 동안 새로운 이벤트가 발생하면 타이머가 리셋된다. 이 방식은 이벤트의 마지막 발생을 기준으로 처리를 하고 싶을 때 사용된다.

상기 코드에서 쓰로틀을 적용했다면, 이는 지정된 시간(0.5초) 동안 첫 번째 이벤트만 처리하고 나머지는 무시하기 때문에, 디바운스를 별도로 적용할 필요는 없다. 쓰로틀은 이미 연속적인 이벤트 발생을 효과적으로 제어하여, 함수가 너무 자주 호출되는 것을 방지한다.

따라서, 사용자의 경우처럼 특정 기능(예: 버튼 클릭 이벤트 처리)을 일정 시간 간격으로 제한하고 싶다면 쓰로틀을 사용하는 것이 적절하다. 

이는 불필요한 함수 호출을 줄이는 데 도움이 되어 애플리케이션의 성능을 개선할 수 있다.

https://developer.apple.com/documentation/combine/passthroughsubject

 

PassthroughSubject | Apple Developer Documentation

A subject that broadcasts elements to downstream subscribers.

developer.apple.com


3. 캐싱 처리

캐싱의 이점

  1. 속도 향상: 자주 사용되는 데이터를 메모리에 저장해 두면, 데이터를 빠르게 접근할 있어 애플리케이션의 반응 속도가 향상
  2. 네트워크 트래픽 감소: 네트워크를 통한 데이터 요청이 줄어들어, 네트워크 부하가 감소하고 데이터 사용량이 줄어든다.
더보기
    func getAnnotations(forQuery query: String, category: String) {
        closeModal()
        if let cachedResults = searchResultsCache[query] {
            // 캐시된 결과가 있으면 해당 결과를 사용하여 어노테이션을 추가
//            print("캐시된 결과: \(cachedResults)")
            addAnnotationsToMap(mapItems: cachedResults, category: category)
            return
        }
        
        //TODO: 기존의 같은 카테고리의 어노테이션 제거
        let allAnnotations = self.mapView.annotations
        for annotation in allAnnotations {
            if let customAnnotation = annotation as? CustomAnnotation, customAnnotation.category != category {
                self.mapView.removeAnnotation(annotation)
            }
        }
        // mapView.overlays 배열에서 currentCircle를 제외하고 모두 제거
        let overlaysToRemove = mapView.overlays.filter { $0 !== currentCircle }
        mapView.removeOverlays(overlaysToRemove)

        //TODO: 사용자의 현재 위치를 가져오기
        guard let currentLocation = self.mapView.userLocation.location else {
            print("Failed to get user location")
            return
        }
        
        //TODO: MKLocalSearch.Request() 활용하여 자연어로 파라미터로 주입된 query를 검색을 정의하고, 지역을 사용자 현재 위치 기준 500m로 설정
        let searchRequest = MKLocalSearch.Request()
        searchRequest.naturalLanguageQuery = query
        searchRequest.region = MKCoordinateRegion(center: currentLocation.coordinate, latitudinalMeters: 5000, longitudinalMeters: 5000)
        
        //MKLocalSearch를 활용, 검색하여 mapItems을 응답값으로 호출
        let search = MKLocalSearch(request: searchRequest)
        search.start { [weak self] (response, error) in
            guard let response = response else {
                print("Search error: \(error?.localizedDescription ?? "Unknown error")")
                return
            }
            
            // 50미터 이내의 결과만 필터링
            let filteredMapItems = response.mapItems.filter { mapItem in
                let distance = currentLocation.distance(from: mapItem.placemark.location!)
                return distance <= 5000
            }
            
            //검색 결과를 캐시에 저장
            self?.searchResultsCache[query] = filteredMapItems
            
            // 검색된 mapItems의 어노테이션을 추가
            self?.addAnnotationsToMap(mapItems: response.mapItems, category: category)
        }

    }
    
    private func addAnnotationsToMap(mapItems: [MKMapItem], category: String) {
        DispatchQueue.main.async {
            // 현재 맵에 있는 모든 어노테이션을 호출
            let existingAnnotations = self.mapView.annotations.compactMap { $0 as? CustomAnnotation }
            
            // 새로운 어노테이션을 추가하기 전에, 이미 존재하는 어노테이션인지 확인
            for item in mapItems {
                let newItemCoordinate = item.placemark.coordinate
                
                // 현재 맵에 동일한 위치의 어노테이션이 있는지 확인
                let isExisting = existingAnnotations.contains(where: { existingAnnotation in
                    existingAnnotation.coordinate.latitude == newItemCoordinate.latitude && existingAnnotation.coordinate.longitude == newItemCoordinate.longitude
                })
                
                // 동일한 위치의 어노테이션이 없을 경우에만 새 어노테이션을 추가
                if !isExisting {
                    let annotation = CustomAnnotation()
                    annotation.coordinate = newItemCoordinate
                    annotation.title = item.name
                    annotation.mapItem = item
                    annotation.category = category
                    self.mapView.addAnnotation(annotation)
                }
            }
        }
    }
    
    func closeModal() {
        if let currentModal = self.presentedViewController {
            currentModal.dismiss(animated: true)
        }
    }
    
    private func removeAnnotationsFromMap() {
        // mapView.annotations 배열에서 MKUserLocation 인스턴스를 제외하고 모두 제거
        let annotationsToRemove = mapView.annotations.filter { $0 !== mapView.userLocation }
        mapView.removeAnnotations(annotationsToRemove)
        
        // mapView.overlays 배열에서 currentCircle를 제외하고 모두 제거
        let overlaysToRemove = mapView.overlays.filter { $0 !== currentCircle }
        mapView.removeOverlays(overlaysToRemove)
    }

 

힙 영역과 스택 영역에 끼치는 영향

  • 스택 영역(Stack): 함수의 호출과 함께 할당되고, 지역 변수가 저장되는 영역입니다. 스택은 LIFO(Last In First Out) 방식으로 동작하며, 함수 호출이 끝나면 자동으로 메모리가 해제됩니다. 쓰로틀, 디바운스, 캐싱 기법 자체가 스택 영역에 직접적인 영향을 주는 것은 아닙니다.
  • 힙 영역(Heap): 동적으로 할당되는 메모리가 저장되는 영역입니다. 객체나 배열 같은 구조체는 힙에 저장됩니다. 캐싱에서는 주로 힙 영역을 사용하여 데이터를 저장합니다. 대량의 데이터를 캐싱할 경우 힙 메모리 사용량이 증가하게 됩니다. 메모리 관리가 중요한 이유는, 힙 메모리가 과도하게 사용되면 시스템의 성능 저하나 메모리 누수로 이어질 수 있기 때문입니다.
  • 쓰로틀과 디바운스는 주로 함수 호출 빈도를 조절하는 기법으로, 직접적으로 메모리 할당에 영향을 주지는 않지만, 결과적으로 리소스 사용량을 줄여 성능을 향상시키는 기여합니다. 

 

러닝타이머를 실행할 때 메모리 누수 발생
Leak Checks

 


https://opendoorlife.tistory.com/19

 

RxSwift :: 중복 클릭 방지를 위한 Throttle vs Debounce 차이와 개념, 사용법 알아보기 (iOS 개발)

보통 앱 화면에서 버튼을 누르면 API 호출이 되는 경우가 잦은데, 종종 다양한 이유로 API 통신이 느려져 유저가 버튼을 연타하는 경우가 생긴다. 마치... 긴박한 티켓팅 같은 상황일 때... 😡 ??? :

opendoorlife.tistory.com

RxSwift에서의 쓰로틀 참고자료

 

 


쓰로틀을 적용하면서 디버깅 하기 

https://meetup.nhncloud.com/posts/315

 

디버깅을 위한 Xcode 활용 방법 : NHN Cloud Meetup

디버깅을 위한 Xcode 활용 방법

meetup.nhncloud.com

https://help.apple.com/instruments/mac/current/#//apple_ref/doc/uid/TP40004652-CH3-SW1

 

https://help.apple.com/instruments/mac/current/#//apple_ref/doc/uid/TP40004652-CH3-SW1

To see this page, you must enable JavaScript. Pour afficher cette page, vous devez activer JavaScript. Zur Anzeige dieser Seite müssen Sie JavaScript aktivieren. このページを表示するには、JavaScript を有効にする必要があります。

help.apple.com

https://help.apple.com/instruments/mac/current/#/dev022f987b

 

https://help.apple.com/instruments/mac/current/#/dev022f987b

To see this page, you must enable JavaScript. Pour afficher cette page, vous devez activer JavaScript. Zur Anzeige dieser Seite müssen Sie JavaScript aktivieren. このページを表示するには、JavaScript を有効にする必要があります。

help.apple.com

 

'Xcode Study' 카테고리의 다른 글

Socket.IO 통신  (0) 2024.05.27
[ReFactoring]Weather777(URLSession -> async/await)  (0) 2024.04.27
ActivityKit & WidgetKit  (0) 2024.03.30
Required Reason API(from App Store Connect)  (0) 2024.03.26
앱 심사 요청 후 reject  (0) 2024.03.22