VesselWheel

[Trouble Shooting] 러닝맵 업체 정보 버튼 확장 간 RequestQuery 제한(feat. cache) 본문

Xcode Study

[Trouble Shooting] 러닝맵 업체 정보 버튼 확장 간 RequestQuery 제한(feat. cache)

JasonYang 2024. 3. 16. 10:17

문제점

오늘도 트러블 메이커, query 호출제한과 완료되지 않은 쓰레드의 표시 접근 제한

 

버튼을 통해서, query와 category로 annotation과 업체 정보를 호출하고자 했다. 

더보기
func getAnnotations(forQuery query: String, category: String) {
        // 모든 어노테이션 제거
        let allAnnotations = self.mapView.annotations
        self.mapView.removeAnnotations(allAnnotations)
        
        guard let currentLocation = self.mapView.userLocation.location else {
            print("Failed to get user location")
            return
        }
        
        let searchRequest = MKLocalSearch.Request()
        searchRequest.naturalLanguageQuery = query
        searchRequest.region = MKCoordinateRegion(center: currentLocation.coordinate, latitudinalMeters: 500, longitudinalMeters: 500)
        
        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
            }
            
            search.start { [weak self] (response, error) in
                guard let response = response else {
                    print("Search error: \(error?.localizedDescription ?? "Unknown error")")
                    return
                }
                
                for item in response.mapItems {
                    let annotation = CustomAnnotation()
                    annotation.coordinate = item.placemark.coordinate
                    annotation.title = item.name
                    annotation.mapItem = item
                    annotation.category = category
                    DispatchQueue.main.async {
                        self?.mapView.addAnnotation(annotation)
                    }
                }
            }
        }
    }

팀원께서 개선 해준 getAnnotations()매소드,

검색하고자 하는 자연어(naturalLanguageQuery)를 query 객체화해서 파라미터로 받고, catergoy 또한 파라미터로 객체화했다. 

현재는 편의점 업체 정보가 GS25 만 있기때문에, 사용자가 사용하기에 정보가 제한적이라 판단되었다. 

 

그래서, 추가적인 업체를 함께 검색하게금하면 어떠할까하는 생각에 업체 정보를 추가하였다.

그랬더니, 업체정보는 어노테이션에 잘 나온다. 하지만 업체 정보를 노출하기 위해 업체정보가 나오지 않는다. 

 

Attempt to present~로 시작하는 부분

Attempt to present <Run_It.StoreViewController: 0x104584910> on <Run_It.MainTabBarViewController: 0x129820600> (from <Run_It.RunningMapViewController: 0x10780ec00>) while a presentation is in progress.

-> 문제는 모달뷰에 업체정보를 표시하기 전에 모댤뷰가 러닝맵뷰에 호출되려고 하니, 완료되지 않은 쓰레드가 사용되어 뷰전환이 거부되었다.

또한 query와 category가 다른 3종류의 버튼을 연달아 탭했더니 아래와 같은 메세지가 콘솔창에 나왔다. 

Throttled "PlaceRequest.REQUEST_TYPE_SEARCH" request: Tried to make more than 50 requests in 60 seconds, will reset in 11 seconds - Error Domain=GEOErrorDomain Code=-3 "(null)" UserInfo={details=(
    {
    intervalType = short;
    maxRequests = 50;
    "throttler.keyPath" = "app:com.team5.Run-It/0x20301/short(default/any)";
    timeUntilReset = 11;
    windowSize = 60;
}
), requestKindString=PlaceRequest.REQUEST_TYPE_SEARCH, timeUntilReset=11, requestKind=769}
Search error: The operation couldn’t be completed. (MKErrorDomain error 3.)
Throttled "PlaceRequest.REQUEST_TYPE_SEARCH" request: Tried to make more than 50 requests in 60 seconds, will reset in 11 seconds - Error Domain=GEOErrorDomain Code=-3 "(null)" UserInfo={details=(
{
intervalType = short;
maxRequests = 50;
"throttler.keyPath" = "app:com.team5.Run-It/0x20301/short(default/any)";
timeUntilReset = 11;
windowSize = 60;
}
), requestKindString=PlaceRequest.REQUEST_TYPE_SEARCH, timeUntilReset=11, requestKind=769}
Error: The operation couldn’t be completed. (MKErrorDomain error 3.).

