샘성의 iOS 개발 일지

[KakaoMap 클론] 5. 장소 즐겨찾기 기능 추가하기 (feat. Firestore) 본문

iOS/UiKit

[KakaoMap 클론] 5. 장소 즐겨찾기 기능 추가하기 (feat. Firestore)

SamusesApple 2023. 6. 11. 18:51
728x90

  오늘은 Firestore를 이용하여 장소 즐겨찾기 기능을 추가할 것이다!

 

배경: Firebase 로그인 기능 완료 된 상태, KakaoMap 세팅 완료 된 상태.

 

 

1. 알아보기

  시작하기 앞서, 어떻게 구현할 것이고 데이터를 어떤 구조로 저장할 것인지에 대해 기록하고자 한다.

 1-1) 구현 로직: 

  1. Firebase Auth를 사용해 현재 유저의 로그인 여부 확인.
    (로그인 된 유저인 경우 즐겨찾기 기능 제공, 아닌 경우 즐겨찾기 기능은 제공되지 않는다.)

  2. 유저의 uid와 카카오맵API에서 제공하는 해당 장소의 id를 사용하여 Firestore에 즐겨찾기 데이터 저장
    (해당 장소의 고유값인 id를 사용하여 해당 장소가 Firestore에 저장되었는지 여부를 체크할 수 있도록 할 것이다.)

  3. 저장 버튼을 누르면 Firestore에 해당 장소 저장 혹은 저장된 장소 삭제되도록 할 것이다.

 

 1-2) 즐겨찾기 데이터 구조 :

  • Firestore의 데이터 구조는 Collection - Document - Collection - Document 형태 (Document-Collection 모델)로 되어있다는 점을 유념해야한다.
    딕셔너리 형태(Key-Value)의 데이터를 저장하고 싶다면 컬렉션 부분에 저장해야 한다는 점을 꼭 유의해야한다.

초록색 : collection , 노랑색 : document

  1. 'favorite'라는 컬렉션을 하나 생성한다.
  2. 'favorite'안에 접속한 유저의 uid로 된 document를 생성한다. (즐겨찾기 저장한 유저의 uid 목록이 이곳에 담길 것)
  3. 유저의 uid로 된 document 안에 다시 한 번 'favorite'를 생성한다. (이 안에 '장소 document - collection'을 저장할 것)
  4. 'favorite'안에 장소id로 된 document를 생성한다.
  5. 장소 id로 된 document에 장소에 대한 필요한 정보를 넣는다 (이름, 위치좌표, 주소, 장소id)

 

 

  해당 모델이 이해가 간다면, 앞으로 다른 데이터를 저장할때도 해당 모델임을 유의하면서 데이터구조를 만들 수 있게 될 것이다!

 

 

 

 

2. 장소에 대한 데이터 모델 만들기

  시작하기에 앞서, Firestore에 해당 장소에 대한 어떠한 정보를 저장하고 싶은지를 구상해야한다.
필자는 나중에 즐겨찾기 모아서 띄울 화면에 어떠한 정보를 보여줄지를 생각해보았다.

 

  간단하게 장소명, 해당 장소의 주소와 셀을 누르면 해당 장소의 위치를 띄우고 즐겨찾기 상태 변경에 필요한 좌표와 장소 id를 저장하기로 했다.


그 결과, 하단의 사진과 같은 데이터를 저장하기로 했다.

 

 

struct FavoritePlace {
    let placeName: String
    let placeID: String
    let address: String
    let coordinate: Coordinate
    
    // Firestore에 저장한 데이터 받기 위한 생성자 
    init(dictionary: [String: Any]) {
        self.placeName = dictionary["placeName"] as! String
        self.placeID = dictionary["placeID"] as! String
        self.address = dictionary["address"] as! String
        
        let coordinate = dictionary["coordinate"] as! [String: Any]
        let longtitude = coordinate["longtitude"] as! String
        let latitude = coordinate["latitude"] as! String
        self.coordinate = Coordinate(longtitude: Double(longtitude) ?? 0, latitude: Double(latitude) ?? 0)
    }
}

  그리고 이후에 저장한 데이터를 꺼내고 저장할 수 있도록 모델을 생성자와 함께 하나 생성한다.

  (필자는 지도 앱 특성상 좌표계를 자주 사용하기에 위도와 경도를 한꺼번에 저장할 Coordinate라는 타입을 만들어놓은 상태다.)

 

 

 

 

 

3. Firestore에 데이터 저장하기 (+ 소스코드)

  Firestore에 이제 데이터를 CRUD 하는 코드를 생성하면 된다.

 필자는 Firestore CRUD를 담당하는 싱글톤 구조체를 하나 생성했다.

 

 

