본문 바로가기

학습 노트/iOS (2021)

200. Performance & Debugging

Performance & debugging


CoreData이 우수한 성능 덕분에 대부분의 개발자나 사용자의 요청을 처리하는 데에는 문제가 없다.

  • Predicate가 난해한 경우
  • 너무 많은 데이터를 불러오는 경우
  • Faults를 너무 자주 발생하는 경우

위의 세 경우를 감안해 요청을 생성한다면,
CoreData의 데이터 처리에 필요한 시간을 획기적으로 개선할 수 있다.

 

CoreData Debugging

CoreData가 어느 부분에서 부하가 높은지 확인하기 위해서는 Xcode의 Scheme과 Instrument를 사용한다.

Scheme

Xcode의 상단에는 현재 프로젝트의 상태를 표시하는 공간이 존재한다.
여기서 프로젝트 이름을 누르면

위와 같은 팝업창이 표시되는데,
여기서 Edit Scheme을 선택해 Log에 표시할 내용을 변경할 수 있다.

Arguments는

  • -com.apple.CoreData.SQLDebug 1
    SQLite의 다양한 Log를 출력한다.
    뒤의 숫자가 커질수록 Log가 자세해진다.
    1은 단순하게 실행한 SQL, 실행 시간을 표시한다.
  • -com.apple.CoreData.ConcurrencyDebug 1
    동시성에 관련된 Log를 출력한다.

추가되는 Argument는 반드시 '-'로 시작함에 주의하자.

아래의 환경변수는

  • SQLITE_ENABLE_THREAD_ASSERTIONS
    Thread 추적에 용이하다
  • SQLITE_ENABLE_FILE_ASSERTIONS
    CoreData의 형식이 SQLite이면 CoreData를 통해 데이터를 처리하도록 되어있다.
    이를 무시하고 직접 변경하게 되면 DB의 손상을 야기하는데 이를 감지하게 된다.

Log가 너무 많아도 가독성이 떨어지기 때문에
대부분의 경우 '-com.apple.CoreData.SQLDebug'만 Argument에 추가한 뒤 사용한다.


결과

CoreData의 동작 하나하나가 전부 표시되는 것을 확인할 수 있다.


 

Instrument

Xcode의 메뉴에서 'Product > Profile'을 선택하거나 'cmd + i' 단축키로 Instrumet를 실행할 수 있다.

CoreData를 선택하면

앱의 동작에 따라 CoreData의 자세한 정보를 시각적, 수치적으로 확인할 수 있다.
기본 상태에서 Faults, Fetch, Save를 확인할 수 있고,
위와 같은 가는 선이 아닌 굵은 선들이 보이면 해당 부분은 CoreData에 부하가 걸린다는 의미가 된다.

위의 두 방법으로 CoreData에서 부하가 걸리는 부분을 찾았다면 해당 문제를 해결해야 할 차례다.

 

최적화

Predicate 최적화

컴퓨터가 곱셈보다 덧셈을 더 잘하듯,
Predicate도 연산의 종류에 따라 속도의 차이가 발생한다.
따라서 같은 결과를 얻을 수 있다면 더 빠른 연산으로 더 많은 데이터를 필터링하는 것이 효율적이라고 할 수 있다.

Predicate에서 사용하는 위의 연산자들은 위로 갈수록 빠르고,
아래로 갈 수록 느린 연산 속도를 가진다.
특히 LIKE, MATCHES, *, ? 네 개의 연산자는 특히나 느려 정팔 필요한 경우가 아니라면 지양해야 한다.

PredicateOptimizationTableViewController.swift

request.predicate = NSPredicate(format: "address LIKE '*NY*' AND name LIKE 'A*' AND salary >= 50000")

결과


현재의 predicate는 주소가 NY인지, 이름이 A로 시작하는지, salary가 50000 이상인지를 확인한다.
더 빠른 연산으로 더 많은 데이터를 필터링할 수 있다면 predicate는 다중 연산에서 더 높은 성능을 기대할 수 있다.