관련된 내용을 검색해보니, 

더보기

해당 콘솔 메시지는 API 요청 제한으로 인한 오류 메시지입니다. 여기서 발생한 문제는 여러분이 사용하고 있는 지도 서비스의 API가 일정 시간 동안에 허용하는 요청 횟수를 초과했다는 것을 의미합니다. 구체적으로, "Tried to make more than 50 requests in 60 seconds"라는 부분에서 볼 수 있듯이, 여러분의 애플리케이션이 60초 동안 50번 이상의 요청을 시도했고, 이로 인해 일시적으로 요청이 제한되었다는 것을 알 수 있습니다.

해결 방법으로는 다음과 같은 몇 가지 방법이 있습니다:

  1. 요청 횟수 최적화: 필요한 정보만을 요청하도록 알고리즘을 조정하여 요청 횟수를 줄이거나, 한 번의 요청으로 여러 데이터를 받아올 수 있는 방법을 모색해보세요.
  2. 지연 시간 추가: 각 요청 사이에 일정 시간 동안 지연시간을 두어 API 요청 횟수를 조절하세요. 예를 들어, 각 요청 후 1~2초의 지연 시간을 추가할 수 있습니다.
  3. 캐싱 사용: 이전에 요청했던 데이터는 로컬에 캐싱하여 같은 정보를 다시 요청할 필요가 없도록 합니다. 이 방법은 특히 자주 사용되거나 변하지 않는 데이터에 효과적입니다.
  4. API 요청 제한 확장: 사용 중인 지도 서비스의 API 요청 제한이 너무 낮다고 판단된다면, 서비스 제공업체에 요청하여 요청 제한을 늘려달라고 요청하거나, 유료 플랜으로 전환하는 것을 고려해볼 수 있습니다.

 

1. 요청 횟수 최적화는 검색되는 업체 정보를 줄이거나, 지역을 한정하면 된다. 

2. 지연 시간 추가는 한 업체의 query 검색이 완료될 때까지 지연시간을 적용하고 완료 후에 다음 검색을 진행한다. 

3. 캐시 사용 : 스택에 저장되는 배열을 활용하여 검색이 완료되면 배열에 넣어준다. 

4. 카카오나, 네이버와 같이 검색 횟수에 따라 유료로 전환되어 API 요청 제한이 있다. Mapkit의 경우 추가적인 요금은 발생하지 않지만, 

1초 내에 검색가능 횟수가 50회로 제한되어 있다. 

 

우선 시도한 방법은 캐시를 활용한 배열에 저장 후 재호출하고, 지연시간을 주어 검색과 호출이 완료되면 다음 query를 실행하였다. 

1. 우선, 캐싱할 배열을 생성하고, 요청 완료 여부를 설정한 뒤에, 진행되는 동안 사용자에게 진행중 인디케이터를 표시하려고 한다. 

enum PresentView {
    case inProgress
    case completed
}
var searchResultsCache: [String: [MKMapItem]] = [:]
    
var presentationState = PresentView.completed
    
var loadingIndicator: UIActivityIndicatorView?

 

