iOS
Compositional Layout과 Diffable DataSource로 홈 리팩토링하기
이승연여기어때
2022년 5월 3일
원문에서 보기 ↗여기어때 iOS 앱 홈 화면을 리팩토링한 경험을 공유합니다

https://unsplash.com/photos/4ie4fXv7cX4
안녕하세요. 여기어때 앱개발팀에서 iOS 개발을 맡고 있는 샐리입니다. Compositional Layout와 Diffable DataSource에 대한 설명과 여기어때 홈 화면을 어떻게 리팩토링했는지에 대해서 공유하고자 합니다.
Compositonal Layout
Compositional Layout은 빠르고 유연하고 composable하게 컬렉션뷰를 구현할 수 있는 CollectionViewLayout의 한 종류이며 iOS 13.0 이상부터 지원합니다.
iOS 13부터 바뀐 사진 앱이나 앱스토어 앱을 이미 보셨으리라 생각합니다. 이렇게 다양한 레이아웃을 쉽게 구성할 수 있게 도와주는 것이 바로 Compositional Layout입니다.

iOS 13 부터 바뀐 앱스토어와 사진 앱
장점
- 복잡한 레이아웃을 선언형 API 로 간단하게 구축할 수 있음
- 하나의 컬렉션 뷰로 다양한 레이아웃을 구성할 수 있음
- 빠른 속도
Compositonal Layout 구성
Compositional Layout은 Item, Group, Section 으로 구성되어 있습니다.
- Item: 하나의 Item (Cell)
- Group: Item의 집합
- Section: 여러 Group의 집합
NSCollectionLayoutDimension — Layout Size를 지정할 때 사용
- .fractionalWidth, .fractionalHeight: 부모 컴포넌트 크기에 비례하여 크기를 설정할 때 사용.
- .absolute: 고정된 크기일 때 사용.
- .estimated: 크기가 변동될 수 있는 경우에 사용.
https://gist.github.com/f6e890102dab5950fd1b2f0d430081c3.git
Diffable DataSource
혹시 UITableView나 UICollectionView를 구현하다가 이런 에러를 보신 적 있으신가요?
*** Terminating app due to uncaught exception ‘NSInternalInconsistencyException’, reason: ‘Invalid update: invalid number of sections. The number of sections contained in the collection view after the update (10) must be equal to the number of sections contained in the collection view before the update (10), plus or minus the number of sections inserted or deleted (0 inserted, 1 deleted).’
***
기존 DataSource 방식에서 데이터를 업데이트했을 때 발생하는 에러입니다. 이 에러를 해결하기 위해서는 reloadData()를 호출해주어야 하는데요. reloadData()를 호출하면 모든 셀을 다시 그리게 되면서 UI가 애니메이션 없이 부자연스럽게 변경됩니다. 따라서 사용자 경험이 저하된다는 단점이 있습니다.
그렇다면 이 에러는 왜 발생하는 걸까요? 🤔
UI와 DataSource 역할을 하는 Data Controller의 Truth가 맞지 않기 때문입니다. 여기서 Truth란 시간이 지남에 따라 변화하는 자신의 버전을 말합니다. 이 문제를 해결하기 위해 UI와 DataSource를 중앙화된(centralized) Truth로 관리하는 Diffable DataSource가 등장했습니다.
Diffable DataSource는 Hashable 기반으로 동작하며 데이터 업데이트를 단순하고 효율적으로 관리할 수 있게 합니다. performBatchUpdates() 대신 **apply()**를 사용하며 iOS 13.0 부터 지원합니다.
- NSDiffableDataSourceSnapshot UI State의 Truth이며 IndexPath 대신 Section과 Item의 Unique identifier를 사용합니다. Section과 Item 모두 Hashable을 상속하거나 Hashable을 준수해야합니다.
- apply() snapshot에 data의 상태를 반영하여 UI를 업데이트합니다. UI 변경 시 애니메이션 동작 여부를 설정할 수 있습니다.
장점
- 변경 사항이 있을 경우 애니메이션이 적용되어 UI가 자연스럽게 업데이트되므로 사용자 경험을 해치지 않음
- Centralized Truth를 사용하기 때문에 UI와 DataSource 간에 Truth가 맞지 않아 크래시나 에러가 발생하는 일이 없음
- Hashable 기반으로 O(n)의 빠른 성능을 가지고 있음
👀 기존 여기어때 홈 화면 구조 분석