PredicateOptimizationTableViewController.swift

request.predicate = NSPredicate(format: "salary >= 50000 AND name BEGINSWITH 'A' AND address CONTAINS 'NY'")

결과


predicate를 재배치 하기 전인 0.0266초 보다 빨라진 0.0074초가 소요됐다.
연산 속도가 빠른 연산자를 앞에 배치해 많은 양의 데이터를 단시간에 제외하고.
최종적으로는 연산 속도가 느린 연산자(주로 문자열 검색)가 다뤄야 할 데이터를 줄이도록 하면 성능 향상을 기대할 수 있다.

 

FetchLimit

한 번에 필요한 적절한 양의 데이터를 가져오는 것도 속도에 영향을 미친다.

FetchLimitTableViewController.swift > fetchTop1-SalaryInNewYork()

   func fetchTop10SalaryInNewYork() {
      let request = NSFetchRequest<NSManagedObject>(entityName: "Employee")
      
      let sortBySalary = NSSortDescriptor(key: "salary", ascending: false)
      let sortByName = NSSortDescriptor(key: "name", ascending: true)
      request.sortDescriptors = [sortBySalary, sortByName]
      
      request.predicate = NSPredicate(format: "address CONTAINS 'NY'")
      
      
      do {
         let result = try DataManager.shared.mainContext.fetch(request)
         list = Array(result[0..<10])
         
         tableView.reloadData()
      } catch {
         print(error.localizedDescription)
      }
   }

결과


최고 연봉자 10명의 이름을 알파벳 순으로 가져온다고 가정했을 때
위의 Fetch 방식은 규칙에 맞도록 정렬된 477개의 데이터를 가져온 뒤 10개만 사용하는 방식으로
467개의 의미 없는 데이터를 함께 가져온다는 문제가 있다.

FetchLimitTableViewController.swift > fetchTop1-SalaryInNewYork()

   func fetchTop10SalaryInNewYork() {
      let request = NSFetchRequest<NSManagedObject>(entityName: "Employee")
      
      let sortBySalary = NSSortDescriptor(key: "salary", ascending: false)
      let sortByName = NSSortDescriptor(key: "name", ascending: true)
      request.sortDescriptors = [sortBySalary, sortByName]
      
      request.predicate = NSPredicate(format: "address CONTAINS 'NY'")
      
      request.fetchLimit = 10
      
      do {
         list = try DataManager.shared.mainContext.fetch(request)
         
         tableView.reloadData()
      } catch {
         print(error.localizedDescription)
      }
   }

결과


이를 위와 같이 fetchLimit 속성을 설정하고,
CoreData에서 해당하는 만큼의 데이터만 전달하도록 한다면
메모리 자체도 효율적으로 사용할 수 있고, 그런 만큼 작업 속도도 빨라진다.

FetchLimit을 설정하기 전의 0.0104초 보다 소폭 빨라진 0.0074초가 소요되는 것을 확인할 수 있다.

 

BatchSize

전체를 가져와서 사용하는 것보다는 필요할 때마다 조금씩 가져오는 것이 초반 속도가 빠르다.

FetchBatchSizeTableViewController.swift

class FetchBatchSizeTableViewController: UITableViewController {
   lazy var resultController: NSFetchedResultsController<NSManagedObject> = { [weak self] in
      let request = NSFetchRequest<NSManagedObject>(entityName: "Employee")
      
      let sortByName = NSSortDescriptor(key: "name", ascending: true)
      request.sortDescriptors = [sortByName]
      
      let controller = NSFetchedResultsController(fetchRequest: request, managedObjectContext: DataManager.shared.mainContext, sectionNameKeyPath: nil, cacheName: nil)
      controller.delegate = self
      return controller
      }()
   
   
   override func viewDidLoad() {
      super.viewDidLoad()
      
      do {
         try resultController.performFetch()
      } catch {
         print(error.localizedDescription)
      }
   }
}

