
API가 무한 로딩에 걸렸을 때 (Timeout 처리)
서버가 죽었는지 1분째 로딩바만 돌아가고 있습니다. http 패키지와 Dio에서 타임아웃을 설정하는 방법, 그리고 사용자에게 '잠시 후 다시 시도해주세요'라고 말하는 우아한 방법을 정리해봤습니다.

서버가 죽었는지 1분째 로딩바만 돌아가고 있습니다. http 패키지와 Dio에서 타임아웃을 설정하는 방법, 그리고 사용자에게 '잠시 후 다시 시도해주세요'라고 말하는 우아한 방법을 정리해봤습니다.
매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

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

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

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

앱을 출시하고 나서 사용자 리뷰가 달렸습니다. "앱이 멈췄어요. 아무것도 안 돼요."
로그를 확인해보니 서버가 잠시 과부하 상태여서 응답을 못 주고 있었습니다. 문제는 제 앱이 "서버가 대답할 때까지 영원히 기다리도록" 짜여 있었다는 겁니다. 사용자 입장에서는 앱이 멈춘(Freezing) 것처럼 보였겠죠.
기약 없는 기다림만큼 괴로운 건 없습니다. 앱에서도 마찬가지입니다.
Flutter에서 가장 많이 쓰는 http 패키지의 기본 타임아웃은 얼마일까요?
놀랍게도 없음(NULL)입니다.
OS나 서버가 연결을 강제로 끊지 않는 한, 클라이언트는 영원히 기다립니다.
반면 Dio 패키지는 기본값이 있지만, 그래도 명시적으로 설정해주지 않으면 낭패를 봅니다.
네트워크 요청에는 반드시 "마감 시간(Deadline)"이 있어야 합니다.
http 패키지를 쓴다면 .timeout() 메서드를 체이닝해야 합니다.
import 'package:http/http.dart' as http;
Future<void> fetchData() async {
try {
final response = await http.get(Uri.parse('https://api.myapp.com'))
.timeout(
const Duration(seconds: 5), // 👈 5초까지만 기다림
onTimeout: () {
// 시간 초과 시 실행될 로직
throw TimeoutException('API Server is too slow');
},
);
// 데이터 처리...
} on TimeoutException catch (e) {
print('시간 초과! 다시 시도하게 해주세요.');
} catch (e) {
print('그 외 에러: $e');
}
}
이제 5초가 지나면 즉시 에러가 발생하고, 앱은 멈춤 상태에서 벗어납니다.
실제로는 더 강력한 기능(인터셉터 등)이 있는 Dio를 많이 씁니다.
Dio는 객체를 생성할 때 전역적으로 타임아웃을 걸 수 있습니다.
final dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 5), // 연결(Handshake) 타임아웃
receiveTimeout: const Duration(seconds: 10), // 데이터 수신 타임아웃
sendTimeout: const Duration(seconds: 5), // 데이터 전송 타임아웃
));
Future<void> fetchData() async {
try {
final response = await dio.get('/users');
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
// 타임아웃 에러 처리
showRetryDialog();
}
}
}
Tip:
connectTimeout: 서버랑 '안녕?'(TCP Handshake) 하는 시간. 보통 3~5초.receiveTimeout: 요청은 보냈는데 답변이 오는 시간. DB 쿼리가 느리면 여기서 걸립니다. 10~30초.타임아웃이 났다고 바로 에러를 띄우는 게 최선일까요? 엘리베이터에서 잠깐 LTE가 끊긴 걸 수도 있습니다. 이럴 땐 "자동 재시도(Retry)"를 해주는 게 센스 있는 개발자입니다.
dio_smart_retry 같은 패키지를 쓰거나 직접 인터셉터를 구현할 수 있습니다.
dio.interceptors.add(RetryInterceptor(
dio: dio,
retries: 3, // 최대 3번 재시도
retryDelays: const [
Duration(seconds: 1), // 1초 뒤
Duration(seconds: 2), // 2초 뒤
Duration(seconds: 4), // 4초 뒤 (지수 백오프)
],
));
이렇게 하면 사용자는 네트워크가 잠깐 끊겨도 에러 화면을 보지 않고, 앱이 알아서 복구하는 마법을 경험합니다. 지수 백오프(Exponential Backoff)는 서버가 힘들 때 쉴 시간을 주면서 재요청하는 매너 있는 알고리즘입니다.
기술적으로 타임아웃을 잡는 건 쉽습니다. 중요한 건 그 다음 사용자 경험(UX)입니다.
Box나 Hive 캐시 활용)class ErrorView extends StatelessWidget {
final VoidCallback onRetry;
const ErrorView({required this.onRetry});
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.wifi_off, size: 50),
Text("서버 응답이 늦어지고 있어요."),
ElevatedButton(
onPressed: onRetry, // 여기서 setState나 refresh 호출
child: Text("다시 시도"),
),
],
);
}
}
한 번은 결제 요청 API에 타임아웃 설정을 3초로 짧게 잡은 적이 있습니다. 문제는 "타임아웃은 클라이언트의 사정일 뿐, 서버는 계속 일한다"는 점이었습니다.
결과는 대참사였습니다. 사용자는 "결제 실패" 화면을 보고 다시 결제 버튼을 눌렀고, 이중 결제가 일어났습니다.
교훈:
단순 재시도(Retry)의 문제는 "서버가 죽었는데 계속 찔러본다"는 겁니다. 서버는 안 그래도 아파서 죽겠는데 1만 명의 사용자가 3번씩 재시도를 하면 DDOS 공격과 다를 게 없습니다.
이때 등장하는 패턴이 Circuit Breaker(서킷 브레이커)입니다. "연속으로 5번 실패했어? 그럼 앞으로 1분 동안은 요청 보내지 말고 바로 에러 뱉어."
앱 레벨에서는 복잡한 구현보다는 간단한 변수로 막을 수 있습니다.
bool isServerDown = false;
DateTime? blockUntil;
Future<void> request() async {
if (isServerDown && DateTime.now().isBefore(blockUntil!)) {
throw Exception('Server is temporarily blocked.'); // 즉시 실패 (Fail Fast)
}
try {
// 요청...
} catch (e) {
failCount++;
if (failCount > 5) {
isServerDown = true;
blockUntil = DateTime.now().add(Duration(minutes: 1));
}
}
}
"Fail Fast(빨리 실패하기)"가 오히려 전체 시스템을 살립니다.
라이브러리 없이 직접 retry 로직을 짜야 할 때가 있습니다. (소켓 연결 등)
단순한 while 문으로 구현해봤다.
Future<T> retry<T>(Future<T> Function() apiCall) async {
int attempt = 0;
while (true) {
try {
return await apiCall();
} catch (e) {
if (++attempt > 3) rethrow; // 3번 넘으면 포기
final delay = Duration(seconds: pow(2, attempt).toInt()); // 2^1, 2^2, 2^3...
print('${delay.inSeconds}초 뒤 재시도...');
await Future.delayed(delay);
}
}
}
1초 -> 2초 -> 4초 -> 8초... 이렇게 대기 시간이 기하급수적으로 늘어나야, 네트워크 폭주를(Thundering Herd) 막을 수 있습니다.
문제: 사용자가 검색어를 입력하다가 "취소" 버튼을 누르고 화면을 나갔습니다. 하지만 백그라운드에서는 무거운 API 요청이 계속 돌고 있습니다. 서버 낭비, 데이터 낭비입니다.
도전:
Dio의 CancelToken을 사용하여, 위젯이 dispose될 때 진행 중인 요청을 강제로 끊으세요.
class SearchState extends State<SearchPage> {
CancelToken? _cancelToken;
void search() async {
_cancelToken?.cancel('New Request Started'); // 이전 요청 취소
_cancelToken = CancelToken();
try {
await dio.get('/search', cancelToken: _cancelToken);
} catch (e) {
if (CancelToken.isCancel(e)) print('요청 취소됨');
}
}
@override
void dispose() {
_cancelToken?.cancel('Page Closed'); // 화면 나갈 때 취소
super.dispose();
}
}
이 패턴은 "검색어 자동완성" 기능에서 필수입니다.
개발하다 보면 두 에러가 헷갈립니다.
이걸 구분해서 예외 처리를 해야 합니다.
SocketException이라면 "인터넷 연결을 확인하세요"를 띄우고,
TimeoutException이라면 "서버가 혼잡합니다. 잠시 후 다시 시도하세요"를 띄워야 합니다.
await 없는 요청 (Fire and Forget)가끔 타임아웃이 필요 없는 요청도 있습니다. "로그 수집"이나 "통계 전송" 같은 것들이죠. 이건 실패하든 말든 상관없고, 사용자에게 로딩을 보여줄 필요도 없습니다.
// await 안 함!
analytics.sendEvent('click_button');
// 또는 오류 무시
api.sendLog().catchError((e) => print('로그 전송 실패 (무시됨)'));
중요도를 따져서 메인 스레드를 잡지 않게 하세요.
서버는 배신할 수 있어도, 클라이언트는 끝까지 사용자 편이어야 합니다.