Skip to content

lxodud/Open-Market

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

66 Commits
 
 
 
 
 
 

Repository files navigation

🏪 오픈 마켓

  • 서버와 네트워킹하여 마켓의 상품들을 받아와 보여주는 어플입니다.

📖 목차

  1. 팀 소개
  2. 기능 소개
  3. Diagram
  4. 폴더 구조
  5. 타임라인
  6. 프로젝트에서 경험하고 배운 것
  7. 트러블 슈팅
  8. 참고 링크

🌱 팀 소개

inho Hamo Jeremy

🛠 기능 소개

리스트 스크롤 화면 리스트와 그리드 화면 전환 그리드 스크롤 화면
상품 등록 화면 키보드 타입와 화면 이동 이미지 추가 및 삭제버튼 구현

👀 Diagram

🗂 폴더 구조

OpenMarket
├── Model
│   ├── Error
│   │   ├── NetworkError.swift
│   │   ├── ErrorManager.swift
│   │   └── UserInputError.swift
│   ├── Network
│   │   ├── URLSessionProtocol.swift
│   │   ├── NetworkManager.swift
│   │   ├── UsetInputError.swift
│   │   └── NetworkRequest.swift
│   ├── DTO
│   │   ├── ProductData.swift
│   │   ├── ProductListData.swift
│   │   ├── PostProduct.swift
│   │   ├── Currency.swift
│   │   ├── VendorData.swift
│   │   └── ImageData.swift
│   ├── View
│   │   ├── GridCell.swift
│   │   ├── ListCell.swift
│   │   ├── ProductFormView
│   │   └── RegistrationImageCell.swift
│   ├── Controller
│   │   ├── AppDelegate.swift
│   │   ├── SceneDelegate.swift
│   │   ├── OpenMarketViewController.swift
│   │   ├── ProductRegistrationViewController.swift
│   │   ├── ProductEditViewController.swift
│   │   └── ProductDetailViewController.swift
│   ├── TestDouble
│   │   ├── DummyData
│   │   ├── StubURLSessionDataTask
│   │   └── StubURLSession
│   └── OpenMarketTests
│       ├── StubURLSessionTest
│       └── ProductListDataTest
├── Assets
├── Info.plist
└── README.md

⏰ 타임라인

날짜 구현 내용
22.11.15 <step1 시작>ProductData, ProductListData, VendorData, ImageData DTO타입 구현, 네트워킹을 담당할 NetworkManager타입 구현, UnitTest작성
22.11.16 OpenMarketError타입 구현, TestDouble을 위한 Dummuy,Stub구현
22.11.17 StubURLSessionTest작성, 접근제어 및 파일분리
22.11.18 NetworkManager열거형에 연관값 적용 및 url에 매개변수 전달, 테스트 코드에 강제언래핑 제거
22.11.22 <step2 시작> 뷰에 segemented control 추가, ProductCell클래스 구현 및 CollectionView구현
22.11.23 이미지를 가져올 네트워킹 메서드 구현, 셀 높이를 수동을 지정하는 preferredLayoutAttributesFitting메서드 재정의, cell의 텍스트에 attributedString 적용, GridCell클래스 구현, 컬렉션뷰에 grid레이아웃 추가, 셀을 구성하는 메서드의 기능 분리
22.11.24 상품 추가 뷰를 보여주는 버튼과 액션 구현, cell에 이미지 로드 전 로딩 이미지 추가, 클래스에 final 적용 및 접근제어 추가
22.11.28 컬렉션 뷰의 스크롤이 제일 하단에 도달했을 때 상품을 20개씩 가져오도록 페이지네이션 구현
22.11.29 diffable dataSource를 사용하고 CollectionView의 layout을 변경했을 때 발생하는 버그 수정
22.12.01 Post 작업 헤더, 바디를 구성하는 메서드 구현
22.12.05 ProductRegistrationView, imagesCollectionView layout, RegistrationImageCell 구현
22.12.06 키보드 유무에 따른 UI 업데이트, ProductEditViewController 구현
22.12.07 이미지 캐싱, 이미지 리사이징 구현
22.12.08 상품 등록시 발생하는 입력 에러 처리, 상품 등록 중 이미지 삭제기능 구현
Details - 구현 내용과 기능 설명

