한 것
이번 주에 저는 여행 목록에서 여행을 탭하면 나타나는 여행 별 일정 리스트를 구현했습니다.
저희 앱은 별도의 로그인이 필요 없기 때문에 코어데이터를 사용해 로컬에 정보를 저장하고 있는데요,
최초의 구현에서는 일정이 1개든 100개든 100,000개든 한 번에 전부 가져오도록 되어있었습니다!

하고 싶었던 것
하지만 실제로 앱을 사용하는 유저는 아무래도 최근 일정만 확인할 확률이 높은데
모든 일정을 다 가져오는 것은 속도 측면에서도, 메모리 측면에서도 비효율적이라는 생각이 들었습니다.
특히 저희 앱은 일정 개수에 제한이 없기 때문에
극한의 J형 유저가 있다면 일정이 10만개, 20만개, 100만개까지…무수히 늘어날 수 있거든요….!
물론 그럴 가능성은 극히 낮겠지만 그래도 혹시 모르니까!
기왕이면 서버로부터 데이터를 받아올 때 페이지네이션을 하는 것처럼
코어데이터를 사용할 때도 필요한 만큼의 데이터만 그때 그때 가져올 수 없을까 고민했습니다.
FetchLimit? FetchBatchSize?
따라서 코어데이터에서 데이터를 받아오기 위해 fetchRequest를 날릴 때
받아올 데이터의 개수를 조절할 수 있는 방법이 있는지 찾아봤는데요
fetchLimit과 fetchBatchSize라는 두 프로퍼티를 발견했습니다.

fetchLimit의 경우 말 그대로 request가 반환할 일정의 최대 개수를 설정하는 거고
fetchBatchSize의 경우 해당 request의 조건에 일치하는 일정은 한 번에 모두 찾지만,
이 데이터를 주어진 개수만큼씩 쪼개서 한 번에 딱 그만큼씩만 반환하게 합니다.
그런데 저는 솔직히 공식문서만 보고는 뭐가 다른건지 잘 이해가 가지 않더라구요…
코어데이터 디버깅 하기
그래서 직접 디버깅해보면서 어떤 차이가 있는지 알아보았습니다.
디버깅 방법은 간단한데요, 아래 사진과 같이
1. Xcode 상단의 scheme을 누르고 Edit Scheme..을 선택한 다음
2. Run탭에 -com.apple.CoreData.SQLDebug 1 을 추가해주면 됩니다

저는 일정이 많이 누적되었을 때의 성능을 비교하고 싶어서
100,000개의 일정 더미데이터를 추가해준 다음,
1) request에 아무런 제한을 설정하지 않은 경우
2) fetchLimit만 20으로 설정한 경우
3) fetchBatchSize만 20으로 설정한 경우를 비교했습니다.
tmi지만 기준을 20으로 설정한 이유는
현재 가장 큰 기기인 14 Max에서 한번에 최대 16개 가량의 셀이 보이기 때문인데요,
나중에 리팩토링할 때 디바이스 별로 개수도 동적으로 계산하도록 개선해보고 싶습니다!
여러번 반복 시행했을 때 메모리 크기나 속도에 약간 편차가 있긴 했지만
메모리와 속도 모두 fetchLimit ≥ fetchBatchSize >>> 아무런 제한 없음 순으로 성능이 좋았습니다.
아래의 이미지를 통해 확인할 수 있듯이 일단 제한을 주면 성능이 확연히 개선되었어요!
하지만 fetchBatchSize와 fetchLimit만 두고 보면 실제 결과도 엎치락 뒤치락해서 상대적으로 차이가 크지 않다고 생각했습니다.

