# 오픈 마켓 PR 오픈 마켓 [STEP 1] 써니쿠키, 메네 @inwoodev 안녕하세요 제임스! 🙇🏻‍♀️🙇 Step1 PR드립니다. 잘부탁드립니다 ## 📝 STEP 진행 중 경험하고 배운 것 - JSONData 파싱 - [X] 파싱한 JSON 데이터와 매핑할 모델 설계 - [X] `keyDecodingStrategy`을 사용해 SnakeCase -> CamelCase 디코딩 - URL Session을 활용한 서버와의 통신 - [X] `URLComponents`타입으로 URL의 Path와 QueryItem으로 url주소생성 - [X] HTTPURLResponse, mimeType, error를 확인하면서 `dataTask` 생성 - Unit Test를 통한 설계 검증 - [X] JSON Data를 Decoding하는 메서드의 UnitTest 구현 - [X] Test Double(Mock)을 통해 네트워크 상태와 무관하게 작동하는 UnitTest구현 ## ⚒️ 코드 구현내용 <details> <summary>Details - 타입 별 코드 구현내용 </summary> ### 1️⃣ DataType - `Market` 구조체, `Page` 구조체 - [서버 API 문서](https://documenter.getpostman.com/view/21303036/2s83znq2Pa#intro)의 데이터 형식을 고려한 모델 타입을 구현했습니다. - 서버에서 제공되는 JSON파일과 매핑하는 타입입니다. - STEP1에서는 디코딩만 사용하고 있어서 `Decodable`을 채택하였습니다. ### 2️⃣ `MarketURLSessionProvider` 클래스 - 서버에서 데이터를 받아오는 기능을 합니다, - `fetchData(url:, type:, completionHandler:)`메서드 - `HTTPURLResponse`, `mimeType`, `error`를 확인하고 서버에서 데이터를 받아와 디코딩 합니다. ### 3️⃣ `Request` 열거형 - [HealthChekcer], [상품 리스트 조회], [상품 상세 조회] 데이터를 조회할 수 있는 url주소를 case로 갖고있습니다. ### 4️⃣ `NetworkError` 열거형 - 서버와의 통신 중 발생가능한 Error를 case로 갖고있습니다. ### 5️⃣ `URLComponents` extension - `healthCheckUrl` 메서드 - 서버와 소통이 정상인지 확인하는 주소인 `healthCheckUrl`을 리턴합니다. - `marketUrl(path:, queryItems:)` 메서드 - path와 queryItems를 배열로 받아 Market의 baseUrl을 바탕으로 url 주소를 생성 후 리턴합니다. ### 6️⃣ `JSONDecoder` Extension - `decodeFromSnakeCase(type:, from:)`메서드 - JSON타입의 데이터를 decoding합니다. - `decodeFromSnakeCase` 메서드를 구현했습니다. - `keyDecodingStrategy`로 `.convertFromSnakeCase`를 적용했습니다. - `dateDecodingStrategy`로 `.formatted(DateFormatter.dateFormatter)`를 적용했습니다 ### 7️⃣ MockURLSession - `URLSessionProtocol`, `MockURLSession` 클래스, `MockURLSessionDataTask`클래스 , `SampleData` 열거형 - 네트워크 상태와 무관하게 URLSession작동을 확인하는 단위 테스트(Unit Test)에 사용하는 타입 입니다. ### 8️⃣ Unit Test - `DecodeTests` 클래스, `MockURLSessionTests` 클래스 - `DecodeTests` 클래스 - 제공된 JSON 데이터를 `Market` 타입으로 Parsing 할 수 있는지에 대한 단위 테스트(Unit Test)입니다. - `MockURLSessionTests` 클래스 - `MockURLSession`과 `SampleData`를 이용해 네트워크 상태와 무관하게 URLSession작동이 정상적인지 확인하는 단위 테스트(Unit Test) 입니다. </details> --- ## 💭 고민한 부분 ### JSON 데이터와 매핑할 모델을 설계할 때, 대부분 같은 Key 목록을 구성하고 있지만 두개가 빠져있었습니다. - [상품 리스트 조회]페이지에서 파싱해오는 Market 타입의 `pages: [Page]` 의 데이터 Key목록과 [상품상세조회]페이지에서 파싱해오는 `Page`타입의 Key목록이 12개는 동일하고, 2개의 Key만 전자에서 빠져있었습니다. - `images` / `vendor` Key를 **옵셔널**로 타입으로 만들어 전자의 경우에서는 파싱하지 않고, 후자의 경우에선 파싱하는 타입으로 구현했습니다. ### URL 요청시 어떠한 방법을 사용하여 호출할지 고민해 보았습니다. - enum으로 만들거나 Type Property를 사용하는 방법 중 어떤 것을 사용할지 고민하다가 enum의 case에 `case productDetail(productNumber: Int)`와 같이 원하는 값을 받을 수 있다는 것을 새롭게 알게 되어 enum의 url 프로퍼티에 `URLComponents`타입으로 `hostURL`에 여러개의 `path`와 `query`를 `QueryItem`을 이용하여 연결하도록 구현하였습니다. ### JSON snake_case를 CamelCase로 변환하는 방법을 고민해 보았습니다. - `Coding Key`와 `keyDecodingStrategy`를 사용하는 방법 중, `keyDecodingStrategy`를 사용하여 `convertFromSnakeCase`를 적용하였습니다. ### 디코딩 메서드를 `decodeFromAsset`, `decodeFromServer` 2개로 구현하였다가 하나의 메서드로 수정하였습니다. - 처음에는 에셋의 JSON 파일에서 디코딩하는 메서드와 서버에서 받아오는 JSON 파일을 디코딩 하는 메서드를 분리하여 작성하였다가 `decodeFromSnakeCase` 하나의 메서드에서 함께 처리하도록 구현하였습니다. --- ## 🧐 조언을 얻고 싶은 부분 ### 👉 [상품 리스트 조회]에서 아무 데이터가 없는 페이지를 조회를 할 때 오류처리 해야 하는지에 대한 문제 - `https://openmarket.yagom-academy.kr/api/products?page_no=3&items_per_page=100` 과 같이 상품리스트 조회 시 서버 응답엔 성공했지만(code: 204) 페이지 범위를 넘어가는 숫자를 입력하고 요청하는 경우 아무 값이 보이지 않는데 이런 경우에 처리를 어떻게 하는 것이 좋은지 제임스의 의견이 궁금합니다. - 지금은 인덱스 범위를 넘어가는 숫자를 입력하고 요청을 보낼 수 있지만 UI App에서 구현하는 경우 버튼의 text를 받아오거나 입력할 수 있는 범위를 지정하여 요청하면 문제가 없을 것 같은데 서버에서 성공코드를 받았지만 값이 없는 경우 "값이 없습니다."와 같이 무언가를 보여주는 편이 좋을까요? ### 👉 `URLSessionDataTask`클래스 `init`메서드의 deprecated 문제 <Img width = 700 src = "https://i.imgur.com/v8tMXNF.png"> - Mock을 이용한 유닛테스트에 쓰일 `URLSessionDataTask`를 만들기 위해 이 클래스를 상속받는 `MockURLSessionDataTask`를 구현했습니다. - `URLSessionDataTask` 대신 만들어준 `MockURLSessionDataTask`를 사용해주기위해 이니셜라이징을 해야하는데 deprecated되었다는 경고가 뜹니다. - 공식문서를 봐도 이니셜라이저 관련된 새로운 메서드가 없어서 방법을 못찾겠습니다.. 실제 IOS앱에서는 사용되지 않는 타입이니 무시해도 되는건지, 아니면 또 다른 방법이 있는건지 궁금합니다. --- # 오픈 마켓 PR 오픈 마켓 [STEP 2] 써니쿠키, 메네 @inwoodev 안녕하세요 제임스! 🙇🏻‍♀️🙇 Step2 완료하고 PR드립니다. Modern Collection Views에 대해 공부하고 사용해보느라 PR이 많이 늦은 점 죄송합니다. 이번에도 잘 부탁드립니다. 많이 알려주세요! 🙏🏻 ## 💭 고민한 부분 ### 1️⃣ 썸네일 이미지의 비동기 작업 서버에서 가져오는 썸네일 이미지의 url값을 이용해 사진을 넣어줄 때, `init(contentsOf:)`메서드를 사용해 Data를 가져왔는데 공식문서에 네트워크 기반 URL을 이용해 이 메서드를 사용 할 때는 비동기 작업으로 처리해주라하여 URLSession을 이용한 `fetchImage`메서드를 만들어 비동기로 처리했습니다. ### 2️⃣ 빠르게 스크롤 했을 대 Cell의 위치가 뒤죽박죽 섞이는 문제가 있었습니다. 사진을 비동기로 가져오게 되면서, List와 Grid에서 스크롤을 빠르게 했을 때, Cell의 위치가 뒤죽박죽되고 다른 이미지가 들어있다가 순식간에 다시 정상이미지가 돌아오는 등, Cell이 제자리를 찾지 못하는 문제가 있었습니다. <img width = 200, src = "https://i.imgur.com/xe6R3pE.gif"></br> cell 이 제자리를 찾을 수 있도록 indexPath를 이용했습니다. UICollectionView.CellRegistration`메서드에서 사용할 수 있는 `indexPath값과 `UICollectionView`의 indexPath 값이 같을 때에만 cell의 content를 생성하도록 하였습니다. 처음엔 이 비교를 위해 셀의 컨텐츠를 구성하는 기능을 하는 `configureCell` 메서드에서 3가지 `UICollectionView`, `Cell타입`, `IndexPath` 를 추가로 매개변수로 받아야 했습니다.. 매개변수사용 대신 `completionHandler` 로 `@escaping` 클로저를 사용해 뷰컨에서 indexPath를 비교하도록 수정과정을 거쳤습니다. ```swift // class MarketListCell func configureCell(page: Page, completionHandler: @escaping (() -> Void) -> Void) { // ... (셀의 콘텐츠를 구성) DispatchQueue.main.async { let updateConfiguration = { self.pageListContentView.configuration = content } completionHandler(updateConfiguration) } } // class MarketListViewController private func configureDataSource() { let cellRegistration = UICollectionView.CellRegistration<MarketListCell, Page> { (cell, indexPath, page) in cell.configureCell(page: page) { updateConfiguration in if indexPath == self.listView.indexPath(for: cell) { updateConfiguration() } } // ... ``` ### 3️⃣ 기본 이미지가 등록되어 있지 않아 로딩 시 셀의 크기가 각각 다른 문제 Modern Collection Views에서는 셀의 사이즈가 자동으로 지정되어 List뷰 로딩 시 레이블의 크기만큼 셀의 크기가 먼저 정해지고 이미지가 로딩되면 이후에 셀이 커지면서 각각 다른 크기의 셀로 작동되는 문제가 있었습니다. <img width = 200, src = "https://i.imgur.com/oN1iL7v.png"></br> `content.imageProperties.reservedLayoutSize` 프로퍼티를 이용하여 사이즈를 지정하고, 로딩 이미지를 `PlaceHolder`로 넣어주어 셀의 크기가 먼저 잡히도록 처리해 주었습니다. ```swift content.image = UIImage(named: "loading") content.imageProperties.reservedLayoutSize = CGSize(width: 70, height: 70) content.imageProperties.maximumSize = CGSize(width: 70, height: 70) ``` ### 4️⃣ 요구사항 List Cell 우측 "잔여수량: " 우측의 `>`를 위쪽으로 배치하기 위해 고민해 보았습니다. |요구사항|Disclosure<br>이용 시| |:--:|:--: |![](https://i.imgur.com/WbKY4EI.png)|![](https://i.imgur.com/XnSpod7.png)| 잔여수량 label과 우측 > label을 `StackView`로 담아주고 Alignment를 `Top`으로 설정해 주었습니다. ### 5️⃣ 레이블에 ~~취소선~~과 색상을 주기 위해 고민해 보았습니다. `AttributedString`을 사용하여 취소선과 할인 가격, 품절 색상을 적용하여 주었는데 `UILabel`을 extention하여 label에 적용하는 방법과 `NSMutableAttributedString`을 extention하는 방법 중 어떤 것을 사용할지 고민했습니다. ➡️ ListView에서 label에 직접 접근할 수 없어서 `NSMutableAttributedString`을 익스텐션하여 적용해주고 싶은 설정을 `append`로 연결하여 출력해 주었습니다. ### 6️⃣ 이미지다운의 비동기작업으로 인한 딜레이의 화면표현방법과 캐싱작업 이미지 마다 `UIActivityIndicatorView`를 이용해 이미지 로딩을 표현하고 싶었는데, `UICollectionViewListCell의` 기본 셀인 `.subtitleCell`을 사용하는 구성이라 이용에 어려움이 있었습니다. `UIListContentConfiguration`을 이용해 특성을 잡아줘야하는데 이미지뷰대신 indicator를 넣어주려니 타입문제와, addSubView가 되지않는 문제가 있어 적용하지 못했습니다. `LaunchScreen`도 생각해 보았습니다. 처음에 런치스크린을 노출해 주고 데이터를 받아오는 시간을 벌어보려 했는데 HIG 문서에서 보여줄 수 있는 정보는 먼저 보여주라고 쓰여있는 것을 보았던 기억이 나서 적용을 보류했습니다. 최종적으론, 로딩 image하나를 Asset으로 등록하여 작업전까지 로딩이미지가 보이도록 처리했습니다 더불어 작업 성능을 위해 이미지를 캐싱해놓도록 했습니다. </br> ## 🧐 조언을 얻고 싶은 부분 ### ❓ 1️⃣ cell의 콘텐츠를 구성해주는 메서드의 양이 많아서 기능분리를 해야하는지 고민됩니다 List와 Grid의 각 Cell의 내용을 구성해주는 `configureCell`은 cell에 이미지, 이름, 가격을 담아주는 역할을 합니다. 내용 구성을 하다보니 이미지의 캐싱작업, 이미지의 비동기작업, 이미지의 프레임작업, 이미지의 placeHolder넣어주기 같은 작업들도 필요하다보니 길이가 길어서 기능분리를 해줘야하는 것인지.. 하나의 기능으로 생각하여 이대로 내용이 길지만 묶어서 가지고있어도 될 지 모르겠습니다.. 특히 `ListCell`에서는 `Configuration`을 이용해 특성들을 캡슐화하는것이니 괜찮다는 생각도 드는데 코드 길이가 길다보니 어느 방향이 더 좋을지 모르겠습니다.. 제임스라면 어떻게 하셨을지 궁금합니다 조언 부탁드리겠습니다! ### ❓ 2️⃣ 제목이 길어지는 경우 어떻게 보여주는 편이 좋을지 조언을 듣고 싶습니다. Grid뷰에서 제목이 긴 경우, 현재는 말줄임 표시를 해서 보여주고 있는데 `numberOfLines`를 0으로 변경하면 줄바꿈하여 전체를 보여줄 수 있지만 셀의 크기를 잡아주었기 때문에 아래쪽 Label이 잘려서 보여지게 됩니다. 이 문제를 해결하기 위해 셀의 크기를 크게 준다면 아래쪽으로 비어있는 공간이 많이 노출될 것 같습니다. |말줄임 X|말줄임 O| |:--:|:--:| |![](https://i.imgur.com/NYU8AiL.png)|![](https://i.imgur.com/7VFlO2C.png)| 이런 것은 기획의 문제이지 않을까 싶은데요, 등록 화면에서 (아직 구현이 되어있진 않지만)글자수 제한을 둔다거나 하는 방법으로 어느정도 해결할 수 있을 것 같은데 제임스의 생각이 궁금합니다. --- # 오픈 마켓 PR 오픈 마켓 [STEP 3] 써니쿠키, 메네 @inwoodev 안녕하세요 제임스! 🙇🏻‍♀️🙇 Step3 완료하고 PR드립니다. `multipart/form-data`, `UIImagePickerController`등 새로운 개념들을 공부하고 사용해보느라 PR이 많이 늦은 점 죄송합니다. 이번에도 잘 부탁드립니다. 많이 알려주세요! 🙏🏻 ## 💭 고민한 부분 ### 1️⃣ 키보드 1. 등록화면에서 TextView에 제품 세부정보를 입력할 때, Keyboard가 콘텐츠를 가리는 문제가 있었습니다. - 키보드의 Notification을 이용해 키보드가 올라올 때, 키보드 높이만큼 뷰가 위로 이동할 수 있도록 했습니다. - 전체 뷰를 스크롤뷰에 담고, 키보드가 올라올 때 화면 밑으로 키보드의 높이만큼 `contentInset`을 추가하고, `contentOffset.y`에도 같은 값을 더해 컨텐츠를 가리지 않는 뷰가 보여지도록 구현했습니다. - 이 때, 세부정보를 입력하는 TextView에서만 화면 이동이 되도록 `isFirstResponder`를 확인했습니다. 2. 입력이 끝난 후 키보드를 내리는 방법에 세가지 방법을 적용했습니다. - 1. 스크롤 시 - 스크롤 뷰 Delegate의 `scrollViewWillBeginDragging`메서드를 사용했고, 내부에서 `endEditing`메서드를 사용해 키보드가 내려가도록 했습니다. - 2. 여백 터치 시 - `addGestureRecognizer`를 이용해 사진을 보여주는 테이블뷰를 제외한 나머지 빈공간 터치 시 키보드가 내려갈 수 있도록 `UITapGestureRecognizer`를 사용했습니다 - 3. 키보드 상단 Done버튼 클릭 시 - 키보드에 `UIToolbar`를 추가하고, `Done버튼`을 만들어 터치 시 키보드가 내려갈 수 있도록 했습니다. ### 2️⃣ 네비게이션 바에 추가한 `+` 버튼이 BarButtonItem 속성일 때 미동작하는 문제 - 처음에 스토리보드에서 네비게이션 바에 `+` 버튼을 추가하고 IBAction 메서드와 연결해 등록화면으로 넘어가는 코드를 작성했었습니다. 하지만 버튼 클릭 시 아무런 동작을 하지 않는 문제가 있었습니다. 디버깅 과정에서 함수 내부를 아예 타지 않는 것을 확인했고, BarButton 구현을 스토리보드 대신 코드로 구현하는 방법으로 수정했습니다. ### 3️⃣ DTO vs String - HTTPRequest 생성 시 Body에 담길 Data를 만들어주는 과정에서 오픈마켓 프로젝트에서는 body의 key로 `params`하나만 받고 있지만 여러개의 파라미터를 받게 될 경우를 생각하고 `Dictionary`로 Key와 Value를 받도록 구성해`for문`으로 여러개의 TextBody를 만들어서 추가하도록 구현했습니다 - 이때 처음엔 value값을 `String`으로 받도록 구현했었는데(textParameters: [String : String]), 파라미터로 받는 텍스트가 아래와 같이 길게 입력받는 것이 좋지 않아 `Data`타입을 받도록 수정했고((textParameters: [String : Data])), `createProductFromUserInput` 메서드를 통해 Product 타입의 인스턴스를 생성하여 인코딩한 data를 Body에 추가하도록 하였습니다. ```swift //String 사용 { “name”: “ttt”, “description”: “t1t1t1t1t1t1t1t1”, “price”: 10000, “currency”: “KRW”, “discounted_price”: 500, “stock”: 1234567, “secret”: “soobak1234” }` //DTO사용 후 Data타입사용 let product = Product(name: “ttt”, description: “t1t1t1t1t1t1t1t1”, price: 10000, currency: Currency.krw, discountedPrice: 500, stock: 1234567) guard let productData = try? encoder.encode(product) else { return } ``` ### 4️⃣ 사진등록 버튼 (버튼추가 vs `collectionView(_:, didSelectItemAt:)`메서드 사용 ) - 등록한 사진들을 보여주는 테이블뷰를 구성할 때, 아래 사진의 빨간 박스부분은 다른 셀들과 달리 터치 시 앨범을 띄워주는 기능을 가져야해서 구현방향을 고민했습니다. - Cell에 `Button`을 추가하는 방법과 `collectionView(_:, didSelectItemAt:)` 메서드를 사용할지 고민했고 후자의 방법으로 구현했습니다. 셀 터치 시 마지막 셀임을 확인하고, ImagePicker를 불러옵니다. <img width = 300, src ="https://i.imgur.com/PyKLvKF.png"> ### 5️⃣ 상품등록 후 리스트 화면으로 돌아와 업데이트 확인 - 상품 등록 페이지에서 내용을 작성하고 `Done` 버튼 터치 시, ListView에서 새롭게 추가된 상품이 노출되지 않는 문제가 있었습니다. - MarketListViewController에서 `viewWillAppear`에 `fetchMarketData()`메서드를 호출하여 주면 해결될 것으로 생각했지만 등록 후 바로 노출되지 않고 앱을 다시 실행했을 때 이전에 등록된 상품이 노출되었습니다. - 디버깅 과정 중 상품 등록이 완료되기 전에 `fetchMarketData()`가 먼저 호출이 되고 있어서 `uploadProduct`메서드에서 `completionHandler`로 등록이 완료될 때까지 기다려주도록 처리하였습니다. ### 6️⃣ UITextView에 PlaceHolder 적용 - `UITextField`와 달리 `UITextView`에서는 PlaceHolder를 지원하고 있지 않아 PlaceHolder를 넣어주기 위해 고민해 보았습니다. - 처음엔 textView.text에 `상세내용` String을 넣어주고 입력이 시작될 때 UITextViewDelegate의 `textViewDidBeginEditing` 메서드 에서 내용을 지워주는 방법으로 구현하였다가, 이 방법은 처음 화면 노출 시에만 보여지고 내용을 작성한 후 모두 지우고 다른 입력 Field로 이동하면 노출되지 않는 문제가 있어 `textViewDidEndEditing` 메서드에서 공백과 줄바꿈을 제외한 내용이 없으면 다시 PlaceHolder가 노출되도록 수정하였습니다. ```swift func textViewDidEndEditing(_ textView: UITextView) { if textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { textView.text = "상세내용" textView.textColor = .systemGray3 } } ``` ## 🧐 조언을 얻고 싶은 부분 ### ❓ 1️⃣ Resizing........ >이미지당 용량은 최대 300KB로 제한합니다. >이미지의 용량이 허용치를 초과하는 경우, 업로드 전 이미지의 크기를 조절합니다. 현재 저희 코드에서는 이미지 등록시 이미지용량을 확인하고 300KB이상이면 `UIGraphicsImageRenderer`를 사용해 한 변의 길이를 150으로 줄인 후 등록되도록 구현해놓았습니다. 하지만 4K같은 고용량의 이미지 등록을 가정하면, 사이즈를 150으로 줄여도 300KB이하로 내려가지 않을 수 있다고 생각합니다. 그래서 반복문을 사용해 300KB이하로 내려갈 때까지 리사이징을 해주는게 맞는것인지 고민됩니다...반복문에서 계속해서 10%씩 줄여나가면 시간이 아주 오래걸리지 않을까 하는 생각이 듭니다. 또다른 방법으론 `jpegData(compressionQuality:)`을 이용해 용량을 줄이는 방법도 생각해보았는데, 이 또한 고 용량일 경우 0.1 값을 주어도 300KB이하로 내려가지 않을 수 있다고 생각합니다.. 혹시 이 두가지 방법 외에 리사이징 외에 용량을 줄이는 빠르고 효과적인 방법이 있을까요?