STEP 1-1

1️⃣ DTO

  • 데이터를 전달받을 타입들을 구현했습니다. 각 타입의 이름 뒤에는 데이터를 전달받을 목적임을 명시하기 위해 Data를 포함합니다.
    • ProductListData
    • ProductData
    • VendorData
    • ImageData

2️⃣ DummyData

  • 네트워크와 무관한 테스트를 작성하기 위해 Test Double을 작성

3️⃣ StubURLSession

STEP 1-2

1️⃣ UICollectionViewCompositionalLayout

  • 컬렉션뷰의 레이아웃을 구성할때, CompositionLayout객체를 이용하여 구현했습니다.
  • 리스트 형식에 ListConfiguration와 그리드 형식에 CompositionalLayout과 섹션을 이용해서 구현하였습니다.

2️⃣ ListCell & GridCell

  • ListCellUICollectionViewListCell을 상속받아 UIListContentView에서 제공하는 기본 셀 타입의 구성을 이용합니다.
  • GridCell은 커스텀 셀로 필요한 뷰들을 요구사항과 일치하게 구성합니다.

3️⃣ UICollectionView.CellRegistration

  • CellRegistration을 이용하여 컬렉션뷰에 셀을 등록하고, 각 셀을 구성하는 역할을 수행합니다. 제네릭타입으로 전달한 셀과 데이터 타입으로 셀을 구성하고, registration핸들러에서 셀의 프로퍼티에 값을 지정합니다.

4️⃣ UICollectionViewDiffableDataSource

  • 컬렉션뷰의 데이터 소스 객체로는 DiffableDataSource를 이용하였습니다.

STEP 2-1

1️⃣ ProductFormView

  • 상품 등록과 상품 수정에 사용되는 양식을 구현한 뷰입니다.
    • 추가될 이미지와 이미지 추가 버튼을 imagesCollectionViewcell로 보여줍니다.
    • 상품의 이름, 가격, 재고 등을 textField & textView로 입력받습니다.
    • 전체 요소들을 scrollView안에 포함하여 컨텐츠가 화면을 초과하면 스크롤 가능하도록 구현하였습니다.

2️⃣ ProductRegistrationViewController

  • 상품 등록을 위한 뷰컨트롤러 입니다.
    • 뷰에 입력받은 요소들을 확인하고, 조건이 충족된다면 Done버튼을 눌렀을때 상품 등록하는 registerProduct메서드가 실행됩니다.

3️⃣ ProductEditViewController

  • 상품 수정을 위한 뷰컨트롤러입니다.
    • 상품 등록과 같은 양식으로 이루어진 뷰를 보여주지만, 초기화면의 셀을 눌렀을때 해당 셀의 상품 정보를 이미지, 텍스트필드 등에 추가한 상태로 보여지게 됩니다.
    • 수정과 삭제 과정은 개인벤더 정보와 일치할때 진행할 수 있다고 생각하여, 현재에는 Done버튼에 액션을 추가하지 않았고 다음 스텝에서 기능을 구현할 예정입니다.

✅ 프로젝트에서 경험하고 배운 것

  • CompositionalLayout을 이용한 리스트 구현
    ☑️ UICollectionLayoutListConfiguration
    ☑️ UICollectionViewListCell
    ☑️ preferredLayoutAttributesFitting
    ☑️ UICollectionViewDiffableDataSource
  • CompositionalLayout을 이용한 그리드 구현
    ☑️ UICollectionViewCell
    ☑️ UICollectionViewCompositionalLayout
    ☑️ UICollectionViewDiffableDataSource
  • Segmented Control 적용과 활용
    ☑️ UISegmentedControl
    ☑️ addTarget
  • Post
    ☑️ multipart/form-data의 구조 파악
    ☑️ http request 구조 파악
    ☑️ uploadTask 메서드 사용
  • Caching
    ☑️ NSCache
    ☑️ URLCache
  • Keyboard 유무에 따라 동적으로 UI업데이트
    ☑️ Notification Center
    ☑️ ImagePickerController
    ☑️ 텍스트필드의 입력값에 따른 키보드 설정
    ☑️ 스크롤뷰의 Content Inset

