- 서비스 소개: 자신만의 사진 스팟 공유 앱
- 개발 인원: 1인
- 개발 기간: 24.04.12 ~ 24.05.05(총 24일)
- 개발 환경
- 최소버전: iOS 16
- Portrait Orientation 지원
- 라이트 모드 지원
- 회원 인증: 회원 가입 / 로그인 / 회원 탈퇴 / 로그아웃
- 프로필 조회 / 수정
- Direct Message 기능
- 게시글
- 게시글 작성 / 삭제
- 게시글 조회
- 게시글 후원(결제 기능)
- 게시글 댓글 작성 / 삭제
- 연관 게시글 추천 기능
- 게시글 좋아요 / 좋아요 취소
- 후원 내역 조회
- 유저 팔로우 / 언팔로우
- 해시태그
회원가입 | 로그인 | 로그아웃 | 회원탈퇴 |
---|---|---|---|
![]() ![]() |
![]() ![]() |
![]() ![]() |
![]() ![]() |
게시글 작성 | 게시글 조회 | 게시글 상세정보 조회 | 연관 게시글 추천 | 해시태크 게시글 검색 |
---|---|---|---|---|
![]() ![]() |
![]() ![]() |
![]() ![]() |
![]() ![]() |
![]() ![]() |
프로필 조회 | 프로필 수정 | 프로필 조회(지도) | 유저 팔로우 | 유저 언팔로우 |
---|---|---|---|---|
![]() ![]() |
![]() ![]() |
![]() ![]() |
![]() ![]() |
![]() ![]() |
DM 생성 | 채팅방 목록 조회 | 채팅 | 후원내역 조회 |
---|---|---|---|
![]() ![]() |
![]() ![]() |
![]() ![]() |
![]() ![]() |
- UIKit, RxSwift, SnapKit
- MVVM, Input/Output, Router, Repository Pattern
- Alamofire, Kingfisher, LocalizedError
- NWPathMonitor, IAMPort, SocketIO, Realm
- RxDatasoure, Modern CollectionView
- MapKit, PHAsset, Custom Property Wrapper
- MVVM Input/Ouput 패턴을 통한 View와 ViewModel간 책임 분리
- URLRequestConvertible를 준수하는 Custom TargetType을 이용한 네트워크 요청 로직 Router 패턴 구성
- Multipart Form Data 전송 방식을 통한 이미지 파일 업로드 구현
- 홈 화면에서 효율적인 대량 게시글 데이터 로드를 위한 Cursor-Based Pagination 구현
- EventMonitor 프로토콜 준수하는 이벤트 모니터 구성으로 네트워크 요청 및 응답을 위한 로깅 구현
- Interceptor를 활용한 토큰 갱신 로직 구성
- NWPathMonitor를 활용한 실시간 네트워크 연결 상태 모니터링 구성
- Custom UICollectionViewLayout 구성으로 핀터레스트 UI 구성
- Resizable Image를 활용한 말풍선 UI 구현
- Type Erasure Wrapper를 활용한 멀티 섹션 타입의 CollectionView 구성(RxDatasource)
- Compositional Layout을 활용한 Dynamic Island로 인한 기기별 Layout 차이 대응
업로드할 사진 | 위치선택 | 추천 방문 시간대 |
방문일 | 제목, 본문 |
-
각 이벤트를 Input으로 전달
-
transform 메서드 내부에서 유효성 검사 후 Output 결과 전달
Input/Output pattern
final class AddPostViewModel: ViewModelType { var disposeBag = DisposeBag() struct Input { let titleText: ControlEvent<String> // ... } struct Output { let registerButtonValid: Driver<Bool> // ... } func transfor(input: Input) -> Output { return Output( registerButtonValid: registerButtonValid.asDriver(onErrorJustReturn: false) // ... ) } }
-
게시글 등록 시 과도한 네트워크 호출 방지를 위해 .debounce(.seconds(1), scheduler: MainScheduler.instance) 사용
.debounce(.seconds(1), scheduler: MainScheduler.instance)
input.rightBarButtonItemTap .debounce(.seconds(1), scheduler: MainScheduler.instance) .map { //... } .subscribe(with: self) { // ... } .disposed(by: disposeBag)
-
RequestInterceptor를 상속받아 UserDefaults에 저장된 Access Token의 유효성 검증 후, 필요 시 토큰 갱신
RequestInterceptor 구현
final class TokenRefresher: RequestInterceptor { func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) { // 네트워크 요청전 Header에 Access 토큰 추가 var urlRequest = urlRequest let token = UserDefaults.standard.accessToken urlRequest.setValue(token, forHTTPHeaderField: HTTPHeader.authorization.rawValue) completion(.success(urlRequest)) } func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) { // Access 토큰인 만료되지 않은 경우 guard let response = request.task?.response as? HTTPURLResponse, response.statusCode == 419 else { completion(.doNotRetryWithError(error)) return } // Access 토큰이 만료된 경우 let refreshToken = UserDefaults.standard.refreshToken if !refreshToken.isEmpty { UserManager.refreshToken { [weak self] response in guard let self else { return } switch response { case .success(let refreshedAccessToken): UserDefaults.standard.accessToken = refreshedAccessToken completion(.retry) case .failure(let error): print("failure") print(error.errorCode, error.errorDesc) if error.errorCode == 418 || error.errorCode == 401 { NotificationCenter.default.post(name: .refreshTokenExpired, object: nil, userInfo: ["showReloginAlert": true]) } completion(.doNotRetryWithError(error)) } } } } }
-
각 사진의 가로/세로 비율에 따른 다이나믹한 셀의 크기를 가지고 있는 핀터레스트 UI 구현을 위해 커스컴 UICollectionViewLayout 구성
커스텀 UICollectionViewLayout 코드
protocol PinterestLayoutDelegate: AnyObject { func collectionView( _ collectionView: UICollectionView, heightForPhotoAtIndexPath indexPath: IndexPath) -> CGFloat } final class PinterestLayout: UICollectionViewLayout { weak var delegate: PinterestLayoutDelegate? private let numberOfColumns = 2 private let cellPadding: CGFloat = 6 private var cache: [UICollectionViewLayoutAttributes] = [] private var contentHeight: CGFloat = 0 private var contentWidth: CGFloat { guard let collectionView = collectionView else { return 0 } let insets = collectionView.contentInset return collectionView.bounds.width - (insets.left + insets.right) } override var collectionViewContentSize: CGSize { return CGSize(width: contentWidth, height: contentHeight) } } extension PinterestLayout { override func prepare() { cache.removeAll() contentHeight = 0 guard cache.isEmpty, let collectionView = collectionView else { return } let columnWidth = contentWidth / CGFloat(numberOfColumns) var xOffset: [CGFloat] = [] for column in 0..<numberOfColumns { xOffset.append(CGFloat(column) * columnWidth) } var column = 0 var yOffset: [CGFloat] = .init(repeating: 0, count: numberOfColumns) for item in 0..<collectionView.numberOfItems(inSection: 0) { let indexPath = IndexPath(item: item, section: 0) let photoHeight = delegate?.collectionView( collectionView, heightForPhotoAtIndexPath: indexPath) ?? 180 let height = cellPadding * 2 + photoHeight let frame = CGRect(x: xOffset[column], y: yOffset[column], width: columnWidth, height: height) let insetFrame = frame.insetBy(dx: cellPadding, dy: cellPadding) let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath) attributes.frame = insetFrame cache.append(attributes) contentHeight = max(contentHeight, frame.maxY) yOffset[column] = yOffset[column] + height column = column < (numberOfColumns - 1) ? (column + 1) : 0 } } } extension PinterestLayout { override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { var visibleLayoutAttributes: [UICollectionViewLayoutAttributes] = [] for attributes in cache { if attributes.frame.intersects(rect) { visibleLayoutAttributes.append(attributes) } } return visibleLayoutAttributes } override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { return cache[indexPath.item] } }
-
Header를 이미지 다운로드를 위해 Kingfisher Extension에 커스텀 메서드 구성
Kingfisher 커스텀 메서드 구성 코드
extension KingfisherWrapper where Base: UIImageView { func setImageWithAuthHeaders( with resource: Resource?, placeholder: Placeholder? = nil, options: KingfisherOptionsInfo? = nil, progressBlock: DownloadProgressBlock? = nil, completionHandler: ((Result<RetrieveImageResult, KingfisherError>) -> Void)? = nil ) { // Header 추가를 위한 URLRequest 구성 let imageDownloadRequest = AnyModifier { request in var requestBody = request requestBody.setValue(UserDefaultsManager.accessToken, forHTTPHeaderField: HTTPHeader.authorization.rawValue) requestBody.setValue(APIKeys.sesacKey, forHTTPHeaderField: HTTPHeader.sesacKey.rawValue) return requestBody } // 이미지 다운로드를 위한 옵션 구성 let newOptions: KingfisherOptionsInfo = options ?? [] + [.requestModifier(imageDownloadRequest), .cacheMemoryOnly] self.setImage( with: resource, placeholder: placeholder, options: newOptions, progressBlock: progressBlock, completionHandler: completionHandler ) } }
말풍선 구성 코드
final class ChatBubbleImageView: UIImageView {
override init(frame: CGRect) {
super.init(frame: frame)
configureUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func configureUI() {
let image = UIImage(named: "messageBubble")
let horizontalInset = (image?.size.width ?? 0.0) * 0.4
let verticalInset = (image?.size.height ?? 0.0) * 0.4
self.image = image?.resizableImage(
withCapInsets: UIEdgeInsets(
top: verticalInset,
left: horizontalInset,
bottom: verticalInset,
right: horizontalInset),
resizingMode: .stretch
).withRenderingMode(.alwaysTemplate)
}
}
문제상황
-
UICollectionViewFlowLayout으로 Layout 구성 시 각 Cell마다 하단에 약간의 오차 발생하여 스크롤할 때마다 Cell이 화면에서 오차만큼 올라가는 현상 발생
UICollectionViewFlowLayout로 구성했던 코드
func createCollectionViewLayout() -> UICollectionViewLayout { let layout = UICollectionViewFlowLayout() let width = UIScreen.main.bounds.width // Safe Area Inset 상단 높이 구하기 let scenes = UIApplication.shared.connectedScenes let windowScene = scenes.first as? UIWindowScene let window = windowScene?.windows.first let topPadding = window?.safeAreaInsets.top ?? 0 // NavigationBar 높이 구하기 let navBarHeight = navigationController?.navigationBar.bounds.height ?? 0 // TabBar 높이 구하기 let tabBarHeight = tabBarController?.tabBar.bounds.height ?? 0 // 기기 화면 크기에서 Safe Area Inset 상단, NavigationBar 높이, TabBar 높이만큼 각각 빼어주어 Cell 높이 정의 let height = UIScreen.main.bounds.height - (topPadding + navBarHeight + tabBarHeight) layout.itemSize = CGSize(width: width, height: height) layout.minimumLineSpacing = 0 layout.minimumInteritemSpacing = 0 layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) return layout }
문제 원인 파악
- iPhone 14 Pro부터 도입된 Dynamic Island로 인해 Status bar의 높이와 Safe Area Insets의 top에서 차이가 발생하여 홈 화면 피드 구성 시 Collection View Layout으로는 오차가 항상 발생하였던 것
- Status bar(상태 표시줄)의 높이는 54포인트
- Safe Area Insets (portrait): top: 59, bottom: 34, left: 0, right: 0
해결방법
-
Modern CollectionView인 UICollectionViewCompositionalLayout으로 대응
UICollectionViewCompositionalLayout으로 대응
extension HomeViewController: UICollectionViewConfiguration { func createCollectionViewLayout() -> UICollectionViewLayout { let itemSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0) ) let group = NSCollectionLayoutGroup.horizontal( layoutSize: groupSize, subitems: [item] ) let section = NSCollectionLayoutSection(group: group) return UICollectionViewCompositionalLayout(section: section) } }
문제상황
- 다중 타입의 Section 정의한 후, dataSource를 구성할 때 각 Section의 공통 타입인 SectionModelType을 dataSource의 제네릭 타입으로 사용 불가
문제 원인 파악
-
SectionTypeModel의 경우, Associated Type으로 구성되어져 있는 프로토콜이기 때문에, Item의 타입이 구체적이지 않아 컴파일 시 변수의 할당 크기 등을 결정할 수 없어 컴파일 오류가 발생한 것
SectionModelType 프로토콜 정의
public protocol SectionModelType { associatedtype Item var items: [Item] { get } init(original: Self, items: [Item]) }
해결방법 - Type Erasure Wrapper
-
기존 Section 클래스를 감싸는 Type Erasure Wrapper 클래스를 만들어서 기존 타입을 없앤 후, Section 구성
Type Erasure Wrapper
import RxDataSources class AnySectionModel: SectionModelType { var items: [Item] init<S: SectionModelType>(_ sectionModel: S) { self.items = sectionModel.items } required init(original: AnySectionModel, items: [Item]) { self.items = items } } extension AnySectionModel { typealias Item = Any }
문제상황
문제 원인 파악
- 소켓 연결 해제와 동시에 모든 이벤트 인스턴스들이 삭제되지 않아 남아 있음
해결방법
-
소켓 연결 해제와 동시에 모든 이벤트 인스턴스들을 삭제해줌으로써 문제 해결
소켓 이벤트 인스턴스 삭제 코드
func removeAllEventHandlers() { print("Clear up all handlers.") print("socket handler count", socket?.handlers.count) socket?.removeAllHandlers() // 소켓 이벤트 인스턴스 삭제 }