들어가며
지난 1편에서 UICollectionView
의 커스텀 레이아웃 기능을 활용해 대화방 UI를 표시하는 방법에 대해 설명했습니다. 이번 글에서는 대화방에 뷰 모델을 적용한 방법과 비동기 콘텐츠 로딩을 처리한 방법을 설명한 뒤 리팩토링하면서 어려웠던 점과 개선된 점, 그리고 앞으로 더 개선해 나가야 할 점들에 대해서 이야기해 보겠습니다.
개선 사항
메시지 뷰 모델(MessageViewModel) 도입
MessageViewModel
클래스는 메시지 셀을 표시하기 위한 데이터를 관리하는 클래스입니다. 메시지 셀과 유사하게 각 메시지 종류별로 서브클래스를 만들어 정의했습니다. 각 서브클래스에서는 cellClass
라는 프로퍼티를 구현하고 해당 메시지 뷰 모델을 표시하기 위해 사용할 셀 클래스를 반환합니다. cellClass
프로퍼티는 ChatView
에서 UICollectionView
에 셀을 등록하는 시점에 사용합니다.
메시지 뷰 모델을 생성할 땐 팩토리 패턴을 이용합니다. 아래와 같은 함수를 이용해 메시지 종류별로 적절한 뷰 모델을 생성해서 반환합니다.
static func viewModel(with message: Message) -> MessageViewModel {
// create `MessageViewModel` object
return viewModel
}
NSFetchedResultsController와 뷰 모델 연동
Core Data의 NSFetchedResultsController
를 이용하면 특정 NSPredicate
를 통해 데이터를 가져와서(fetch) 데이터베이스의 변경 사항을 쉽게 뷰에 반영할 수 있습니다. 이를 이용해서 기존에는 NSFetchedResultsController
에서 전달된 데이터 객체(NSManagedObject
의 서브클래스)를 직접 사용해 셀을 표시하는 데 사용했는데요. 셀 내부에서 데이터 모델을 직접 사용하기 때문에 뷰에서 필요한 데이터로 변환하거나 메시지 콘텐츠를 로드하는 등의 처리 로직이 셀 내부에 존재하게 되어 코드가 복잡해졌습니다. 이 부분을 리팩토링해서 데이터 모델을 바로 사용하지 않고 뷰 모델로 변환하여 뷰 모델 내부에서 필요한 여러 작업을 수행할 수 있도록 변경했습니다. NSFetchedResultsController
를 이용해 데이터를 가져와서 NSManagedObject
를 이용해 뷰 모델 배열을 생성합니다. 이후 NSFetchedResultsControllerDelegate
를 통해 전달된 변경 사항들이 뷰 모델 배열에 반영되면 뷰를 갱신합니다.
뷰 모델을 이용한 메시지 셀 구성
모든 메시지 셀의 상위 클래스인 MessageCell
에 뷰 모델을 통해 셀을 업데이트하는 함수를 추가하고, 하위 클래스에서는 셀에서 사용하려는 클래스로 뷰 모델을 형변환(casting)해서 사용합니다. 메시지 종류마다 사용하는 뷰의 클래스가 다르고 각각 내용을 표시하는 방법도 다르기 때문에, 아래와 같이 상위 클래스에서 공통 함수를 정의하고 각 메시지 종류에 맞게 함수를 오버라이드해서 구현했습니다.
class MessageCell: UICollectionViewCell {
class var messageContentViewClass: MessageContentView.Type {
MessageContentView.self
}
func configureCell(with viewModel: MessageViewModel) {
// configure common subviews
}
}
class StickerMessageCell: MessageCell {
override class var messageContentViewClass: MessageContentView.Type {
StickerMessageContentView.self
}
override func configureCell(with viewModel: MessageViewModel) {
super.configureCell(with: viewModel)
guard let viewModel = viewModel as? StickerMessageViewModel else {
return
}
// configure for sticker view with `StickerMessageViewModel` object
}
}
위와 같이 뷰 모델을 사용해서 셀을 업데이트하면 아래 그림과 같이 뷰와 데이터 모델 사이의 의존성이 사라져 데이터 모델을 자유롭게 변경할 수 있고, 뷰에 표시하기 위한 데이터를 가공하거나 콘텐츠를 로딩하는 작업 등을 뷰 모델에서 처리하므로 뷰의 구조가 단순해지는 장점이 있습니다.
비동기 콘텐츠 로딩 방법 개선
일반적인 텍스트 메시지를 표시할 때는 별도의 서버 로드(load) 작업이 필요하지 않지만, 미디어 형태(이미지, 비디오 등)의 메시지를 표시할 때는 서버에서 데이터를 다운로드하는 작업이 필요합니다. 서버에 요청한 뒤 응답을 처리하는 작업을 각 MessageViewModel
에 구현할 수도 있지만, 그렇게 하면 중복 코드가 불필요하게 늘어나고 전체적인 메시지의 로드 상태를 쉽게 알 수 있는 방법이 없습니다. 이런 단점을 개선하기 위해 아래와 같이 셀과 유사한 형태로 MessageViewModel
의 상위 클래스에 contentLoadStatus
프로퍼티를 정의하고, 서브클래스에서 오버라이드하여 콘텐츠 로딩 작업을 처리할 수 있는 함수를 정의했습니다.
class MessageViewModel {
enum ContentLoadStatus {
case unknown
case loading
case loaded
case failed
}
private(set) var contentLoadStatus: ContentLoadStatus = .unknown
func loadContent(completionHandler: @escaping (_ contentLoadStatus: ContentLoadStatus) -> Void) {
// override this method to load contents asynchronously
}
func unloadContent() -> Bool {
// override this method to unload content when receive memory warning
}
}
class ImageMessageViewModel: MessageViewModel {
override func loadContent(completionHandler: @escaping (_ contentLoadStatus: ContentLoadStatus) -> Void) {
// load image from server
ImageLoader.loadImage(messageID) { [weak self] image in
self.thumbnailImage = image
completionHandler(image != nil ? .loaded : .failed)
}
}
override func unloadContent() -> Bool {
// reset image in memory
self.thumbnailImage = nil
}
}
메시지 셀이 화면에 표시될 때 loadContent()
함수가 호출됩니다. 이때 로딩 결과가 completionHandler
를 통해 전달되면서 내부적으로 contentLoadStatus
가 관리됩니다. 만약 메모리 부족 경고 상황에서 해당 MessageViewModel
이 화면에 표시되지 않았다면, unloadContent()
를 통해 메모리에 로드된 데이터를 해제할 수도 있습니다.
리팩토링 결과
리팩토링 결과 개선된 점과 리팩토링 과정에서 어려웠던 점을 정리해 보았습니다.
개선된 점
기능별 컨트롤러 구현
기존에는 대부분의 기능이 뷰 컨트롤러 내부에 구현되어 있었습니다. 이를 기능별로 분리해 별도의 컨트롤러로 구현하면서, 복잡도가 줄어들고 각 컨트롤러 간 의존 관계가 보기 쉽게 개선되었으며 코드의 가독성도 개선되었습니다. 또한 기능별로 분리했기 때문에 변경 발생 시 외부에 미치는 영향을 최소화할 수 있습니다.
UICollectionView 도입
UITableView
를 사용할 땐 커스텀 뷰를 표시하기 위해 셀을 사용할 수밖에 없었지만, UICollectionView
를 도입하면서 필요할 경우 커스텀 보충 뷰를 추가할 수 있게 되었고, 다양한 커스텀 뷰를 원하는 레이아웃으로 쉽게 표시할 수 있게 되었습니다. 또한 UICollectionViewLayoutAttributes
클래스의 서브클래스를 만들어서 CollectionViewLayout에서 셀에 필요한 레이아웃 관련 데이터를 쉽게 전달할 수 있게 되었습니다. 추가로 레이아웃과 관련된 업데이트는 UICollectionReusableView.apply()
함수를 오버라이드해서 구현하고 셀의 내용 업데이트는 셀 디큐(dequeue) 이후 별도로 처리하도록 변경하면서, 레이아웃 관련 데이터와 메시지 콘텐츠 관련 데이터가 분리되어 관리가 쉬워졌습니다.
셀 업데이트 로직 통일
기존에는 UITableView
를 직접 뷰 컨트롤러에서 사용하면서 셀을 업데이트해야 할 땐 UITableView
의 업데이트 함수들을 사용해서 직접 갱신하는 구조였기 때문에 어디에서 셀을 갱신하고 있는지 잘 파악되지 않는 단점이 있었습니다. 이를 개선하기 위해 Chat UI를 표시하기 위한 커스텀 뷰(ChatView)를 두고, 내부에서 UICollectionView
를 캡슐화(encapsulate)한 뒤 업데이트 함수만 외부로 노출시켜 셀 업데이트에 필요한 작업들을 한곳에서 진행할 수 있게 되었습니다. 또한 MessageViewModel
내부에 notifyUpdate()
함수를 추가해서 뷰 모델이 업데이트되어 셀을 업데이트해야 하는 경우 이 함수를 호출하여 쉽게 셀을 갱신할 수 있게 개선되었습니다.
메시지 뷰 모델 도입
비동기 메시지 콘텐츠 로딩을 포함한 다양한 비즈니스 로직이 뷰에서 분리되면서 뷰의 복잡도가 감소했습니다. 새로운 메시지 타입을 추가할 때도 MessageViewModel
의 서브클래스를 만들어 뷰 모델 팩토리에서 해당 뷰 모델을 반환하도록 구현하는 방법으로 간단하게 추가할 수 있게 개선했습니다.
기타 개선 사항
- 성능을 저하시키지 않는 범위에서 자동 레이아웃을 적용하여 복잡한 레이아웃 코드를 단순화했고, 복잡한 뷰의 크기도 쉽게 계산할 수 있게 개선했습니다.
- Objective-C로 구현되어 있던 대부분의 코드를 Swift로 포팅(porting)하거나 다시 설계해 개발했습니다. 기존 Objective-C 코드 중 약 9% 정도가 삭제됐습니다.
- 싱글턴(singleton) 패턴을 사용해 여러 개의 뷰 컨트롤러 객체를 사용할 경우 문제가 발생할 수 있는 부분이 있었습니다. 이 부분을 뷰 컨트롤러와 라이프 사이클을 맞췄습니다.
어려웠던 점
레거시 코드 문제
이모티콘을 표시하는 기존 메시지 텍스트 처리 로직에서 멀티 스레드 처리를 고려하지 않은 부분이 있었습니다. 이번 리팩토링에서 대화방에 메시지를 표시하는 부분을 새로 개발했지만, 이런 제약 때문에 성능을 개선하는 데 한계가 있었습니다. 그 결과 리팩토링 작업을 완료한 후에도 대화방 진입 속도는 기존과 비슷하거나 약간 더 빨라진 수준입니다. 이 부분은 추후에 개선할 필요가 있다고 생각합니다.
UICollectionView 업데이트 관련 문제
UICollectionView
를 부분적으로 업데이트할 때, IndexPath
가 맞지 않거나 업데이트 전후의 항목 개수가 일치하지 않으면 UICollectionView
내부 Assertion 때문에 크래시가 발생합니다(이 문제는 UITableView
를 사용해도 마찬가지입니다). 이런 문제 때문에 NSFetchedResultsController
변경 이벤트가 발생해 뷰 모델을 업데이트한 후 UICollectionView
를 업데이트하는 과정에서 크래시가 발생하지 않도록 꼼꼼하게 신경 쓸 필요가 있습니다. 혹시라도 크래시가 발생하는 것을 방지하기 위해, 실제 UICollectionView
업데이트하기 전에 변경 이벤트를 통해 업데이트 전과 후의 메시지 카운트를 계산해서 맞지 않을 경우 부분 업데이트 대신 reloadData()
를 이용해 전체 UICollectionView
를 다시 로드하도록 조치했습니다.
자동 레이아웃과 프레임 기반 레이아웃이 혼재
대부분의 메시지 종류에 대해서 메시지 콘텐츠를 표시하는 뷰를 새로 개발했지만, 일부 메시지 종류는 기존에 구현된 뷰를 재활용했는데요. 기존에 구현된 뷰는 자동 레이아웃을 사용하지 않고 프레임 기반의 커스텀 레이아웃을 사용하는 경우가 많았습니다. 뷰 전체가 자동 레이아웃으로 구성된 뷰는 뷰 크기를 쉽게 계산할 수 있지만, 프레임 기반의 레이아웃이 존재하는 뷰는 별도로 크기에 대한 자동 레이아웃 제약 조건을 지정하는 것과 같은 추가 작업이 필요했습니다. 또한 여러 줄로 구성된 UILabel
은 UILabel
의 preferredMaxLayoutWidth
값을 지정해 주지 않으면 레이아웃이 정상적으로 처리되지 않거나 크기 계산이 잘못되는 등의 문제가 있었습니다.
롤백(rollback) 가능한 구조
이번 리팩토링 작업은 대화방 전체를 새로 만드는 수준의 작업이었습니다. 물론 개발 이후 충분한 테스트를 거치지만 그럼에도 출시 후 다양한 환경에서 동작하다 보면 예상치 못한 심각한 문제가 발생할 수도 있습니다. 따라서 기존의 대화방 코드를 유지하면서 새로운 대화방을 개발했으며, 문제가 발생했을 때 언제든지 기존 대화방으로 복구할 수 있도록 개발했는데요. 이 부분이 까다로웠습니다. 또한 리팩토링을 진행하는 도중 대화방에 새로운 기능이 추가되면, 코드가 중복되는 걸 최대한 방지하면서 다른 부분에 영향을 끼치지 않도록 작업하는 것도 상당히 어려운 작업이었습니다.
마치며
두 번에 걸쳐 UICollectionView를 도입해 LINE iOS 대화방을 리팩토링하게 된 계기와 과정을 설명하고 리팩토링 결과를 공유드렸습니다. 이번 리팩토링에서는 UITableView
를 UICollectionView
로 교체하는 부분에 중점을 두었는데요. 사용성을 좀 더 개선하기 위해서는 데이터를 가져오는(fetch) 부분을 메인 스레드에서 백그라운드 스레드로 변경할 필요가 있다고 생각합니다. 또한 기능별로 나눠진 컨트롤러들을 좀 더 체계적으로 정리할 필요가 있고, 화면에 보이지 않는 셀의 뷰 크기를 계산하는 시점을 늦춰서 대화방에 진입하는 속도를 높이는 방식으로 개선하는 것도 가능하다고 생각합니다. 향후 이런 부분에 초점을 맞춰 더욱 개선해 나갈 계획입니다. 긴 글 읽어주셔서 감사합니다.