Skip to content

오픈마켓 iOS 프로젝트 저장소입니다

Notifications You must be signed in to change notification settings

ZZBAE/ios-open-market

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

13 Commits
 
 
 
 
 
 

Repository files navigation

🛍 오픈마켓 OPEN MARKET

프로젝트 기간 : 2022.07.11 월 ~ 2022.08.05 금
리뷰어 :


🪧 목차


📜 프로젝트 소개

물건 팔아요~ 오픈 마켓 🛍 상품 목록 리스트와 그리드 뷰 형식으로 보여주는 화면 구성, 새로운 상품을 등록할 수 있는 화면 구성, 실제 서버와 통신하여 등록한 상품을 생성하고, 정보를 수정하고, 상품을 삭제할 수 있는 기능.


👥 팀원

재재(ZZBAE) 주디(Judy)
Github Github

💾 개발환경 및 라이브러리

swift xcode

💡 핵심경험

  • 파싱한 JSON 데이터와 매핑할 모델 설계
  • URL Session을 활용한 서버와의 통신
  • CodingKeys 프로토콜의 활용
  • 네트워크 상황과 무관한 네트워킹 데이터 타입의 단위 테스트(Unit Test)
  • Safe Area을 고려한 오토 레이아웃 구현
  • Collection View의 활용
  • Mordern Collection View 활용
  • multipart/form-data의 구조 파악
  • URLSession을 활용한 multipart/form-data 요청 전송
  • 사용자 친화적인 UI/UX 구현 (적절한 입력 컴포넌트 사용, 알맞은 키보드 타입 지정)
  • UIAlertController 액션의 completion handler 활용
  • UIAlertController의 textFields 활용
  • UICollectionView 를 통한 좌우 스크롤 기능 구현

🕰 타임라인

첫째 주

날짜 내용
7/11(월) step1-1 모델 타입 구현
7/12(화) URLSession, MockURLSession 구현, UnitTest (step1-2) 후 STEP 1 PR
7/13(수) 리뷰기반 STEP 1 리팩토링 (UICollectionViewList 연습)
7/14(목) UICollectionViewList 연습구현 후 기본 틀 생성
7/15(금) List CollectionView 기본 구현, 리드미 작성

둘째 주

날짜 내용
7/18(월) 서버에서 데이터 가져온 후 ListCell 화면 구현
7/19(화) GridCell 화면 구현 및 개인 공부
7/20(수) View안 세부적인 부분 수정, 에러 핸들링, loadingIndicator 화면 구현 후 STEP 2 PR
7/21(목) 개인 공부
7/22(금) 리드미 작성 및 STEP 2 리뷰 반영 리팩토링 진행

셋째 주

날짜 내용
7/25(월) step2의 간단한 리팩토링 및 multipart-formdata 공부
7/26(화) 서버와 소통하는 post, patch, delete 연습용 코드로 multipart-formdata 기능 구현
7/27(수) 상품등록화면 구성 구현, UIImagePickerController 활용하여 이미지 부분 구현
7/28(목) 상품등록 textView와 키보드, 연습용 코드를 실제 서버와 소통할 수 있게끔 수정, 상품등록화면을 상품수정화면으로 재사용할 수 있게끔 구현
7/29(금) 추가 리팩토링, Readme.md 작성 및 step1 PR

넷째 주

날짜 내용
8/1(월) 사진 5장까지 post 가능하도록 구현, cache 적용,paramManager생성
8/2(화) step1 리팩토링 진행, 상품상세뷰, 상품수정뷰 틀 잡기
8/3(수) UIAlertController액션의 completionhandler로 수정/삭제/취소 구현, 상품수정 (patch), 상품삭제 (delete) 서버와 통신하게끔 구현
8/4(목) description 줄바꿈 해결, step2 PR
8/5(금) Readme.md 작성


📱 구현 화면