그렇다면 유저가 첫 20개 일정을 보고 나서 아래로 스크롤하고 나면,
그러니까 새로 20개의 일정을 다시 받아와야하는 경우에는 어떻게 될까요?
fetchLimit을 설정해준 경우 다시 20개를 찾는 것부터 시작해야하기 때문에
두 번째 호출에도 첫 번째와 거의 비슷한 시간이 소요되지만
fetchBatchSize의 경우에는 최초에 100,000개를 모두 미리 찾아뒀기 때문에 20개를 가져오기만 하면 돼서
3번째 항목의 가운데 줄에서 보이다시피 호출 시간이 0.0001초로 매우 단축됩니다!
따라서 저는 실행속도가 메모리 차이는 크지 않은데 반해
다음 데이터를 가져오는데 fetchLimit보다 fetchBatchSize가 유리하다고 생각해서
fetchBatchSize를 활용해야겠다고 생각했습니다!
뭐야 내 메모리 돌려줘요
다 한 것 같은데…왠지 모르게 스크롤이 좀 많이 남아있죠…?ㅎㅎㅎㅎㅎ
저는 일정 화면을 PlanListView - PlanListViewController - PlanListViewModel - PlanRemoteRepository 로 구성해서
코어데이터를 활용한 CRUD 작업은 PlanRemoteRepository의 역할로 분리해주었는데요,
아까 한 테스트는 레포지토리에서 일정을 호출할 때의 속도와 메모리를 확인한 것이었습니다….
그러니까 아직 뷰모델이랑 뷰컨트롤러랑 연결하기 전 이었던 거죠…
연결을 해주자 화면이 나타남과 동시에 메모리와 CPU가 폭발하기 시작했습니다…^^

