Ver3.0 ๐ฑ ์คํ๋ผ์ธ์์๋ ๋๋ก์๋ ์ฑ ๋ง๋ค๊ธฐ

๐ฑ ์คํ๋ผ์ธ์์๋ ๋๋ก์๋ ์ฑ ๋ง๋ค๊ธฐ
๋คํธ์ํฌ ๋จ์ ์ํฉ์ ์ฐ์ํ๊ฒ ๋์ํ๋ ์ ๋ต ์์ ์ ๋ณต ๐
๐ฏ ์ ์คํ๋ผ์ธ ๊ธฐ๋ฅ์ด ์ค์ํ ๊น?
์ผ, ์์งํ ๋งํด์ ์์ฆ ๋๊ฐ ์ธํฐ๋ท ์์ด ์ด์? ๐ ๊ทผ๋ฐ ๋ง์ด์ผ, ์งํ์ฒ ํ๊ณ ๊ฐ๋ค๊ฐ ํฐ๋ ๋ค์ด๊ฐ๋ฉด? ๋นํ๊ธฐ ํ๋ฉด? ์ฐ ์ ์บ ํ์ฅ์์? ๊ฐ์๊ธฐ ๋ฐ์ดํฐ ๋ค ์จ๋ฒ๋ ธ์ ๋? ์ด๋ด ๋ ์ฑ์ด ๋จนํต๋๋ฉด ์ง์ง ์ง์ฆ๋์์.์ค์ ๋ก ๊ตฌ๊ธ ์กฐ์ฌ์ ๋ฐ๋ฅด๋ฉด ์ฌ์ฉ์์ ์ฝ 70%๊ฐ ๋คํธ์ํฌ ์ฐ๊ฒฐ์ด ๋ถ์์ ํ ํ๊ฒฝ์์ ์ฑ์ ์ฌ์ฉํ ๊ฒฝํ์ด ์๋. ๊ทธ๋ฆฌ๊ณ ์ฑ์ด ์คํ๋ผ์ธ์์ ์๋ํ์ง ์์ผ๋ฉด ๊ทธ ์ค ์ ๋ฐ ์ด์์ด ์ฑ์ ์ญ์ ํด๋ฒ๋ฆฐ๋ค๋ ๊ฑฐ์ผ. ๋ฌด์ญ์ง? ๐ฑ
๊ทธ๋์ ์ค๋์ ๋คํธ์ํฌ๊ฐ ๋๊ฒจ๋ ์ฌ์ฉ์๊ฐ "์ด? ์ด ์ฑ ๊ด์ฐฎ์๋ฐ?" ํ๊ณ ๊ฐํํ ์ ์๋ ์คํ๋ผ์ธ ๊ธฐ๋ฅ ๊ตฌํ ์ ๋ต์ ํํค์ณ๋ณผ ๊ฑฐ์ผ. ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์๋ ์ด๋ฐ ๊ธฐ์ ์ ๊ฐ์ง ๊ฐ๋ฐ์๋ค์ ๋ง์ด ์ฐพ๋๋ผ๊ณ !
๐จ ์คํ๋ผ์ธ ์ ๋ต์ ํต์ฌ ๊ฐ๋
์คํ๋ผ์ธ ๊ธฐ๋ฅ์ ๊ตฌํํ๊ธฐ ์ ์ ๋จผ์ ์์์ผ ํ ํต์ฌ ๊ฐ๋ ๋ค์ด ์์ด. ์ด๊ฑฐ ๋ชจ๋ฅด๊ณ ์์ํ๋ฉด ๋์ค์ ์ฝ๋ ์ ์ฒด๋ฅผ ๋ค์ง์ด์ผ ํ ์๋ ์๊ฑฐ๋ ! ๐ฐ๋ฐ๋ฉด Offline-First ๋ฐฉ์์ ์์ ํ ๋ค๋ฅธ ์ฒ ํ์ด์ผ. ๋ก์ปฌ ๋ฐ์ดํฐ๋ฅผ ๋จผ์ ๋ณด์ฌ์ฃผ๊ณ , ๋ฐฑ๊ทธ๋ผ์ด๋์์ ์๋ฒ์ ๋๊ธฐํํ๋ ๊ฑฐ์ง. ์ฌ์ฉ์๋ ๋คํธ์ํฌ ์ํ๋ฅผ ๊ฑฐ์ ์์ํ์ง ๋ชปํด. ์ด๊ฒ ์ง์ง ํ๋ก์ ์ ๊ทผ๋ฒ์ด์ผ! ๐ช
โข ๋ก์ปฌ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ ๋จ์ผ ์ง์ค ๊ณต๊ธ์(Single Source of Truth)์ผ๋ก ์ฌ์ฉ
โข ๋ชจ๋ ์ฝ๊ธฐ ์์ ์ ๋ก์ปฌ์์ ์ํ
โข ์ฐ๊ธฐ ์์ ์ ๋ก์ปฌ์ ๋จผ์ ์ ์ฅ ํ ๋ฐฑ๊ทธ๋ผ์ด๋ ๋๊ธฐํ
โข ๋คํธ์ํฌ๋ ๋๊ธฐํ ๋ฉ์ปค๋์ฆ์ผ ๋ฟ, ํ์ ์์๊ฐ ์๋
์ฃผ์ ๋๊ธฐํ ํจํด๋ค:
โข Last Write Wins (LWW)
๊ฐ์ฅ ๋ง์ง๋ง์ ์์ ๋ ๋ฐ์ดํฐ๊ฐ ์น๋ฆฌํ๋ ๋ฐฉ์. ๊ฐ๋จํ์ง๋ง ๋ฐ์ดํฐ ์์ค ์ํ์ด ์์ด. ํ์์คํฌํ ๊ธฐ๋ฐ์ผ๋ก ๋์ํ๊ณ , ๊ตฌํ์ ์ฝ์ง๋ง ๋์ ํธ์ง ์ํฉ์์๋ ๋ฌธ์ ๊ฐ ์๊ธธ ์ ์์ง.
โข Operational Transformation (OT)
๊ตฌ๊ธ ๋ ์ค๊ฐ ์ฌ์ฉํ๋ ๋ฐฉ์์ด์ผ. ๊ฐ ์์ ์ ๋ณํํด์ ์ถฉ๋์ ํด๊ฒฐํด. ๋ณต์กํ์ง๋ง ์ค์๊ฐ ํ์ ์ ์ต์ ํ๋์ด ์์ด.
โข Conflict-free Replicated Data Types (CRDT)
์ํ์ ์ผ๋ก ์ถฉ๋์ด ์๋๋ก ์ค๊ณ๋ ๋ฐ์ดํฐ ๊ตฌ์กฐ์ผ. ์ต๊ทผ ๊ฐ์ฅ ํซํ ๊ธฐ์ ์ด๊ณ , ๋ถ์ฐ ์์คํ ์์ ์๋ฒฝํ๊ฒ ์๋ํด. ํ์ง๋ง ๋ฌ๋ ์ปค๋ธ๊ฐ ์ข ์์ด.
โข Delta Sync
๋ณ๊ฒฝ๋ ๋ถ๋ถ๋ง ๋๊ธฐํํ๋ ๋ฐฉ์. ๋์ญํญ์ ์๋ผ๊ณ ๋ฐฐํฐ๋ฆฌ๋ ์ ์ฝํ ์ ์์ด์ ๋ชจ๋ฐ์ผ ์ฑ์ ๋ฑ์ด์ผ!
๐พ ๋ก์ปฌ ์คํ ๋ฆฌ์ง ์ ํํ๊ธฐ
์คํ๋ผ์ธ ๊ธฐ๋ฅ์ ๊ธฐ๋ฐ์ ๊ฒฐ๊ตญ ๋ก์ปฌ ์คํ ๋ฆฌ์ง์ผ. ์ด๋ค ๊ฑธ ์ ํํ๋๋์ ๋ฐ๋ผ ์ฑ์ ์ฑ๋ฅ๊ณผ ๋ณต์ก๋๊ฐ ์์ ํ ๋ฌ๋ผ์ ธ.| ์คํ ๋ฆฌ์ง ํ์ | ์ฉ๋ | ์๋ | ์ ํฉํ ์ฉ๋ |
|---|---|---|---|
| SharedPreferences / UserDefaults | ~1MB | โกโกโก | ์ค์ ๊ฐ, ๊ฐ๋จํ ํค-๊ฐ ์ ์ฅ |
| SQLite | ์ GB | โกโก | ๊ตฌ์กฐํ๋ ๋ฐ์ดํฐ, ๋ณต์กํ ์ฟผ๋ฆฌ |
| Realm | ์ GB | โกโกโก | ๊ฐ์ฒด ์งํฅ ๋ฐ์ดํฐ, ์ค์๊ฐ ๋๊ธฐํ |
| Core Data (iOS) | ์ GB | โกโก | ๋ณต์กํ ๊ฐ์ฒด ๊ทธ๋ํ, iOS ์ ์ฉ |
| Room (Android) | ์ GB | โกโก | SQLite ๋ํผ, ํ์ ์์ ์ฑ |
| IndexedDB (์น) | ์๋ฐฑ MB | โกโก | ์น ์ฑ, PWA |
โ SQLite๋ Room์ด๋ฉด ์ถฉ๋ถํด. ๊ฒ์ฆ๋ ๊ธฐ์ ์ด๊ณ ์์ ์ ์ด์ผ.
์ค์๊ฐ ๋๊ธฐํ๊ฐ ํ์ํ ์ฑ (์ฑํ , ํ์ ๋๊ตฌ)
โ Realm์ด๋ Firebase Realtime Database๋ฅผ ์ถ์ฒํด. ๋๊ธฐํ ๋ก์ง์ด ๋ด์ฅ๋์ด ์์ด์ ๊ฐ๋ฐ ์๊ฐ์ ์์ฒญ ์๋ ์ ์์ด.
๋ณต์กํ ๋ฐ์ดํฐ ๊ด๊ณ๊ฐ ์๋ ์ฑ (SNS, ์ปค๋จธ์ค)
โ SQLite + ์ปค์คํ ๋๊ธฐํ ๋ ์ด์ด๊ฐ ์ต์ ์ด์ผ. ์ ์ฐ์ฑ์ด ์ต๊ณ ๊ฑฐ๋ .
์น ์ฑ / PWA
โ IndexedDB + Service Worker ์กฐํฉ์ด ํ์ค์ด์ผ. Cache API๋ ํจ๊ป ํ์ฉํ๋ฉด ๊ธ์์ฒจํ!
๐ง ์ค์ ๊ตฌํ ์ ๋ต
์ด์ ์ง์ง ์ฝ๋ ๋ ๋ฒจ์์ ์ด๋ป๊ฒ ๊ตฌํํ๋์ง ์์๋ณด์. ํ๋ซํผ๋ณ๋ก ๋ฒ ์คํธ ํ๋ํฐ์ค๊ฐ ๋ค๋ฅด๋๊น ๊ฐ๊ฐ ์ดํด๋ณผ๊ฒ!1๋จ๊ณ: Room ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ค์
@Entity(tableName = "posts")
data class Post(
@PrimaryKey val id: String,
val title: String,
val content: String,
val timestamp: Long,
@ColumnInfo(name = "sync_status")
val syncStatus: SyncStatus = SyncStatus.SYNCED,
@ColumnInfo(name = "is_deleted")
val isDeleted: Boolean = false
)
enum class SyncStatus {
SYNCED, // ์๋ฒ์ ๋๊ธฐํ๋จ
PENDING, // ๋๊ธฐํ ๋๊ธฐ ์ค
FAILED // ๋๊ธฐํ ์คํจ
}
@Dao
interface PostDao {
@Query("SELECT * FROM posts WHERE is_deleted = 0 ORDER BY timestamp DESC")
fun getAllPosts(): Flow<List<Post>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPost(post: Post)
@Query("SELECT * FROM posts WHERE sync_status != 'SYNCED'")
suspend fun getUnsyncedPosts(): List<Post>
@Update
suspend fun updatePost(post: Post)
}
์ฌ๊ธฐ์ ํต์ฌ์ syncStatus ํ๋์ผ. ์ด๊ฑธ๋ก ์ด๋ค ๋ฐ์ดํฐ๊ฐ ๋๊ธฐํ๊ฐ ํ์ํ์ง ์ถ์ ํ๋ ๊ฑฐ์ง.2๋จ๊ณ: Repository ํจํด์ผ๋ก ๋ฐ์ดํฐ ์์ค ์ถ์ํ
class PostRepository(
private val postDao: PostDao,
private val apiService: ApiService,
private val workManager: WorkManager
) {
// ๋ก์ปฌ ๋ฐ์ดํฐ๋ฅผ UI์ ์ ๊ณต (Offline-First!)
fun getPosts(): Flow<List<Post>> = postDao.getAllPosts()
// ์ ํฌ์คํธ ์์ฑ
suspend fun createPost(title: String, content: String) {
val post = Post(
id = UUID.randomUUID().toString(),
title = title,
content = content,
timestamp = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
// 1. ๋จผ์ ๋ก์ปฌ์ ์ ์ฅ (์ฆ์ UI ๋ฐ์)
postDao.insertPost(post)
// 2. ๋ฐฑ๊ทธ๋ผ์ด๋ ๋๊ธฐํ ์์ฝ
scheduleSyncWork()
}
// ๋ฐฑ๊ทธ๋ผ์ด๋ ๋๊ธฐํ ์์
์์ฝ
private fun scheduleSyncWork() {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.build()
workManager.enqueue(syncRequest)
}
}
์ด ํจํด์ ์๋ฆ๋ค์ด ์ ์ ๋ญ๋๋ฉด, UI ๋ ์ด์ด๋ ๋คํธ์ํฌ ์ํ๋ฅผ ์ ํ ์ ๊ฒฝ ์ธ ํ์๊ฐ ์๋ค๋ ๊ฑฐ์ผ. ๊ทธ๋ฅ Repository์์ ๋ฐ์ดํฐ ๋ฐ์์ ๋ณด์ฌ์ฃผ๊ธฐ๋ง ํ๋ฉด ๋ผ! ๐3๋จ๊ณ: WorkManager๋ก ๋ฐฑ๊ทธ๋ผ์ด๋ ๋๊ธฐํ
class SyncWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
val unsyncedPosts = postDao.getUnsyncedPosts()
unsyncedPosts.forEach { post ->
try {
// ์๋ฒ์ ์
๋ก๋
apiService.uploadPost(post)
// ์ฑ๊ณตํ๋ฉด ์ํ ์
๋ฐ์ดํธ
postDao.updatePost(
post.copy(syncStatus = SyncStatus.SYNCED)
)
} catch (e: Exception) {
// ์คํจํ๋ฉด FAILED๋ก ํ์
postDao.updatePost(
post.copy(syncStatus = SyncStatus.FAILED)
)
}
}
Result.success()
} catch (e: Exception) {
// ์ฌ์๋
Result.retry()
}
}
}
WorkManager๋ ์ง์ง ์ฒ์ฌ์ ์ธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ผ. ๋คํธ์ํฌ ์ฐ๊ฒฐ๋๋ฉด ์๋์ผ๋ก ์คํ๋๊ณ , ์คํจํ๋ฉด exponential backoff๋ก ์ฌ์๋ํ๊ณ , ๋ฐฐํฐ๋ฆฌ๋ ๊ณ ๋ คํด์ ์ต์ ์ ํ์ด๋ฐ์ ์คํ๋ผ. ๐import CoreData
import CloudKit
class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentCloudKitContainer
init() {
container = NSPersistentCloudKitContainer(name: "MyApp")
// CloudKit ๋๊ธฐํ ์ค์
guard let description = container.persistentStoreDescriptions.first else {
fatalError("No store description found")
}
// ์๋ ๋๊ธฐํ ํ์ฑํ
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.com.myapp.container"
)
// ๋คํธ์ํฌ ์์ ๋๋ ์๋ํ๋๋ก ์ค์
description.setOption(true as NSNumber,
forKey: NSPersistentHistoryTrackingKey)
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Core Data store failed: \(error)")
}
}
// ์๋ ๋ณํฉ ์ค์
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}
// SwiftUI View์์ ์ฌ์ฉ
struct PostListView: View {
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Post.timestamp, ascending: false)],
animation: .default
)
private var posts: FetchedResults<Post>
@Environment(\.managedObjectContext) private var viewContext
var body: some View {
List {
ForEach(posts) { post in
PostRow(post: post)
}
}
.toolbar {
Button(action: addPost) {
Label("Add", systemImage: "plus")
}
}
}
private func addPost() {
let newPost = Post(context: viewContext)
newPost.id = UUID()
newPost.title = "New Post"
newPost.timestamp = Date()
// ์ ์ฅ (์๋์ผ๋ก CloudKit ๋๊ธฐํ ํ์ ์ถ๊ฐ๋จ)
try? viewContext.save()
}
}
iOS์ ์ฅ์ ์ NSPersistentCloudKitContainer๊ฐ ๋๊ธฐํ ๋ก์ง์ ๊ฑฐ์ ๋ค ์ฒ๋ฆฌํด์ค๋ค๋ ๊ฑฐ์ผ. ์ถฉ๋ ํด๊ฒฐ, ์ฌ์๋, ๋ฐฐ์น ์ฒ๋ฆฌ ๋ฑ๋ฑ ๋ค ์์์ ํด์ค. ๊ฐ๋ฐ์๋ ๊ทธ๋ฅ Core Data์ ์ ์ฅํ๊ธฐ๋ง ํ๋ฉด ๋ผ!Service Worker ๋ฑ๋ก:
// main.js
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('Service Worker registered:', registration);
})
.catch(error => {
console.error('Service Worker registration failed:', error);
});
}
Service Worker ๊ตฌํ (sw.js):const CACHE_NAME = 'my-app-v1';
const OFFLINE_URL = '/offline.html';
// ์ค์น ์ ํ์ ๋ฆฌ์์ค ์บ์ฑ
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
return cache.addAll([
'/',
'/index.html',
'/styles.css',
'/app.js',
OFFLINE_URL
]);
})
);
self.skipWaiting();
});
// ๋คํธ์ํฌ ์์ฒญ ๊ฐ๋ก์ฑ๊ธฐ
self.addEventListener('fetch', event => {
// API ์์ฒญ ์ฒ๋ฆฌ
if (event.request.url.includes('/api/')) {
event.respondWith(
networkFirstStrategy(event.request)
);
}
// ์ ์ ๋ฆฌ์์ค ์ฒ๋ฆฌ
else {
event.respondWith(
cacheFirstStrategy(event.request)
);
}
});
// Network First ์ ๋ต (API์ฉ)
async function networkFirstStrategy(request) {
try {
const networkResponse = await fetch(request);
// ์ฑ๊ณตํ๋ฉด ์บ์์ ์ ์ฅ
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
return networkResponse;
} catch (error) {
// ๋คํธ์ํฌ ์คํจ ์ ์บ์์์ ๊ฐ์ ธ์ค๊ธฐ
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// ์บ์๋ ์์ผ๋ฉด ์คํ๋ผ์ธ ํ์ด์ง
return caches.match(OFFLINE_URL);
}
}
// Cache First ์ ๋ต (์ ์ ๋ฆฌ์์ค์ฉ)
async function cacheFirstStrategy(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
return networkResponse;
} catch (error) {
return caches.match(OFFLINE_URL);
}
}
IndexedDB๋ก ๋ฐ์ดํฐ ์ ์ฅ:// db.js
class OfflineDB {
constructor() {
this.dbName = 'MyAppDB';
this.version = 1;
this.db = null;
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Posts ์คํ ์ด ์์ฑ
if (!db.objectStoreNames.contains('posts')) {
const store = db.createObjectStore('posts', { keyPath: 'id' });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
};
});
}
async addPost(post) {
const tx = this.db.transaction(['posts'], 'readwrite');
const store = tx.objectStore('posts');
const postWithMeta = {
...post,
id: post.id || Date.now().toString(),
syncStatus: 'pending',
timestamp: Date.now()
};
await store.add(postWithMeta);
// ๋ฐฑ๊ทธ๋ผ์ด๋ ๋๊ธฐํ ํธ๋ฆฌ๊ฑฐ
this.triggerSync();
return postWithMeta;
}
async getAllPosts() {
const tx = this.db.transaction(['posts'], 'readonly');
const store = tx.objectStore('posts');
const index = store.index('timestamp');
return new Promise((resolve, reject) => {
const request = index.getAll();
request.onsuccess = () => resolve(request.result.reverse());
request.onerror = () => reject(request.error);
});
}
async getUnsyncedPosts() {
const tx = this.db.transaction(['posts'], 'readonly');
const store = tx.objectStore('posts');
const index = store.index('syncStatus');
return new Promise((resolve, reject) => {
const request = index.getAll('pending');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async updateSyncStatus(id, status) {
const tx = this.db.transaction(['posts'], 'readwrite');
const store = tx.objectStore('posts');
const post = await store.get(id);
post.syncStatus = status;
await store.put(post);
}
async triggerSync() {
if ('sync' in self.registration) {
// Background Sync API ์ฌ์ฉ
await self.registration.sync.register('sync-posts');
} else {
// ํด๋ฐฑ: ์ฆ์ ๋๊ธฐํ ์๋
this.syncNow();
}
}
async syncNow() {
const unsyncedPosts = await this.getUnsyncedPosts();
for (const post of unsyncedPosts) {
try {
await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(post)
});
await this.updateSyncStatus(post.id, 'synced');
} catch (error) {
console.error('Sync failed for post:', post.id, error);
await this.updateSyncStatus(post.id, 'failed');
}
}
}
}
// ์ฌ์ฉ ์์
const db = new OfflineDB();
await db.init();
// ์ ํฌ์คํธ ์ถ๊ฐ (์คํ๋ผ์ธ์์๋ ์๋)
await db.addPost({
title: 'My Post',
content: 'Hello World'
});
// ๋ชจ๋ ํฌ์คํธ ๊ฐ์ ธ์ค๊ธฐ
const posts = await db.getAllPosts();
์น์ ์ฅ์ ์ ํ๋ซํผ ๋
๋ฆฝ์ ์ด๋ผ๋ ๊ฑฐ์ผ. ํ ๋ฒ ๊ตฌํํ๋ฉด ๋ชจ๋ ๋ธ๋ผ์ฐ์ ์์ ์๋ํ๊ฑฐ๋ . ๋ฌผ๋ก iOS Safari๋ ์ข ๊น๋ค๋กญ๊ธด ํ์ง๋ง... ๐
โก ๋คํธ์ํฌ ์ํ ๊ฐ์ง ๋ฐ ์ฒ๋ฆฌ
์คํ๋ผ์ธ ๊ธฐ๋ฅ์ ๊ตฌํํ๋ค๊ณ ๋์ด ์๋์ผ. ์ฌ์ฉ์์๊ฒ ํ์ฌ ๋คํธ์ํฌ ์ํ๋ฅผ ์๋ ค์ฃผ๊ณ , ์ ์ ํ๊ฒ ๋์ํ๋ ๊ฒ๋ ์ค์ํด.class NetworkMonitor(private val context: Context) {
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
as ConnectivityManager
private val _networkStatus = MutableStateFlow(NetworkStatus.UNKNOWN)
val networkStatus: StateFlow<NetworkStatus> = _networkStatus.asStateFlow()
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
_networkStatus.value = NetworkStatus.AVAILABLE
}
override fun onLost(network: Network) {
_networkStatus.value = NetworkStatus.LOST
}
override fun onCapabilitiesChanged(
network: Network,
capabilities: NetworkCapabilities
) {
val isWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
val isCellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
_networkStatus.value = when {
!hasInternet -> NetworkStatus.NO_INTERNET
isWifi -> NetworkStatus.WIFI
isCellular -> NetworkStatus.CELLULAR
else -> NetworkStatus.AVAILABLE
}
}
}
fun startMonitoring() {
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, networkCallback)
}
fun stopMonitoring() {
connectivityManager.unregisterNetworkCallback(networkCallback)
}
}
enum class NetworkStatus {
UNKNOWN,
AVAILABLE,
WIFI,
CELLULAR,
LOST,
NO_INTERNET
}
// ViewModel์์ ์ฌ์ฉ
class MainViewModel(
private val networkMonitor: NetworkMonitor
) : ViewModel() {
val networkStatus = networkMonitor.networkStatus
init {
networkMonitor.startMonitoring()
viewModelScope.launch {
networkStatus.collect { status ->
when (status) {
NetworkStatus.AVAILABLE, NetworkStatus.WIFI -> {
// ๋๊ธฐํ ํธ๋ฆฌ๊ฑฐ
syncRepository.syncNow()
}
NetworkStatus.CELLULAR -> {
// ์ฌ์ฉ์ ์ค์ ์ ๋ฐ๋ผ ๋๊ธฐํ ์ฌ๋ถ ๊ฒฐ์
if (userPreferences.syncOnCellular) {
syncRepository.syncNow()
}
}
NetworkStatus.LOST, NetworkStatus.NO_INTERNET -> {
// ์คํ๋ผ์ธ ๋ชจ๋ ํ์ฑํ
showOfflineIndicator()
}
else -> {}
}
}
}
}
}
iOS (Swift):import Network
import Combine
class NetworkMonitor: ObservableObject {
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitor")
@Published var isConnected = false
@Published var connectionType: ConnectionType = .unknown
enum ConnectionType {
case wifi
case cellular
case ethernet
case unknown
}
init() {
monitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
self?.isConnected = path.status == .satisfied
if path.usesInterfaceType(.wifi) {
self?.connectionType = .wifi
} else if path.usesInterfaceType(.cellular) {
self?.connectionType = .cellular
} else if path.usesInterfaceType(.wiredEthernet) {
self?.connectionType = .ethernet
} else {
self?.connectionType = .unknown
}
}
}
monitor.start(queue: queue)
}
deinit {
monitor.cancel()
}
}
// SwiftUI View์์ ์ฌ์ฉ
struct ContentView: View {
@StateObject private var networkMonitor = NetworkMonitor()
var body: some View {
VStack {
if !networkMonitor.isConnected {
OfflineBanner()
}
PostListView()
}
.onChange(of: networkMonitor.isConnected) { isConnected in
if isConnected {
// ๋คํธ์ํฌ ๋ณต๊ตฌ ์ ๋๊ธฐํ
SyncManager.shared.syncNow()
}
}
}
}
struct OfflineBanner: View {
var body: some View {
HStack {
Image(systemName: "wifi.slash")
Text("์คํ๋ผ์ธ ๋ชจ๋")
Spacer()
Text("๋ณ๊ฒฝ์ฌํญ์ ์๋์ผ๋ก ์ ์ฅ๋ฉ๋๋ค")
.font(.caption)
}
.padding()
.background(Color.yellow.opacity(0.2))
}
}
์น (JavaScript):class NetworkMonitor {
constructor() {
this.listeners = [];
this.isOnline = navigator.onLine;
window.addEventListener('online', () => this.handleOnline());
window.addEventListener('offline', () => this.handleOffline());
// ์ฃผ๊ธฐ์ ์ผ๋ก ์ค์ ์ฐ๊ฒฐ ํ์ธ (navigator.onLine์ ๋ถ์ ํํ ์ ์์)
this.startHealthCheck();
}
handleOnline() {
this.isOnline = true;
this.notifyListeners('online');
this.triggerSync();
}
handleOffline() {
this.isOnline = false;
this.notifyListeners('offline');
}
async startHealthCheck() {
setInterval(async () => {
try {
const response = await fetch('/api/health', {
method: 'HEAD',
cache: 'no-cache'
});
if (response.ok && !this.isOnline) {
this.handleOnline();
}
} catch (error) {
if (this.isOnline) {
this.handleOffline();
}
}
}, 30000); // 30์ด๋ง๋ค ์ฒดํฌ
}
subscribe(callback) {
this.listeners.push(callback);
return () => {
this.listeners = this.listeners.filter(cb => cb !== callback);
};
}
notifyListeners(status) {
this.listeners.forEach(callback => callback(status));
}
async triggerSync() {
const db = new OfflineDB();
await db.init();
await db.syncNow();
}
}
// ์ฌ์ฉ ์์
const networkMonitor = new NetworkMonitor();
// React ์ปดํฌ๋ํธ์์
function App() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const unsubscribe = networkMonitor.subscribe(status => {
setIsOnline(status === 'online');
});
return unsubscribe;
}, []);
return (
<div>
{!isOnline && (
<div className="offline-banner">
โ ๏ธ ์คํ๋ผ์ธ ๋ชจ๋ - ๋ณ๊ฒฝ์ฌํญ์ ์ฐ๊ฒฐ ๋ณต๊ตฌ ์ ์๋ ๋๊ธฐํ๋ฉ๋๋ค
</div>
)}
<PostList />
</div>
);
}
๐ ์ถฉ๋ ํด๊ฒฐ ์ ๋ต
์คํ๋ผ์ธ ๊ธฐ๋ฅ์ ๊ฐ์ฅ ์ด๋ ค์ด ๋ถ๋ถ์ด ๋ฐ๋ก ์ถฉ๋ ํด๊ฒฐ์ด์ผ. ์ฌ๋ฌ ๊ธฐ๊ธฐ์์ ๋์์ ๊ฐ์ ๋ฐ์ดํฐ๋ฅผ ์์ ํ๋ฉด ์ด๋ป๊ฒ ํด์ผ ํ ๊น? ๐ค๊ฐ์ฅ ๊ฐ๋จํ ๋ฐฉ๋ฒ์ด์ผ. ํ์์คํฌํ๊ฐ ์ต์ ์ธ ๋ฐ์ดํฐ๋ฅผ ์ ํํ๋ ๊ฑฐ์ง.
data class Post(
val id: String,
val content: String,
val lastModified: Long,
val version: Int
)
fun resolveConflict(local: Post, remote: Post): Post {
return if (local.lastModified > remote.lastModified) {
local
} else {
remote
}
}
์ฅ์ : ๊ตฌํ์ด ๋งค์ฐ ๊ฐ๋จํด๋จ์ : ๋ฐ์ดํฐ ์์ค ๊ฐ๋ฅ์ฑ์ด ์์ด. ์ฌ์ฉ์๊ฐ ์ด์ฌํ ์์ฑํ ๋ด์ฉ์ด ๋ ์๊ฐ ์ ์์ง.
2. Version Vector ๊ธฐ๋ฐ
๊ฐ ๊ธฐ๊ธฐ๋ง๋ค ๋ฒ์ ๋ฒํธ๋ฅผ ๊ด๋ฆฌํ๋ ๋ฐฉ์์ด์ผ. ์ข ๋ ์ ๊ตํ ์ถฉ๋ ๊ฐ์ง๊ฐ ๊ฐ๋ฅํด.
data class VersionVector(
val versions: Map<String, Int> // deviceId -> version
) {
fun isNewerThan(other: VersionVector): Boolean {
return versions.all { (deviceId, version) ->
version >= (other.versions[deviceId] ?: 0)
} && versions != other.versions
}
fun hasConflict(other: VersionVector): Boolean {
return !isNewerThan(other) && !other.isNewerThan(this)
}
}
data class Post(
val id: String,
val content: String,
val versionVector: VersionVector
)
fun resolveConflict(local: Post, remote: Post): ConflictResult {
return when {
local.versionVector.isNewerThan(remote.versionVector) -> {
ConflictResult.UseLocal(local)
}
remote.versionVector.isNewerThan(local.versionVector) -> {
ConflictResult.UseRemote(remote)
}
else -> {
// ์ง์ง ์ถฉ๋ ๋ฐ์ - ์ฌ์ฉ์์๊ฒ ์ ํํ๊ฒ ํ๊ฑฐ๋ ๋ณํฉ
ConflictResult.Conflict(local, remote)
}
}
}
sealed class ConflictResult {
data class UseLocal(val post: Post) : ConflictResult()
data class UseRemote(val post: Post) : ConflictResult()
data class Conflict(val local: Post, val remote: Post) : ConflictResult()
}
3. Operational Transformation (OT)๊ตฌ๊ธ ๋ ์ค๊ฐ ์ฌ์ฉํ๋ ๋ฐฉ์์ด์ผ. ๊ฐ ์์ (operation)์ ๋ณํํด์ ์ถฉ๋์ ํด๊ฒฐํด.
// ๊ฐ๋จํ ํ
์คํธ OT ์์
sealed class Operation {
data class Insert(val position: Int, val text: String) : Operation()
data class Delete(val position: Int, val length: Int) : Operation()
}
fun transform(op1: Operation, op2: Operation): Pair<Operation, Operation> {
return when {
op1 is Operation.Insert && op2 is Operation.Insert -> {
if (op1.position < op2.position) {
op1 to Operation.Insert(op2.position + op1.text.length, op2.text)
} else {
Operation.Insert(op1.position + op2.text.length, op1.text) to op2
}
}
op1 is Operation.Delete && op2 is Operation.Insert -> {
if (op2.position <= op1.position) {
Operation.Delete(op1.position + op2.text.length, op1.length) to op2
} else if (op2.position >= op1.position + op1.length) {
op1 to Operation.Insert(op2.position - op1.length, op2.text)
} else {
// ๋ณต์กํ ๊ฒฝ์ฐ...
op1 to op2
}
}
// ๋ ๋ง์ ๊ฒฝ์ฐ๋ค...
else -> op1 to op2
}
}
OT๋ ์ง์ง ๋ณต์กํด. ์ค์๊ฐ ํ์
์ด ํ์ํ ๊ฒ ์๋๋ผ๋ฉด ๊ตณ์ด ๊ตฌํํ ํ์ ์์ด. ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฐ๋ ๊ฒ ๋์.4. CRDT (Conflict-free Replicated Data Types)
์ํ์ ์ผ๋ก ์ถฉ๋์ด ์๋๋ก ์ค๊ณ๋ ๋ฐ์ดํฐ ๊ตฌ์กฐ์ผ. ์ต๊ทผ ๊ฐ์ฅ ํซํ ๊ธฐ์ ์ด์ง.
// G-Counter (Grow-only Counter) CRDT ์์
class GCounter(private val deviceId: String) {
private val counts = mutableMapOf<String, Int>()
fun increment() {
counts[deviceId] = (counts[deviceId] ?: 0) + 1
}
fun value(): Int {
return counts.values.sum()
}
fun merge(other: GCounter) {
other.counts.forEach { (device, count) ->
counts[device] = maxOf(counts[device] ?: 0, count)
}
}
}
// LWW-Element-Set (Last-Write-Wins Element Set) CRDT
class LWWSet<T> {
private val addSet = mutableMapOf<T, Long>()
private val removeSet = mutableMapOf<T, Long>()
fun add(element: T, timestamp: Long = System.currentTimeMillis()) {
addSet[element] = timestamp
}
fun remove(element: T, timestamp: Long = System.currentTimeMillis()) {
removeSet[element] = timestamp
}
fun contains(element: T): Boolean {
val addTime = addSet[element] ?: return false
val removeTime = removeSet[element] ?: return true
return addTime > removeTime
}
fun merge(other: LWWSet<T>) {
other.addSet.forEach { (element, timestamp) ->
addSet[element] = maxOf(addSet[element] ?: 0, timestamp)
}
other.removeSet.forEach { (element, timestamp) ->
removeSet[element] = maxOf(removeSet[element] ?: 0, timestamp)
}
}
fun elements(): Set<T> {
return addSet.keys.filter { contains(it) }.toSet()
}
}
CRDT๋ ์ง์ง ๋ฉ์์ด. ์ด๋ค ์์๋ก ๋ณํฉํด๋ ๊ฒฐ๊ณผ๊ฐ ๊ฐ๊ฑฐ๋ . ๋ถ์ฐ ์์คํ
์ ์ฑ๋ฐฐ๋ผ๊ณ ํ ์ ์์ง!// Android Compose UI ์์
@Composable
fun ConflictResolutionDialog(
localPost: Post,
remotePost: Post,
onResolve: (Post) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("์ถฉ๋ ๋ฐ์") },
text = {
Column {
Text("๊ฐ์ ๊ฒ์๋ฌผ์ด ๋ค๋ฅธ ๊ธฐ๊ธฐ์์ ์์ ๋์์ต๋๋ค.")
Spacer(modifier = Modifier.height(16.dp))
Text("๋ด ๋ฒ์ :", fontWeight = FontWeight.Bold)
Text(localPost.content, modifier = Modifier.padding(8.dp))
Text("์์ ์๊ฐ: ${formatTime(localPost.lastModified)}",
style = MaterialTheme.typography.caption)
Spacer(modifier = Modifier.height(16.dp))
Text("์๋ฒ ๋ฒ์ :", fontWeight = FontWeight.Bold)
Text(remotePost.content, modifier = Modifier.padding(8.dp))
Text("์์ ์๊ฐ: ${formatTime(remotePost.lastModified)}",
style = MaterialTheme.typography.caption)
}
},
confirmButton = {
TextButton(onClick = { onResolve(localPost) }) {
Text("๋ด ๋ฒ์ ์ฌ์ฉ")
}
},
dismissButton = {
TextButton(onClick = { onResolve(remotePost) }) {
Text("์๋ฒ ๋ฒ์ ์ฌ์ฉ")
}
}
)
}
๐จ ์ฌ์ฉ์ ๊ฒฝํ ์ต์ ํ
๊ธฐ์ ์ ์ผ๋ก ์๋ฒฝํ๊ฒ ๊ตฌํํ์ด๋ UX๊ฐ ๋ณ๋ก๋ฉด ์์ฉ์์ด. ์คํ๋ผ์ธ ๊ธฐ๋ฅ์ ์ง๊ฐ๋ ์ฌ์ฉ์๊ฐ ์ผ๋ง๋ ์์ฐ์ค๋ฝ๊ฒ ๋๋ผ๋๋์ ๋ฌ๋ ค์๊ฑฐ๋ ! ๐// ViewModel ์์
class PostViewModel : ViewModel() {
private val _posts = MutableStateFlow<List<Post>>(emptyList())
val posts: StateFlow<List<Post>> = _posts.asStateFlow()
fun createPost(title: String, content: String) {
val tempPost = Post(
id = "temp_${UUID.randomUUID()}",
title = title,
content = content,
timestamp = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
// 1. ์ฆ์ UI ์
๋ฐ์ดํธ (๋๊ด์ )
_posts.value = listOf(tempPost) + _posts.value
// 2. ๋ฐฑ๊ทธ๋ผ์ด๋์์ ์๋ฒ ์ ์ก
viewModelScope.launch {
try {
val serverPost = repository.createPost(tempPost)
// 3. ์ฑ๊ณตํ๋ฉด ์์ ID๋ฅผ ์ค์ ID๋ก ๊ต์ฒด
_posts.value = _posts.value.map { post ->
if (post.id == tempPost.id) serverPost else post
}
} catch (e: Exception) {
// 4. ์คํจํ๋ฉด ๋กค๋ฐฑํ๊ณ ์๋ฌ ํ์
_posts.value = _posts.value.filter { it.id != tempPost.id }
showError("๊ฒ์๋ฌผ ์์ฑ ์คํจ. ๋์ค์ ๋ค์ ์๋๋ฉ๋๋ค.")
}
}
}
}
์ด๋ ๊ฒ ํ๋ฉด ์ฌ์ฉ์๋ ์ฆ๊ฐ์ ์ธ ํผ๋๋ฐฑ์ ๋ฐ์. ๋คํธ์ํฌ๊ฐ ๋๋ ค๋ ์ฑ์ด ๋น ๋ฅด๊ฒ ๋๊ปด์ง์ง! ๐@Composable
fun PostItem(post: Post) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(post.title, style = MaterialTheme.typography.h6)
Text(post.content)
}
// ๋๊ธฐํ ์ํ ์์ด์ฝ
when (post.syncStatus) {
SyncStatus.SYNCED -> {
Icon(
Icons.Default.CloudDone,
contentDescription = "๋๊ธฐํ๋จ",
tint = Color.Green
)
}
SyncStatus.PENDING -> {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
SyncStatus.FAILED -> {
Icon(
Icons.Default.CloudOff,
contentDescription = "๋๊ธฐํ ์คํจ",
tint = Color.Red
)
}
}
}
}
}
@Composable
fun PostListSkeleton() {
Column {
repeat(5) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Box(
modifier = Modifier
.fillMaxWidth(0.7f)
.height(24.dp)
.background(
brush = Brush.horizontalGradient(
colors = listOf(
Color.LightGray.copy(alpha = 0.6f),
Color.LightGray.copy(alpha = 0.2f),
Color.LightGray.copy(alpha = 0.6f)
)
),
shape = RoundedCornerShape(4.dp)
)
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(16.dp)
.background(
brush = Brush.horizontalGradient(
colors = listOf(
Color.LightGray.copy(alpha = 0.6f),
Color.LightGray.copy(alpha = 0.2f),
Color.LightGray.copy(alpha = 0.6f)
)
),
shape = RoundedCornerShape(4.dp)
)
)
}
}
}
}
}
@Composable
fun OfflineBanner(isVisible: Boolean) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut()
) {
Surface(
color = Color(0xFFFFF9C4),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.CloudOff,
contentDescription = null,
tint = Color(0xFFF57C00)
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
"์คํ๋ผ์ธ ๋ชจ๋",
fontWeight = FontWeight.Bold,
color = Color(0xFFF57C00)
)
Text(
"๋ณ๊ฒฝ์ฌํญ์ ์๋์ผ๋ก ์ ์ฅ๋ฉ๋๋ค",
style = MaterialTheme.typography.caption
)
}
}
}
}
}
โ๏ธ ๊ณ ๊ธ ์ต์ ํ ๊ธฐ๋ฒ
๊ธฐ๋ณธ์ ์ธ ์คํ๋ผ์ธ ๊ธฐ๋ฅ์ ๊ตฌํํ๋ค๋ฉด, ์ด์ ์ฑ๋ฅ๊ณผ ํจ์จ์ฑ์ ๊ทน๋ํํ ์ฐจ๋ก์ผ! ๐ฅdata class SyncDelta(
val added: List<Post>,
val modified: List<Post>,
val deleted: List<String>, // ID๋ง
val lastSyncTimestamp: Long
)
class DeltaSyncManager(
private val localDb: PostDao,
private val apiService: ApiService
) {
private var lastSyncTimestamp: Long = 0
suspend fun performDeltaSync() {
// 1. ๋ง์ง๋ง ๋๊ธฐํ ์ดํ ๋ก์ปฌ ๋ณ๊ฒฝ์ฌํญ ์์ง
val localDelta = collectLocalChanges()
// 2. ์๋ฒ์ ๋ก์ปฌ ๋ณ๊ฒฝ์ฌํญ ์ ์กํ๊ณ ์๋ฒ ๋ณ๊ฒฝ์ฌํญ ๋ฐ๊ธฐ
val serverDelta = apiService.sync(localDelta, lastSyncTimestamp)
// 3. ์๋ฒ ๋ณ๊ฒฝ์ฌํญ์ ๋ก์ปฌ์ ์ ์ฉ
applyServerChanges(serverDelta)
// 4. ํ์์คํฌํ ์
๋ฐ์ดํธ
lastSyncTimestamp = System.currentTimeMillis()
}
private suspend fun collectLocalChanges(): SyncDelta {
val added = localDb.getPostsAddedAfter(lastSyncTimestamp)
val modified = localDb.getPostsModifiedAfter(lastSyncTimestamp)
val deleted = localDb.getDeletedPostIds(lastSyncTimestamp)
return SyncDelta(added, modified, deleted, lastSyncTimestamp)
}
private suspend fun applyServerChanges(delta: SyncDelta) {
// ์ถ๊ฐ๋ ํญ๋ชฉ
delta.added.forEach { post ->
localDb.insertPost(post.copy(syncStatus = SyncStatus.SYNCED))
}
// ์์ ๋ ํญ๋ชฉ
delta.modified.forEach { post ->
val localPost = localDb.getPostById(post.id)
if (localPost != null) {
// ์ถฉ๋ ํด๊ฒฐ
val resolved = resolveConflict(localPost, post)
localDb.updatePost(resolved)
} else {
localDb.insertPost(post)
}
}
// ์ญ์ ๋ ํญ๋ชฉ
delta.deleted.forEach { id ->
localDb.deletePost(id)
}
}
}
๋ธํ ๋๊ธฐํ๋ฅผ ์ฌ์ฉํ๋ฉด ๋ฐ์ดํฐ ์ ์ก๋์ 90% ์ด์ ์ค์ผ ์ ์์ด! ํนํ ๋ชจ๋ฐ์ผ์์ ๋ฐฐํฐ๋ฆฌ์ ๋ฐ์ดํฐ๋ฅผ ์๋ ์ ์์ง. ๐ฑ๐class BatchSyncManager(
private val syncManager: DeltaSyncManager
) {
private val pendingChanges = mutableListOf<Change>()
private var syncJob: Job? = null
fun queueChange(change: Change) {
pendingChanges.add(change)
scheduleBatchSync()
}
private fun scheduleBatchSync() {
// ๊ธฐ์กด ์์
์ทจ์
syncJob?.cancel()
// ์ ์์
์์ฝ (5์ด ํ ๋๋ 100๊ฐ ๋ชจ์ด๋ฉด)
syncJob = CoroutineScope(Dispatchers.IO).launch {
delay(5000) // 5์ด ๋๊ธฐ
if (pendingChanges.size >= 100) {
// ์ฆ์ ๋๊ธฐํ
performBatchSync()
} else {
// 5์ด ํ ๋๊ธฐํ
performBatchSync()
}
}
}
private suspend fun performBatchSync() {
if (pendingChanges.isEmpty()) return
val batch = pendingChanges.toList()
pendingChanges.clear()
try {
syncManager.syncBatch(batch)
} catch (e: Exception) {
// ์คํจํ ํญ๋ชฉ ๋ค์ ํ์ ์ถ๊ฐ
pendingChanges.addAll(batch)
}
}
}
class CompressionHelper {
fun compress(data: String): ByteArray {
val output = ByteArrayOutputStream()
GZIPOutputStream(output).use { gzip ->
gzip.write(data.toByteArray(Charsets.UTF_8))
}
return output.toByteArray()
}
fun decompress(compressed: ByteArray): String {
val input = ByteArrayInputStream(compressed)
val output = ByteArrayOutputStream()
GZIPInputStream(input).use { gzip ->
gzip.copyTo(output)
}
return output.toString(Charsets.UTF_8.name())
}
}
// API ํธ์ถ ์ ์ฌ์ฉ
class ApiService {
suspend fun syncWithCompression(delta: SyncDelta): SyncDelta {
val json = Json.encodeToString(delta)
val compressed = CompressionHelper().compress(json)
val response = httpClient.post("/api/sync") {
header("Content-Encoding", "gzip")
setBody(compressed)
}
val responseData = response.body<ByteArray>()
val decompressed = CompressionHelper().decompress(responseData)
return Json.decodeFromString(decompressed)
}
}
ํ
์คํธ ๋ฐ์ดํฐ๋ ์์ถ๋ฅ ์ด ์์ฒญ ์ข์. 70-80% ์ ๋ ์ค์ผ ์ ์๊ฑฐ๋ ! ๐class IncrementalBackupManager(
private val context: Context
) {
private val backupDir = File(context.filesDir, "backups")
suspend fun createIncrementalBackup() {
val timestamp = System.currentTimeMillis()
val backupFile = File(backupDir, "backup_$timestamp.json")
// ๋ง์ง๋ง ๋ฐฑ์
์ดํ ๋ณ๊ฒฝ๋ ๋ฐ์ดํฐ๋ง ์ถ์ถ
val changes = getChangesSinceLastBackup()
// JSON์ผ๋ก ์ง๋ ฌํ
val json = Json.encodeToString(changes)
// ์์ถํด์ ์ ์ฅ
val compressed = CompressionHelper().compress(json)
backupFile.writeBytes(compressed)
// ์ค๋๋ ๋ฐฑ์
์ ๋ฆฌ
cleanupOldBackups()
}
suspend fun restoreFromBackups() {
val backupFiles = backupDir.listFiles()
?.sortedBy { it.name }
?: return
// ๋ชจ๋ ์ฆ๋ถ ๋ฐฑ์
์ ์์๋๋ก ์ ์ฉ
backupFiles.forEach { file ->
val compressed = file.readBytes()
val json = CompressionHelper().decompress(compressed)
val changes = Json.decodeFromString<BackupData>(json)
applyBackup(changes)
}
}
private fun cleanupOldBackups() {
val files = backupDir.listFiles()?.sortedByDescending { it.name }
// ์ต๊ทผ 10๊ฐ๋ง ์ ์ง
files?.drop(10)?.forEach { it.delete() }
}
}
๐ ๋ณด์ ๊ณ ๋ ค์ฌํญ
์คํ๋ผ์ธ ๊ธฐ๋ฅ์ ๊ตฌํํ ๋ ๋ณด์์ ๊ฐ๊ณผํ๋ฉด ํฐ์ผ๋! ๋ก์ปฌ์ ๋ฐ์ดํฐ๋ฅผ ์ ์ฅํ๋ค๋ ๊ฑด ๋ณด์ ๋ฆฌ์คํฌ๊ฐ ์ฆ๊ฐํ๋ค๋ ๋ป์ด๊ฑฐ๋ . ๐ฐAndroid (EncryptedSharedPreferences & SQLCipher):
// EncryptedSharedPreferences ์ฌ์ฉ
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val encryptedPrefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
// ํ ํฐ ์ ์ฅ
encryptedPrefs.edit()
.putString("auth_token", token)
.apply()
// SQLCipher๋ก ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ํธํ
@Database(entities = [Post::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun postDao(): PostDao
companion object {
fun create(context: Context, passphrase: CharArray): AppDatabase {
val factory = SupportFactory(passphrase)
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"app_database"
)
.openHelperFactory(factory)
.build()
}
}
}
iOS (Keychain & Core Data Encryption):import Security
class KeychainHelper {
static func save(key: String, data: Data) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
static func load(key: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
return status == errSecSuccess ? result as? Data : nil
}
}
// Core Data ์ํธํ
let description = NSPersistentStoreDescription()
description.setOption(
FileProtectionType.complete as NSObject,
forKey: NSPersistentStoreFileProtectionKey
)
class SecureTokenManager(private val context: Context) {
private val encryptedPrefs = createEncryptedPrefs()
fun saveToken(token: String, expiresAt: Long) {
encryptedPrefs.edit()
.putString("access_token", token)
.putLong("expires_at", expiresAt)
.apply()
}
fun getToken(): String? {
val token = encryptedPrefs.getString("access_token", null)
val expiresAt = encryptedPrefs.getLong("expires_at", 0)
// ๋ง๋ฃ ํ์ธ
if (System.currentTimeMillis() > expiresAt) {
clearToken()
return null
}
return token
}
fun clearToken() {
encryptedPrefs.edit().clear().apply()
}
private fun createEncryptedPrefs(): SharedPreferences {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
context,
"auth_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
}
import java.security.MessageDigest
class DataIntegrityChecker {
fun calculateHash(data: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(data.toByteArray())
return hash.joinToString("") { "%02x".format(it) }
}
fun verifyIntegrity(data: String, expectedHash: String): Boolean {
val actualHash = calculateHash(data)
return actualHash == expectedHash
}
}
data class SecurePost(
val id: String,
val content: String,
val hash: String // ๋ด์ฉ์ ํด์๊ฐ
) {
fun verify(): Boolean {
val checker = DataIntegrityChecker()
return checker.verifyIntegrity(content, hash)
}
companion object {
fun create(id: String, content: String): SecurePost {
val checker = DataIntegrityChecker()
val hash = checker.calculateHash(content)
return SecurePost(id, content, hash)
}
}
}
- โ ๏ธ ๋ณด์ ์ฒดํฌ๋ฆฌ์คํธ:
- โ ๋ฏผ๊ฐํ ๋ฐ์ดํฐ๋ ์ํธํํด์ ์ ์ฅ
- โ ์ธ์ฆ ํ ํฐ์ Keychain/EncryptedSharedPreferences ์ฌ์ฉ
- โ ๋ฐ์ดํฐ๋ฒ ์ด์ค ํ์ผ ์ํธํ (SQLCipher ๋ฑ)
- โ ๋คํธ์ํฌ ํต์ ์ HTTPS/TLS ํ์
- โ Certificate Pinning ๊ณ ๋ ค
- โ ๋ฃจํ /ํ์ฅ ๊ธฐ๊ธฐ ๊ฐ์ง
- โ ์ฑ ๋ฐฑ๊ทธ๋ผ์ด๋ ์ ํ๋ฉด ๋ธ๋ฌ ์ฒ๋ฆฌ
๐งช ํ ์คํธ ์ ๋ต
์คํ๋ผ์ธ ๊ธฐ๋ฅ์ ํ ์คํธํ๊ธฐ ๊น๋ค๋ก์. ๋ค์ํ ๋คํธ์ํฌ ์ํฉ์ ์๋ฎฌ๋ ์ด์ ํด์ผ ํ๊ฑฐ๋ . ์ ๋๋ก ํ ์คํธํ๋ ๋ฐฉ๋ฒ์ ์์๋ณด์! ๐ฌ@Test
fun `๋๊ธฐํ ์ถฉ๋ ํด๊ฒฐ ํ
์คํธ`() = runTest {
// Given
val localPost = Post(
id = "1",
content = "Local content",
lastModified = 1000L,
version = 1
)
val remotePost = Post(
id = "1",
content = "Remote content",
lastModified = 2000L,
version = 2
)
// When
val resolved = conflictResolver.resolve(localPost, remotePost)
// Then
assertEquals(remotePost, resolved) // ๋ ์ต์ ๋ฒ์ ์ ํ
}
@Test
fun `์คํ๋ผ์ธ ์ํ์์ ๋ฐ์ดํฐ ์ ์ฅ ํ
์คํธ`() = runTest {
// Given
val repository = PostRepository(mockDao, mockApi, mockWorkManager)
coEvery { mockApi.createPost(any()) } throws IOException("No network")
// When
repository.createPost("Title", "Content")
// Then
coVerify { mockDao.insertPost(any()) } // ๋ก์ปฌ์๋ ์ ์ฅ๋จ
coVerify { mockWorkManager.enqueue(any()) } // ๋๊ธฐํ ์์
์์ฝ๋จ
}
@Test
fun `๋ธํ ๋๊ธฐํ ํ
์คํธ`() = runTest {
// Given
val localChanges = SyncDelta(
added = listOf(post1, post2),
modified = listOf(post3),
deleted = listOf("4"),
lastSyncTimestamp = 1000L
)
coEvery { mockApi.sync(any(), any()) } returns SyncDelta(
added = listOf(post5),
modified = emptyList(),
deleted = emptyList(),
lastSyncTimestamp = 2000L
)
// When
syncManager.performDeltaSync()
// Then
coVerify { mockDao.insertPost(post5) }
assertEquals(2000L, syncManager.lastSyncTimestamp)
}
@RunWith(AndroidJUnit4::class)
class OfflineSyncIntegrationTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
private lateinit var database: AppDatabase
private lateinit var repository: PostRepository
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.allowMainThreadQueries()
.build()
repository = PostRepository(
database.postDao(),
FakeApiService(),
FakeWorkManager()
)
}
@After
fun tearDown() {
database.close()
}
@Test
fun `์คํ๋ผ์ธ์์ ์จ๋ผ์ธ์ผ๋ก ์ ํ ์ ์๋ ๋๊ธฐํ`() = runTest {
// Given: ์คํ๋ผ์ธ ์ํ์์ ํฌ์คํธ ์์ฑ
val networkMonitor = FakeNetworkMonitor(isOnline = false)
repository.createPost("Test", "Content")
// When: ์จ๋ผ์ธ ์ ํ
networkMonitor.setOnline(true)
delay(1000) // ๋๊ธฐํ ๋๊ธฐ
// Then: ์๋ฒ์ ๋๊ธฐํ๋จ
val syncedPosts = repository.getSyncedPosts()
assertEquals(1, syncedPosts.size)
assertEquals(SyncStatus.SYNCED, syncedPosts[0].syncStatus)
}
@Test
fun `์ฌ๋ฌ ๊ธฐ๊ธฐ์์ ๋์ ์์ ์ ์ถฉ๋ ํด๊ฒฐ`() = runTest {
// Given: ๊ฐ์ ํฌ์คํธ๋ฅผ ๋ ๊ธฐ๊ธฐ์์ ์์
val device1Post = Post("1", "Device 1 content", 1000L, 1)
val device2Post = Post("1", "Device 2 content", 2000L, 1)
// When: ๋๊ธฐํ
repository.syncPost(device1Post)
repository.syncPost(device2Post)
// Then: ์ต์ ๋ฒ์ ์ด ์ ํ๋จ
val finalPost = repository.getPost("1")
assertEquals("Device 2 content", finalPost.content)
}
}
@RunWith(AndroidJUnit4::class)
class OfflineUITest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun `์คํ๋ผ์ธ ๋ฐฐ๋ ํ์ ํ
์คํธ`() {
// Given: ๋คํธ์ํฌ ๋๊ธฐ
setNetworkState(false)
// Then: ์คํ๋ผ์ธ ๋ฐฐ๋ ํ์๋จ
onView(withId(R.id.offline_banner))
.check(matches(isDisplayed()))
onView(withText("์คํ๋ผ์ธ ๋ชจ๋"))
.check(matches(isDisplayed()))
}
@Test
fun `์คํ๋ผ์ธ์์ ํฌ์คํธ ์์ฑ ๋ฐ ๋๊ธฐํ ํ์`() {
// Given: ์คํ๋ผ์ธ ์ํ
setNetworkState(false)
// When: ํฌ์คํธ ์์ฑ
onView(withId(R.id.fab_add)).perform(click())
onView(withId(R.id.edit_title)).perform(typeText("Test Post"))
onView(withId(R.id.edit_content)).perform(typeText("Test Content"))
onView(withId(R.id.btn_save)).perform(click())
// Then: ํฌ์คํธ๊ฐ ํ์๋๊ณ ๋๊ธฐํ ๋๊ธฐ ์์ด์ฝ ํ์
onView(withText("Test Post")).check(matches(isDisplayed()))
onView(withId(R.id.sync_status_icon))
.check(matches(withTagValue(equalTo("pending"))))
// When: ์จ๋ผ์ธ ์ ํ
setNetworkState(true)
Thread.sleep(2000) // ๋๊ธฐํ ๋๊ธฐ
// Then: ๋๊ธฐํ ์๋ฃ ์์ด์ฝ์ผ๋ก ๋ณ๊ฒฝ
onView(withId(R.id.sync_status_icon))
.check(matches(withTagValue(equalTo("synced"))))
}
private fun setNetworkState(isOnline: Boolean) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val uiAutomation = instrumentation.uiAutomation
if (isOnline) {
uiAutomation.executeShellCommand("svc wifi enable")
uiAutomation.executeShellCommand("svc data enable")
} else {
uiAutomation.executeShellCommand("svc wifi disable")
uiAutomation.executeShellCommand("svc data disable")
}
Thread.sleep(1000) // ์ํ ๋ณ๊ฒฝ ๋๊ธฐ
}
}
Charles Proxy / Proxyman
- ๋คํธ์ํฌ ์๋ ์ ํ (3G, 4G, 5G ์๋ฎฌ๋ ์ด์ )
- ํน์ ์์ฒญ ์ฐจ๋จ
- ์๋ต ์ง์ฐ ์ถ๊ฐ
- ์๋ฌ ์๋ต ์ฃผ์
Android Emulator Network Settings
- ๋คํธ์ํฌ ์๋ ์กฐ์
- ํจํท ์์ค ์๋ฎฌ๋ ์ด์
- ์ง์ฐ ์๊ฐ ์ถ๊ฐ
iOS Network Link Conditioner
- ๋ค์ํ ๋คํธ์ํฌ ํ๋กํ์ผ
- ์ปค์คํ ๋คํธ์ํฌ ์กฐ๊ฑด ์ค์
- โ ์์ ์คํ๋ผ์ธ ์ํ์์ ์ฑ ์ฌ์ฉ
- โ ๋๋ฆฐ ๋คํธ์ํฌ (2G/3G)
- โ ๋ถ์์ ํ ๋คํธ์ํฌ (์ฐ๊ฒฐ/๋๊น ๋ฐ๋ณต)
- โ ๋๊ธฐํ ์ค ๋คํธ์ํฌ ๋๊น
- โ ๋์ฉ๋ ๋ฐ์ดํฐ ๋๊ธฐํ
- โ ์ฌ๋ฌ ๊ธฐ๊ธฐ์์ ๋์ ์์
- โ ์ฑ ๊ฐ์ ์ข ๋ฃ ํ ์ฌ์์
- โ ๋ฐฐํฐ๋ฆฌ ๋ถ์กฑ ์ํ
- โ ์ ์ฅ ๊ณต๊ฐ ๋ถ์กฑ
- โ ์๊ฐ๋ ๋ณ๊ฒฝ
๐ ๋ชจ๋ํฐ๋ง ๋ฐ ๋ถ์
์คํ๋ผ์ธ ๊ธฐ๋ฅ์ ๋ฐฐํฌํ ํ์๋ ๊ณ์ ๋ชจ๋ํฐ๋งํด์ผ ํด. ์ค์ ์ฌ์ฉ์ ํ๊ฒฝ์์ ์ด๋ป๊ฒ ์๋ํ๋์ง ํ์ ํ๋ ๊ฒ ์ค์ํ๊ฑฐ๋ ! ๐| ์งํ | ์ค๋ช | ๋ชฉํ |
|---|---|---|
| ๋๊ธฐํ ์ฑ๊ณต๋ฅ | ์ ์ฒด ๋๊ธฐํ ์๋ ์ค ์ฑ๊ณตํ ๋น์จ | > 95% |
| ํ๊ท ๋๊ธฐํ ์๊ฐ | ๋๊ธฐํ ์๋ฃ๊น์ง ๊ฑธ๋ฆฌ๋ ํ๊ท ์๊ฐ | < 5์ด |
| ์ถฉ๋ ๋ฐ์๋ฅ | ๋๊ธฐํ ์ ์ถฉ๋์ด ๋ฐ์ํ ๋น์จ | < 1% |
| ์คํ๋ผ์ธ ์ฌ์ฉ ์๊ฐ | ์ฌ์ฉ์๊ฐ ์คํ๋ผ์ธ ์ํ๋ก ์ฑ์ ์ฌ์ฉํ ์๊ฐ | - |
| ๋ก์ปฌ ๋ฐ์ดํฐ ํฌ๊ธฐ | ๊ธฐ๊ธฐ์ ์ ์ฅ๋ ๋ฐ์ดํฐ ์ฉ๋ | < 100MB |
| ๋ฐฐํฐ๋ฆฌ ์๋ชจ๋ | ๋๊ธฐํ๋ก ์ธํ ๋ฐฐํฐ๋ฆฌ ์ฌ์ฉ๋ | < 5%/์ผ |
class SyncLogger {
private val analytics = FirebaseAnalytics.getInstance(context)
fun logSyncStart(itemCount: Int) {
val bundle = Bundle().apply {
putInt("item_count", itemCount)
putLong("timestamp", System.currentTimeMillis())
}
analytics.logEvent("sync_started", bundle)
}
fun logSyncSuccess(duration: Long, itemsSynced: Int) {
val bundle = Bundle().apply {
putLong("duration_ms", duration)
putInt("items_synced", itemsSynced)
putString("network_type", getNetworkType())
}
analytics.logEvent("sync_success", bundle)
}
fun logSyncFailure(error: String, itemCount: Int) {
val bundle = Bundle().apply {
putString("error_type", error)
putInt("items_pending", itemCount)
putString("network_type", getNetworkType())
}
analytics.logEvent("sync_failure", bundle)
}
fun logConflict(postId: String, resolutionMethod: String) {
val bundle = Bundle().apply {
putString("post_id", postId)
putString("resolution_method", resolutionMethod)
}
analytics.logEvent("sync_conflict", bundle)
}
fun logOfflineUsage(duration: Long, actionsPerformed: Int) {
val bundle = Bundle().apply {
putLong("offline_duration_ms", duration)
putInt("actions_performed", actionsPerformed)
}
analytics.logEvent("offline_usage", bundle)
}
private fun getNetworkType(): String {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return "none"
val capabilities = cm.getNetworkCapabilities(network) ?: return "unknown"
return when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
else -> "other"
}
}
}
class PerformanceMonitor {
private val performanceMonitoring = FirebasePerformance.getInstance()
fun trackSync(block: suspend () -> Unit) {
val trace = performanceMonitoring.newTrace("sync_operation")
trace.start()
val startTime = System.currentTimeMillis()
var success = false
try {
block()
success = true
} catch (e: Exception) {
trace.putAttribute("error", e.message ?: "unknown")
throw e
} finally {
val duration = System.currentTimeMillis() - startTime
trace.putMetric("duration_ms", duration)
trace.putAttribute("success", success.toString())
trace.stop()
}
}
fun trackDatabaseOperation(operation: String, block: () -> Unit) {
val trace = performanceMonitoring.newTrace("db_$operation")
trace.start()
try {
block()
} finally {
trace.stop()
}
}
}
๋์๋ณด๋์ ํฌํจํ ๋ด์ฉ:
- ์ค์๊ฐ ๋๊ธฐํ ์ฑ๊ณต/์คํจ์จ
- ํ๊ท ๋๊ธฐํ ์๊ฐ ์ถ์ด
- ๋คํธ์ํฌ ํ์ ๋ณ ์ฑ๋ฅ
- ์ถฉ๋ ๋ฐ์ ๋น๋
- ์คํ๋ผ์ธ ์ฌ์ฉ ํจํด
- ์๋ฌ ๋ก๊ทธ ๋ฐ ์คํ ํธ๋ ์ด์ค
- ์ฌ์ฉ์ ํผ๋๋ฐฑ
๐ ์ค์ ํ๋ก์ ํธ ์์
์ด๋ก ๋ง ์์์๋ ๋ถ์กฑํด. ์ค์ ํ๋ก์ ํธ์ ์ด๋ป๊ฒ ์ ์ฉํ๋์ง ์์๋ฅผ ๋ณด์! ๐ช์ฃผ์ ๊ธฐ๋ฅ:
- ๋ ธํธ ์์ฑ/์์ /์ญ์
- ์คํ๋ผ์ธ์์ ์์ ํ ์๋
- ์๋ ๋๊ธฐํ
- ์ถฉ๋ ํด๊ฒฐ
- ์ด๋ฏธ์ง ์ฒจ๋ถ (์บ์ฑ)
์ํคํ ์ฒ:
// 1. ๋ฐ์ดํฐ ๋ชจ๋ธ
@Entity(tableName = "notes")
data class Note(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val title: String,
val content: String,
val imageUrl: String? = null,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val syncStatus: SyncStatus = SyncStatus.PENDING,
val isDeleted: Boolean = false,
val version: Int = 1
)
// 2. DAO
@Dao
interface NoteDao {
@Query("SELECT * FROM notes WHERE isDeleted = 0 ORDER BY updatedAt DESC")
fun getAllNotes(): Flow<List<Note>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertNote(note: Note)
@Update
suspend fun updateNote(note: Note)
@Query("UPDATE notes SET isDeleted = 1, syncStatus = 'PENDING' WHERE id = :id")
suspend fun softDeleteNote(id: String)
@Query("SELECT * FROM notes WHERE syncStatus != 'SYNCED'")
suspend fun getUnsyncedNotes(): List<Note>
}
// 3. Repository
class NoteRepository(
private val noteDao: NoteDao,
private val apiService: ApiService,
private val imageCache: ImageCache,
private val syncManager: SyncManager
) {
// ๋
ธํธ ๋ชฉ๋ก (๋ก์ปฌ ๋ฐ์ดํฐ)
fun getNotes(): Flow<List<Note>> = noteDao.getAllNotes()
// ๋
ธํธ ์์ฑ
suspend fun createNote(title: String, content: String, imageUri: Uri?) {
val note = Note(
title = title,
content = content,
imageUrl = imageUri?.toString()
)
// ๋ก์ปฌ์ ์ ์ฅ
noteDao.insertNote(note)
// ์ด๋ฏธ์ง๊ฐ ์์ผ๋ฉด ์บ์ฑ
imageUri?.let { uri ->
imageCache.cacheImage(note.id, uri)
}
// ๋๊ธฐํ ์์ฝ
syncManager.scheduleSyncWork()
}
// ๋
ธํธ ์์
suspend fun updateNote(note: Note) {
val updatedNote = note.copy(
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING,
version = note.version + 1
)
noteDao.updateNote(updatedNote)
syncManager.scheduleSyncWork()
}
// ๋
ธํธ ์ญ์ (Soft Delete)
suspend fun deleteNote(id: String) {
noteDao.softDeleteNote(id)
syncManager.scheduleSyncWork()
}
// ๋๊ธฐํ
suspend fun sync() {
val unsyncedNotes = noteDao.getUnsyncedNotes()
unsyncedNotes.forEach { note ->
try {
when {
note.isDeleted -> {
apiService.deleteNote(note.id)
}
note.version == 1 -> {
apiService.createNote(note)
}
else -> {
val serverNote = apiService.getNote(note.id)
val resolved = resolveConflict(note, serverNote)
apiService.updateNote(resolved)
}
}
noteDao.updateNote(note.copy(syncStatus = SyncStatus.SYNCED))
} catch (e: Exception) {
noteDao.updateNote(note.copy(syncStatus = SyncStatus.FAILED))
}
}
}
private fun resolveConflict(local: Note, remote: Note): Note {
return if (local.updatedAt > remote.updatedAt) local else remote
}
}
// 4. ViewModel
class NoteListViewModel(
private val repository: NoteRepository,
private val networkMonitor: NetworkMonitor
) : ViewModel() {
val notes = repository.getNotes()
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val isOnline = networkMonitor.networkStatus
.map { it == NetworkStatus.AVAILABLE }
.stateIn(viewModelScope, SharingStarted.Lazily, false)
fun createNote(title: String, content: String, imageUri: Uri?) {
viewModelScope.launch {
repository.createNote(title, content, imageUri)
}
}
fun updateNote(note: Note) {
viewModelScope.launch {
repository.updateNote(note)
}
}
fun deleteNote(id: String) {
viewModelScope.launch {
repository.deleteNote(id)
}
}
fun syncNow() {
viewModelScope.launch {
repository.sync()
}
}
}
// 5. Compose UI
@Composable
fun NoteListScreen(viewModel: NoteListViewModel) {
val notes by viewModel.notes.collectAsState()
val isOnline by viewModel.isOnline.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("๋ด ๋
ธํธ") },
actions = {
if (!isOnline) {
Icon(
Icons.Default.CloudOff,
contentDescription = "์คํ๋ผ์ธ",
tint = Color.Gray
)
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = { /* ์ ๋
ธํธ */ }) {
Icon(Icons.Default.Add, contentDescription = "์ถ๊ฐ")
}
}
) { padding ->
LazyColumn(
modifier = Modifier.padding(padding)
) {
if (!isOnline) {
item {
OfflineBanner()
}
}
items(notes, key = { it.id }) { note ->
NoteItem(
note = note,
onEdit = { viewModel.updateNote(it) },
onDelete = { viewModel.deleteNote(it.id) }
)
}
}
}
}
@Composable
fun NoteItem(
note: Note,
onEdit: (Note) -> Unit,
onDelete: (Note) -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable { onEdit(note) }
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
note.title,
style = MaterialTheme.typography.h6
)
Text(
note.content,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
formatDate(note.updatedAt),
style = MaterialTheme.typography.caption,
color = Color.Gray
)
}
// ๋๊ธฐํ ์ํ
when (note.syncStatus) {
SyncStatus.SYNCED -> {
Icon(
Icons.Default.CloudDone,
contentDescription = "๋๊ธฐํ๋จ",
tint = Color.Green,
modifier = Modifier.size(20.dp)
)
}
SyncStatus.PENDING -> {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
}
SyncStatus.FAILED -> {
Icon(
Icons.Default.Error,
contentDescription = "์คํจ",
tint = Color.Red,
modifier = Modifier.size(20.dp)
)
}
}
}
}
}
์ด ์์๋ ์ค์ ๋ก ์๋ํ๋ ์์ ํ ์คํ๋ผ์ธ ๋
ธํธ ์ฑ์ด์ผ. ์ด๊ฑธ ๊ธฐ๋ฐ์ผ๋ก ๋ ๋ณต์กํ ๊ธฐ๋ฅ์ ์ถ๊ฐํ ์ ์์ด! ๐
๐ก ๋ฒ ์คํธ ํ๋ํฐ์ค & ํ
์ง๊ธ๊น์ง ๋ฐฐ์ด ๋ด์ฉ์ ์ ๋ฆฌํ๊ณ , ์ค์ ์์ ์ ์ฉํ ํ๋ค์ ๋ชจ์๋ดค์ด! ๐1. ํญ์ Offline-First๋ก ์ค๊ณํ๊ธฐ
์ฒ์๋ถํฐ ์คํ๋ผ์ธ์ ๊ณ ๋ คํด์ ์ค๊ณํด์ผ ํด. ๋์ค์ ์ถ๊ฐํ๋ ค๋ฉด ์ ์ฒด ๊ตฌ์กฐ๋ฅผ ๋ค์ง์ด์ผ ํ ์๋ ์์ด.
2. ๋๊ด์ UI ์ ๋ฐ์ดํธ ์ฌ์ฉ
์ฌ์ฉ์ ์ก์ ์ ์ฆ์ ๋ฐ์ํด. ์๋ฒ ์๋ต์ ๊ธฐ๋ค๋ฆฌ์ง ๋ง.
3. ๋ช ํํ ๋๊ธฐํ ์ํ ํ์
์ฌ์ฉ์๊ฐ ํ์ฌ ์ํ๋ฅผ ์ ์ ์๋๋ก ์๊ฐ์ ํผ๋๋ฐฑ ์ ๊ณต.
4. ๋ธํ ๋๊ธฐํ ๊ตฌํ
์ ์ฒด ๋ฐ์ดํฐ๊ฐ ์๋ ๋ณ๊ฒฝ์ฌํญ๋ง ๋๊ธฐํํด์ ํจ์จ์ฑ ๊ทน๋ํ.
5. ์ถฉ๋ ํด๊ฒฐ ์ ๋ต ๋ช ํํ ํ๊ธฐ
์ด๋ค ๋ฐฉ์์ผ๋ก ์ถฉ๋์ ํด๊ฒฐํ ์ง ๋ฏธ๋ฆฌ ์ ํ๊ณ ์ผ๊ด๋๊ฒ ์ ์ฉ.
6. ๋ฏผ๊ฐํ ๋ฐ์ดํฐ ์ํธํ
๋ก์ปฌ์ ์ ์ฅ๋๋ ๋ฐ์ดํฐ๋ ๋ฐ๋์ ์ํธํ.
7. ์ฒ ์ ํ ํ ์คํธ
๋ค์ํ ๋คํธ์ํฌ ์ํฉ์ ์๋ฎฌ๋ ์ด์ ํด์ ํ ์คํธ.
8. ๋ชจ๋ํฐ๋ง ๋ฐ ๋ก๊น
์ค์ ์ฌ์ฉ์ ํ๊ฒฝ์์์ ๋์์ ์ถ์ ํ๊ณ ๋ถ์.
1. ๋คํธ์ํฌ ์ํ์ ๋ฐ๋ผ ๊ธฐ๋ฅ ์ ํํ์ง ์๊ธฐ
์คํ๋ผ์ธ์ด๋ผ๊ณ ์ฑ์ ๋ชป ์ฐ๊ฒ ๋ง๋ค์ง ๋ง. ๋ชจ๋ ๊ธฐ๋ฅ์ด ์๋ํด์ผ ํด.
2. ๋๊ธฐํ ์คํจ๋ฅผ ์ฌ์ฉ์์๊ฒ ๋ ๋๊ธฐ์ง ์๊ธฐ
์๋์ผ๋ก ์ฌ์๋ํ๊ณ , ์ ๋ง ์ด์ฉ ์ ์์ ๋๋ง ์๋ ค์ค.
3. ๊ณผ๋ํ ๋๊ธฐํ ๋น๋
๋ฐฐํฐ๋ฆฌ์ ๋ฐ์ดํฐ๋ฅผ ๋ญ๋นํ์ง ๋ง. ์ ์ ํ ๊ฐ๊ฒฉ์ผ๋ก ๋ฐฐ์น ์ฒ๋ฆฌ.
4. ์ถฉ๋ ํด๊ฒฐ์ ์ฌ์ฉ์์๊ฒ ๋งก๊ธฐ์ง ์๊ธฐ
๊ฐ๋ฅํ ํ ์๋์ผ๋ก ํด๊ฒฐํ๊ณ , ์ตํ์ ์๋จ์ผ๋ก๋ง ์ฌ์ฉ์์๊ฒ ๋ฌผ์ด๋ด.
5. ๋ก์ปฌ ๋ฐ์ดํฐ ๋ฌดํ์ ์์๋๊ธฐ
์ค๋๋ ๋ฐ์ดํฐ๋ ์ ๋ฆฌํ๊ณ , ์ ์ฅ ๊ณต๊ฐ์ ๊ด๋ฆฌํด.
6. ๋ณด์ ๋ฌด์ํ๊ธฐ
๋ก์ปฌ ์ ์ฅ์๋ ํดํน ์ํ์ด ์์ด. ์ํธํ๋ ํ์!
7. ์๋ฌ ์ฒ๋ฆฌ ์ํํ ํ๊ธฐ
๋ชจ๋ ์์ธ ์ํฉ์ ์ฒ๋ฆฌํ๊ณ , ์ฐ์ํ๊ฒ ๋ณต๊ตฌํด์ผ ํด.
- ์ ์ ํ ์ธ๋ฑ์ค ์์ฑ
- ์ฟผ๋ฆฌ ์ต์ ํ
- ํ์ด์ง๋ค์ด์ ๊ตฌํ
- ๋ถํ์ํ ๋ฐ์ดํฐ ์ ๋ฆฌ
โ ๋คํธ์ํฌ ์ต์ ํ
- ๋ธํ ๋๊ธฐํ
- ์์ถ ์ฌ์ฉ
- ๋ฐฐ์น ์ฒ๋ฆฌ
- ์ ์ ํ ํ์์์ ์ค์
โ ๋ฐฐํฐ๋ฆฌ ์ต์ ํ
- ๋ฐฑ๊ทธ๋ผ์ด๋ ์์ ์ต์ํ
- WiFi ์ฐ๊ฒฐ ์ ๋๊ธฐํ ์ฐ์
- Doze ๋ชจ๋ ๊ณ ๋ ค
- WorkManager ํ์ฉ
โ ์ ์ฅ ๊ณต๊ฐ ์ต์ ํ
- ์ด๋ฏธ์ง ์์ถ
- ์บ์ ํฌ๊ธฐ ์ ํ
- ์ค๋๋ ๋ฐ์ดํฐ ์ ๋ฆฌ
- ์ฌ์ฉ์ ์ค์ ์ ๊ณต
๐ ํ์ต ๋ฆฌ์์ค ๋ฐ ๋ค์ ๋จ๊ณ
์คํ๋ผ์ธ ๊ธฐ๋ฅ ๊ตฌํ์ ๋ํด ๋ ๊น์ด ๊ณต๋ถํ๊ณ ์ถ๋ค๋ฉด? ์ฌ๊ธฐ ์ถ์ฒ ๋ฆฌ์์ค๋ค์ด์ผ! ๐- Android Developers - Offline-First Architecture
- Apple Developer - Core Data & CloudKit
- Google Developers - Progressive Web Apps
- Firebase - Offline Capabilities
์คํ์์ค ๋ผ์ด๋ธ๋ฌ๋ฆฌ:
- Realm - ๋ชจ๋ฐ์ผ ๋ฐ์ดํฐ๋ฒ ์ด์ค
- WatermelonDB - React Native์ฉ ์คํ๋ผ์ธ DB
- PouchDB - JavaScript ์คํ๋ผ์ธ DB
- Yjs - CRDT ๊ธฐ๋ฐ ํ์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ
1. ๊ฐ๋จํ To-Do ์ฑ์ ์คํ๋ผ์ธ ๊ธฐ๋ฅ ์ถ๊ฐ
2. SQLite/Room ๋ง์คํฐํ๊ธฐ
3. WorkManager๋ก ๋ฐฑ๊ทธ๋ผ์ด๋ ๋๊ธฐํ ๊ตฌํ
4. ๊ธฐ๋ณธ์ ์ธ ์ถฉ๋ ํด๊ฒฐ (LWW) ๊ตฌํ
์ค๊ธ โ ๊ณ ๊ธ:
1. ๋ธํ ๋๊ธฐํ ๊ตฌํ
2. CRDT ํ์ต ๋ฐ ์ ์ฉ
3. ๋ณต์กํ ์ถฉ๋ ํด๊ฒฐ ์๋๋ฆฌ์ค ์ฒ๋ฆฌ
4. ์ฑ๋ฅ ์ต์ ํ (์์ถ, ๋ฐฐ์น ์ฒ๋ฆฌ)
5. ๋ณด์ ๊ฐํ (์ํธํ, ์ธ์ฆ)
๊ณ ๊ธ โ ์ ๋ฌธ๊ฐ:
1. ์ปค์คํ ๋๊ธฐํ ํ๋กํ ์ฝ ์ค๊ณ
2. ๋ถ์ฐ ์์คํ ์ด๋ก ํ์ต
3. ๋๊ท๋ชจ ์คํ๋ผ์ธ ์ฑ ์ํคํ ์ฒ ์ค๊ณ
4. ์ค์๊ฐ ํ์ ๊ธฐ๋ฅ ๊ตฌํ
5. ์คํ์์ค ๊ธฐ์ฌ
- ๋์ด๋: โญโญ
- ํ์ต ํฌ์ธํธ: ๊ธฐ๋ณธ CRUD, ๋๊ธฐํ, ์ด๋ฏธ์ง ์บ์ฑ
2. ์คํ๋ผ์ธ ๋ด์ค ๋ฆฌ๋
- ๋์ด๋: โญโญโญ
- ํ์ต ํฌ์ธํธ: ์ปจํ ์ธ ์บ์ฑ, ๋ฐฑ๊ทธ๋ผ์ด๋ ์ ๋ฐ์ดํธ, ์ ์ฅ ๊ณต๊ฐ ๊ด๋ฆฌ
3. ์คํ๋ผ์ธ ์ฑํ ์ฑ
- ๋์ด๋: โญโญโญโญ
- ํ์ต ํฌ์ธํธ: ์ค์๊ฐ ๋๊ธฐํ, ์ถฉ๋ ํด๊ฒฐ, ๋ฉ์์ง ํ
4. ํ์ ๋ฌธ์ ํธ์ง๊ธฐ
- ๋์ด๋: โญโญโญโญโญ
- ํ์ต ํฌ์ธํธ: OT/CRDT, ์ค์๊ฐ ํ์ , ๋ณต์กํ ์ถฉ๋ ํด๊ฒฐ
5. ์คํ๋ผ์ธ ์ง๋ ์ฑ
- ๋์ด๋: โญโญโญโญ
- ํ์ต ํฌ์ธํธ: ํ์ผ ์บ์ฑ, ๋์ฉ๋ ๋ฐ์ดํฐ ๊ด๋ฆฌ, ์์น ๊ธฐ๋ฐ ์๋น์ค
๐ ๋ง๋ฌด๋ฆฌํ๋ฉฐ
์! ์ฌ๊ธฐ๊น์ง ์ฝ์๋ค๋ฉด ์ ๋ง ๋๋จํด! ๐๐๐์คํ๋ผ์ธ ๊ธฐ๋ฅ ๊ตฌํ์ ์ฝ์ง ์์ง๋ง, ์ฌ์ฉ์ ๊ฒฝํ์ ์์ฒญ๋๊ฒ ํฅ์์ํฌ ์ ์๋ ๊ฐ๋ ฅํ ๊ธฐ์ ์ด์ผ. ๋คํธ์ํฌ๊ฐ ๋ถ์์ ํ ํ๊ฒฝ์์๋ ๋๊น ์์ด ์๋ํ๋ ์ฑ์ ๋ง๋ค ์ ์๋ค๋ฉด, ๊ทธ๊ฒ ๋ฐ๋ก ์ง์ ํ ํ๋ก ๊ฐ๋ฐ์์ ์ค๋ ฅ์ด์ง! ๐ช
์ฒ์์๋ ๋ณต์กํ๊ฒ ๋๊ปด์ง ์ ์์ด. ํ์ง๋ง ํ๋์ฉ ์ฐจ๊ทผ์ฐจ๊ทผ ๊ตฌํํ๋ค ๋ณด๋ฉด ์ด๋์ ๋ง์คํฐํ๊ฒ ๋ ๊ฑฐ์ผ. ์์ ํ๋ก์ ํธ๋ถํฐ ์์ํด์ ์ ์ ๋ณต์กํ ๊ธฐ๋ฅ์ ์ถ๊ฐํด๋ณด์!
๊ทธ๋ฆฌ๊ณ ํผ์ ๊ณต๋ถํ๊ธฐ ํ๋ค๋ค๋ฉด? ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์ ๋ฉํ ๋ฅผ ์ฐพ๊ฑฐ๋, ์ค์ ํ๋ก์ ํธ์ ์ฐธ์ฌํด๋ณด๋ ๊ฒ๋ ์ข์ ๋ฐฉ๋ฒ์ด์ผ. ์ค์ ํ๋ก์ ํธ๋ฅผ ๊ฒฝํํ๋ฉด์ ๋ฐฐ์ฐ๋ ๊ฒ ๊ฐ์ฅ ๋น ๋ฅด๊ฑฐ๋ ! ๐
๊ธฐ์ตํด:
โจ ์ฌ์ฉ์๋ ๋คํธ์ํฌ ์ํ๋ฅผ ์์ํ์ง ์์์ผ ํด
โจ ๋ชจ๋ ์์ ์ ์ฆ์ ๋ฐ์๋์ด์ผ ํด
โจ ๋๊ธฐํ๋ ๋ฐฑ๊ทธ๋ผ์ด๋์์ ์กฐ์ฉํ ์ผ์ด๋์ผ ํด
โจ ์ถฉ๋์ ์๋์ผ๋ก ํด๊ฒฐ๋์ด์ผ ํด
โจ ๋ณด์์ ์ ๋ ํํํ์ง ๋ง
์, ์ด์ ๋น์ ์ฐจ๋ก์ผ! ๋ฉ์ง ์คํ๋ผ์ธ ์ฑ์ ๋ง๋ค์ด๋ณด์! ํ์ดํ ! ๐ฅ๐ฅ๐ฅ
๊ด๋ จ ํค์๋
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.

๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.