상품의 ListView 상품의 GridView 상품을 등록하는 화면
상품 기본 정보 입력 상품 상세 설명 입력 상품의 이미지 최대 5장까지 추가
상품명, 상품가격, 할인가격, 재고수량 정보 입력할 때 해당 키보드가 나오게끔 구현 상품의 상세 설명이 길어져도 밑에서 올라오는 키보드가 설명칸을 가리지 않게끔 구현 상품 이미지를 정사각형으로 넣을 수 있고, 1장 ~ 5장을 추가할 수 있도록 구현
상품 수정 기능 상품 삭제 실패 상품 삭제 성공
등록되어 있는 상품정보를 수정할 수 있음 등록된 상품의 비밀번호와 일치하지 않을 시 삭제 불가능 등록되어 있는 상품의 비밀번호와 일치할시 삭제 가능
상세 화면 이미지 넘기기 타인이 등록한 상품에 대한 수정 제한
좌우로 밀어서 다음/이전 이미지 확인 본인이 등록하지 않은 상품은 수정할 수 없음

🧑‍💻 코드 설명

UML

STEP 1

Model

1) Product

상품 정보를 파싱할 구조체

  • JSON 키 값을 스위프트의 네이밍에 맞게 변환하기 위해 CodingKeys 프로토콜의 활용

2) Page

상품 리스트를 가진 페이지 정보를 파싱할 구조체

  • JSON 키 값을 스위프트의 네이밍에 맞게 변환하기 위해 CodingKeys 프로토콜의 활용

3) Currency

통화의 단위를 나타내는 열거형

  • case: krw = "KRW", usd = "USD"

4) Item

product Model에서 가져온 정보를 뷰에다가 뿌려줄 Item 정보들

  • productImage(상품이미지), productName(상품이름), price(상품의 원래 가격), bargainPrice(상품의 할인된 가격), stock(잔여수량), devidePrice(상품 가격을 천의 단위로 , 넣어주는 함수)

Protocol

1) URLSessionProtocol

dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTaskProtocol
  • 위 메서드를 구현하도록 함
  • URLSessionMockURLSession에서 채택

2) URLSessionDataTaskProtocol

  • resume()을 구현하도록 함
  • URLSessionDataTaskMockURLSessionDataTask에서 채택

URLSession

1) URLSessionProvider

네트워킹에 사용할 세션을 제공하는 클래스

  • session : 통신에 사용할 세션 프로퍼티 -> URLSession 또는 MockURLSession
  • baseURL : api-host를 담은 프로퍼티
  • dataTask : sessiondataTask 메서드를 통해 서버로 부터 데이터를 받아오는 메서드
  • receivePage : URLRequest를 만들어 dataTask를 통해 상품 리스트를 받아오는 메서드

2) DataTaskError

통신 과정에서 발생할 수 있는 에러를 정의한 열거형

  • case: incorrectResponseError, invalidDataError

Mock

1) MockURLSession

Mock 테스트를 위해 사용할 가짜 세션

  • isSuccess: 인위적으로 결정할 통신 성공, 실패 여부 프로퍼티
  • dataTask : 실제로 통신하지 않고 지정된 결과를 반환하는 메서드

2) MockURLSessionDataTask

URLSessionDataTaskProtocol에 있는 resume 함수 변경

  • newResume()resume()의 실행을 하는 함수

STEP 2

View

1) LoadingIndicator

로딩화면을 시작하고 끝내는 클래스

  • showLoading: 로딩화면을 실행하는 타입 메서드
  • hideLoading: 로딩화면을 정지하는 타입 메서드

2) MarketListCollectionViewCell

상품들을 List 형태로 보여주는 컬렉션 뷰 셀

  • imageView: 상품의 이미지
  • accessoryImageView: 우측 > 이미지
  • nameLabel: 상품의 이름 라벨
  • priceLabel: 상품의 원가, 할인가격 라벨
  • stockLabel: 상품의 잔여수량 라벨
  • horizontalStackView: 상품 이름과 subHorizontalStackView를 묶어준 스택뷰
  • subHorizontalStackView: 잔여수량, >를 묶어준 스택뷰
  • verticalStackView: horizontalStackView와 상품 가격 라벨을 묶어준 스택뷰
  • entireStackView: verticalStackView와 상품의 이미지를 묶어준 스택뷰
  • arrangeSubView: 각 스택뷰의 Constraint를 잡아준 함수
  • extension CALayer {func addBottomBorder()}: 셀 밑에 선처럼 보이게 bottomBorder를 만들어주는 함수

3) MarketGridCollectionViewCell

