UICollectionView를 이용한 LINE iOS 대화방 리팩토링 – 1

들어가며

LINE의 대화방 화면은 사용자가 가장 많이 사용하는 화면 중 하나이며 새로운 기능이 계속 추가되고 있습니다. 그에 따라 코드가 점점 복잡해지면서 최근에 리팩토링을 진행했고, 그 과정에서 UICollectionView를 도입했습니다. 저희는 이번 리팩토링 과정에서 사용한 iOS 기술과 뷰(view) 모델 도입을 중점으로 대화방의 코드를 어떻게 개선했는지 블로그를 통해 두 번에 걸쳐 공유하려고 합니다. 

이번 리팩토링 프로젝트에서는 기존에 UITableView를 이용해 구현되어 있던 UI를 UICollectionView를 이용하도록 변경하고, UICollectionViewLayout의 서브클래스를 만들어 원하는 뷰를 자유롭게 대화방에 표시할 수 있도록 변경했습니다. 또한 메시지 셀(cell) 내부의 프레임 기반 레이아웃을 자동 레이아웃 기반으로 변경하여 레이아웃 코드를 단순화했고, 기존에 없던 뷰 모델을 도입하여 복잡한 메시지 콘텐츠 로드와 비즈니스 로직 등을 뷰 내부가 아니라 뷰 모델에서 처리할 수 있도록 개선했습니다. 먼저 이번 1편에서는 UICollectionView를 어떻게 적용하였는지에 대해 설명하겠습니다.

 

기존 코드의 문제점

 

Massive View Controller?

언뜻 메신저 대화방에서는 메시지를 전송하고 수신하는 기능만 제공하고 있다고 생각할 수도 있지만 그렇지 않습니다. 대화방에선 기본적인 메시지 전송 기능 외에도 다양한 부가 기능을 함께 제공하고 있는데요. 예를 들어 사용자가 메시지를 입력할 때 메시지와 관련된 스티커를 추천해 주는 스티커 자동 추천 기능이나 특정 사용자를 언급하는 멘션(mention) 기능, 특정 메시지에 답장하는 기능, 공식 계정을 위해 특별히 제공하는 UI 등 하나하나 나열하기 어려울 정도로 많은 기능을 제공하고 있습니다. 또한 대화방에선 텍스트는 물론 스티커와 이미지, 비디오 등 다양한 종류의 메시지를 표시하고 있으며, 메시지 탭 이벤트와 롱 프레스 이벤트, 스크롤 이벤트와 같은 사용자와의 상호 작용도 처리합니다. 키보드 알림과 메모리 경고 알림, 애플리케이션 상태 변화 알림 등 다양한 시스템 알림과 대화 상대 프로필 변경, 친구 관계 변경, VoIP 통화 시작/종료, 테마 변경과 같은 외부 모듈의 각종 알림도 대화방에서 처리하고 있습니다. 

이렇게 다양한 형태와 종류의 메시지를 화면에 표시하기 위해선 UITableView의 UITableViewDataSource와 UITableViewDelegate를 구현해야 하는데요. 기존에는 모두 하나의 뷰 컨트롤러에 구현되어 있었습니다. 또한 코드 파일 하나에 너무 많은 내용이 포함되어 있었습니다. 카테고리나 익스텐션(extension) 등으로 분리해 놓긴 했지만 뷰 컨트롤러의 복잡도를 근본적으로 해결하는 데에는 한계가 있었습니다. 수년간 다양한 기능을 빠르게 개발하면서 MessageViewController에 너무 많은 기능이 추가되는 바람에 내부적으로 MVC(Massive View Controller)라고 부를 정도로 거대하고 복잡한 뷰 컨트롤러가 되어 있었습니다.

 

UITableView의 한계

