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에서 네트워크 통신을 처리하는 방법은 다음과 같습니다:

  1. 기본 패키지 선택

    • 간단한 요청에는 http 패키지
    • 더 많은 기능이 필요하면 dio 패키지
  2. API 클라이언트 구조화

    • 모든 API 호출을 한 곳에서 관리
    • 인증, 에러 처리, 재시도 로직 중앙화
  3. 데이터 모델링

    • JSON 응답을 Dart 객체로 변환
    • 간단한 모델은 수동으로, 복잡한 모델은 json_serializable 사용
  4. 비동기 상태 관리

    • FutureBuilder 또는 상태 관리 라이브러리(Provider, Bloc 등) 사용
    • 로딩, 성공, 에러 상태 처리
  5. 오프라인 지원과 캐싱

    • 네트워크 요청 캐싱
    • 오프라인 우선 전략 구현
    • 백그라운드 동기화

이러한 방법들을 적절히 조합하여 사용하면 Flutter 앱에서 효율적이고 안정적인 네트워크 통신을 구현할 수 있습니다.

results matching ""

    No results matching ""