Notice
Recent Posts
Recent Comments
Link
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
Archives
Today
Total
관리 메뉴

주니곰의 괴발노트

여행가자곰 출시 프로젝트 회고 본문

기타

여행가자곰 출시 프로젝트 회고

Jhe2 2022. 10. 14. 02:41

1. Intro

 

 

안녕하세요. 이번 SeSAC 2기 과정을 수강하며 처음으로 개인 앱을 출시하여 배포해보았습니다.

 

여행 플래너 앱을 제작하였고, 지도에 어노테이션을 표기 및 각 어노테이션 간의 경로를 보여줌으로써 내가 계획한 여행의 전체적인 경로를 확인 할 수 있도록 도와주는 앱입니다.

 

앞으로 앱을 업데이트할 때 앱을 더 개선해보고, 같은 실수를 반복하지 않기 위해 출시 과정에 대한 회고를 해보려고 합니다.

 


 

2. 데이터 구조 설계

 

이번 개인 앱 프로젝트를 진행하면서 데이터를 관리하기 쉬운 쪽으로 구조를 여러 번 바꾸게 되면서 불필요한 시간을 낭비하게 되었습니다.

 

이를 통해 초기에 앱을 구상할 때, 설계를 제대로 해야 한다는 것을 느꼈습니다.

 

Realm을 활용해서 앱에 사용되는 데이터를 구성하였습니다.

 

현재 여행지 정보 같이 여행가는 친구 정보 여행 히스토리 관광지 정보
목적지 이름 이름 여행 이름 관광지 이름
목적지 주소   여행지 목록 관광지 주소
위도   친구 목록 관광지 소개
경도     전화번호
등록 순번     위도
      경도

 

그리고 여행 히스토리에 현재 여행지 정보같이 여행가는 친구정보를 List 타입으로 연결시켜줬습니다.

 

 


 

3. 코드 돌아보기

 

커스텀 객체들

 

프로젝트를 진행하면서 최대한 View Controller에 작성되는 코드를 줄이고, 기능별로 코드를 분리하기 위해 노력했습니다.

 

Base View Controller와 Base View를 만들고, 이를 상속받은 객체를 활용하여 View안에 코드를 작성하였습니다.

 

하지만 View 내부에 tableView를 만들다보니 didSelectRowAt을 활용한 화면전환 코드가 실행이 되지 않는 점이었습니다. 

 

그래서 View Controller에서 무조건 코드를 덜어내는 것이 좋은 방법인가에 대해 좀 더 생각해보게 되었습니다.

 


 

final class TripHistoryRepository {

    private init() { }
    
    static let standard = TripHistoryRepository()
    
    // MARK: - Init
    
    let localRealm = try! Realm()
    var tasks: Results<TripHistory>!

 

두번째로는 Realm을 Singleton으로 활용하였습니다.

 

Realm()은 어느곳에서 생성해도 동일하지만 tasks 같은 경우는 View Controller마다 데이터가 달라지다보니 이 방식을 이용하였습니다.

 

그 이유는 여러 View Controller를 이동하면서 tasks를 활용하기 위함이었습니다. 

 


 

final class SearchViewModel {
    
    // MARK: - Properties
    
    var results: Observable<[SearchResults]> = Observable([])
    var searchText: Observable<String> = Observable("")
    var totalCounts: Observable<Int> = Observable(0)
    var pages: Observable<Int> = Observable(1)

    
    // MARK: - Helper Functions
    
    func reloadTableView(_ tableView: UITableView) {
        results.bind { results in
            tableView.reloadData()
            tableView.isHidden = results.isEmpty ? true : false
        }
    }
    // MARK: - Selectors
    
    @objc private func searchButtonTapped() {
        viewModel.searchButtonTapped(mapView, vcs: self)
    }
    
    @objc private func deleteButtonTapped() {
        viewModel.deleteButtonClicked(mapView, vc: self)
    }
    
    @objc private func createPathButtonTapped() {
        viewModel.createPathButtonTapped(mapView)
    }
    