UITableView는 iOS 초창기 버전부터 제공되었습니다. UITableView를 이용하면 셀을 재사용할 수 있어 셀을 초기화하는 부담을 줄일 수 있고, 메모리를 절약하여 많은 데이터를 효율적으로 표시할 수 있기 때문에 단순한 항목을 표시할 때는 UITableView로도 충분합니다. 하지만 읽지 않은 메시지를 표시하는 것과 같은 여러 디자인 요소를 추가하고 싶거나, 섹션 헤더나 푸터(footer) 외 다른 곳에 날짜를 표시하고 싶을 때는 그렇지 않습니다. 셀 내부에 뷰를 별도로 추가하여 표시하거나 강제로 섹션을 나누는 등의 일종의 트릭이 필요합니다. 또한 메시지 셀의 높이는 메시지의 종류와 내용에 따라 달라질 수 있습니다. 서로 높이가 다른 다양한 종류의 셀을 표시하기 위해서는 표시하려는 셀을 반환하기 전에 메시지 크기에 맞는 셀의 높이를 미리 계산해서 UITableView에게 전달해야 하고, 성능을 향상시키려면 별도의 캐시를 마련해서 셀의 높이를 관리해야 하는 등의 추가 작업도 필요합니다. 게다가 오래전에 개발된 셀의 경우 layoutSubviews를 오버라이드(override)해서 프레임 기반으로 레이아웃을 처리하고 있었으며, 셀 높이를 계산하는 코드도 별도로 분리되어 있었습니다.

 

뷰 모델의 부재

기존 대화방 코드는 뷰 모델 구조로 설계되지 않았습니다. Core Data NSManagedObject의 서브클래스인 Core Data 모델 객체를 사용해 직접 셀에서 데이터를 표시했습니다. 그 때문에 셀에 표시하려는 데이터를 Core Data 모델 객체에서 읽어 와서 뷰에 표시할 내용으로 변환하는 과정이 필요했습니다. 뷰 내부 값을 변환하거나 다운로드와 같은 작업을 처리하는 코드와 같이 하나의 셀에서 진행되는 다양한 작업과 관련된 코드가 셀 내부에 존재하고 있어서 셀 내부가 매우 복잡했습니다.

 

레거시 코드 

LINE은 2011년에 출시되어 2020년 현재까지 이어지고 있는 장기 프로젝트입니다. 그 사이에 iOS는 iOS3부터 iOS13까지 총 10번의 업데이트를 거쳤고 Swift라는 새로운 언어가 등장하는 등 환경에 많은 변화가 있었습니다. 하지만 매번 새로운 기능 출시에 밀려 코드 리팩토링과 레거시 코드 정리 작업을 충분히 진행할 수는 없었습니다. 특히 대화방은 관련된 레거시 코드가 많았는데요. MessageViewController를 비롯한 대부분의 코드가 Objective-C로 작성되어 있었고, 프레임 기반의 레이아웃 작업 코드도 존재했습니다.

MessageViewController의 카테고리와 익스텐션
layoutSubview 지옥

 

개선 사항

위에서 설명한 문제점들을 해결하기 위해서 처음에는 UITableView를 UICollectionView로 변경하고 뷰 모델을 도입하는 정도로 생각했습니다만, 아예 이번 기회에 전체 코드를 Swift로 새로 작성하는 방향으로 계획을 변경했습니다.

 

UITableView vs UICollectionView

UICollectionView는 iOS6에서 추가된 뷰입니다. UITableView에서 구현하기 어려웠던 여러 가지 레이아웃 커스터마이징 기능을 제공합니다. 별도로 레이아웃 서브클래스를 만들지 않고 UICollectionViewFlowLayout를 사용하는 것만으로도 기본적인 그리드 레이아웃을 구현할 수 있으며, 좀 더 세밀하게 레이아웃을 제어할 필요가 있을 땐 UICollectionViewLayout의 서브클래스를 만들어 구현하는 것도 가능합니다.

UICollectionViewLayout의 서브클래스를 만들어 레이아웃을 구현할 경우, UICollectionView 내부에 뷰가 표시되는 형태에 대해서는 기본적으로 iOS에서 관여하지 않습니다. 대신 UICollectionView에서 화면에 셀이나 보충(supplementary) 뷰를 표시하기 위해 레이아웃의 특정 화면 영역이나 IndexPath에 대한 레이아웃 속성을 요청하면 이를 제공하여 개발자가 원하는 형태로 셀과 보충 뷰의 레이아웃을 구현할 수 있도록 합니다. 대화방 화면의 경우 UICollectionViewFlowLayout 기능만으로는 여러 가지 요소를 표현하기 어려워서 UICollectionViewLayout의 서브클래스를 만들어 내부 레이아웃을 직접 구현하는 방식으로 진행했습니다.

 

