Flutter 오프라인 퍼스트 아키텍처 — OfflineQueue와 SyncService
BrainFit은 미니게임을 통해 두뇌를 훈련하는 RPG 앱이다. 플레이어는 게임을 클리어하면서 BQ(Brain Quotient)를 올리고, 행성을 진화시킨다. 이 앱의 주 사용 환경이 문제인데 -- 지하철, 엘리베이터, 지하 카페처럼 네트워크가 불안정한 곳에서 많이 플레이한다.
한 판이 60초 정도 걸리는 게임이다. 역 사이 터널에서 게임을 클리어하고, BQ 점수가 오르고, Elo 레이팅이 갱신되고, 협동 미션에 기여하는 것까지 -- 이 모든 게 네트워크가 죽어 있는 상태에서 일어날 수 있다. "인터넷 연결을 확인해주세요" 같은 에러를 띄우면서 결과를 날려버리면, 유저 입장에서는 앱이 망가진 것이다.
그래서 BrainFit은 처음부터 오프라인 퍼스트로 설계했다. 핵심 원칙은 하나다: 네트워크 요청 실패가 유저 액션의 실패 원인이 되어서는 안 된다. 모든 변경 사항은 로컬 OfflineQueue에 먼저 저장하고, 네트워크가 돌아오면 SyncService가 큐를 비우고, Pro 구독 같은 중요 상태는 로컬에 캐시한다. 이 글에서 실제 BrainFit 코드를 보여주면서 구현을 설명하겠다.
아키텍처 3요소
오프라인 퍼스트 아키텍처는 세 가지 요소로 구성된다:
- OfflineQueue -- 대기 중인 액션을 저장하는 로컬 SQLite 테이블
- SyncService -- 큐를 언제, 어떻게 비울지 조율하는 오케스트레이터
- ConnectivityService -- 실시간 네트워크 상태 감지
하나씩 살펴보자.
1. OfflineQueue 테이블 설계
BrainFit은 Drift(구 Moor)를 SQLite ORM으로 사용한다. OfflineQueue 테이블은 의도적으로 최소한으로 설계했다:
/// 오프라인 액션 큐
class OfflineQueue extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get actionType => text()();
TextColumn get payload => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
IntColumn get retryCount => integer().withDefault(const Constant(0))();
}
자동 증가 PK 포함 5개 컬럼이 전부다. 각 설계 결정에는 이유가 있다:
actionType은 enum이 아니라 문자열이다. 새로운 액션 타입을 추가해도 스키마 마이그레이션이 필요 없다. 현재 사용하는 타입은bq_push,feed_push,subscription_sync세 가지다.payload는 JSON 문자열이다. 각 액션 타입마다 페이로드 구조가 다르지만,jsonEncode로 직렬화하면 테이블 스키마를 안정적으로 유지할 수 있다. 액션 타입이 아무리 늘어나도 테이블 구조는 변하지 않는다.createdAt은 현재 시각이 기본값이다. FIFO 순서로 큐를 처리할 때 이 값을 기준으로 정렬한다. 순서가 중요한 이유는 나중에 설명한다.retryCount는 해당 아이템의 처리 시도 횟수를 추적한다. 3번 실패하면 불량 아이템으로 간주하고 제거한다.
DAO 레이어
OfflineQueueDao는 5가지 연산을 제공한다:
@DriftAccessor(tables: [OfflineQueue])
class OfflineQueueDao extends DatabaseAccessor<AppDatabase>
with _$OfflineQueueDaoMixin {
OfflineQueueDao(super.db);
Future<void> enqueue(String actionType, String payload) async {
await into(offlineQueue).insert(OfflineQueueCompanion.insert(
actionType: actionType,
payload: payload,
));
}
Future<List<OfflineQueueData>> getPending() async {
return (select(offlineQueue)
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
.get();
}
Future<void> remove(int id) async {
await (delete(offlineQueue)..where((t) => t.id.equals(id))).go();
}
Future<void> incrementRetry(int id) async {
await (update(offlineQueue)..where((t) => t.id.equals(id)))
.write(OfflineQueueCompanion.custom(
retryCount: offlineQueue.retryCount + const Constant(1),
));
}
Future<void> removeStale() async {
await (delete(offlineQueue)
..where((t) => t.retryCount.isBiggerOrEqualValue(3)))
.go();
}
}
주목할 점:
getPending()은createdAt오름차순으로 정렬한다. FIFO가 보장된다. 플레이어가 BQ 100을 벌고, 그 다음 BQ 50을 벌었다면 서버에도 그 순서대로 반영되어야 한다.incrementRetry()는 Drift의 expression 기반 업데이트를 사용해서 retry count를 원자적으로 증가시킨다. read-then-write 경쟁 조건이 없다.removeStale()은 retry count가 3 이상인 아이템을 삭제한다. 일종의 dead-letter 큐다. 3번 실패한 아이템은 복구 불가능한 것으로 간주한다 -- Supabase RLS가 거부했거나, 앱 버전 업데이트로 페이로드 형식이 바뀌었을 수 있다.
2. ConnectivityService: 네트워크 상태 감지
큐를 비우려면 먼저 온라인인지 알아야 한다. BrainFit은 connectivity_plus 패키지를 얇은 서비스로 감쌌다:
class ConnectivityService {
final Connectivity _connectivity = Connectivity();
bool _isOnline = true;
StreamSubscription<List<ConnectivityResult>>? _subscription;
bool get isOnline => _isOnline;
/// 테스트용 수동 설정
void setOnline(bool value) => _isOnline = value;
Future<void> init() async {
final results = await _connectivity.checkConnectivity();
_isOnline = !results.contains(ConnectivityResult.none);
_subscription = _connectivity.onConnectivityChanged.listen((results) {
_isOnline = !results.contains(ConnectivityResult.none);
});
}
void dispose() {
_subscription?.cancel();
}
}
초기화 시 현재 상태를 확인하고, 이후 변경 사항을 스트림으로 리슨한다. setOnline() 메서드는 순수하게 테스트용이다 -- 플랫폼 채널을 모킹하지 않고도 오프라인 시나리오를 시뮬레이션할 수 있다.
한 가지 미묘한 점: connectivity_plus는 네트워크 인터페이스 가용성을 알려줄 뿐, 실제 인터넷 도달 가능성은 알려주지 않는다. Wi-Fi에 연결되어 있어도 캡티브 포탈 뒤에 갇혀 있을 수 있다. 실제로 이 엣지 케이스는 모바일 게임에서 드물어서 HTTP 핑 체크를 추가하지 않았다. flush가 실패하면 재시도 메커니즘이 처리한다.
3. SyncService: 오케스트레이터
SyncService는 놀라울 정도로 작다:
class SyncService {
final SocialRepository _socialRepo;
final CoopMissionService _coopService;
final ConnectivityService _connectivity;
SyncService(this._socialRepo, this._coopService, this._connectivity);
/// 게임 완료 후 호출 — BQ push + 오프라인 큐 flush
Future<void> onGameComplete({
required String gameId,
required String area,
required int score,
required int totalBq,
required int planetStage,
required Map<String, double> elos,
String? galaxyId,
}) async {
// 1. BQ/Elo → Supabase push
await _socialRepo.pushBqUpdate(
totalBq: totalBq,
planetStage: planetStage,
elos: elos,
);
// 2. 오프라인 큐 flush
await _socialRepo.flushOfflineQueue();
}
/// 앱 foreground 복귀 시 호출
Future<void> onAppResume() async {
if (!_connectivity.isOnline) return;
await _socialRepo.flushOfflineQueue();
await _socialRepo.heartbeat();
}
}
트리거 포인트가 두 군데다:
- 게임 완료 후 --
onGameComplete()이 최신 BQ/Elo 데이터를 푸시하고, 대기 중인 큐 아이템을 처리한다.pushBqUpdate()자체가 오프라인이면 큐에 넣으므로, flush가 새 아이템과 기존 아이템을 한꺼번에 처리한다. - 앱이 포그라운드로 돌아올 때 --
onAppResume()이 먼저 연결 상태를 확인한다. 온라인이면 큐를 비우고 하트비트(소셜 기능용last_active_at갱신)를 보낸다.
이 두 트리거 방식이 대부분의 시나리오를 커버한다. 플레이어가 지하에서 게임을 클리어하면 BQ push가 큐에 들어간다. 다음 게임을 클리어할 때 (이때는 지상일 수 있다) flush가 이전 큐 아이템과 새 아이템을 모두 처리한다. 앱을 완전히 닫았다가 나중에 다시 열면 onAppResume()이 처리한다.
큐 Flush: 핵심 로직
실제 flush 로직은 SocialRepository.flushOfflineQueue()에 있다:
Future<void> flushOfflineQueue() async {
if (!_isOnline || _userId == null) return;
final items = await _db.offlineQueueDao.getPending();
for (final item in items) {
try {
final data = jsonDecode(item.payload) as Map<String, dynamic>;
switch (item.actionType) {
case 'bq_push':
final uid = data.remove('_user_id') as String?;
if (uid != null) {
await _supabase!
.schema('brainfit')
.from('user_profiles')
.update(data)
.eq('id', uid);
}
case 'feed_push':
await _supabase!
.schema('brainfit')
.from('social_feed')
.insert(data);
case 'subscription_sync':
final user = Supabase.instance.client.auth.currentUser;
if (user != null) {
final tier = await PurchaseService.getCurrentTier();
final info = await Purchases.getCustomerInfo();
String? productId;
String? expiresAt;
if (info.entitlements.active.containsKey('pro')) {
final entitlement = info.entitlements.active['pro']!;
productId = entitlement.productIdentifier;
expiresAt = entitlement.expirationDate;
}
await _supabase!
.schema('brainfit')
.from('user_profiles')
.update({
'subscription_tier': tier.name,
'subscription_expires_at': expiresAt,
'subscription_product_id': productId,
}).eq('id', user.id);
}
default:
break;
}
await _db.offlineQueueDao.remove(item.id);
} catch (_) {
await _db.offlineQueueDao.incrementRetry(item.id);
}
}
await _db.offlineQueueDao.removeStale();
}
설계 결정을 풀어보자.
순차 처리
아이템을 하나씩 FIFO 순서로 처리한다. 처리량을 위해 병렬 처리를 고려했지만, 순차 정렬이 BQ 업데이트가 올바른 시간 순서로 Supabase에 도착하는 것을 보장한다. 플레이어가 BQ 500에서 520, 그 다음 535로 올랐다면, 서버도 그 순서대로 받아야 한다.
아이템별 에러 처리
각 아이템에 개별 try-catch가 있다. 5개 아이템 중 2번째가 실패해도 1, 3, 4, 5번은 정상 처리될 수 있다. 실패한 아이템은 retry count가 증가하고, 다음 flush 사이클에서 다시 시도된다.
불량 아이템 정리
모든 아이템 처리 후 removeStale()이 3번 이상 실패한 아이템을 삭제한다. 실용적인 선택이다. 여러 flush 사이클을 거쳐 3번 시도해도 성공하지 못하는 액션은 근본적으로 문제가 있는 것이다 (잘못된 페이로드, 권한 철회 등). 큐에 영원히 남겨두면 처리 시간만 낭비한다.
subscription_sync 핸들러
이 핸들러는 흥미로운 점이 있다 -- 저장된 페이로드를 재생하지 않는다. 대신 flush 시점에 RevenueCat에서 최신 구독 데이터를 가져온다. 구독 상태는 시간에 민감하기 때문에 이게 맞다. 오프라인에서 구매한 구독이 flush 시점에는 이미 서버 측에서 활성화되어 있을 수 있다.
인큐 패턴
앱의 모든 네트워크 의존 mutation은 같은 패턴을 따른다. pushBqUpdate가 직접 전송할지 큐에 넣을지 결정하는 방식을 보자:
Future<void> pushBqUpdate({
required int totalBq,
required int planetStage,
required Map<String, double> elos,
}) async {
final userId = _userId;
if (userId == null) return;
final data = {
'total_bq': totalBq,
'planet_stage': planetStage,
'current_elo': elos,
'last_synced_at': DateTime.now().toUtc().toIso8601String(),
};
if (_isOnline) {
await _supabase!
.schema('brainfit')
.from('user_profiles')
.update(data)
.eq('id', userId);
} else {
await _db.offlineQueueDao
.enqueue('bq_push', jsonEncode({...data, '_user_id': userId}));
}
}
오프라인 아이템의 페이로드에 _user_id 필드가 주입되는 걸 주목하자. 오프라인으로 큐에 넣을 때는 Supabase 세션이 보장되지 않으므로, 유저 ID를 명시적으로 저장한다. flush 시점에 핸들러가 페이로드에서 _user_id를 추출하고 제거한 후 업데이트를 전송한다.
RetryWithBackoff: 일시적 장애 처리
모든 네트워크 작업이 OfflineQueue를 거치는 건 아니다. 시간에 민감한 작업은 즉시 재시도가 유리하다. 이를 위해 BrainFit은 지수 백오프 유틸리티를 사용한다:
static Future<bool> retryWithBackoff(
Future<void> Function() action, {
int maxRetries = 3,
Duration initialDelay = const Duration(seconds: 1),
}) async {
for (var i = 0; i < maxRetries; i++) {
try {
await action();
return true;
} catch (e) {
debugPrint('retryWithBackoff attempt ${i + 1}/$maxRetries failed: $e');
if (i < maxRetries - 1) {
await Future.delayed(initialDelay * (1 << i));
}
}
}
return false;
}
딜레이는 표준 지수 패턴을 따른다: 1초, 2초, 4초. 함수는 boolean을 반환해서 호출자가 모든 재시도 소진 후 어떻게 할지 결정할 수 있다.
구독 동기화에서 이렇게 사용된다:
static Future<void> syncSubscriptionToSupabase() async {
try {
final user = Supabase.instance.client.auth.currentUser;
if (user == null) return;
} catch (_) {
return;
}
final success = await retryWithBackoff(() async {
final user = Supabase.instance.client.auth.currentUser;
if (user == null) return;
final tier = await getCurrentTier();
final info = await Purchases.getCustomerInfo();
// ... 업데이트 페이로드 구성 ...
await Supabase.instance.client
.schema('brainfit')
.from('user_profiles')
.update({
'subscription_tier': tier.name,
'subscription_expires_at': expiresAt,
'subscription_product_id': productId,
}).eq('id', user.id);
});
if (!success && _db != null) {
try {
await _db!.offlineQueueDao.enqueue('subscription_sync', '{}');
} catch (e) {
debugPrint('PurchaseService: failed to queue subscription sync: $e');
}
}
}
이것은 2단계 재시도 전략이다. 먼저 지수 백오프로 즉시 해결을 시도한다. 3번 모두 실패하면 OfflineQueue로 폴백해서 eventual delivery를 보장한다. Pro를 방금 구매한 유저는 서버에 빠르게 반영되길 기대하지만, 네트워크가 정말로 죽어 있다면 큐를 통한 최종 일관성이 수용 가능하다.
구독 캐싱: 오프라인 Pro 접근
걱정되는 시나리오가 있다: Pro 구독자가 지하철에서 BrainFit을 열었는데, 앱이 RevenueCat에 접속하지 못해서 구독을 확인할 수 없다. 이때 Free로 다운그레이드하고 Pro 게임을 잠가야 할까? 최악의 사용자 경험이다.
BrainFit은 SubscriptionCache로 이 문제를 해결한다. 구독 상태를 HMAC-SHA256 무결성 보호와 함께 로컬에 저장한다:
class SubscriptionCache {
static const _tierKey = 'sub_cache_tier';
static const _productIdKey = 'sub_cache_product_id';
static const _expirationKey = 'sub_cache_expiration';
static const _hmacKey = 'sub_cache_hmac';
static const _salt = 'brainfit_sub_cache_v1_7x2m';
String _computeHmac(String tier, String productId, String expiration) {
final hmac = Hmac(sha256, utf8.encode(_salt));
return hmac.convert(utf8.encode('$tier:$productId:$expiration')).toString();
}
Future<void> save({
required SubscriptionTier tier,
required String? productId,
required String? expirationDate,
}) async {
_tier = tier.name;
_productId = productId;
_expirationDate = expirationDate;
// SharedPreferences에 HMAC과 함께 저장 ...
}
SubscriptionTier? getCachedTier() {
if (_tier == null) return null;
if (_expirationDate != null) {
final expiry = DateTime.tryParse(_expirationDate!);
if (expiry != null && DateTime.now().isAfter(expiry)) {
return null; // 만료됨
}
}
return SubscriptionTier.values
.where((t) => t.name == _tier)
.firstOrNull;
}
}
흐름은 이렇다:
getCurrentTier()가 RevenueCat에 성공적으로 접속할 때마다, 결과를 캐시에 저장한다.- RevenueCat이 접근 불가(오프라인)하면, catch 블록에서 캐시를 읽는다.
- 캐시는 만료일을 확인한다 -- 저장된 만료일이 지났으면 null(Free)을 반환한다.
- HMAC이 SharedPreferences 값의 일반적인 변조를 방지한다.
static Future<SubscriptionTier> getCurrentTier() async {
if (AppConfig.revenueCatApiKey.isEmpty) return SubscriptionTier.free;
try {
final info = await Purchases.getCustomerInfo();
if (info.entitlements.active.containsKey(_proEntitlement)) {
final entitlement = info.entitlements.active[_proEntitlement]!;
final pid = entitlement.productIdentifier;
final tier = (pid == familyMonthly || pid == familyAnnual)
? SubscriptionTier.family
: SubscriptionTier.pro;
// 캐시에 저장
_subscriptionCache?.save(
tier: tier,
productId: pid,
expirationDate: entitlement.expirationDate,
);
return tier;
}
_subscriptionCache?.clear();
} catch (e, st) {
// 오프라인 fallback: 캐시에서 읽기
final cached = _subscriptionCache?.getCachedTier();
if (cached != null) {
return cached;
}
}
return SubscriptionTier.free;
}
HMAC이 완벽한가? 아니다. 루팅된 기기에서 APK를 디컴파일하면 salt를 추출할 수 있다. 하지만 BrainFit은 은행 앱이 아니라 두뇌 훈련 게임이다. HMAC은 SharedPreferences 에디터를 쓰는 수준의 일반적인 변조를 막기 충분하고, 그게 모바일 게임의 현실적인 위협 모델이다.
Supabase 익명 인증과 RLS
BrainFit은 Supabase 익명 인증을 사용한다. 앱이 시작되면 Supabase에서 signInAnonymously()를 호출하고, 이것이 실제 auth.uid()를 가진 세션을 만든다. 계정을 만들지 않은 유저도 Row Level Security가 사용할 수 있는 영구적인 신원을 갖게 된다.
brainfit 스키마의 모든 Supabase 테이블에는 auth.uid() 기반 RLS 정책이 있다. 예를 들어 user_profiles 테이블의 업데이트 정책은 이렇다:
CREATE POLICY "Users can update own profile"
ON brainfit.user_profiles
FOR UPDATE
USING (auth.uid() = id);
익명 유저에게도 동작한다. auth.uid()가 익명 세션에서도 유효한 UUID를 반환하기 때문이다. 보안 경계는 유저가 "진짜"(이메일/OAuth) 유저든 익명이든 상관없이 자기 데이터만 읽고 쓸 수 있다는 것이다.
이 설계가 오프라인 큐에 중요한 이유는, 큐에 넣어진 모든 액션이 유저의 Supabase ID를 갖고 있다는 것이다. 큐가 flush될 때 RLS 정책은 여전히 적용된다 -- 큐에 넣어진 bq_push 액션은 그것을 만든 유저의 행만 업데이트할 수 있다.
엣지 케이스
충돌 해결
BrainFit은 대부분의 데이터에 last-write-wins 전략을 사용한다. BQ 업데이트에는 last_synced_at 타임스탬프가 포함되어 있어서, 두 업데이트가 순서가 뒤바뀌어 도착해도 서버는 둘 다 수락하지만 가장 최신 값이 이전 값을 덮어쓰면서 자연스럽게 이긴다. 소셜 피드 항목처럼 insert인 경우에는 충돌 자체가 없다 -- 각 항목이 자기 행을 갖는다.
서버 측에서 버전 벡터를 이용한 충돌 감지를 고려했지만, 모바일 게임에서는 복잡성이 정당화되지 않았다. BQ 충돌의 최악의 경우는 플레이어의 표시 점수가 다음 동기화가 수정하기 전까지 몇 점 차이가 나는 것이다. 수용 가능하다.
큐 순서 보장
getPending()은 createdAt 오름차순으로 정렬하고, 아이템은 순차적으로 처리된다. 인과 순서가 보장된다: 액션 A가 B보다 먼저 발생했다면, A가 먼저 flush된다. autoIncrement PK가 동일 타임스탬프 행의 타이브레이커 역할을 하므로(Drift가 삽입 순서를 보장), 정렬은 결정적이다.
최대 재시도와 Dead-Letter 패턴
retryCount 필드는 간단한 dead-letter 큐 역할을 한다. 3번 실패하면 removeStale()이 아이템을 삭제한다. 3을 선택한 이유는, 각 flush 사이클이 상당한 시간 간격(최소 한 번의 게임 완료 또는 앱 복귀)을 나타내므로, 3번 재시도는 시스템이 여러 세션에 걸쳐 시도했다는 뜻이기 때문이다.
손실된 데이터는 어떻게 되는가? BQ push의 경우, 다음 성공적인 pushBqUpdate가 현재 누적 BQ를 쓰면서 사실상 따라잡는다. 피드 이벤트의 경우, 손실된 항목은 친구들의 피드에 나타나지 않는다 -- 미션 크리티컬하지 않은 소셜 피드로서는 수용 가능하다. 구독 동기화의 경우, 서버 측 RevenueCat 웹훅이 어차피 원천 소스다.
큐가 너무 커지면?
이론적으로, 플레이어가 며칠간 오프라인이면 수십 개의 큐 아이템이 쌓일 수 있다. 실제로는 BrainFit의 플레이 제한 시스템(Free 유저 하루 3판, Pro는 무제한)이 큐 증가를 제한한다. 열심히 플레이하는 Pro 유저라도 하루에 20-30개 아이템을 생성하는 정도다. 이것을 순차적으로 처리하는 데 현대 폰에서 1초도 걸리지 않는다.
데이터 흐름 다이어그램
데이터 흐름을 그리면 이렇다:
게임 완료
|
v
pushBqUpdate()
|
+-- 온라인? --> Supabase (직접 전송)
|
+-- 오프라인? --> OfflineQueue (SQLite)
|
v
다음 트리거:
- onGameComplete()
- onAppResume()
|
v
flushOfflineQueue()
+-- 성공 --> 큐에서 제거
+-- 실패 --> incrementRetry()
|
+-- retryCount >= 3?
--> removeStale()
배운 것들
단순하게 시작하라. OfflineQueue 테이블은 5개 컬럼뿐이다. 우선순위, TTL 필드, 배치 처리를 처음부터 넣고 싶었지만, 그 중 어느 것도 필요하지 않았다. 단순한 FIFO 큐 + retry count가 BrainFit에 필요한 모든 걸 처리한다.
감지와 액션을 분리하라. ConnectivityService는 상태만 감지한다. SyncService가 그 상태로 뭘 할지 결정한다. 이 분리가 테스트를 간단하게 만든다 -- connectivity.setOnline(false)를 설정하고 큐가 커지는 걸 확인하면 된다. 네트워크 모킹이 필요 없다.
적극적으로 캐시하되, 무결성을 갖추라. 구독 캐시가 HMAC을 사용하는 건 정교한 공격을 예상해서가 아니라, 추가 비용이 거의 없으면서 가장 흔한 변조 벡터(루팅 기기의 SharedPreferences 에디터)를 막기 때문이다.
2단계 재시도가 실용적이다. 구독 동기화처럼 시간에 민감한 작업은 백오프로 즉시 시도한다. 실패하면 큐로 폴백해서 최종 전달을 보장한다. 두 시스템을 과도하게 복잡하게 만들지 않으면서 양쪽의 장점을 취할 수 있다.
최종 일관성을 받아들여라. BrainFit은 게임이지 은행이 아니다. 플레이어의 BQ가 몇 분간 동기화되지 않아도 아무도 눈치채지 못한다. 최종 일관성을 목표로 설계하면 강한 일관성보다 훨씬 단순한 시스템을 만들 수 있다.
마무리
이 패턴은 서울 지하철에서 베타 테스트하는 유저들과 함께 검증되었다. 데이터 손실 보고 제로, "내 점수가 사라졌어요" 불만 제로. 시스템이 영리한 게 아니라 -- 기본에 충실한 것이다: 모든 걸 로컬에 먼저 저장하고, 가능할 때 동기화하고, 실패하면 재시도하고, 정말 고장난 건 우아하게 포기한다.
불안정한 네트워크 환경에서 작동해야 하는 Flutter 앱을 만들고 있다면, 이 글이 구체적인 출발점이 되길 바란다. OfflineQueue 패턴 자체는 새로운 것이 아니지만, 세부 사항이 중요하다 -- 그리고 그 세부 사항이 "오프라인에서 작동해야 하는" 앱과 "실제로 작동하는" 앱의 차이를 만든다.