API가 무한 로딩에 걸렸을 때 (Timeout 처리)
1. "로딩바만 3분째 보고 있어요."
앱을 출시하고 나서 사용자 리뷰가 달렸습니다. "앱이 멈췄어요. 아무것도 안 돼요."
로그를 확인해보니 서버가 잠시 과부하 상태여서 응답을 못 주고 있었습니다. 문제는 제 앱이 "서버가 대답할 때까지 영원히 기다리도록" 짜여 있었다는 겁니다. 사용자 입장에서는 앱이 멈춘(Freezing) 것처럼 보였겠죠.
기약 없는 기다림만큼 괴로운 건 없습니다. 앱에서도 마찬가지입니다.
2. 원리 이해: 기본 설정은 '무한대'다
Flutter에서 가장 많이 쓰는 http 패키지의 기본 타임아웃은 얼마일까요?
놀랍게도 없음(NULL)입니다.
OS나 서버가 연결을 강제로 끊지 않는 한, 클라이언트는 영원히 기다립니다.
반면 Dio 패키지는 기본값이 있지만, 그래도 명시적으로 설정해주지 않으면 낭패를 봅니다.
네트워크 요청에는 반드시 "마감 시간(Deadline)"이 있어야 합니다.
3. 해결책 1: http 패키지에서 Timeout 설정
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초가 지나면 즉시 에러가 발생하고, 앱은 멈춤 상태에서 벗어납니다.
4. 해결책 2: Dio 패키지에서 Timeout 설정 (추천)
실제로는 더 강력한 기능(인터셉터 등)이 있는 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초.
5. 심화: Interceptors & Retry (Exponential Backoff)
타임아웃이 났다고 바로 에러를 띄우는 게 최선일까요? 엘리베이터에서 잠깐 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)는 서버가 힘들 때 쉴 시간을 주면서 재요청하는 매너 있는 알고리즘입니다.
6. 심화: 사용자에게 뭐라고 말할까? (UX)
기술적으로 타임아웃을 잡는 건 쉽습니다. 중요한 건 그 다음 사용자 경험(UX)입니다.
- 조용한 실패 (Silent Failure): 사용자가 모르게 그냥 로그만 남기고, 화면엔 캐시된 예전 데이터를 보여줍니다. (베스트 -
Box나Hive캐시 활용) - 스낵바 (Snackbar): "인터넷 연결이 불안정합니다."라고 살짝 알려줍니다. (Good)
- 재시도 버튼 (Retry): 전체 화면 에러 페이지를 띄우고, "다시 시도" 버튼을 줍니다. (필수)
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("다시 시도"),
),
],
);
}
}
7. Case Study: 유령 요청 (Ghost Request)
한 번은 결제 요청 API에 타임아웃 설정을 3초로 짧게 잡은 적이 있습니다. 문제는 "타임아웃은 클라이언트의 사정일 뿐, 서버는 계속 일한다"는 점이었습니다.
- 클라이언트: "3초 지났네? 에러 띄우고 취소해야지." (Timeout)
- 서버: (4초 뒤에) "결제 완료!" (DB 처리 끝)
결과는 대참사였습니다. 사용자는 "결제 실패" 화면을 보고 다시 결제 버튼을 눌렀고, 이중 결제가 일어났습니다.
교훈:
- Idempotency Key(멱등성 키): 재요청할 때는 반드시 고유한 요청 ID를 보내서 서버가 "아 이거 아까 그거랑 같은 거네?" 하고 무시하게 해야 합니다.
- 쓰기(Write) 작업은 타임아웃을 넉넉하게 잡거나, 결과 조회(Polling) 로직을 추가해야 합니다.
8. Deep Dive: 두꺼비집 내리기 (Circuit Breaker)
단순 재시도(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(빨리 실패하기)"가 오히려 전체 시스템을 살립니다.
9. Case Study: 지수 백오프 직접 구현하기 (Exponential Backoff)
라이브러리 없이 직접 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) 막을 수 있습니다.
10. Refactoring Challenge: 요청 취소하기 (Cancel Token)
문제: 사용자가 검색어를 입력하다가 "취소" 버튼을 누르고 화면을 나갔습니다. 하지만 백그라운드에서는 무거운 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();
}
}
이 패턴은 "검색어 자동완성" 기능에서 필수입니다.
11. Deep Dive: SocketException vs TimeoutException
개발하다 보면 두 에러가 헷갈립니다.
- SocketException: 아예 인터넷이 끊겨서 서버 IP를 못 찾거나, 포트가 막힌 경우. (문 자체가 안 보임)
- TimeoutException: 연결은 됐는데 응답이 늦는 경우. (노크는 했는데 대답이 없음)
- HttpException: 연결은 됐고 응답도 왔는데 404, 500 등이 온 경우. (문은 열렸는데 주인이 화냄)
이걸 구분해서 예외 처리를 해야 합니다.
SocketException이라면 "인터넷 연결을 확인하세요"를 띄우고,
TimeoutException이라면 "서버가 혼잡합니다. 잠시 후 다시 시도하세요"를 띄워야 합니다.
12. Tip: await 없는 요청 (Fire and Forget)
가끔 타임아웃이 필요 없는 요청도 있습니다. "로그 수집"이나 "통계 전송" 같은 것들이죠. 이건 실패하든 말든 상관없고, 사용자에게 로딩을 보여줄 필요도 없습니다.
// await 안 함!
analytics.sendEvent('click_button');
// 또는 오류 무시
api.sendLog().catchError((e) => print('로그 전송 실패 (무시됨)'));
중요도를 따져서 메인 스레드를 잡지 않게 하세요.
11. Glossary
- DNS Resolution: Converting human-readable domain name (google.com) to IP address. Timeouts can happen here too.
- Handshake: The initial negotiation between client and server (TCP/TLS).
- Exponential Backoff: Increasing the waiting time between retries (1s, 2s, 4s, 8s) to prevent overwhelming a struggling server.
- Idempotency: The property that doing the same action multiple times has the same effect as doing it once.
12. Summary
- 영원히 기다리지 마라. 기본 타임아웃은 없거나 너무 길다.
- 5초~10초의 법칙. 모바일 환경은 불안정하다. 적절한 데드라인을 줘라.
- 재시도(Retry)는 자동으로. 사용자를 귀찮게 하지 마라.
- 멱등성(Idempotency) 주의. 타임아웃이 났다고 서버 작업이 취소된 건 아니다.
서버는 배신할 수 있어도, 클라이언트는 끝까지 사용자 편이어야 합니다.