Chat UI 커스텀 뷰 구현

먼저 Chat UI를 표현하는 데 최적화된 커스텀 뷰(ChatView) 구현을 목표로 정했습니다. UICollectionView를 뷰 컨트롤러의 뷰에 직접 추가하여 구현할 수도 있지만, 그보다는 UIView의 서브클래스를 만들고 그 안에 private 변수로 UICollectionView를 추가하는 게 좋다고 생각했습니다. 이런 방식을 택하면 아래와 같은 이점이 있습니다.

  • UICollectionView에서 제공하지 않는 아래와 같은 기능을 추가로 쉽게 개발할 수 있습니다.
    • 체크박스를 이용해 특정 메시지를 선택하는 편집 기능
    • 레이아웃 속성을 기반으로 특정 위치로 스크롤하는 기능
    • 자동 스크롤 방지 상태 관리 기능
  • 외부에서 직접 UICollectionView에 접근하는 방식이 아니라서 셀을 업데이트할 때 필요한 레이아웃 캐시 무효화(invalidation) 코드 등이 뷰에 통합됩니다. 따라서 외부에서 그런 부분에 신경 쓰지 않아도 됩니다.
  • 외부에서는 시스템에서 제공한 기본 UICollectionViewCell의 관점이 아닌 메시지 셀의 관점에서 ChatView를 사용할 수 있습니다.

또한 외부에서는 UICollectionView의 데이터 소스와 델리게이트(delegate)를 직접 구현하지 않고 메시지 관점에서 ChatView가 이를 연계하여 처리하도록 구현했습니다. 기본적으로 UICollectionViewDataSource와 UICollectionViewDelegate에서 제공하는 함수의 형태와 최대한 유사하게 정의한 뒤 메시지에 특화된 기능을 별도로 정의했습니다. 아래는 UICollectionView와 ChatView를 비교한 표입니다.

UICollectionViewChatView
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Intfunc numberOfMessages(in chatView: ChatView) -> Int
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath)
func chatView(_ chatView: ChatView, willDisplay cell: MessageCell, at indexPath: IndexPath)
func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath)func chatView(_ chatView: ChatView, didEndDisplaying cell: MessageCell, at indexPath: IndexPath)
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)func chatView(_ chatView: ChatView, didSelectMessageCellAt indexPath: IndexPath)
(없음)func chatView(_ chatView: ChatView, shouldShowDateHeaderAt indexPath: IndexPath) -> Bool
(없음)func chatView(_ chatView: ChatView, dateStringAt indexPath: IndexPath) -> String?

커스텀 CollectionViewLayout 사용

위에서 사용한 UICollectionView는 화면에 셀을 표시하는 역할만 담당합니다. 레이아웃은 UICollectionViewLayout을 구현해서 처리해야 합니다. UICollectionViewLayout에서는 레이아웃을 구현하기 위해 오버라이드할 수 있는 다양한 함수를 제공하고 있는데요. 기본적으로 아래와 같은 프로퍼티와 함수를 구현해야 합니다.

var collectionViewContentSize: CGSize
func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool

UICollectionView의 보충 뷰나 데코레이션 뷰를 사용하는 경우에는 추가로 아래 함수들도 구현해야 합니다.

func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?

커스텀 CollectionViewLayout의 주 역할은 UICollectionView에서 요청하는 레이아웃 관련 정보를 제공하는 것입니다. 레이아웃을 미리 준비해 놓았다가 UICollectionView에서 요청하면 준비한 레이아웃 정보를 제공합니다.

레이아웃 준비

