안녕하세요 @jungseungyeo 두 번째 PR 보냅니다!
요번 스텝에서 많은 것을 배운 것 같으면서 어려운 부분이 많았네요😢
최선을 다해서 작성해서 보내드립니다!
잘부탁드립니다 😊
## 📝 구조 설명

### 기본 구조
- `MainViewController`: productView에서 구성한 view를 올리고, 네트워크 통신을 통해 받아온 데이터를 cell에 뿌려주는 역할
- `ProductView`: segmented control, collectionView, listLayout과 gridLayout을 구성하는 역할
- `ListCollectionViewCell`: MainViewController의 dataSource에서 받아온 데이터로 cell을 list 형태로 구성하는 역할
- `GridCollectionViewCell`:MainViewController의 dataSource에서 받아온 데이터로 cell을 grid 형태로 구성하는 역할
## 🧐 고민한점 & 해결방법
### 📌 1. list cell과 grid cell에 dataSource의 data를 어떻게 뿌려줄까?
처음에 MainViewController의 makeDataSource 메서드 내부에서 데이터들을 가지고 cell을 구성해주었는데요, 그렇게 하다보니 makeDataSource 메서드가 너무 길어지고 역할 분리도 애매해졌다는 생각이 들었습니다. 그래서 configureCell이라는 메서드에 파라미터로 product를 받도록 하고, cell 내부에서 파라미터의 product 데이터로 cell을 구성해주도록 변경했습니다.
### 📌 2. collectionView의 cell에 구분선을 어떻게 만들어 줘야 할까?
cell마다 구분선을 주는 방법에 대해 오래 고민했습니다ㅠㅠ 다른 캠퍼들에게 조언을 구해 view를 얇게 만들어서 넣는 방법이 있다는 것도 알게 되었고, CALayer를 extension해서 bottom에 구분선을 넣어주는 방법도 알게 되어, CALayer를 extension하는 방법을 채택했습니다.
혹시 footer나 다른 방법으로도 구현할 수 있는 방법이 있을까요? 린생은 어떤 방법을 사용하시는지 궁금합니다!
```swift=
extension CALayer {
func addSeparator() {
let separator = CALayer()
separator.frame = CGRect.init(x: 10, y: frame.height - 0.5, width: frame.width, height: 0.5)
separator.backgroundColor = UIColor.systemGray2.cgColor
self.addSublayer(separator)
}
}
```
---
## ❓ 궁금한점
### 📌 1. List화면에서 Grid 갔다가 List로 넘어갔을 때 AccessoryUI 쪽이 더 생겨나는 버그
처음에 동일한 셀을 제대로 초기화하지 않고 사용하기에 문제가 발생한다고 생각했습니다.
그래서 prepareForReuse() 메서드를 활용해서 하면 될 것이라고 생각했는데, 코드를 작성해보고 했으나 저희가 이해를 못한것인지 아니면 코드가 적절하지 않았던 것인지 제대로 작동이 되지 않습니다. 왜 이런 문제가 발생한 것일까요?
<img src="https://i.imgur.com/LjYL1Sp.png" width="500" height="800"/>
prepareForReuse는 두 cell에 작성했습니다
```swift
// ListCollectionViewCell
override func prepareForReuse() {
super.prepareForReuse()
productImage.image = UIImage(systemName: "swift")
productName.text = nil
currency.text = nil
price.text = nil
bargainPrice.text = nil
stock.text = nil
accessoryStackView.removeFromSuperview()
}
// GridCollectionViewCell.swift
override func prepareForReuse() {
super.prepareForReuse()
productImage.image = nil
productName.text = nil
currency.text = nil
price.text = nil
bargainPrice.text = nil
stock.text = nil
}
```
### 📌 2. Layout 설정 한 것이 적절하게 동적으로 대응한 것인지?
저희가 레이아웃에서 일명 숫자를 활용해서 constraint를 맞추는 곳이 많아서 이렇게 해도 될지 궁금합니다. 이렇게 하게되면 동적으로 처리가 안될 것 같은데.. 저희가 다르게 생각해봐야 될까요?
### 📌 3. DataSource 메서드 내부를 밖으로 캡슐화해서 빼는 방법이 없는지?
```swift
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: GridCollectionViewCell.identifier, for: indexPath) as? GridCollectionViewCell else {
return UICollectionViewCell()
}
DispatchQueue.main.async {
if collectionView.indexPath(for: cell) == indexPath {
cell.configureCell(productDetail)
}
}
```
이와 같은 코드가 분기에 따라 거의 동일하게 이루어지고 있어서 재사용성 있게 만들고 싶었는데, 이를 밖에서 메서드로 만들어서 호출하는 방법이 떠오르지 않습니다.
indexPath를 파라미터로 받으면 될 것 같은데, 그외에도 다른 요소들이 또 필요한 것같아서 메서드로 분리하다가 실패했는데 이 부분도 분리가 가능할지 궁금하네요!
### 📌 4. GridLayOut 보여줄 때 첫 화면의 위치
처음 GridLayout으로 가게되면 스크롤이 맨위에 있지 않고 바로 하나의 그룹 아래에 놓여져 있습니다. 원래 의도대로라면 GridLayout으로 가게되면 첫번째의 셀이 보여줘야되는데 이처럼 되는 현상이 무엇 떄문에 그런 것일까요? 이를 강제로 처음에 보여주게 만드는 방법을 사용해야할까요? 아니면 reuseCell과 같은 문제로 생긴 부분일까요?
### 📌 5. segmented control에서 selected되지 않은 segment의 색상 지정
선택된 segment는 selectedSegmentTintColor와 setTitleTextAttributes로 색을 정해줄 수 있었는데요,
selected되지 않은 segment에 대한 속성을 지정해줄 수 있는 방법을 찾지 못했습니다ㅠㅠ
LIST가 선택된 상태일때 GRID가 blue가 될 수 있도록 지정할 수 있는 방법이 있을까요?!
안녕하세요 @jungseungyeo 첫 번째 PR 보냅니다!
첫 번째 PR인데 내용도 어렵고 저희가 작성한 코드에 확신이 적네요ㅠㅠ
많이 지적해주시고 리뷰해주시면 공부해서 반영 잘하도록 하겠습니다!!
## 📝 UML

