모바일 오프라인 퍼스트 아키텍처 — 동기화 전략과 충돌 해결

모바일 아키텍처

오프라인 퍼스트SQLite동기화CRDTFlutter

이 글은 누구를 위한 것인가

  • 네트워크가 불안정한 환경에서 사용되는 모바일 앱을 만드는 개발자
  • "오프라인 지원 추가해주세요"라는 요청을 받고 어디서부터 시작해야 할지 모르는 팀
  • 동기화 충돌로 데이터 손실이 발생하는 앱을 개선하려는 엔지니어

오프라인 퍼스트란 무엇인가

"오프라인 지원"과 "오프라인 퍼스트"는 다르다.

오프라인 지원: 네트워크가 없을 때 기본적인 기능만 유지. 주로 읽기 캐싱.

오프라인 퍼스트: 네트워크 유무와 관계없이 동일한 사용자 경험을 제공. 오프라인 상태에서도 쓰기, 수정, 삭제가 가능하고, 연결이 복원되면 서버와 동기화한다.

오프라인 퍼스트 앱의 핵심 원칙은 "로컬 DB가 단일 진실의 원천" 이라는 것이다. 서버는 장기 저장소이자 다른 기기와의 동기화 허브다.


아키텍처 개요

┌─────────────────────────────────┐
│            UI Layer              │
│   (화면은 로컬 DB만 바라본다)       │
└──────────────┬──────────────────┘
               │ 읽기/쓰기
┌──────────────▼──────────────────┐
│         Local DB (SQLite)        │
│  - 모든 읽기는 여기서              │
│  - 모든 쓰기는 여기에 먼저          │
└──────────────┬──────────────────┘
               │ 동기화 (백그라운드)
┌──────────────▼──────────────────┐
│         Sync Engine              │
│  - 변경사항 감지 (outbox pattern) │
│  - 충돌 감지 및 해결              │
│  - 재연결 시 자동 동기화           │
└──────────────┬──────────────────┘
               │ API 호출
┌──────────────▼──────────────────┐
│           Server                 │
│  - 영구 저장소                    │
│  - 멀티 기기 동기화 허브            │
└─────────────────────────────────┘

1. 로컬 DB 설계

변경 추적을 위한 필수 컬럼

모든 테이블에 다음 컬럼을 추가한다:

CREATE TABLE todos (
  id          TEXT PRIMARY KEY,    -- 클라이언트 생성 UUID
  title       TEXT NOT NULL,
  completed   INTEGER DEFAULT 0,
  created_at  TEXT NOT NULL,
  updated_at  TEXT NOT NULL,       -- 마지막 수정 시각 (ISO 8601)
  deleted_at  TEXT,                -- soft delete (NULL = 살아있음)
  synced_at   TEXT,                -- 마지막 서버 동기화 시각
  version     INTEGER DEFAULT 1    -- 낙관적 잠금용
);

설계 원칙:

  • id는 서버가 아닌 클라이언트에서 생성한다. UUID v4 또는 ULID 사용. 서버 auto increment를 기다리면 오프라인 생성이 불가능해진다.
  • deleted_at으로 soft delete. 하드 삭제하면 서버에서 삭제 사실을 전파할 수 없다.
  • version은 충돌 감지에 사용된다.

2. Outbox 패턴: 변경사항 추적

로컬에서 발생한 모든 변경사항을 즉시 서버에 전송하지 않는다. 대신 **변경 로그(outbox)**에 기록하고, 네트워크가 연결되면 일괄 처리한다.

CREATE TABLE sync_outbox (
  id           TEXT PRIMARY KEY,
  entity_type  TEXT NOT NULL,    -- 'todos', 'notes', ...
  entity_id    TEXT NOT NULL,
  operation    TEXT NOT NULL,    -- 'CREATE', 'UPDATE', 'DELETE'
  payload      TEXT NOT NULL,    -- JSON
  created_at   TEXT NOT NULL,
  attempts     INTEGER DEFAULT 0,
  last_error   TEXT
);

쓰기 트랜잭션 예시 (Flutter/Drift):

Future<void> updateTodo(Todo todo) async {
  await db.transaction(() async {
    // 1. 로컬 DB 업데이트 (즉시)
    await db.update(db.todos).replace(todo.copyWith(
      updatedAt: DateTime.now(),
      version: todo.version + 1,
    ));

    // 2. outbox에 변경 기록
    await db.into(db.syncOutbox).insert(SyncOutboxCompanion.insert(
      id: const Value(Uuid().v4()),
      entityType: const Value('todos'),
      entityId: Value(todo.id),
      operation: const Value('UPDATE'),
      payload: Value(jsonEncode(todo.toJson())),
      createdAt: Value(DateTime.now()),
    ));
  });
}

UI는 로컬 DB 업데이트 결과를 즉시 반영한다. 사용자는 네트워크 없이도 지연 없이 조작할 수 있다.


3. 동기화 엔진

네트워크 연결 감지

class SyncEngine {
  final Connectivity _connectivity = Connectivity();

  void init() {
    _connectivity.onConnectivityChanged.listen((result) {
      if (result != ConnectivityResult.none) {
        _processPendingOutbox();
        _pullServerChanges();
      }
    });
  }
}

Outbox 처리 (Push)

Future<void> _processPendingOutbox() async {
  final pending = await db.getPendingOutbox(); // attempts < 3 항목만

  for (final item in pending) {
    try {
      await _api.syncChange(item);
      await db.deleteOutboxItem(item.id);
    } catch (e) {
      await db.incrementOutboxAttempts(item.id, e.toString());
    }
  }
}

3회 실패한 항목은 처리를 중단하고 사용자에게 알림을 보낸다. 무한 재시도는 배터리와 서버 모두에 부담이 된다.