상품들을 Grid 형태로 보여주는 컬렉션 뷰 셀

  • imageView: 상품의 이미지
  • nameLabel: 상품의 이름 라벨
  • priceLabel: 상품의 원가, 할인가격 라벨
  • stockLabel: 상품의 잔여수량 라벨
  • verticalStackView: 위에 4가지 라벨을 묶어준 스택뷰
  • arrangeSubView: 스택뷰의 Constraint를 잡아준 함수

ViewController

1) MarketCollectionViewController

상품 목록을 List와 Grid 형식으로 보여주는 컬렉션 뷰 셀을 관리하는 뷰컨트롤러

  • createListLayout : List 형식의 UICollectionViewLayout을 반환하는 메서드
  • createGridLayout : Grid 형식의 UICollectionViewLayout을 반환하는 메서드
  • makeListDataSource : List Cell의 Registration을 설정하고 DataSource를 생성하는 메서드
  • makeGridDataSource : : Grid Cell의 Registration을 설정하고 DataSource를 생성하는 메서드
  • receivePageData : 서버를 통해 상품 데이터를 받아오는 메서드
  • applySnapshots : 받아온 데이터로 SnapShot을 생성해 데이터소스에 적용하는 메서드
  • indexChanged :segmentedControl의 값이 변경되었을 때 뷰의 모양을 바꾸는 메서드

STEP 3

Model

Param

상품 등록 화면에 표시되는 해당 상품 정보들의 타입

  • vendorInfo: 판매자의 secretidentifier를 담은 열거형
  • Param: productName(상품명), price(상품가격), discountedPrice(할인가격), currency(통화단위), stock(재고수량), description(상품상세설명), secret(VendorInfo의 사용자 비밀번호)
  • ImageParam: imageName(이미지 파일 이름), imageType(이미지 파일 타입), imageData(이미지 데이터 타입)

View

화면 구성 정리본

1) AddProductView

새로운 상품을 등록(또는 수정)하는 화면

  • arrangeSubView: 요소들을 SubView에 넣고 제약을 설정하는 메서드
  • createParam: 입력된 정보들을 Param으로 반환해주는 메서드
  • configure: Param의 각 요소들을 각각의 칸(TextField)에 넣어주는 메서드

2) AddProductCollectiongViewCell

상품 등록 화면에서 이미지의 설정을 해주는 셀

  • arrangeSubView: 상품 이미지의 constraint를 설정해주는 메서드

ViewController

AddProductViewController

상품등록(또는 수정)하는 뷰를 관리하는 컨트롤러

  • changeToEditMode: 상품등록화면을 상품수정화면에서 사용할 수 있게끔 해주는 메서드
  • configureUI: 상단의 네비게이션 아이템 넣어준 부분의 layout 잡아주는 메서드
  • goBack: 상품리스트 화면으로 돌아가는 objc 메서드
  • goBackWithUpdate: 상품의 정보들을 입력해준 후 post 해주고, 성공과 실패의 얼럿을 띄워주는 메서드
  • CollectionView's DataSource & Delegate: UICollectionViewDataSource, UICollectionViewDelegate를 채택하여 뷰를 재사용할 수 있고, 이미지의 indexPath를 계산하여 얼럿을 띄워주고 제약을 걸어주는 메서드
  • ImagePickerController: UIImagePickerControllerDelegateUINavigationControllerDelegate를 채택하여, post할 이미지의 설정과 제약을 걸어주는 메서드
  • UITextView: textView에 작성을 시작하고 끝낼때의 viewConstraint를 조절해주는 메서드

STEP 4

ViewCell

DetailImageCollectionViewCell

상품상세정보에서 이미지와 이미지 순서를 나타내는 숫자label을 보여주는 콜렉션 뷰 셀

DetailInfoCollectionViewCell

상품상세정보에서 상품 정보를 보여주는 콜렉션 뷰 셀


ViewController

ProductDetailCollectionViewController

상품상세정보 뷰를 관리하는 컨트롤러

  • backBarButtonDidTapped: 뒤로가는 버튼을 눌렀을 때, 리스트 화면으로 pop해주는 @objc 메서드
  • editProductButtonDidTapped: 수정 버튼을 눌렀을 때, 상품 상세 정보를 수정할 수 있게 해주는 @objc 메서드
  • deleteAfterCheckSecret: 사용자 secretKey가 상품정보의 secretKey와 일치하면 delete가 가능하게 해주는 메서드 (일치하지 않으면 실패 얼럿)
  • configureUI: navigationBar의 UI를 구성 및 설정해주는 메서드