UICollectionViewLayout의 prepare() 함수에서는 UICollectionView에 표시되는 전체 크기를 계산하고 각 셀의 레이아웃 속성을 미리 계산하여 메모리에 적재한 뒤 유지합니다. 레이아웃의 데이터 구조를 설계할 때는, UICollectionView에서 특정 화면 영역이나 특정 IndexPath에 대한 레이아웃 속성을 요청할 때 빠르게 응답할 수 있도록 설계하는 것이 중요합니다. 내부적으로 Dictionary나 Array 등을 활용하여 최대한 빠르게 응답할 수 있도록 설계했습니다. 아래는 CollectionViewLayout 내부에서 사용하는 레이아웃 모델의 구조입니다. 

final class ChatLayout: UICollectionViewLayout {
    typealias LayoutAttributesDictionary = [IndexPath: MessageLayoutAttributes]
 
    struct LayoutModel {
        var contentSize: CGSize = .zero
        private(set) var layoutAttributes: [MessageLayoutAttributes] = []
        private(set) var cellLayoutAttributes: [MessageLayoutAttributes] = []
 
        // key=kind, value=dictionary
        private(set) var supplementaryViewLayoutAttributes: [String: LayoutAttributesDictionary] = [
            ChatLayout.elementKindCollectionViewHeader: [:],
            ChatLayout.elementKindCollectionViewFooter: [:],
            ChatLayout.elementKindDateHeader: [:],
            ChatLayout.elementKindUnreadMarkHeader: [:],
        ]
    }
    // ...
}

layoutAttributes에는 셀과 보충 뷰에 대한 모든 속성이 저장되어 있으며, 이진(binary) 탐색 알고리즘을 사용하기 위해 뷰의 표시 순서(위에서 아래로)로 정렬되어 있습니다. 또한 cellLayoutAttributes에는 메시지 셀의 속성만 저장되어 있으며, IndexPath를 이용해 레이아웃 속성을 빠르게 찾을 수 있습니다.

UICollectionView 레이아웃 구성

 

레이아웃 제공

레이아웃이 준비되면, UICollectionView는 레이아웃으로 필요한 레이아웃 속성을 요청합니다. 아래는 셀을 표시할 때 사용하는 함수입니다.

func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?

IndexPath 기반의 요청일 경우 미리 준비된 배열에서 해당 인덱스에 접근하는 방법으로 쉽게 구현할 수 있지만, 화면 표시 영역 기반의 요청일 때는 이를 효율적으로 처리하기 위한 다른 방법이 필요합니다. 모든 속성을 확인해서 주어진 영역과 겹치는 레이아웃 속성을 반환하는 간단한 방법도 있습니다만, 좀 더 성능을 높이기 위해서 셀의 표시 순서 기반으로 정렬된 배열을 유지하면서 이진 탐색 알고리즘을 이용해 특정 영역의 뷰 레이아웃에 필요한 속성을 찾는 방법을 사용했습니다.

 

UICollectionViewLayoutAttributes 서브클래스 활용

UICollectionViewLayoutAttributes에는 셀이나 보충 뷰의 레이아웃을 처리할 때 필요한 frame과 boundscentertransformalpha 등의 속성이 있습니다. 이런 속성들을 통해 UICollectionView는 내부의 셀과 보충 뷰들을 해당 위치에 표시하거나 뷰의 속성을 조정합니다. 추가로 UICollectionViewLayoutAttributes 클래스의 서브클래스를 만들어 원하는 값을 추가하고, 이 값을 활용하여 셀 내부 레이아웃에 활용할 수도 있습니다. 이번 리팩토링에서는 UICollectionViewLayoutAttributes의 서브클래스로 MessageLayoutAttributes 클래스를 만들고 여기에 아래와 같은 구조체를 추가해서 필요한 정보를 셀로 전달했습니다.

struct MessageCellLayoutData: Equatable {
    var messageDirection: MessageDirection = .receive
 
    var messageContentSize: CGSize = .zero
    var messageCellMargins: UIEdgeInsets = .zero
    var messageContentPadding: UIEdgeInsets = .zero
 
    // background (balloon)
    var backgroundType: MessageBackgroundType = .none
    var backgroundInsets: UIEdgeInsets = .zero
    var showsBubbleTail = false
 
    // profile
    var profileImageLayout: ProfileImageLayout = .hidden
    var showsUserName = false
 
