LINE Corporation이 2023년 10월 1일부로 LY Corporation이 되었습니다. LY Corporation의 새로운 기술 블로그를 소개합니다. LY Corporation Tech Blog

Blog


UICollectionView를 이용한 LINE iOS 대화방 리팩토링 - 2

들어가며

지난 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를 통해 전달된 변경 사항들이 뷰 모델 배열에 반영되면 뷰를 갱신합니다.

This image has an empty alt attribute

뷰 모델을 이용한 메시지 셀 구성

모든 메시지 셀의 상위 클래스인 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
    }  
}

위와 같이 뷰 모델을 사용해서 셀을 업데이트하면 아래 그림과 같이 뷰와 데이터 모델 사이의 의존성이 사라져 데이터 모델을 자유롭게 변경할 수 있고, 뷰에 표시하기 위한 데이터를 가공하거나 콘텐츠를 로딩하는 작업 등을 뷰 모델에서 처리하므로 뷰의 구조가 단순해지는 장점이 있습니다.

This image has an empty alt attribute

비동기 콘텐츠 로딩 방법 개선

일반적인 텍스트 메시지를 표시할 때는 별도의 서버 로드(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()를 통해 메모리에 로드된 데이터를 해제할 수도 있습니다. 

This image has an empty alt attribute

리팩토링 결과

리팩토링 결과 개선된 점과 리팩토링 과정에서 어려웠던 점을 정리해 보았습니다.

개선된 점

기능별 컨트롤러 구현

기존에는 대부분의 기능이 뷰 컨트롤러 내부에 구현되어 있었습니다. 이를 기능별로 분리해 별도의 컨트롤러로 구현하면서, 복잡도가 줄어들고 각 컨트롤러 간 의존 관계가 보기 쉽게 개선되었으며 코드의 가독성도 개선되었습니다. 또한 기능별로 분리했기 때문에 변경 발생 시 외부에 미치는 영향을 최소화할 수 있습니다.

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) 부분을 메인 스레드에서 백그라운드 스레드로 변경할 필요가 있다고 생각합니다. 또한 기능별로 나눠진 컨트롤러들을 좀 더 체계적으로 정리할 필요가 있고, 화면에 보이지 않는 셀의 뷰 크기를 계산하는 시점을 늦춰서 대화방에 진입하는 속도를 높이는 방식으로 개선하는 것도 가능하다고 생각합니다. 향후 이런 부분에 초점을 맞춰 더욱 개선해 나갈 계획입니다. 긴 글 읽어주셔서 감사합니다.