2025. 4. 11. 10:48ㆍProgramming/Flutter
Flutter에서 Isolate를 활용한 HTTP 클라이언트 리팩토링
Flutter 앱에서 네트워크 요청은 매우 빈번하게 사용되지만, Flutter 자체는 싱글 스레드로 동작하다보니 메인 UI 스레드에서 무거운 네트워크 작업을 처리하면 앱이 버벅거리거나 응답성이 떨어집니다. 특히 지금 진행하고 있는 프로젝트는 P2P를 통해 IoT 장비와 직접 통신을 하면서, 동시에 클라우드 서버와도 통신을 하는 구조였기 때문에 리렌더링이 반복되면 네트워크 처리 속도가 떨어지는 문제가 있었죠.
이 문제를 해결하기 위해 HTTP 통신을 Isolate에서 처리하도록 HTTP 클라이언트를 구현해서 네트워크 속도에 대한 문제를 개선했는데, iOS에서 앱을 구동할 시 문제가 발생했습니다. 메인 Isolate와 HTTP 클라이언트 Isolate을 초기화하는 과정에서 소켓 통신이 정상적으로 이뤄지지 않아 타임아웃이 발생했던 것이죠.
이번 글에서는 전체적으로 Isolate를 활용해서 HTTP 클라이언트를 만드는 과정과, iOS에서 초기화시 문제가 발생하는 경우 재시도하는 루틴을 추가한 리팩토링 과정을 소개합니다.
기존 구조의 문제점
프로젝트에서 발생한 문제는 다음과 같습니다:
- iOS에서 앱 초기 구동 시 Isolate 초기화 과정에서 타임아웃이 발생해 소켓 통신이 실패하는 현상이 약 10% 확률로 발생
- 프로젝트 내 모든 HTTP 통신이 단일 Isolate에서 처리되며 이 Isolate가 static 객체로 관리되므로, Isolate 초기화 실패 시 앱 전체의 HTTP 통신이 불가능해지는 심각한 문제 발생
- 초기화 타임아웃 발생 시 적절한 복구 메커니즘 부재
- 네트워크 요청 실패 시 재시도 로직 부족
이런 설계는 Isolate 초기화에 실패하면 앱의 모든 네트워크 기능이 마비되는 단일 실패 지점(single point of failure)을 만들었습니다.
아래는 이러한 문제를 가진 간략화된 예제 코드입니다:
import 'dart:async';
import 'dart:isolate';
import 'package:dio/dio.dart';
class NetworkClient {
static final NetworkClient _instance = NetworkClient._internal();
factory NetworkClient() => _instance;
Isolate? _isolate;
SendPort? _sendPort;
ReceivePort _receivePort = ReceivePort();
bool _isInitialized = false;
Completer<void>? _initCompleter;
NetworkClient._internal();
Future<void> initialize() async {
if (_isInitialized) return;
if (_initCompleter != null) {
return _initCompleter!.future;
}
_initCompleter = Completer<void>();
try {
final initPortCompleter = Completer<SendPort>();
_receivePort.listen((message) {
if (message is SendPort) {
_sendPort = message;
_isInitialized = true;
_initCompleter?.complete();
_initCompleter = null;
initPortCompleter.complete(message);
} else if (message is Map<String, dynamic>) {
// 응답 처리 로직
}
});
_isolate = await Isolate.spawn(
_isolateEntryPoint,
_receivePort.sendPort,
);
// 10초 안에 초기화가 완료되지 않는 경우 타임아웃 처리
await initPortCompleter.future.timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('초기화 타임아웃');
},
);
} catch (e) {
_initCompleter?.completeError(e);
_initCompleter = null;
throw Exception('초기화 실패: $e');
}
}
// HTTP 요청 메소드
Future<Response> get(String url) async {
if (!_isInitialized) {
await initialize();
}
// 요청 로직
// ...
}
static void _isolateEntryPoint(SendPort mainSendPort) {
final receivePort = ReceivePort();
mainSendPort.send(receivePort.sendPort);
// Isolate 내부 로직
// ...
}
}
리팩토링 목표
리팩토링의 주요 목표는 다음과 같습니다:
- Isolate 초기화 실패 시 자동 재시도 로직 구현
- 최대 재시도 횟수 설정으로 무한 루프 방지
- 재시도 간 지연 시간 추가로 일시적인 네트워크 문제 해결 기회 제공
- 로깅 강화로 문제 추적 용이성 향상
리팩토링 적용
1. 초기화 메소드 개선
먼저 initialize()
메소드를 수정해 타임아웃 발생 시 적절한 정리 작업을 수행하도록 개선했습니다:
Future<void> initialize() async {
if (_isInitialized) return;
if (_initCompleter != null) {
return _initCompleter!.future;
}
_initCompleter = Completer<void>();
print('NetworkClient: 초기화 중...');
try {
final initPortCompleter = Completer<SendPort>();
_receivePort.listen((message) {
// 기존 리스너 로직...
});
_isolate = await Isolate.spawn(
_isolateEntryPoint,
_receivePort.sendPort,
);
await initPortCompleter.future.timeout(
// 타임아웃까지 걸리는 시간을 5초로 수정
const Duration(seconds: 5),
onTimeout: () {
_receivePort.close();
_receivePort = ReceivePort();
// 초기화 실패 시 자원 정리
_cleanup();
_isInitialized = false;
_initCompleter?.completeError('초기화 타임아웃');
_initCompleter = null;
// 로그 기록
Logger.log('NetworkClient 초기화 타임아웃 발생, 재설정 수행');
throw TimeoutException('초기화 타임아웃');
},
);
// 나머지 초기화 로직...
} catch (e, stackTrace) {
Logger.log('초기화 오류: $e\n스택 트레이스: $stackTrace');
_cleanup();
_initCompleter?.completeError(e);
_initCompleter = null;
throw Exception('NetworkClient 초기화 실패: $e');
}
}
// 자원 정리 메소드
void _cleanup() {
_isolate?.kill(priority: Isolate.immediate);
_isolate = null;
_sendPort = null;
_isInitialized = false;
// 대기 중인 요청 정리 로직...
}
2. 요청 메소드에 재시도 로직 추가
HTTP 요청 메소드에서 초기화 실패 시 재시도 로직을 구현했습니다:
Future<Response> _sendRequest({
required String method,
required String url,
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
// 워커 초기화 확인
if (!_isInitialized) {
int retryCount = 0;
const maxRetries = 3;
while (retryCount < maxRetries) {
try {
await initialize();
// 초기화 성공시 루프 탈출
break;
} catch (e) {
retryCount++;
if (e is TimeoutException) {
// 마지막 시도가 아니면 재시도
if (retryCount < maxRetries) {
Logger.log(
'NetworkClient 초기화 타임아웃 후 재시도 (${retryCount}/${maxRetries-1})',
);
// 잠시 대기 후 재시도
await Future.delayed(Duration(milliseconds: 500 * retryCount));
} else {
// 최대 재시도 횟수 초과
Logger.log(
'NetworkClient 초기화 최대 재시도 횟수(${maxRetries-1}) 초과: $e',
);
rethrow;
}
} else {
// 타임아웃 외 다른 예외는 즉시 전파
Logger.log(
'NetworkClient 초기화 실패 (타임아웃 아님): $e',
);
rethrow;
}
}
}
}
// 여기에 실제 HTTP 요청 로직...
}
개선된 점
이번 리팩토링으로 다음과 같은 개선을 이뤘습니다:
- 안정성 향상: iOS에서 낮은 빈도로 발생하는 초기화 타임아웃 문제를 자동 복구
- 사용자 경험 개선: 백그라운드에서 최대 3회까지 재시도해 사용자는 문제를 인지하지 못함
- 리소스 관리 개선: 초기화 실패 시 적절한 자원 정리로 메모리 누수 방지
성능 영향
이번 리팩토링이 성능에 미치는 영향은 미미합니다. 재시도 로직이 추가됐지만, 이는 초기화 과정에서만 실행되며 정상적인 경우엔 첫 번째 시도에서 성공하기 때문입니다. 재시도 간 지연 시간을 추가했지만, 이는 일시적인 네트워크 문제 해결 시간을 제공하기 위한 것으로 전체 앱 성능에 큰 영향을 주지 않습니다.
결론
Flutter 앱에서 Isolate를 활용한 HTTP 클라이언트는 UI 응답성을 유지하면서 네트워크 요청을 처리하는 좋은 방법입니다. 이번 리팩토링으로 iOS 환경에서의 초기화 타임아웃 문제를 해결하고, 단일 실패 지점이었던 Isolate의 안정성을 크게 향상시켰습니다.
예제 코드는 간략화된 버전이지만, 실제 프로덕션 환경에서는 추가적인 오류 처리와 더 복잡한 상황에 대한 대응이 필요할 수 있습니다. 각 프로젝트의 요구사항에 맞게 이 패턴을 확장하고 개선하는 것이 좋습니다.
참고 자료
'Programming > Flutter' 카테고리의 다른 글
Sign in with Apple: iOS에서는 실패하는데 Android/Web에서는 성공하는 경우? (0) | 2025.04.09 |
---|---|
[Flutter] 때때로 생성자에서 비동기 요청을 하게되면, 비동기 요청이 실행되기 전에 dispose()가 호출될 수도 있다. (0) | 2025.03.18 |
[Dart] Completer를 사용한 비동기 제어 (0) | 2025.01.15 |
[Dart] 메시지를 통해 동작하는 Isolate를 추상화하기 (0) | 2024.07.31 |
[Dart] Isolate에 대해 알아보자 (0) | 2024.07.29 |