여기어때 홈 화면은 사용자들이 가장 처음 그리고 많이 만나는 화면입니다. 사용자들에게 최적의 앱 사용 경험을 제공하기 위해 지속적으로 뷰가 추가되거나 변경되고 있습니다.
그런데 뷰가 늘어나면서 홈 화면이 약간 느려지는 현상이 나타났습니다. 원인은 UIScrollView와 UIStackView로 구성된 홈 화면 구조 때문이었습니다. 이 구조는 모든 뷰를 한 번에 다 그리기 때문에 사용자가 보지 않은 뷰까지도 메모리에 가지고 있습니다. 뷰가 많지 않았을 때는 괜찮았지만 현재는 뷰가 많이 늘어났기 때문에 불필요한 메모리 낭비가 발생할 우려가 있었습니다.
따라서 메모리 사용량을 줄이고 효율적인 뷰 구성을 위해 UICollectionView, Compositional Layout, Diffable DataSource를 이용하여 리팩토링을 진행했습니다.
🔨 여기어때 홈 레이아웃 구현
홈 화면 레이아웃을 어떻게 구현했는지 예시와 함께 간단히 설명하겠습니다.
Grid Section 구현
카테고리 Section은 Group에 4개의 Item을 가지고 있고 화면 가로 넓이에 딱 맞게 구성되어있습니다.

여기어때 홈 카테고리 Section
https://gist.github.com/db49073e0884c27ae827a1ef8aab6e79.git
group에 4개의 아이템이 들어가도록 itemSize를 .fractionalWidth(0.25)로 설정하고 Group에는 horizontal 레이아웃을 적용했습니다.
Orthogonal Section 구현
오늘의 체크인 특가 호텔처럼 좌우로 스크롤 되는 레이아웃을 구현해보겠습니다.

오늘의 체크인 특가 호텔 Section
https://gist.github.com/035f45bab4f552864c6403f12fc9dcac.git
group에 원하는 사이즈를 주고 item은 fractionalWidth, fractionalHeight 을 각각 1.0으로 설정했습니다. 그리고 가장 중요한 것은 orthogonalScrollingBehavior에 값을 지정해야 좌우로 스크롤이 된다는 것입니다.
orthogonalScrollingBehavior 종류
https://gist.github.com/98295d1169c36addd0f6541b648db923.git
Supplementary and decoration view 구현
Supplementary view(header, footer), Decoration view도 Compositional Layout으로 구성이 가능합니다. 예시로 테마가 있는 추천여행모듈을 구현해보겠습니다.

테마가 있는 추천여행 모듈 구성
https://gist.github.com/90207b88ffbc8f74773a45b2de843d52.git
header는 boundarySupplementaryItems에 background는 decorationItems에 각각 지정해주었습니다. 그리고 Supplementary View가 section의 contentInsets에 영향을 받지 않도록 supplemetariesFollowContentInsets = false로 설정했습니다.
⚠️ [주의]
Supplementary View는 UICollectionView 내에서 register()
Decoration View는 UICollectionViewCompositionalLayout 내에서 register() 해야합니다.
🔨 Diffable DataSource 구현
여기어때 홈의 Diffable DataSource 구현 부분을 간단한 버전으로 정리해보았습니다.
https://gist.github.com/b4a51d0cce7f18218574cce117d7aaba.git
먼저 Hashable 채택하여 Section과 Item을 생성합니다. 여기어때 홈은 약 20개의 Section이 있기 때문에 관리를 쉽게 하기 위해서 enum으로 생성했습니다.
https://gist.github.com/78a33578fa703e9d449da931652e50f6.git
dataSource 생성 시 item enum값에 따라 Cell을 리턴하도록 구현했습니다. snapshot 생성 후 Section과 Item을 추가하고 apply를 호출하여 UI를 업데이트합니다.
🔥 Diffable DataSource 이슈
iOS 14 에서 홈 화면 진입 시 카테고리 이미지가 간헐적으로 노출이 되지 않거나 카테고리 badge가 다른 셀에 잘못 노출되는 이슈가 있었습니다.

