grep

Engineering

사용성을 고려한 앱 구조 만들기

이승연여기어때

2024년 12월 27일

원문에서 보기 ↗

여기어때 iOS 카테고리 홈 개선기

로딩이 오래 걸리는 앱을 사용해 보신 경험이 한 번쯤은 있으실 겁니다. 저는 앱개발자로서(?) 다른 서비스를 이용할 때 대부분 앱을 사용하는 편인데요. 앱을 사용하던 중에 갑자기 화면이 나오지 않거나 무한 로딩이 돌면 답답하고 조금 짜증이 나기도 하고요. 이거 혹시 에러 난 거 아닌가? 😥 라는 생각이 들기도 합니다. 기다려보다가 너무 느리면 아예 앱을 꺼버리는 상황도 발생합니다. 그리고 다음부터는 그 앱을 잘 안쓰게 되죠.

로딩 시간 3초의 법칙

구글 리서치에 따르면 로딩 시간에 따른 이탈률은 아래와 같다고 합니다.

이렇듯 화면이 노출되기까지 로딩 시간이 오래 걸릴수록 사용성에 대한 불편함은 물론이고 사용자 이탈률 또한 증가합니다. 이탈률이 증가할수록 서비스 사용자가 줄어들면서 매출에 영향이 생길 수 있기 때문에 이탈률 증가는 서비스 측면에서 매우 큰 손해라고 볼 수 있습니다.

사용성은 높이고 이탈률은 줄여보자🤓

여기어때 카테고리홈 화면

카테고리 홈은 여기어때 홈 화면에서 모텔, 호텔, 펜션, 캠핑, 게스트 하우스 아이콘을 탭 했을 때 나오는 화면입니다. 여기어때 앱을 사용하는 사용자가 숙소 탐색을 시작하는 포인트가 크게 세 군데 있는데요. 검색, 주변과 더불어 카테고리 홈이 그중 하나입니다.

카테고리 홈은 숙소 탐색 여정의 시작 시점에서 광고 및 기획전, 키워드 검색, 지역 검색 등 핵심 기능들을 정돈해서 사용자에게 제공하며 사용자 상호작용에 따라 알맞은 숙소들을 사용자에게 제공해 주는 중요한 교두보 역할을 하고 있습니다.

여기어때 활성 사용자 5명 중 1명은 모텔 카테고리 홈에 진입한다고 합니다. 모텔 외에 다른 카테고리까지 합치면 더 많은 사용자들이 카테고리 홈을 이용하고 있는데요. 이렇듯 카테고리 홈은 많은 사용자가 이용하는 중요한 화면이다 보니 사용자에게 쾌적한 사용성을 제공할 필요성이 있었습니다.

그래서 카테고리 홈 리뉴얼 시점에 웹 뷰로 되어있던 화면을 네이티브로 전환하기로 했습니다.

Server Driven UI 적용

웹 뷰와 달리 iOS 앱은 UI 변경사항을 즉시 반영할 수 없고 항상 애플의 심사를 거쳐야 하는데요. 이런 단점을 해결하고 네이티브 앱의 성능과 사용자 경험을 유지하면서도 UI 변경사항에 대한 유연성을 확보하기 위해서 Server Driven UI를 적용했습니다.

Server Driven UI를 적용하면 앱 스토어 심사 없이 CMS에서 값을 변경해서 컨텐츠 내용을 즉시 수정하고 반영할 수 있습니다. 뿐만 아니라 모듈의 순서나 구성까지도 언제든지 변경하고 즉각 반영할 수 있습니다. 즉, 앱개발자가 매번 수정사항에 대해서 대응을 하지 않아도 된다라는 것이죠.

화면 노출 방법에 대한 고민

카테고리 홈은 모듈마다 API를 따로 호출해야 하는 구조로 되어있습니다. 각 카테고리 홈은 CMS 응답값을 바탕으로 총 3개의 API를 추가로 호출해야합니다.

이 경우 화면을 그리는 방법에는 크게 두 가지가 있습니다.

  1. 모든 응답을 받은 후 뷰 그리기
  2. 응답이 들어올 때마다 뷰 업데이트

두 가지 방법의 장단점

진입 시점에 조금 기다리게 하더라도 모든 응답을 받은 후 한 번에 완성된 화면을 보여주는 게 사용자 경험에 오히려 쾌적할 경우도 있고, 미완성된 화면이더라도 우선 뷰를 띄운 뒤에 응답을 받는 대로 사용자에게 제공하는 게 더 유리한 경우도 있습니다.

쾌적한 사용성을 목표로 어느 것이 나을지 고민해 보고 적재적소에 잘 활용해야 하는데요. 처음에는 API의 응답을 모두 받았을 때 뷰를 한 번에 노출하는 방법으로 구현을 했었습니다. 한 번에 완성된 화면을 노출하는 것이 사용자가 느끼기에 더 매끄러울 것으로 생각했었기 때문입니다.

그런데 구현하다 보니 API 응답을 받을 때마다 즉시 뷰를 노출하는 게 더 장점이 많다는 생각이 들었습니다. 두 가지 방법의 장/단점을 정리하면서 카테고리 홈은 이 두 가지 방법 중에 어떤 방법을 사용해야 적절할지 다시 고민해 보았습니다.