⛹🏻‍♀️ STEP 1 트러블 슈팅

1. URLSession 관련 로직 고민

요구명세서에 나와있는 것처럼 실제 API 서버와 통신하지 않고, MockData를 사용하여 테스트를 하기 위해 MockURLSession을 생성하고, URLSession도 동일하게 protocol을 채택하고 extension 구현을 해주었습니다. 처음에는 mockURLSession을 굳이 구현해주지 않고 어차피 나중에 사용할 (실제 서버와 소통을 하는) URLSession을 활용해주려고 했는데, 그러면 step 1의 명세대로 구현을 하기가 어려울 것 같아 따로 mockURLSession을 생성해주었습니다.
그리고 URLSessionProvider 생성 후 URLSessionProvider에 있는 dataTask 메서드를 구현해준 뒤 MockURLSession에서 그 메서드를 가로채와서 상세구현 후 MockData로 UnitTest를 진행하는 방식으로 전반적인 step 1 의 방향을 잡았습니다

2. Mock 테스트를 한 방법

UnitTest는 서버와 소통을 하지 않는 MockURLSession으로 진행했습니다. MockURLSession 내부의 dataTask는 실제 서버 통신을 하지 않고 isSuccess프로퍼티를 통해 임의로 지정한 성공-실패 여부에 따라 HTTPURLResponse를 보내도록 했습니다. 성공 시 MockData를 반환하고, 실패 시 400번대 응답코드를 반환하도록 했습니다.
UnitTest에서는receivePage 메서드를 통해 서버요청이 성공한 경우에는 받아온 jsonData가 이미 가지고 있는 mockData와 같은지, 서버요청이 실패한 경우에는 에러를 반환하는지를 테스트하는 함수를 만들어주었습니다. 혹시 추가로 할 수 있는 테스트는 어떤 종류가 있을지, 아니면 더 이상 테스트를 하지 않아도 되는지 콘의 의견이 궁금합니다.

3. URLSessionDataTask deprecated warning 해결

밑의 사진처럼 URLSessionDataTask에서 deprecated warning이 떴는데, 코드 빌드에는 문제가 되지 않았지만 애플에서 지양하도록 권장하는 경고이기 때문에 해결을 하고 싶었습니다.

  1. URLSessionDataTask를 상속받으면서 생기는 에러니까 상속 대신에 URLSessionDatatTaskProtocol을 만들고 conform하면?
  2. MockURLSessionDataTaskURLSessionDataTask의 상속이 아니라 URLSessionDatatTaskProtocol을 채택하도록 변경
  3. resume 앞에 override 키워드 제거
  4. URLSessionProtocoldataTask 함수에서 URLSessionDataTask를 반환한 부분을 URLSessionDataTaskProtocol을 반환하도록 변경
  5. URLSession에는 URLSessionDataTaskProtocol을 반환하는 기본 메서드가 없어서 URLSession안에서 해당 함수를 선언해주고, 이 안에서는 dataTask(with:completionHandler:)함수 호출


이런 흐름의 생각을 반영하여 URLSessionDatatTaskProtocol을 생성한 후 URLSessionDataTaskMockURLSessionDataTask이 protocol을 conform하게끔 만들어주었습니다.


⛹🏻‍♀️ STEP 2 트러블 슈팅

1. 뷰 Constraint 관련

1) contentView vs self

// MarketGridCollectionViewCell.swift
self.addSubview(verticalStackView)

NSLayoutConstraint.activate([
	 verticalStackView.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
         verticalStackView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -8),
	 verticalStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
	 verticalStackView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 8),
	 imageView.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 0.58)
])

Grid 셀과 List 셀 모두 콘텐츠에 대한 제약을 self와 관련하여 잡아주었습니다. CollectionViewCell에서 적어준 코드이니 self는 셀 자신이고 contentView는 셀 내부의 콘텐츠 영역이라고 생각해서 처음에는 contentView로 제약을 걸어주었습니다
하지만 contentView에 제약을 설정하니 아래 사진에서 같이 콘텐츠(특히 이미지)가 셀 크기를 벗어나는 문제가 있어 selfconstraints를 주었는데 self로 셀에 직접 제약을 걸어도 되는지 궁금했습니다.

