Flutter에서 네트워크 통신은 어떻게 처리하나요?
질문
Flutter에서 네트워크 통신(API 호출, HTTP 요청 등)을 처리하는 방법은 무엇인가요?
답변
Flutter에서 네트워크 통신은 다양한 패키지와 방법을 통해 처리할 수 있습니다. 기본적인 HTTP 요청부터 복잡한 RESTful API 통신까지 다양한 방법을 살펴보겠습니다.
1. http 패키지 사용하기
가장 기본적인 네트워크 통신 방법은 Flutter 팀에서 제공하는 http
패키지를 사용하는 것입니다.
패키지 추가
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
http: ^1.1.0
기본 HTTP 요청 예제
import 'package:http/http.dart' as http;
import 'dart:convert';
Future<void> fetchData() async {
try {
// GET 요청
final response = await http.get(Uri.parse('https://api.example.com/data'));
if (response.statusCode == 200) {
// 성공적으로 응답을 받은 경우
final data = jsonDecode(response.body);
print('받은 데이터: $data');
} else {
// 에러 응답을 받은 경우
print('요청 실패: ${response.statusCode}');
}
} catch (e) {
print('예외 발생: $e');
}
}
// POST 요청 예제
Future<void> postData() async {
try {
final response = await http.post(
Uri.parse('https://api.example.com/create'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token123',
},
body: jsonEncode({
'name': '홍길동',
'email': 'hong@example.com',
}),
);
if (response.statusCode == 201) {
print('데이터 생성 성공: ${response.body}');
} else {
print('데이터 생성 실패: ${response.statusCode}');
}
} catch (e) {
print('예외 발생: $e');
}
}
다른 HTTP 메소드
// PUT 요청
final putResponse = await http.put(
Uri.parse('https://api.example.com/update/1'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'name': '김철수'}),
);
// DELETE 요청
final deleteResponse = await http.delete(
Uri.parse('https://api.example.com/delete/1'),
headers: {'Authorization': 'Bearer token123'},
);
// 커스텀 요청
final client = http.Client();
final request = http.Request('PATCH', Uri.parse('https://api.example.com/partial-update/1'));
request.headers['Content-Type'] = 'application/json';
request.body = jsonEncode({'status': 'completed'});
final streamedResponse = await client.send(request);
final response = await http.Response.fromStream(streamedResponse);
2. dio 패키지 사용하기
dio
는 더 많은 기능을 제공하는 강력한 HTTP 클라이언트 패키지입니다.
패키지 추가
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
dio: ^5.3.2
기본 사용법
import 'package:dio/dio.dart';
Future<void> fetchDataWithDio() async {
final dio = Dio();
try {
// GET 요청
final response = await dio.get('https://api.example.com/data');
print('받은 데이터: ${response.data}');
// POST 요청
final postResponse = await dio.post(
'https://api.example.com/create',
data: {
'name': '홍길동',
'email': 'hong@example.com',
},
options: Options(
headers: {
'Authorization': 'Bearer token123',
},
),
);
print('응답: ${postResponse.data}');
} catch (e) {
if (e is DioException) {
print('에러 상태 코드: ${e.response?.statusCode}');
print('에러 데이터: ${e.response?.data}');
}
print('예외 발생: $e');
}
}
Dio의 고급 기능
// 기본 설정으로 Dio 인스턴스 생성
final dio = Dio(BaseOptions(
baseUrl: 'https://api.example.com',
connectTimeout: Duration(seconds: 5),
receiveTimeout: Duration(seconds: 3),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
));
// 인터셉터 추가
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
// 요청이 보내지기 전에 실행됨
print('요청: ${options.method} ${options.path}');
return handler.next(options);
},
onResponse: (response, handler) {
// 응답이 들어올 때 실행됨
print('응답: ${response.statusCode}');
return handler.next(response);
},
onError: (DioException e, handler) {
// 에러가 발생했을 때 실행됨
print('에러: ${e.message}');
return handler.next(e);
},
));
// FormData 사용하기 (파일 업로드)
Future<void> uploadFile() async {
FormData formData = FormData.fromMap({
'name': '홍길동',
'profile': await MultipartFile.fromFile(
'./profile.jpg',
filename: 'profile.jpg',
),
'files': [
await MultipartFile.fromFile('./file1.txt', filename: 'file1.txt'),
await MultipartFile.fromFile('./file2.txt', filename: 'file2.txt'),
]
});
final response = await dio.post('/upload', data: formData);
print('업로드 응답: ${response.data}');
}
// 다운로드 진행 상황 추적
Future<void> downloadFile() async {
try {
await dio.download(
'https://example.com/file.pdf',
'./downloads/file.pdf',
onReceiveProgress: (received, total) {
if (total != -1) {
print('${(received / total * 100).toStringAsFixed(0)}%');
}
},
);
print('다운로드 완료');
} catch (e) {
print('다운로드 실패: $e');
}
}
3. REST API 클라이언트 구현하기
실제 애플리케이션에서는 API 호출을 체계적으로 관리하기 위한 클래스를 만드는 것이 좋습니다.
// api_client.dart
import 'package:dio/dio.dart';
class ApiClient {
final Dio _dio;
// 싱글톤 패턴
static final ApiClient _instance = ApiClient._internal();
factory ApiClient() => _instance;
ApiClient._internal() : _dio = Dio(BaseOptions(
baseUrl: 'https://api.example.com',
connectTimeout: Duration(seconds: 5),
receiveTimeout: Duration(seconds: 3),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
)) {
_setupInterceptors();
}
void _setupInterceptors() {
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
// 토큰이 필요한 경우, 저장소에서 불러오기
final token = await _getToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
return handler.next(options);
},
onError: (DioException error, handler) async {
// 401 에러 처리 (인증 만료)
if (error.response?.statusCode == 401) {
// 토큰 갱신 로직
bool refreshed = await _refreshToken();
if (refreshed) {
// 원래 요청 재시도
return handler.resolve(await _retry(error.requestOptions));
}
}
return handler.next(error);
},
));
}
Future<String?> _getToken() async {
// 토큰 저장소에서 불러오는 로직
return 'sample_token';
}
Future<bool> _refreshToken() async {
// 토큰 갱신 로직
return true;
}
Future<Response<dynamic>> _retry(RequestOptions requestOptions) async {
final options = Options(
method: requestOptions.method,
headers: requestOptions.headers,
);
return _dio.request<dynamic>(
requestOptions.path,
data: requestOptions.data,
queryParameters: requestOptions.queryParameters,
options: options,
);
}
// API 메소드들
Future<Response> getUsers() {
return _dio.get('/users');
}
Future<Response> getUserById(int id) {
return _dio.get('/users/$id');
}
Future<Response> createUser(Map<String, dynamic> data) {
return _dio.post('/users', data: data);
}
Future<Response> updateUser(int id, Map<String, dynamic> data) {
return _dio.put('/users/$id', data: data);
}
Future<Response> deleteUser(int id) {
return _dio.delete('/users/$id');
}
}
// 사용 예제
void main() async {
final apiClient = ApiClient();
try {
final usersResponse = await apiClient.getUsers();
print('사용자 목록: ${usersResponse.data}');
final newUser = {
'name': '홍길동',
'email': 'hong@example.com',
};
final createResponse = await apiClient.createUser(newUser);
print('생성된 사용자: ${createResponse.data}');
} catch (e) {
print('API 오류: $e');
}
}
4. 모델 클래스와 직렬화
네트워크에서 받아온 JSON 데이터는 Dart 객체로 변환하는 것이 좋습니다.
수동 변환 방식
// user_model.dart
class User {
final int id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
// JSON에서 변환하는 팩토리 생성자
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
name: json['name'],
email: json['email'],
);
}
// 객체를 JSON으로 변환하는 메소드
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
};
}
}
// 사용 예제
final jsonData = {
'id': 1,
'name': '홍길동',
'email': 'hong@example.com',
};
final user = User.fromJson(jsonData);
print('사용자 이름: ${user.name}');
final jsonOutput = user.toJson();
print('JSON으로 변환: $jsonOutput');
json_serializable 패키지 사용하기
복잡한 모델의 경우 json_serializable
패키지를 사용하면 편리합니다.
# pubspec.yaml
dependencies:
json_annotation: ^4.8.1
dev_dependencies:
build_runner: ^2.4.6
json_serializable: ^6.7.1
// user_model.dart
import 'package:json_annotation/json_annotation.dart';
part 'user_model.g.dart'; // 코드 생성기가 생성할 파일
@JsonSerializable()
class User {
final int id;
final String name;
final String email;
@JsonKey(name: 'phone_number') // JSON 키 이름이 다를 경우
final String phoneNumber;
User({
required this.id,
required this.name,
required this.email,
required this.phoneNumber,
});
// 코드 생성기가 구현해주는 메소드
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
// 코드 생성 명령어: flutter pub run build_runner build
5. Future 및 비동기 작업 관리
네트워크 작업은 비동기이므로 적절한 상태 관리가 필요합니다.
FutureBuilder 사용
class UserListScreen extends StatelessWidget {
final ApiClient apiClient = ApiClient();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('사용자 목록')),
body: FutureBuilder<Response>(
future: apiClient.getUsers(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(child: Text('에러: ${snapshot.error}'));
} else if (snapshot.hasData) {
final users = (snapshot.data!.data as List)
.map((json) => User.fromJson(json))
.toList();
return ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return ListTile(
title: Text(user.name),
subtitle: Text(user.email),
);
},
);
} else {
return Center(child: Text('데이터 없음'));
}
},
),
);
}
}
상태 관리와 함께 사용
// providers/user_provider.dart (Provider 패키지 사용)
import 'package:flutter/foundation.dart';
import '../models/user_model.dart';
import '../services/api_client.dart';
enum DataState { initial, loading, loaded, error }
class UserProvider with ChangeNotifier {
List<User> _users = [];
DataState _state = DataState.initial;
String? _error;
List<User> get users => _users;
DataState get state => _state;
String? get error => _error;
final ApiClient _apiClient = ApiClient();
Future<void> fetchUsers() async {
try {
_state = DataState.loading;
notifyListeners();
final response = await _apiClient.getUsers();
final List<dynamic> usersJson = response.data;
_users = usersJson.map((json) => User.fromJson(json)).toList();
_state = DataState.loaded;
} catch (e) {
_state = DataState.error;
_error = e.toString();
} finally {
notifyListeners();
}
}
Future<void> createUser(Map<String, dynamic> userData) async {
try {
_state = DataState.loading;
notifyListeners();
await _apiClient.createUser(userData);
await fetchUsers(); // 목록 갱신
} catch (e) {
_state = DataState.error;
_error = e.toString();
notifyListeners();
}
}
}
// UI에서 사용
class UserScreen extends StatefulWidget {
@override
_UserScreenState createState() => _UserScreenState();
}
class _UserScreenState extends State<UserScreen> {
@override
void initState() {
super.initState();
// 화면이 로드되면 사용자 목록 불러오기
Future.microtask(() =>
Provider.of<UserProvider>(context, listen: false).fetchUsers()
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('사용자')),
body: Consumer<UserProvider>(
builder: (context, userProvider, child) {
switch (userProvider.state) {
case DataState.loading:
return Center(child: CircularProgressIndicator());
case DataState.loaded:
return ListView.builder(
itemCount: userProvider.users.length,
itemBuilder: (context, index) {
final user = userProvider.users[index];
return ListTile(
title: Text(user.name),
subtitle: Text(user.email),
);
},
);
case DataState.error:
return Center(child: Text('에러: ${userProvider.error}'));
default:
return Center(child: Text('데이터를 불러오세요'));
}
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// 사용자 추가 다이얼로그 표시
},
child: Icon(Icons.add),
),
);
}
}
6. 네트워크 통신 디버깅
Dio를 사용한 로깅
import 'package:dio/dio.dart';
import 'package:logger/logger.dart';
final logger = Logger();
final dio = Dio();
dio.interceptors.add(LogInterceptor(
request: true,
requestHeader: true,
requestBody: true,
responseHeader: true,
responseBody: true,
error: true,
logPrint: (object) => logger.d(object),
));
네트워크 프록시 설정
개발 중 네트워크 요청을 모니터링하기 위해 Charles 또는 Fiddler와 같은 프록시 도구를 사용할 수 있습니다.
final dio = Dio(BaseOptions(
baseUrl: 'https://api.example.com',
// 프록시 설정 (로컬 개발에서만 사용)
proxy: 'localhost:8888',
));
7. 오프라인 지원 및 캐싱
네트워크 상태에 관계없이 앱이 동작하도록 캐싱 전략을 구현할 수 있습니다.
기본 캐싱 전략
import 'package:dio/dio.dart';
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
void setupDioWithCache() {
// 캐시 옵션 설정
final options = CacheOptions(
// 디스크에 캐시 저장
store: HiveCacheStore('./cache'),
// 모든 HTTP 메소드에 대해 캐시 가능
policy: CachePolicy.forceCache,
// 캐시 우선 (네트워크 요청 실패시 캐시 사용)
hitCacheOnErrorExcept: [401, 403],
// 6시간 동안 캐시 유지
maxStale: const Duration(hours: 6),
priority: CachePriority.normal,
cipher: null,
keyBuilder: CacheOptions.defaultCacheKeyBuilder,
allowPostMethod: false,
);
final dio = Dio();
dio.interceptors.add(DioCacheInterceptor(options: options));
}
오프라인 우선 모델
class OfflineFirstApiService {
final Dio _dio;
final LocalDatabase _db; // 로컬 데이터베이스 (Hive 또는 SQLite)
final ConnectivityService _connectivity; // 네트워크 연결 상태 확인
OfflineFirstApiService(this._dio, this._db, this._connectivity);
Future<List<User>> getUsers() async {
try {
// 네트워크 연결 확인
final isConnected = await _connectivity.isConnected();
if (isConnected) {
// 온라인 상태: API에서 데이터 가져오기
final response = await _dio.get('/users');
final users = (response.data as List)
.map((json) => User.fromJson(json))
.toList();
// 로컬 DB에 데이터 저장
await _db.saveUsers(users);
return users;
} else {
// 오프라인 상태: 로컬 DB에서 데이터 가져오기
return await _db.getUsers();
}
} catch (e) {
// 에러 발생: 가능하면 로컬 DB에서 데이터 가져오기
return await _db.getUsers();
}
}
Future<User?> createUser(Map<String, dynamic> userData) async {
try {
final isConnected = await _connectivity.isConnected();
if (isConnected) {
// 온라인 상태: API로 사용자 생성
final response = await _dio.post('/users', data: userData);
final user = User.fromJson(response.data);
// 로컬 DB에 저장
await _db.saveUser(user);
return user;
} else {
// 오프라인 상태: 작업 큐에 추가
await _db.addToSyncQueue('create_user', userData);
return null;
}
} catch (e) {
// 에러 발생: 작업 큐에 추가
await _db.addToSyncQueue('create_user', userData);
return null;
}
}
// 백그라운드 동기화 작업
Future<void> syncPendingOperations() async {
final isConnected = await _connectivity.isConnected();
if (!isConnected) return;
final pendingOperations = await _db.getPendingSyncOperations();
for (final op in pendingOperations) {
try {
switch (op.type) {
case 'create_user':
await _dio.post('/users', data: op.data);
break;
case 'update_user':
await _dio.put('/users/${op.id}', data: op.data);
break;
case 'delete_user':
await _dio.delete('/users/${op.id}');
break;
}
// 성공적으로 동기화된 작업 제거
await _db.removeSyncOperation(op.id);
} catch (e) {
print('동기화 실패: ${op.type} - $e');
}
}
}
}
요약
Flutter에서 네트워크 통신을 처리하는 방법은 다음과 같습니다:
기본 패키지 선택
- 간단한 요청에는
http
패키지 - 더 많은 기능이 필요하면
dio
패키지
- 간단한 요청에는
API 클라이언트 구조화
- 모든 API 호출을 한 곳에서 관리
- 인증, 에러 처리, 재시도 로직 중앙화
데이터 모델링
- JSON 응답을 Dart 객체로 변환
- 간단한 모델은 수동으로, 복잡한 모델은
json_serializable
사용
비동기 상태 관리
FutureBuilder
또는 상태 관리 라이브러리(Provider, Bloc 등) 사용- 로딩, 성공, 에러 상태 처리
오프라인 지원과 캐싱
- 네트워크 요청 캐싱
- 오프라인 우선 전략 구현
- 백그라운드 동기화
이러한 방법들을 적절히 조합하여 사용하면 Flutter 앱에서 효율적이고 안정적인 네트워크 통신을 구현할 수 있습니다.