🚀 트러블 슈팅

STEP 1-1

1️⃣ 로컬의 JSON (테스트할때 사용할)키 값과 API문서 상의 키 값이 다른 문제

API문서의 키 값 로컬 JSON파일의 키값
  • 서버(pageNo)와 JSON(page_no)의 현재 페이지 번호의 key값이 일치하지 않는 문제점이 있었습니다. 코딩키를 pageNO로 작성하면 JSON파일을 디코딩할 수 없었습니다. 이를 해결하기 위해 서버의 키와 JSON키 모두 camelCase로 변환하는 프로퍼티를 사용했습니다.

    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase                 

해당 프로퍼티를 사용함으로써 아래와 같이 들어오는 vendor_id 키 값을 카멜케이스로 변환하여 원하는 케이스 네임으로 수정할 수 있었습니다.

API문서의 키
코드로 구현한 키 case vendorIdentifier = "vendorId"

2️⃣ 같은 상품 상세에 대해서 요구하는 키값이 서로 다른 문제

  • 서버에 상품 리스트 조회, 상품 상세 조회 두 가지 요청을 보낼 때 받아오는 JSON파일의 키가 일치하지 않아서 ProductData DTO를 중복으로 사용하였을 때 디코딩이 되지 않는 문제가 있었습니다.

  • ProductList에서는 ProductdescriptionvendorName을 요구하고, Product상세에서는 imagesvendors를 요구하는 부분을 어떻게 해결할지 고민후에 겹치지 않는 키에 해당하는 프로퍼티를 옵셔널 처리하여 해결하였습니다.

    struct ProductData: Decodable {    
        ...
        let vendorName: String?
        let description: String?
        let images: [ImageData]?
        let vendors: VendorData?
    }

3️⃣ 비동기로 동작하는 dataTask가 끝난 시점에 데이터를 받는 방법

  • NetworkManagerloadData메서드 내부에서 호출하는 dataTask 메서드는 파라미터인 completionHandler를 이용하여 data, response, error를 받을 수 있는데 비동기적으로 동작하기 때문에 끝나는 시점을 알수없어서 받아온 데이터를 어떻게 전달할지 고민이었습니다.

  • loadData의 파라미터로 escaping closure를 받아서 dataTaskcompletionHandler가 해당 클로저를 캡처하여 비동기적으로 작업이 끝난 시점에 캡처한 클로저를 호출하는 방법으로 해결하였습니다.

    func loadData<T: Decodable>(of request: NetworkRequest,
                                dataType: T.Type,
                                completion: @escaping (Result<T, Error>) -> Void) {
        guard let url = request.url else { return }
    
        session.dataTask(with: url) { data, response, error in
            ...
    
            do {
                ...
                let data = try decoder.decode(T.self, from: data)
                completion(.success(data))
            } catch {
                completion(.failure(OpenMarketError.failedToParse))
            }
        }.resume()
    }

4️⃣ Test Double에서의 코드 흐름과 테스트를 위해 구현한 타입들

  • 네트워크가 없는 환경에서도 테스트를 수행하기 위해서 네트워킹을 수행하는 URLSessionURLSessionDataTask대신하는 Stub객체를 구현했습니다. 그래서 DummyData를 만들어놓고 이를 dataTaskcompletionHandler까지 전달합니다.

    //테스트 코드 예시
    func test_productListData를받았을때_전달받은값을_리턴해야한다() {
        //given
        guard let url = NetworkRequest.productList.url else { return }
    
        let expectedData = """
                        {
                            ...
                            "totalCount": 116,
                            ...
                            
                            """.data(using: .utf8)
        let response = HTTPURLResponse(url: url,
                                       statusCode: 200,
                                       ...)
        let dummyData = DummyData(data: expectedData,
                                  response: response,
                                  error: nil)
        stubUrlSession.dummyData = dummyData
    
        //when
        sut.loadData(of: NetworkRequest.productList,
                        dataType: ProductListData.self) { result in
            switch result {
            case .success(let productListData):
            //then
                XCTAssertEqual(productListData.totalCount, 116)
            case .failure(let error):
                XCTFail("loadData failure: \(error)")
            }
        }
    }
    • 위 예시에서, 받아올 것이라고 예상되는 데이터를 작성하고, 응답이 성공한다고 가정해서 성공하는response를 작성하였습니다. 이 정보들을 dummyData에 포함하여 가짜 객체인 stubUrlSession에 전달하고, NetworkManagerloadData메서드를 호출해서 의도한 결과와 일치하는지 확인합니다.