3-1) 배경 및 코드 로직 설명 :

  배경 : 

     유저가 로그인 되면, UserDefaults에 로그인된 유저의 uid가 저장된 상태.
    키워드로 검색하기 API 네트워킹을 통해 장소에 대한 정보를 담은 KeywordDocument 타입 데이터를 받은 상태.

 

 

  코드 설명 :  

  • 데이터 저장 (Create) :
      키워드로 검색하기 API 네트워킹을 통해 받은 KeywordDocument타입 데이터를 아규먼트로 받아서, 해당되는 장소의 정보를 저장
      - completion 블럭에는 저장 완료 후의 액션을 정의하면 됨
         (e.g 저장 완료 toast 메세지 띄우기, 해당 장소의 저장 상태 true로 바꾸기)

  • 데이터 받기 (Read) :
      로그인 된 유저의 uid에 저장된 'favorites'에 속한 문서들을 2번에서 생성한 FavoritePlace 모델의 생성자를 사용해 매핑하여 completion 블럭에 전달한다.
    - completion 블럭으로부터 받은 FavoritePlace 타입의 데이터를 사용하여 즐겨찾기 모아보는 view의 cell UI를 그릴 수 있다.

  • 데이터 삭제 (Delete) :
      어떤 장소의 즐겨찾기를 삭제할지 알아야하기 때문에 아규먼트로 placeID(장소 고유id)를 받는다. 그리고 placeId에 해당되는 다큐먼트에 속한 데이터를 지운다.

  • 해당 장소가 즐겨찾기에 속한 장소인지 구분하기 :
      placeID(장소 고유id)에 대한 다큐먼트에 데이터가 존재하는지 확인한다. 존재하는 경우 true를, 아닌 경우 false를 completion 블럭에 전달한다.
    - completion 블럭으로부터 받은 Bool 타입 데이터 활용해 해당 장소의 즐겨찾기 여부에 따른 저장 버튼의 UI와 로직을 분기처리 할 수 있다.
       (true인 경우 - 버튼 색칠, 클릭하면 장소 즐겨찾기 삭제됨  /    false인 경우 - 버튼 색칠 안함, 클릭하면 장소 즐겨찾기 추가됨)

 

 

3-2) 소스코드

struct FirestoreManager {
    static let shared = FirestoreManager()
    
    private init() { }
    
    /// 즐겨찾기에 장소 추가하기
    func addFavoritePlace(place: KeywordDocument, completion: @escaping () -> Void) {
        guard let placeName = place.placeName,
              let placeId = place.id,
              let address = place.roadAddressName,
              let longtitude = place.x,
              let latitude = place.y else { return }
        
        let uid = UserDefaultsManager.shared.getUserInfo().uid
        
        let data = ["placeName": placeName, // 장소명
                    "placeID": placeId, // 장소 고유 id
                    "address": address, // 도로명 주소
                    "coordinate": [     // 해당 장소의 위치 좌표
                        "longtitude": longtitude, // 위도
                        "latitude": latitude // 경도
                    ]] as [String : Any]
        
        Firestore.firestore().collection("favorites").document(uid).collection("favorites").document(placeId).setData(data) { error in
            if let _ = error {
                print("즐겨찾기 장소 추가 실패")
                return
            }
            completion()
        }
    }
    
    /// 즐겨찾기 저장된 장소들 데이터 받기
    func getFavoritePlaceList(completion: @escaping ([FavoritePlace]) -> Void) {
        let userUID = UserDefaultsManager.shared.getUserInfo().uid
        Firestore.firestore().collection("favorites").document(userUID).collection("favorites").getDocuments { snapshot, error in
            guard let data = snapshot else { return }
            let favorites = data.documents.map { FavoritePlace(dictionary: $0.data()) }
            completion(favorites)
        }
    }
    
    /// 즐겨찾기 취소
    func removeFavorite(placeID: String, completion: @escaping () -> Void) {
        let userUID = UserDefaultsManager.shared.getUserInfo().uid
        Firestore.firestore().collection("favorites").document(userUID).collection("favorites").document(placeID).delete { error in
            if let error = error {
                print("즐겨찾기 삭제 실패 : \(error)")
                return
            }
            print("삭제 성공")
            completion()
        }
    }
    
    /// 특정 장소가 즐겨찾기에 추가된 장소인지 확인 (즐겨찾기에 추가된 장소인 경우 true, 아닌 경우 false를 리턴)
    func checkIfIsFavoritePlace(placeID: String, completion: @escaping (Bool) -> Void) {
        let userUID = UserDefaultsManager.shared.getUserInfo().uid
        Firestore.firestore().collection("favorites").document(userUID).collection("favorites").document(placeID).getDocument { data, error in
            guard let data = data,
                  data.exists == true
                else {
                print("즐겨찾기에 해당되는 장소 아님")
                completion(false)
                return
            }
            print("즐겨찾기에 해당되는 장소")
            completion(true)
        }
    }
}

 

 

 

 

 

 