공간대여 셀의 NEW 뱃지가 프리미엄블랙 셀에 잘못 노출되는 현상
이슈의 원인은 animatingDifferences = true 때문이었는데요. 처음 apply를 호출하는 부분에서 animatingDifferences = false 로 값을변경해주니 정상적으로 잘 나오는 것을 확인할 수 있었습니다.
그런데 주의할 점은 iOS 15에서 apply()의animatingDifferences = false 동작 방식이 변경되어 iOS 15 이전 버전과 다르다는 것입니다. WWDC 2021 — Make blazing fast lists and collection views 에 의하면 iOS 15 이전 버전에서는 animatingDifferences = false일 경우, 내부적으로 reloadData()로 변환되어 동작한다고 합니다. reloadData()는 모든 셀을 버리고 다시 생성하기 때문에 성능이 좋지 않습니다. 그래서 iOS 15부터는 animatingDifferences = false일 때 애니메이션 없이 변경 사항만 업데이트하도록 변경되었다고 합니다.
💡[정리] animatingDifferences = false 일 때
- iOS 15.0 이상: 애니메이션 없이 변경 사항만 업데이트
- iOS 15.0 미만: 내부적으로
reloadData()로 동작
😎 이번 리팩토링으로 얻은 것

리팩토링 된 여기어때 iOS 앱 홈 화면
간결해진 구조와 코드
기존 CollectionViewLayout을 사용했다면 Cell 안에 CollectionView를 중첩해야 했지만, Compositional Layout 덕분에 하나의 컬렉션 뷰에 여러 Layout을 적용해서 구현할 수 있었습니다. 뷰 구조가 어떻게 변경되었는지 Debug View Hierarchy로 비교해보겠습니다.

리팩토링 전 vs 리팩토링 후 Debug View Hierarchy 비교
리팩토링 후 복잡했던 view depth가 꽤 줄어든 것을 확인할 수 있습니다.
또한 UICollectionView, Data Source 관련 BoilerPlate Code가 줄었고 코드 가독성도 높아졌습니다.
Self-Sizing 이슈 해결
가장 좋았던 점은 바로 Cell의 Self-Sizing 이슈를 신경 쓰지 않아도 되었던 것입니다. UITableView에서는 Cell 높이가 비동기적으로 변경될 경우에 Cell이 제대로 보이지 않는 경우가 있었습니다. 그래서 비동기 작업이 완료된 시점에 performBatchUpdates() 로 높이를 업데이트해줘야 했는데요. UICollectionView와 Compositional Layout 조합에서는 따로 처리해줄 필요가 없었습니다. 또 Cell 내에 있는 UICollectionView의 스크롤 지점을 유지하기 위해 contentOffset을 캐싱해서 관리할 필요도 없어 편리했습니다.
메모리 사용량 개선
홈 화면 구조를 UICollectionView로 변경한 후 메모리 사용량이 얼마나 개선됐는지 알아보기 위해서 버전 5.7.0과 버전 5.8.2를 동일한 환경과 조건에서 메모리 사용량 측정을 해보았습니다.
[테스트 환경 및 조건]
Device — iPhone 12 Pro
0~30sec — 홈 화면 진입 후 아무것도 하지 않음
30sec — 하단으로 스크롤 시작
30sec~1min — 최하단 도착 시 아무것도 하지 않음

5.7.0 메모리 점유율 테스트

5.8.2 메모리 점유율 테스트
- 홈 화면 진입: 5.7.0 버전 대비 약 49% 감소. (자사 내부 테스트 기준)
- 홈 화면 맨 끝까지 스크롤: 5.7.0 버전 대비 약 16% 감소. (자사 내부 테스트 기준)
마치며
이번 성능 개선 건은 앱 사용자들이 체감하기엔 미미할 수도 있지만 수치상으로는 확연하게 개선된 것을 확인할 수 있었습니다. 저희와 같은 문제를 고민하시는 분들이나 Compositional Layout과 Diffable DataSource를 도입하려는 분들께 이 글이 조금이나마 도움이 되었으면 좋겠습니다. 감사합니다.