
와이파이를 껐는데 앱이 멈췄어요 (완벽한 오프라인 모드 구현하기)
엘리베이터만 타면 앱이 먹통이 됩니까? connectivity_plus로 네트워크 상태를 감지하고, Hive로 데이터를 캐싱하며, Optimistic UI와 Background Sync(WorkManager)를 통해 끊기지 않는 사용자 경험을 만드는 법을 배웁니다.

엘리베이터만 타면 앱이 먹통이 됩니까? connectivity_plus로 네트워크 상태를 감지하고, Hive로 데이터를 캐싱하며, Optimistic UI와 Background Sync(WorkManager)를 통해 끊기지 않는 사용자 경험을 만드는 법을 배웁니다.
매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

로그인 화면을 만들었는데 키보드가 올라오니 노란 줄무늬 에러가 뜹니다. resizeToAvoidBottomInset부터 스크롤 뷰, 그리고 채팅 앱을 위한 reverse 팁까지, 키보드 대응의 모든 것을 정리해봤습니다.

HTTP는 무전기(오버) 방식이지만, 웹소켓은 전화기(여보세요)입니다. 채팅과 주식 차트가 실시간으로 움직이는 기술적 비밀.

IP는 이사 가면 바뀌지만, MAC 주소는 바뀌지 않습니다. 주민등록번호와 집 주소의 차이. 공장 출고 때 찍히는 고유 번호.