결과


5000개의 데이터를 전부 가져와서 화면에 표시하는데 까지 0.180초가 걸렸다

FetchBatchSizeTableViewController.swift

class FetchBatchSizeTableViewController: UITableViewController {
   lazy var resultController: NSFetchedResultsController<NSManagedObject> = { [weak self] in
      let request = NSFetchRequest<NSManagedObject>(entityName: "Employee")
      
      let sortByName = NSSortDescriptor(key: "name", ascending: true)
      request.sortDescriptors = [sortByName]
      
      request.fetchBatchSize = 30
      
      let controller = NSFetchedResultsController(fetchRequest: request, managedObjectContext: DataManager.shared.mainContext, sectionNameKeyPath: nil, cacheName: nil)
      controller.delegate = self
      return controller
      }()
   
   
   override func viewDidLoad() {
      super.viewDidLoad()
      
      do {
         try resultController.performFetch()
      } catch {
         print(error.localizedDescription)
      }
   }
}

결과


반면, 화면에 한 번에 보여줄 수 있는 만큼의 데이터만 가져오도록 BatchSize를 설정하면
0.0022초의 큰 폭으로 소요시간이 줄어든 것을 확인할 수 있다.

 

Partial Faulting

partial Faulting Scene은 위와 같이 이름만 Table에 표시하면 된다.

PartialFaultingTableViewController.swift

class PartialFaultingTableViewController: UITableViewController {
   lazy var resultController: NSFetchedResultsController<NSManagedObject> = { [weak self] in
      let request = NSFetchRequest<NSManagedObject>(entityName: "Employee")
      
      let sortByName = NSSortDescriptor(key: "name", ascending: true)
      request.sortDescriptors = [sortByName]
      
      let controller = NSFetchedResultsController(fetchRequest: request, managedObjectContext: DataManager.shared.mainContext, sectionNameKeyPath: nil, cacheName: nil)
      controller.delegate = self
      return controller
      }()
   
   
   override func viewDidLoad() {
      super.viewDidLoad()
      
      do {
         try resultController.performFetch()
      } catch {
         print(error.localizedDescription)
      }
   }
}

위와 같이 단순하게 fetch 해 이름을 사용할 경우,
Context는 필요한 이름뿐만이 아닌 연결된 다른 데이터도 함께 가져오게 된다.
특히 지금 접근하고 있는 Employee Entity는 프로필 사진을 가지고 있어 이것이 문제가 될 수 있다.


결과


단순하게 이름을 Table에 표시할 뿐인 Scene의 메모리 점유량이 190MB에 육박한다.
이는 대단히 비효율적이라고 할 수 있다.

PartialFaultingTableViewController.swift

class PartialFaultingTableViewController: UITableViewController {
   lazy var resultController: NSFetchedResultsController<NSManagedObject> = { [weak self] in
      let request = NSFetchRequest<NSManagedObject>(entityName: "Employee")
      
      let sortByName = NSSortDescriptor(key: "name", ascending: true)
      request.sortDescriptors = [sortByName]
      
      request.propertiesToFetch = ["name"]
      
      let controller = NSFetchedResultsController(fetchRequest: request, managedObjectContext: DataManager.shared.mainContext, sectionNameKeyPath: nil, cacheName: nil)
      controller.delegate = self
      return controller
      }()
   
   
   override func viewDidLoad() {
      super.viewDidLoad()
      
      do {
         try resultController.performFetch()
      } catch {
         print(error.localizedDescription)
      }
   }
}

결과


위와 같이 request의 propertiesToFetch 속성을 지정하면
필요에 의해 다른 데이터를 호출하기 전까지는 는 name 만 가져오게 된다.
이전의 160MB와 비교하면 확연히 메모리 점유량이 줄어든 것을 확인할 수 있다.

 