STEP 1-2

1️⃣ 모던 컬렉션뷰를 이용한 컬렉션뷰 구현

  • 컬렉션 뷰를 구현할때, flowLayoutdataSource 대신, composableLayoutdiffableDataSource를 이용해서 구현하였습니다.
  • snapshot은 뷰의 데이터의 특정 시점의 상태를 나타내고 섹션별로 나누어서 원하는 섹션과 아이템으로 구성하고, dataSourceapply메서드를 이용하여 snapshot의 데이터를 현재 state와 새 state를 비교하여 업데이트합니다.
  • 애플의 Implementing Modern Collection Views를 기반으로 Customize List Cells를 구현하려했습니다.
  • 이때 customstate를 만들어서 활용할 수 있는데, 이번 프로젝트에서는 셀에서 선택, 하이라이트, 이동 등의 상태에 따른 변경이 없어서 custom state를 사용할 필요성이 없다고 생각하여 제외하였습니다.

2️⃣ UICollectionViewListCell의 크기를 커스텀으로 지정하는 방법

  • list형태의 collectionViewcell이 기본적으로 제공하는 레이아웃을 이용하여 아래 코드처럼 간편하게 만들었습니다. 그런데 셀이 self-sizing을 하여서 클릭되었을 때 의도치 않게 높이가 늘어나는 문제가 발생했습니다. 이를 셀이 레아이웃에 전달되기 전에 수동으로 크기를 조절할 수 있도록 하는preferredLayoutAttributesFitting() 메서드를 이용해서 커스텀한 cell size를 정해주어 해결하였습니다.
    let configure = UICollectionLayoutListConfiguration(appearance: .plain)
    let layout = UICollectionViewCompositionalLayout.list(using: configure)

3️⃣ 세그먼트 컨트롤을 이용하여 화면전환시 컬렉션뷰가 중첩되는 문제

  • 세그먼트 컨트롤의 값에 따라 grid 레이아웃, list 레이아웃을 가지는 컬렉션 뷰를 다시 그려서 viewController의 root viewaddSubview하여 화면이 전환되도록 구현했습니다.
  • 이때 기존에 있던 collectionView가 사라지지 않고 중첩되는 문제가 있었습니다.
  • productCollectionView 변수에 새로운 collectionView를 할당하기 때문에 참조가 사라져서 deinit될거라 생각했는데 UIView의 addSubView를 사용하면 추가한 뷰를 강하게 참조하여서 deinit되지 않는다는 점을 확인하고, 자신을 상위 뷰로부터 해제하는 removeFromSuperview() 메서드를 사용하여 해결하였습니다.

4️⃣ 셀을 빠르게 스크롤했을때 셀의 데이터와 일치하지 않는 이미지가 로드되는 문제

  • 네트워킹을 통해서 셀의 이미지를 서버에서 가져오는데 빠르게 스크롤했을 때 셀의 이미지가 여러번 바뀌는 문제가 있었습니다.
  • 셀이 재사용될 때마다 이미지를 가져오는 작업이 생성되고 여러개의 작업들이 끝날때마다 셀의 이미지를 바꾸기 때문에 발생하는 문제였습니다.
  • 네트워킹 작업을 생성하는 시점의 indexPath와 현재 cellindexPath를 비교하여 두 값이 같은 경우에만 이미지를 변경하여 해결하였습니다.
    if indexPath == self.productCollectionView.indexPath(for: cell) {
        cell.imageView.image = image
    }

5️⃣ Diffable Datasource 인스턴스를 만들 때 클로저 내부에서 Cell Registration을 생성할 때 발생하는 에러

  • CellRegistration을 diffabel datasource 클로저 내부에서 만들면 위 그림과 같은 에러가 발생한다.
  • 이 부분에 대해서 UICollectionView.CellRegistration 공식문서에 명시되어 있는데 아래 그림과 같다.

  • 따라서 외부에서 CellRegistration 인스턴스를 생성하고 클로저 내부에서 사용하여 문제를 해결하였다.

