paneiOS / E-Commerce

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

RX/MVVM을 적용한 오픈마켓 프로젝트

목차

프로젝트 소개

Network 통신을 통해 서버에서 데이터를 받아 CollectionView로 상품의 목록화면 및 상세화면을 보여줍니다. RxSwift + MVVM-C를 적용했습니다.

참여자

  • 호댕 @yanghojoon, 애플사이다 @just1103

프로젝트 기간

  • 2022.04.20 - 2022.05.17 (총 4주)

📱 구현 화면

1. MenuBar 2. 목록 스크롤 3. 다음 목록 업데이트 4. 신상품 추가 알림 5. 상품 상세

🗺 Architecture

image

🗂 파일 디렉토리 구조

├── OpenMarket-MVVM-Rx
│   ├── App
│   ├── Presentation
│   │   ├── ProductListView
│   │   │   ├── View
│   │   │   ├── ViewModel
│   │   ├── ProductDetailView
│   │   │   ├── View
│   │   │   ├── ViewModel
│   ├── Network
│   ├── Model
│   ├── Utility
│   ├── Protocol
│   ├── Extension
│   └── Resource
└── OpenMarket-MVVM-RxTests
    └──Mock

🌐 Feature-1. 네트워크 구현

1-1 고민한 점

1️⃣ MockURLSession을 통한 테스트

아래의 목적을 위해 MockURLSession을 구현했습니다.

  • 실제 서버와 통신할 경우 테스트의 속도가 느려짐
  • 인터넷 연결상태에 따라 테스트 결과가 달라지므로 테스트 신뢰도가 떨어짐
  • 실제 서버와 통신을 하며 서버에 테스트 데이터가 불필요하게 업로드되는 Side-Effect가 발생함

또한 향후 테스트 대상 파일이 늘어날 것에 대비하여 Mock 데이터로 JSON 파일을 추가하고, Bundle(for: type(of: self))로 데이터에 접근했습니다.

2️⃣ API 추상화

API를 열거형으로 관리하는 경우, API를 추가할 때마다 새로운 case를 생성하여 열거형이 비대해지고, 열거형 관련 switch문을 매번 수정해야 하는 번거로움이 있었습니다. 따라서 API마다 독립적인 구조체 타입으로 관리되도록 변경하고, URL 프로퍼티 외에도 HttpMethod 프로퍼티를 추가한 APIProtocol 타입을 채택하도록 개선했습니다. 이로써 코드유지 보수가 용이하며, 협업 시 각자 담당한 API 구조체 타입만 관리하면 되기 때문에 충돌을 방지할 수 있습니다.

1-2 Trouble Shooting

1️⃣ Mock 데이터 접근 시 Bundle에 접근하지 못하는 문제

JSON Decoding 테스트를 할 때, Bundle.main.path를 통해 Mock 데이터에 접근하도록 했는데, path에 nil이 반환되는 문제가 발생했습니다. LLDB 확인 결과 Mock 데이터 파일이 포함된 Bundle은 OpenMarketTests.xctest이며, 테스트 코드를 실행하는 주체는 OpenMarket App Bundle임을 파악했습니다.

  • LLDB 내용: OpenMarket.app/PlugIns/OpenMarketTests.xctest

따라서 현재 executable의 Bundle 개체를 반환하는 Bundle.main (즉, App Bundle)이 아니라, 테스트 코드를 실행하는 주체를 가르키는 Bundle(for: type(of: self)) (즉, XCTests Bundle)로 path를 수정하여 문제를 해결했습니다.

이외에도 테스트 코드 내부에서 옵셔널 바인딩을 하는 경우 else문에 XCTFail()을 추가하여 예상 결과값이 반환되지 않았음에도 테스트를 Pass하는 오류를 방지했습니다.

2️⃣ Rx를 사용한 네트워크 처리 시 불필요한 Subscribe 삭제

기존에는 loadData(), request(api:), fetchData<T: Codable>(api:decodingType:)으로 나누어 해당 메서드에서 전부 Observable을 create하고 순차적으로 subscribe를 하여 네트워크를 처리하는 방법을 사용했습니다. 이때 subscribe를 최소화하는 방향으로 개선하려 했으나, 단순히 map을 사용해 데이터를 가공하고 넘겨주는 경우 onError를 통해 발생하는 에러를 처리하지 못하는 문제가 존재했습니다.