    // ...
}
 
class MessageLayoutAttributes: UICollectionViewLayoutAttributes {
    var layoutData = MessageCellLayoutData()
}

위 구조체에선 메시지 방향(.send.receive.unknown)과 메시지 크기(messageContentSize), 프로필 이미지 표시 형태(horizontalvertical), 메시지 배경 종류 등 셀의 레이아웃을 처리하는 데 필요한 정보를 정의합니다.

 

메시지 셀 구조 개선

메시지를 표시하는 셀의 구조는, 모든 종류의 메시지에서 공통으로 필요한 부분을 담당하는 기본(base) 클래스인 MessageCell을 구현한 뒤, 메시지 종류별로 서브클래스를 만들어 사용하는 구조로 개발했습니다. 예를 들어 스티커를 표시하는 메시지는 MessageCell의 서브클래스인 StickerMessageCell을 만들어 구현합니다. 상위 클래스인 MessageCell에선 프로필 이미지와 사용자 이름, 말풍선 배경, 전송 시간 등의 공통 요소를 담당하고, 하위 클래스(예를 들어 StickerMessageCell)에서는 메시지 내용을 표시하기 위한 뷰 클래스를 정의하며 필요한 정보가 있다면 상위 클래스에서 추가로 오버라이드해 반환합니다.

class StickerMessageCell: MessageCell {
    override class var messageContentViewClass: BaseMessageView.Type {
        return StickerMessageView.self
    }
}

MessageCell은 오버라이드한 함수를 통해 뷰 유형에 맞는 메시지 콘텐트 뷰(Message Content View)를 생성하여 레이아웃을 처리합니다.

 

자동 레이아웃 제약 조건(auto layout constraints) 관리

사용자가 UICollectionView를 스크롤하면 화면에 보이지 않는 셀들을 재사용해서 새로운 메시지를 표시해야 합니다. 이를 위해 시스템에서 제공하는 updateConstraints()를 구현하여 자동 레이아웃 제약 조건을 조정하고, 필요하면 setNeedsUpdateConstraints()를 호출하여 제약 조건을 업데이트할 수 있습니다. 그러나 메시지가 변경될 때마다 모든 제약 조건을 삭제하고 다시 만드는 방식은 비효율적이며, 스크롤 성능을 저하시킬 수 있습니다. 이를 해결하기 위해 내부적으로 여러 개의 ‘더티 플래그(dirty flag)’를 관리하면서 업데이트가 필요한 부분만 플래그를 설정해 updateConstraints()에서 처리합니다.

 

커스텀 레이아웃 속성 적용

위에서 정의한 커스텀 레이아웃 속성(attributes)은 UICollectionReusableView에 정의된 apply(_:) 함수를 사용하여 다음과 같은 형태로 사용할 수 있습니다.

override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
    super.apply(layoutAttributes)
 
    guard let layoutAttributes = layoutAttributes as? MessageLayoutAttributes else {
        return
    }
 
    let oldLayoutData = self.layoutData
    let layoutData = layoutAttributes.layoutData
 
    // update auto layout constraints
    if oldLayoutData.messageDirection != layoutData.messageDirection {
        setNeedsUpdateConstraints(
            flags: [.contentContainerView, .profileImageView, .userNameButton, .messageStatusView, .shareButton]
        )
    }
 
    // adjust layout using `layoutAttributes`
    // ...
}

 

커스텀 보충 뷰 사용

UICollectionView를 사용하면 UITableView에서 사용할 수 없었던 다양한 보충 뷰를 사용할 수 있습니다. 헤더와 푸터뿐 아니라 그 외 다양한 종류의 뷰를 원하는 위치에 배치하여 화면을 구성할 수 있어서 대화방에서는 메시지와 함께 다양한 정보를 보여주고 있습니다. 예를 들어 메시지를 받은 날짜나 사용자가 어디까지 메시지를 읽었는지 표시하거나, 캡처 모드에서 선택 영역을 표시하는 것 등이 있습니다. UITableView 대화방에서는 이런 정보를 표시하기 위해 비효율적인 구조와 트릭을 사용해야 했지만, UICollectionView 대화방에서는 커스텀 보충 뷰를 사용해서 이런 구조를 개선할 수 있었습니다.

 

