[회고] 개인앱 프로젝트<택시팟> 회고

 

프로젝트 소개

taxiparty.png

앱 소개 & 기획

택시 합승 플랫폼, 택시팟!

 

 

기획 계기

 수도권에서는 대중교통이 잘 되어 있어서 택시를 굳이 안 이용해도 되는 경우가 많다. 하지만 지방은 수도권의 상황과 다르다. 우선 버스 배차 시간이 길고, 지하철 역이 없는 곳이 많다. 작년 애플 디벨로퍼 아카데미 생활을 했는데, 아카데미가 위치한 포항은 대중교통이 불편했고, 택시를 자주 이용하였다. 그러다 보니 생각보다 교통비가 많이 부담되어 택시를 같이 탈 사람을 구해서 목적지까지 이동하는 경우가 왕왕 있었다. 그래서 택시를 같이 탈 사람을 구할 수 있는 플랫폼이 있다면 좋지 않을까라는 생각이 들어 기획을 하게 되었다.

 

개발 기간과 v1.0 버전 기능

개발 기간

  • 2024.4.10 ~ 2024.5.5 (26일)

Configuration

  • 최소버전 16.0 / 라이트 모드 / 세로 모드 / iOS전용

v1.0 기능

    1. 택시 합승 파티 만들기

  • 위치 키워드 검색 기능
  • 맵에서 스크롤하여 위치 설정 기능
  • 예상 경로 
  • 예상 택시비 / 1인당 예상 택시비 정보

    2. 근처 택시팟 찾기 기능

  • 맵에서 근처 택시팟 표시 기능
  • 줌 level에 따라 클러스터링 처리
  • 해당 택시팟 클릭 시 상세 페이지로 이동
  • 리스트로 전체 포스트 조회 기능
  • 커서 기반 페이지네이션

    3. 프로필 설정

  • 프로필 닉네임 변경
  • 프로필 사진 변경

 

기술 스택

  • UIKit / SwiftUI / MVVM input - output Pattern
  • RxSwift / RxCoCoa / RxGesture
  • CodeBaseUI / SnapKit / Then
  • Alamofire Router Pattern / Kingfisher
  • TextFieldEffects / AnimatedTabbar
  • NaverMap / NVActivityIndicatorView
  • SPM / CocoaPods

 

⚒️트러블 슈팅

 처음 RxSwift를 적용한 프로젝트이면서 백앤드 서버를 제대로 써본 프로젝트이다. 아래는 그 문제들 중 어려웠던 문제들과 해결했던 방법을 설명해보려고 한다.

1. Custom Bottom Sheet의 크기 조절

etc-image-1etc-image-2
바텀시트의 사이즈가 최소(왼쪽), 최대(오른쪽) 일때

 

 

  어느 택시 앱과 같은 바텀시트뷰를 만들기 위해 택스트 필드를 누르면 전체화면으로 뒤로가기 버튼을 누르면 최소 크기로 바뀌게 만들고 싶었다. 기존 라이브러리로는 이와 같은 구현이 힘들어 라이브러리를 쓰지 않고 Custom Bottom Sheet View를 만들고, 버튼과 택스트필드에 타깃을 추가하여 클릭되었을 때 크기가 바뀌게 설정하여 해결했다.

 

final class BottomSheetView: PassThroughView {
    
    enum Mode {
        case tip
        case full
    }
    
    private enum Const {
        static let duration = 0.5
        static let cornerRadius = 20.0
        static let bottomSheetRatio: (Mode) -> Double = { mode in
            switch mode {
            case .tip:
                return 0.7
            case .full:
                return 0
            }
        }
        static let bottomSheetYPosition: (Mode) -> Double = { mode in
            Self.bottomSheetRatio(mode) * UIScreen.main.bounds.height
        }
    }
    
    let bottomSheetView = SearchAddressView()
    let addressLabel = UILabel()
    
    lazy var mode: Mode = .tip {
        didSet {
            switch self.mode {
            case .tip:
                break
            case .full:
                break
            }
            self.updateConstraint(offset: Const.bottomSheetYPosition(self.mode))
            self.bottomSheetView.updateConstraints(isFullmode: self.mode == .full)
        }
    }
    var bottomSheetColor: UIColor? {
        didSet { self.bottomSheetView.backgroundColor = self.bottomSheetColor }
    }
    
    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init() has not been implemented")
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        self.layer.shadowRadius = 1
        self.layer.shadowOpacity = 0.2
        self.layer.shadowOffset = CGSize.zero
        
        self.backgroundColor = .clear
        
        self.bottomSheetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
        self.bottomSheetView.layer.cornerRadius = Const.cornerRadius
        self.bottomSheetView.clipsToBounds = true
        
