일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 | 31 |
- requirenativecomponent
- 파노라마 뷰
- React Native
- privacyinfo.plist
- launch screen
- 리액트
- 뷰 정체성
- panorama view
- ios
- 구조적 정체성
- 3b52.1
- data driven construct
- ssot
- 360도 이미지 뷰어
- 앱 성능 개선
- react
- react-native-fast-image
- 라이브러리 없이
- 네이티브
- 360도 뷰어
- 리액트 네이티브
- SwiftUI
- React-Native
- native
- 스켈레톤 통합
- Android
- 360도 이미지
- 뷰 생명주기
- 명시적 정체성
- launchscreen
- Today
- Total
Neoself의 기술 블로그
CoreData를 활용한 오프라인 동기화 시스템 구축하기 본문
오늘은 제가 출시한TyTE라는Todo 관리 앱에 오프라인 동기화 시스템을 도입하면서 배운 점들을 공유하고자 합니다. 특히 데이터 무결성 보장과 불변성 원칙을 지키면서도 사용자 경험을 해치지 않는 구조를 설계하는 과정에서의 고민들을 다뤄보겠습니다.
0. 기존 구현사항
기존 TyTE의 모든 비즈니스 로직은 모두 네트워크 통신에 의존하고 있었습니다. 따라서, 네트워크 연결이 불안정하거나 없는 상황에서는 사용자가 할 일을 수정하거나 삭제하는 등의 기본적인 CRUD 작업도 수행할 수 없었습니다.
그러던 중, 일정관리를 보조하는 Notion 앱에서는 오프라인 상황에서도 일정 수정 및 이동을 지원하는 것을 보게 되었습니다.
Notion을 사용하면서 오프라인 상황에서도 끊김 없이 작업할 수 있는 UX가 얼마나 중요한지 깨달았고, 이를 TyTE에도 구현해보기로 했습니다.
1. 동기화 시스템 로직 구상
TyTE에서 다루는 핵심 데이터 모델(도메인)들은 아래와 같이 정리해볼 수 있습니다.
1. Todo : 사용자가 추가하고, 완료체크할 수 있는 할일 모델
2. Tag : Todo에 1:1로 연결되며, 사용자가 직접 지정 및 해제할 수 있는 태그 모델
3. User & FriendRequest : 친구 추가요청 상태(수락됨, 수락대기중 ...)를 관리하는 FriendRequest 모델 및 친구의 이메일과 닉네임이 담긴 User 모델
4. DailyStat : 각 날짜별 사용자가 완료한 할일들의 Tag 및 각종 메타데이터들을 바탕으로 계산되는 각종 수치들을 저장합니다. 생산성 다이아몬트 UI와 날짜별 조언을 구성하기 위해 주로 사용됩니다.
1.1 동기화 대상에서 불필요한 데이터 모델 소거
FriendRequest, User
현재 TyTE의 소셜 탭에서는 친구 사용자의 월간 할일들 및 생산성 다이아몬드들을 확인할 수 있습니다. 이러한 정보들은 사용자 본인이 아닌친구의 상호작용이 데이터 변경 트리거가 되기 때문에 로컬 저장소 저장 및 갱신만으로는 올바른 정보를 보여줄 수 없었습니다. 따라서, 해당 탭에서의 기능들은 모두 네트워크 통신을 전제로 로직을 설계하였으며, 로컬 저장소 동기화 로직 연결 대상에서 제외했습니다.
1.2 Core Data를 사용하는 내부 메서드 리스트업
동기화 대상 모델을 Tag와 Todo, DailyStat로 추린 이후, CoreData 프레임워크를 사용해 로컬 저장소에 직접 접근하는 내부 CRUD 메서드들을 아래와 같이 리스트업해보았습니다.
// Todo CRUD
func readTodosFromStore(for date: String) // Read
func saveTodoToStore(_ todo: Todo) // Update(단일)
func saveTodosToStore(_ todos: [Todo]) // Update(복수)
func deleteTodoToStore(_ id: String) // Delete
// Tag CRUD
func readTagsFromStore() // Read
func saveTagToStore(_ tag: Tag) // Update(단일)
func saveTagsToStore(_ tags: [Tag]) // Update(복수)
func deleteTagToStore(_ id: String) // Delete
// DailyStat CRUD
func readDailyStatsFromStore(for yearMonth: String) // Read
func saveDailyStatToStore(_ stat: DailyStat) // Update(단일)
func saveDailyStatsToStore(_ stats: [DailyStat]) // Update(복수)
눈치가 빠르신 분이라면, CRUD중 C 메서드의 행방을 궁금해하시는 분들이 계실 것 같은데요... 오프라인 환경에서 Entity를 생성하게 될 경우, 자체적으로 임시 ID를 할당시켜줘야했기 때문에, 동기화 시점에서 서버 ID와 충돌할 수 있어, Create는 오프라인 상황에서 동작하지 않도록 사전에 막았습니다.
1.3 동기화가 동반되어야 하는 메서드 리스트업
그 이후에는, 위 내부 CRUD 메서드들을 활용해 Presentation Layer에서 호출하는 메서드 플로우를 pseudo 코드로 구상해보았습니다.
// Todo 생성
func addTodo(text: String) {
createTodo_Server(text) // 네트워크 api 호출
saveTodo_Store( {서버에서 가져온 Todo} ) // 생성 후 서버로부터 반환받은 Todo 객체 로컬저장소에 저장
getTodos_Store() // 생성한 할일이 포함된 투두 리스트 로컬 저장소에서 read
getDailyStats_Server() // 할일 생성으로 변경된 dailyStat들 서버로부터 fetch
saveDailyStats_Store( {서버에서 가져온 DailyStat들} ) // 새로 fetch한 dailyStat들 로컬저장소에 저장
}
// 날짜별 Todo 리스트 및 월간 DailyStat들 조회
func getData() {
getTodos_Store() // 선택한 날짜에 대한 할일들 로컬 저장소에서 read
getTodos_Server() // 선택한 날짜에 대한 최신 Todo 서버에서 fetch
getDailyStats_Store() // 선택한 날짜에 대한 월간 DailyStat들 로컬 저장소에서 read
getDailyStats_Server() // 선택한 날짜에 대한 최신 월간 DailyStat들 서버에서 fetch
}
// Todo 토글
func toggleTodo( {토글처리한 Todo 객체} ) {
saveTodo_Store( {토글처리한 Todo 객체} ) // 토글 낙관적 업데이트 후, 로컬 저장소에 저장
if networkConnected { // 현재시점 네트워크 연결상태 확인
updateTodo_Server( {토글처리한 Todo 객체} ) // 토글 처리 서버에 반영
} else {
saveCommand(.updateTodo( {토글처리한 Todo 객체} ) // 네트워크 연결될때까지 작업 임시저장
}
getDailyStat_Server() // 할일 수정으로 인해 변경된 DailyStat 서버에서 fetch
}
// Todo 수정
func editTodo( {수정된 Todo 객체} ) {
saveTodo_Store( {수정된 Todo 객체} ) // 수정된 투두 로컬 저장소에 저장
if networkConnected { // 현재시점 네트워크 연결상태 확인
updateTodo_Server( {수정된 Todo 객체} ) // 할일 수정처리 서버에 반영
} else {
saveCommand(.updateTodo( {수정된 Todo 객체} ) // 네트워크 연결될때까지 작업 임시저장
}
getDailyStat_Server() // 할일 수정으로 인해 변경된 DailyStat 서버에서 fetch
}
// Todo 삭제
func deleteTodo({삭제할 Todo id값}) {
deleteTodo_Store( {삭제할 Todo id값} ) // 낙관적 업데이트 후, 로컬 저장소 내부 할일 삭제
if networkConnected { // 현재시점 네트워크 연결상태 확인
deleteTodo_Server( {삭제할 Todo id값} ) // 삭제 처리 서버에 반영
} else {
saveCommand(.deleteTodo( {삭제할 Todo id값} ) // 네트워크 연결될때까지 작업 임시저장
}
getDailyStat_Server() // 할일 수정으로 인해 변경된 DailyStat 서버에서 fetch
}
Create(addTodo)
createTodo 메서드의 경우 네트워크 통신이 가능하다는 것을 전제로 실행되어야 합니다. 때문에, 나머지 RUD 메서드들과 달리 서버 통신을 먼저 진행한 후, 서버로부터 반환받은 데이터를 로컬저장소에 반영하는 순서로 로직 순서를 구성하였습니다.
Read(getData)
기존에는 날짜변경과 같이 할일과 dailyStat에 대한 변경이 필요할 경우, 매번 서버로부터 데이터를 반환받았었습니다. 하지만, 데이터 자체의 변경을 트리거하는 상호작용이 아니기 때문에, 기존 로컬 저장소에 있는 데이터들을 read하는 로직들로만 구성할 수 있게 되었습니다.
Update(toggleTodo, editTodo) & Delete(deleteTodo)
Update와 Delete 메서드는 모두 기존 로컬저장소에 있는 데이터들의 제어로 동작이 가능하다는 공통점을 갖고 있습니다. 때문에, 수정사항을 먼저 로컬저장소에 반영하는 작업을 선행해, 네트워크연결 상태와 상관없이 일관된 UI 피드백을 사용자에게 전달하고자 했습니다.
그 이후 네트워크 통신이 가능한 상태이면, 변경사항을 서버에 반영하고, 통신이 불가한 상태면 변경사항을 또다른 Core Data Entity로 임시 저장하게끔 로직을 구성했습니다. 이후 임시저장된 작업들을 처리하는 방식은 밑에서 자세히 설명드리겠습니다.
앞서 말씀드렸듯, DailyStat 데이터 모델은 Todo의 상태에 따라 변경되는 특징을 갖고 있습니다. 때문에, 메서드가 종료되기 전 변경된 DailyStat을 서버에서 fetch하여 뷰에 반영하는 메서드를 일괄 호출해주었습니다.
2. 구조적 고민
2.1 복잡한 Entity 관계에서의 데이터 정합성 문제
로컬저장소로 동기화하기 제일 까다로운 데이터 모델은 DailyStat 모델이였습니다. 생산성 다이아몬드 UI를 구성하기 위해 TagStat 객체를 tagStat 속성과 연결해 완료처리한 태그들의 분포를 저장했고, tagStat 객체 또한 마찬가지로 실제 태그 객체를 참조해 데이터를 구성하고 있습니다.
struct DailyStat {
let tagStats: [TagStat] // 해당 날짜의 태그 분포
// ... 기타 속성들
}
struct TagStat {
let tag: Tag // 실제 태그 객체에 대한 참조
let count: Int // 해당 태그의 사용 횟수
}
// 실제 태그 객체
struct Tag: Codable, Identifiable, Equatable {
let id: String
var name: String
let color: String
}
이러한 계층적 데이터 구조를 로컬 저장소에서도 동일하게 반영하려다 보니 하나의 작업이 여러 엔티티의 연쇄적인 업데이트를 필요로 하게 되었습니다.
private func saveDailyStatToStore(_ stat: DailyStat) throws {
let request = DailyStatEntity.fetchRequest()
request.predicate = NSPredicate(
format: "date == %@ AND userId == %@",
stat.date,
stat.userId
)
// 기존 데이터가 있으면 삭제
if let existingEntity = try coreDataStack.context.fetch(request).first {
coreDataStack.context.delete(existingEntity)
}
// 1. 새 DailyStat 엔티티 생성
let statEntity = DailyStatEntity(context: coreDataStack.context)
statEntity.id = stat.id
// stat 객체를 DailyStatEntity에 매핑...
// 2. TagStatEntity 관계 설정
try setupTagRelationship(for: statEntity, with: stat.tagStats)
try? coreDataStack.context.save()
}
private func setupTagRelationship(for statEntity: DailyStatEntity, with tagStats: [TagStat]) throws {
for tagStat in tagStats {
let tagStatEntity = TagStatEntity(context: coreDataStack.context)
tagStatEntity.id = tagStat.id
tagStatEntity.count = Int16(tagStat.count)
// 3. TagEntity 탐색 후, tagStat에 TagEntity 연결
let tagRequest = TagEntity.fetchRequest()
tagRequest.predicate = NSPredicate(format: "id == %@", tagStat.tag.id)
// 4. dailyStatEntity와 tagStatEntity 양방향 연결
if let tagEntity = try coreDataStack.context.fetch(tagRequest).first {
tagStatEntity.tag = tagEntity
statEntity.addToTagStats(tagStatEntity)
tagStatEntity.dailyStat = statEntity
}
}
}
따라서, DailyStat 업데이트를 위해 DailyStat와 연결된 tagStat 및 tag 데이터를 로컬 저장소에 저장하는 과정에서 실패하게 된다면, 로컬 상에 서버상의 데이터와 동일하지 않은 불완전한 데이터가 저장되는 위험이 있었습니다.
뿐만 아니라, 각 데이터모델의 변경사항을 로컬 저장소에 반영하는 과정에는 이전 데이터의 삭제작업을 선행해야하는데, 이전 데이터를 삭제한 후, 새로 저장하는 과정에서 실패하게 되면, 데이터를 모두 소실하는 문제도 발생할 위험이 있었습니다.
때문에, delete(), save()를 통해 로컬저장소 context를 직접적으로 반영하는 방식은 적절하지 않다고 생각했습니다.
이에 대한 해결책을 모색하던 중, Clean Architecture 서적 내부 함수형 프로그래밍의 예시로 언급된 금융 서비스 예제를 떠올리게 되었습니다.
2.2 트랜잭션과 롤백
바로 CoreDataStack에 performInTransaction 메서드를 중심으로 CoreData의 트랜잭션과 롤백 메커니즘을 추가하는 것인데요
final class CoreDataStack {
static let shared = CoreDataStack()
/// 트랜잭션 내에서 작업을 수행하고 자동으로 저장 또는 롤백을 처리하는 메서드
/// - Parameter block: 트랜잭션 내에서 실행할 작업
/// - Throws: 작업 수행 중 발생한 오류
func performInTransaction(_ block: () throws -> Void) throws {
// 컨텍스트에서 동기적으로 작업 수행
try context.performAndWait {
do {
try block() // 작업 실행
if context.hasChanges { // 변경사항이 있는 경우에만 저장
try context.save()
}
} catch {
context.rollback()
throw error
}
}
}
}
class CoreDataSyncService{
...
func saveTodoToStore(_ todo: Todo) throws{
try coreDataStack.performInTransaction {
let request = TodoEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", todo.id)
let todoEntity = try coreDataStack.context.fetch(request).first ?? TodoEntity(context: coreDataStack.context)
todoEntity.id = todo.id
// ...
}
}
}
이러한 트랜잭션 중심의 업데이트 매커니즘은 다음과 같은 이점이 있습니다:
- 데이터 일관성: 여러 Entity 간 관계가 있는 작업(예: Todo 삭제 시 연관된 Tag 관계 제거)이 모두 성공하거나 모두 실패하도록 보장
- 오류 복구: 작업 중 실패 시 rollback을 통해 데이터 정합성 유지
- 동시성 제어: performAndWait 메서드를 통해 트랜젝션 작업을 동기로 처리하게됨에 따라, 여러 스레드에서의 동시 접근으로 인한 race condition 방지
- 성능 최적화: 변경사항이 있을 때만 저장 작업 수행
위 코드의 핵심은 context.rollback()입니다. 만약 여러 단계의 업데이트 중 어느 하나라도 실패하면 모든 변경사항이 취소되며 데이터베이스가 마지막으로 성공한 상태로 복원되는데요. 이로 인해 네트워크 통신 악화로 인한 외부적인 요인으로 인해 서버와 로컬 데이터가 불일치하는 문제를 방지할 수 있게 되었습니다.
3. 시스템 구성요소 구현
오프라인 동기화 시스템 구현 방향과 구조적 고민을 해결한 후, 본격적으로 시스템 구현을 시작하였습니다.
3.1 CoreData 스택 구축
먼저 데이터 영구 저장을 위한 CoreDataStack을 구현했습니다. 여러 뷰모델에서 동시에 접근할 수 있는 만큼 data race 방지를 위해 싱글톤 패턴을 사용했습니다. 여기서 저는 위젯 Extension과 동일한 로컬데이터를 공유하기 위해 PersistentStore를 설정하는 과정에서 App group의 공유 컨테이너 디렉토리 URL를 가져와 실제 SQLite 데이터베이스 파일 저장 경로와 연결해주었습니다.
final class CoreDataStack {
static let shared = CoreDataStack()
var persistentContainer: NSPersistentContainer
var context: NSManagedObjectContext
private init() {
persistentContainer = {
let storeURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.app"
)!
let storeDescription = NSPersistentStoreDescription(
url: storeURL.appendingPathComponent("DataModel.sqlite")
)
let container = NSPersistentContainer(name: "DataModel")
container.persistentStoreDescriptions = [storeDescription]
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Unable to load persistent stores: \(error)")
}
}
return container
}()
context = persistentContainer.viewContext
}
}
3.2 NetworkManager 구현
2.1 네트워크 연결 감지
오프라인/온라인 상태 변화를 감지하고 앱 전역에 전파하기 위해 NetworkManager 싱글톤을 구현했습니다
import Network
import Combine
class NetworkManager {
static let shared = NetworkManager()
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitor")
@Published private(set) var isConnected = true
@Published private(set) var connectionType: ConnectionType = .unknown
enum ConnectionType {
case wifi
case cellular
case ethernet
case unknown
}
private init() {
startMonitoring()
}
private func startMonitoring() {
monitor.start(queue: queue)
monitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
let isConnected = path.status == .satisfied
print("isConnected: \(isConnected)")
self?.isConnected = isConnected
if isConnected {
OfflineUIManager.shared.hide()
} else {
OfflineUIManager.shared.show()
}
}
}
}
func stopMonitoring() {
monitor.cancel()
}
}
먼저 네트워크 연결 상태를 실시간으로 관찰하는 시스템 수준의 모니터링 도구인 NWPathMonitor 인스턴스를 생성한 후, NWPathMonitor의 콜백이 실행될 전용 백그라운드 큐를 할당해주었습니다. 이로써 모니터링 작업 자체는 전용 큐에서 실행되지만, 연결 상태가 변경될 경우, DispathQueue.main.async 메서드를 통해 메인큐에서 UI 업데이트가 진행되도록 구성하였습니다.
4. CoreDataSyncService
먼저 전체코드 공유드리고 기능별로 영역을 나누어 설명드리도록 하겠습니다.
CoreDataSyncService.swift
class CoreDataSyncService {
static let shared = CoreDataSyncService()
private let coreDataStack: CoreDataStack = .shared
private let todoService: TodoServiceProtocol
private let tagService: TagServiceProtocol
private let dailyStatService: DailyStatServiceProtocol
private let widgetService: WidgetServiceProtocol
private let syncQueue = SyncQueue()
private var cancellables = Set<AnyCancellable>()
init (
todoService: TodoServiceProtocol = TodoService(),
tagService: TagServiceProtocol = TagService(),
dailyStatService: DailyStatServiceProtocol = DailyStatService(),
widgetService: WidgetServiceProtocol = WidgetService()
) {
self.todoService = todoService
self.tagService = tagService
self.dailyStatService = dailyStatService
self.widgetService = widgetService
setupNetworkMonitoring()
}
// MARK: 네트워크 상태를 실시간으로 감시
/// 온라인 상태가 되면 자동으로 동기화 시작
/// 오프라인 상태가 되면 동기화 중지
func setupNetworkMonitoring() {
NetworkManager.shared.$isConnected
.sink { [weak self] isConnected in
if isConnected {
self?.syncQueue.startSync()
} else {
self?.syncQueue.stopSync()
}
}
.store(in: &cancellables)
}
///로컬 저장소 업데이트
/// - Parameter operation:진행중인 작업
/// 네트워크 동기화 수행, 오프라인일 경우 작업을 큐에 저장
func performSync(_ operation: SyncOperation) -> AnyPublisher<Any, Error> {
print("1.performSync: \(operation.type)".prefix(56))
// 1. CoreData로 영구저장소에 변경사항 먼저 반영
do {
switch operation.type {
case .updateTodo(let todo):
try saveTodoToStore(todo)
widgetService.updateWidget(.todoList)
case .deleteTodo(let id):
try deleteTodoToStore(id)
widgetService.updateWidget(.todoList)
case .updateTag(let tag):
try saveTagToStore(tag)
case .deleteTag(let id):
try deleteTagToStore(id)
}
} catch {
return Fail(error: error).eraseToAnyPublisher()
}
// 2. SyncCommand 생성 및 SyncQueue로 처리 요청
return syncQueue.process(SyncCommand(
id: UUID(),
operation: operation,
status: .pending,
retryCount: 0
))
.eraseToAnyPublisher()
}
}
// MARK: - CRUD Method
extension CoreDataSyncService {
func createTodo(text: String, in date: String) -> AnyPublisher<[Todo], Error> {
return todoService.createTodo(text: text, in: date)
.tryMap { [weak self] todos in
try self?.saveTodosToStore(todos)
self?.widgetService.updateWidget(.todoList)
return todos
}
.eraseToAnyPublisher()
}
func updateTodo(_ todo: Todo) -> AnyPublisher<Todo, Error> {
performSync(.create(type: .updateTodo(todo)))
.map { $0 as! Todo }
.eraseToAnyPublisher()
}
func deleteTodo(_ id: String) -> AnyPublisher<String, Error> {
performSync(.create(type: .deleteTodo(id)))
.map { $0 as! String } // deletedTodoId
.eraseToAnyPublisher()
}
// MARK: - Tag domain
func createTag(name: String, color: String) -> AnyPublisher<Tag, Error> {
return tagService.createTag(name: name, color: color)
.tryMap { [weak self] createdTag in
try self?.saveTagToStore(createdTag)
return createdTag
}
.eraseToAnyPublisher()
}
func updateTag(_ tag: Tag) -> AnyPublisher<Tag, Error> {
performSync(.create(type: .updateTag(tag)))
.map { $0 as! Tag }
.eraseToAnyPublisher()
}
func deleteTag(_ id: String) -> AnyPublisher<String, Error> {
performSync(.create(type: .deleteTag(id)))
.map { $0 as! String }
.eraseToAnyPublisher()
}
}
// MARK: - Refresh Method
/// 네트워크 통신부터 하고, 새로운거 있으면 sync
extension CoreDataSyncService {
//MARK: 밑 refresh 메서드를 실행하기 전에 필수로 호출이 필수 -> Presentation layer에서 refresh메서드 호출 순서에 대해 유념해야함.
func refreshTags() -> AnyPublisher<[Tag], Error> {
return tagService.fetchTags()
.tryMap { [weak self] tags in
try self?.saveTagsToStore(tags)
return tags
}
.eraseToAnyPublisher()
}
func refreshDailyStat(for date: String) -> AnyPublisher<DailyStat?, Error> {
return dailyStatService.fetchDailyStat(for: date)
.tryMap { [weak self] _dailyStat in
if let dailyStat = _dailyStat{
try self?.saveDailyStatToStore(dailyStat)
self?.widgetService.updateWidget(.calendar)
}
return _dailyStat
}
.eraseToAnyPublisher()
}
func refreshDailyStats(for yearMonth: String) -> AnyPublisher<[DailyStat], Error> {
return dailyStatService.fetchMonthlyStats(in: yearMonth)
.tryMap { [weak self] dailyStats in
try self?.saveDailyStatsToStore(dailyStats)
self?.widgetService.updateWidget(.calendar)
return dailyStats
}
.eraseToAnyPublisher()
}
func refreshTodos(for date: String) -> AnyPublisher<[Todo], Error> {
return todoService.fetchTodos(for: date)
.tryMap { [weak self] todos in
try self?.saveTodosToStore(todos)
return todos
}
.eraseToAnyPublisher()
}
}
// MARK: - 영구저장소에서 데이터 Read
extension CoreDataSyncService {
func readTodosFromStore(for date: String) throws -> [Todo] {
let request = TodoEntity.fetchRequest()
request.predicate = NSPredicate(format: "deadline == %@", date)
request.sortDescriptors = [
NSSortDescriptor(key: "isImportant", ascending: false),
NSSortDescriptor(key: "createdAt", ascending: false),
NSSortDescriptor(key: "title", ascending: true)
]
let todoEntities = try coreDataStack.context.fetch(request)
let todos = todoEntities.map { entity in
return Todo(
id: entity.id ?? "",
raw: entity.raw ?? "",
title: entity.title ?? "",
isImportant: entity.isImportant,
isLife: entity.isLife,
tag: entity.tag?.toDomain(),
difficulty: Int(entity.difficulty),
estimatedTime: Int(entity.estimatedTime),
deadline: entity.deadline ?? "",
isCompleted: entity.isCompleted,
userId: entity.userId ?? "",
createdAt: entity.createdAt ?? ""
)
}
return todos
}
func readDailyStatsFromStore(for yearMonth: String) throws -> [DailyStat] {
let request = DailyStatEntity.fetchRequest()
request.predicate = NSPredicate(format: "date BEGINSWITH %@", yearMonth)
let statEntities = try coreDataStack.context.fetch(request)
return statEntities.map { entity in
// TagStats 변환 로직
let tagStats: [TagStat] = (entity.tagStats as? Set<TagStatEntity>)?.map { tagStatEntity in
TagStat(
id: tagStatEntity.id ?? "",
tag: _Tag(
id: tagStatEntity.tag?.id ?? "",
name: tagStatEntity.tag?.name ?? "",
color: tagStatEntity.tag?.color ?? "",
userId: tagStatEntity.tag?.userId ?? ""
),
count: Int(tagStatEntity.count)
)
} ?? []
return DailyStat(
id: entity.id ?? "",
date: entity.date ?? "",
userId: entity.userId ?? "",
balanceData: BalanceData(
title: entity.balanceTitle ?? "",
message: entity.balanceMessage ?? "",
balanceNum: Int(entity.balanceNum)
),
productivityNum: entity.productivityNum,
tagStats: tagStats,
center: SIMD2<Float>(entity.centerX, entity.centerY)
)
}
}
func readTagsFromStore() throws -> [Tag] {
guard let userId = UserDefaultsManager.shared.currentUserId else { return [] }
let request = TagEntity.fetchRequest()
request.predicate = NSPredicate(format: "userId == %@", userId)
let tagEntities = try coreDataStack.context.fetch(request)
return tagEntities.map { entity in
Tag(
id: entity.id ?? "",
name: entity.name ?? "",
color: entity.color ?? "",
userId: entity.userId ?? ""
)
}
}
}
// MARK: - CoreData CRUD(단일)
extension CoreDataSyncService {
// MARK: - Todo domain
private func saveTodoToStore(_ todo: Todo) throws{
try coreDataStack.performInTransaction {
let request = TodoEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", todo.id)
let todoEntity = try coreDataStack.context.fetch(request).first ?? TodoEntity(context: coreDataStack.context)
todoEntity.id = todo.id
todoEntity.raw = todo.raw
todoEntity.title = todo.title
todoEntity.isImportant = todo.isImportant
todoEntity.isLife = todo.isLife
todoEntity.difficulty = Int16(todo.difficulty)
todoEntity.estimatedTime = Int16(todo.estimatedTime)
todoEntity.deadline = todo.deadline
todoEntity.isCompleted = todo.isCompleted
todoEntity.userId = todo.userId
todoEntity.createdAt = todo.createdAt
if let tag = todo.tag {
try setupTagRelationship(for: todoEntity, with: tag)
} else {
todoEntity.tag = nil
}
}
}
func deleteTodoToStore(_ id: String) throws {
try coreDataStack.performInTransaction {
let request = TodoEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id)
guard let todoEntity = try coreDataStack.context.fetch(request).first else {
throw NSError(domain: "TodoNotFound", code: 404)
}
coreDataStack.context.delete(todoEntity)
}
}
// MARK: - DailyStat
private func saveDailyStatToStore(_ stat: DailyStat) throws {
try coreDataStack.performInTransaction {
let request = DailyStatEntity.fetchRequest()
request.predicate = NSPredicate(
format: "date == %@ AND userId == %@",
stat.date,
stat.userId
)
// 기존 데이터가 있으면 삭제
if let existingEntity = try coreDataStack.context.fetch(request).first {
coreDataStack.context.delete(existingEntity)
}
// 새 엔티티 생성
let statEntity = DailyStatEntity(context: coreDataStack.context)
statEntity.id = stat.id
statEntity.date = stat.date
statEntity.userId = stat.userId
statEntity.productivityNum = stat.productivityNum
statEntity.balanceTitle = stat.balanceData.title
statEntity.balanceMessage = stat.balanceData.message
statEntity.balanceNum = Int16(stat.balanceData.balanceNum)
statEntity.centerX = stat.center.x
statEntity.centerY = stat.center.y
// TagStats 관계 설정
try setupTagRelationship(for: statEntity, with: stat.tagStats)
}
}
// MARK: - Tag
private func saveTagToStore(_ tag: Tag) throws {
try coreDataStack.performInTransaction {
let request = TagEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", tag.id)
let tagEntity = try coreDataStack.context.fetch(request).first ?? TagEntity(context: coreDataStack.context)
tagEntity.id = tag.id
tagEntity.color = tag.color
tagEntity.name = tag.name
tagEntity.userId = tag.userId
tagEntity.lastUpdated = Date().koreanDate
}
}
func deleteTagToStore(_ id: String) throws {
try coreDataStack.performInTransaction {
let request = TagEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id)
guard let tagEntity = try coreDataStack.context.fetch(request).first else {
throw NSError(domain: "TagNotFound", code: 404)
}
coreDataStack.context.delete(tagEntity)
}
}
}
// MARK: - CoreData CRUD(복수) Batch Operations with Transaction
extension CoreDataSyncService {
private func saveTodosToStore(_ todos: [Todo]) throws {
try coreDataStack.performInTransaction {
for todo in todos {
try saveTodoToStore(todo)
}
}
}
private func saveDailyStatsToStore(_ stats: [DailyStat]) throws {
try coreDataStack.performInTransaction {
if let firstStat = stats.first {
let yearMonth = String(firstStat.date.prefix(7))
let request = DailyStatEntity.fetchRequest()
request.predicate = NSPredicate(
format: "date BEGINSWITH %@ AND userId == %@",
yearMonth,
firstStat.userId
)
let existingEntities = try coreDataStack.context.fetch(request)
existingEntities.forEach { coreDataStack.context.delete($0) }
}
for stat in stats {
try saveDailyStatToStore(stat)
}
}
}
private func saveTagsToStore(_ tags: [Tag]) throws {
try coreDataStack.performInTransaction {
for tag in tags {
let request = TagEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", tag.id)
let tagEntity = try coreDataStack.context.fetch(request).first ?? TagEntity(context: coreDataStack.context)
tagEntity.id = tag.id
tagEntity.name = tag.name
tagEntity.color = tag.color
tagEntity.userId = tag.userId
}
}
}
private func setupTagRelationship(for statEntity: DailyStatEntity, with tagStats: [TagStat]) throws {
// 기존 관계 제거
if let existingStats = statEntity.tagStats as? Set<TagStatEntity> {
for stat in existingStats {
statEntity.removeFromTagStats(stat)
coreDataStack.context.delete(stat)
}
}
// 새로운 TagStat 관계 설정
for tagStat in tagStats {
let tagStatEntity = TagStatEntity(context: coreDataStack.context)
tagStatEntity.id = tagStat.id
tagStatEntity.count = Int16(tagStat.count)
// Tag 관계 설정 -> 여기서 local 저장소에 tagStat에 명시된 id값을 지닌 tag가 없을 경우, tag가 연결되지 않음에 따라 버그 발생
let tagRequest = TagEntity.fetchRequest()
tagRequest.predicate = NSPredicate(format: "id == %@", tagStat.tag.id)
if let tagEntity = try coreDataStack.context.fetch(tagRequest).first {
tagStatEntity.tag = tagEntity
statEntity.addToTagStats(tagStatEntity)
tagStatEntity.dailyStat = statEntity
}
}
}
/// Tag 관계 설정을 위한 헬퍼 메서드
private func setupTagRelationship(for todoEntity: TodoEntity, with tag: Tag) throws {
/// 전달받은 Tag에 대한 Entity가 로컬에 존재하는지 파악하고, 있으면 그대로 전달받은 부모 Entity에 주입, 없으면 새로 만들어서 주입
let tagRequest = TagEntity.fetchRequest()
tagRequest.predicate = NSPredicate(format: "id == %@", tag.id)
let tagEntity = try coreDataStack.context.fetch(tagRequest).first ?? nil
todoEntity.tag = tagEntity
}
}
4.1 구성요소 정의 및 핵심 메서드 구현
class CoreDataSyncService {
static let shared = CoreDataSyncService()
private let coreDataStack: CoreDataStack = .shared
private let todoService: TodoServiceProtocol
private let tagService: TagServiceProtocol
private let dailyStatService: DailyStatServiceProtocol
private let widgetService: WidgetServiceProtocol
private let syncQueue = SyncQueue()
private var cancellables = Set<AnyCancellable>()
init (
todoService: TodoServiceProtocol = TodoService(),
tagService: TagServiceProtocol = TagService(),
dailyStatService: DailyStatServiceProtocol = DailyStatService(),
widgetService: WidgetServiceProtocol = WidgetService()
) {
self.todoService = todoService
self.tagService = tagService
self.dailyStatService = dailyStatService
self.widgetService = widgetService
setupNetworkMonitoring()
}
// MARK: 네트워크 상태를 실시간으로 감시
/// 온라인 상태가 되면 자동으로 동기화 시작
/// 오프라인 상태가 되면 동기화 중지
func setupNetworkMonitoring() {
NetworkManager.shared.$isConnected
.sink { [weak self] isConnected in
if isConnected {
self?.syncQueue.startSync()
} else {
self?.syncQueue.stopSync()
}
}
.store(in: &cancellables)
}
///로컬 저장소 업데이트
/// - Parameter operation:진행중인 작업
/// 네트워크 동기화 수행, 오프라인일 경우 작업을 큐에 저장
func performSync(_ operation: SyncOperation) -> AnyPublisher<Any, Error> {
print("1.performSync: \(operation.type)".prefix(56))
// 1. CoreData로 영구저장소에 변경사항 먼저 반영
do {
switch operation.type {
case .updateTodo(let todo):
try saveTodoToStore(todo)
widgetService.updateWidget(.todoList)
case .deleteTodo(let id):
try deleteTodoToStore(id)
widgetService.updateWidget(.todoList)
case .updateTag(let tag):
try saveTagToStore(tag)
case .deleteTag(let id):
try deleteTagToStore(id)
}
} catch {
return Fail(error: error).eraseToAnyPublisher()
}
// 2. SyncCommand 생성 및 SyncQueue로 처리 요청
return syncQueue.process(SyncCommand(
id: UUID(),
operation: operation,
status: .pending,
retryCount: 0
))
.eraseToAnyPublisher()
}
}
Class 내부에서 필요로 하는 서비스 레이어, CoreDataStack 등 동기화 시스템에 필요한 의존성들을 초기화기에서 주입받도록 구성했습니다. 또한 앞서 설명한 NetworkManager에서 출판한 isConnected 상태변수 변화를 구독해, 이에따라 동기화 작업 시작 여부를 결정하는 setupNetworkMonitoring 메서드를 초기화기에서 호출해주었습니다. 마지막으로, 로컬 저장소 업데이트 -> 네트워크 호출 or 작업 저장 로직을 수행하는 performSync 핵심 메서드를 구현하였습니다.
4.2 Presentation Layer에서 호출되는 메서드
이후 ViewModel에서 직접 호출하게 되는 public 메서드들을 정의해주었습니다.
4.2.1 Create, Update, Delete
위에 설명한 performSync 메서드를 실행하며, 네트워크 호출을 선행하는 Create 메서드의 경우 로컬저장소 업데이트 메서드를 연쇄적으로 호출하도록 구성해주었습니다.
extension CoreDataSyncService {
func createTodo(text: String, in date: String) -> AnyPublisher<[Todo], Error> {
return todoService.createTodo(text: text, in: date)
.tryMap { [weak self] todos in
try self?.saveTodosToStore(todos)
self?.widgetService.updateWidget(.todoList) // 위젯 갱신 로직
return todos
}
.eraseToAnyPublisher()
}
func updateTodo(_ todo: Todo) -> AnyPublisher<Todo, Error> {
performSync(.create(type: .updateTodo(todo)))
.map { $0 as! Todo }
.eraseToAnyPublisher()
}
func deleteTodo(_ id: String) -> AnyPublisher<String, Error> {
performSync(.create(type: .deleteTodo(id)))
.map { $0 as! String } // deletedTodoId
.eraseToAnyPublisher()
}
// MARK: - Tag domain
func createTag(name: String, color: String) -> AnyPublisher<Tag, Error> {
return tagService.createTag(name: name, color: color)
.tryMap { [weak self] createdTag in
try self?.saveTagToStore(createdTag)
return createdTag
}
.eraseToAnyPublisher()
}
func updateTag(_ tag: Tag) -> AnyPublisher<Tag, Error> {
performSync(.create(type: .updateTag(tag)))
.map { $0 as! Tag }
.eraseToAnyPublisher()
}
func deleteTag(_ id: String) -> AnyPublisher<String, Error> {
performSync(.create(type: .deleteTag(id)))
.map { $0 as! String }
.eraseToAnyPublisher()
}
}
4.2.2 Read
로컬저장소 내부 특정 조건을 충족하는 데이터를 찾고 이를 반환하는 메서드들입니다. 초기 앱화면 데이터 구성과 같은 상황에 Presentation 레이어에서 호출됩니다.
// MARK: - 로컬저장소에서 데이터 Read
extension CoreDataSyncService {
func readTodosFromStore(for date: String) throws -> [Todo] {
let request = TodoEntity.fetchRequest()
request.predicate = NSPredicate(format: "deadline == %@", date)
request.sortDescriptors = [
NSSortDescriptor(key: "isImportant", ascending: false),
NSSortDescriptor(key: "createdAt", ascending: false),
NSSortDescriptor(key: "title", ascending: true)
]
let todoEntities = try coreDataStack.context.fetch(request)
let todos = todoEntities.map { entity in
return Todo(
id: entity.id ?? "",
// ...
)
}
return todos
}
func readDailyStatsFromStore(for yearMonth: String) throws -> [DailyStat] {
let request = DailyStatEntity.fetchRequest()
request.predicate = NSPredicate(format: "date BEGINSWITH %@", yearMonth)
let statEntities = try coreDataStack.context.fetch(request)
return statEntities.map { entity in
// TagStats 변환 로직
let tagStats: [TagStat] = (entity.tagStats as? Set<TagStatEntity>)?.map { tagStatEntity in
TagStat(
id: tagStatEntity.id ?? "",
// ...
)
} ?? []
return DailyStat(
id: entity.id ?? "",
date: entity.date ?? "",
// ...
)
}
}
func readTagsFromStore() throws -> [Tag] {
guard let userId = UserDefaultsManager.shared.currentUserId else { return [] }
let request = TagEntity.fetchRequest()
request.predicate = NSPredicate(format: "userId == %@", userId)
let tagEntities = try coreDataStack.context.fetch(request)
return tagEntities.map { entity in
Tag(
id: entity.id ?? "",
// ...
)
}
}
}
4.2.3 네트워크 통신 + 저장소 업데이트 메서드 구현
서버로부터 최신 데이터를 반환받고 변경사항이 있을 겨우 로컬 저장소를 업데이트하는 메서드입니다.
// MARK: - Refresh Method
/// 네트워크 통신부터 하고, 새로운거 있으면 sync
extension CoreDataSyncService {
//MARK: 밑 refresh 메서드를 실행하기 전에 필수로 호출이 필수 -> Presentation layer에서 refresh메서드 호출 순서에 대해 유념해야함.
func refreshTags() -> AnyPublisher<[Tag], Error> {
return tagService.fetchTags()
.tryMap { [weak self] tags in
try self?.saveTagsToStore(tags)
return tags
}
.eraseToAnyPublisher()
}
func refreshDailyStat(for date: String) -> AnyPublisher<DailyStat?, Error> {
return dailyStatService.fetchDailyStat(for: date)
.tryMap { [weak self] _dailyStat in
if let dailyStat = _dailyStat{
try self?.saveDailyStatToStore(dailyStat)
self?.widgetService.updateWidget(.calendar)
}
return _dailyStat
}
.eraseToAnyPublisher()
}
func refreshDailyStats(for yearMonth: String) -> AnyPublisher<[DailyStat], Error> {
return dailyStatService.fetchMonthlyStats(in: yearMonth)
.tryMap { [weak self] dailyStats in
try self?.saveDailyStatsToStore(dailyStats)
self?.widgetService.updateWidget(.calendar)
return dailyStats
}
.eraseToAnyPublisher()
}
func refreshTodos(for date: String) -> AnyPublisher<[Todo], Error> {
return todoService.fetchTodos(for: date)
.tryMap { [weak self] todos in
try self?.saveTodosToStore(todos)
return todos
}
.eraseToAnyPublisher()
}
}
4.3 private 메서드
앞서 언급한 메서드들이 내부적으로 사용하는 private 메서드들입니다.
4.3.1 단일 CRUD 메서드
private extension CoreDataSyncService {
func saveTodoToStore(_ todo: Todo) throws{
try coreDataStack.performInTransaction {
let request = TodoEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", todo.id)
let todoEntity = try coreDataStack.context.fetch(request).first ?? TodoEntity(context: coreDataStack.context)
todoEntity.id = todo.id
// ...
if let tag = todo.tag {
try setupTagRelationship(for: todoEntity, with: tag)
} else {
todoEntity.tag = nil
}
}
}
func deleteTodoToStore(_ id: String) throws {
try coreDataStack.performInTransaction {
let request = TodoEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id)
guard let todoEntity = try coreDataStack.context.fetch(request).first else {
throw NSError(domain: "TodoNotFound", code: 404)
}
coreDataStack.context.delete(todoEntity)
}
}
func saveDailyStatToStore(_ stat: DailyStat) throws {
try coreDataStack.performInTransaction {
let request = DailyStatEntity.fetchRequest()
request.predicate = NSPredicate(
format: "date == %@ AND userId == %@",
stat.date,
stat.userId
)
// 기존 데이터가 있으면 삭제
if let existingEntity = try coreDataStack.context.fetch(request).first {
coreDataStack.context.delete(existingEntity)
}
// 새 엔티티 생성
let statEntity = DailyStatEntity(context: coreDataStack.context)
statEntity.id = stat.id
// ...
// TagStats 관계 설정
try setupTagRelationship(for: statEntity, with: stat.tagStats)
}
}
func saveTagToStore(_ tag: Tag) throws {
try coreDataStack.performInTransaction {
let request = TagEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", tag.id)
let tagEntity = try coreDataStack.context.fetch(request).first ?? TagEntity(context: coreDataStack.context)
tagEntity.id = tag.id
// ...
}
}
func deleteTagToStore(_ id: String) throws {
try coreDataStack.performInTransaction {
let request = TagEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id)
guard let tagEntity = try coreDataStack.context.fetch(request).first else {
throw NSError(domain: "TagNotFound", code: 404)
}
coreDataStack.context.delete(tagEntity)
}
}
}
4.3.2 복수 CRUD 메서드
private extension CoreDataSyncService {
func saveTodosToStore(_ todos: [Todo]) throws {
try coreDataStack.performInTransaction {
for todo in todos {
try saveTodoToStore(todo)
}
}
}
func saveDailyStatsToStore(_ stats: [DailyStat]) throws {
try coreDataStack.performInTransaction {
if let firstStat = stats.first {
let yearMonth = String(firstStat.date.prefix(7))
let request = DailyStatEntity.fetchRequest()
request.predicate = NSPredicate(
format: "date BEGINSWITH %@ AND userId == %@",
yearMonth,
firstStat.userId
)
let existingEntities = try coreDataStack.context.fetch(request)
existingEntities.forEach { coreDataStack.context.delete($0) }
}
for stat in stats {
try saveDailyStatToStore(stat)
}
}
}
func saveTagsToStore(_ tags: [Tag]) throws {
try coreDataStack.performInTransaction {
for tag in tags {
let request = TagEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", tag.id)
let tagEntity = try coreDataStack.context.fetch(request).first ?? TagEntity(context: coreDataStack.context)
tagEntity.id = tag.id
// ...
}
}
}
func setupTagRelationship(for statEntity: DailyStatEntity, with tagStats: [TagStat]) throws {
// 기존 관계 제거
if let existingStats = statEntity.tagStats as? Set<TagStatEntity> {
for stat in existingStats {
statEntity.removeFromTagStats(stat)
coreDataStack.context.delete(stat)
}
}
// 새로운 TagStat 관계 설정
for tagStat in tagStats {
let tagStatEntity = TagStatEntity(context: coreDataStack.context)
tagStatEntity.id = tagStat.id
tagStatEntity.count = Int16(tagStat.count)
// Tag 관계 설정 -> 여기서 local 저장소에 tagStat에 명시된 id값을 지닌 tag가 없을 경우, tag가 연결되지 않음에 따라 버그 발생
let tagRequest = TagEntity.fetchRequest()
tagRequest.predicate = NSPredicate(format: "id == %@", tagStat.tag.id)
if let tagEntity = try coreDataStack.context.fetch(tagRequest).first {
tagStatEntity.tag = tagEntity
statEntity.addToTagStats(tagStatEntity)
tagStatEntity.dailyStat = statEntity
}
}
}
/// Tag 관계 설정을 위한 헬퍼 메서드
func setupTagRelationship(for todoEntity: TodoEntity, with tag: Tag) throws {
/// 전달받은 Tag에 대한 Entity가 로컬에 존재하는지 파악하고, 있으면 그대로 전달받은 부모 Entity에 주입, 없으면 새로 만들어서 주입
let tagRequest = TagEntity.fetchRequest()
tagRequest.predicate = NSPredicate(format: "id == %@", tag.id)
let tagEntity = try coreDataStack.context.fetch(tagRequest).first ?? nil
todoEntity.tag = tagEntity
}
}
5. 오프라인 작업 큐(SyncQueue)
5.1 구성요소 정의
함수형 프로그래밍의 불변성 원칙은 동기화 큐를 구성하는 구조체를 설계하는 데에도 도움이 되어주었습니다. 한번 생성된 데이터가 변경되지 않는다는 원칙을 준수하고자, SyncOperation과 SyncCommand를 Reference type이 아닌 Value type으로 설계해, 작업 자체에 대한 수정을 원천차단하고자 했습니다.
enum SyncOperationType: Codable {
case updateTodo(Todo)
case deleteTodo(String)
case updateTag(Tag)
case deleteTag(String)
}
enum SyncStatus: String, Codable {
case pending, inProgress, completed, failed
}
struct SyncOperation: Codable {
let type: SyncOperationType
let timestamp: Date
}
struct SyncCommand: Codable {
let id: UUID
let operation: SyncOperation
var status: SyncStatus
var retryCount: Int
var lastAttempt: Date?
var errorMessage: String?
}
트랜잭션 단위로 분리된 각 CRUD 메서드들은 실행되는 순서를 동일하게 유지해야합니다.
만약 할일 수정 -> 할일 삭제 순으로 오프라인 환경에서 실행했던 트랜잭션들이 할일삭제 -> 할일 수정 순으로 순서가 변경된다면, 삭제된 할일을 접근함에 따라 에러를 반환하게 되겠죠.
따라서 실행 순서를 보존할 수 있는 FIFO 방식의 Queue 자료구조를 중심으로 SyncQueue Class 내부에서 앞서 정의한 불변 작업들을 저장 및 처리하도록 했습니다.
class SyncQueue {
private var syncTimer: Timer?
func process(_ command: SyncCommand) -> AnyPublisher<Any, Error> {
if NetworkManager.shared.isConnected {
return executeCommand(command)
} else {
try? saveCommand(command)
return Just(()).setFailureType(to: Error.self).eraseToAnyPublisher()
}
}
func startSync() {
syncTimer = Timer.scheduledTimer(withTimeInterval: retryInterval,
repeats: true) { [weak self] _ in
self?.processPendingCommands()
}
processPendingCommands()
}
}
5.2 전체 코드
// MARK: 오프라인 상태에서의 작업을 큐에 저장하고 관리
private class SyncQueue {
private let coreDataStack: CoreDataStack = .shared
private let todoService: TodoServiceProtocol
private let tagService: TagServiceProtocol
private let dailyStatService: DailyStatServiceProtocol
private var syncTimer: Timer?
private var retryInterval: TimeInterval = 15
private var cancellables = Set<AnyCancellable>()
init(
todoService: TodoServiceProtocol = TodoService(),
tagService: TagServiceProtocol = TagService(),
dailyStatService: DailyStatServiceProtocol = DailyStatService()
) {
self.todoService = todoService
self.tagService = tagService
self.dailyStatService = dailyStatService
}
func process(_ command: SyncCommand) -> AnyPublisher<Any, Error> {
if NetworkManager.shared.isConnected {
print("2.process online: \(command.operation.type)".prefix(56))
return executeCommand(command) // 온라인: 즉시 서버와 동기화
} else {
print("2.process offline: \(command.operation.type)".prefix(56))
return Future { promise in
do {
try self.saveCommand(command)
switch command.operation.type {
case .updateTodo(let todo):
promise(.success(todo))
case .updateTag(let tag):
promise(.success(tag))
case .deleteTodo(let todoId):
promise(.success(todoId))
case .deleteTag(let tagId):
promise(.success(tagId))
}
} catch {
promise(.failure(error))
}
}.eraseToAnyPublisher()
}
}
private func executeCommand(_ command: SyncCommand) -> AnyPublisher<Any, Error> {
switch command.operation.type {
case .updateTodo(let todo):
return todoService.updateTodo(todo: todo)
.map { $0 as Any }
.mapError { $0 as Error }
.eraseToAnyPublisher()
case .deleteTodo(let id):
return todoService.deleteTodo(id: id)
.map { $0.id as Any }
.mapError { $0 as Error }
.eraseToAnyPublisher()
case .updateTag(let tag):
return tagService.updateTag(tag)
.map { $0 as Any }
.mapError { $0 as Error }
.eraseToAnyPublisher()
case .deleteTag(let id):
return tagService.deleteTag(id: id)
.map { $0 as Any }
.mapError { $0 as Error }
.eraseToAnyPublisher()
}
}
// MARK: - SyncQueue Background Processing
func startSync() {
stopSync()
syncTimer = Timer.scheduledTimer(withTimeInterval: retryInterval, repeats: true) { [weak self] _ in
self?.processPendingCommands()
}
processPendingCommands()
}
func stopSync() {
syncTimer?.invalidate()
syncTimer = nil
}
}
// MARK: - Core Data Operations
private extension SyncQueue {
func processPendingCommands() {
guard let commands = try? getPendingCommands(), !commands.isEmpty else {
stopSync()
return
}
for command in commands {
print("startSync->processPendingCommand: \(command.operation.type)".prefix(64))
executeCommand(command)
.sink(
receiveCompletion: { [weak self] completion in
guard let self = self else { return }
switch completion {
case .failure(let error):
print("processPendingCommands failed: \(error)")
let newRetryCount = command.retryCount + 1
if newRetryCount >= 3 {
try? self.markAsFailed(command.id, error: error)
} else {
try? self.updateRetryCount(command.id, count: newRetryCount)
}
case .finished:
break
}
},
receiveValue: { [weak self] _ in
guard let self = self else { return }
try? self.markAsCompleted(command.id)
// 모든 명령 처리 완료 후 상태 체크
if let remainingCommands = try? self.getPendingCommands(), remainingCommands.isEmpty {
self.stopSync()
}
}
)
.store(in: &cancellables)
}
}
func saveCommand(_ command: SyncCommand) throws {
print("3.(off)saveCommand: \(command.operation.type)".prefix(56))
try coreDataStack.performInTransaction {
let entity = SyncCommandEntity(context: coreDataStack.context)
entity.id = command.id
entity.payload = try JSONEncoder().encode(command)
entity.status = command.status.rawValue
entity.createdAt = Date()
}
}
func getPendingCommands() throws -> [SyncCommand] {
let request = SyncCommandEntity.fetchRequest()
request.predicate = NSPredicate(format: "status == %@", SyncStatus.pending.rawValue)
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
let entities = try coreDataStack.context.fetch(request)
return try entities.map {
if let data = $0.payload {
return try JSONDecoder().decode(SyncCommand.self, from: data)
}
throw NSError(domain: "SyncCommand", code: 404, userInfo: [NSLocalizedDescriptionKey: "Invalid payload data"])
}
}
func markAsCompleted(_ commandId: UUID) throws {
try coreDataStack.performInTransaction {
let request = SyncCommandEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", commandId as CVarArg)
if let entity = try coreDataStack.context.fetch(request).first {
entity.status = SyncStatus.completed.rawValue
}
}
}
private func updateRetryCount(_ commandId: UUID, count: Int) throws {
print("\(commandId): updateRetryCount")
try coreDataStack.performInTransaction {
let request = SyncCommandEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", commandId as CVarArg)
if let entity = try coreDataStack.context.fetch(request).first {
var command = try JSONDecoder().decode(SyncCommand.self, from: entity.payload ?? Data())
command.retryCount = count
entity.payload = try JSONEncoder().encode(command)
}
}
}
private func markAsFailed(_ commandId: UUID, error: Error) throws {
print("\(commandId): markAsFailed")
try coreDataStack.performInTransaction {
let request = SyncCommandEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", commandId as CVarArg)
if let entity = try coreDataStack.context.fetch(request).first {
var command = try JSONDecoder().decode(SyncCommand.self, from: entity.payload ?? Data())
command.status = .failed
command.errorMessage = error.localizedDescription
entity.payload = try JSONEncoder().encode(command)
}
}
}
}
5.2.1 기본 구조와 동기화 전략 정의
private class SyncQueue {
private let coreDataStack: CoreDataStack = .shared
private let todoService: TodoServiceProtocol
private let tagService: TagServiceProtocol
private let dailyStatService: DailyStatServiceProtocol
private var syncTimer: Timer?
private var retryInterval: TimeInterval = 15
private var cancellables = Set<AnyCancellable>()
init(
todoService: TodoServiceProtocol = TodoService(),
tagService: TagServiceProtocol = TagService(),
dailyStatService: DailyStatServiceProtocol = DailyStatService()
) {
self.todoService = todoService
self.tagService = tagService
self.dailyStatService = dailyStatService
}
func process(_ command: SyncCommand) -> AnyPublisher<Any, Error> {
if NetworkManager.shared.isConnected {
print("2.process online: \(command.operation.type)".prefix(56))
return executeCommand(command) // 온라인: 즉시 서버와 동기화
} else {
print("2.process offline: \(command.operation.type)".prefix(56))
return Future { promise in
do {
try self.saveCommand(command)
switch command.operation.type {
case .updateTodo(let todo):
promise(.success(todo))
case .updateTag(let tag):
promise(.success(tag))
case .deleteTodo(let todoId):
promise(.success(todoId))
case .deleteTag(let tagId):
promise(.success(tagId))
}
} catch {
promise(.failure(error))
}
}.eraseToAnyPublisher()
}
}
private func executeCommand(_ command: SyncCommand) -> AnyPublisher<Any, Error> {
switch command.operation.type {
case .updateTodo(let todo):
return todoService.updateTodo(todo: todo)
.map { $0 as Any }
.mapError { $0 as Error }
.eraseToAnyPublisher()
case .deleteTodo(let id):
return todoService.deleteTodo(id: id)
.map { $0.id as Any }
.mapError { $0 as Error }
.eraseToAnyPublisher()
case .updateTag(let tag):
return tagService.updateTag(tag)
.map { $0 as Any }
.mapError { $0 as Error }
.eraseToAnyPublisher()
case .deleteTag(let id):
return tagService.deleteTag(id: id)
.map { $0 as Any }
.mapError { $0 as Error }
.eraseToAnyPublisher()
}
}
// MARK: - SyncQueue Background Processing
func startSync() {
stopSync()
syncTimer = Timer.scheduledTimer(withTimeInterval: retryInterval, repeats: true) { [weak self] _ in
self?.processPendingCommands()
}
processPendingCommands()
}
func stopSync() {
syncTimer?.invalidate()
syncTimer = nil
}
}
위 코드는 크게 3가지 영역으로 나눌 수 있습니다.
1. 동기화 명령 처리 전략:
- process: 네트워크 상태에 따른 동기화 전략을 결정하는 핵심 메서드
- 온라인: 즉시 서버와 동기화 실행
- 오프라인: 명령을 저장소에 보관하고 임시 결과 반환
2. 서버 통신 실행:
- executeCommand: 실제 서버 통신을 수행하는 메서드
- Todo와 Tag의 업데이트/삭제 작업을 각각의 Service를 통해 처리
- Combine 프레임워크를 활용한 비동기 처리
3. 주기적 동기화 관리:
- startSync/stopSync: 동기화 주기 관리 메서드
- Timer를 사용한 15초 간격의 주기적 동기화 시도
- 네트워크 상태 변화에 따른 동기화 시작/중지
5.2.2 Core Data 관련 작업
// MARK: - Core Data Operations
private extension SyncQueue {
func processPendingCommands() {
guard let commands = try? getPendingCommands(), !commands.isEmpty else {
stopSync()
return
}
for command in commands {
print("startSync->processPendingCommand: \(command.operation.type)".prefix(64))
executeCommand(command)
.sink(
receiveCompletion: { [weak self] completion in
guard let self = self else { return }
switch completion {
case .failure(let error):
print("processPendingCommands failed: \(error)")
let newRetryCount = command.retryCount + 1
if newRetryCount >= 3 {
try? self.markAsFailed(command.id, error: error)
} else {
try? self.updateRetryCount(command.id, count: newRetryCount)
}
case .finished:
break
}
},
receiveValue: { [weak self] _ in
guard let self = self else { return }
try? self.markAsCompleted(command.id)
// 모든 명령 처리 완료 후 상태 체크
if let remainingCommands = try? self.getPendingCommands(), remainingCommands.isEmpty {
self.stopSync()
}
}
)
.store(in: &cancellables)
}
}
func saveCommand(_ command: SyncCommand) throws {
print("3.(off)saveCommand: \(command.operation.type)".prefix(56))
try coreDataStack.performInTransaction {
let entity = SyncCommandEntity(context: coreDataStack.context)
entity.id = command.id
entity.payload = try JSONEncoder().encode(command)
entity.status = command.status.rawValue
entity.createdAt = Date()
}
}
func getPendingCommands() throws -> [SyncCommand] {
let request = SyncCommandEntity.fetchRequest()
request.predicate = NSPredicate(format: "status == %@", SyncStatus.pending.rawValue)
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
let entities = try coreDataStack.context.fetch(request)
return try entities.map {
if let data = $0.payload {
return try JSONDecoder().decode(SyncCommand.self, from: data)
}
throw NSError(domain: "SyncCommand", code: 404, userInfo: [NSLocalizedDescriptionKey: "Invalid payload data"])
}
}
func markAsCompleted(_ commandId: UUID) throws {
try coreDataStack.performInTransaction {
let request = SyncCommandEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", commandId as CVarArg)
if let entity = try coreDataStack.context.fetch(request).first {
entity.status = SyncStatus.completed.rawValue
}
}
}
private func updateRetryCount(_ commandId: UUID, count: Int) throws {
print("\(commandId): updateRetryCount")
try coreDataStack.performInTransaction {
let request = SyncCommandEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", commandId as CVarArg)
if let entity = try coreDataStack.context.fetch(request).first {
var command = try JSONDecoder().decode(SyncCommand.self, from: entity.payload ?? Data())
command.retryCount = count
entity.payload = try JSONEncoder().encode(command)
}
}
}
private func markAsFailed(_ commandId: UUID, error: Error) throws {
print("\(commandId): markAsFailed")
try coreDataStack.performInTransaction {
let request = SyncCommandEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", commandId as CVarArg)
if let entity = try coreDataStack.context.fetch(request).first {
var command = try JSONDecoder().decode(SyncCommand.self, from: entity.payload ?? Data())
command.status = .failed
command.errorMessage = error.localizedDescription
entity.payload = try JSONEncoder().encode(command)
}
}
}
}
위 코드는 크게 2가지 영역으로 나눌 수 있습니다.
1. 오프라인 데이터 관리 메서드들:
- saveCommand: 오프라인 상태에서 발생한 동기화 명령을 CoreData에 저장
- getPendingCommands: 아직 처리되지 않은(pending 상태) 동기화 명령들을 시간순으로 조회
- markAsCompleted: 성공적으로 처리된 명령을 완료 상태로 표시
- updateRetryCount: 실패한 명령의 재시도 횟수를 업데이트
- markAsFailed: 최대 재시도 횟수(3회)를 초과한 명령을 실패 상태로 표시
2. 동기화 프로세스 실행 메서드:
- processPendingCommands: 저장된 동기화 명령들을 실제로 처리하는 핵심 메서드
- 보류 중인 명령들을 가져와서 순차적으로 처리
- 각 명령 실행 결과에 따라 성공/실패 처리
- 실패 시 최대 3번까지 재시도
- 모든 명령이 처리되면 동기화 타이머 중지
이전 코드가 동기화의 전략과 흐름을 정의했다면, 이 코드는 실제로 오프라인 상태에서 발생한 명령들을 어떻게 저장하고 관리하며, 온라인 상태가 되었을 때 어떻게 처리할지에 대한 구체적인 구현을 담당합니다.
6. 마치며
오프라인 동기화 구현은 단순히 기능 추가를 넘어 데이터 무결성, 사용자 경험 등 다양한 측면을 고려해야 하는 도전적인 과제였습니다. 특히 함수형 프로그래밍의 원칙들을 실제 프로젝트에 적용해보며 그 가치를 체감할 수 있었습니다.
감사합니다.
'개발지식 정리 > Swift' 카테고리의 다른 글
Understanding Swift Performance 정리 (0) | 2025.01.07 |
---|---|
RxSwift 정리 (0) | 2025.01.06 |
전동 킥보드 대여 서비스 iOS 앱 개발기 (0) | 2024.12.25 |
WidgetKit 활용해 캘린더 위젯 구현하기 (0) | 2024.12.24 |
SwiftUI 심층정리 (0) | 2024.12.16 |