self로만 제약 걸어줄 때 self.contentView로 제약 걸어줄 때

해당 질문에 대해 리뷰어인 콘이 contentView에 해야한다고 알려주셨습니다. contentView의 공식문서에서 편집 모드일 때 레이아웃에 적절히 배치되기 위해서는 contentView에 아이템을 추가하는 것을 권장하고 있습니다.

"you should add them to the content view so they position appropriately as the cell transitions in to and out of editing mode."


⬆️ 해당 부분 변경된 코드 사진 첨부

처음에 contentView에 제약을 걸었을 때 문제가 생겼던 이유는 selfaddsubView()stackView를 추가하고 제약은 contentView에 걸었기 때문이었습니다.. contentViewaddsubView() 하는 것으로 변경하니 해결되었습니다.

2) translatesAutoresizingMaskIntoConstraints 범위

translatesAutoresizingMaskIntoConstraints = false 는 전체를 감싼 stackView에서만 해주어도 되는지, 혹은 stackView 내부에 있는 모든 콘텐츠(label, image 등)마다 따로 설정을 해줘야하는건지 궁금했는데, 뷰 내부적으로 자동으로 false로 설정이 된다고 리뷰어분께서 설명해주셨습니다. 하지만 그래도 뷰 정의시 false로 지정하는 습관을 가져가는게 좋다고 해주셔서 각각의 콘텐츠마다 설정을 해주었습니다.

2. 콘솔 에러

처음 실행했을 때는 오류가 없다가 리스트 뷰에서 그리드 뷰로 전환하면 콘솔창에 LayoutConstraints관련 에러 메세지가 출력되었습니다. 빌드할 때는 지장이 가지 않고 뷰도 잘 나오기 때문에 어떤 문제인지 찾기 어려웠습니다.


하지만 1번 트러블 슈팅 내용인 셀의 아이템들을 contentView에 넣고 제약을 걸어주었더니 자연스럽게 해결된 것으로 보아 아마 self로 셀 자체에 제약을 건 상태로 Layout을 변경하면서 문제가 생겼던 것 같습니다.


3. StackView에 ImageView 넣기

컬렉션 뷰를 표시하면서 가장 문제였던 것은 이미지였습니다. List와 Grid 형식 모두 먼저 크게 하나의 StackViewCell에 제약을 걸고 그 StackViewImageViewLabel을 넣었습니다. 하지만 특히 이미지가 셀을 벗어나거나 크기가 제멋대로 나오는 문제가 자주 발생했습니다. 현재는 이미지에 크기를 지정하여 표시되는 것은 해결하였는데 혹시 StackView 안에 ImageView를 넣는 것이 좋지 않은 방법인가 고민했습니다.

리뷰어 콘이 말씀해주신 것처럼, 이미지뷰에 별도 사이즈를 지정하거나 제약을 가하지 않으면 이미지 크기가 이미지뷰의 크기가 되기 때문에 밑의 사진 처럼 나오는 것 (intrinsic content size) 이라고 이해했습니다.

제각각인 이미지 사이즈 예시 이미지 사이즈 제약 실패 예시




⛹🏻‍♀️ STEP 3 트러블 슈팅

1. 텍스트뷰 자동 스크롤

TextView에 글자를 입력 시 글자가 키보드에 가려지지 않게 즉 키보드 위로 자동으로 스크롤되도록 하는 요구사항이 있습니다. 키보드 위로 글자가 보이게 하는 방법을 세가지로 시도해봤습니다.

1) StackView의 Constraint 변경

extension ProductViewController: UITextViewDelegate {
    func textViewDidBeginEditing(_ textView: UITextView) {
        productView.entireStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -300).isActive = true
    }
    
    func textViewDidEndEditing(_ textView: UITextView) {
        productView.entireStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8).isActive = true
    }
}

첫 번쩨는 TextView가 속해있는 StackViewConstraint를 변경해보려고 했습니다. 키보드 크기만큼 bottom을 올렸다가 입력이 끝나면 내리도록 했는데 올리는 것은 성공했으나 다시 내려오지 않았습니다.