서버 변경사항 수신 (Pull)

Future<void> _pullServerChanges() async {
  final lastSync = await db.getLastSyncTimestamp();
  final serverChanges = await _api.getChangesSince(lastSync);

  for (final change in serverChanges) {
    await _applyServerChange(change);
  }

  await db.updateLastSyncTimestamp(DateTime.now());
}

4. 충돌 해결

오프라인 상태에서 로컬 수정이 발생하고, 그 사이 서버(또는 다른 기기)에서도 같은 항목이 수정된 경우 충돌이 발생한다.

Last-Write-Wins (LWW)

가장 단순한 전략. 타임스탬프가 최신인 변경사항이 이긴다.

Future<void> _applyServerChange(ServerChange change) async {
  final local = await db.getTodo(change.entityId);

  if (local == null) {
    // 서버에만 있는 항목 → 로컬에 생성
    await db.insertTodo(change.toTodo());
    return;
  }

  // 타임스탬프 비교
  if (change.updatedAt.isAfter(local.updatedAt)) {
    // 서버 버전이 더 최신 → 서버 버전으로 덮어씀
    await db.update(db.todos).replace(change.toTodo());
  }
  // 로컬이 더 최신이면 아무것도 하지 않음 (outbox가 서버에 push할 것)
}

LWW의 한계: 두 기기에서 동시에 다른 필드를 수정했을 때 한쪽 변경이 사라진다. 할 일 앱의 제목과 완료 상태를 각각 수정했다면, 나중에 동기화된 기기의 전체 상태가 이긴다.

필드 단위 병합

필드별로 최신 값을 채택한다.

Future<void> _mergeChange(LocalTodo local, ServerChange server) async {
  final merged = local.copyWith(
    // 각 필드의 updated_at을 별도 추적
    title: server.titleUpdatedAt.isAfter(local.titleUpdatedAt)
        ? server.title
        : local.title,
    completed: server.completedUpdatedAt.isAfter(local.completedUpdatedAt)
        ? server.completed
        : local.completed,
  );

  await db.update(db.todos).replace(merged);
}

구현이 복잡해지지만 데이터 손실을 줄인다. 필드 수가 적고 각 필드가 독립적일 때 적합하다.

CRDT (Conflict-free Replicated Data Type)

충돌 자체가 발생하지 않도록 데이터 구조를 설계한다. 수학적으로 충돌 없이 병합 가능한 자료구조다.

가장 많이 쓰이는 유형:

  • G-Counter: 증가만 가능한 카운터. "좋아요 수" 같은 카운터에 적합
  • LWW-Register: 필드별 타임스탬프를 갖는 레지스터
  • OR-Set (Observed-Remove Set): 아이템 추가/삭제를 충돌 없이 처리

CRDT는 개념은 강력하지만 구현이 어렵다. 대부분의 앱에서는 직접 구현보다 AutomergeYjs 같은 라이브러리를 쓰는 것이 현실적이다. 협업 편집 기능(구글 독스 형태)이 필요한 경우에만 CRDT를 검토한다.


5. UX: 동기화 상태 표시

오프라인 퍼스트 앱에서 사용자는 자신의 변경사항이 "저장되었는지"를 알고 싶어한다.

상태 표시 원칙:

  • 로컬 저장 완료 → 즉시 성공 UI (체크, "저장됨")
  • 서버 동기화 중 → 조용한 인디케이터 (로딩 스피너 강조 금지)
  • 동기화 실패 → 구체적인 안내 ("나중에 다시 시도됩니다" 또는 "재시도" 버튼)
  • 충돌 발생 → 사용자에게 선택권 제공 (자동 해결이 불가능한 경우만)

오프라인 상태임을 알리는 배너는 방해가 되지 않는 위치에 단 한 번만 표시한다. 매 액션마다 "오프라인입니다" 토스트를 띄우면 사용자가 앱 사용을 포기한다.


실무에서 자주 겪는 함정

1. 서버 ID 의존 오프라인 생성 항목은 서버 ID가 없다. UUID를 클라이언트에서 생성하지 않으면 로컬 항목 간 관계(외래키) 설정이 불가능하다.

2. 삭제 전파 실패 하드 삭제를 쓰면 다른 기기가 삭제 사실을 알 수 없다. Soft delete + deleted_at 전파가 필수다. Soft delete 항목은 서버 정책에 따라 30일 후 hard delete한다.

3. 과도한 재시도 outbox 처리 실패 시 무한 재시도하면 배터리를 소모하고 서버에 부하를 준다. 지수 백오프 + 최대 시도 횟수를 설정한다.

4. 대용량 데이터 전체 동기화 lastSyncedAt 이후 변경된 항목만 가져오는 델타 동기화가 필수다. 전체 데이터를 매번 내려받으면 대역폭과 배터리 모두 문제가 된다.

5. 테스트 없는 충돌 해결 충돌 시나리오를 테스트하지 않으면 실제 사용자 데이터가 손실된다. 단위 테스트에서 다음 시나리오를 반드시 커버한다:

  • 동일 항목을 두 기기에서 동시 수정
  • 오프라인 중 삭제 후 다른 기기에서 수정
  • 연결 복구 후 outbox 대량 처리

맺으며

오프라인 퍼스트는 "나중에 추가하는 기능"이 아니다. 로컬 DB를 단일 진실의 원천으로 삼는 설계는 아키텍처 초기에 결정해야 한다. 나중에 추가하려면 데이터 레이어 전체를 재설계해야 한다.

시작점은 단순하다: UUID를 클라이언트에서 생성하고, 모든 쓰기를 로컬 DB에 먼저 저장하고, outbox로 변경사항을 추적한다. 충돌 해결은 LWW부터 시작해 실제 데이터 손실 사례가 발생할 때 점진적으로 정교화하면 된다.