    @objc private func finishTripButtonTapped() {
        viewModel.finishTripButtonTapped(mapView, vc: self)
    }

 

세번째로 viewModel을 활용, bind를 통해 데이터가 없을 때 자동으로 tableView를 숨김처리하는 등의 처리를 하였습니다.

 

그 외에도 View Controller의 코드를 최대한 숨김처리하려고 하였습니다.

 

Button Action에 대한 Selectors도 viewModel내부에 구현해두고 parameter를 전달하는 것을 통해 구현하였습니다.

 


 

enum TripStatus {
    case current, past
}

    func setAnnotation(_ mapView: MKMapView, lat: CLLocationDegrees?, lon: CLLocationDegrees?, turn: Int, index: Int?, status: TripStatus) {
        
        guard let lat = lat, let lon = lon else { return }
        let center = CLLocationCoordinate2D(latitude: lat, longitude: lon)
        
        switch status {
        case .current:
            
            let currentTrip = TripHistoryRepository.standard.fetchCurrentTrip()
            
            let annotation = Annotation(currentTrip[0].trips[turn - 1].turn)
            annotation.coordinate = center
            annotation.title = currentTrip[0].trips[turn - 1].name
            annotation.subtitle = currentTrip[0].trips[turn - 1].address
            annotations.append(annotation)
            
        case .past:
            
            let tripHistory = TripHistoryRepository.standard.fetchTripHistory()
            guard let index = index else { return }
            
            let annotation = Annotation(tripHistory[index].trips[turn - 1].turn)
            annotation.coordinate = center
            annotation.title = tripHistory[index].trips[turn - 1].name
            annotation.subtitle = tripHistory[index].trips[turn - 1].address
            historyAnnotations.append(annotation)
            
        }

 

마지막으로 같은 기능을 하는 method지만 사용처가 다른 부분을 enum을 이용하여 재활용하였습니다.

 


 

4. 개발하며 어려웠던 지점

 

이번 앱을 만들며 Realm Data를 json 형태로 encoding / decoding을 통해 백업 / 복구 기능을 넣었습니다.

 

하지만 encoding / decoding에 대한 개념도 부족했고, List 형태의 데이터를 내보내기 위해서는 다른 특별한 처리가 필요할 것 같다는 생각이 머리속을 맴돌았습니다.

 

그래서 이 부분을 중점으로 Googling을 하다보니 원하는 자료를 찾기 힘들었습니다.

 

찾은 자료들은 전부 build를 하면 오류가 발생하거나 encoding / decoding 과정에서 오류가 발생하였습니다.

 

그러다 같이 수강을 하는 석준님의 도움을 받아 encoding / decoding의 흐름을 파악할 수 있게 되었고!

(석준님 감사합니다~~ 복 많이 받으세요~~ ㅎㅎ )

 

이를 기반으로 Googling을 통해 백업 / 복구 기능을 완성하였습니다!

 

final class TripHistory: Object, Codable {
    
    private override init() { }
    
    @Persisted var tripName: String
    @Persisted var isTraveling: Bool
    @Persisted var trips = List<CurrentTrip>()
    @Persisted var companions = List<Companions>()
    
    @Persisted(primaryKey: true) var objectId: ObjectId
    
    convenience init(tripName: String, trips: [CurrentTrip], companions: [Companions]) {
        self.init()
        self.tripName = tripName
        self.trips.append(objectsIn: trips)
        self.companions.append(objectsIn: companions)
        self.isTraveling = true
    }

 

최상단에 위치하는 Realm 데이터 형태는 위와 같습니다.

 

	private enum CodingKeys: String, CodingKey {
        case tripName, isTraveling, trips, companions
    }
    
    private enum TripsCodingKeys: String, CodingKey {
        case name, address, latitude, longitude, turn
    }

    private enum CompanionsCodingKeys: String, CodingKey {
        case companion
    }

 

거기에 다음과 같이 코딩키를 추가해주고,

 

    func encode(to encoder: Encoder) throws {
        
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(self.tripName, forKey: .tripName)
        try container.encode(self.isTraveling, forKey: .isTraveling)
        
        let tripsContainer = container.superEncoder(forKey: .trips)
        try trips.encode(to: tripsContainer)
        
        let companionContainer = container.superEncoder(forKey: .companions)
        try companions.encode(to: companionContainer)
        
    }

 

위처럼 encoding 코드를 작성해주었습니다. 

 

그리고 superEncoder를 통해 배열을 만들어주고, 내부에 데이터를 encoding 하는 방식으로 json을 만들어 내보냈습니다.

 

    required convenience init(from decoder: Decoder) throws {
        self.init()
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.tripName = try container.decode(String.self, forKey: .tripName)
        self.isTraveling = try container.decode(Bool.self, forKey: .isTraveling)
        
        var companiesContainer = try container.nestedUnkeyedContainer(forKey: .companions)
        
        var compArray = [String]()
        while !companiesContainer.isAtEnd {
            let itemCountContainer = try companiesContainer.nestedContainer(keyedBy: CompanionsCodingKeys.self)
            compArray.append(try itemCountContainer.decode(String.self, forKey: .companion))
        }
        
        compArray.forEach {
            self.companions.append(Companions(companion: $0))
        }
        
        var tripsContainer = try container.nestedUnkeyedContainer(forKey: .trips)
        
        var itemsArray = [(name: String, address: String, latitude: Double, longitude: Double, turn: Int)]()
        while !tripsContainer.isAtEnd {
            let itemCountContainer = try tripsContainer.nestedContainer(keyedBy: TripsCodingKeys.self)
            let name = try itemCountContainer.decode(String.self, forKey: .name)
            let address = try itemCountContainer.decode(String.self, forKey: .address)
            let latitude = try itemCountContainer.decode(Double.self, forKey: .latitude)
            let longitude = try itemCountContainer.decode(Double.self, forKey: .longitude)
            let turn = try itemCountContainer.decode(Int.self, forKey: .turn)
            itemsArray.append((name, address, latitude, longitude, turn))
        }
        
        itemsArray.forEach {
            self.trips.append(CurrentTrip(name: $0.name, address: $0.address, latitude: $0.latitude, longitude: $0.longitude, turn: $0.turn))
        }
    }
    
}

 

반대로 decoding의 경우에는 단순한 Realm column의 경우는 바로 decode를 해주고 배열 내부에 있는 데이터는

nestedContainer를 활용하여 내부에 들어가고 반복문으로 데이터를 꺼내와 Realm 데이터에 추가해주는 방식으로 진행하였습니다.

 

List 타입으로 선언된 Realm 데이터 내부에는 encode와 codingKey만 활용하여 코드를 구성하였습니다.

 


 

5. Outro

 

이번 기회로 앱을 처음으로 제 이름을 달고 출시를 해보게 되었는데 앱 만드는 게 만만치 않다는 것을 느끼게 되었습니다.

 

그리고 앱의 기획이 구체적이지 않았다보니 UI구성을 자주 바꾸게 되었습니다.

 

이를 통해 앱의 기획 / 설계가 가장 중요하다는 것을 깨닫는 계기가 되었습니다.

 

앞으로 꾸준히 업데이트를 통해 더 사용자 친화적인 앱을 만드는 방향으로 가겠습니다.

 

많이 다운로드 해주세요~!!

https://apps.apple.com/kr/app/%EC%97%AC%ED%96%89%EA%B0%80%EC%9E%90%EA%B3%B0/id6443563655

 

‎여행가자곰

‎[여행가자곰] 곰돌이와 함께하는 여행 플래너 [여행가자곰의 특징] - 가고 싶은 목적지와 같이 가는 친구를 등록하여 여행 흐름을 한눈에 파악해보세요. - 등록한 목적지 간 이동경로를 표시하

apps.apple.com

 

'기타' 카테고리의 다른 글

새싹스터디 앱 개발 회고  (1) 2022.12.25
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
Comments