2) View의 높이 변경

extension ProductViewController: UITextViewDelegate {
    func textViewDidBeginEditing(_ textView: UITextView) {
         view.frame.size.height -= 300
    }

    func textViewDidEndEditing(_ textView: UITextView) {
        view.frame.size.height += 300
    }
}

두 번째 방법으로 전체 View의 높이를 변경해봤습니다. 키보드 크기만큼 올린 후 다시 내려오는 것이 가능했으니 빈 부분이 검정색으로 보이고 View가 압축되어 일그러졌습니다.

3) Content Offset 변경

extension ProductViewController: UITextViewDelegate {
    func textViewDidChange(_ textView: UITextView) {
        productView.descriptionTextView.setContentOffset(CGPoint(x: 0, y:  productView.descriptionTextView.contentSize.height - productView.descriptionTextView.bounds.height + 300), animated: false)
    }
    
    func textViewDidEndEditing(_ textView: UITextView) {
        productView.descriptionTextView.setContentOffset(CGPoint(x: 0, y:  productView.descriptionTextView.contentSize.height - productView.descriptionTextView.bounds.height), animated: false)
    }
}

마지막으로는 TextView의 스크롤의 커서 위치를 조절하려 했습니다. 하지만 입력을 시작하자마자 키보드 위치로 커서가 이동해버렸고, 입력을 끝내면 다시 위로 이동시키는 것이 어려웠습니다.

시도했던 방법의 예시와 문제점

StackView View Content Offset
TextView의 크기가 줄어들었으나 다시 돌아오지 않음 위로 올라간 후 다시 내려오지만 view가 압축되어 일그러짐 처음부터 중간에서 시작하게 되고 입력이 끝나면 아래로 이동

(최종) 첫 번째 방법 사용

최종적으로 첫 번째 방법을 사용했습니다. 첫 시도에서 발생했던 문제는 계속 새로운 Constraint를 추가해서 상충됐기 때문이었습니다.

private lazy var viewConstraint = productView.entireStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -260)

extension AddProductViewController: UITextViewDelegate {
    func textViewDidBeginEditing(_ textView: UITextView) {
        viewConstraint.isActive = true
    }
    
    func textViewDidEndEditing(_ textView: UITextView) {
        viewConstraint.isActive = false
    }
}

하나의 Constraint를 선언해 isActive를 조절하는 방식으로 했습니다. 또한 기본적으로 뷰를 구성할 때 지정한 bottom 제약의 Priority를 낮춰서 상충없이 동작하도록 구현했습니다.

2. 스크롤 시 상품에 맞지 않는 이미지가 나오는 문제 해결

빠르게 스크롤 하다보면 엉뚱한 사진이 띄워져 있는 문제가 있었습니다. 서버를 통해 사진을 받아오는 요청은 비동기로 진행되고, Cell이 재사용되기 때문에 발생하는 문제였습니다. 사진의 용량이 크거나 느린 네트워크일 경우 이전에 요청한 사진이 뒤늦게 돌아와서 잘못된 이미지가 떴습니다.

[문제 예시 - 제다이가 된 주디와 웡빙 (원래는 연예인 사진)]


이미지를 요청한 Cell과 현재 Cell이 일치하지 않았을 때의 문제이므로 Cell에 이미지를 할당하기 전에 이미지를 요청했던 Cell 비교하여 Cell이 일치한 경우에만 이미지를 넣도록 하여 해결했습니다.

 guard indexPath == self.collectionView.indexPath(for: cell) else { return }

3. 상위 셀 생성해 상속받기

이전 스텝에서 사용한 LISTGrid Cell이 중복된 코드가 많아 해결하고 싶었습니다. UICollectionViewCell을 상속받는 MarketCollectionViewCell을 생성해 중복되는 UI와 메서드를 갖도록 했습니다. 이후 LISTGrid CellMarketCollectionViewCell를 상속받아 사용할 수 있도록 하여 코드의 중복을 줄이고 추상화해 줄 수 있었습니다.

4. Alert은 Main Thread에서 실행하기

get이나 post와 같은 서버 요청이 실패한 경우 얼럿을 띄워 사용자에게 알려주도록 했는데 통신이 실패했을 때 얼럿이 뜨지 않고 아래와 같은 에러가 발생했습니다.

