---
# System prepended metadata

title: '오픈 마켓2 [STEP1] 허황, 애플사이다'

---

# 오픈 마켓2 [STEP1] 허황, 애플사이다 

메이슨 안녕하세요. @myssun0325
애플사이다 @just1103, 허황 @hwangjeha 입니다.
STEP1 완료하여 PR 드립니다. ViewController가 비대해져서 DataSource 타입을 생성하여 구분해줬는데, 부족한 점이 많을 것 같습니다. 😅 이번 리뷰도 잘 부탁드립니다. :)

## STEP1 구현내용
### 구현 화면 

|이미지 추가|이미지 수정/삭제|상품 등록 실패|상품 등록 성공|키보드 입력 처리|
|-|-|-|-|-|
|![](https://i.imgur.com/oDjsZYI.gif)|![](https://i.imgur.com/rr6Dd1h.gif)|![](https://i.imgur.com/edftfPN.gif)|![](https://i.imgur.com/GDTV2wS.gif)|![](https://i.imgur.com/cMXZ1n6.gif)|

### 1. HTTP Post
Gettable, Postable 프로토콜을 만들고 기존의 APIProtocol를 상속받았습니다.
Gettable, Postable을 분리한 이유는 이번 HTTP Post를 구현하면서 Post에 필요한 contentType, body 등을 APIProtocol의 프로퍼티로 넣게되면 get을 요청하는 API구조체들에도 프로퍼티가 생기기 때문이었습니다.

HTTP Post 메서드에 필요한 body를 만드는 MultipartFormData 타입을 구현했습니다.
오픈마켓 서버 명세의 고정된 body("params", "images")의 키를 사용하는 방식이 아니라 유동적으로 body를 구성할 수 있도록 구현했습니다.

### 2. 상품 등록/수정 화면의 구성
`상품 등록 화면` 및 `상품 수정 화면`의 차이점이 `이미지 추가/수정 기능` 가능 여부 밖에 없기 때문에 두 화면을 구성할 때, 동일한 ViewController 및 동일한 Scroll View를 공유하도록 설계했습니다.
(STEP1에서 `상품 등록 화면` 및 `상품 수정 화면`을 구현해야 했는데, `상품 수정화면`을 아직 구현하지 않았습니다. 사용자가 `상품 목록 화면`에서 상품을 탭하여 `상품 상세화면`을 확인할 때, vendor id를 비교하여 해당 상품의 vendor일 경우 `상품 수정 화면`으로 넘어갈 수 있는 버튼을 추가하기로 설계했습니다. STEP2에서 구현할 예정입니다.)

화면의 UI를 구성할 때는 `Scroll View` 내부에 `Vertical StackView`를 올리고, TextFeild, TextView 등을 올려서 구현했습니다. 
또한 제품 상세내용을 작성하는 `TextView`에도 `Placeholder`를 추가하여 사용자가 쉽게 글자수 제한 등을 알 수 있게 했습니다.

### 3. 상품 등록 화면의 이미지 추가 버튼 및 자동 Scroll 기능 구현
이미지를 수평 방향으로 Scroll할 수 있게 구현하기 위해 `CollectionView`를 사용했습니다. 그리고 사용자가 상품 이미지를 등록할 수 있도록 `CollectionView의 Footer`를 만들고, `이미지 추가 버튼`을 생성했습니다.
또한 이미지를 추가한 직후에 사용자가 추가버튼을 바로 볼 수 있도록 CollectionView를 자동으로 Scroll하게 구현했습니다. 이 과정에서 데이터를 reload한 이후에 Scroll할 수 있도록 completionHandler 기능을 넣은 `reloadDataCompletion` 메서드를 구현했습니다.

### 4. 키보드가 TextView를 가리지 않도록 수정
Keyboard 관련 Notification을 통해 키보드가 나타날 때 ScrollView의 `Bottom Content Inset`을 `키보드 높이`만큼 지정하도록 했습니다. 또한 키보드가 내려할 때 다시 Inset을 없애도록 했습니다.

### 5. Collection View Cell FirstResponder
키보드를 구현하면서 키보드 영역이 아닌 다른 영역을 눌렀을 때 키보드가 내려갈 수 있도록 ViewController의 view에 UITabGesture를 추가해줬습니다.
여기서 문제는 image Collection View의 Cell을 눌렀을 때 Collection View의 델리게이트 메서드 `didSeleteItemAt` 메서드가 호출되지 않는 문제가 발생했습니다.
저희가 원인을 유추해봤는데 cell에는 탭 제스쳐를 인식할 수 없어 탭 제스쳐가 ViewController의 View까지 도달했다는 생각을 했습니다.
따라서, 저희는 Cell위에 버튼을 만들고 그 버튼을 눌렀을 때 어떤 Cell이 눌렸는지 판단하는 기능을 추가해서 문제를 해결했습니다.
```swift
// 상위 뷰 중 매개변수로 받은 ofType 타입이 있다면 그 타입을 반환해주는 메서드
private extension UIView {
    func findSuperview<T>(ofType: T.Type) -> T? {
        var currentView = self
        var resultView: T?
        while let currentSuperview = currentView.superview {
            if currentSuperview is T {
                resultView = currentSuperview as? T
                break
            }
            currentView = currentSuperview
        }
        return resultView
    }
}
// ...
 @objc func editImageOfSelectedItem(_ sender: UIButton) {
     // Cell의 Button을 눌렀을 때 그 버튼의 superview 중 cell 타입이 있다면 
     // cell의 indexPath를 받아와서 이미지를 수정/삭제 할 수 있다.
        guard let productImageCell = sender.findSuperview(ofType: ProductImageCell.self),
              let indexPath = productImageCell.indexPath else {
            return
        }
     /...
 }
```

### 6. 메모리 캐시(보너스 스탭)
저번 오픈마켓1에서 캐시를 구현해보라는 보너스 스탭이 있어 NSCache를 사용해서 `메모리 캐시`를 구현했습니다.
저희는 서버에서 받아오는 이미지의 URL을 캐시에 key로 사용하고, 이미지를 로드하는 흐름을 설계해봤습니다.
1. key(이미지의 URL)가 메모리에 있는지 확인한다.
2. 메모리에 이미지가 있다면 메모리에서 꺼내서 로드한다.
3. 메모리에 이미지가 없다면 이미지의 URL로 데이터를 다운받아 변환한 이미지를 생성한다.
4. 생성한 메모리를 캐시에 저장한다.
위와 같은 흐름으로 이미지가 재사용될 때마다 서버에서 이미지를 다운받는 방식이 아닌 메모리에 이미지가 있는 경우 캐시를 사용하는 방법으로 이미지 캐시를 구현했습니다.

## 고민한 점 및 궁금한 점
### 1. Tap Gesture
사용자가 화면에서 `이미지 CollectionView` 또는 `textField/textView`가 아닌 영역을 탭하면, 키보드가 내려가도록 구현하기 위해 ViewController에 `Tap Gesture Recognizer`를 추가했습니다.
하지만 이로 인해 사용자가 등록한 이미지를 다시 수정하기 위해 `이미지 CollectionView`의 이미지를 탭했을 때, ViewController가 제스쳐를 가로채서 CollectionView가 제스쳐를 인식하지 못하는 문제가 발생했습니다.
따라서 CollectionView의 Cell 내부에 Button을 추가하고, 해당 버튼에 이미지를 넣도록 하여 해결했습니다. (버튼은 항상 Tap Gesture의 first responder이기 때문입니다.) 현업에서는 이 문제에 대해 어떤 방법을 사용하는지 궁금합니다.

### 2. ViewController 거대화
기능을 하나씩 구현하면서 ViewController이 점점 커지는 문제(?)발생했습니다.
따라서, ViewController의 Extension을 별도의 파일로 만들어서 ViewController의 소스코드를 분리시켰습니다.
사실 저희가 별도의 파일로 만든건 ViewController의 Extension이지 ViewController의 기능을 분리시켜준건 아니라고 생각합니다.
ViewController이 거대해질 경우 Extension을 사용해서 다른 파일로 분리해줘도 될까? 라는 고민을 했습니다.

또, 동일한 파일 내 ViewController를 확장한 경우 private 메서드, 프로퍼티에 접근이 가능하지만 다른 파일로 Extension해준 경우 private 메서드, 프로퍼티에 접근을 하지 못하는 경우가 발생했습니다.
다른 파일이더라도 extention이므로 "구현부"에 속하는데 왜 다른 파일에 만들어준 ViewController의 extension은 private에 접근을 못하는 것인지도 궁금합니다.

### 3. 등록할 이미지 크기 축소
이미지 크기를 300KB로 제한하라는 요구사항이 있어 이미지 크기를 줄이는 기능을 추가했습니다.

처음 이미지 사이즈를 줄이는 방법 메서드(preparingThumbnail)를 찾았지만 iOS 15부터 제공하는 기능이라서 `#available(iOS 15.0, *)`을 사용해서 버전 별로 기능을 분기처리했습니다.

이미지 크기를 줄이는 로직은 
`비율 = 제한된 용량(300KB) / imagePicker에서 받아온 이미지의 데이터 크기`
하게 되면 제한된 용량 대비 현재 이미지 용량의 비율이 나오는데
위에서 구한 비율로 이미지의 width, height 크기를 재조정했습니다.

### 4. CollectionView의 deleteItem 메서드
사용자가 이미지 우상단의 `x 버튼`을 탭하여 등록한 이미지를 삭제할 경우, 아래의 코드를 통해 `CollectionView` 및 `productImages 배열`에 반영하도록 했습니다. 하지만 앱이 중지되지는 않았지만 console log에서 `업데이트 이후 item 개수 = 업데이트 이전 item 개수 - 삭제한 item 개수`가 되지 않았다는 경고가 나타났습니다.
따라서 deleteItems 메서드를 reloadData 메서드로 대체했습니다. reloadData를 호출하면 DataSource 메서드인 cellForItemAt가 호출되기 때문에 이를 활용했습니다. 이 방법이 적절한지, 그렇다면 deleteItems 메서드는 어떤 경우에 사용하는지 궁금합니다. (다른 예제를 찾아봐도 이번 상황과 비슷한 경우에 사용하고 있었습니다.)

```swift 
@objc func removeImage(_ sender: UIButton) {
    guard let productImageCell = sender.findSuperview(ofType: ProductImageCell.self),
          let indexPath = productImageCell.indexPath else {
        return
    }
    
    // 수정 이전
    imageCollectionView.deleteItems(at: [indexPath])  // delete 직후에 reload됨
    productImages.remove(at: indexPath.item)
    
    // 수정 이후
    productImages.remove(at: indexPath.item)
    imageCollectionView.reloadData()  // dataSource 메서드 (cellForItemAt)가 호출되므로 해당 indexPath의 item이 삭제됨
}
```

### 5. Custom Cell, ProductManagementScrollView 등 View 구현내용
오픈마켓1 PR에서 Custom Cell 관련한 부분은 별도의 피드백을 주시지 않았는데, 저희가 제대로 구현한 것이 맞을지 궁금합니다. 😅 (인터페이스 빌더를 사용하지 않고 코드로만 View를 구현해봤는데, 개선할 점이 많을 것 같아서 찔렸어요..) 피드백 부탁드립니다. :) 