더보기
    func getAnnotations(forQuery query: String, category: String) {
        if let cachedResults = searchResultsCache[query] {
            // 캐시된 결과가 있으면 해당 결과를 사용하여 어노테이션을 추가
            addAnnotationsToMap(mapItems: cachedResults, category: category)
            return
        }
        
        // 캐시에 저장된 결과가 없으면 네트워크 요청을 시작
        guard let currentLocation = self.mapView.userLocation.location else {
            print("Failed to get user location")
            return
        }
        
        // 모든 어노테이션 제거
        let allAnnotations = self.mapView.annotations
        self.mapView.removeAnnotations(allAnnotations)
        
        guard let currentLocation = self.mapView.userLocation.location else {
            print("Failed to get user location")
            return
        }
        
        let searchRequest = MKLocalSearch.Request()
        searchRequest.naturalLanguageQuery = query
        searchRequest.region = MKCoordinateRegion(center: currentLocation.coordinate, latitudinalMeters: 500, longitudinalMeters: 500)
        
        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
            }
            
            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 <= 25
                }
                
                self?.searchResultsCache[query] = response.mapItems
                
                // 캐시된 결과를 사용하여 어노테이션을 추가
                self?.addAnnotationsToMap(mapItems: response.mapItems, category: category)
            }
        }
    }
    
    private func addAnnotationsToMap(mapItems: [MKMapItem], category: String) {
        for item in mapItems {
            let annotation = CustomAnnotation()
            annotation.coordinate = item.placemark.coordinate
            annotation.title = item.name
            annotation.mapItem = item
            annotation.category = category
            DispatchQueue.main.async {
                self.mapView.addAnnotation(annotation)
            }
        }
    }

 

더보기
    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)
    }
    
    func calculateDistance(to location: CLLocation) -> Int {
        let userLocation = CLLocation(latitude: mapView.userLocation.coordinate.latitude, longitude: mapView.userLocation.coordinate.longitude)
        let distanceInMeters = userLocation.distance(from: location)
        return Int(distanceInMeters)
    }
    
    func isStoreFavorite(name: String, latitude: Double, longitude: Double) -> Bool {
        let viewModel = FavoritesViewModel()
        return viewModel.isFavorite(storeName: name, latitude: latitude, longitude: longitude)
    }
    
    
    func searchAndPresentStores(with query: String, location: CLLocation) {
        let searchRequest = MKLocalSearch.Request()
        searchRequest.naturalLanguageQuery = query
        // 사용자의 현재 위치를 중심으로 하는 지역 설정
        let region = MKCoordinateRegion(center: location.coordinate, latitudinalMeters: 500, longitudinalMeters: 500)
        searchRequest.region = region
        
        let search = MKLocalSearch(request: searchRequest)
        search.start { (response, error) in
            guard let response = response else {
                print("Error: \(error?.localizedDescription ?? "Unknown error").")
                return
            }
            
            var places: [AnnotationInfo] = []
            for item in response.mapItems {
                // 필요한 데이터를 places 배열에 추가
                let place = AnnotationInfo(
                    name: item.name ?? "Unknown",
                    category: query, // 카테고리를 검색어로 설정
                    address: item.placemark.title ?? "No address",
                    url: item.url?.absoluteString ?? "No URL",
                    latitude: item.placemark.coordinate.latitude,
                    longitude: item.placemark.coordinate.longitude,
                    isOpenNow: false, // 이 값을 설정하기 위한 로직이 필요
                    distance: self.calculateDistance(to: CLLocation(latitude: item.placemark.coordinate.latitude, longitude: item.placemark.coordinate.longitude)),
                    isFavorite: self.isStoreFavorite(name: item.name ?? "", latitude: item.placemark.coordinate.latitude, longitude: item.placemark.coordinate.longitude)
                )
                
                places.append(place)
            }
            
            DispatchQueue.main.async {
                self.presentStoreViewController(with: places)
            }
        }
    }
    
    private func presentStoreViewController(with places: [AnnotationInfo]) {
        // 모달을 표시하기 전 상태 확인
        if presentationState == .completed {
            // 상태를 inProgress로 변경
            presentationState = .inProgress
            loadingIndicator?.startAnimating()
            
            // 기존에 표시된 모달이 있다면 닫기
            if let currentModal = self.presentedViewController {
                currentModal.dismiss(animated: true) {
                    self.showStoreVC(with: places)
                }
            } else {
                showStoreVC(with: places)
            }
        } else {
            // 프레젠테이션 상태가 inProgress인 경우, 완료될 때까지 기다렸다가 모달 표시
            DispatchQueue.global(qos: .background).async {
                while self.presentationState != .completed {
                    // 상태가 completed로 변경될 때까지 대기
                    Thread.sleep(forTimeInterval: 0.1) // CPU를 과도하게 사용하지 않도록 적당한 대기 시간 설정
                }
                DispatchQueue.main.async {
                    self.presentStoreViewController(with: places)
                }
            }
        }
        loadingIndicator?.stopAnimating()
    }
    
    private func showStoreVC(with places: [AnnotationInfo]) {
        let storeVC = StoreViewController()
//        storeVC.delegate = self
        storeVC.stores = places // 데이터 전달
        storeVC.modalPresentationStyle = .formSheet
        storeVC.modalTransitionStyle = .coverVertical
        
        // 모달을 표시하기 전에 sheetPresentationController 설정을 추가
        if let sheet = storeVC.presentationController as? UISheetPresentationController {
            let customDetentIdentifier = UISheetPresentationController.Detent.Identifier("customBottomBarHeight")
            let customDetent = UISheetPresentationController.Detent.custom(identifier: customDetentIdentifier) { _ in
                return 250
            }
            
            sheet.detents = [customDetent] // 모달의 높이 설정
            sheet.prefersGrabberVisible = true
            sheet.largestUndimmedDetentIdentifier = customDetentIdentifier // 음영 처리 없이 표시
            sheet.prefersScrollingExpandsWhenScrolledToEdge = false // 모달 내부 스크롤 시 확장되지 않도록 설정
            sheet.prefersEdgeAttachedInCompactHeight = true // 컴팩트 높이에서 화면 가장자리에 붙도록 설정
            sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true // 너비가 preferredContentSize를 따르도록 설정
        }
        
        self.present(storeVC, animated: true) {
            // 프레젠테이션이 완료되면 상태를 completed로 변경
            self.presentationState = .completed
        }
    }
    
    private func setupLoadingIndicator() {
        loadingIndicator = UIActivityIndicatorView(style: .large)
        loadingIndicator?.center = self.view.center
        loadingIndicator?.hidesWhenStopped = true
        if let indicator = loadingIndicator {
            self.view.addSubview(indicator)
        }
    }

 

 