이에 따라 데이터를 받아오는 fetchData<T: Codable>(api:decodingType:)와 데이터를 요청하는 request(api:)메서드로 분리하고, dataTask<T:Codable>(api:emitter:)메서드에서 URLSessiondataTask 메서드를 실행시켜 에러를 던지도록 개선했습니다.

1-3 키워드

  • Network : 비동기 처리, URLSession, MultipartFormData, REST-ful API
  • TDD : MockURLSession, MockData
  • SPM : RxSwift/RxCocoa, SwiftLint
  • JSON Parsing, Generics
  • Cache, Notification, Alert
  • Build UI Programmatically

🛍 Feature-2. 상품 목록화면 구현

2-1 고민한 점

1️⃣ DiffableDataSource 및 Snapshot 활용

상품 목록은 크게 Banner SectionList Section으로 구분했습니다. DiffableDataSource를 활용하여 CollectionView에 나타낼 데이터 타입 (UniqueProduct)은 Hashable을 채택하도록 했습니다. 또한 HeaderView를 통해 각 Section의 타이틀을 나타냈고, Banner SectionFooterView를 통해 배너 이미지의 PageControl을 보여주도록 했습니다. 또한 RxSwift를 통해 ViewModel과 ViewController를 Binding 시켜서 역할을 분리했습니다. 예를 들어 상품 목록화면에서 스크롤을 최하단으로 내리면, ViewModel은 서버를 통해 상품 목록을 업데이트하고, ViewController는 Snapshot을 apply하여 화면을 다시 그리도록 했습니다.

2️⃣ CompositionalLayout 활용

CompositionalLayout을 활용하여 Item/Group/Section 요소를 반응성 있게 배열했습니다. 또한 높이는 estimatedHeight, 너비는 fractionalWidth를 활용하여 Cell의 크기가 Device에 따라 유동적으로 조절됩니다. 특히 estimatedHeight를 사용하여 Cell의 높이를 고정하지 않고, Cell의 내부 구성에 따라 자동으로 산정하도록 했습니다. 또한 현재 Layout 스타일이 Grid인지, Table인지에 따라 CollectionViewcolumnCount를 바뀌도록 구현했습니다.

3️⃣ Observable Subscribe 최소화

Stream이 발생하는 경우, Observable을 최종 사용하는 위치에서만 Subscribe하여 Stream이 끊기지 않도록 구현했습니다. 따라서 Observable을 생성하고 이를 처리하는 중간 단계에서는 flatmap, map, filter 등을 사용하여 필요한 형태로 변경만 해준 뒤 Observable 타입을 반환하도록 구현했습니다.

4️⃣ Flow Coordinator 활용

Coordinator에서 모든 화면의 ViewController 및 ViewModel을 초기화하여 의존성을 관리하고, 화면 전환을 담당하도록 구현했습니다. 이때 화면 전환에 필요한 작업은 Coordinator에서 정의하여 클로저 타입의 변수로 구성된 action에 저장해두고, ViewModel에서 해당 action에 접근하여 클로저를 실행하도록 했습니다.

5️⃣ UnderlinedMenuBar 구현

최근 상용앱에서 흔히 사용하는 Custom MenuBar를 구현했습니다. Custom Component이므로 SegmentedControl 보다는 Button을 활용하여 자유롭게 기능을 구현할 수 있도록 했습니다. Grid 및 Table의 2개 Button으로 구성하고, 각 버튼을 탭하면 CollectionView의 Layout이 변경되도록 했습니다. 또한 UIView로 Button 하단에 Underline을 표현하고, animate 메서드를 통해 Underline이 이동하는 애니메이션 효과를 적용했습니다. 이때 Underline의 위치를 변경하기 위해 기존 constraint를 deactivate하고, frame origin을 각 Button의 frame origin으로 할당했습니다. UnderlinedMenuBar 위치는 기존에는 NavigationBar의 titleView로 배치했지만, 화면 전환 시 시스템이 titleView의 크기를 재조정하는 문제가 발생하여 NavigationBar 대신 SafeArea 상단에 위치하도록 개선했습니다.