4. 실제 활용 코드

* 검색 결과를 보여주는 Map View에 대한 ViewModel *

/// 유저가 장소를 선택하면 해당 장소가 즐겨찾기 된 장소인지에 대한 상태를 담은 변수
   private var isFavoritePlace: Bool = false {
	// 즐겨찾기 버튼을 누르면 상태가 변함 - 버튼 색 UI 변경 필요
       didSet {
           configureButtonForFavoritePlace(isFavoritePlace)
       }
   }
    
/// 해당되는 장소가 즐겨찾기에 위치한 장소라면 true, 아니라면 false를 리턴하는 클로저
   var configureButtonForFavoritePlace: (Bool) -> Void = { _ in }

/// 유저가 지도상의 pin을 누르면 실행되는 함수 - id에 해당되는 장소를 return함
    func filterResults(with id: Int) -> KeywordDocument {
        let targetPlace = searchResults.filter({ $0.id == String(id) }).first
        self.targetPlace = targetPlace
        self.checkIfPlaceIsFavoritePlace(placeID: targetPlace!.id!) { [weak self] isFavoritePlace in
            self?.isFavoritePlace = isFavoritePlace
        }
        return targetPlace!
    }
	
/// 해당 장소가 즐겨찾기 된 장소인지 확인하는 메서드 (저장 된 상태라면 true, 아니라면 false를 return)
   private func checkIfPlaceIsFavoritePlace(placeID: String, completion: @escaping (Bool) -> Void) {
        // 현재 유저가 로그인 된 상태인지 먼저 확인
        guard let _ = Auth.auth().currentUser else { return }
        FirestoreManager.shared.checkIfIsFavoritePlace(placeID: placeID) { isFavoritePlace in
            if !isFavoritePlace {
                completion(false)
                return
            }
            if isFavoritePlace {
                completion(true)
                return
            }
        }
    }
    
    
  /// 즐겨찾기에 추가 된 경우 : 즐겨찾기 해제 / 즐겨찾기 추가 안된 경우 : 즐겨찾기 추가
    func changeFavoritePlaceStatus() {
        guard let targetPlace = targetPlace,
              let placeID = targetPlace.id else { return }
        if isFavoritePlace {
            FirestoreManager.shared.removeFavorite(placeID: placeID) { [weak self] in
                self?.isFavoritePlace = false
            }
        } else {
            FirestoreManager.shared.addFavoritePlace(place: targetPlace) { [weak self] in
                self?.isFavoritePlace = true
            }
        }
    }

 

 

 

* 검색 결과를 보여주는 MapView ViewController *

  override func viewDidLoad() {
  	// 해당되는 장소의 즐겨찾기 여부에 따라 UI 세팅하도록 클로저 실행내용 정의
      viewModel.needToSetTargetPlaceUI = { [weak self] in
           self?.configureUIwithDetailedData()
       }
   }
   

  /// 즐겨찾기 버튼에 대한 selector 함수
   @objc private func saveButtonTapped() {
     /// 현재 로그인 상태 여부 체크 후, 로그인 안 된 경우 toast 메세지 띄우기
      guard let _ = Auth.auth().currentUser else {
          view.makeToast(message: "즐겨찾기 기능은 로그인 유저에게만 제공됩니다.")
          return
      }
      print("Firebase에 장소 저장")
      self.saveButton.setImage(UIImage(named: "save.filled")?
          .withRenderingMode(.alwaysTemplate)
          .resizeImage(targetSize: CGSize(width: 25, height: 25)), for: .normal)        
      // 즐겨찾기 상태 변경
      viewModel.changeFavoritePlaceStatus()
    }
    
    
  /// 즐겨찾기 상태를 아규먼트로 받아서 UI 분기처리
   private func configureButtonUIforFavoritePlace(_ isFavoritePlace: Bool) {
     if !isFavoritePlace {
          self.saveButton.setImage(UIImage(named: "save")?
               .withRenderingMode(.alwaysTemplate)
               .resizeImage(targetSize: CGSize(width: 25, height: 25)), for: .normal)
       }  else {
            self.saveButton.setImage(UIImage(named: "save.filled")?
                .withRenderingMode(.alwaysTemplate)
                .resizeImage(targetSize: CGSize(width: 25, height: 25)), for: .normal)
        }
    }

 

 

 

 

 

 

5. 결과물

 

좌: 로그인 안 된 경우     /      우 : 유저 로그인 된 경우

 

끝 - 

 

술술 되서 재밌구만... 버튼 색칠되는 색만 수정해야겠다.

728x90