DateHeaderView 표시 방법 개선

UITableView 대화방에서는 날짜를 표시하는 DateHeaderView를 모든 셀에 포함시켜 놓고 이전 메시지와 날짜를 비교해서 각 일자별 최상단 셀에서만 DateHeaderView를 표시하는데요. 이런 방법은 아래와 같이 여러 부분에서 추가 코드가 필요합니다.

  1. 각 메시지가 그날의 가장 최신 메시지인지를 매번 확인해야 합니다.
  2. 셀 크기를 계산하는 시점에 DateHeaderView의 유무를 고려해야 합니다.
  3. 셀을 화면에 배치하는 시점에 DateHeaderView의 유무를 고려해야 합니다.
DateHeaderView를 포함하고 있는 TableViewCell

각 셀은 화면에 나타날 때마다 hasDateHeader 확인을 거쳐 DateHeaderView를 표시하거나 숨겨야 합니다. 이 과정은 비효율적이고 로직 복잡도를 증가시킵니다. 하지만 UICollectionView를 사용하면 보충 뷰와 UICollectionViewCell을 완전히 분리할 수 있습니다. UICollectionViewLayout의 prepare() 함수에서는 셀뿐 아니라 보충 뷰의 레이아웃 정보도 함께 구성할 수 있습니다. DateHeaderView 레이아웃 정보를 포함한 레이아웃 모델을 만들면 추가 작업 없이 셀과 별개로 날짜를 표시할 수 있습니다.

새로운 대화방에서는 DateHeaderView를 포함해 아래와 같이 총 4가지의 커스텀 보충 뷰를 관리하고 있습니다.

// Key=Kind, Value=[IndexPath: SupplementaryLayoutAttributes]
private(set) var supplementaryViewLayoutAttributes: [String: LayoutAttributesDictionary] = [
    ChatLayout.elementKindCollectionViewHeader: [:],
    ChatLayout.elementKindCollectionViewFooter: [:],
    ChatLayout.elementKindDateHeader: [:],
    ChatLayout.elementKindUnreadMarkHeader: [:],
]
셀과 분리한 보충 뷰

 

CaptureHighlightMaskView 추가

대화방에서는 메시지 캡처 기능을 제공합니다. 메시지 캡처 기능에선 아래와 같이 사용자가 선택한 캡처 영역을 하이라이트 처리해서 보여줄 필요가 있습니다. 

메시지 캡처 선택 화면

이를 위해 UITableView 대화방에서는 각 셀에 CaptureDimmingView를 추가했습니다. 셀이 캡처 영역에 포함되면 CaptureDimmingView를 숨기고, 포함되지 않으면 표시하는 방식으로 사용자가 선택한 영역을 보여줍니다. 이는 간단한 해결 방법이긴 하지만, DateHeaderView와 마찬가지로 셀에 불필요한 뷰를 추가해야 합니다. 이런 구조를 UICollectionView를 사용해서 개선할 수 있습니다. 아래와 같이 UICollectionView 전체 영역을 차지하면서 선택된 메시지 영역에 투명한 홀(hole)이 있는 CaptureHighlightMaskView를 보충 뷰로 정의하고, UICollectionViewLayout의 prepare()에서 CaptureHighlightMaskView의 레이아웃 정보를 구성한 뒤, 캡처 모드에서 CaptureHighlightMaskView를 보여줍니다. CaptureHighlightMaskView는 셀과 분리된 하나의 뷰로 관리할 수 있습니다.

CaptureHighlightMaskView

 

마치며

이번 글에서는 LINE iOS 클라이언트에서 대화방의 UI를 표시하기 위해 UICollectionView 커스텀 레이아웃을 어떻게 활용했는지 설명했습니다. 다음 편에서는 뷰 모델 구현 방법과 Core Data의 NSFetchedResultsController 활용 방법, 그리고 비동기 콘텐츠 로딩 방법을 설명하겠습니다. 많이 기대해 주세요!