6️⃣ 새로운 상품이 등록되는 경우 Banner 변경

앱을 사용하던 도중 새로운 할인상품이 등록되는 경우 Banner의 Item도 변경을 해야 할 지에 대해 고민했습니다. 대부분의 상용앱은 배너가 자주 변경되지 않기 때문에, 앱을 사용하는 도중에 배너가 바뀌지 않도록 구현했습니다.

2-2 Trouble Shooting

1️⃣ UniqueProduct 타입을 추가하여 Hashable Item 생성

BannerList의 전체 상품 중에서 할인이 적용된 최근 5개 상품이 나타나도록 구현했습니다. 그 과정에서 BannerList에 동일한 ID의 상품을 적용해야 했는데, DiffableDataSource의 Item이 Unique하지 않아서 문제가 발생했습니다. 따라서 기존 Product 타입에 UUID 타입의 프로퍼티를 추가한 UniqueProduct 타입을 추가하고, 서버에서 받은 상품 정보를 BannerList에 전달하기 전에 UniqueProduct 타입으로 변환시켜서 Item이 충돌하지 않도록 개선했습니다.

2️⃣ CollectionView Layout을 Table 및 Grid 스타일로 변경

UnderlinedMenuBar를 탭해서 CollectionView의 Layout을 변경할 때, 기존에 화면에 보이던 Cell은 스타일이 변하지 않고 그대로 남아있는 문제가 있었습니다. 따라서 버튼을 탭할 경우 이에 맞는 Layout을 생성하여 변경하고, reloadData 메서드를 호출하여 문제를 해결했습니다.

2-3 키워드

  • CollectionView : DiffableDataSource, CompositionalLayout/estimatedHeight, Header/Footer
  • MVVM-C, FlowCoordinator
  • Custom MenuBar
  • Deactivate Layout

🎁 Feature-3. 상품 상세화면 구현

3-1 고민한 점

1️⃣ orthogonalScrollingBehavior를 활용한 Pagination

Section 마다 Scroll Direction을 다르게 지정하기 위해 고민했습니다. CollectionView의 main layout axis와 반대 방향으로 Scroll 되도록 설정할 수 있는 orthogonalScrollingBehavior을 활용했습니다. 또한 Pagination을 구현하여 여러 개의 이미지가 있을 경우 화면 양 끝에 다른 이미지 일부가 보이도록 구현했습니다.

3-2 Trouble Shooting

1️⃣ Sequence를 준수하는 Observable 타입을 Cell에 적용

CollectionView의 Cell은 하나 이상 존재하기 때문에 Observable의 타입이 Sequence 프로토콜을 준수하는 Collection 타입이어야 했습니다. 따라서 DetailViewProduct를 받아 image의 배열을 타입으로 갖는 Observable을 새롭게 생성하여 이를 bind하는 방법으로 해결했습니다.

2️⃣ PageControl에 현재 페이지를 반영하는 기능

CollectionView를 Horizontal Scroll할 때마다 현재 페이지를 파악하여 PageControl에 반영되도록 구현했습니다. 기존에는 collectionView(:willDisplay:forItemAt:)collectionView(:didEndDisplaying:forItemAt:의 indexPath.row를 비교하여 둘이 다른 경우에 스크롤이 되었다고 판단하여 현재 페이지를 계산하는 로직을 사용했습니다.

하지만 이 경우 스크롤을 했을 때 제대로 인식이 되지 않는 문제가 있었습니다. 따라서 section의 visibleItemsInvalidationHandler 클로저를 활용해 현재 페이지를 알 수 있는 로직으로 변경했습니다.

section.visibleItemsInvalidationHandler = { [weak self] _, contentOffset, environment in
    let bannerIndex = Int(max(0, round(contentOffset.x / environment.container.contentSize.width)))
    self?.imagePageControl.currentPage = bannerIndex
}

3-3 키워드

  • orthogonalScrollingBehavior, Pagination
  • PageControl
  • AttributedString

About


Languages

Language:Swift 100.0%