- 서버와 네트워킹하여 마켓의 상품들을 받아와 보여주는 어플입니다.
inho | Hamo | Jeremy |
---|---|---|
리스트 스크롤 화면 | 리스트와 그리드 화면 전환 | 그리드 스크롤 화면 |
상품 등록 화면 | 키보드 타입와 화면 이동 | 이미지 추가 및 삭제버튼 구현 |
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 - 구현 내용과 기능 설명
- 데이터를 전달받을 타입들을 구현했습니다. 각 타입의 이름 뒤에는 데이터를 전달받을 목적임을 명시하기 위해
Data
를 포함합니다.ProductListData
ProductData
VendorData
ImageData
- 네트워크와 무관한 테스트를 작성하기 위해 Test Double을 작성
- 컬렉션뷰의 레이아웃을 구성할때,
CompositionLayout
객체를 이용하여 구현했습니다. - 리스트 형식에
ListConfiguration
와 그리드 형식에CompositionalLayout
과 섹션을 이용해서 구현하였습니다.
ListCell
은UICollectionViewListCell
을 상속받아UIListContentView
에서 제공하는 기본 셀 타입의 구성을 이용합니다.GridCell
은 커스텀 셀로 필요한 뷰들을 요구사항과 일치하게 구성합니다.
CellRegistration
을 이용하여 컬렉션뷰에 셀을 등록하고, 각 셀을 구성하는 역할을 수행합니다. 제네릭타입으로 전달한 셀과 데이터 타입으로 셀을 구성하고,registration
핸들러에서 셀의 프로퍼티에 값을 지정합니다.
- 컬렉션뷰의 데이터 소스 객체로는
DiffableDataSource
를 이용하였습니다.
- 상품 등록과 상품 수정에 사용되는 양식을 구현한 뷰입니다.
- 추가될 이미지와 이미지 추가 버튼을
imagesCollectionView
와cell
로 보여줍니다. - 상품의 이름, 가격, 재고 등을
textField & textView
로 입력받습니다. - 전체 요소들을
scrollView
안에 포함하여 컨텐츠가 화면을 초과하면 스크롤 가능하도록 구현하였습니다.
- 추가될 이미지와 이미지 추가 버튼을
- 상품 등록을 위한 뷰컨트롤러 입니다.
- 뷰에 입력받은 요소들을 확인하고, 조건이 충족된다면
Done
버튼을 눌렀을때 상품 등록하는registerProduct
메서드가 실행됩니다.
- 뷰에 입력받은 요소들을 확인하고, 조건이 충족된다면
- 상품 수정을 위한 뷰컨트롤러입니다.
- 상품 등록과 같은 양식으로 이루어진 뷰를 보여주지만, 초기화면의 셀을 눌렀을때 해당 셀의 상품 정보를 이미지, 텍스트필드 등에 추가한 상태로 보여지게 됩니다.
- 수정과 삭제 과정은 개인벤더 정보와 일치할때 진행할 수 있다고 생각하여, 현재에는
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
API문서의 키 값 |
로컬 JSON파일의 키값 |
-
서버(pageNo)와 JSON(page_no)의 현재 페이지 번호의 key값이 일치하지 않는 문제점이 있었습니다. 코딩키를
pageNO
로 작성하면JSON
파일을 디코딩할 수 없었습니다. 이를 해결하기 위해 서버의 키와 JSON키 모두camelCase
로 변환하는 프로퍼티를 사용했습니다.let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase
해당 프로퍼티를 사용함으로써 아래와 같이 들어오는 vendor_id
키 값을 카멜케이스로 변환하여 원하는 케이스 네임으로 수정할 수 있었습니다.
API문서의 키 | |
코드로 구현한 키 | case vendorIdentifier = "vendorId" |
-
서버에 상품 리스트 조회, 상품 상세 조회 두 가지 요청을 보낼 때 받아오는 JSON파일의 키가 일치하지 않아서 ProductData DTO를 중복으로 사용하였을 때 디코딩이 되지 않는 문제가 있었습니다.
-
ProductList
에서는Product
의description
과vendorName
을 요구하고,Product
상세에서는images
와vendors
를 요구하는 부분을 어떻게 해결할지 고민후에 겹치지 않는 키에 해당하는 프로퍼티를 옵셔널 처리하여 해결하였습니다.struct ProductData: Decodable { ... let vendorName: String? let description: String? let images: [ImageData]? let vendors: VendorData? }
-
NetworkManager
의loadData
메서드 내부에서 호출하는dataTask
메서드는 파라미터인completionHandler
를 이용하여data, response, error
를 받을 수 있는데 비동기적으로 동작하기 때문에 끝나는 시점을 알수없어서 받아온 데이터를 어떻게 전달할지 고민이었습니다. -
loadData의
파라미터로escaping closure
를 받아서dataTask
의completionHandler
가 해당 클로저를 캡처하여 비동기적으로 작업이 끝난 시점에 캡처한 클로저를 호출하는 방법으로 해결하였습니다.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() }
-
네트워크가 없는 환경에서도 테스트를 수행하기 위해서 네트워킹을 수행하는
URLSession
과URLSessionDataTask
대신하는Stub
객체를 구현했습니다. 그래서DummyData
를 만들어놓고 이를dataTask
의completionHandler
까지 전달합니다.//테스트 코드 예시 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
에 전달하고,NetworkManager
의loadData
메서드를 호출해서 의도한 결과와 일치하는지 확인합니다.
- 위 예시에서, 받아올 것이라고 예상되는 데이터를 작성하고, 응답이 성공한다고 가정해서 성공하는
- 컬렉션 뷰를 구현할때,
flowLayout
과dataSource
대신,composableLayout
과diffableDataSource
를 이용해서 구현하였습니다. snapshot
은 뷰의 데이터의 특정 시점의 상태를 나타내고 섹션별로 나누어서 원하는 섹션과 아이템으로 구성하고,dataSource
의apply
메서드를 이용하여snapshot
의 데이터를 현재state
와 새 state를 비교하여 업데이트합니다.- 애플의
Implementing Modern Collection Views
를 기반으로Customize List Cells
를 구현하려했습니다. - 이때
custom
한state
를 만들어서 활용할 수 있는데, 이번 프로젝트에서는 셀에서 선택, 하이라이트, 이동 등의 상태에 따른 변경이 없어서custom state
를 사용할 필요성이 없다고 생각하여 제외하였습니다.
list
형태의collectionView
를cell
이 기본적으로 제공하는 레이아웃을 이용하여 아래 코드처럼 간편하게 만들었습니다. 그런데 셀이self-sizing
을 하여서 클릭되었을 때 의도치 않게 높이가 늘어나는 문제가 발생했습니다. 이를 셀이 레아이웃에 전달되기 전에 수동으로 크기를 조절할 수 있도록 하는preferredLayoutAttributesFitting()
메서드를 이용해서 커스텀한cell size
를 정해주어 해결하였습니다.let configure = UICollectionLayoutListConfiguration(appearance: .plain) let layout = UICollectionViewCompositionalLayout.list(using: configure)
- 세그먼트 컨트롤의 값에 따라
grid
레이아웃,list
레이아웃을 가지는 컬렉션 뷰를 다시 그려서viewController의
root view
에addSubview
하여 화면이 전환되도록 구현했습니다. - 이때 기존에 있던
collectionView
가 사라지지 않고 중첩되는 문제가 있었습니다. productCollectionView
변수에 새로운collectionView
를 할당하기 때문에 참조가 사라져서deinit
될거라 생각했는데UIView의
addSubView
를 사용하면 추가한 뷰를 강하게 참조하여서deinit
되지 않는다는 점을 확인하고, 자신을 상위 뷰로부터 해제하는removeFromSuperview()
메서드를 사용하여 해결하였습니다.
- 네트워킹을 통해서 셀의 이미지를 서버에서 가져오는데 빠르게 스크롤했을 때 셀의 이미지가 여러번 바뀌는 문제가 있었습니다.
- 셀이 재사용될 때마다 이미지를 가져오는 작업이 생성되고 여러개의 작업들이 끝날때마다 셀의 이미지를 바꾸기 때문에 발생하는 문제였습니다.
- 네트워킹 작업을 생성하는 시점의
indexPath
와 현재cell
의indexPath
를 비교하여 두 값이 같은 경우에만 이미지를 변경하여 해결하였습니다.if indexPath == self.productCollectionView.indexPath(for: cell) { cell.imageView.image = image }
CellRegistration을 diffabel datasource
클로저 내부에서 만들면 위 그림과 같은 에러가 발생한다.- 이 부분에 대해서
UICollectionView.CellRegistration
공식문서에 명시되어 있는데 아래 그림과 같다.
- 따라서 외부에서
CellRegistration
인스턴스를 생성하고 클로저 내부에서 사용하여 문제를 해결하였다.
- 상품 정보에 이미지 파일을 포함하고 있기 때문에
POST
와multipart/form-data
형식을 이해해야 했습니다. - 형식에 맞게
request
의 헤더와 바디를 구성하고, 이미지를 데이터 형식으로 바디에 추가한 후 이를uploadTask
메서드의 매개변수로 전달합니다. (request & data
)
- 상품 등록 화면에서 사진을 선택하기 위한 방법으로
phpickerViewController
와imagePickerViewController
2가지가 있습니다. - 요구사항에 선택된 사진을
crop
하는 기능이 추가되어야 하는데phpickerViewController
의 경우crop
기능이 없기 때문에imagePickerViewController
를 선택하였습니다.
- 텍스트뷰에 입력을 할 때 키보드가 올라와서 화면을 가리는 문제가 있어서 키보드가 올라왔을 때 키보드 높이만큼
scrollView
의contentOffSet
을 높여주었습니다. contentOffSet
을 조절하여도content
의height
가 증가하는게 아니기 때문에 스크롤하거나 타이핑을 하거나 스크롤했을 때contentOffSet
이 이전으로 변하는 문제가 발생했습니다.scrollView
의contentInset
을 이용하여 키보드가 올라올 때 그 높이 만큼의 여백을 주고 내려갈 때 다시 0으로 만들어주어서 해결하였습니다.
- 터치 이벤트가
responder chain
을 타고 내려오기 때문에viewController
에서 이벤트를 처리하는touchBegan
을 재정의하여 내부에서endEditing
을 호출하려 하였는데 특정 부분에서는viewController
가 이벤트를 처리하지 못하는 상황이 발생하였습니다. - 해당 뷰에서
viewController
위에scrollView
가 있게 그 위에contentView
가 있는 계층이었고 터치 이벤트를scrollView
가 가져가기 때문에viewController
에서 이벤트를 처리하지 못하는 문제임을 파악하고scrollView
에gesture
를 추가하여 터치 이벤트가 발생했을 때endEditing
하도록 하였습니다.
ProductFormView
에서 각각의TextField
의 요구사항 충족 및 타입을 확인하는 연산 프로퍼티를 구현했습니다.- 상품 등록 조건을 충족하지 않으면
nil
을 반환하도록하여 값이nil
이면POST
가 진행되지 않도록 구현했습니다.
var nameInput: String? {
guard let text = productNameTextField.text,
(3...100).contains(text.count) else { return nil } //글자수 제한
return text
}
}
...
Done
버튼을 여러번 누르면 한 게시물이 여러번 등록되는 문제가 있었습니다.- 이를 해결하기 위해 POST가 진행될 때 버튼이 한번 눌리면
button
의isEnabled
프로퍼티를 이용해서 비활성화 되도록 구현하였습니다. - 상품 등록화면은 등록이 성공적으로 진행된 후에 내려가야 한다고 생각하여, 업로드를 수행하는
postData
에completion handler
를 추가하여 작업이 완료된 후에dismiss
하도록 구현하였습니다.
networkManager.postData(request: request, data: data) {
DispatchQueue.main.async {
self.dismiss(animated: true)
}
}
[공식문서]
- 📎 Closure
- 📎 URLSession
- 📎 URLSessionDataTask
- 📎 dataTask(with:completionHandler:)
- 📎 uploadTask(with:from:completionHandler:)
- 📎 Fetching Website Data into Memory
- 📎 Uploading Data to a Website
- 📎 Implementing Modern Collection Views
- 📎 NSCache
- 📎 URLCache
- 📎 URLCache.StoragePolicy
- 📎 NSURLRequest.CachePolicy
- 📎 NSURLRequest.CachePolicy.useProtocolCachePolicy
- 📎 UIImagePickerController
- 📎 PHPickerViewController
[WWDC]
- 📎 Modern cell configuration
- 📎 List in Collection View
- 📎 Advances in UICollectionView
- 📎 Advances in Collection View Layout
[그 외 참고문서]