모든 응답을 받은 후 뷰를 그리는 방식의 경우 가장 느린 API 응답을 받은 후에 뷰를 그립니다. 따라서 여러 API 중 하나라도 응답이 늦어지면 뷰가 그려지지 않아 사용성이 저하되는 이슈가 발생합니다. 속도가 빠른 API라면 괜찮지만, 속도가 비교적 느린 API가 있다면 응답값을 모두 받을 때까지 기다렸다가 가장 늦게 들어온 응답 이후에 뷰가 그려지게 됩니다.

예를 들어 광고 상품 API는 10초, 지역 API는 0.1초, 배너 API가 1초라고 했을 때 10초 동안 빈 화면에 로딩바만 뜬 상태가 지속되다가 광고 상품 API 응답을 받고 나서야 뷰가 노출될 것입니다.

설명을 위해 극단적으로 예시를 들은 것이지만 만약 네트워크가 느리거나 여러 API 중 하나라도 응답이 늦게 들어온다면, 사용자가 화면이 뜨기까지 기다리다가 지쳐 결국 앱을 꺼버리는 최악의 사용 시나리오에 직면할 가능성이 높습니다.

결론: 응답을 받은 후 즉각적인 뷰 업데이트⚡️

이런 이유로 API 응답을 받을 때마다 즉각적으로 뷰를 업데이트하는 방식이 더 적합하다고 생각했습니다.

응답을 빨리 받은 모듈은 빨리 노출되고, 느리게 받은 경우에는 늦게 노출될 것입니다. 만약 광고 상품 모듈이 늦게 뜨더라도 사용자는 지역 모듈을 선택해서 숙소를 탐색할 수 있습니다. 모든 API가 응답이 없어서 화면에 표시할 것이 없지 않은 이상, 사용자가 앱을 이탈하는 최악의 시나리오를 방지할 수 있게 개선했습니다. 앱 화면 노출 속도를 줄임과 동시에 이탈률도 줄이고 사용성은 높일 수 있었습니다.

에러 아니에요, 로딩 중이에요🙏

API 리스폰스가 빨리 와서 뷰를 빨리 그려줄 수 있다면 그 방법이 가장 좋은 방법이겠지만, 현실은 그렇지 않은 경우가 많습니다.

모텔 카테고리 홈의 주변 추천 숙소 모듈은 다른 모듈보다 응답 속도가 느린 편이었는데요. 모듈이 그려지는 속도가 느리다 보니 사용자에게 데이터를 받아오고 있음을 알려줄 필요성이 있었습니다. 게다가 주변 추천 숙소 모듈은 진입하자마자 바로 화면에 보이는 위치에 있는 모듈이기 때문에 API 응답을 받은 후에 뷰를 노출할 경우 주변 추천 숙소 모듈이 갑자기 툭 튀어나오면서 이미 그려져 있던 다른 모듈이 뒤로 밀리는 것이 눈에 띄게 보이는 현상이 발생합니다. 잘못하면 버벅거리는 것처럼 보일 수도 있겠죠.

이 점을 해결하기 위해서는 주변 추천 숙소 모듈의 높이를 미리 고정으로 잡아두고 로딩을 띄우는 것이 좀 더 매끄러운 사용자경험을 제공할 수 있겠다고 생각했습니다.

그 결과물!

주변 추천 숙소 응답이 늦게 들어오더라도 스크롤을 유지한 상태에서 바로 뷰를 노출할 수 있고, 사용자는 로딩 바를 노출되고 있는 것을 보고 데이터를 받아오고 있는 중이라는 것을 인지할 수 있습니다. 만약 API 응답이 늦게 오더라도 에러가 발생한 것이 아니라는 것을 알 수 있습니다.

카테고리홈은 모두 Compositional Layout과 Diffable DataSource를 사용하여 구현했습니다. 이전에 여기어때 홈 화면에 Compositional Layout과 Diffable DataSource를 적용한 경험은 아래 글을 참고해주세요.

Compositional Layout과 Diffable DataSource로 홈 리팩토링하기

여기어때 iOS 앱 홈 화면을 리팩토링한 경험을 공유합니다techblog.gccompany.co.kr

Compositional Layout으로 어떻게 백그라운드 뷰와 로딩 뷰를 구현했는지 보여드리겠습니다.

먼저 회색 백그라운드 뷰를 위한 PromotionSectionBackgroundView와 로딩 뷰를 보여주기 위한 PromotionItemSpinnerView를 생성했습니다.

// 주변 추천 숙소 섹션
final class PromotionItemSection: CategoryHomeSection {
    // 생략..

    // 레이아웃 설정
    override func layout(environment: NSCollectionLayoutEnvironment, itemCount: Int) -> NSCollectionLayoutSection? {
        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .continuous
        section.interGroupSpacing = 12
        
        // 회색 백그라운드 뷰 지정
        section.decorationItems = [
            NSCollectionLayoutDecorationItem.background(elementKind: PromotionSectionBackgroundView.defaultReuseIdentifier)
        ]
        
        // 아직 응답값이 오지 않았을때에도 높이를 잡고 있도록 함
        let sectionEmptyBackgroundSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                heightDimension: .absoulte(233))
        