---
## 🧐 고민한점 & 해결방법
### 📌 1. URL을 어떻게 조합해서 만들어줘야 할까?
네트워크에 요청을 보내는 GET 역할을 하는 execute 메서드에서 사용할 url을 어떻게 만들어줄지 고민했습니다.
공통으로 사용되는 hostAPI, path 등을 API enum에 static let으로 선언해주고, case 별로 돌면서 enum 메서드에서 연관값을 입력받아 쿼리를 완성한 뒤에, hostAPI와 Path와 쿼리를 합쳐 url을 만들어주었습니다.
```swift
enum API {
static let hostAPI = "https://market-training.yagom-academy.kr"
static let productPath = "/api/products"
static let healthCheckerPath = "/healthChecker"
case productList(pageNo: Int, itemsPerPage: Int)
case productDetail(productId: Int)
case healthChecker
func generateURL() -> String {
switch self {
case .productList(let pageNo, let itemsPerPage):
return API.hostAPI + API.productPath + "?page_no=\(pageNo)&items_per_page=\(itemsPerPage)"
case .productDetail(let productId):
return API.hostAPI + API.productPath + "/\(productId)"
case .healthChecker:
return API.hostAPI + API.healthCheckerPath
}
}
}
```
### 📌 2. 하나의 NetworkManager로 네트워크 통신 성공시 여러 타입의 데이터를 디코딩하기 위한 제네릭 타입 사용
Product, ProductDetail, Application HealthChecker 3가지 타입의 데이터를 하나의 GET 메서드를 사용해서 가져올 수 있도록 NetworkManager에 Decodable을 채택하는 제네릭 타입을 선언해주었습니다. 처음에는 ProductNetworkManager와 HealthCheckerNetworkManager 2개의 타입을 만들어주었는데, String이 Decodable 프로토콜을 채택하고 있다는 것을 알게되어 하나로 사용할 수 있었습니다.
---
## ❓ 궁금한점
### 📌 1. dataTask 속 error의 에러처리 표현
dataTask에서 data, response, error 가 있는데 각각 guard 문을 통해 failure 상황을 처리해주려고 했습니다.
```swift
session.dataTask(with: url) { data, response, error in
guard error == nil else {
completion(.failure(.error))
return
}
```
이곳에서 completion이 실패했을 때 에처를 처리하기 위해 enum으로 모아서 했는데 이곳에서 나는 에러가 무엇일지 정확하게 판단하기 어려웠습니다.
serverError, transportError 둘 중 하나가 날 것이라고 구글링 결과 나왔는데, serverError는 statusCode에서 처리가 되는 게 아닌가 싶습니다..!
그래서 정확하게 어떤 에러가 나는지 파악이 어려워서 네이밍을 선정하기 어려웠습니다. 이곳에서 나는 에러는 전송이 실패한 에러일까요? 아니면 다른 에러일까요?
### 📌 2. init Deprecated
저희가 init를 StubURLSessionDataTask에서 사용해서 dummyData와 completion를 초기화해주려고 했습니다.
```swift
init(dummy: DummyData?, completionHandler: DataTaskCompletionHandler?) {
self.dummyData = dummy
self.dummyData?.completionHandler = completionHandler
}
```
이것을 사용하면 이렇게 경고창이 계속 뜨더라구요.. 이를 근데 아래서 제안한 방식으로 바꿔야할지..? 아니면 이것을 사용해도 무방할지 모르겠었습니다..ㅠㅠ 예시를 찾아봐도 코드가 obj-c 같은 코드들인데.. 왜이것을 제안하는것인지..? 이 방식을사용해야하는지 궁금합니다!
```
'init()' was deprecated in iOS 13.0:
Please use -[NSURLSession dataTaskWithRequest:]
or other NSURLSession methods to create instances
```
### 📌 3. test code에서 강제언래핑 사용가능여부
강제 언래핑을 사용하지 않아야 하고, 이는 앱 크러쉬가 날 수도 있기에 조심해야한다고 생각합니다.
하지만 test code에서는 앱의 사용에 지장이 없을 뿐더러 정확한 환경 속에서 테스트한다고 생각해서
강제 언래핑을 사용해도 되지 않을까? 생각을 했었습니다.
물론 옵셔널 바인딩을 통해 풀어줄 수도 있지만.. 확실하다고 생각하는 부분을 강제언래핑으로 하면 안될까하는 생각을 했었는데 린생의 생각은 어떤가요?
### 📌 4. HTTPURLResponse 에 대한 궁금증
저희가 dummyData를 test할 시 response에 아래와 같이 성공케이스 조건을 넣도록 사용했습니다.
```swift
let response = HTTPURLResponse(url: url,
statusCode: 200,
httpVersion: nil,
headerFields: nil)
```
이곳에서 저희가 궁금했던게 어떤 곳에서는 httpVersion를 명시한 곳도 있고 이렇게 nil로 한곳도 있는데, 이게 지정이 필요하지 않는 곳인가요? 또한 만약 httpVersion이 1.1이랑 2가 있다면 서로 통신이 불가능하거나 수신이 안될 수 있나요?
그리고 headerFields 부분이 오는 곳이 응답의 헤더필드를 말하는 것 같은데, GET은 body는 없지만 헤더는 요청과 응답에 포함되는 것으로 아는데, headerFields가 nil로 하더라도 되는 이유가 궁금하네요..! 혹시 저희가 header에 어떤 것도 안넣은 것일까요? GET으로 보낸다고는 한거같은데 왜 응답에는 nil로 처리한것인지 궁금합니다!
# 🛍 오픈 마켓
>프로젝트 기간 2022.05.09 ~ 2022.05.20
>
>팀원 : [marisol](https://github.com/marisol-develop), [Eddy](https://github.com/kimkyunghun3) / 리뷰어 : [린생](https://github.com/jungseungyeo)
## 목차
- [프로젝트 소개](#프로젝트-소개)
- [UML](#UML)
- [키워드](#키워드)
- [고민한점](#고민한점)
- [배운개념](#배운개념)
## 프로젝트 소개
오픈마켓 만들기!
## 실행 화면

## UML

## 개발환경 및 라이브러리



## 키워드
`git flow` `Test Double` `URLSession` `StubURLSession` `Protocol Oriented Programming` `추상화` `json` `HTTP method` `decode` `escaping closure` `Cache` `CompositionalLayout`
### 자세한 고민 보기
#### [STEP1](https://github.com/yagom-academy/ios-open-market/pull/142)
#### [STEP2](https://github.com/yagom-academy/ios-open-market/pull/153)
# STEP 1
## 고민한점
- URL을 어떻게 조합해서 만들어줘야 할까?
- 하나의 NetworkManager로 네트워크 통신 성공시 여러 타입의 데이터를 디코딩하기 위한 제네릭 타입 사용
- dataTask 속 error의 에러처리 표현
- init Deprecated
- test code에서 강제언래핑 사용가능여부
- HTTPURLResponse 에 대한 궁금증
## 배운개념
### 📌 1. URL을 어떻게 조합해서 만들어줘야 할까?
네트워크에 요청을 보내는 GET 역할을 하는 execute 메서드에서 사용할 url을 어떻게 만들어줄지 고민했습니다.
공통으로 사용되는 hostAPI, path 등을 API enum에 static let으로 선언해주고, case 별로 돌면서 enum 메서드에서 연관값을 입력받아 쿼리를 완성한 뒤에, hostAPI와 Path와 쿼리를 합쳐 url을 만들어주었습니다.
```swift
enum API {
static let hostAPI = "https://market-training.yagom-academy.kr"
static let productPath = "/api/products"
static let healthCheckerPath = "/healthChecker"
case productList(pageNo: Int, itemsPerPage: Int)
case productDetail(productId: Int)
case healthChecker
func generateURL() -> String {
switch self {
case .productList(let pageNo, let itemsPerPage):
return API.hostAPI + API.productPath + "?page_no=\(pageNo)&items_per_page=\(itemsPerPage)"
case .productDetail(let productId):
return API.hostAPI + API.productPath + "/\(productId)"
case .healthChecker:
return API.hostAPI + API.healthCheckerPath
}
}
}
```
### 📌 2. 하나의 NetworkManager로 네트워크 통신 성공시 여러 타입의 데이터를 디코딩하기 위한 제네릭 타입 사용
Product, ProductDetail, Application HealthChecker 3가지 타입의 데이터를 하나의 GET 메서드를 사용해서 가져올 수 있도록 NetworkManager에 Decodable을 채택하는 제네릭 타입을 선언해주었습니다. 처음에는 ProductNetworkManager와 HealthCheckerNetworkManager 2개의 타입을 만들어주었는데, String이 Decodable 프로토콜을 채택하고 있다는 것을 알게되어 하나로 사용할 수 있었습니다.
### STEP 1 추가 코멘트
### 📝 구조 설명
- `URLSessionProtocol`: URLSession과 StubURLSession에 의존성을 주입해주기 위한 프로토콜
- `URLSession` : dataTask 메서드를 실행하여 URLSessionDataTask를 생성하고 resume하여 서버로부터 data, statusCode, error를 전달받아서 NetworkManager에게 전달하는 객체
- `APIable`: hostAPI와 path, parameter, HTTPMethod를 갖는 프로토콜
- `StubURLSession`:URLSessionProtocol을 활용하여 data, statusCode, error를 포함한 가짜 response를 만들어서 NetworkManager에게 전달하는 객체
- `NetworkManager`: URLSession으로부터 받아온 reponse를 HTTPmethod방식에 따라 구분하여 error, statuscode, data를 확인 후 data를 디코더하여 그 값을 내보내는 객체
### ❓ 추가 질문
#### get 이외에 post/put/delete의 처리
NetworkManager의 execute 메서드에서 URLSession으로부터 받은 HTTPMethod 정보에 따라 다르게 처리해주기 위해서 switch문을 작성했는데요, post/put/delete에 대해서는 처리가 어떻게 달라져야 할지 아직 몰라서 구현을 해두지 않은 상태입니다🥲 이 부분은 STEP2에서 구현을 해도 될까요??
#### StubDataTask 제거
저희 생각에 이 구조체는 초기화도 사용 못하고 DataTaskCompletionHandler에서 저희가 만들어준 Custom Response 타입을 사용하고 실제로도 이를 활용해서 데이터를 받도록 했기 때문에 StubDataTask는 불필요하다고 생각해서 없앴습니다. 그리고 이를 테스트 사용 시에도 잘 되는 것은 확인했는데 이게 적절한 구조와 설계였는지 궁금합니다!!
# STEP 2
## 📝 구조 설명
### 기본 구조
- `MainViewController`: productView에서 구성한 view를 올리고, 네트워크 통신을 통해 받아온 데이터를 cell에 뿌려주는 역할
- `Presenter`: MainViewController의 makeDataSource에서 받은 product 데이터를 cell에 뿌려주기 전에 가공하는 역할
- `ProductView`: segmented control, collectionView, listLayout과 gridLayout을 구성하는 역할
- `ListCollectionViewCell`: Presenter에서 받아온 데이터로 cell을 list 형태로 구성하는 역할
- `GridCollectionViewCell`:Presenter에서 받아온 데이터로 cell을 grid 형태로 구성하는 역할
## 🧐 고민한점 & 해결방법
### 📌 1. list cell과 grid cell에 dataSource의 data를 어떻게 뿌려줄까?
처음에 MainViewController의 makeDataSource 메서드 내부에서 데이터들을 가지고 cell을 구성해주었는데요, 그렇게 하다보니 makeDataSource 메서드가 너무 길어지고 역할 분리도 애매해졌다는 생각이 들었습니다. 그래서 configureCell이라는 메서드에 파라미터로 product를 받도록 하고, cell 내부에서 파라미터의 product 데이터로 cell을 구성해주도록 변경했습니다.
> 각각 grid, list cell에 따라 View에 직접적으로 뿌려주게 되면 View에 로직이 더 필요하기 떄문에 Presenter라는 중간 매체를 사용했습니다. 이 곳에서는 cell에서 통신을 통해 받아온 데이터를 활용해서 뷰에 필요한 로직들을 구현했습니다.
예를 들어, 데이터포맷터를 통해 쉼표를 넣어주거나 재고 수량유무에 따라 cell에 다르게 보여줘야하는 부분을 처리하여서 View에서는 보여주는 역할만을 수행하도록 분리했습니다.
### 📌 2. collectionView의 cell에 구분선을 어떻게 만들어 줘야 할까?
cell마다 구분선을 주는 방법에 대해 오래 고민했습니다ㅠㅠ 다른 캠퍼들에게 조언을 구해 view를 얇게 만들어서 넣는 방법이 있다는 것도 알게 되었고, CALayer를 extension해서 bottom에 구분선을 넣어주는 방법도 알게 되어, CALayer를 extension하는 방법을 채택했습니다.
혹시 footer나 다른 방법으로도 구현할 수 있는 방법이 있을까요? 린생은 어떤 방법을 사용하시는지 궁금합니다!
```swift=
extension CALayer {
func addSeparator() {
let separator = CALayer()
separator.frame = CGRect.init(x: 10, y: frame.height - 0.5, width: frame.width, height: 0.5)
separator.backgroundColor = UIColor.systemGray2.cgColor
self.addSublayer(separator)
}
}
```
> cell 속에 view를 넣어서 구분선을 넣는 방법이 존재한다는 것을 알게 되었습니다. 하지만 위의 코드로 CALayer를 확장하여 사용하면 더 편리하다고 판단하여 위의 코드를 사용했습니다.
### 📌 3. modern CollectionView의 뷰의 전환을 비동기적으로 구현
segmentedControl의 index를 활용해서 뷰의 전환을 구현했었습니다.
하지만 이 코드는 modern ColletctionView에서 제공하는 init의 비동기적으로 뷰의 전환을 활용하지 않는 코드였습니다.
그리하여 내부에 존재하는 init를 활용해서 처리했습니다.
```swift
public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider,
configuration: UICollectionViewCompositionalLayoutConfiguration)
```
이 init는 뷰 안에 여러 Section들에 대한 전환을 비동기적으로 처리해주므로 이전 코드와 다르게 더 효율적으로 가능해집니다.
그리하여 아래처럼 구현했으며
```swift
private lazy var layouts: UICollectionViewCompositionalLayout = {
return UICollectionViewCompositionalLayout(sectionProvider: { _, _ in
return LayoutType.section(self.layoutType)()
}, configuration: .init())
}()
```
이곳에서 layoutType는 enum를 활용해서 내부를 캡슐화해서 사용했습니다. 그리하여 외부에서의 접근도 피할 수 있으며 segmentedIndex를 활용하지 않고도 뷰의 변환을 비동기적으로 처리할 수 있었습니다.
### 📌 4. 이미지 캐싱 처리 방법
NSCache를 사용해서 이미지를 캐싱하도록 했습니다.
UIImageView를 extension해서 `loadImage(_ urlString: String)`라는 메서드를 만들고,
이미 캐시에 해당 url의 이미지가 존재한다면 해당 이미지를 사용하도록 하고, 캐시에 없는 이미지라면 해당 url로 이미지를 만들어 캐시에 저장하도록 했습니다.
### 📌 5. 뷰의 ancestor 문제
뷰의 레이아웃을 잡고 빌드를 했을 때 아래와 같은 문제가 자주 발생했습니다.
> Thread 1: "Unable to activate constraint with anchors <NSLayoutXAxisAnchor:0x6000034dcec0 \"UILabel:0x12260c090.centerX\"> and <NSLayoutXAxisAnchor:0x6000034dcf00 \"UIView:0x12260ab30.centerX\"> because they have no common ancestor. Does the constraint or its anchors reference items in different view hierarchies? That's illegal."
이 문제가 발생했을 시 2가지를 고려하면 됩니다.
1. addSubview가 잘되어있는지
2. addSubview -> Layout Constraint 순으로 잘했는지
두 가지를 잘 생각해보면 해결가능합니다!!
### 📌 6. cell의 indexPath 비교 vs cell prepareForReuse
#### prepareForReuse
- 셀을 재사용하는 메서드다
- CollectionView의 장점은 셀을 재사용한다는것. 그래서 이 메서드가 효율적으로 발휘할 수 있다.
- 또한 Object 책에서 말하길, "객체지향에서 객체 사이에서 서로의 정보를 많이 알수록 의존 관계가 높아진다."고 하는데, indexPath가 cell의 정보를 아는 것이 객체 사이의 정보를 공유하는 것이기 떄문에 이는 의존관계를 높일 수 있는 결과를 초래한다.
- 값을 부분적으로 update할 수 있는 장점이 존재한다.
#### indexPath
- indexPath를 사용하면 고정적으로 update를 해줄 수 있다
- 여기에서는 재사용 셀을 사용하지 않고 고정적으로 적절한 곳에 업데이트 해주므로 고정적으로 사용한다면 유리할 듯싶다.
- 하지만 재사용성이 떨어지므로 이를 주의해야하며, 관리를 해줄 수 없는 단점이 존재한다.
> 위와 같은 특성이 있고, collectionView의 장점인 재사용성을 이용한 prepareForReuse를 사용하도록 변경하였다
---
## ❓ 궁금한점
### 📌 1. List화면에서 Grid 갔다가 List로 넘어갔을 때 AccessoryUI 쪽이 더 생겨나는 버그
처음에 동일한 셀을 제대로 초기화하지 않고 사용하기에 문제가 발생한다고 생각했습니다.
그래서 prepareForReuse() 메서드를 활용해서 하면 될 것이라고 생각했는데, 코드를 작성해보고 했으나 저희가 이해를 못한것인지 아니면 코드가 적절하지 않았던 것인지 제대로 작동이 되지 않습니다. 왜 이런 문제가 발생한 것일까요?
<img src="https://i.imgur.com/LjYL1Sp.png" width="500" height="800"/>
prepareForReuse는 두 cell에 작성했습니다
```swift
// ListCollectionViewCell
override func prepareForReuse() {
super.prepareForReuse()
productImage.image = UIImage(systemName: "swift")
productName.text = nil
currency.text = nil
price.text = nil
bargainPrice.text = nil
stock.text = nil
accessoryStackView.removeFromSuperview()
}
// GridCollectionViewCell.swift
override func prepareForReuse() {
super.prepareForReuse()
productImage.image = nil
productName.text = nil
currency.text = nil
price.text = nil
bargainPrice.text = nil
stock.text = nil
}
```
> cell이 재사용될 때 AccessoryStackView가 계속 쌓여서 발생하는 문제라고 파악했고,
그래서 `prepareForReuse` 메서드에서 accessoryStackView의 subview들을 `removeFromSuperview`해서 삭제시켜주었습니다.
``` swift
override func prepareForReuse() {
super.prepareForReuse()
discountedPrice.isHidden = false
originalPrice.attributedText = nil
accessoryStackView.arrangedSubviews.forEach {
$0.removeFromSuperview()
}
}
```
### 📌 2. Layout 설정 한 것이 적절하게 동적으로 대응한 것인지?
저희가 레이아웃에서 일명 숫자를 활용해서 constraint를 맞추는 곳이 많아서 이렇게 해도 될지 궁금합니다. 이렇게 하게되면 동적으로 처리가 안될 것 같은데.. 저희가 다르게 생각해봐야 될까요?
> 일반적으로는 고정적으로 하지 않고 동적처리를 해주어야 하지만 동적으로 변하지 않을 곳에는 고정적으로 해도 된다고는 하지만 변화에 대응하기 어렵다고 판단했습니다. 현재 코드에서는 문제없지만 추후에 변화가 생기면 동적으로 처리해줘야 한다고 인지했습니다.
### 📌 3. DataSource 메서드 내부를 밖으로 캡슐화해서 빼는 방법이 없는지?
```swift
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: GridCollectionViewCell.identifier, for: indexPath) as? GridCollectionViewCell else {
return UICollectionViewCell()
}
DispatchQueue.main.async {
if collectionView.indexPath(for: cell) == indexPath {
cell.configureCell(productDetail)
}
}
```
이와 같은 코드가 분기에 따라 거의 동일하게 이루어지고 있어서 재사용성 있게 만들고 싶었는데, 이를 밖에서 메서드로 만들어서 호출하는 방법이 떠오르지 않습니다.
indexPath를 파라미터로 받으면 될 것 같은데, 그외에도 다른 요소들이 또 필요한 것같아서 메서드로 분리하다가 실패했는데 이 부분도 분리가 가능할지 궁금하네요!
> 빼려면 제네릭으로 뺄 수는 있지만, 어차피 configure에서 한번 타입캐스팅이 되어야 하기 때문에 현행을 유지하도록 했습니다.
### 📌 4. GridLayOut 보여줄 때 첫 화면의 위치
처음 GridLayout으로 가게되면 스크롤이 맨위에 있지 않고 바로 하나의 그룹 아래에 놓여져 있습니다. 원래 의도대로라면 GridLayout으로 가게되면 첫번째의 셀이 보여줘야되는데 이처럼 되는 현상이 무엇 떄문에 그런 것일까요? 이를 강제로 처음에 보여주게 만드는 방법을 사용해야할까요? 아니면 reuseCell과 같은 문제로 생긴 부분일까요?
> Layout 에러가 존재해서 첫 화면의 위치가 이상했었습니다. 그리하여 에러를 수정하며 자연스럽게 해결되었습니다.
### 📌 5. segmented control에서 selected되지 않은 segment의 색상 지정
선택된 segment는 selectedSegmentTintColor와 setTitleTextAttributes로 색을 정해줄 수 있었는데요,
selected되지 않은 segment에 대한 속성을 지정해줄 수 있는 방법을 찾지 못했습니다ㅠㅠ
LIST가 선택된 상태일때 GRID가 blue가 될 수 있도록 지정할 수 있는 방법이 있을까요?!
# 오픈마켓2 1번째 PR
안녕하세요 @jungseungyeo 첫 번째 PR 보냅니다!
시간이 많이 늦어져서 죄송합니다!!!
기능만 구현하고 하려고 했으나 많은 부분에서 막혀서 이를 수정하고 바꾸다 보니 늦어졌습니다!
리뷰 잘부탁드립니다 린생 :)
## 구현되지 않은 부분
1. 키보드 사용 시 텍스트 뷰가 가려지지 않도록 하는 기능
2. 목록 화면 셀 페이징, Refresh 구현
3. PATCH를 통해 뷰에 업데이트(구현은 대부분 완성)
4. 상품등록 화면 사진을 버튼 -> 이미지로 변경할 예정
5. 상품 등록, 수정 시 필수 입력과 선택입력 가이드라인 로직
## 🧐 고민한점 & 해결방법
### 📌 1. 공통 코드에 프로토콜 기본 구현 활용
상품 등록 화면 (productDetailView)와 상품 수정 화면 (productEditView)에서 동일한 view를 그려주기 때문에, 프로토콜로 메서드를 명세하고, 기본 구현을 통해 공통된 부분을 한번만 구현해줄 수 있도록 했습니다.
Drawable이라는 프로토콜에 구현을 했는데, 프로토콜에는 stored property가 사용이 불가해서, 전부 computed property로 구현해주었습니다.
그리고 private 설정도 불가했습니다ㅠㅠ
중복된 코드 작성을 막을 수는 있었지만, 이로 인해 은닉화가 불가능해졌는데 혹시 다른 방법이 있을지 궁금합니다!
### 📌 2. endpoint 활용하기
아시다시피 처음에는 hostAPI, path, params, httpmethod를 가지고 있는 APIable 프로토콜을 생성해서 네트워크 통신을 했었는데요,
get, post마다 계속 APIable을 채택하는 구조체를 만들어줘야 해서 비효율적이라고 생각했습니다.
그래서 동료 캠퍼의 조언을 받아 endpoint 구조체를 만들었습니다.
그래서 case별로 선택하면 알아서 연관값을 활용하여 url을 만들어주도록 구현했습니다.
```swift
enum Endpoint {
case healthChecker
case productList(pageNumber: Int, itemsPerPage: Int)
case productRegistration
case productEdit(productId: Int)
}
extension Endpoint {
var url: URL? {
switch self {
case .healthChecker:
return .makeUrl(with: "healthChecker")
case .productList(let page, let itemsPerPage):
return .makeUrl(with: "api/products?page_no=\(page)&items_perpage=\(itemsPerPage)")
case .productRegistration:
return .makeUrl(with: "api/products")
case .productEdit(let productId):
return .makeUrl(with: "api/products/\(productId)")
}
}
}
private extension URL {
static let baseURL = "https://market-training.yagom-academy.kr/"
static func makeUrl(with endpoint: String) -> URL? {
return URL(string: baseURL + endpoint)
}
}
```
### 📌 3. 다른 뷰에 프로퍼티 전달하는 방법
MainViewController -> ProductDetailViewController로 넘어갈때 “선택된 상품의 product id”를 전달해주고,
ProductDetailViewController -> ProductEditViewController로 넘어갈 때 “선택된 상품의 상세 정보 (ProductDetail 타입)”을 전달해줘야 한다고 생각했습니다.
그런데 그러려면 ProductDetailViewController의 id과 ProductEditViewController의 productDetail이 은닉화가 될 수 없는 문제가 있습니다.
프로퍼티는 은닉화를 하고, 메서드로 이 프로퍼티를 전달해주도록 변경해야할까요?🥲
조언 주시면 감사하겠습니다!🙇♀️
### 📌 4. UIImagePicker에서 선택한 이미지를 순서대로 보여주기
image가 선택되었을때, `didFinishPickingMediaWithInfo` 메서드 내에서
선택된 이미지를 `imageArray에` append해주었습니다.
그리고 `cellForItemAt` 메서드에서 `imageArray[indexPath.row]`번째 이미지를 `button.setImage` 해주는 방식으로 이미지를 순서대로 보여주도록 구현했습니다.
## ❓ 궁금한점
### 📌 1. compactMap에서만 가능한 이유
아래의 코드를 돌렸을 때 forEach에서는 append가 정상적으로 되지 않아서 값이 하나도 들어가지 않고 있습니다.
```swift
presenter.images = productDetail.images?.compactMap { image in
image.url
}
print(presenter.images)
// productDetail.images?.forEach { image in
// guard let imageURL = image.url else { return }
// presenter.images?.append(imageURL)
//
// }
```
하지만 compactMap를 사용해서 하니 제대로 되는 것을 확인할 수 있었습니다.
compactMap으로 하면 nil이 아닌 값들만 배열에 추가해서 반환하는 특성이 있다고 생각합니다. 그래서 이곳에서 옵셔널이기 때문에 이게 제대로 된것인걸까요?
forEach는 단순 반복이라 옵셔널과 연관이 있어서 안되는 것일까요?
### 📌 2. 여러 개의 ImageView 보여지도록 하는 방법
현재 이미지뷰가 여러 개를 등록해도 하나로만 통일되어서 나오는 현상이 있습니다!
저희가 이걸 해결하기 위해서 배열을 만들어서 그곳에 이미지뷰를 넣은 다음 하나씩 부르면 될 것이라고 생각도 해봤는데 그게 잘 안되더라구요! 그래서 아래처럼 addSubview에 하나씩 넣으면 되지 않을까 싶었는데도 각 다른 이미지가 나오게 하는 것을 실패했습니다 ㅜㅜ
저희가 생각을 잘 못하고 있는것일까요?
```swift
func setImage(_ presenter: Presenter) {
guard let productImages = presenter.images else {
return
}
for image in productImages {
guard let url = URL(string: image) else {
return
}
guard let data = try? Data(contentsOf: url) else {
return
}
imageView.image = UIImage(data: data)
contentView.addSubview(imageView)
}
}
```
### 📌 3. PATCH 안되는 부분
저희가 PATCH를 구현했고 이를 뷰에서 업데이트하려고 하는데 동작이 되지않네요ㅜㅜ
그래서 값이 안 바뀐 것인가싶어서 브레이크포인트를 통해 찍어보고 찾아봤는데도 값이 바뀌는 것을 확인할 수 있었습니다.
하지만 실제 뷰에서 업뎃이 안되고 통신을 확인했을 때에도 값이 변하지는 않고 있었습니다.
그래서 아직 이 에러를 수정하지 못하고 있습니다..!
### 📌 4. ImageView 용량 사이즈
저희가 이미지뷰의 용량을 줄이기 위해 여러 방법이 있다는 것을 찾았습니다.
해상도 낮추기, 이미지 크기 줄이기 등이 있는 것을 보았고 실제 요구사항을 보았을 때 이미지의 크기를 줄이라는 말로 이해해서 그렇게 진행했습니다.
그래서 용량을 체크한 후 resize하여 저장하도록 했습니다.
그러나 실제 리사이즈한 것을 보게 되면 요구사항 보다 더 작은 이미지 크기가 되는 것을 확인할 수 있었습니다. 그래서 이를 레이아웃으로 키워서 꽉차도록 해야 하는 것인지 아니면 이대로 작아진 크기가 적절한 방법이였는지 궁금합니다!
```swift
// 구현부
extension UIImage {
func resize(newWidth: CGFloat) -> UIImage {
let scale = newWidth / self.size.width
let newHeight = self.size.height * scale
let size = CGSize(width: newWidth, height: newHeight)
let renderer = UIGraphicsImageRenderer(size: size)
let renderedImage = renderer.image { context in
self.draw(in: CGRect(origin: .zero, size: size))
}
return renderedImage
}
func checkImageCapacity() -> Double {
var capacity: Double = 0.0
guard let data = self.pngData() else {
return 0.0
}
capacity = Double(data.count) / 1024
return capacity
}
}
```
```swift
// 호출부
guard let image = info[UIImagePickerController.InfoKey.editedImage] as? UIImage else {
return
}
let imageCapacity = image.checkImageCapacity()
if imageCapacity > 300 {
let resizedImage = image.resize(newWidth: 80)
self.imageArray.append(resizedImage)
guard let imageData = resizedImage.jpegData(compressionQuality: 1) else {
return
}
let imageInfo = ImageInfo(fileName: "rimasol.jpeg", data: imageData, type: "jpeg")
self.networkImageArray.append(imageInfo)
} else {
self.imageArray.append(image)
guard let imageData = image.jpegData(compressionQuality: 1) else {
return
}
let imageInfo = ImageInfo(fileName: "rimasol.jpeg", data: imageData, type: "jpeg")
self.networkImageArray.append(imageInfo)
}
```
# 오픈마켓2 2번째 PR
안녕하세요 @jungseungyeo 두 번째 PR 보냅니다!
벌써 마지막 PR이네요🥲
STEP1에서 피드백 주신 내용을 아직 다 반영을 못했습니다ㅠㅠ
아래 구현되지 않은 부분에 적어두었으니, 나머지 부분 봐주시면 감사하겠습니다!
아직 구현되지 않은 부분은 주말에 계속 해보겠습니다.
4주동안 정말 감사했습니다🙇♀️
마지막 리뷰 잘부탁드립니다 린생 :)
## 구현되지 않은 부분
1. 키보드 사용 시 텍스트 뷰가 가려지지 않도록 하는 기능
2. 상품 디테일 뷰 로그에 뜨는 레이아웃 에러
3. 네트워크 추상화
4. 마지막에 리뷰해주신 addTarget 은닉화
## 🧐 고민한점 & 해결방법
### 📌 1. 상품 secret 조회를 위한 secretPost 구현
DELETE를 하기 전에, secretPOST를 통해 product의 secret을 받아서 해당 product의 secret으로 DELETE를 실행했습니다.
그런데 secretPOST는 상품 등록할 때의 POST와 request가 달라서
networkManater의 execute 메서드에 case를 하나 더 생성했었어야 했습니다ㅠㅠ🥲
이로써 httpMethod가 get / post / patch / delete / secretPost 이렇게 5개가 되어버렸습니다
이 부분은 어쩔 수 없이 post와 secretPost를 구분해야 하는걸까요?
## ❓ 궁금한점
### 📌 1. View의 로직을 VC에서 사용함에 따른 은닉화 문제
View에서 로직 구현을 하는 것이 옳지 않기에 VC에서 로직을 구현하려고 노력했습니다. 그렇게 하다보니 View에서 만든 Label를 접근해서 해야하기 때문에 은닉화를 할 수 없는 상황이 있었습니다ㅠㅠ 그래서 이부분이 열려있기에 찝찝한데, 어쩔수 없이 로직을 만들다보니 이런 현상이 생기는데 이는 자연스러운 건가요? 아니면 다른 방식으로 은닉화를 시도해야할까요?
```swift
final class ProductDetailView: UIView {
...
private let entireScrollView = UIScrollView()
private let productImage = UIImageView()
let stockLabel = UILabel()
let priceLabel = UILabel()
let discountedLabel = UILabel()
let productNameLabel = UILabel()
private let descriptionTextView = UITextView()
private let currencyLabel = UILabel()
let pageControl = UIPageControl()
}
// VC 사용처
private func configureProductNameUI() {
productDetailView.productNameLabel.font = UIFont.boldSystemFont(ofSize: 15)
}
private func configureStockUI() {
productDetailView.stockLabel.textColor = .systemGray2
}
```
### 2. 네트워크 추상화
우선 각 객체의 역할을 아래와 같이 생각했습니다.
- `ViewController`
- 정보 (ex: pageNo, itemsPerPage, productId 등 )를 가지고 APIable의 request 메서드 호출
- `APIable`
- VC에게 받은 정보로 request 메서드에서 get / post / patch / delete 구분해서 NetworkManager의 메서드 (execute) 호출
- `NetworkManager`
- APIable에게 전달받은 host, path, params 등 조합해서 url 생성
- url로 request 만들어서 네트워크 통신
- 통신한 결과를 VC에 전달
APIable을 먼저 구현했는데요,
VC는 APIable의 request 메서드를 호출할 때 GET인지, POST인지를 알고 있을 필요가 없기 때문에, request 메서드의 파라미터로 받은 값에 따라 request 메서드 내부에서 httpMethod를 정해줘야 한다고 생각했습니다.
그런데 파라미터의 형식이 다양하다보니, APIable의 변수들과 request의 파라미터 종류가 많아졌습니다..
```swift
protocol APIable {
var method: HTTPMethod? { get }
var baseUrl: String? { get }
var listParams: [String: String]? { get }
var registrationParams: String? { get }
var updateParams: Int? { get }
var deleteParams: String? { get }
var secretParams: String? { get }
var images: [ImageInfo]? { get }
func request(_ list: [String: String]?, _ registration: String?, _ update: Int?, _ delete: String?, _ secret: String?, completionHandler: @escaping (Result<Any, Error>) -> Void)
}
```
ViewController에게서 받은 정보를 구분하기 위해서는 각 상황마다 필요한 정보들을 파라미터로 모두 만들어두어야 한다고 생각했는데, ViewController에게서 정보를 받고, httpMethod를 구분하기 위한 다른 방법이 있을지 궁금합니다ㅠㅠ
저희가 생각한 로직으로는 request 메서드 내부에서 httpMethod를 구분하려면 각 파라미터가 nil이 아닐때로 분기처리를 해야할 것 같습니다.
```swift
if list != nil {
api = API(method: .get,
baseUrl: "https://market-training.yagom-academy.kr/api/products",
listParams: list,
params: nil,
productForPOST: nil,
productForPATCH: nil,
images: nil)
networkManager.execute(with: api) { result in
switch result {
case .success(let result):
…
case .failure(let error):
print(error.localizedDescription)
}
}
} else if registration != nil {
…
}
```
저번 STEP에서 열심히 알려주셨는데 적용을 못해서 정말 죄송합니다😭😭😭
PR 보내고 나서도 계속 고민해보겠습니다