Alert을 메인 스레드에서 하지 않았을 때 나타난 에러

얼럿을 띄우는 것 역시 UI 동작이기 때문에 Main Thread에서 실행되도록 변경해 해결했습니다.




⛹🏻‍♀️ STEP 4 트러블 슈팅

1. 상품 상세 화면의 이미지 섹션 스냅샷

상품 상세 화면을 컬렉션 뷰로 구현해봤습니다. 상품 리스트 화면은 Lis일 때나, Grid일 때나 각 Cell의 형태는 동일해서 하나의 Custom Cell과 하나의 Section으로 표현 가능했지만 상세 화면은 다양한 구성을 가지고 있어 두 개의 섹션으로 표현했습니다.

enum Section: Int ,Hashable {
    case image // 이미지를 스크롤 할 수 있는 섹션
    case info	// 상품의 정보를 보여주는 섹션
 }

이미지 섹션의 snapshot을 지정할 때 어려움이 있었는데 우선 상품 정보를 컬렉션 뷰에 띄우기 위해 서버에서 GET한 데이터를 DetailProductItem 타입으로 디코딩한 후 사용했습니다. 따라서 snapshot으로 사용할 타입도 DetailProductItem인데 문제는 이미지를 표현하는 섹션의 데이터 역시 DetailProductItem으로 표현해야 했습니다.

그래서 처음에는 DetailProductItem[String]으로 이미지 url을 가지도록 했으나 이미지 데이터가 상품의 사진 개수만큼 있어야 띄울 수 있었습니다. 그래서 단순하게 동일한 DetailProductItem를 배열에 넣어서 snapshot으로 사용하려 했으나 각 요소가 Hashable하지 않아 사용할 수 없었습니다.

// DetailProductItem.swift
init(detailItem: DetailProductItem, image: String) { ... }

결국 단일한 이미지 url을 가지는 DetailProductItem을 사진 개수만큼 가진 배열을 사용해야 했습니다. 최종적으로는 DetailProductItem에 이미지 url만 변경하는 init을 추가했습니다.

typealias SnapShot = NSDiffableDataSourceSnapshot<Section, DetailProductItem>
private var detailProductItem: DetailProductItem? // 서버에서 받은 상품 상세 데이터를 decode한 정보
private var images: [String] = [] // 서버에서 받은 상품 이미지 url 배열

// 스냅샷 적용하는 코드
private func applySnapshots() {
    var itemSnapshot = SnapShot()
    guard let detailProduct = detailProductItem else { return }
    var detailImages: [DetailProductItem] = []
        
    images.forEach {
        detailImages.append(DetailProductItem(detailItem: detailProduct, image: $0))
    }
    itemSnapshot.appendSections([.image, .info])
    itemSnapshot.appendItems(detailImages , toSection: .image)
    itemSnapshot.appendItems([detailProduct], toSection: .info)
        
    dataSource.apply(itemSnapshot, animatingDifferences: false)
}

전체 이미지 url을 담은 배열을 forEach를 통해 url만 변경한 DetailProductItem을 생성해 배열로 만들어서 image 섹션의 snapshot으로 사용했습니다.

데이터는 잘 출력되지만 이미지만을 위해 DetailProductItem 타입을 사용하고, init을 통해 불필요하게 타입을 복제하는 것 같아 고민이 필요할 것 같습니다.

2. 상품 상세 화면에서 이미지를 넘길 때

상품 상세 화면에서 1개 이상의 이미지가 가로로 나열되어 있어 이미지를 스크롤해서 볼 수 있습니다. 하지만 다음 이미지를 보기 위해 수평 스크롤을 하였을 때, 이미지가 천천히 또는 빠르게 스크롤되고 좌우의 여백이 맞지 않는 상태여서 사용자 입장에서 보기 불편하다고 느껴졌습니다.

그래서 이미지 섹션의 orthogonalScrollingBehavior 설정을 .continuousGroupLeadingBoundary에서 .groupPagingCentered로 변경해주었습니다. 변경을 해준 뒤, 다음 이미지로 스크롤 하였을 때 이미지가 cell의 중앙에 맞춰져 멈추게 되어 한 장씩 넘길 수 있어 보기 편해졌습니다.