        var supplymentaryItems: [NSCollectionLayoutBoundarySupplementaryItem] = []
        
        // 로딩 뷰 지정
        let promotionItemSpinnerView = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: sectionEmptyBackgroundSize,
                                                                                   elementKind: PromotionItemSpinnerView.defaultReuseIdentifier,
                                                                                   alignment: .bottom)
        supplymentaryItems.append(promotionItemSpinnerView)
        
        section.supplementariesFollowContentInsets = false
        section.boundarySupplementaryItems = supplymentaryItems
        section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 20, bottom: 0, trailing: 20)
    }
}

백그라운드 뷰는 decorationItems에 지정하고, 로딩 뷰는 supplymentaryItems에 각각 지정해 줍니다.

데이터가 없을 경우에 로딩 뷰가 노출되어야 하는데요. layoutSize에 로딩 뷰의 높이를 지정하여 주변 추천 숙소 모듈의 영역을 잡아줍니다.

// 주변 추천 숙소 섹션
final class PromotionItemSection: CategoryHomeSection {
    private var spinnerView: PromotionItemSpinnerView?
    
    // 생략 ...
    
    override func supplementaryView(kind: String, for item: AnyHashable?, at indexPath: IndexPath, in collection: UICollectionView) -> UICollectionReusableView? {
        switch kind {
        case PromotionItemSpinnerView.defaultReuseIdentifier:
            guard let spinnerView = collection.dequeueReusableSupplementaryView(
                ofKind: kind,
                withReuseIdentifier: PromotionItemSpinnerView.defaultReuseIdentifier,
                for: indexPath) as? PromotionItemSpinnerView else {
                return nil
            }
            // 로딩 뷰 할당
            self.spinnerView = spinnerView
            return spinnerView
            
        default:
            return nil
        }
    }
    
    /// spinner 뷰 show/hidden 설정
    public func setSpinnerHidden(isHidden: Bool) {
        spinnerView?.isHidden = isHidden
    }
}

많은 Section을 관리하기 위해서 Super Class로 CategoryHomeSection을 생성하고 이 클래스를 상속하여 각각 Section 클래스를 구현했습니다.

로딩 뷰는 데이터 존재 여부에 따라 show/hidden 처리가 되어야하기 때문에 주변 추천 숙소 섹션 클래스 내에 spinnerView를 선언해두고 setSpinnerHidden 메서드를 통해 노출/미노출 여부를 설정하도록 구현했습니다.

dataSource.supplementaryViewProvider = { [weak self] (collectionView, kind, indexPath) -> UICollectionReusableView? in
    guard let self = self else { return nil }
    
    let section: CategoryHomeSection = getSection(by: indexPath.section)
    
    switch section {
    case is PromotionItemSection:
        return section.supplementaryView(kind: kind, for: nil, at: indexPath, in: collectionView)
    default:
        return nil
    }
}

Controller쪽 dataSource에 supplementaryViewProvider를 지정하는 코드에서 PromotionItemSection의 supplymentaryView를 가져와서 지정합니다.

이렇게 하면 데이터 유무에 따라 supplymentaryView 로딩 뷰를 노출/미노출할 수 있는 구조를 만들 수 있습니다.

개선 결과

모텔 카테고리 홈 기준으로 개선 전 대비 개선 후의 화면 노출 속도가 약 10배 이상 향상하여 큰 속도 개선을 이루어냈습니다.

여기어때 모텔 카테고리홈 진입

네이티브로 전환 후 속도가 정말 빨라졌고 성능 개선이 된 것이 확실히 체감된다는 의견을 많이 받았습니다. 이제 더 이상 카테고리 홈에서 긴 로딩을 기다려야하는 일은 없게 되었습니다.

마치며

저도 저희 여기어때 앱을 자주 사용하는데요. 일명 DogFooding, 개밥 먹기라고 하죠. 항상 카테고리 홈 속도를 개선해서 쾌적한 사용성을 가진 앱을 만들고 싶다는 생각이 있었는데 이렇게 직접 프로젝트에 참여할 수 있어 더욱 보람찬 경험이었고, 단순히 네이티브로 전환하는 것에서 그치는 것이 아니라 사용자 입장에서 더 나은 사용성과 성능을 위해서 고민해 보고 해결 방법을 적용할 수 있는 시간이었습니다.

여기어때는 사용자 경험 개선을 위해 끊임없이 노력하고 있습니다. 여기어때는 올해 모텔 카테고리 홈을 시작으로 호텔, 펜션, 캠핑, 게스트 하우스 카테고리 홈을 네이티브로 전환을 완료했고, 최근에는 해외 숙소 홈까지 네이티브로 전환했습니다. 카테고리 홈 네이티브 전환을 시작으로 앞으로는 더 많은 화면에 대해서 네이티브 전환을 진행할 예정입니다.

읽어주셔서 감사합니다.