https://explorer89.tistory.com/305
Firestore에 새로운 트윗 저장 + Combine 비동기 처리
https://explorer89.tistory.com/304 Firestore 데이터베이스에서 문서 업데이트, Combine 비동기작업https://explorer89.tistory.com/303 Firebase - Combine 프레임워크 사용 비동기 작업 처리 (저장) map VS flatmaphttps://explorer
explorer89.tistory.com
func collectionTweets(retreiveTweets forUserID: String) -> AnyPublisher<[Tweet], Error> {
db.collection(tweetsPath).whereField("authorID", isEqualTo: forUserID)
.getDocuments()
.tryMap(\.documents)
.tryMap { snapshots in
try snapshots.map({
try $0.data(as: Tweet.self)
})
}
.eraseToAnyPublisher()
}
이 함수는 Firestore에서 특정 사용자의 트윗 데이터를 가져와서 Tweet 객체 배열로 변환하는 기능을 구현한 것입니다.
func collectionTweets(retreiveTweets forUserID: String) -> AnyPublisher<[Tweet], Error>
- 파라미터: forUserID는 트윗 작성자의 authorID를 지정하기 위한 문자열입니다.
- 반환값: Combine 프레임워크의 AnyPublisher<[Tweet], Error> 타입으로 반환됩니다. 이는 Tweet 객체 배열을 발행하거나 에러를 발행할 수 있는 Publisher입니다.
db.collection(tweetsPath).whereField("authorID", isEqualTo: forUserID)
.getDocuments()
- db.collection(tweetsPath):
- Firestore 데이터베이스에서 tweetsPath 경로의 컬렉션을 참조합니다. 예를 들어, tweetsPath가 "tweets"라면 트윗 데이터를 저장하는 컬렉션입니다.
- .whereField("authorID", isEqualTo: forUserID):
- Firestore에서 authorID 필드가 forUserID와 같은 문서만 필터링합니다. 이 필드가 특정 사용자의 트윗을 식별합니다.
- .getDocuments():
- Firestore에서 조건에 맞는 모든 문서를 가져옵니다. 반환값은 QuerySnapshot입니다.
.tryMap(\.documents)
- QuerySnapshot에서 .documents 속성을 사용하여 각 문서를 나타내는 배열([QueryDocumentSnapshot])을 가져옵니다.
- tryMap은 데이터를 변환하며, 변환 중에 에러를 발생시킬 수 있습니다. 여기서는 에러가 발생할 가능성이 없지만, Swift에서 KeyPath(\.)를 사용한 간단한 변환에도 tryMap이 허용됩니다.
.tryMap { snapshots in
try snapshots.map({
try $0.data(as: Tweet.self)
})
}
- 이 부분은 QueryDocumentSnapshot 객체 배열(snapshots)을 실제 Tweet 객체 배열로 변환하는 과정입니다.
내부 동작
- snapshots.map:
- 각 QueryDocumentSnapshot 객체를 순회하며 변환합니다.
- $0.data(as: Tweet.self):
- Firestore SDK의 data(as:) 메서드를 사용하여 Firestore 문서를 Tweet 타입으로 디코딩합니다.
- Tweet은 Firestore 문서의 JSON 데이터를 Swift 타입으로 변환하기 위해 Codable 프로토콜을 채택했어야 합니다.
- try 사용 이유:
- data(as:) 메서드는 디코딩 중 에러가 발생할 수 있습니다. 예를 들어:
- JSON 구조가 Tweet 타입과 맞지 않는 경우.
- Firestore 데이터에 누락된 필드가 있는 경우.
- 이 경우 함수가 실패(throw)하며 Combine의 에러 흐름으로 전달됩니다.
- data(as:) 메서드는 디코딩 중 에러가 발생할 수 있습니다. 예를 들어:
🔥 왜 tryMap을 두 번 사용했나요?
db.collection(tweetsPath).whereField("authorID", isEqualTo: forUserID)
.getDocuments()
.tryMap(\.documents) // (1) QuerySnapshot에서 문서 배열([QueryDocumentSnapshot]) 추출
.tryMap { snapshots in
try snapshots.map { try $0.data(as: Tweet.self) } // (2) 배열의 각 문서를 Tweet 객체로 디코딩
}
.eraseToAnyPublisher()
첫 번째 tryMap:
- Input: QuerySnapshot
- Output: [QueryDocumentSnapshot] (문서 배열)
- 이 단계는 쿼리 결과(QuerySnapshot)에서 documents 속성을 추출하여, 해당 문서들의 배열을 반환합니다.
두 번째 tryMap:
- Input: [QueryDocumentSnapshot] (문서 배열)
- Output: [Tweet] (트윗 객체 배열)
- 이 단계에서는 문서 배열([QueryDocumentSnapshot])을 순회하며 각 문서를 Tweet 객체로 디코딩합니다.
이유: 단계별 작업의 가독성과 디버깅 용이성
- 각 tryMap은 서로 다른 작업을 수행합니다:
- 첫 번째는 Firestore의 쿼리 결과를 정리하여 필요한 데이터 구조(문서 배열)로 변환.
- 두 번째는 해당 데이터를 앱에서 사용할 객체 배열로 변환.
- 단계별로 분리하면:
- 각 단계의 역할이 명확해집니다.
- 디버깅이 쉬워집니다. 예를 들어, 첫 번째 tryMap에서 실패하면 QuerySnapshot에서 문서 추출에 문제가 있다는 것을 바로 알 수 있습니다.
한 번에 처리하면 안 되나요?
db.collection(tweetsPath).whereField("authorID", isEqualTo: forUserID)
.getDocuments()
.tryMap { querySnapshot in
try querySnapshot.documents.map { try $0.data(as: Tweet.self) }
}
.eraseToAnyPublisher()
차이점: 가독성과 유지보수성
- 한 번에 처리한 경우:
- 코드가 짧아져 간결합니다.
- 그러나 여러 작업이 한 번에 이루어지므로, 쿼리 결과에서 문제가 생겼는지, 디코딩 과정에서 문제가 생겼는지 구분하기 어렵습니다.
- 두 번으로 나눈 경우:
- 코드는 다소 길어지지만, 각 단계의 역할이 명확합니다.
- 쿼리 결과를 문서 배열로 변환하는 작업과, 그 문서를 객체로 디코딩하는 작업을 분리하여 디버깅과 유지보수에 유리합니다.
왜 첫 번째 단계에서 배열로 만든 건가요?
Firestore의 QuerySnapshot의 documents 속성은 문서 배열([QueryDocumentSnapshot])을 나타냅니다.
db.collection(tweetsPath).whereField("authorID", isEqualTo: forUserID)
.getDocuments()
.tryMap(\.documents)
이 단계에서 documents를 추출하므로 결과가 이미 배열입니다.
그렇다면, 배열이 아닌 상태로 처리할 수 없나요?
Firestore의 데이터는 쿼리 결과로 반환되며, 기본적으로 배열 형식의 문서([QueryDocumentSnapshot])입니다. Firestore 쿼리는 항상 여러 문서를 반환할 가능성을 고려합니다.
- Firestore의 기본 구조:
- Firestore는 문서 중심 데이터베이스로, 컬렉션 내에 여러 문서를 저장합니다.
- 따라서, 쿼리 결과(QuerySnapshot)도 여러 문서의 배열입니다.
- 배열이 아닌 상태로 처리하려면:
- 쿼리 결과가 항상 단일 문서일 것을 보장해야 합니다.
- 하지만 Firestore의 쿼리는 다수의 결과를 반환할 수 있으므로, 배열로 처리하는 것이 일반적입니다.
func fetchTweets() {
guard let userID = user?.id else { return }
DatabaseManager.shared.collectionTweets(retreiveTweets: userID)
.sink { [weak self] completion in
if case .failure(let error) = completion {
self?.error = error.localizedDescription
}
} receiveValue: { [weak self] retreivedTweets in
self?.tweets = retreivedTweets
}
.store(in: &subscription)
}
이 함수는 Combine을 사용하여 Firestore에서 특정 사용자의 트윗 데이터를 비동기로 가져오고, 가져온 데이터를 tweets라는 프로퍼티에 저장하는 역할을 합니다.
DatabaseManager.shared.collectionTweets(retreiveTweets: userID)
- DatabaseManager.shared는 데이터베이스와의 상호작용을 담당하는 싱글턴 객체로 보입니다.
- collectionTweets(retreiveTweets:)는 이전에 설명했던 함수입니다. 특정 사용자(userID)의 트윗 데이터를 가져옵니다.
- 이 메서드는 Combine의 AnyPublisher<[Tweet], Error>를 반환하므로, 비동기로 데이터를 구독할 수 있습니다.
.sink { [weak self] completion in
if case .failure(let error) = completion {
self?.error = error.localizedDescription
}
} receiveValue: { [weak self] retreivedTweets in
self?.tweets = retreivedTweets
}
- sink 메서드:
- sink는 Publisher에서 발행하는 데이터를 받아 처리하는 Combine 메서드입니다.
- 두 개의 클로저를 전달합니다:
- 첫 번째는 완료 이벤트(completion)를 처리합니다.
- 두 번째는 성공적으로 발행된 값을 처리합니다.
- completion 클로저:
- 이 클로저는 Publisher의 성공 또는 실패 여부를 확인합니다.
- 에러가 발생한 경우(.failure), 에러 메시지를 error 프로퍼티에 저장합니다. 이는 UI에 에러 메시지를 표시하거나 디버깅에 사용할 수 있습니다.
- 성공(.finished)인 경우, 별도의 처리를 하지 않습니다.
- receiveValue 클로저:
- Firestore에서 성공적으로 데이터를 가져왔을 때 호출됩니다.
- retreivedTweets는 Tweet 객체 배열입니다.
- 이 데이터를 tweets 프로퍼티에 저장합니다.
- [weak self] 사용 이유:
- 클로저가 self를 캡처하여 강한 참조 순환(Circular Reference)을 유발하는 것을 방지합니다.
- weak를 사용하면 self가 해제될 경우 클로저에서 참조를 끊습니다.
🔥 만약 네트워크 연결 상태에 따라 추가적인 처리(예: 로딩 상태 표시)가 필요하다면, 이를 위한 상태 관리 로직을 추가할 수 있습니다. 예를 들어, isLoading 같은 변수를 두고 Combine 체인에서 로딩 상태를 관리하는 방법을 고려할 수 있습니다.
@Published var isLoading = false
func fetchTweets() {
guard let userID = user?.id else { return }
isLoading = true
DatabaseManager.shared.collectionTweets(retreiveTweets: userID)
.handleEvents(receiveCompletion: { [weak self] _ in
self?.isLoading = false
})
.sink { ... }
.store(in: &subscription)
}
func retreiveUser() {
guard let id = Auth.auth().currentUser?.uid else { return }
DatabaseManager.shared.collectionUsers(retreive: id)
.handleEvents(receiveOutput: { [weak self] user in
self?.user = user
self?.fetchTweets() // fetchTweets 는 나중에 -> 이건 트윗을 불러오기 위함임 사용자 정보 얻어오고 난 후에 할 것
})
.sink { [weak self] completion in
if case .failure(let error) = completion {
self?.error = error.localizedDescription
}
} receiveValue: { [weak self] user in
self?.user = user
}
.store(in: &subscription)
}
이 코드에서 fetchTweets를 handleEvents 내에서 호출하고, self.user = user를 두 번 호출한 이유는 다음과 같은 설계 의도 때문입니다.
.handleEvents(receiveOutput: { [weak self] user in
self?.user = user
self?.fetchTweets()
})
handleEvents의 역할
- handleEvents는 Publisher의 데이터 흐름 중간에 개입하여 부수 효과(Side Effects)를 추가하는 데 사용됩니다.
- 실제 데이터를 변경하거나 반환하는 대신, 흐름 중간에서 어떤 작업을 수행할 때 사용됩니다.
- 여기서 receiveOutput 클로저는 Firestore에서 user 객체를 가져온 직후 호출됩니다.
fetchTweets 호출 의도
- fetchTweets는 사용자 정보를 기반으로 트윗 데이터를 가져오는 함수입니다.
- fetchTweets를 호출하려면 사용자의 ID와 같은 정보가 필요합니다. 이 정보는 retreiveUser 함수에서 얻어오고 나서야 사용할 수 있습니다.
- 따라서, 사용자 정보를 self.user에 저장한 후, 추가적으로 사용자의 트윗 데이터를 가져오도록 fetchTweets를 호출합니다.
왜 handleEvents를 사용했을까?
- handleEvents는 데이터 흐름을 방해하지 않고 추가 작업을 수행할 수 있습니다.
- sink 내부에서 fetchTweets를 호출하지 않은 이유는:
- sink는 데이터의 최종 소비자이며, 추가적인 데이터 흐름 로직을 추가하는 데는 적합하지 않습니다.
- handleEvents를 사용하면 "데이터가 전달된 시점"에서 부수 작업을 명확히 실행할 수 있어 코드 가독성이 높아집니다.
self.user = user를 두 번 호출한 이유
- handleEvents에서 self.user를 설정하는 이유는 fetchTweets를 호출하기 위해 self.user에 사용자 정보를 미리 저장해야 하기 때문입니다.
- fetchTweets는 내부적으로 self.user?.id를 참조하므로, fetchTweets 호출 전에 self.user를 업데이트해야 합니다.
.receiveValue: { [weak self] user in
self?.user = user
}
- receiveValue는 Publisher가 발행한 데이터를 최종적으로 소비하는 클로저입니다.
- 이곳에서도 self.user = user를 호출하여 데이터를 저장합니다.
- 이렇게 두 번 호출하는 이유는, handleEvents는 부수 작업만 처리할 뿐이며, 데이터 소비의 책임은 sink의 receiveValue에 있기 때문입니다.
fetchTweets 호출을 handleEvents가 아니라 receiveValue에서 처리할 수 있습니다. 다만, 두 접근 방식에는 설계적인 차이가 있고 각각의 장단점이 있습니다.
.handleEvents(receiveOutput: { [weak self] user in
self?.user = user
self?.fetchTweets()
})
장점
- 명확한 데이터 흐름 분리
- handleEvents는 데이터를 수신한 직후에 발생해야 하는 부수적인 작업을 처리하기에 적합합니다.
- fetchTweets 호출은 사용자 데이터를 수신한 후 추가적으로 이루어지는 작업이므로, 이를 handleEvents에서 수행하면 데이터 흐름과 부수 효과가 분리됩니다.
- 조기 실행
- handleEvents는 데이터가 sink로 도달하기 전에 실행됩니다.
- 이는 데이터를 소비하는 부분 (receiveValue)과 관계없이 작업을 미리 실행할 수 있게 합니다.
- 유지보수성
- sink 내부에 fetchTweets 로직이 포함되지 않으므로, sink는 데이터 소비 작업에만 집중할 수 있습니다.
- 코드의 역할이 명확히 분리됩니다.
단점
- handleEvents는 부수 효과를 처리하는 것이 주목적이므로, 데이터의 소비를 처리하는 데 적합하지 않습니다.
- 만약 user 데이터가 이후 sink 단계에서 실패 처리되면, 이미 실행된 fetchTweets가 의미를 잃을 수 있습니다.
.receiveValue: { [weak self] user in
self?.user = user
self?.fetchTweets()
}
장점
- 더 직관적인 흐름
- 데이터를 최종적으로 소비하는 곳(receiveValue)에서 모든 후속 작업(fetchTweets)을 처리하므로, "데이터를 받았을 때 실행되는 작업"이라는 의도가 명확합니다.
- 조건부 실행
- receiveValue는 데이터가 성공적으로 전달된 경우에만 실행됩니다.
- 실패(completion에서 failure)한 경우에는 호출되지 않으므로, 불필요한 작업(fetchTweets 호출)을 방지할 수 있습니다.
단점
- 코드 집중도 저하
- receiveValue 내에 추가 작업(fetchTweets)이 포함되므로, 데이터 소비(self.user = user)와 추가 로직(fetchTweets)이 섞이게 됩니다.
- 이는 데이터 흐름을 분리하기 어려워질 수 있습니다.
- 순서 의존성 증가
- receiveValue에서 호출하는 경우, 이 작업이 sink의 다른 부분과 밀접히 연결됩니다.
- 향후 코드 수정 시 receiveValue의 내용이 복잡해질 가능성이 있습니다.
선택 기준
handleEvents에서 호출이 적합한 경우
- 부수 효과를 명확히 분리하고 싶을 때: fetchTweets는 데이터의 소비와는 별개로 추가적인 작업이므로, 이를 분리하고 싶을 때 handleEvents를 사용합니다.
- 데이터 흐름을 유연하게 처리하고 싶을 때: fetchTweets가 반드시 성공한 데이터가 아니라도 실행되어야 한다면 handleEvents가 적합합니다.
receiveValue에서 호출이 적합한 경우
- 데이터 소비와 추가 작업이 밀접한 관계일 때: fetchTweets가 반드시 성공적인 데이터 소비 이후에만 실행되어야 한다면, receiveValue에서 호출하는 것이 더 적합합니다.
- 단순한 흐름: user 데이터와 관련된 모든 작업을 한 곳에서 처리하고 싶다면 receiveValue가 직관적입니다.
'UIKIT' 카테고리의 다른 글
ViewController 내에 init으로 viewModel 할당하는 이유 (0) | 2025.01.10 |
---|---|
유저를 검색하기 위한 함수 + Combine (0) | 2025.01.10 |
Firestore에 새로운 트윗 저장 + Combine 비동기 처리 (0) | 2025.01.09 |
Firestore 데이터베이스에서 문서 업데이트, Combine 비동기작업 (0) | 2025.01.09 |
Firebase - Combine 프레임워크 사용 비동기 작업 처리 (저장) map VS flatmap (0) | 2025.01.09 |