낙관적 업데이트(Optimistic Update)
는 사용자가 어떤 작업을 수행할 때 그 작업이 성공적으로 완료될 것으로 가정하고 먼저 화면을 갱신하는 방식입니다.
즉, 사용자의 요청에 대한 응답을 기다리지 않고, 작업이 완료될 것이라고 예측하고 화면을 업데이트하는 것을 말합니다.
낙관적 업데이트는 사용자 경험을 향상시키는 데 도움이 되며, 작업이 비동기적으로 처리되는 경우 사용자의 대기 시간을 최소화할 수 있습니다. 주로 다음과 같은 상황에서 활용될 수 있습니다:
빠른 피드백 제공: 사용자가 작업을 수행한 후 서버 응답을 기다리지 않고 즉시 화면을 갱신함으로써 사용자에게 작업의 진행 상황을 빠르게 보여줄 수 있습니다.
의사소통 부담 감소: 사용자가 작업을 요청하면 서버 응답을 기다리지 않고 화면을 업데이트하면 되기 때문에 서버와의 의사소통 부담이 줄어듭니다.
작업 충돌 방지: 여러 사용자가 동시에 작업하는 경우, 낙관적 업데이트를 통해 충돌이 발생하는 것을 방지하고 서버에서 충돌을 처리하는 방식을 채택할 수 있습니다.
사용자 경험 개선: 사용자는 화면이 즉시 업데이트되어 작업이 성공한 것처럼 느껴지므로 부드러운 사용자 경험을 제공합니다.
이전에 작성한 블로그에서처럼 사용자가 Todo를 생성할 때 Firestore에 저장하는 작업 수행 결과를 기다리지 않고, 작업이 완료될 것이라고 예측하고 화면을 먼저 업데이트하여 사용자 경험성을 높이는 방법(낙관적 업데이트)을 사용했습니다.
(실패 케이스를 생각하여 최대 3번까지 재전송하도록 구현)
하지만!!! 디바이스가 네트워크 연결이 끊어진 상황에서 Todo를 생성하게 되면 실패 케이스를 생각하여 최대 3번까지 재전송하도록 만든 부분도 실패하여 로컬 데이터와 Firestore에 저장된 데이터 간 '데이터 동기화 문제'가 생기게 됩니다.
따라서 위의 블로그에서 작성한 낙관적 업데이트는 완벽하지 않은 코드였습니다.
이를 해결하기 위해 많은 생각을 해본 결과 좋은 방법이 떠올라서 구현해 보았습니다.
아래의 코드를 보면 Firestore에 저장을 실패할 시 saveTempData(id: UUID)
메서드를 호출하여 해당 Todo의 UUID를 저장합니다.
final class FirestoreManager {
static let shared = FirestoreManager()
private init() { }
private let dataBase = Firestore.firestore().collection("User")
func saveTodo(data: Todo) async {
guard let uid = FirebaseManager.shared.getUID() else { return }
var retryCount = 0
while retryCount < 3 {
do {
try await dataBase.document(uid)
.collection(data.date).document(data.id.uuidString).setData(
[
"id": data.id.uuidString,
"content": data.content,
"date": data.date,
"priority": data.priority,
"done": data.done,
"alarm": data.alarm ?? "nil"
]
)
print("Document successfully added!")
return
} catch {
print("Error adding document: \(error.localizedDescription)")
retryCount += 1
}
}
print("Max retry count reached, document could not be added.")
saveTempData(id: data.id)
}
}
private extension FirestoreManager {
func saveTempData(id: UUID) {
if CoreDataManager.shared.saveTempTodoData(id: id) {
print("Temp data successfully saved")
} else {
print("temp 저장 실패")
}
}
}
extension CoreDataManager {
func saveTempTodoData(id: UUID) -> Bool {
guard let context = CoreDataManager.context else { return false }
guard let entity = NSEntityDescription.entity(
forEntityName: CoreDataManager.tempTodoEntityName, in: context
) else { return false }
let object = NSManagedObject(entity: entity, insertInto: context)
object.setValue(id, forKey: Todokeys.uuid.key)
do {
try context.save()
return true
} catch {
print("error: \(error.localizedDescription)")
return false
}
}
}
// Network Monitoring 시작
func startMonitoring() {
monitor.start(queue: .global())
monitor.pathUpdateHandler = { [weak self] path in
guard let self = self else { return }
self.isConnected = path.status == .satisfied
self.getConnectionType(path)
if self.isConnected == true {
print("인터넷 연결!")
// 네트워크에 연결이 된다면 Firestore에 동기화 진행
} else {
print("인터넷 연결 끊김!")
}
}
}
startMonitoring()
메서드를 application(_:didFinishLaunchingWithOptions:)
메서드 내부에서 호출하여 네트워크 연결 상태를 모니터링하다가 네트워크에 연결이 된다면 동기화에 실패한 데이터들을 저장한 TempTodo의 UUID로 검색 후 saveTodo(data: Todo)
메서드를 사용하여 동기화를 진행합니다.Todo를 디바이스에 저장하는 메서드 실행
Firestore에 저장하는 메서드 실행
Firestore에 저장을 실패하면 해당 Todo 데이터를 TempTodo에 저장
디바이스가 네트워크에 연결되면 Firestore에 데이터 동기화를 진행
동기화에 성공한 데이터들은 TempTodo에서 삭제하고, 실패한 데이터들은 보존하여 다음 네트워크 연결 시 다시 동기화를 진행하도록 만든다.
⭐️ 동기화가 안된 데이터를 CRUD 하더라도 이미 TempTodo에는 해당 데이터의 UUID가 들어가 있으므로 동기화 진행 시 해당 데이터의 최종 상태만을 동기화할 수 있다.
Firestore에 저장되지 않은 Todo의 UUID만을 따로 저장했기 때문에 CRUD를 진행해도 Firestore에는 마지막 상태만 최종적으로 업데이트할 수 있다. (동기화를 진행할 때 TempTodo에 저장된 UUID를 이용하여 해당 Todo 데이터의 현재 상태를 가져오기 때문에 중간에 CRUD를 아무리 많이 한다 하더라도 Firestore에는 최종 상태만 동기화한다.)
이렇게 열심히 생각하고 구현까지 한 후 구현한 코드를 주석 처리하고 기본적으로 네트워크 연결이 해제되었을 때 Firestore에 데이터 동기화가 안된다는 것을 확인해 보려고 했는데 웬걸? 네트워크를 연결하고 데이터를 확인했을 때 데이터가 동기화되어 있었다. 이게 뭐야... 😱
공식 문서를 다시 확인해 보니까 Firestore의 주요 기능으로 오프라인 지원 기능이 있었습니다.
앞으로 공식 문서를 꼼꼼히 읽어보는 습관을 들여야겠다는 것을 다시금 깨달았습니다 ㅠㅠ
그래도 깊이 생각하고 구현해 보는 경험을 할 수 있어서 좋았다~
Firestore 주요 기능
Firestore 문서