iOS 앱 개발 포트폴리오 | 네컷사진 추억앨범 & 포토위키 플랫폼
내일배움캠프 7기 앱 개발 과정 iOS 트랙 수료생 최종 프로젝트 '포키'를 소개합니다.
Mar 07, 2024
Contents
🎥 시연 영상🗓️ 개발 기간 및 주차별 타임라인🧑🏻🔧 Service Architecture / Diagram📄 ERD(모델) 설계📲 페이지별 주요 기능📚 Tech Stacks📓 Library👨👨👦👦 Team POKI Member💥 Team CommunicationKingfisher 라이브러리 도입 배경문제점이미지 캐싱 적용 전/후 속도 비교Kingfisher 는 이미지를 어디에 캐싱할까?추가적인 옵션 제공메인페이지에서 셀의 아이템이 랜덤으로 배치되는 현상?1.1 브랜치 유형2.1 Commit Type ☑️ 코드 컨벤션PR Naming ✏️Screenshot 📸Work Description ✏️DescriptionTaskETC3.1 Git Flow내가 찍은 인생네컷 사진을 저장하고, 인생네컷 찍을때 포즈잡기 어려울때 도와주는 포토위키 포키!
소중한 사람과 찍은 네컷사진 추억을 한번에 저장해보세요.
🎥 시연 영상🗓️ 개발 기간 및 주차별 타임라인🧑🏻🔧 Service Architecture / Diagram📄 ERD(모델) 설계📲 페이지별 주요 기능메인 페이지추억 저장 / 추억 수정 페이지상세 페이지포즈 추천 페이지마이페이지 & 찜한 포즈 페이지📚 Tech Stacks📓 Library👨👨👦👦 Team POKI Member취업 준비, 어디서부터 시작해야 할지 모르겠다면?
🎥 시연 영상
🗓️ 개발 기간 및 주차별 타임라인
2023.10.10 ~ 2023.11.17
🧑🏻🔧 Service Architecture / Diagram
📄 ERD(모델) 설계
사진 모델
- Photo
- id
- 이미지
- memo
- date
- tag
유저 모델
- User
- nickname
- imageURL
- 이미지 데이터
- imageUrl
- category
- isSelected
태그 모델
- TagModel
- tagLabel
- tagImage
공지사항 모델
- NoticeList
- title
- date
- content
📲 페이지별 주요 기능
메인 페이지
- 피드형식으로 내가 추가한 인생네컷 사진 보여주기 →
CarouselFlowLayout
- 네비게이션 바 상단에 추가하기 버튼 →
UIMenu
- 갤러리에서 이미지 선택하여 추가하는 방식 →
PHPickerViewController
- 인생네컷 실물사진에 보이는 QR코드를 찍어서 URL에 있는 이미지를 받아와서 추가하기 페이지 이미지뷰에 띄우는 방식 →
AVFoundation / AVCaptureSession, AVCaptureDevice
- 추가한 이미지에서 가장 많이 차지하는 색상 비율을 뽑아내어 컬렉션 뷰 셀 하단에 그라데이션 으로 표현 →
CoreImage + CAGradientLayer
추억 저장 / 추억 수정 페이지
- 선택한 이미지에서 다른 이미지로 변경하고 싶을때, 이미지를 탭하면 이미지 다시 선택 가능.
- 날짜 선택 →
UIDatePicker
- 어느 인생네컷 플랫폼에서 찍었는지 태그 지정가능
- 간단한 메모 작성하기
상세 페이지
- 네비게이션 바에는 수정, 공유, 삭제 버튼 배치
- 다른 사람과의 인생네컷 공유 기능 →
UIActivityViewController
- 추가한 제목
- 생성 날짜
- 페이지 상단에 추가한 이미지에서 가장 많이 차지하는 색상 비율을 뽑아내어 컬렉션 뷰 셀 하단에 그라데이션 으로 표현 →
CoreImage + CAGradientLayer
포즈 추천 페이지
- 여러가지 포즈 추천 이미지를 보고 마음에 드는 이미지가 있다면 아래의 별모양으로 찜하기 가능.
- 다른 포즈를 보고싶다면 하단에 다른 포즈보기 버튼을 누르면 여러가지 이미지를 볼 수 있음.
마이페이지 & 찜한 포즈 페이지
- 메인페이지에도 있는 네컷 추가하는 로직 추가 → 추후 다른 컨텐츠로 변경 예정.
- 앱설정 → 앱설정 페이지로 이동
- 회원탈퇴 → 앱설정 페이지 내부에 회원탈퇴 를 탭하면 회원탈퇴 안내 페이지 이동
- 회원탈퇴 안내 페이지에서 탈퇴 여부 확인 체크하면 회원탈퇴 버튼 활성화
- 프로필 수정 가능 (프로필 사진 + 닉네임)
- 개인정보처리방침 → 노션 페이지에 내용 작성하여
SFSafariViewController
으로 구현
- 이용약관 → 노션 페이지에 내용 작성하여
SFSafariViewController
으로 구현
- 프로젝트 내 버전 가져오는것을 메서드로 만들어 프로젝트에서 버전을 바꾸면 마이페이지에도 자동으로 변경.
📚 Tech Stacks
Stacks
Swift
UIKit
Tools
Figma
Git
Collaboration
GitHub
Notion
Slack
Firebase
📓 Library
라이브러리 (Library) | 목적 (Purpose) | 버전 (Version) |
Then | 간결한 코드, 가독성 UP | 3.0.0 |
SnapKit | 오토레이아웃 | 5.6.0 |
KingFisher | 이미지 캐싱 | 7.9.1 |
Firebase | 서버 및 데이터 처리 | 10.16.0 |
👨👨👦👦 Team POKI Member
트러블슈팅
Tag에 이미지 캐싱 적용하기
TagVC 에서 Cell 의 이미지뷰에 이미지를 적용하는 현재 과정
cellForItemAt 메서드 내부를 아래와 같은 과정으로 변경했다.
- storageManager 의
downloadImage(urlString:completion:)
메서드를 호출해서 completion 클로저의 파라미터로 전달 받는 image(UIImage 객체)를cell.tagImageView.image
에 할당하고 있다.
let data = self.dataArray[indexPath.row] stoageManager.downloadImage(urlString: data.tagImage) { image in DispatchQueue.main.async { cell.tagImageView.image = image cell.tagLabel.text = data.tagText } } return cell
- Kingfisher 를 사용해서 이미지 캐싱과 다운로드 모두 적용해보기 위해 아래와 같이 코드를 수정했다.
let data = self.dataArray[indexPath.row] let url = URL(string: data.tagImage) cell.tagImageView.kf.setImage(with: url) cell.tagLabel.text = data.tagLabel return cell
위와 같이 코드를 작성해보니, url 상수가 nil 을 가지고 있어서
tagImageView
의 이미지 를 적용하지 못했다.tagImage 에 담긴 데이터는 이미지 URL이 아닌 저장소의 위치 URL이 담겨있다.
위 사진의 맨 마지막에 저장소 위치 주소가 있는데, 해당 URL 값을 가지고 있어서 이미지를 불러오지 못하는 것이다.
그래서
cell.tagImage
에 이미지 URL을 가질 수 있도록 수정이 필요하다. 먼저, Tag 의 데이터를 아래와 같이 TagData
의 타입 프로퍼티인 data
를 가져와서 사용하고 있다.struct TagData { static let data: [TagModel] = [ TagModel(tagLabel: "인생네컷", tagImage: "gs://poki-ios-87d7e.appspot.com/Frame 61138.png"), TagModel(tagLabel: "하루필름", tagImage: "gs://poki-ios-87d7e.appspot.com/Frame 61147.png"), TagModel(tagLabel: "셀픽스", tagImage: "gs://poki-ios-87d7e.appspot.com/Frame 61137.png"), TagModel(tagLabel: "포토아이브", tagImage: "gs://poki-ios-87d7e.appspot.com/Frame 61144.png"), TagModel(tagLabel: "포토그레이", tagImage: "gs://poki-ios-87d7e.appspot.com/Frame 61145.png"), TagModel(tagLabel: "포토 시그니쳐", tagImage: "gs://poki-ios-87d7e.appspot.com/Frame 61143.png"), TagModel(tagLabel: "포토드링크", tagImage: "gs://poki-ios-87d7e.appspot.com/Frame 61146.png"), TagModel(tagLabel: "포토이즘", tagImage: "gs://poki-ios-87d7e.appspot.com/Frame 61139.png"), TagModel(tagLabel: "모노맨션", tagImage: "gs://poki-ios-87d7e.appspot.com/Frame 61142.png"), TagModel(tagLabel: "플레이인더박스", tagImage: "gs://poki-ios-87d7e.appspot.com/Frame 61148.png"), TagModel(tagLabel: "비분류 브랜드", tagImage: "gs://poki-ios-87d7e.appspot.com/Group 61116.png") ] }
TagModel을 생성할 때, tagImage를 이미지 URL 값으로 수정 후 다시 확인해보니 원하는 결과를 얻을 수 있었다.
그리고 이미지 캐싱을 적용하기 전과 후를 비교할 수 있었다. 왼쪽이 이미지 캐싱을 적용하기 전이고 오른쪽이 이미지 캐싱을 적용한 모습이다.
태그의 사진들은 동일한 사진들을 계속 사용하게 된다. 이 때, 매번 이미지를 다운받아서 사용하는 것은 비효율적이기 때문에 이미지 캐싱을 통해 임시로 저장한 이미지를 불러올 수 있기 때문에 이런 문제점을 해결할 수 있다.
이미지 불러 오는 모든 곳에 Kingfisher 적용하기
Kingfisher 라이브러리 도입 배경
우리가 개발하는 POKI 앱은 사용자의 사진을 저장하고 불러오는 과정에서 많은 이미지를 사용하게 된다. 이런 과정에서 발생하는 문제점은 뭐가 있었을까?
문제점
- 이미지를 불러오는 과정이 1초 정도 걸린다.
- 초기에는 인디케이터를 사용해서 사용자에게 앱이 멈추지 않은 것을 알려줬다.
- 이미지를 사용할 때 마다, 이미지 URL을 통해 이미지를 반복해서 불러오는 것은 비효율적이다.
위와 같은 문제점을 해결하기 위해 이미지 다운로드 기능과 이미지 캐싱 기능을 제공하는 Kingfisher 라이브러리를 사용하게 되었다. 추가적으로 이미지 처리 과정에서 발생하는 문제를 해결하기 위한 부가적인 옵션도 제공한다.
이미지 캐싱 적용 전/후 속도 비교
사진 상세보기 페이지
사진 상세보기 페이지(PhotoDetailVC)에서 이미지를 불러오는 속도를 비교해보자.
[ 적용 전 ]
[ 적용 후 ]
- Kingfisher 라이브러리 적용 전에 이미지를 불러오는 속도는 아래의 코드를 사용해서 확인했다.
- 기존
storageManager
의downloadImage(urlString:)
메서드를 호출해서 이미지를 불러오는 코드 - Kingfisher 라이브러리의
setImage(with:)
메서드를 호출해서 이미지를 불러오는 코드
이미지 불러오는 속도 확인 코드
var photoData: Photo? { didSet { setupPhotoData { start in print("걸린 시간은 \(Date().timeIntervalSince(start))초 입니다.") } } }
private func setupPhotoData(completion: @escaping (Date) -> Void) { let start = Date() guard let photoData = photoData else { return } storageManager.downloadImage(urlString: photoData.image) { image in self.mainImageView.image = image self.backgroundImageView.image = image let dominantColor = image?.dominantColor() self.setGradientBackground(dominantColor: dominantColor!) self.titleLabel.text = photoData.memo self.dateLabel.text = photoData.date completion(start) } }
private func setupPhotoData(completion: @escaping (Date) -> Void) { let start = Date() guard let photoData = photoData else { return } guard let photoURL = URL(string: photoData.image) else { return } self.mainImageView.kf.setImage(with: photoURL) self.backgroundImageView.kf.setImage(with: photoURL) { [weak self] result in switch result { case .success(let value): if let dominantColor = value.image.dominantColor() { self?.setGradientBackground(dominantColor: dominantColor) } self?.titleLabel.text = photoData.memo self?.dateLabel.text = photoData.date completion(start) case .failure(let error): print("Error: \(error)") } } }
이미지를 불러오는 과정과 상세 페이지의 UI를 그리는 속도를 확인했을 때, 아래 표와 같이 차이를 확인할 수 있다.
시도 | 적용 전 | 적용 후 |
1 | 약 0.5 초 | 0.05 초 |
2 | 약 0.5 초 | 0.05 초 |
3 | 약 0.5 초 | 0.05 초 |
사진 추가 페이지에서 태그 이미지
사진을 추가하는 페이지(AddPhotoVC)에서 태그를 선택할 때, 태그 이미지를 불러오는 속도를 비교해보자.
[ 적용 전 ]
[ 적용 후 ]
- Kingfisher 라이브러리 적용 전에 이미지를 불러오는 속도는 아래의 코드를 사용해서 확인했다.
- 기존
storageManager
의downloadImage(urlString:)
메서드를 호출해서 이미지를 불러오는 코드 - Kingfisher 라이브러리의
setImage(with:)
메서드를 호출해서 이미지를 불러오는 코드
이미지 불러오는 속도 확인 코드
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "TagCollectionViewCell", for: indexPath) as? TagCell else { return UICollectionViewCell() } let start = Date() let data = self.dataArray[indexPath.row] StorageManager.shared.downloadImage(urlString: data.tagImage) { image in cell.tagImageView.image = image cell.tagLabel.text = data.tagLabel print("걸린 시간 \(Date().timeIntervalSince(start))초 입니다.") } return cell }
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "TagCollectionViewCell", for: indexPath) as? TagCell else { return UICollectionViewCell() } let start = Date() let data = self.dataArray[indexPath.row] let url = URL(string: data.tagImage) cell.tagImageView.kf.indicatorType = .activity cell.tagImageView.kf.setImage(with: url) { result in cell.tagLabel.text = data.tagLabel print("걸린 시간 \(Date().timeIntervalSince(start))초 입니다.") } return cell }
아래 표를 참고해서 적용 전/후 사진 추가하기 페이지의 태그를 눌렀을 때, UI 그리는 속도를 비교할 수 있다. 해당 시간은 11개의 셀이 그려진 평균 시간이다.
시도 | 적용 전 | 적용 후 |
1 | 약 1.1 초 | 약 4 ms |
2 | 약 0.5 초 | 약 0.2 ms |
3 | 약 0.5 초 | 약 0.2 ms |
찜한 포즈 페이지
찜한 포즈(LikedPoseVC)를 눌렀을 때, 찜한 포즈를 얼마나 빨리 보여주는지 비교해보자.
[ 적용 전 ]
[ 적용 후 ]
- 사진 추가하기 페이지에서 태그 이미지를 불러오는 비교 코드와 동일한 방식으로 테스트했다.
아래 표를 참고해서 적용 전/후 찜한 포즈 UI 그리는 속도를 비교할 수 있다.
시도 | 적용 전 | 적용 후 |
1 | 약 0.7 초 | 약 0.4 초 |
2 | 약 0.7 초 | 약 0.3 ms |
3 | 약 0.7 초 | 약 0.3 ms |
Kingfisher 는 이미지를 어디에 캐싱할까?
Kingfisher는 메모리 캐시와 디스크 캐시를 사용할 수 있다.
setImage(with:)
메서드를 호출할 때, 기본 설정으로는 NSCache
를 사용해서 UIImage
를 메모리에 캐싱하게 된다. 이 때, with:
파라미터로 전달 받은 URL의 absoluteString
값을 키 값으로 사용한다.추가적인 옵션 제공
Kingfisher는
UIImageView
의 Image
가 설정되기 전까지 UIImageView
의 가운데에 인디케이터를 띄우는 기능을 제공한다.아래의 메인 페이지와 찜한 포즈 페이지에서 Kingfisher의 인디케이터 기능을 적용했다. 이미지를 다운로드하는 과정이 조금 느릴 때, 인디케이터를 보여줘서 앱이 멈추지 않은 것을 사용자에게 알려줄 수 있다.
Kingfisher 인디케이터 사용 방법
Firestore 문서 ID를 인덱스로 부여하기
메인페이지에서 셀의 아이템이 랜덤으로 배치되는 현상?
사진을 추가하고, 메인 페이지로 이동했을 때는 마지막으로 추가한 사진이 맨 앞에 있다. 하지만 다른 페이지로 전환했다가 다시 돌아왔을 때는 해당 사진이 다른 위치에 있는 경우가 간혹 발생했다.
아래 스크린샷을 보면 우리가 사진의 이미지를 추가할 때 문서의 ID를 랜덤으로 부여하고 생성한다. 이 때, 어떤 문제가 발생할까?
문서는 숫자, 영대문자, 영소문자 오름차순으로 생성된다. 만약 마지막으로 생성한 문서의 ID가 숫자
1
로 시작한다면, 문서의 맨 앞에 생성될 것이고 문서의 ID가 소문자 a
로 시작한다면 문서의 맨 마지막에 생성된다.그래서 문서의 ID를 순차적으로 만들 수 있도록 인덱스를 부여해봤다. 문서가 없는 경우 ID는 1000부터 시작하고, 그 다음 생성되는 문서는 +1 씩 증가하는 인덱스를 갖게된다.
아래와 같이 코드를 작성했고, 문서를 생성하는 곳(
AddPhotoVC
)에서 newPhotoDocumentID
값을 사용해서 인덱스 번호를 부여한다.// FirestoreManager.swift var newPhotoDocumentID: String? { guard let id = self.photoList.first?.id else { return String(1000) } guard let index = Int(id) else { return String(1000) } return String(index + 1) }
인덱스가 1000부터 시작하는 이유
인덱스 1 ~ 9를 갖는 문서를 생성하고, 10을 생성할 때 맨 위에 문서를 생성한다. 문서 ID를 숫자로 인식하는게 아니라 문자열로 인식하기 때문에 그런 것 같다. 좀 더 알아봐야할 것 같다.
그리고
Photo
문서를 업데이트할 때, 식별할 수 있도록 문서ID와 동일한 값을 입력받는 id
필드를 생성했다.기존에는
documentRefernce
를 저장하는 필드가 있었는데, 해당 필드를 id
로 변경했다. 변경한 이유
- 문서 ID와 동일한 값을 갖는
id
필드를 사용함으로써 업데이트 또는 삭제 시 해당 문서를 찾는 코드가 간단해진다.
- 파이어 스토어에서 문서를 검증할 때, UUID 형태보다는 가독성이 높아진다. (이건 문서 ID를 인덱스 형태로 변환한 이유다.)
추가적으로, 배열의
reverse()
메서드를 사용해서 마지막 인덱스부터 내림 차순으로 컬렉션 뷰의 셀 아이템을 배치하도록 변경했다.Firebase에서 커스텀 타입 사용하기
기존에는 Firestore 에서 데이터를
[String: Any]
타입으로 전달 받고, Photo
타입으로 변환 후 사용했다.private func createPhotoFromData(_ data: [String: Any]) -> Photo? { guard let documentReference = data["documentReference"] as? String, let image = data["image"] as? String, let memo = data["memo"] as? String, let date = data["date"] as? String, let tagData = data["tag"] as? [String: Any], let tagLabel = tagData["tagLabel"] as? String, let tagImage = tagData["tagImage"] as? String else { // 필수 필드가 누락되었거나 형식이 맞지 않는 경우 nil 반환 return nil } let tag = TagModel(tagLabel: tagLabel, tagImage: tagImage) return Photo(documentReference: documentReference, image: image, memo: memo, date: date, tag: tag) }
- Firebase 에서는 swift 에서 정의한 커스텀 타입을 사용할 수 있도록 지원한다.
아래와 같이 기존에는 문서를 불러오는 과정에서
data
상수를 위에서 정의한 createPhotoFromData()
메서드를 호출해서 Photo 타입으로 변환하는 과정이 필요했다.self.photoList = documents.compactMap { doc -> Photo? in // Firestore 스냅샷에서 필요한 데이터를 가져와 Photo 모델에 직접 할당 let data = doc.data() if let photo = self.createPhotoFromData(data) { return photo } return nil }
그리고 아래와 같이 swift 에서 정의한 커스텀 타입을 사용해서 코드를 간략하게 변경할 수 있다.
self.photoList = documents.compactMap { document -> Photo? in let photoData = try? document.data(as: Photo.self) return photoData }
Firestore 의 문서를 만들 때도 커스텀 타입을 사용할 수 있다.
func createPhotoDocument(photo: Photo, completion: @escaping (Error?) -> Void) { do { guard let userUID = authManager.currentUserUID else { return } let docRef = db.collection("users/\(userUID)/Photo").document(photo.id) // 문서를 만들 때, Photo 객체를 전달할 수 있다. try docRef.setData(from: photo) print("Document added successfully.") completion(nil) } catch let error { print("Error adding document: \(error)") completion(error) } }
Firestore 및 Storage 관련 Method 변경 후 테스트
순서 | 항목 | 홍식 확인 | 광조 확인 |
ㅤ | 회원 가입 및 사진 관련 항목 | ㅤ | O |
1 | 회원 가입 시 Firestore 에서 Users 컬렉션의 문서를 회원가입 시 입력한 이메일로 생성합니다. | O | O |
2 | 처음 사진 추가 시 Firestore 에서 Users/(로그인한 이메일)/Photo 컬렉션의 문서ID가 “1000” 으로 생성됩니다. | O | O |
3 | 사진 추가 시 Storage 에서 자신의 이메일로 생성된 폴더 하위에 랜덤 UUID를 갖는 폴더를 생성하고, 포토 이미지와 태그 이미지를 저장합니다. | O | O |
4 | 이후 사진 추가 시 1000 에서 +1 씩 증가된 ID를 갖는 문서를 생성합니다. | O | O |
5 | 사진 수정 시 해당 문서의 필드 데이터가 수정됩니다. | O | O |
6 | 사진 삭제 시 해당 문서가 삭제됩니다. | O | O |
ㅤ | ㅤ | ㅤ | ㅤ |
ㅤ | 포즈 추천 관련 항목 | ㅤ | ㅤ |
1 | 포즈 추천 페이지 전환 시 Firestore의 /Users/(로그인한 이메일)/Image 컬렉션에 랜덤한 ID를 갖는 문서들이 생성됩니다. | O | O |
2 | 랜덤 포즈 페이지에서 북마크 버튼을 눌렀을 때, Image 컬렉션의 해당 문서 isSelected 필드의 값이 true 로 변경됩니다. | O | O |
3 | 랜덤 포즈 페이지에서 북마크 버튼을 취소할 때, Image 컬렉션의 해당 문서 isSelected 필드의 값이 false 로 변경됩니다. | O | O |
4 | 찜한 포즈 페이지에서 포즈 상세보기 시 우측 상단의 별 버튼을 눌렀을 때, Image 컬렉션의 해당 문서 isSelected 필드의 값이 false 로 변경됩니다. | O | O |
5 | 찜한 포즈 페이지에서 포즈 상세보기 시 우측 상단의 별 버튼이 비활성화일 때, Image 컬렉션의 해당 문서 isSelected 필드의 값이 true 로 변경됩니다. | O | O |
ㅤ | ㅤ | ㅤ | ㅤ |
ㅤ | 유저 데이터 관련 항목 | ㅤ | ㅤ |
1 | 프로필 수정 시 Users 컬렉션의 로그인한 이메일 ID를 갖는 문서의 nickname, imageURL 필드가 변경됩니다. | O | O |
2 | 프로필 이미지 저장 시 Storage 에서 로그인한 이메일로 생성된 폴더 하위에 profile 이라는 폴더를 생성하고 해당 폴더에 이미지를 저장합니다. | O | O |
3 | 회원 탈퇴 시 Users 컬렉션의 로그인한 이메일 ID 문서 및 하위 문서(Photo, Image)들이 모두 삭제됩니다. | O | O |
4 | 회원 탈퇴 시 Storage 에서 로그인한 이메일로 생성된 폴더 및 하위 폴더의 포토 이미지, 태그 이미지가 모두 삭제됩니다. | O | O |
특이 사항
- 계정삭제 실패 에러 반복(실기기에서 테스트 필요,) → 계정삭제 오류로 인해 storage 사진 삭제 X (deleteReason에는 정상적으로 처리)
- 계정 삭제 실패 This operation is sensitive and requires recent authentication. Log in again before retrying this request.
https://firebase.google.com/docs/auth/ios/manage-users?hl=ko#re-authenticate_a_user
- nil 일 때, UIImage()를 만들어서 넣어주는데, 해당 값을 스토리지에 업로드하고 업로드한 URL을 통해 String으로 변환하는 과정에서 오류가 발생했다.
참고 사항
- FirestoreManager.swift
- Users 컬렉션의 문서 관리
- Firestore 에서 Users 컬렉션의 문서를 가져오는 것은
fetchUserDocumentFromFirestore
메서드를 호출해서 처리합니다. - Firestore 에서 Users 컬렉션의 문서(사용자 정보) 생성은
createUserDocument(email:user:)
메서드를 호출해서 처리합니다. - Firestore 에서 Users 컬렉션의 문서 수정은
updateUserDocument(user:)
메서드를 호출해서 처리합니다. - Firestore 에서 Users 컬렉션의 문서 삭제는
deleteUserDocument
메서드를 호출해서 처리합니다. - Photo 문서 관리
- Firestore 에서 Photo 문서를 가져오는 것은
fetchPhotoFromFirestore(completion:)
메서드를 호출해서 처리합니다. - Firestore 에서 Photo 문서의 생성과 수정은
createPhotoDocument(photo:completion:)
메서드 하나를 호출해서 처리합니다. - Firestore 에서 Photo 문서의 삭제는
deletePhotoDocument(id:)
메서드를 호출해서 처리합니다. - Image 문서 관리(포즈 추천 이미지)
- Firestore 에서 Image 문서 생성은
createRecommendPoseDocument(imageData:)
메서드를 호출해서 처리합니다. - Image 문서를
makePoseData
메서드를 호출해서 실제 문서 데이터를 생성합니다. (makePoseData
메서드는 포즈 추천 페이지의viewDidLoad
에서 호출합니다.)
- StorageManager.swift
- 공통
- Storage 에서 이미지 삭제는
deleteImage(imageURL:completion:)
메서드를 호출해서 처리합니다. - Storage 에서 이미지 다운은
downloadImage(urlString:completion:)
메서드를 호출해서 처리합니다. - 이미지 다운로드 과정은 Kingfisher 라이브러리 적용으로 인해 사용하지 않을 수도 있습니다!
- Photo 및 Tag
- Storage 에서 Photo, Tag 이미지 업로드는
uploadPhotoImage(image:completion)
메서드를 호출해서 처리합니다. - Storage 에서 Photo, Tag 전체 이미지 삭제는
deleteAllImagesFromStorage(photo:completion:)
메서드를 호출해서 처리합니다. - User
- Storage 에서 User 이미지 업로드는
uploadUserImage(image:completion)
메서드를 호출해서 처리합니다 - 날짜순, 등록 순서를 로그인 후 선택할 수 있도록 제공하고, 원하는 필터로 메인 페이지에서 사진을 제공한다.
- 필터에 지금은 날짜 순만 있지만 , 등록순서도 추가
- 토스트 뷰 시간 조정!
아이디어
Firestore 데이터 연결 문제
- 문제 : 사용하려는 모델과 FireStore의 컬렉션과의 연결문제
- 하나의 프로퍼티가 구조체 타입을 가질 때 파이어스토어에서는 어떤 타입의 유형으로 설정해야 하는지 모르는 문제.
- 나의 코드
struct Photo: Codable { var documentReference: String var image: String var memo: String var date: String var tag: TagModel enum CodingKeys: String, CodingKey { case documentReference case image case memo case date case tag } }
—파이어스토어 컬렉션 필드
- 해결방법 : 컬렉션 안에 컬렉션을 만들고 그 컬렉션을 참조로 해서 설정.
- 기존에 에러 메세지인 데이터 연결 에러도 삭제 완료
기존 이미지 로드 → Kingfisher 로 수정
func collectionViewDataBinding(collectionView: UICollectionView, indexPath: IndexPath, category: PoseCategory) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) let imageView = UIImageView() cell.contentView.addSubview(imageView) let imageData = imageDatas[indexPath.row] let urlData = imageData.imageUrl storageManager.downloadImage(urlString: urlData) { [weak self] image in guard self != nil else { return } imageView.image = image } imageView.snp.makeConstraints { $0.edges.equalToSuperview() } return cell }
이 메서드 내부를 킹피셔를 사용해서 수정했을때 문제점
이미지 데이터를 가지고 있어도 초기화면에 emptyPage가 화면에 나오게 되는 문제가 있었음.
수정 코드
func collectionViewDataBinding(collectionView: UICollectionView, indexPath: IndexPath, category: PoseCategory) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) let imageView = UIImageView() cell.contentView.addSubview(imageView) let imageData = imageDatas[indexPath.row] let urlData = URL(string: imageData.imageUrl) imageView.kf.indicatorType = .activity imageView.kf.setImage(with: urlData) imageView.snp.makeConstraints { $0.edges.equalToSuperview() } return cell }
수정한 코드가 문제가 있다고 생각 했었지만
firestoreManager.fetchRecommendPoseDocumentFromFirestore { _ in self.updatePoseCategory(self.poseCategory) }
위 코드처럼 image를 업데이트하는 메서드를 클로저 안에 넣어줘야하는데 밖에 입력해서 나오는 현상이었다.
결국 데이터를 로드하는 시점이 언제 인가에 대한 문제였다.
코드를 꼼꼼히 봐야될것 같다. 계속 허무한 이유로 에러가 발생하는것 같다.
Git Convention
1.1 브랜치 유형
main
: 완성된 버전의 코드를 저장하는 브랜치
develop
: 개발이 진행되는 동안 완성된 코드를 저장하는 브랜치
-
#13
2.1 Commit Type
- [커밋 카테고리] : 커밋 내용 → 일반
- [커밋 카테고리] : #이슈번호 커밋 내용 → 이슈에 추가된 내용 작성할때
- [Feat] : #100 홈 뷰 구현
- [Del] : MainVC 필요없는 코드 삭제
- [Refactor] : MainVC2 SnapKit 라이브러리 적용
- [Chore] : 변수명 수정
- [Fix] : 회원가입 버튼 활성화 로직 버그 수정
2.2 Commit Category
[Feat]
: 새로운 기능 구현
[Docs]
: 문서 수정 (README나 WIKI 등의 문서 개정)
[Add]
: 새로운 파일/라이브러리 추가 or Feat 이외의 부수적인 코드 추가.
[Fix]
: 버그, 오류 수정
[Style]
: 코드 포맷팅, 코드 변경이 없는 경우
[Del]
: 쓸모없는 라인/코드 삭제
[Move]
: 프로젝트 내 파일이나 코드의 이동
[Remove]
: 파일을 삭제하는 작업만 수행한 경우
[Refactor]
: 코드 리펙토링
[Rename]
: 파일 이름 변경
[Design]
: UI/Storyboard 수정한 경우
[Chore]
: 변수명 및 함수명 수정과 같은 사소한 수정
[Etc]
: 그 외 수정
Code Convention
- 기본 명명규칙은 API Design Guidelines , Swift Style Guide를 참고한다.
☑️ 코드 컨벤션
변수명, 상수명, 함수명은 lowerCamelCase로 작성합니다.
가독성을 위해 한 줄에 하나의 문장만 작성합니다.
축약형을 사용하지 않습니다.
좋은 예
class LoginViewController{} let loginButton = UIButton()
나쁜 예
class LoginVC{} let loginBtn = UIButton()
모듈 임포트는 알파벳 순으로 정렬합니다. 내장 프레임워크를 먼저 임포트하고, 이후에 서드파티 프레임워크를 임포트합니다.
import UIKit import Kingfisher import SnapKit import Then
Delegate 메서드는 프로토콜명으로 네임스페이스를 구분합니다.
좋은 예
protocol UserCellDelegate { func userCellDidSetProfileImage(_ cell: UserCell) func userCell(_ cell: UserCell, didTapFollowButtonWith user: User) }
나쁜 예
protocol UserCellDelegate { func didSetProfileImage() func followPressed(user: User) // `UserCell`이라는 클래스가 존재할 경우 컴파일 에러 발생 func UserCell(_ cell: UserCell, didTapFollowButtonWith user: User) }
주석
// MARK:
를 사용해서 연관된 코드를 구분짓습니다.
// MARK: Init override init(frame: CGRect) { // doSomething() } deinit { // doSomething() } // MARK: Layout override func layoutSubviews() { // doSomething() } // MARK: Actions override func menuButtonDidTap() { // doSomething() }
띄어쓰기
- 콜론(
:
)을 사용할때는 콜론의 오른쪽에만 공백을 둡니다.
[상수] - 좋은 예 let names: [String: String]? - 나쁜 예 let names: [String:String]? let names: [String : String]?
[클래스] - 좋은 예 class MyClass: SuperClass { // ... } - 나쁜 예 class MyClass : SuperClass { // ... }
- 삼항연산자의 경우 콜론 앞뒤로 띄웁니다.
- 좋은 예 func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { return shouldRotate ? .allButUpsideDown : .portrait } - 나쁜 예 func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { return shouldRotate ? .allButUpsideDown: .portrait }
- 기타
// specifying type let pirateViewController: PirateViewController // dictionary syntax (note that we left-align as opposed to aligning colons) let ninjaDictionary: [String: AnyObject] = [ "fightLikeDairyFarmer": false, "disgusting": true ] // declaring a function func myFunction<T, U: SomeProtocol>(firstArgument: U, secondArgument: T) where T.RelatedType == U { /* ... */ } // calling a function someFunction(someArgument: "Kitten") // superclasses class PirateViewController: UIViewController { /* ... */ } // protocols extension PirateViewController: UITableViewDataSource { /* ... */ }
-
if let
,guard let
구문이 긴 경우에는 줄바꿈하고 한 칸 들여씁니다.
if let user = self.veryLongFunctionNameWhichReturnOptionalUser(), let name = user.veryLongFunctionNameWhichReturnsOptionlName(), user.gender == .female { // ... } guard let user = self.veryLongFunctionNameWhichReturnsOptionalUser(), let name = user.veryLongFunctionNameWhichReturnsOptionalName(), user.gender == .female else { return }
열거형의 케이스는 소문자로 시작합니다.
enum Result { case success case failure }
클래스
- 함수 이름에는 되도록
get
을 붙이지 않습니다.❗️
func name(for user: User) -> String?
- 더 이상 상속이 발생하지 않는 클래스는 항상 final 키워드로 선언
프로토콜
- • 프로토콜을 적용할 때는 extension을 만들어서 관련된 메서드를 모아둡니다.
- 좋은 예 final class MyViewController: UIViewController { // ... } // MARK: - UITableViewDataSource, UITableViewDelegate extension MyViewController: UITableViewDataSource, UITableViewDelegate { // ... } // MARK: - UICollectionDataSource, UICollectionDelegate extension MyViewController: UICollectionDataSource, UICollectionDelegate { // ... } - 나쁜 예 final class MyViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { // ... }
Issue, PR Convention
PR Convention
ISSUE Convention
3.1 Git Flow
Git Flow
- 본인이 작업해야할 내용을 깃허브 레포지토리 이슈 템플릿을 선택하고 추가 하여 내부 템플릿에 맞게 작성
- 본인이 깃허브에 작성한 이슈 내용대로 이슈 번호로 브랜치를 새로 만들고 새로 만든 브랜치로 이동한 다음에 개발 및 작업 진행 ex )
#13
- 개발 및 작업진행이 완료 되었다면, Add - Commit - Push - Pull Request 의 과정을 거친다. (commit은 최대한 자세하게!)
- 깃허브에서 Pull Request 올릴때 본인이 작업한 이슈 번호 브랜치에서 → develop으로 보낸다는 설정 하고 Pull Request 생성
- Pull Request가 생성되면 작성자 이외의 다른 팀원이 Code Review를 한다. (스크럼 때)
- Code Review가 완료되면 Pull Request Merge 담당자가 develop Branch로 merge 한다.
→ merge 후 슬랙에 무조건 말하기 !!!!!!!!!!!
- merge된 작업이 있을 경우, 즉시 바로 ‼️다른 브랜치에서 작업을 진행 중이던 개발자는 본인의 브랜치로 merge된 작업을 Pull 받아온다.‼️
3.2 ETC
협업 시 준수해야 할 규칙은 다음과 같다.
1. develop 에서의 작업은 원칙적으로 금지. 단, 초기 세팅 및 README 작성은 develop Branch에서 수행한다.
2. 자신이 담당한 부분 이외에 다른 팀원이 담당한 부분을 수정할 때에는 게더, Slack을 통해 변경 사항을 전달한다.
3. 본인이 작업한 내용은 본인이 Pull Request 올리고 PR 올렸다고 슬랙 DM으로 공유
→ Merge는 리더 & 부리더 에게 요청
4. 작업 완료 후 Commit, Push, Merge, Pull Request 등 모든 작업은 앱이 정상적으로 실행되는 지 확인 후 수행한다. (빌드 해본 후에!)
취업 준비, 어디서부터 시작해야 할지 모르겠다면?
🧐비전공자인데 IT 업계 취업할 수 있을까?
😟프로젝트 경험이 부족한데, 어떻게 준비해야 할까?
🥺IT 기업으로 이직하고 싶은데 뭐부터 시작해야 할까?
이런 고민을 하고 있다면, 내일배움캠프의 IT 취업 컨설팅을 받아보세요.
취업 코칭 전문가들이 여러분의 고민을 해결해 드립니다.
다음 링크에 이메일을 입력하시면 메일로 1:1 커리어 상담권과 취준 자료집을 보내드릴게요.
Share article
Subscribe to our newsletter