와이파이를 껐는데 앱이 멈췄어요 (완벽한 오프라인 모드 구현하기)
1. "엘리베이터만 타면 앱이 죽어요."
출근길 지하철이나 엘리베이터 안에서 갑자기 인터넷이 끊깁니다. 잘 만든 앱(인스타그램, 슬랙)은 "인터넷 연결을 확인해주세요"라는 예쁜 스낵바를 띄우거나, 아까 로딩된 피드라도 계속 보여줍니다. 심지어 좋아요를 누르면 하트가 채워지고, 나중에 인터넷이 돌아왔을 때 서버로 전송됩니다.
하지만 제 앱은 하염없이 로딩 스피너만 돌다가, 30초 뒤에 TimeoutException을 뱉고 하얀 화면(Grey Screen of Death)이 되어버립니다.
사용자는 생각합니다. "이 앱은 인터넷 없으면 깡통이네."
모바일 환경은 언제든 오프라인이 될 수 있다는 것을 전제로 코드를 짜야 합니다.
2. 원리 이해: Connectivity vs Internet
Flutter에서 네트워크 상태를 확인하는 표준 패키지는 connectivity_plus입니다.
하지만 얘는 "와이파이 스위치가 켜져 있나?"만 확인합니다.
와이파이가 켜져 있어도, 공유기가 인터넷에 연결 안 되어 있으면(Captive Portal이나 통신 장애) 말짱 꽝입니다.
그래서 실제로는 internet_connection_checker_plus 같은 패키지를 같이 써서 "진짜 구글 서버(DNS 8.8.8.8)에 핑(Ping)이 가지나?"를 확인해야 합니다.
3. 해결책 1 - 실시간 감지 및 디바운스 (Stream + Debounce)
앱 전역에서 인터넷 상태를 감시하다가, 끊기면 사용자에게 즉시 알려줘야 합니다. 단, 연결이 1초에 10번씩 붙었다 끊겼다 할 수 있으므로 Debounce(지연 처리)가 필수입니다.
// StreamTransform 패키지 활용
Connectivity().onConnectivityChanged
.debounce(Duration(milliseconds: 300)) // 👈 깜빡임 방지
.listen((result) {
if (result == ConnectivityResult.none) {
showGlobalOfflineSnackbar();
} else {
// 연결 복구 시 재시도 로직 트리거
retryFailedRequests();
}
});
4. 해결책 2 - 캐시 우선 전략 (Cache First with Hive)
가장 좋은 오프라인 경험은 "오프라인인 줄도 모르게 하는 것"입니다. 가벼운 NoSQL DB인 Hive를 써서 API 응답을 JSON 그대로 저장해봤다.
Repository 패턴 예시
class DataRepository {
final ApiClient api;
final Box cacheBox;
Future<Data> fetchData() async {
try {
// 1. 서버 요청 시도
final data = await api.get();
// 2. 성공 시 캐시 업데이트 (덮어쓰기)
cacheBox.put('latestData', data.toJson());
return data;
} catch (e) {
// 3. 서버 실패 시 캐시된 데이터 확인 (Fallback)
if (cacheBox.containsKey('latestData')) {
return Data.fromJson(cacheBox.get('latestData'));
}
// 4. 캐시도 없으면 진짜 에러
rethrow;
}
}
}
이제 비행기 모드에서도 앱의 메인 화면이 뜹니다.
5. 낙관적 UI (Optimistic UI) 더 알아보기
사용자가 '좋아요'를 눌렀는데, 인터넷이 느려서 3초 뒤에 하트가 빨개지면 답답합니다. Optimistic UI는 "성공할 것이라 가정하고" UI를 먼저 업데이트합니다. 그리고 백그라운드에서 요청을 보내고, 실패하면 그때 롤백합니다.
void toggleLike() {
// 1. UI 즉시 반영 (가짜 데이터)
setState(() {
isLiked = !isLiked;
});
// 2. 백그라운드 요청
api.like(post.id).catchError((e) {
// 3. 실패 시 롤백 및 에러 표시
setState(() {
isLiked = !isLiked;
});
showError('좋아요 반영 실패');
});
}
인스타그램의 하트, 카카오톡의 메시지 전송이 다 이 방식입니다.
6. 백그라운드 동기화 (Background Sync with WorkManager) 한 걸음 더
사용자가 오프라인 상태에서 글을 쓰고 "전송"을 눌렀습니다.
앱은 "작성 완료"라고 뻥을 치고, 로컬 DB에 pending_posts 큐(Queue)에 저장합니다.
그리고 앱이 꺼져도 나중에 인터넷이 연결되면 자동으로 업로드하고 싶습니다.
이때 Android WorkManager (iOS BGTaskScheduler)를 사용합니다. Flutter에서는 workmanager 패키지로 구현합니다.
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
// 백그라운드에서 실행될 코드 (인터넷 연결 시)
// 1. 로컬 DB에서 대기중인 작업 조회
var tasks = await getPendingTasks();
// 2. 서버로 전송
for (var t in tasks) {
await api.upload(t);
}
return Future.value(true);
});
}
// 인터넷 연결 시 실행되도록 예약
Workmanager().registerOneOffTask(
"sync-posts",
"uploadTask",
constraints: Constraints(
networkType: NetworkType.connected, // 👈 인터넷 필수 조건
),
);
이제 사용자가 엘리베이터에서 쓴 글이, 사무실 와이파이에 연결되는 순간 자동으로 업로드됩니다.
9. Architecture: "완벽한" 동기화 로직 (Sync Strategy)
단순히 retryAll() 만으로는 부족할 때가 있습니다.
진정한 오프라인 퍼스트(Offline-First) 앱은 3단계 동기화를 거칩니다.
- Pull (당겨오기): 네트워크가 연결되자마자 서버의 최신 데이터를 가져옵니다.
- Merge (합치기): 로컬에서 수정한 데이터와 서버 데이터를 병합합니다. 충돌이 나면 'LWW(Last Write Wins)' 규칙을 따릅니다.
- Push (밀어넣기): 병합된 최종 데이터를 서버로 보냅니다.
Supabase의 powersync 같은 도구를 쓰면 이 복잡한 과정을 자동으로 처리해줍니다.
10. Refactoring Challenge: 사용자에게 친절하게 거짓말하기
문제: 오프라인 상태에서 '좋아요'를 눌렀습니다. API 요청은 실패했고 큐에 담겼습니다. 하지만 UI에는 아무 변화가 없거나 에러 토스트가 뜹니다. 사용자는 "버그인가?" 하고 3번 더 누릅니다.
도전:
API 응답을 기다리지 말고, UI를 먼저 업데이트(Optimistic Update)하세요.
State를 즉시 isLiked = true로 바꾸고 하트를 빨갛게 칠해주세요.
만약 나중에 진짜로 실패하면? 그때 조용히 롤백(Rollback)하거나 "나중에 다시 시도해주세요"라고 알리세요.
사용자는 기술적인 성공보다 "즉각적인 반응"을 원합니다.
"요청 큐(Queue)" 직접 구현하기 (Dio Interceptor) 제대로 이해하기
WorkManager까지 쓰기엔 너무 무겁다면, Dio Interceptor로 간단한 인메모리 큐를 만들 수 있습니다.
class OfflineInterceptor extends Interceptor {
// 실패한 요청을 담을 리스트
final List<RequestOptions> _queue = [];
@override
void onError(DioError err, ErrorInterceptorHandler handler) {
if (isNetworkError(err)) {
// 1. 실패한 요청을 큐에 저장
_queue.add(err.requestOptions);
// 2. 에러 덮어쓰기 (사용자에겐 "성공한 척" 응답을 줌)
return handler.resolve(Response(
requestOptions: err.requestOptions,
statusCode: 200,
data: {'offline': true} // 오프라인 모드임을 표시
));
}
return super.onError(err, handler);
}
// 네트워크 복구 시 호출
void retryAll() {
for (var req in _queue) {
dio.request(req.path, options: req...);
}
_queue.clear();
}
}
앱이 켜져 있는 동안 잠깐 터널을 지날 때 유용합니다.
네트워크가 돌아오면 retryAll()을 호출해서 밀린 숙제를 처리합니다.
간단한 "좋아요"나 "북마크" 기능은 이걸로 충분합니다.
8. Case Study: 지하 시설 관리 앱
건물 지하 기계실(통신 안 터짐)에서 점검 일지를 쓰는 앱을 만들었습니다.
문제
작업자가 지하에서 사진을 찍고 "전송"을 누르면 실패. 다시 지상으로 올라와서 전송 버튼을 또 눌러야 함. 까먹으면 데이터 날아감.
해결책
- Local-First: "전송" 버튼을 누르면 서버가 아니라 로컬 SQLite에 먼저 저장. (UI: "저장 완료. 통신 대기 중...")
- Auto Sync:
connectivity_plus가connected상태를 감지하면, 백그라운드에서 사진을 업로드하고 데이터를 서버로 보냄. - Notification: "지하 2층 점검 기록이 서버에 안전하게 업로드되었습니다." 푸시 알림 발송.
결과
작업자들은 더 이상 인터넷 연결을 신경 쓰지 않게 되었습니다. 그냥 찍고 올리면, 앱이 알아서 처리해주니까요. 이것이 진정한 DX(User Experience)입니다.
9. FAQ: 자주 묻는 질문
Q: CRDT(Conflict-free Replicated Data Type)는 뭔가요?
A: "동시 편집"을 위한 수학적 데이터 구조입니다. 구글 독스처럼 오프라인에서 여러 명이 같은 글을 고쳐도 충돌 없이 합쳐주는 알고리즘인데, 구현 난이도가 극상(Hell)입니다. 일반 앱에선 Last Write Wins로 충분합니다.
Q: 오프라인에서 이미지 캐싱은 어떻게 하나요?
A: cached_network_image 패키지를 쓰면 됩니다. 알아서 로컬 파일 시스템에 저장했다가 꺼내줍니다.
10. Tip: 시뮬레이터에서 오프라인 테스트하기
개발할 때 인터넷 선을 뽑을 순 없잖아요? 시뮬레이터/에뮬레이터에서 네트워크 상태를 조작하는 법입니다.
- iOS Simulator:
Features->Condition->Network Link Conditioner설치 필요. (조금 복잡함) - Android Emulator: 설정(...) 메뉴 ->
Cellular->Network type을None이나Edge(느린 인터넷)로 변경.
저는 그냥 맥북 와이파이를 끕니다. 그게 제일 확실합니다. 😅 하지만 "느린 인터넷(3G)" 환경은 꼭 테스트해보세요. 타임아웃 30초가 사용자에게 얼마나 긴 시간인지 느껴봐야 합니다.
한 줄 요약
8. 요약
- Real-time Check:
connectivity_plus+ ping test로 진짜 인터넷 연결을 확인하세요. - Cache First:
Hive로 읽기(Read) 작업을 오프라인에서도 가능하게 하세요. - Optimistic UI: 쓰기(Write) 작업 시 UI부터 바꾸고 요청하세요.
- Background Sync:
workmanager로 앱이 꺼져도 중요한 데이터가 전송되게 하세요.
오프라인 모드는 "있으면 좋은 기능"이 아니라, 모바일 앱의 완성도를 가르는 기준입니다.
Flutter: Handling Offline Mode Like a Pro (Optimistic UI & Bg Sync)
1. "App Dies Inside the Elevator."
Connection drops in the subway or elevator. Good apps (Instagram, Slack) show a polite "Please check connection" snackbar or keep showing cached feeds. Even if you tap 'Like', the heart fills instantly and syncs later.
My app simply spins forever, hits a 30-second timeout, and dies into the Grey Screen of Death. Mobile networks are unstable by nature. You must code with "Offline-First" mindset.
2. Theory: Connectivity vs Internet
The connectivity_plus package tells you if WiFi/Data switch is ON.
It doesn't guarantee Internet Access. (WiFi on but router unplugged = No Internet).
You must use internet_connection_checker_plus to verify by Pinging distinct servers (like Google DNS 8.8.8.8).
3. Solution 1: Real-time Monitoring & Debounce
Watch network status globally. But connections can "flap" (on/off rapid-fire). Use Debounce.
Connectivity().onConnectivityChanged
.debounce(Duration(milliseconds: 300)) // 👈 Prevent flickering
.listen((result) {
if (result == ConnectivityResult.none) {
showGlobalOfflineSnackbar();
} else {
// Trigger automatic retry logic
authProvider.retryTokenRefresh();
}
});
4. Solution 2: Cache First Strategy (with Hive)
The best offline experience is Invisible Offline. Use Hive (Lightweight NoSQL DB) to cache JSON responses.
Repository Pattern
Future<Data> fetchData() async {
try {
// 1. Try Fetching Server
final data = await api.get();
// 2. Update Cache (Overwrite)
cacheBox.put('latestData', data.toJson());
return data;
} catch (e) {
// 3. Fallback to Cache
if (cacheBox.containsKey('latestData')) {
return Data.fromJson(cacheBox.get('latestData'));
}
// 4. No Cache? Throw Error
rethrow;
}
}
Now the app shows content even in Airplane Mode.
5. Optimistic UI Updates
Users hate waiting 3 seconds for a 'Like' button to turn red. Optimistic UI updates the state "assuming success" immediately. It rolls back only if the server request fails.
void toggleLike() {
// 1. Update UI Instantly
setState(() { isLiked = !isLiked; });
// 2. Send Request in Background
api.like(post.id).catchError((e) {
// 3. Revert on Failure
setState(() { isLiked = !isLiked; });
showSnackbar('Failed to like post');
});
}
6. Background Synchronization (WorkManager)
User writes a post offline and hits "Send".
You save it to a local Queue (pending_posts).
The app closes. Later, internet returns. Who sends the post?
WorkManager (Android) or BGTaskScheduler (iOS) does.
In Flutter, use workmanager package.
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
// Code running in background process
var tasks = await getPendingTasks();
for (var t in tasks) {
await api.upload(t);
}
return Future.value(true);
});
}
// Schedule task when Network is CONNECTED
Workmanager().registerOneOffTask(
"sync-job",
"uploadTask",
constraints: Constraints(
networkType: NetworkType.connected, // 👈 Key Requirement
),
);
7. Conflict Resolution Strategies
What if I edit a profile offline, but I also edited it on the web? Synchronization Conflict.
- Last Write Wins (LWW): The most recent timestamp wins. Easiest, but data loss possible.
- Server Wins: Server is truth. Safe, but annoying for users.
- Merge: Complex logic (like Git).
9. Architecture: The Holy Trinity of Sync
Just calling retryAll() isn't enough for complex apps.
True Offline-First apps follow the 3-step synchronization:
- Pull: Fetch latest data from server immediately upon connection.
- Merge: Combine local changes with server data. Handle conflicts (usually Last Write Wins).
- Push: Upload the final merged state.
Tools like PowerSync (for Supabase) automate this painful process.
10. Refactoring Challenge: The "Optimistic" Lie
Problem: User taps 'Like' while offline. Request fails and queues. UI does nothing. User thinks "It's broken" and taps 3 more times.
Challenge:
Implement Optimistic UI Updates.
Flip the heart to Red (isLiked = true) INSTANTLY, without waiting for the network.
If the queued request fails later (e.g., auth error), then silently rollback the UI.
Users care about Responsiveness, not 200 OK status codes.
11. Deep Dive: Implementing a Request Queue (Dio Interceptor)
If WorkManager feels like overkill, use a Dio Interceptor for a lightweight in-memory queue.
class OfflineInterceptor extends Interceptor {
final List<RequestOptions> _queue = [];
@override
void onError(DioError err, ErrorInterceptorHandler handler) {
if (isNetworkError(err)) {
// 1. Save failed request to Queue
_queue.add(err.requestOptions);
// 2. Resolve as Success (Fake it till you make it)
return handler.resolve(Response(
requestOptions: err.requestOptions,
statusCode: 200,
data: {'offline': true}
));
}
return super.onError(err, handler);
}
void retryAll() {
// Flush the queue when network is back
for (var req in _queue) {
dio.request(...)
}
_queue.clear();
}
}
Perfect for short disconnects (like tunnels). When network returns while the app is open, flush the queue.
8. Case Study: The Basement Inspection App
I built an app for technicians inspecting facility basements (No Signal Zone).
The Problem
Techs took photos, hit "Send", and it failed. They had to remember to hit "Retry" when they went back upstairs. They often forgot.
The Fix
- Local-First: Hit "Send" -> Save to SQLite immediately. (UI: "Saved locally. Waiting for sync...")
- Auto Sync: When
connectivity_plusdetects connection, upload photos in background. - Notification: "Basement B2 Inspection Report uploaded successfully."
The Result
Techs stopped caring about signal bars. They just worked. This is True UX—making technology invisible.
9. FAQ
Q: What is CRDT?
A: Conflict-free Replicated Data Type. It's complex math used in Google Docs to merge offline edits from multiple users without conflicts. For most apps, stick to Last Write Wins. CRDT is engineering overkill for simple CRUD.
Q: How to cache images offline?
A: Use cached_network_image. It automatically saves downloaded images to the file system and serves them when offline.
10. Tip: Testing Offline on Simulators
You can't just unplug your laptop every time.
- iOS Simulator: Requires
Network Link Conditioner(Additional Tool for Xcode). - Android Emulator: Settings (...) ->
Cellular-> Set Network Type toNoneorEdge.
Honestly? I just turn off my MacBook's WiFi. It's the most reliable method. 😅 But DO TEST on "Slow Connectivity" (3G/Edge). You need to feel the pain of a 30-second timeout to empathize with your users.