STEP 2-1

1️⃣ 상품 등록을 위한 PostData메서드 구현

  • 상품 정보에 이미지 파일을 포함하고 있기 때문에 POSTmultipart/form-data형식을 이해해야 했습니다.
  • 형식에 맞게 request의 헤더와 바디를 구성하고, 이미지를 데이터 형식으로 바디에 추가한 후 이를 uploadTask메서드의 매개변수로 전달합니다. (request & data)

2️⃣ 상품 등록 화면에서 사진 개수에 따른 imagePicker 구현?

  • 상품 등록 화면에서 사진을 선택하기 위한 방법으로 phpickerViewControllerimagePickerViewController 2가지가 있습니다.
  • 요구사항에 선택된 사진을 crop하는 기능이 추가되어야 하는데 phpickerViewController의 경우 crop 기능이 없기 때문에 imagePickerViewController를 선택하였습니다.

3️⃣ 키보드 높이만큼 스크롤뷰 올리기

  • 텍스트뷰에 입력을 할 때 키보드가 올라와서 화면을 가리는 문제가 있어서 키보드가 올라왔을 때 키보드 높이만큼 scrollViewcontentOffSet을 높여주었습니다.
  • contentOffSet을 조절하여도 contentheight가 증가하는게 아니기 때문에 스크롤하거나 타이핑을 하거나 스크롤했을 때 contentOffSet이 이전으로 변하는 문제가 발생했습니다.
  • scrollViewcontentInset을 이용하여 키보드가 올라올 때 그 높이 만큼의 여백을 주고 내려갈 때 다시 0으로 만들어주어서 해결하였습니다.

4️⃣ 상품 등록, 수정 뷰에서 텍스트 필드, 텍스트 뷰를 제외한 곳을 터치했을 때 키보드 내리기

  • 터치 이벤트가 responder chain을 타고 내려오기 때문에 viewController에서 이벤트를 처리하는 touchBegan을 재정의하여 내부에서 endEditing을 호출하려 하였는데 특정 부분에서는 viewController가 이벤트를 처리하지 못하는 상황이 발생하였습니다.
  • 해당 뷰에서 viewController위에 scrollView가 있게 그 위에 contentView가 있는 계층이었고 터치 이벤트를 scrollView가 가져가기 때문에 viewController에서 이벤트를 처리하지 못하는 문제임을 파악하고 scrollViewgesture를 추가하여 터치 이벤트가 발생했을 때 endEditing하도록 하였습니다.

5️⃣ 유저의 입력을 확인하는 과정 및 POST 시도 제한

  • ProductFormView에서 각각의 TextField의 요구사항 충족 및 타입을 확인하는 연산 프로퍼티를 구현했습니다.
  • 상품 등록 조건을 충족하지 않으면 nil을 반환하도록하여 값이 nil이면 POST가 진행되지 않도록 구현했습니다.
var nameInput: String? {
    guard let text = productNameTextField.text,
          (3...100).contains(text.count) else { return nil } //글자수 제한
        return text
    }
}
...

6️⃣ Done버튼을 여러번 눌러 중복 POST되는 현상과 POST가 완료된 후에 dismiss작업

  • Done 버튼을 여러번 누르면 한 게시물이 여러번 등록되는 문제가 있었습니다.
  • 이를 해결하기 위해 POST가 진행될 때 버튼이 한번 눌리면 buttonisEnabled 프로퍼티를 이용해서 비활성화 되도록 구현하였습니다.
  • 상품 등록화면은 등록이 성공적으로 진행된 후에 내려가야 한다고 생각하여, 업로드를 수행하는 postDatacompletion handler를 추가하여 작업이 완료된 후에 dismiss하도록 구현하였습니다.
networkManager.postData(request: request, data: data) {
    DispatchQueue.main.async {
        self.dismiss(animated: true)
    }
}

🔗 참고 링크

[공식문서]

[WWDC]

[그 외 참고문서]

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 4

  •  
  •  
  •  
  •  

Languages