리팩토링 과정에서 겪은 기술 문제와 해결 과정
Woody • Won Heo, iOS Engineer
안녕하세요 채널톡 iOS 엔지니어 우디입니다.
채널톡에서 채팅은 많은 사용자들이 이용하는 핵심 기능입니다. 이번 게시글에서는 채팅 리팩토링 과정과 어떻게 안정성과 성능을 개선했는지 공유하고자 합니다.
aka AsyncDisplayKit
Facebook Paper 프로젝트에서 시작된 Texture는 복잡한 인터페이스를 부드럽고 빠르게 처리할 수 있도록 도와주는 iOS UI 프레임워크입니다. UIKit과 다르게 Texture는 레이아웃 계산과 렌더링을 백그라운드 스레드에서 처리하기 때문에 성능 면에서 큰 장점이 있습니다. 또한 선언형 UI API를 지원해서 레이아웃을 빠르게 설계할 수 있습니다.
채널톡 iOS 팀은 이러한 Texture를 기반으로 채팅 관련 화면들을 구현했고 새로운 기능들을 추가해 왔습니다. 하지만 Texture의 주요 기여자들이 떠나며 라이브러리의 유지 보수가 이루어지지 않았습니다. 이로 인해 크래시와 프리징 같은 심각한 문제들이 쌓이고 기술 부채도 증가했습니다.
서비스에 큰 영향을 주는 문제들은 팀에서 직접 수정했지만, 근본적으로 해결하기에는 한계가 있었습니다. 따라서 Texture를 제거하고 UIKit으로 채팅을 리팩토링하기로 결정했습니다.
2016년부터 서비스가 제공된 채널톡은 고객, 팀 메신저를 시작으로 제품이 성장하며 AI, 전화까지 여러 기능이 채팅에 추가되었습니다. 이에 따라 메시지에는 다양한 컨텐츠가 포함되었습니다.
메시지 하나에 프로필, 텍스트, 버튼, 비디오, 사진, 파일, 리액션, 스레드 등등 다양한 컨텐츠가 포함될 수 있습니다.
데이터에 따라 여러 조합의 메시지 UI 형태가 될 수 있는데요. 위와 같은 구조는 메시지를 서로 다른 셀로 나누어서 재사용하기 어렵다는 것을 의미합니다. 또한 채널톡은 업무용 메신저로 사용되기 때문에 천 줄이 넘는 긴 텍스트 메시지도 말 줄임 없이 보입니다.
Texture는 비동기 레이아웃과 컴포넌트를 사용하고 셀을 재사용하지 않아서 성능상 큰 문제는 없었습니다. 하지만 UIKit은 레이아웃 처리가 모두 메인 스레드에서 수행되기 때문에 리팩토링 과정에서 기존 채팅 성능을 유지하는 것은 큰 기술적 과제였습니다.
채널톡은 채팅과 관련된 많은 기능을 가지고 있습니다. 기존에 메시지 뷰가 ASCellNode로 되어 있었기 때문에 해당 화면들도 Texture를 기반으로 구현되어 있었습니다. 따라서 UIKit으로 리팩토링을 진행하면서 관련된 기능들도 함께 작업이 필요했고 어떤 구조를 선택할지 고민이 필요했습니다.
Texture에서 제공하는 ASCollectionNode를 제거하고 UICollectionView로 변경하며 처음 겪었던 문제는 성능이었습니다. 오토레이아웃과 콜렉션 뷰의 estimated size를 사용하는 경우 아래와 같은 과정을 거칩니다.
셀이 추가될 때 estmiated size를 참고해서 크기를 예측하고 contentSize를 계산합니다.
preferredLayoutAttributesFitting
에서 오토레이아웃을 통해 크기를 계산하고 UICollectionViewLayoutAttributes를 반환합니다.
func invalidationContext(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes)
에서 계산된 값을 통해 셀 크기를 결정합니다.
contentSize를 다시 계산합니다.
위 과정에서 내부 시스템 로직과 오토레이아웃 계산 로직이 수행되며 메인 스레드에 큰 부담을 주게 됩니다. 이를 해결하기 위해 CollectionViewLayout을 별도로 구현했고, ViewModel을 통해 셀 크기를 미리 결정할 수 있는 LayoutData 구조를 도입했습니다.
ViewModel을 통해 높이를 미리 구할 수 있기 때문에 불필요한 2, 3번 과정은 수행되지 않도록 preferredLayoutAttributesFitting
메서드를 오버라이딩했습니다. 또한 LayoutData를 UICollectionViewLayoutAttributes의 프로퍼티로 추가해서 셀의 apply 시점에서 해당 정보를 활용해 레이아웃을 업데이트하도록 했습니다. 이를 통해 UICollectionView로 전환하고 성능을 최적화하여 UI를 구현할 수 있었습니다.
채널톡은 채팅과 관련된 많은 기능이 있습니다. 공통으로 사용하는 메시지뿐만 아니라 특정 화면에서만 사용하는 UI도 존재합니다.
“팀챗”에서는 날짜 셀이 표시되고 “스레드”에서는 댓글 개수가 보입니다.
지금까지 구현한 ChatStreamLayoutAttributes은 구체 타입으로 고정되어 있기 때문에 다른 화면에서 공통으로 사용되지 않는 LayoutData 정보도 모두 추가해야했습니다. 이를 해결하기 위해 제네릭을 도입했습니다.
LayoutDatable는 LayoutData가 채택하는 프로토콜입니다. 기본값을 지정하기 위해 default라는 case를 강제합니다. 그리고 UICollectionViewLayout 부터 제네릭을 사용해서 특정 LayoutData 타입에 의존하지 않도록 했습니다.
이를 통해 각 화면에서 필요한 LayoutData를 선언하고 UICollectionViewLayout 로직을 공유하여 여러 채팅 기능을 구현할 수 있었습니다.
리팩토링 과정에서 메시지 셀에 컨텐츠를 추가할 때마다 스크롤이 버벅대는 문제가 있었습니다. 일반적으로 비슷한 형태를 가진 셀에 동적으로 보이고 숨겨지는 UI가 있다면 아래와 같이 isHidden을 사용하는 것을 권장합니다.
removeFromSuperView의 경우 부모 뷰와 responder chain에서 자신을 제거하고 오토레이아웃 엔진에서 제약을 삭제하는 등의 복잡한 과정을 거치기 때문입니다. 따라서 셀 생성자에서 자식 뷰를 미리 생성 및 추가하고 상태에 따라 isHidden 값을 조정하는 방식을 주로 사용합니다.
하지만 채팅 메시지 셀은 컨텐츠가 매우 복잡했기 때문에 자식 뷰가 늘어날 때마다 셀 생성자에서 큰 부하가 발생했습니다.
메시지 셀 생성에 120ms 가 걸려 스크롤 성능이 크게 저하되었습니다.
해당 문제를 어떻게 해결할지 고민하던 중 swift의 lazy 키워드가 떠올랐습니다. 대부분의 경우에는 모든 컨텐츠 뷰가 한꺼번에 보이지 않기 때문에 필요한 경우에만 뷰를 지연 생성하여 레이아웃 사이클의 부하를 줄이고자 시도했습니다.
모든 컨텐츠 뷰는 ChatMessageContentable라는 프로토콜을 채택합니다. 그다음 뷰 생성이 필요하다면 addSubView와 오토레이아웃 설정을 수행하고, 이미 뷰가 존재한다면 해당 과정을 넘어가고 컨텐츠를 업데이트합니다. 이를 통해 셀 생성자에서 컨텐츠를 항상 만드는 것이 아니라 필요한 경우에만 생성하도록 구조를 변경했습니다.
개선 전 - 최상단으로 스크롤 하는 데 487ms 가 걸립니다.
개선 후 - 최상단으로 스크롤 하는 데 487ms → 320ms로 개선되어 코드 실행시간이 약 34% 단축되었습니다.
지연 생성 구조를 통해 텍스트, 사진, 비디오, 리액션등이 포함된 일반적인 팀 채팅방에서 스크롤 성능을 개선할 수 있었습니다.
UIKit은 텍스트를 표현하는 여러 컴포넌트를 제공합니다. 이 중에서 UITextView는 여러 줄의 텍스트를 표현하는 데 적합하고, 링크와 전화번호 같은 데이터를 자동으로 인식합니다. 또한 UITextViewDelegate를 통해 텍스트 아이템 클릭을 감지하고 처리할 수 있습니다.
하지만 채팅 기능에서는 긴 텍스트를 주로 다루게 되는데요. 이때 UITextView를 사용하면 내부 시스템과 여러 텍스트 서식 로직에 의해 큰 메인 스레드가 부하가 발생할 수 있습니다.
긴 텍스트의 경우 attributedText를 설정하는 로직에서만 73ms가 소요됩니다.
이를 개선하기 위해 저수준에서 동작하는 CoreText 프레임워크를 사용하여 오버헤드가 적은 텍스트 컴포넌트를 구현했습니다.
UIGraphicsImageRenderer를 통해 Core Graphics 이미지 렌더링을 준비합니다. 그런 다음 NSAttributedString를 통해 CTFramesetter를 생성하고, CTFrameDraw로 주어진 CGContext에 텍스트를 그립니다. 그 후 렌더링 결과인 CGImage를 CALayer contents에 대입하면 화면에 텍스트를 표시할 수 있습니다.
그리고 텍스트 컴포넌트 크기를 계산하는 CoreTextSizeCalculator를 구현하여 텍스트 콘텐츠에 따라 해당 뷰의 크기를 예측하고 배치할 수 있도록 했습니다.
문자 개수에 따라 렌더링에 걸리는 시간
커스텀 텍스트 컴포넌트를 구현한 후 렌더링의 걸리는 시간을 비교해보았는데요. UITextView 보다 좋은 성능을 가지고, 보여주는 문자 개수가 늘어날때마다 큰 차이가 발생합니다. 더 나아가 CoreText는 백그라운드에서 렌더링을 처리할 수 있기 때문에 추가적인 성능 개선의 여지도 있습니다. 이를 통해 채팅을 스크롤 할 때 생기는 병목을 최소화했습니다.
하지만 CoreText는 낮은 수준의 API를 직접 사용하기 때문에 텍스트 정보를 수동으로 처리해야 합니다. 또한 이미지 첨부, 링크 클릭, 복잡한 서식 표현은 지원하지 않기 때문에 필요하다면 직접 구현해야 하는 단점이 있습니다.
채팅 기능을 UIKit으로 리팩토링하면서 Texture를 제거할 수 있게 되었는데요. 이와 연계된 PINCache, PINOperation 등과 같은 Objective-C 기반의 라이브러리도 함께 제거했습니다. 이를 통해 Texture 내부 로직으로 인해 발생했던 크래시와 프리징과 같은 버그도 함께 해결되어 제품 안정성이 개선되었습니다.
채팅 리팩토링뿐만 아니라 입력기 개편 작업도 함께 작업되어 배포되었는데요. 많은 기능이 한꺼번에 변경되었기 때문에 피처 플래그 기능을 도입해서 언제든지 기존 채팅으로 롤백할 수 있도록 했습니다. 그 후 점진적인 배포를 통해 안정성을 검증하고 작업을 마무리할 수 있었습니다.
많은 책임을 가지고 있던 채팅 객체를 리팩토링했습니다. 소켓, 메시지 정렬 및 업데이트, 셀 생성 등 각 역할을 서로 다른 객체로 나누어서 코드 가독성을 개선하고 SRP 원칙을 준수하도록 했습니다.
기존에는 메시지를 Array를 통해 처리하고 있었는데요. OrderedDict를 도입해서 특정 메시지를 가져오고, 중복을 제거하는 로직을 O(N)에서 O(1)로 개선했습니다.
이번 게시글에서는 채팅 리팩토링을 진행한 이유와 그 과정을 공유했습니다. 여러 기능을 함께 수정해야 하는 대규모 작업이었지만 팀원분들과의 긴밀한 협업, 테스트 그리고 코드 리뷰를 통해 큰 문제 없이 배포할 수 있었습니다.
더 나아가 추가로 개선할 부분도 남아 있습니다. 성능을 위해 LayoutData 구조를 도입하면서 개발자가 직접 뷰 크기를 계산해야 하는데요. 실수할 여지가 있기 때문에 유닛 혹은 스냅샷 테스트를 통해 로직을 교차 검증할 필요가 있습니다. 또한 채널톡 뿐만 아니라 채널톡 SDK, 채널X에도 채팅 기능이 존재합니다. 따라서 서비스에서 공통으로 사용하는 채팅 로직을 모듈로 통합하는 것도 고려하고 있습니다.
이후에 위 내용들을 중점으로 채팅 기능을 계속 개선해 나갈 예정입니다. 긴 글 읽어주셔서 감사합니다.
We Make a Future Classic Product
채널팀과 함께 성장하고 싶은 분을 기다립니다