더보기
    @objc func presentConvenienceStoreAnnotations() {
        let convenienceStores = ["GS25", "CU", "세븐일레븐", "이마트24", "미니스톱"]
        let category = "편의점"
        
        // currentLocation이 nil인 경우를 처리
        guard let currentLocation = currentLocation else {
            print("현재 위치 정보가 없습니다.")
            return
        }
        
        for convenienceStore in convenienceStores {
            getAnnotations(forQuery: convenienceStore, category: category)
            searchAndPresentStores(with: convenienceStore, location : currentLocation)
        }
    }
    // 유저에게 편의전 옵션을 주고, 편의점 옵션을 선택해서 하프모달로 노출

    @objc func presentcoffeeAndBakeryFranchisesAnnotations() {
        
        let coffeeAndBakeryFranchises = ["Cafe", "coffee", "투썸플레이스", "컴포즈커피",
                                         "스타벅스", "파리바게뜨", "뚜레쥬르", "할리스커피",
                                         "이디야커피", "메가커피", "브레드톡"]
        let category = "카페/베이커리"
        
        // currentLocation이 nil인 경우를 처리
        guard let currentLocation = currentLocation else {
            print("현재 위치 정보가 없습니다.")
            return
        }
        
        for coffeeAndBakeryFranchise in coffeeAndBakeryFranchises {
            getAnnotations(forQuery: coffeeAndBakeryFranchise, category: category)
            searchAndPresentStores(with: coffeeAndBakeryFranchise, location : currentLocation)
        }
        
    }
    
    @objc func presenthealthyEatingOptionsAnnotations() {
        
        let healthyEatingOptions = ["샐러디", "subway"]
        let category = "건강식"
        
        // currentLocation이 nil인 경우를 처리
        guard let currentLocation = currentLocation else {
            print("현재 위치 정보가 없습니다.")
            return
        }
        for healthyEatingOption in healthyEatingOptions {
            getAnnotations(forQuery: healthyEatingOption, category: category)
            DispatchQueue.main.async {
                self.searchAndPresentStores(with: healthyEatingOption, location : currentLocation)
            }
        }
    }