잠~깐~만 코어데이터는 어떻게 작동할까요?
범인을 밝히러 가기 전에 코어데이터의 작동 방식에 대해 슥 알아보겠습니다
영구 저장소에 저장된 데이터를 코드로는 어떤식으로 나타낼 수 있을까요?
바로 NSManagedObject를 사용해서 표현하는데요,
이러한 managed object는 최초에는 보통 fault로 대체되어 메모리에 올라갑니다.
fault란 아직 프로퍼티 값들이 초기화되지 않은 일종의 placeholder로
메모리를 절약하기 위해 초기화는 실제 어트리뷰트의 값들이 필요할 때 비로소 이루어집니다.
이때 fault를 초기화된 managed object로 변환하는 작업은 코어데이터가 알아서 해주고
이러한 일련의 과정을 fault firing 이라고 부릅니다!
그러니까 우리가 오사카 여행 일정 목록을 나타내려면
일단 오사카 일정 요청 줘~ 하고 요청을 보내야겠죠?
코어데이터는 요청을 받으면 첫 번째로는 캐시를 확인합니다.
캐시에 있으면 해당 값을 바로 리턴하고,
없으면 영구 저장소에 가서 데이터를 찾아 이를 fault의 형태로 반환합니다.
즉, ID와 같이 해당 일정을 식별할 수 있는 정보만을 담은 fault를 생성해서 아무튼 일정이야~ 하고 빈 껍데기만 전달합니다.
다시 말해 여기에는 일정 이름이라던가 장소라던가 시간 등등 일정에 관한 세부 사항은 담겨있지 않습니다.
그리고 나서 우리가 레이블에 일정 이름을 표시하기 위해 실제 값이 필요할 때
코어데이터가 알아서 다시 저장소로부터 해당 값을 받아옵니다!
모든 프로퍼티와 릴레이션십은 별도의 설정을 해주지 않으면 fault로 호출되기 때문에
실제로 fetchRequest로 데이터를 받아온 직후에
isFault 혹은 hasFault(forRelationshipNamed:) 로 확인해보면
둘 다 true가 나오는 것을 확인할 수 있습니다.
예를 들어서 여행에 날짜 프로퍼티가 있고, 여행과 일정이 관계로 설정되어 있다면 아래와 같은 결과가 나옵니다.
print(travel.isFault) // true
print(travel.hasFault(forRelationshipNamed: "plan") // true
travel.date // travel fault fired
print(travel.isFault) // false
print(travel.hasFault(forRelationshipNamed: "plan") // true
travel.plan.first?.name // plan fault fired
print(travel.hasFault(forRelationshipNamed: "plan") // false
IT’S UR FAULT!
아마도 지금쯤이면 위에서 구구절절 fault에 대해서 이야기한 이유를 눈치채셨을 것 같은데요…
아무튼 아까 전 사진에서 저 미친 메모리는 뭔가가 잘못됐고
fault 상태로 있어도 되는 애들까지 다 초기화가 되어버렸구나 싶으실거에요…
맞습니다 맞아요 정확히 그 문제였습니다~~~

일정 목록은 UICollectionView를 사용했는데
저는 일정들을 받아온 다음 이를 PlanViewModel로 변환해서 각 셀에 전달하고 있었습니다.
아래 convertPlansToPlanViewModelsAndAppend(_:) 메서드를 보시면
변환 과정에서 forEach문을 사용해서 모든 일정을 다 돌면서
어떤 섹션에 들어가야하는 지 연산하기 위해 `date` 프로퍼티에 접근하고 있습니다.
이로 인해 어쩌면 유저가 앞으로는 전혀 확인할 일이 없을 지도 모르는 100,000번째 일정까지 다 fault firing이 일어나고 있었던 거죠…
final class PlanListViewModel {
var planFetchHandler: (() -> Void)?
private(set) var planViewModels = [String: [PlanViewModel]]()
private let repository: PlanRepository
private let travelID: UUID
private var plans = [Plan]()
func fetchPlans() throws {
plans = try repository.fetchPlans(ofTravelID: travelID, batchSize: Metric.batchSize)
planFetchHandler?(planViewModels)
}
private func convertPlansToPlanViewModelsAndAppend() {
plans.forEach { // 범인은 바로 여기와 바로 밑줄!
guard let date = $0.date
else {
return
}
let section = sectionDateFormatter.string(from: date)
let viewModel = PlanViewModel(plan: $0, repository: repository)
planViewModels[section, default: []].append(viewModel)
}
}
}
실제로 일정 목록 화면에 진입함과 동시에 아래와 같은 로그가 무한 반복되었습니다…

해결~?!
그렇다면 화면에 아직 나타나지 않은 일정까지 다 fault firing이 발생하는 것을 막으려면 어떻게 해야 할까요?
저는 화면에 실제로 나타날 일정만큼만 그때그때 변환해야겠다고 생각했습니다.
저희는 데이터 동기화의 용이성과 매끄러운 애니메이션을 위해 DiffableDataSource를 사용하고 있는데요,
DiffableDataSource는 변화가 생길 때마다 갱신된 데이터에 대한 스냅샷을 만들어서
이 새로운 스냅샷을 뷰에 적용함으로써 이를 가능하게 합니다
여기서 적으면 너무 길어지니까 관련 내용은 나중에 또 포스팅을…할 수 있겠죠?
사실 자세한 내용은 저도 또 공부해봐야 하기 떄문에...미래의 나에게 맡긴다...
따라서 유저가 스크롤할 때마다 위치를 감지해서 전체 내용의 약 80% 까지 스크롤 할 때마다
새로운 일정 20개를 PlanViewModel로 변환하고 스냅샷에 추가하도록 변경했습니다.

그럼 최종적으로 변경한 코드만 슥 훑어보고 마무리하겠습니다!
PlanViewController 에서는 유저가 스크롤할 때마다 이를 감지해서
스크롤 뷰의 80% 정도를 지나게 되면
PlanListViewModel 의 userDidScrollToEnd() 메서드를 호출합니다.
// MARK: - UICollectionViewDelegate
extension PlanListViewController: UICollectionViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.didPassPoint()
else { return }
viewModel.userDidScrollToEnd()
}
}
userDidScrollToEnd() 메서드에서는 PlanViewModel 의 convertPlansToPlanViewModelsAndAppend() 메서드를 호출하는데요,
보시면 이전과 달리 forEach문이 batchSize 크기만큼, 그러니까 화면에 나타낼 20개씩만 변환을 수행하고 있습니다.
그리고 나서 새로 변환된 일정 20개의 ID와 섹션 ID를 planFetchHandler에 전달합니다.
final class PlanViewModel {
// MARK: - Properties
/// 새로운 일정 데이터가 추가되었을 때 호출하는 클로저
///
/// 새로 추가된 일정의 ID와 섹션ID를 인자로 받음
var planFetchHandler: (([SectionAndPlanID]) -> Void)?
/// 아직 뷰모델로 변환되지 않은 Plan의 시작 인덱스
private var planOffset = Int.zero
// MARK: - Functions
func userDidScrollToEnd() {
let newSectionAndPlanID = convertPlansToPlanViewModelsAndAppend()
planFetchHandler?(newSectionAndPlanID)
}
/// 딕셔너리의 해당 Section키의 배열에 Plan을 PlanViewModel로 변환해서 추가한 후,
/// 추가한 PlanViewModel들을 섹션 정보와 함께 리턴
private func convertPlansToPlanViewModelsAndAppend() -> [SectionAndPlanID] {
var newSectionAndPlanID = [SectionAndPlanID]()
(Int.zero..<Metric.batchSize).forEach { /// 동네 사람들~ 여기가 바뀌었어요!!!
let index = planOffset + $0
guard plans.indices ~= index,
let date = plans[index].date
else {
return
}
let section = sectionDateFormatter.string(from: date)
let viewModel = PlanViewModel(plan: plans[index], repository: repository)
planViewModels[section, default: []].append(viewModel)
newSectionAndPlanID.append((section, viewModel.id))
}
planOffset += Metric.batchSize
return newSectionAndPlanID
}
}
planFetchHandler는 PlanListViewController에서 등록한 녀석으로
새롭게 추가된 일정들을 인자로 받아서 스냅샷을 업데이트하는 작업을 수행합니다
final class PlanListViewController: UIViewController
private func bindToViewModel() {
viewModel.planFetchHandler = { [weak self] in
self?.applySnapshot(usingData: $0)
self?.collectionView.isEmpty = self?.viewModel.planViewModels.isEmpty == true
}
}
/// 새롭게 추가된 데이터만을 반영해서 스냅샷을 갱신
private func applySnapshot(usingData data: [(Section, ItemID)]) {
var snapshot = self.dataSource.snapshot()
data.forEach { section, itemID in
if !snapshot.sectionIdentifiers.contains(section) {
snapshot.appendSections([section])
}
snapshot.appendItems([itemID], toSection: section)
}
dataSource.apply(snapshot, animatingDifferences: false)
}
}
끝!
드디어 다 썼다...
이렇게 코드를 변경하고 다시 실행한 결과!
일정 목록에 진입했을 때 메모리가 20.6mb로 훅 줄어들고, CPU 사용량도 급감한 것을 확인할 수 있었습니다!
참 쉽죠?!…^^

여기부터는 tmi 인데
플젝 시작 전에 매주 플젝 관련 기술 로그 쓰는 게 목표라고 팀원들한테 당당하게 얘기했지만
속으로는 과연 하나라도 제대로 올릴 수 있을까 걱정했었는데요
일단 하나는 클리어~~~~~~~
알고 있다고 착각하던 것들에 대해서 배우는 좋은 경험이었는데 미루지 않고 포스팅까지 한 나 자신 칭찬해…
플젝 끝나기 전에 포스팅 하나쯤은 더 하겠죠?
파...이...팅...
참고 자료
https://developer.apple.com/documentation/coredata
https://developer.apple.com/documentation/coredata/nsmanagedobjectcontext
https://developer.apple.com/documentation/coredata/nsfetchrequest/1506622-fetchlimit
https://developer.apple.com/documentation/coredata/nsfetchrequest/1506558-fetchbatchsize
https://developer.apple.com/documentation/coredata/nsfetchrequest/1506387-includespropertyvalues
https://developer.apple.com/documentation/swiftui/loading_and_displaying_a_large_data_feed
https://www.avanderlee.com/debugging/core-data-debugging-xcode/
https://developer.apple.com/documentation/uikit/nsdiffabledatasourcesnapshot