Prefetching

prefetching Scene은 직원의 이름과 해당 직원의 부서를 나란히 표시한다.

PrefetchingTableViewController.swift

class PrefetchingTableViewController: UITableViewController {
   lazy var resultController: NSFetchedResultsController<NSManagedObject> = { [weak self] in
      let request = NSFetchRequest<NSManagedObject>(entityName: "Employee")
      
      let sortByName = NSSortDescriptor(key: "name", ascending: true)
      request.sortDescriptors = [sortByName]
      
      request.predicate = NSPredicate(format: "department != nil")
      
      
      let controller = NSFetchedResultsController(fetchRequest: request, managedObjectContext: DataManager.shared.mainContext, sectionNameKeyPath: nil, cacheName: nil)
      controller.delegate = self
      return controller
      }()
   
   
   override func viewDidLoad() {
      super.viewDidLoad()
      
      do {
         try resultController.performFetch()
      } catch {
         print(error.localizedDescription)
      }
   }
}

부서와 직원은 서로 relation 관계에 있고,
위와 같이 구현할 경우

  1. 직원 전체를 fetch
  2. 필요할 때마다 부서 fetch

의 방식으로 동작한다.


결과


따라서 화면에 표시할 새로운 부서가 생길 때 마다 department에 접근해 부서 명을 가져오는 것을 확인할 수 있다.

PrefetchingTableViewController.swift

class PrefetchingTableViewController: UITableViewController {
   lazy var resultController: NSFetchedResultsController<NSManagedObject> = { [weak self] in
      let request = NSFetchRequest<NSManagedObject>(entityName: "Employee")
      
      let sortByName = NSSortDescriptor(key: "name", ascending: true)
      request.sortDescriptors = [sortByName]
      
      request.predicate = NSPredicate(format: "department != nil")
      
      request.relationshipKeyPathsForPrefetching = ["department.name"]
      
      let controller = NSFetchedResultsController(fetchRequest: request, managedObjectContext: DataManager.shared.mainContext, sectionNameKeyPath: nil, cacheName: nil)
      controller.delegate = self
      return controller
      }()
   
   
   override func viewDidLoad() {
      super.viewDidLoad()
      
      do {
         try resultController.performFetch()
      } catch {
         print(error.localizedDescription)
      }
   }
}

prefetch는 이러한 데이터를 미리 가져와 CoreData에 상습적으로 접근하는 불필요한 병목을 막는다.
따라서 Employee Entity에 접근할 때 부서명까지 한 번에 메모리에 할당해 병목을 없앤다.


결과


 

Blob 최적화

Empolyee Entity에는 프로필 사진이 함께 저장돼 있고,
이러한 데이터를 의미 없이 메모리에 할당하는 것은 대단한 낭비다.

이를 어느 정도 해소하기 위해 Partial Faulting을 사용하는 등
필요한 시점에 메모리에 할당할 수 있도록 대처할 수는 있지만 한 가지 맹점이 존재한다.
Entity의 Attribute에 접근하는 순간 모든 Attribute를 메모리에 할당하도록 동작하기 때문이다.
예를 들어 Employee Entity의 salary에 접근하는 것 만으로 필요하지 않은 contact, profile까지 전부 메모리에 할당한다.

이런 경우 사진 등의 큰 데이터인 BLOB을 별도의 Entity로 분리하고,
이를 Relation으로 연결한다면 전체 필요한 사진 단 하나만 메모리에 할당할 수 있기 때문에 대단히 효율적이다.

단, 이 경우 migration이 필요하다.

'학습 노트 > iOS (2021)' 카테고리의 다른 글

202. Networking  (0) 2022.07.06
201. Migration  (0) 2022.07.06
199. Context Synchronization  (0) 2022.06.29
198. Concurrency with Context  (0) 2022.06.09
197. Batch Processing with CoreData  (0) 2022.06.03