출근길 지하철이나 엘리베이터 안에서 갑자기 인터넷이 끊깁니다. 잘 만든 앱(인스타그램, 슬랙)은 "인터넷 연결을 확인해주세요"라는 예쁜 스낵바를 띄우거나, 아까 로딩된 피드라도 계속 보여줍니다. 심지어 좋아요를 누르면 하트가 채워지고, 나중에 인터넷이 돌아왔을 때 서버로 전송됩니다.
하지만 제 앱은 하염없이 로딩 스피너만 돌다가, 30초 뒤에 TimeoutException을 뱉고 하얀 화면(Grey Screen of Death)이 되어버립니다.
사용자는 생각합니다. "이 앱은 인터넷 없으면 깡통이네."
모바일 환경은 언제든 오프라인이 될 수 있다는 것을 전제로 코드를 짜야 합니다.
Flutter에서 네트워크 상태를 확인하는 표준 패키지는 connectivity_plus입니다.
하지만 얘는 "와이파이 스위치가 켜져 있나?"만 확인합니다.
와이파이가 켜져 있어도, 공유기가 인터넷에 연결 안 되어 있으면(Captive Portal이나 통신 장애) 말짱 꽝입니다.
그래서 실제로는 internet_connection_checker_plus 같은 패키지를 같이 써서 "진짜 구글 서버(DNS 8.8.8.8)에 핑(Ping)이 가지나?"를 확인해야 합니다.
앱 전역에서 인터넷 상태를 감시하다가, 끊기면 사용자에게 즉시 알려줘야 합니다. 단, 연결이 1초에 10번씩 붙었다 끊겼다 할 수 있으므로 Debounce(지연 처리)가 필수입니다.
// StreamTransform 패키지 활용
Connectivity().onConnectivityChanged
.debounce(Duration(milliseconds: 300)) // 👈 깜빡임 방지
.listen((result) {
if (result == ConnectivityResult.none) {
showGlobalOfflineSnackbar();
} else {
// 연결 복구 시 재시도 로직 트리거
retryFailedRequests();
}
});
가장 좋은 오프라인 경험은 "오프라인인 줄도 모르게 하는 것"입니다. 가벼운 NoSQL DB인 Hive를 써서 API 응답을 JSON 그대로 저장해봤다.
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;
}
}
}
이제 비행기 모드에서도 앱의 메인 화면이 뜹니다.
사용자가 '좋아요'를 눌렀는데, 인터넷이 느려서 3초 뒤에 하트가 빨개지면 답답합니다. Optimistic UI는 "성공할 것이라 가정하고" UI를 먼저 업데이트합니다. 그리고 백그라운드에서 요청을 보내고, 실패하면 그때 롤백합니다.
void toggleLike() {
// 1. UI 즉시 반영 (가짜 데이터)
setState(() {
isLiked = !isLiked;
});
// 2. 백그라운드 요청
api.like(post.id).catchError((e) {
// 3. 실패 시 롤백 및 에러 표시
setState(() {
isLiked = !isLiked;
});
showError('좋아요 반영 실패');
});
}
인스타그램의 하트, 카카오톡의 메시지 전송이 다 이 방식입니다.
사용자가 오프라인 상태에서 글을 쓰고 "전송"을 눌렀습니다.
앱은 "작성 완료"라고 뻥을 치고, 로컬 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, // 👈 인터넷 필수 조건
),
);
이제 사용자가 엘리베이터에서 쓴 글이, 사무실 와이파이에 연결되는 순간 자동으로 업로드됩니다.
단순히 retryAll() 만으로는 부족할 때가 있습니다.
진정한 오프라인 퍼스트(Offline-First) 앱은 3단계 동기화를 거칩니다.
Supabase의 powersync 같은 도구를 쓰면 이 복잡한 과정을 자동으로 처리해줍니다.
문제: 오프라인 상태에서 '좋아요'를 눌렀습니다. API 요청은 실패했고 큐에 담겼습니다. 하지만 UI에는 아무 변화가 없거나 에러 토스트가 뜹니다. 사용자는 "버그인가?" 하고 3번 더 누릅니다.
도전:
API 응답을 기다리지 말고, UI를 먼저 업데이트(Optimistic Update)하세요.
State를 즉시 isLiked = true로 바꾸고 하트를 빨갛게 칠해주세요.
만약 나중에 진짜로 실패하면? 그때 조용히 롤백(Rollback)하거나 "나중에 다시 시도해주세요"라고 알리세요.
사용자는 기술적인 성공보다 "즉각적인 반응"을 원합니다.
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()을 호출해서 밀린 숙제를 처리합니다.
간단한 "좋아요"나 "북마크" 기능은 이걸로 충분합니다.
건물 지하 기계실(통신 안 터짐)에서 점검 일지를 쓰는 앱을 만들었습니다.
작업자가 지하에서 사진을 찍고 "전송"을 누르면 실패. 다시 지상으로 올라와서 전송 버튼을 또 눌러야 함. 까먹으면 데이터 날아감.
connectivity_plus가 connected 상태를 감지하면, 백그라운드에서 사진을 업로드하고 데이터를 서버로 보냄.작업자들은 더 이상 인터넷 연결을 신경 쓰지 않게 되었습니다. 그냥 찍고 올리면, 앱이 알아서 처리해주니까요. 이것이 진정한 DX(User Experience)입니다.
Q: CRDT(Conflict-free Replicated Data Type)는 뭔가요?
A: "동시 편집"을 위한 수학적 데이터 구조입니다. 구글 독스처럼 오프라인에서 여러 명이 같은 글을 고쳐도 충돌 없이 합쳐주는 알고리즘인데, 구현 난이도가 극상(Hell)입니다. 일반 앱에선 Last Write Wins로 충분합니다.
Q: 오프라인에서 이미지 캐싱은 어떻게 하나요?
A: cached_network_image 패키지를 쓰면 됩니다. 알아서 로컬 파일 시스템에 저장했다가 꺼내줍니다.
개발할 때 인터넷 선을 뽑을 순 없잖아요? 시뮬레이터/에뮬레이터에서 네트워크 상태를 조작하는 법입니다.
Features -> Condition -> Network Link Conditioner 설치 필요. (조금 복잡함)Cellular -> Network type을 None이나 Edge(느린 인터넷)로 변경.저는 그냥 맥북 와이파이를 끕니다. 그게 제일 확실합니다. 😅 하지만 "느린 인터넷(3G)" 환경은 꼭 테스트해보세요. 타임아웃 30초가 사용자에게 얼마나 긴 시간인지 느껴봐야 합니다.
connectivity_plus + ping test로 진짜 인터넷 연결을 확인하세요.Hive로 읽기(Read) 작업을 오프라인에서도 가능하게 하세요.workmanager로 앱이 꺼져도 중요한 데이터가 전송되게 하세요.오프라인 모드는 "있으면 좋은 기능"이 아니라, 모바일 앱의 완성도를 가르는 기준입니다.
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.
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).
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();
}
});
The best offline experience is Invisible Offline. Use Hive (Lightweight NoSQL DB) to cache JSON responses.
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.
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');
});
}
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
),
);
What if I edit a profile offline, but I also edited it on the web? Synchronization Conflict.
Just calling retryAll() isn't enough for complex apps.
True Offline-First apps follow the 3-step synchronization:
Tools like PowerSync (for Supabase) automate this painful process.
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.
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.
I built an app for technicians inspecting facility basements (No Signal Zone).
Techs took photos, hit "Send", and it failed. They had to remember to hit "Retry" when they went back upstairs. They often forgot.
connectivity_plus detects connection, upload photos in background.Techs stopped caring about signal bars. They just worked. This is True UX—making technology invisible.
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.
You can't just unplug your laptop every time.
Network Link Conditioner (Additional Tool for Xcode).Cellular -> Set Network Type to None or Edge.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.