        self.addSubview(self.bottomSheetView)
        
        self.bottomSheetView.snp.makeConstraints {
            $0.left.right.bottom.equalToSuperview()
            $0.top.equalTo(Const.bottomSheetYPosition(.tip))
            $0.bottom.equalTo(keyboardLayoutGuide)
        }

        self.bottomSheetView.startPointTextField.addTarget(self, action: #selector(textFieldTapped), for: .editingDidBegin)
        self.bottomSheetView.destinationTextField.addTarget(self, action: #selector(textFieldTapped), for: .editingDidBegin)
        self.bottomSheetView.backButton.addTarget(self, action: #selector(backButtonTapped), for: .touchUpInside)
    }
    
    @objc private func backButtonTapped(sender: UIButton) {
        UIView.animate(
            withDuration: Const.duration,
            delay: 0,
            options: .allowAnimatedContent,
            animations: {
                self.mode = .tip
            },
            completion: nil
        )
    }
    
    @objc private func textFieldTapped(sender: UITextField) {
        UIView.animate(
            withDuration: Const.duration,
            delay: 0,
            options: .allowAnimatedContent,
            animations: {
                self.mode = .full
            },
            completion: nil
        )
    }
    
    private func updateConstraint(offset: Double) {
        self.bottomSheetView.snp.remakeConstraints {
            $0.left.right.bottom.equalToSuperview()
            $0.top.equalToSuperview().inset(offset)
        }
    }

}

 

 

2. Custom Bottom Sheet의 레이아웃이 찌그러지는 이슈

bottomSheetBug.PNG

 

  바텀 시트 사이즈가 tip 일 때, 다른 뷰 객체에 밀려서 원래 원하던 뷰 레이아웃이 제대로 그려지지 않는 버그가 발생하였다. 이 부분을 해결하기 위해 사이즈가 tip일때는 보이지 않는 뷰객체들의 height을 0으로 바꾸고 사이즈가 full로 바뀔 때 Snapkit의 remake를 통해 의도한 사이즈를 다시 부여하여 제대로 레이아웃이 그려지게 설정하였다.

 

   // bottomSheetView
    lazy var mode: Mode = .tip {
        didSet {
            switch self.mode {
            case .tip:
                break
            case .full:
                break
            }
            self.updateConstraint(offset: Const.bottomSheetYPosition(self.mode))
            self.bottomSheetView.updateConstraints(isFullmode: self.mode == .full)
        }
    }


// SearchAddressView
func updateConstraints(isFullmode: Bool) {
        if isFullmode {
            dividerView.snp.remakeConstraints { make in
                make.top.equalTo(destinationTextField.snp.bottom).offset(30)
                make.width.equalTo(self)
                make.height.equalTo(8)
            }
            headerLabel.snp.remakeConstraints { make in
                make.top.equalTo(dividerView.snp.bottom).offset(20)
                make.leading.equalTo(15)
            }
            tableView.snp.remakeConstraints { make in
                make.top.equalTo(headerLabel.snp.bottom).offset(10)
                make.horizontalEdges.equalTo(self)
                make.bottom.equalTo(self).offset(-85)
            }
        } else {
            dividerView.snp.remakeConstraints { make in
                make.top.equalTo(destinationTextField.snp.bottom).offset(30)
                make.width.equalTo(self)
                make.height.equalTo(0)
            }
            headerLabel.snp.remakeConstraints { make in
                make.top.equalTo(dividerView.snp.bottom).offset(20)
                make.leading.equalTo(15)
                make.height.equalTo(0)
            }
            tableView.snp.remakeConstraints { make in
                make.top.equalTo(headerLabel.snp.bottom).offset(10)
                make.horizontalEdges.equalTo(self)
                make.height.equalTo(0)
            }
        }
    }

 

🤔회고

잘한 점

1. 같은 서버라도 차별성이 있는 앱이었는지?

 이전 개인 프로젝트에서도 언급한 부분이지만, 같은 API, 같은 서버를 사용한다하더라도 남들과는 차별화된 앱을 만들 수 있다. 이번에 주어진 서버는 post 작성 기준으로 요청 바디에 title, content, content1 ~ 5, product_id, files로 이루어져 있다. 따라서 SNS 서비스를 만들 수도 중고거래 서비스를 만들 수도 있다. 여기서 더 나아가 다양한 기술을 사용해 볼 수 있으면서도 새로운 서비스나 많은 사람들이 구현해보지 않은 서비스를 만들어보고 싶었고, 단순히 content에 텍스트 대신 좌표값을 비롯한 위치 정보를 업로드하여 <택시팟>으로 차별화된 앱을 만들 수 있었다고 생각한다.

 

2. 새로운 라이브러리 사용

  • NaverMap
  • RxSwift / RxCocoa / RxGesture

 제대로 Map을 사용해본 적이 없어서 이번에 사용해보고 싶었다. 현재 API로 사용가능한 대표적인 Map은 NaverMap, KaKaoMap, GoogleMap, Apple Map이 있는데, 이번 프로젝트에서는 NaverMap을 사용하였다. 그 이유는 다음과 같다. 첫째로 Map을 사용할 때 중요한 부분은 Map으로 출발지부터 도착지까지 Route를 그려주는 API가 존재하느냐였다. 해당 API를 찾아보다 보니 Naver API 중 Direction5라고 출발지부터 도착지까지 루트 값을 주는 API를 발견하게 되었다. 두 번째로 Map 마다 좌표 값이 약간 씩 다르다. 그래서 Naver API로 받은 값을 KaKaoMap에 적용 시 위치가 조금 다르게 나온다. 그 이유는 네이버와 카카오가 서로 다른 좌표계를 사용하고 있기 때문이다. 마지막 이유는 CocoaPods를 이번 기회에 사용해보고 싶어서 NaverMap을 사용하였다.

 또 RxSwift를 이번 프로젝트에 적용을 하였다. 처음 사용해서 적절하게 사용하는 건지 의문이 들었지만 확실히 RxSwift로 MVVM input - output 패턴으로 구현하니 가독성적인 측면이나 코드를 관리하는 부분에서 편리함을 느낄 수 있었다. 또 Reactive Programming 사용하여 데이터 흐름을 공부할 수 있는 기회였다. 이 과정 중에서 associatedType을 공부하고 프로토콜을 적용해볼 수 있었다.

protocol ViewModelProtocol {
    associatedtype Input
    associatedtype Output
    
    var disposeBag: DisposeBag { get set }
    
    func transform(input: Input) -> Output
}

 

3. 이전에 받은 피드백을 고려했는지?

  이전 프로젝트에서는 피드백 중 네트워크 단절 상황을 대처하는 부분을 구현하지 못했었다. 이 부분을 이번에는

NWPathMonitor를 통해 네트워크 연결 상태를 감지하고, 단절 상태일때는 SceneDelegate에서 새로운 UIWindow를 화면 제일 상단에 보여주고, 연결되었을 때는 제거하는 방법으로 구현을 하였다. 이 방법을 통해 뷰마다 NWPathMonitor 객체를 만들어줄 필요가 없어져서 리소스 낭비를 줄일 수 있었다.

networkDisconnect.PNG

반성할 점

1. 부족한 에러 핸들링

 이번에 에러핸들링을 통해 에러 처리를 하긴했지만, 완벽한 에러핸들링이 아니었다고 생각한다. API 서버 통신을 통해 일부는 에러핸들링을 하여 에러 상황에 따라 처리를 했지만 나머지 부분은 하지 못했다. 이 계기로 에러를 하나하나 모두 처리하는 것은 굉장히 어렵고 귀찮은 작업이라는 것을 알게 되었다. 이런 문제의 해결방법으로는 에러 메시지를 받아서 그대로 alert로 띄우고 이벤트는 발생하지 않게 하거나 하는 방식으로 구현할 수 있었다고 생각한다. 

 

2. 공수산정을 하였는가?

 사실 이번 프로젝트를 진행하면서 공수산정을 하지 않았다. 공수산정이 필요한 작업이라는 것을 알고있었지만, 이번에는 하지 못했다. 변명을 해보자면 기획이 늦게 정해졌고 도중에 3일 이상을 포트폴리오를 만드느라 시간을 사용해 버렸다. 물론 오히려 그렇기 때문에 더 공수산정을 했어야 했다.(물론 1인 기획, 디자인, 개발이라 공수산정에 어려움이 있었다.)   

 

3. 트러블이 있었던 부분을 기록하였는가?

 리드미를 쓰기 시작하면서 가장 큰 문제는 정확히 어떤 부분에서 막혔고, 그 이유가 무엇이었으며, 어떤 방식으로 어떤 과정을 통해 해결했다라는 점이 기억나지 않는 점이다. 리드미를 제대로 작성하기 시작하면서 이 부분이 굉장히 중요하다는 것을 알게 되었다. 따라서 앞으로는 공수산정과 함께 트러블이 있었던 부분과 해결을 위해 어떤 방법들을 사용했는지를 기록해야겠다.

'# 개발 > 프로젝트' 카테고리의 다른 글

[회고] 개인앱 프로젝트<UQuiz> 회고  (0) 2024.03.29