주니곰의 괴발노트
새싹스터디 앱 개발 회고 본문
1. Intro
안녕하세요. 이번 SeSAC 2기 과정의 마지막 코스인 SLP(Service Level Project)를 진행해보았습니다. 새싹스터디는 내가 하고 싶은 스터디의 키워드를 기반으로 같이 스터디할 멤버를 검색하고 채팅으로 연결시켜주는 앱입니다. 약 한 달간 진행한 프로젝트이고, Firebase를 통한 회원가입 로직부터 소켓통신 및 인앱결제까지 구현해 본 경험이었습니다.
2. 프로젝트 진행계획
프로젝트 진행 전, 노션을 이용하여 각 주차별로 계획을 세우고 진행하였습니다. 뷰를 그리거나 로직을 구현하면서 문제가 발생하여 일정이 틀어져 수정한 지점이 있지만, 최대한 계획해놓은 일정에 맞춰 진행하려고 노력하였습니다. 5주 정도 진행하면서 85 ~ 90% 정도 진행하였고, 추가로 일주일 더 진행하여 시간이 부족하여 놓친 부분, 남은 구현(IAP부분, 새싹샵)을 구현하였습니다.
3. 코드 돌아보기
(1) 폴더링
개인 프로젝트(여행가자곰 출시 프로젝트 회고)를 진행하면서 폴더를 세부적으로 구분하려고 했지만, 항상 뭔가 불만족스러웠습니다. 뷰컨트롤러 / 뷰/ 뷰모델처럼 구분을 하니, 위아래로 왔다갔다하면서 작업을 해야해서 효율적이지 못한 것 같다고 느꼈습니다.
그래서 이번에는 Asset과 관련된 Resources, Extension과, Network Manager 등 여러 뷰컨트롤러에서 사용되는 Utilities 그리고 화면별로 분류를 해놓은 Scenes로 크게 구분을 하였습니다. 해당 뷰 내부에 push나 present 등을 통해 다른 뷰컨트롤러를 호출할 경우, Nested라는 폴더를 만들고 계층을 만들어 관리하였습니다.
(2) 세부 코드
이번 프로젝트에서 코드를 개선해보기 위해 다음과 같이 목표를 잡았습니다.
1. 같은 형태를 가진 뷰는 최대한 재활용하기
2. Enum을 활용하여 분기처리 하기
3. 중복되는 함수는 재활용하도록 코드 구성하기
새싹샵의 새싹과 배경은 뷰컨트롤러만 다르고 내부 구성은 똑같아 하나의 뷰로 구현하였습니다. 다만 collection view의 layout만 달라지는데 이 부분은 convenience init과 enum을 이용하여 분기처리를 하였습니다.
// init에 enum을 parameter로 전달하여 뷰 생성
final class ShopSharedView: BaseView {
convenience init(state: ShopViewSelected) {
self.init()
configureHierarchy(state: state)
configureDataSource(state: state)
}
}
// enum parameter를 전달하여 collectionView의 layout과 dataSource 생성
extension ShopSharedView {
private func configureHierarchy(state: ShopViewSelected) {
switch state {
case .face:
collectionView = UICollectionView(frame: bounds, collectionViewLayout: createLayout(state: .face))
case .background:
collectionView = UICollectionView(frame: bounds, collectionViewLayout: createLayout(state: .background))
}
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
addSubview(collectionView)
collectionView.snp.makeConstraints {
$0.edges.equalToSuperview().inset(4)
}
}
func configureDataSource(state: ShopViewSelected) {
switch state {
case .face:
let cellRegistration = UICollectionView.CellRegistration<FaceCollectionViewCell, FaceImages> { (cell, indexPath, item) in
cell.ConfigureCells(item: item)
}
faceDataSource = UICollectionViewDiffableDataSource<Int, FaceImages>(collectionView: collectionView) {
(collectionView, indexPath, item) -> UICollectionViewCell? in
return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item)
}
updateUI(state: state)
case .background:
let cellRegistration = UICollectionView.CellRegistration<BackgroundCollectionViewCell, BackgroundImages> { (cell, indexPath, item) in
cell.ConfigureCells(item: item)
}
backgroundDataSource = UICollectionViewDiffableDataSource<Int, BackgroundImages>(collectionView: collectionView) {
(collectionView, indexPath, item) -> UICollectionViewCell? in
return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item)
}
updateUI(state: state)
}
}
}
그리고 버튼이나 텍스트필드의 Action은 Enum으로 정의를 해두고 다음과 같이 RxSwift와 Input Output 패턴으로 구현하였습니다.
final class LoginViewController: BaseViewController {
private func bindData() {
let input = LoginViewModel.Input(textFieldText: myView.phoneNumTextField.rx.text,
textFieldIsEditing: myView.phoneNumTextField.rx.controlEvent(.editingDidBegin),
textFieldFiniedEditing: myView.phoneNumTextField.rx.controlEvent(.editingDidEnd),
tap: myView.getCertiNumButton.rx.tap)
let output = myView.viewModel.transform(input: input)
output.textFieldActions
.withUnretained(self)
.bind { (vc, actions) in
switch actions {
case .editingDidBegin:
vc.myView.lineView.backgroundColor = SSColors.black.color
case .editingDidEnd:
vc.myView.lineView.backgroundColor = SSColors.gray6.color
}
}
.disposed(by: myView.viewModel.disposeBag)
}
}
final class LoginViewModel: CommonViewModel {
struct Input {
let textFieldIsEditing: ControlEvent<Void>
let textFieldFiniedEditing: ControlEvent<Void>
}
struct Output {
let textFieldActions: Observable<TextFieldActions>
}
func transform(input: Input) -> Output {
let textFieldActions = Observable.merge(input.textFieldIsEditing.map { _ in TextFieldActions.editingDidBegin },
input.textFieldFiniedEditing.map { _ in TextFieldActions.editingDidEnd })
return Output(textFieldActions: textFieldActions)
}
}
이번 프로젝트에서는 실제 서버와 통신하며 네트워크 관련된 코드를 많이 사용하였습니다. 그래서 네트워크 관련된 메서드의 사용성을 높이기 위하여 Alarmofire의 URLRequestConvertible을 활용하였습니다.
enum SeSacApiUser {
case login, signup, myPage, withdraw, fcmToken
}
extension SeSacApiUser: URLRequestConvertible {
var url: URL {
switch self {
default:
guard let url = URL(string: UserDefaultsManager.userBaseURLPath) else { return URL(fileURLWithPath: "") }
return url
}
}
var path: String {
switch self {
case .login, .signup: return UserDefaultsManager.loginAndSignupPath
case .myPage: return UserDefaultsManager.myPagePath
case .withdraw: return UserDefaultsManager.withdrawPath
case .fcmToken: return UserDefaultsManager.updateFcmToken
}
}
var method: HTTPMethod {
switch self {
case .login: return .get
case .signup, .withdraw: return .post
case .myPage, .fcmToken: return .put
}
}
var headers: HTTPHeaders {
switch self {
case .login, .signup, .myPage: return ["Content-Type": UserDefaultsManager.contentType, "idtoken": UserDefaultsManager.token]
case .withdraw, .fcmToken: return ["idtoken": UserDefaultsManager.token]
}
}
var parameters: [String: String]? {
switch self {
case .signup: return ["phoneNumber": UserDefaultsManager.phoneNum,
"FCMtoken": UserDefaultsManager.fcmToken,
"nick": UserDefaultsManager.nickname,
"birth": UserDefaultsManager.birth,
"email": UserDefaultsManager.email,
"gender": "\(UserDefaultsManager.gender)"]
case .myPage: return ["searchable": "\(NetworkManager.shared.userData.searchable)",
"ageMin": "\(NetworkManager.shared.userData.ageMin)",
"ageMax": "\(NetworkManager.shared.userData.ageMax)",
"gender": "\(NetworkManager.shared.userData.gender)",
"study": "\(NetworkManager.shared.userData.study)"]
case .fcmToken: return ["FCMtoken": UserDefaultsManager.fcmToken]
default: return nil
}
}
var encoding: ParameterEncoding {
switch self {
case .login, .signup, .myPage, .withdraw, .fcmToken: return URLEncoding.default
}
}
func asURLRequest() throws -> URLRequest {
let url = url.appendingPathComponent(path)
var urlRequest = URLRequest(url: url)
urlRequest.method = method
urlRequest.headers = headers
return try URLEncoding.default.encode(urlRequest, with: parameters)
}
}
위와 같이 세팅해놓은 URLRequestConvertible 타입이 총 4개가 존재하며, 이를 효율적으로 활용하기 위해 메서드 parameter에 URLRequestConvertible타입을 전달하여 구성하였습니다.
final class NetworkManager {
func request<T: Codable>(_ types: T.Type = T.self, router: URLRequestConvertible) -> Single<(data: T, state: Int)> {
return Single<(data: T, state: Int)>.create { single in
AF.request(router).responseDecodable(of: types.self) { response in
guard let statusCode = response.response?.statusCode else { return }
switch response.result {
case .success(let value):
let dataWithState = (value, statusCode)
single(.success(dataWithState))
case .failure:
guard let error = SesacStatus.DefaultError(rawValue: statusCode) else { return }
single(.failure(error))
}
}
return Disposables.create()
}
}
func request(router: URLRequestConvertible) -> Single<(data: String, state: Int)> {
return Single<(data: String, state: Int)>.create { single in
AF.request(router).responseString() { response in
guard let statusCode = response.response?.statusCode else { return }
switch response.result {
case .success(let value):
let dataWithState = (value, statusCode)
single(.success(dataWithState))
case .failure:
guard let error = SesacStatus.DefaultError(rawValue: statusCode) else { return }
single(.failure(error))
}
}
return Disposables.create()
}
}
}
4. 개발하며 어려웠던 지점
이번 프로젝트에서는 주로 CollectionView를 Compositional Layout과 Diffable DataSource를 활용하며 많은 어려움을 느끼게 되었습니다. 단순히 뷰를 보여주는 부분에서는 문제가 없지만 데이터를 업데이트하고 snapshot으로 뷰를 보여주게 될 경우 Layout이 깨지거나 원활하게 작동되지 않는 부분이 컸습니다.
해당 문제를 해결하기 위해 CollectionViewCell의 내부에 preferredLayoutAttributesFitting이라는 메서드를 활용하였습니다. Label의 intrinsicContentSize와 제가 지정한 inset을 이용하였습니다.
이미지 위에 버튼을 올리고 버튼의 Action을 통해 새로운 뷰컨트롤러를 present해야 하는 부분이 종종 있었습니다. CollectionView의 헤더를 UICollectionReusableView를 통해 만들고 해당 뷰 내부에 구현을 하다보니 새로운 뷰컨트롤러의 present가 불가능하였습니다. 처음에는 코드를 모두 뷰컨트롤러에 구현하였으나 코드를 간결하고 기능별로 분리해보자는 목표와 맞지 않는 것 같기도 하고, 추후 코드 리팩토링을 할 때에도 많이 힘들 것 같아 다른 방법을 찾게 되었습니다.
제가 찾아본 방법으로는 현재 스크린에서 최상단 뷰컨트롤러를 호출하는 방법으로 해당 문제를 해결하였습니다.
extension UIApplication {
class func getTopMostViewController() -> UIViewController? {
let keyWindow = UIApplication.shared.connectedScenes
.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }
.filter { $0.isKeyWindow }.first
if var topController = keyWindow?.rootViewController {
while let presentedViewController = topController.presentedViewController {
topController = presentedViewController
}
return topController
} else {
return nil
}
}
}
처음엔 채팅화면은 UITableViewDelegate와 UITableViewDataSource를 활용하여 구현하려고 하였습니다. 하지만 채팅을 할 때마다 데이터를 업데이트하고 reloadData를 지속적으로 해야하는 번거로움이 있어서 RxSwift를 적극활용하기로 하였습니다. 그래서 Rx Community에 있는 오픈 라이브러리를 최대한 활용해보려고 하였습니다.
RxDataSource를 이용해서 각 셀에 해당하는 모델 타입을 만들고 원하는 데이터만 넣기 위해 다음과 같이 커스텀을 하였습니다.
struct ChatSections {
var items: [Item]
}
enum ChatItems {
case dateCell(DateCellModel)
case userChatCell(UserChatCellModel)
case introCell(IntroCellModel)
case myChatCell(MyChatCellModel)
}
extension ChatSections: SectionModelType {
typealias Item = ChatItems
init(original: ChatSections, items: [Item] = []) {
self = original
self.items = items
}
}
struct UserChatCellModel {
let chat: String
let date: String
}
struct DateCellModel {
let string: String
}
struct IntroCellModel {
let string: String
}
struct MyChatCellModel {
let chat: String
let date: String
}
그리고 받아온 채팅 데이터를 Realm에 저장한 뒤, 저장된 데이터를 이용해서 dataSource를 구성해주고 적용시켰습니다.
private func createCellData() {
chatting.removeAll(keepingCapacity: true)
if let first = ChatRepository.shared.tasks.first?.createdAt.toDate()?.toString(withFormat: "M월 dd일 EEEE") {
let firstDate = ChatItems.dateCell(DateCellModel(string: first))
chatting.append(firstDate)
}
let withWhom = ChatItems.introCell(IntroCellModel(string: NetworkManager.shared.nickName))
chatting.append(withWhom)
ChatRepository.shared.tasks.forEach { chat in
if chat.to == UserDefaultsManager.myUid {
guard let time = chat.createdAt.toDate()?.toString(withFormat: "HH:mm") else { return }
let item = ChatItems.userChatCell(UserChatCellModel(chat: chat.chat, date: time))
chatting.append(item)
} else if chat.to == NetworkManager.shared.uid {
guard let time = chat.createdAt.toDate()?.toString(withFormat: "HH:mm") else { return }
let item = ChatItems.myChatCell(MyChatCellModel(chat: chat.chat, date: time))
chatting.append(item)
}
}
}
private func addData() {
let sections = [ChatSections(items: chatting)]
viewModel.dateSection.accept(sections)
}
RxDataSource에 viewModel 내부에 구성해 둔 BehaviorRelay타입으로 선언된 프로퍼티를 통해 데이터를 연결하였습니다. 그리고 RxRealm을 이용하여 Realm의 tasks가 변경될 때마다 해당 메서드를 실행하고 ScrollToRow를 해서 최신 채팅으로 보여질 수 있도록 구성하였습니다.
private func bindData() {
viewModel.dateSection
.bind(to: tableView.rx.items(dataSource: dataSource))
.disposed(by: viewModel.disposeBag)
Observable.changeset(from: ChatRepository.shared.tasks)
.bind { [weak self] _ in
guard let self = self else { return }
self.createCellData()
self.addData()
if ChatRepository.shared.tasks.count > 0 {
self.tableView.scrollToRow(at: IndexPath(row: ChatRepository.shared.tasks.count - 1, section: 0), at: .bottom, animated: false)
}
}
.disposed(by: viewModel.disposeBag)
}
5. Outro
이번 프로젝트를 진행하며 굉장히 사소한 부분에서도 많은 어려움을 느낀 것 같습니다. 예를 들면, imageView에 버튼을 올리고 addTarget을 하였으나, 동작이 되지 않아 열심히 인터넷을 찾아보았습니다. 하지만 ImageView의 isUserInteractionEnabled가 생성 시 기본으로 false가 되어있는 부분을 인지하지 못해 시간을 많이 잡아먹었습니다. 새로 알게 된 기술을 공부하고 써보는 것도 중요하지만 기본을 다시 다지는 것이 많이 중요하다는 것을 느끼게 되었습니다.
앞으로는 기본 문법과 애플 프레임워크에 대한 공부 비중과 새로운 기술에 대한 공부를 7:3으로 잡고 공부해볼 예정입니다.
'기타' 카테고리의 다른 글
여행가자곰 출시 프로젝트 회고 (0) | 2022.10.14 |
---|---|
SeSAC TIL - 22.07.19 (0) | 2022.07.20 |
SeSAC TIL - 22.07.18 (0) | 2022.07.20 |
SeSAC TIL - 22.07.15 (0) | 2022.07.18 |
SeSAC TIL - 22.07.14 (0) | 2022.07.18 |