변경 전 코드

변경 후 코드

3. jpg 이외의 이미지 형식 Post

이전 step 1 (브랜치 step 3) 리뷰에서 알려주신 것처럼 이미지 타입을 구분하는 방법을 아래 사진처럼 적용해보았습니다.

여기서 저희가 발견한 문제점은, jpg는 구분이 잘 되지만 png 타입인 이미지를 어떤 방식으로 넣어도 png로 구분되지도 않고 출력되지도 않았습니다. 혹시나 시뮬레이터에서는 jpg 타입의 이미지만 가질 수 있는 것인가 의문이 들었습니다. 또한 핸드폰으로 실행해봤을 때 직접 찍은 사진이 아닌 스크린샷 또는 포털에서 다운받은 사진들은 Post되지도 않았습니다.

위 두 가지 문제는 모두 사진의 용량을 줄이는 compressImage 메서드 로직에 문제가 있었습니다.

private func compressImage(_ image: UIImage) -> Data {
    guard var imageDataSize = image.jpegData(compressionQuality: 1.0)?.count else { return Data() }
    var imageData = Data()
    var scale = 0.9
    
    while imageDataSize >= 300 * 1024 {
        imageData = image.jpegData(compressionQuality: scale) ?? Data()
        imageDataSize = imageData.count
        scale -= 0.1
    }
    return imageData
}

1) jpg로만 구분된 이유

사진 용량을 줄이기 위해 jpegData(compressionQuality: ) 메서드를 사용했습니다. compressionQuality을 지정해주면 해당 배율로 압축해서 사진을 Data 타입으로로 변환할 수 있습니다. 하지만 해당 메서드를 사용하면 어떤 사진도 jpeg로 변환되기 때문에 jpg 외에는 다른 타입이 나올 수 없었습니다 😅

2) 특정 사진들이 Post되지 않은 이유

위 코드를 보면 imageData에 빈 데이터를 넣은 후 while을 돌며 사진 용량이 300KB 이하가 될 때까지 압축한 후 다시 imageData에 할당했습니다. 하지만 이런 로직이면 이미 300KB 이하여서 while문을 실행하지 않으면 그대로 빈 데이터가 리턴되는 문제가 발생하고 있었습니다. 따라서 이미지가 존재하지 않으니 Post 역시 실패할 수밖에 없었습니다.

guard var imageData = image.jpegData(compressionQuality: 1.0) else { return Data() }

사진의 용량을 판단하기 전에 우선 jpegData로 변환한 데이터를 imageData로 할당해 빈 데이터가 리턴되는 문제를 해결했습니다.




⌨️ 커밋 규칙

  • feat : 기능 추가 (새로운 기능)
  • refactor : 리팩토링 (네이밍 수정 등)
  • style : 스타일 (코드 형식, 세미콜론 추가: 비즈니스 로직에 변경 없음)
  • docs : 문서 변경 (문서 추가, 수정, 삭제)
  • test : 테스트 (테스트 코드 추가, 수정, 삭제: 비즈니스 로직에 변경 없음)
  • chore : 기타 변경사항 (빌드 스크립트 수정 등)

🔗 참고 링크

[STEP 1]

wody's tistory
wody's notion
Fetching Website Data into Memory
Implementing Modern Collection Views
raywendelich test project
URLSession
오동나무의 비동기 테스트

[STEP 2]

contentView
wody - UILabel에 취소선 (strikethroughStyle) 적용하기
UICollectionViewCompositionalLayout
Lists in UICollectionView
UIActivityIndicatorView
modernCellConfiguration
UICollectionViewDataSource
implementing Modern CollectionView

[STEP 3]

UIImagePickerController 를 사용해 이미지 수정 및 가져오기
MultiPart통신 (멀티파트 이미지업로드)
URLSession으로 multipart/form-data request하기-wody
UIImagePickerControllerDelegate
UITextViewDelegate

[STEP 4]

UICollectionViewCompositionalLayout - init(sectionProvider:)
UICollectionViewDiffableDataSource
UIAlertController - addTextField(configurationHandler:)
UIAlertAction - init(title:style:handler:)

About

오픈마켓 iOS 프로젝트 저장소입니다

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • Swift 100.0%