목차
1. 서론: 왜 플러터(Flutter)인가?
플러터는 구글이 개발하고 세상에 선보인 오픈소스 UI 소프트웨어 개발 키트입니다. 그 핵심 가치는 '단일 코드베이스'로 iOS, 안드로이드, 웹, 데스크톱 등 다양한 플랫폼에서 미려하고 성능이 뛰어난 네이티브 애플리케이션을 구축할 수 있다는 점에 있습니다. 이는 개발자에게 엄청난 생산성 향상을 가져다주며, 기업 입장에서는 시간과 비용을 획기적으로 절감할 수 있는 강력한 무기가 됩니다.
플러터의 매력은 단순히 크로스플랫폼 지원에만 국한되지 않습니다. 몇 가지 핵심적인 특징을 더 깊이 살펴보겠습니다.
- 선언적 UI와 위젯(Widget) 기반 아키텍처: 플러터에서는 모든 것이 위젯입니다. UI를 구성하는 버튼, 텍스트, 레이아웃 구조는 물론, 애니메이션이나 제스처 처리와 같은 기능적 요소까지도 위젯으로 표현됩니다. 개발자는 원하는 UI의 상태를 코드로 선언하기만 하면, 프레임워크가 내부적으로 해당 상태에 맞게 화면을 렌더링합니다. 이는 UI 코드를 직관적으로 만들고 상태 변화에 따른 복잡성을 줄여줍니다.
- 빠른 개발 사이클을 위한 Hot Reload: 플러터의 가장 사랑받는 기능 중 하나는 'Hot Reload'입니다. 코드를 수정한 후 저장하면, 단 몇 초 만에 변경사항이 실행 중인 앱에 즉시 반영됩니다. 앱의 상태는 그대로 유지된 채 UI만 갱신되므로, 디자이너와 개발자가 함께 작업하며 UI를 미세 조정하거나 버그를 수정하는 과정이 놀랍도록 빨라집니다.
- 네이티브에 버금가는 성능: 플러터는 자바스크립트 브릿지를 통해 네이티브 위젯을 호출하는 방식이 아닙니다. 자체적인 고성능 렌더링 엔진인 스키아(Skia)를 사용하여 화면의 모든 픽셀을 직접 그립니다. 또한, 애플리케이션 코드는 Dart 언어로 작성되어 ARM 또는 x64 머신 코드로 직접 컴파일되므로, 네이티브 앱과 거의 구별할 수 없는 부드러운 애니메이션과 빠른 실행 속도를 보장합니다.
하지만 아무리 화려하고 빠른 애플리케이션이라도, 그 자체로 고립되어 있다면 가치는 제한적일 수밖에 없습니다. 현대의 애플리케이션은 대부분 외부 세계, 즉 인터넷상의 서버와 데이터를 주고받으며 사용자에게 동적인 콘텐츠와 서비스를 제공합니다. 날씨 앱은 기상청 서버에서 최신 예보를 가져오고, 소셜 미디어 앱은 친구들의 새로운 소식을 서버로부터 받아옵니다. 이처럼 플러터 앱에 생명을 불어넣는 핵심 기술이 바로 HTTP 통신입니다. 본 글에서는 플러터 애플리케이션이 어떻게 외부 서버와 데이터를 교환하는지, 그 원리와 실전 적용 방법을 심도 있게 다룰 것입니다.
2. 네트워크의 언어: HTTP 프로토콜 심층 분석
HTTP(HyperText Transfer Protocol)는 월드 와이드 웹(WWW)의 기반을 이루는 핵심 프로토콜로, 클라이언트(주로 웹 브라우저나 모바일 앱)와 서버 간에 데이터를 요청하고 응답하는 방식을 규정한 약속입니다. 플러터 앱이 서버의 API를 호출하고 데이터를 받아오는 과정은 모두 이 HTTP라는 언어를 통해 이루어집니다. 따라서 효율적인 네트워크 프로그래밍을 위해서는 HTTP에 대한 깊이 있는 이해가 필수적입니다.
HTTP 메시지 구조
클라이언트와 서버가 주고받는 데이터 패킷을 'HTTP 메시지'라고 부릅니다. 이는 요청(Request) 메시지와 응답(Response) 메시지로 나뉘며, 기본적인 구조는 유사합니다.
- 시작 줄 (Start Line): 메시지의 가장 첫 줄로, 요청의 종류나 응답의 결과를 나타냅니다.
- 요청 시: HTTP 메서드 (예: `GET`), 요청 대상의 URL (예: `/users/1`), HTTP 버전 (예: `HTTP/1.1`)으로 구성됩니다. (`GET /users/1 HTTP/1.1`)
- 응답 시: HTTP 버전, 상태 코드 (예: `200`), 상태 텍스트 (예: `OK`)로 구성됩니다. (`HTTP/1.1 200 OK`)
- 헤더 (Headers): 메시지에 대한 추가 정보를 담고 있는 'Key: Value' 쌍의 집합입니다. 요청 헤더에는 클라이언트의 정보, 원하는 데이터 형식 등이 포함되고, 응답 헤더에는 서버의 정보, 전송되는 데이터의 형식(`Content-Type`) 등이 포함됩니다.
- 본문 (Body): 실제 전송하려는 데이터를 담는 부분입니다. GET 요청처럼 데이터를 가져오는 경우에는 본문이 비어있는 경우가 많지만, POST나 PUT 요청처럼 서버에 데이터를 전송할 때는 이 부분에 JSON이나 XML 형식의 데이터가 담깁니다. 서버의 응답에도 사용자에게 보여줄 실제 데이터가 본문에 담겨 전달됩니다.
주요 HTTP 요청 메서드
HTTP는 특정 리소스(Resource, URL로 식별되는 데이터)에 대해 수행하고자 하는 행동을 '메서드'로 정의합니다. 이는 CRUD(Create, Read, Update, Delete) 연산과 밀접한 관련이 있습니다.
- GET: 서버로부터 특정 리소스를 조회(Read)하기 위해 사용됩니다. 가장 흔하게 사용되는 메서드로, 데이터를 가져오는 역할만 수행하며 서버의 상태를 변경하지 않는 것이 특징입니다(이를 '안전하다(Safe)'고 표현합니다).
- POST: 서버에 새로운 리소스를 생성(Create)하기 위해 사용됩니다. 요청 본문에 생성할 데이터의 정보를 담아 전송하며, 호출할 때마다 새로운 리소스가 생성될 수 있습니다.
- PUT: 특정 리소스를 전체 수정(Update)하거나, 해당 리소스가 존재하지 않으면 생성하기 위해 사용됩니다. 요청 본문에 리소스의 전체 데이터를 담아 전송합니다. 동일한 요청을 여러 번 보내도 결과가 항상 같다는 '멱등성(Idempotent)'을 가집니다.
- PATCH: 특정 리소스의 일부만 수정(Update)하기 위해 사용됩니다. PUT과 달리 변경하려는 필드만 본문에 담아 전송하므로 더 효율적일 수 있습니다.
- DELETE: 특정 리소스를 삭제(Delete)하기 위해 사용됩니다. 성공적으로 삭제되면 해당 리소스는 더 이상 접근할 수 없게 됩니다.
서버의 답변: HTTP 상태 코드
클라이언트가 요청을 보내면, 서버는 요청의 처리 결과를 세 자리 숫자로 된 '상태 코드'로 응답합니다. 이 코드를 통해 개발자는 요청이 성공했는지, 실패했다면 그 원인이 무엇인지 파악할 수 있습니다.
- 2xx (성공): 요청이 성공적으로 처리되었음을 의미합니다.
- `200 OK`: 가장 일반적인 성공 응답입니다.
- `201 Created`: POST 요청에 대한 응답으로, 리소스가 성공적으로 생성되었음을 의미합니다.
- `204 No Content`: 요청은 성공했지만 응답 본문에 보낼 데이터가 없음을 의미합니다. (예: DELETE 성공 후)
- 3xx (리다이렉션): 요청을 완료하기 위해 추가적인 조치가 필요함을 의미합니다. 주로 페이지 이동에 사용됩니다.
- 4xx (클라이언트 오류): 요청 자체에 오류가 있음을 의미합니다.
- `400 Bad Request`: 요청의 문법이 잘못되었거나 서버가 이해할 수 없는 요청일 경우 발생합니다.
- `401 Unauthorized`: 인증되지 않은 사용자의 요청을 거부할 때 사용됩니다. 로그인이 필요함을 의미합니다.
- `403 Forbidden`: 인증은 되었지만 해당 리소스에 접근할 권한이 없을 때 발생합니다.
- `404 Not Found`: 요청한 리소스를 서버에서 찾을 수 없을 때 발생합니다. 가장 흔하게 접하는 오류 중 하나입니다.
- 5xx (서버 오류): 서버 내부에서 오류가 발생하여 요청을 처리할 수 없음을 의미합니다.
- `500 Internal Server Error`: 서버 코드의 버그, 데이터베이스 연결 문제 등 서버 측에서 예기치 못한 오류가 발생했음을 나타내는 일반적인 코드입니다.
3. 플러터 앱이 서버와 소통해야 하는 이유
플러터 앱이 외부 서버와 HTTP 통신을 하는 것은 선택이 아닌 필수입니다. 앱 내부에 모든 데이터를 저장하는 것은 비효율적이고 불가능에 가깝습니다. HTTP 통신은 앱의 기능을 확장하고 사용자에게 풍부한 경험을 제공하는 생명줄과도 같습니다.
- 동적 데이터 제공: 사용자가 앱을 열 때마다 항상 새로운 콘텐츠를 보여주기 위해 서버로부터 데이터를 가져와야 합니다. 뉴스 앱의 최신 기사, 쇼핑 앱의 상품 목록, 지도 앱의 실시간 교통 정보 등은 모두 HTTP GET 요청을 통해 서버에서 가져온 동적 데이터입니다.
- 사용자 데이터 관리: 사용자가 생성하는 데이터(게시글, 댓글, 프로필 정보 등)를 안전하게 저장하고 다른 기기에서도 동일하게 접근할 수 있도록 하려면 서버의 데이터베이스에 저장해야 합니다. 이 과정에서 사용자가 입력한 데이터를 HTTP POST 또는 PUT 요청을 통해 서버로 전송합니다.
- 사용자 인증 및 개인화: 로그인, 회원가입 기능은 사용자의 인증 정보를 서버로 보내 유효성을 검사하는 과정이 필수적입니다. 인증이 성공하면 서버는 사용자별 맞춤 데이터를 제공하거나 특정 기능에 대한 접근 권한을 부여합니다. 이는 모두 HTTP 통신을 기반으로 이루어집니다.
- 외부 서비스 연동: 현대의 앱은 다양한 외부 API를 활용하여 기능을 확장합니다. 예를 들어, 소셜 로그인(구글, 카카오), 결제 시스템 연동, 지도 서비스, 날씨 정보 API 등 외부 서비스와의 상호작용은 모두 정해진 규격에 따라 HTTP 요청을 보내고 응답을 받는 방식으로 구현됩니다.
이 모든 과정은 비동기적으로 처리되어야 합니다. 네트워크 요청은 완료되기까지 시간이 걸릴 수 있는데, 만약 이 시간 동안 앱의 UI가 멈춘다면(Blocking) 사용자는 극심한 불편함을 겪게 될 것입니다. 플러터의 기반 언어인 Dart는 `Future`와 `async`/`await` 문법을 통해 비동기 프로그래밍을 매우 간결하고 직관적으로 지원하며, 이를 통해 네트워크 작업이 진행되는 동안에도 부드러운 사용자 경험을 유지할 수 있습니다.
4. 플러터 HTTP 통신을 위한 도구들: http vs. dio
플러터에서 HTTP 통신을 구현하기 위해 직접 소켓을 다룰 필요는 없습니다. 잘 만들어진 패키지들을 활용하면 훨씬 쉽고 안정적으로 네트워크 기능을 구현할 수 있습니다. 가장 대표적인 두 패키지는 `http`와 `dio`입니다.
기본에 충실한 'http' 패키지
http
패키지는 Dart 팀에서 공식적으로 제공하는 패키지로, HTTP 통신에 필요한 기본적인 기능을 간단하고 직관적인 API로 제공합니다. 가볍고 사용법이 쉬워 간단한 GET, POST 요청을 처리하는 데 적합합니다.
설치 방법: `pubspec.yaml` 파일에 의존성을 추가합니다.
dependencies:
flutter:
sdk: flutter
http: ^1.1.0 # 최신 버전 확인 후 사용
터미널에서 `flutter pub get` 명령을 실행하여 패키지를 설치합니다.
기본 사용법 (GET 요청):
import 'package:http/http.dart' as http;
import 'dart:convert';
void fetchPosts() async {
final url = Uri.parse('https://jsonplaceholder.typicode.com/posts/1');
try {
final response = await http.get(url);
if (response.statusCode == 200) {
// 요청 성공
final body = jsonDecode(response.body);
print('Title: ${body['title']}');
} else {
// 서버 오류 (4xx, 5xx)
print('Error: ${response.statusCode}');
}
} catch (e) {
// 네트워크 오류 또는 기타 예외
print('Exception: $e');
}
}
http
패키지는 입문자가 배우기 쉽다는 장점이 있지만, 복잡한 애플리케이션에서 요구하는 고급 기능(인터셉터, 요청 취소, 타임아웃 설정, 폼 데이터 전송 등)은 직접 구현해야 하는 번거로움이 있습니다.
강력하고 유연한 'dio' 패키지
dio
는 플러터 커뮤니티에서 가장 널리 사용되는 강력한 HTTP 클라이언트 라이브러리입니다. http
패키지의 모든 기능을 포함하면서, 실제 애플리케이션 개발에 필요한 다양한 고급 기능을 기본적으로 제공합니다.
주요 기능:
- 인터셉터(Interceptors): 모든 요청과 응답, 에러를 가로채서 로깅, 인증 토큰 추가, 에러 변환 등의 공통 로직을 일괄적으로 처리할 수 있습니다.
- 전역 설정: 기본 URL(Base URL), 타임아웃, 헤더 등 공통 옵션을 한 번에 설정하고 모든 요청에 적용할 수 있습니다.
- 요청 취소(Request Cancellation): 불필요해진 네트워크 요청을 중간에 취소하여 리소스를 절약할 수 있습니다.
- 폼 데이터(FormData): 파일 업로드와 같은 multipart/form-data 요청을 쉽게 처리할 수 있습니다.
- 다운로드/업로드 진행률 추적: 대용량 파일 전송 시 진행 상태를 모니터링할 수 있습니다.
설치 방법: `pubspec.yaml`에 추가합니다.
dependencies:
flutter:
sdk: flutter
dio: ^5.3.3 # 최신 버전 확인 후 사용
기본 사용법 (GET 요청):
import 'package:dio/dio.dart';
void fetchPostsWithDio() async {
final dio = Dio();
final url = 'https://jsonplaceholder.typicode.com/posts/1';
try {
final response = await dio.get(url);
if (response.statusCode == 200) {
// dio는 기본적으로 JSON을 파싱하여 Map/List 형태로 반환합니다.
print('Title: ${response.data['title']}');
}
// dio는 2xx가 아닌 상태 코드에 대해 DioException을 발생시키므로,
// 별도의 상태 코드 분기 없이 catch 블록에서 에러를 처리할 수 있습니다.
} on DioException catch (e) {
if (e.response != null) {
// 서버가 응답했지만, 상태 코드가 2xx가 아님
print('Server Error: ${e.response?.statusCode}');
print('Response Data: ${e.response?.data}');
} else {
// 요청 설정 중 또는 전송 중 오류 발생 (네트워크 문제 등)
print('Request Error: ${e.message}');
}
}
}
대부분의 실무 프로젝트에서는 코드의 재사용성과 유지보수성을 높여주는 `dio`를 사용하는 것이 일반적입니다. 이 글의 실전 예제에서도 `dio`를 사용하여 진행하겠습니다.
5. 실전 CRUD 예제: REST API 연동 애플리케이션 제작
이론을 배웠으니 이제 직접 코드를 작성해 볼 차례입니다. 가상의 블로그 포스트 데이터를 다루는 간단한 앱을 만들어 보겠습니다. 공개된 테스트용 API인 JSONPlaceholder를 사용하여 게시글 목록을 조회(Read), 새로운 글을 등록(Create), 기존 글을 수정(Update), 삭제(Delete)하는 전체 CRUD 흐름을 구현합니다.
프로젝트 설정 및 의존성 추가
먼저, 새로운 플러터 프로젝트를 생성합니다. 그리고 `pubspec.yaml` 파일에 `dio` 패키지를 추가하고 `flutter pub get`을 실행합니다.
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
dio: ^5.3.3
데이터 모델 클래스 정의 (JSON 직렬화)
API로부터 받는 JSON 데이터를 Dart 객체로 쉽게 변환하고, Dart 객체를 다시 JSON으로 변환하기 위해 데이터 모델 클래스를 만듭니다. 이는 코드의 안정성과 가독성을 크게 향상시킵니다.
// post_model.dart
class Post {
final int? userId;
final int? id;
final String title;
final String body;
Post({
this.userId,
this.id,
required this.title,
required this.body,
});
// JSON(Map)에서 Post 객체로 변환하는 팩토리 생성자
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
userId: json['userId'],
id: json['id'],
title: json['title'],
body: json['body'],
);
}
// Post 객체를 JSON(Map)으로 변환하는 메서드
Map<String, dynamic> toJson() {
return {
'userId': userId,
'id': id,
'title': title,
'body': body,
};
}
}
API 통신을 담당하는 서비스 레이어 구현
모든 네트워크 요청 로직을 한 곳에 모아 관리하면 코드의 유지보수가 용이해집니다. `ApiService` 클래스를 만들어 CRUD 기능을 각각 메서드로 구현합니다.
// api_service.dart
import 'package:dio/dio.dart';
import 'post_model.dart';
class ApiService {
final Dio _dio = Dio(BaseOptions(
baseUrl: 'https://jsonplaceholder.typicode.com',
connectTimeout: Duration(seconds: 5),
receiveTimeout: Duration(seconds: 3),
));
// 모든 게시글 조회 (GET)
Future<List<Post>> getPosts() async {
try {
final response = await _dio.get('/posts');
// response.data는 List<dynamic> 타입이므로, 각 요소를 Post 객체로 변환
return (response.data as List).map((post) => Post.fromJson(post)).toList();
} catch (e) {
throw Exception('Failed to load posts: $e');
}
}
// 새로운 게시글 생성 (POST)
Future<Post> createPost(Post post) async {
try {
final response = await _dio.post(
'/posts',
data: post.toJson(), // Post 객체를 JSON으로 변환하여 body에 담아 전송
);
return Post.fromJson(response.data);
} catch (e) {
throw Exception('Failed to create post: $e');
}
}
// 게시글 수정 (PUT)
Future<Post> updatePost(int id, Post post) async {
try {
final response = await _dio.put(
'/posts/$id',
data: post.toJson(),
);
return Post.fromJson(response.data);
} catch (e) {
throw Exception('Failed to update post: $e');
}
}
// 게시글 삭제 (DELETE)
Future<void> deletePost(int id) async {
try {
await _dio.delete('/posts/$id');
} catch (e) {
throw Exception('Failed to delete post: $e');
}
}
}
데이터 목록 조회 (GET) 및 화면 표시 (FutureBuilder)
비동기 작업의 결과를 손쉽게 UI에 반영하기 위해 `FutureBuilder` 위젯을 사용합니다. `FutureBuilder`는 `future` 속성으로 받은 비동기 작업의 상태(로딩 중, 완료, 에러)에 따라 다른 위젯을 보여줄 수 있습니다.
// main.dart
import 'package:flutter/material.dart';
import 'api_service.dart';
import 'post_model.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter CRUD Example',
home: PostListPage(),
);
}
}
class PostListPage extends StatefulWidget {
@override
_PostListPageState createState() => _PostListPageState();
}
class _PostListPageState extends State<PostListPage> {
final ApiService _apiService = ApiService();
late Future<List<Post>> _posts;
@override
void initState() {
super.initState();
_posts = _apiService.getPosts();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Posts')),
body: FutureBuilder<List<Post>>(
future: _posts,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
// 로딩 중일 때
return Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
// 에러가 발생했을 때
return Center(child: Text('Error: ${snapshot.error}'));
} else if (!snapshot.hasData || snapshot.data!.isEmpty) {
// 데이터가 없을 때
return Center(child: Text('No posts found.'));
} else {
// 데이터 로딩 성공
final posts = snapshot.data!;
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return ListTile(
title: Text(post.title),
subtitle: Text(post.body),
// 여기에 수정/삭제 기능 추가 예정
);
},
);
}
},
),
// 여기에 글쓰기 기능 추가 예정
);
}
}
새로운 데이터 생성 (POST)
글쓰기 버튼(FloatingActionButton)을 누르면 다이얼로그가 나타나고, 제목과 내용을 입력하여 새 글을 등록하는 기능을 구현합니다.
// PostListPage의 build 메서드 내 Scaffold에 추가
floatingActionButton: FloatingActionButton(
onPressed: _showCreatePostDialog,
child: Icon(Icons.add),
),
// PostListPageState 클래스에 메서드 추가
void _showCreatePostDialog() {
final titleController = TextEditingController();
final bodyController = TextEditingController();
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Create New Post'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: titleController, decoration: InputDecoration(labelText: 'Title')),
TextField(controller: bodyController, decoration: InputDecoration(labelText: 'Body')),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel')),
TextButton(
onPressed: () async {
final newPost = Post(
title: titleController.text,
body: bodyController.text,
userId: 1, // 예시용 사용자 ID
);
try {
await _apiService.createPost(newPost);
Navigator.pop(context);
// 목록을 새로고침
setState(() {
_posts = _apiService.getPosts();
});
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Post created successfully!')));
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to create post: $e')));
}
},
child: Text('Create'),
),
],
);
},
);
}
기존 데이터 수정 (PUT)
리스트의 각 항목을 탭하면 기존 내용을 수정할 수 있는 다이얼로그를 띄웁니다. 로직은 생성과 매우 유사합니다.
데이터 삭제 (DELETE)
리스트 항목에 삭제 버튼을 추가하고, 확인 다이얼로그를 거쳐 해당 데이터를 삭제하는 기능을 구현합니다. 성공적으로 삭제되면 목록을 다시 불러와 UI를 갱신합니다.
(Update와 Delete 기능의 구체적인 UI 코드는 Create와 유사한 방식으로 구현할 수 있으므로, 핵심 로직에 집중하기 위해 전체 코드는 생략합니다. 핵심은 `_apiService.updatePost(...)`와 `_apiService.deletePost(...)`를 호출하고, 성공 시 `setState`를 통해 목록을 갱신하는 것입니다.)
6. 고급 주제 및 문제 해결
기본적인 CRUD 구현을 넘어, 실제 프로덕션 환경에서 마주할 수 있는 여러 가지 상황에 대비해야 합니다.
우아한 예외 처리 전략
단순히 `try-catch`로 예외를 잡는 것을 넘어, 사용자에게 어떤 문제가 발생했는지 명확히 알려주고 다음 행동을 유도하는 것이 중요합니다.
- 네트워크 연결 오류: `DioException`의 `type`이 `DioExceptionType.connectionError`나 `DioExceptionType.connectionTimeout`인 경우, 인터넷 연결을 확인하라는 메시지를 보여줄 수 있습니다.
- 서버 응답 오류 (4xx, 5xx): `e.response` 객체를 확인하여 상태 코드에 따라 분기 처리를 할 수 있습니다. 예를 들어 401 Unauthorized 오류 시, 로그인 페이지로 리다이렉트 시키는 로직을 구현할 수 있습니다.
- 에러 로깅: Sentry나 Firebase Crashlytics와 같은 서비스를 연동하여 발생하는 모든 네트워크 예외를 수집하고 분석하면, 잠재적인 문제를 빠르게 파악하고 대응하는 데 큰 도움이 됩니다.
상태 관리와 비동기 UI
`FutureBuilder`는 단일 비동기 작업을 처리하는 데는 유용하지만, 앱의 상태가 복잡해지면 한계에 부딪힙니다. 여러 화면에서 동일한 데이터를 사용하거나, 사용자의 상호작용에 따라 데이터가 계속 변경되는 경우, Provider, Riverpod, BLoC, GetX와 같은 상태 관리 라이브러리를 도입하는 것이 좋습니다. 이러한 라이브러리들은 비동기 데이터의 상태(loading, data, error)를 체계적으로 관리하고, 데이터가 변경되었을 때 필요한 UI만 효율적으로 갱신해주어 훨씬 깔끔하고 확장 가능한 코드를 작성할 수 있게 해줍니다.
인증 헤더와 토큰 관리
대부분의 API는 인증된 사용자만 접근할 수 있도록 API 키나 JWT(JSON Web Token) 같은 인증 토큰을 요구합니다. 이 토큰은 보통 로그인 시 서버로부터 발급받아 기기 내 안전한 공간(예: `flutter_secure_storage` 패키지 사용)에 저장됩니다. 이후 모든 API 요청 시 HTTP 헤더에 이 토큰을 포함하여 보내야 합니다.
dio
의 인터셉터를 사용하면 이 과정을 매우 우아하게 자동화할 수 있습니다.
// auth_interceptor.dart
import 'package:dio/dio.dart';
// import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class AuthInterceptor extends Interceptor {
// final _storage = FlutterSecureStorage();
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
// 저장된 토큰을 읽어옴 (실제로는 secure storage 사용)
// final token = await _storage.read(key: 'accessToken');
const token = 'YOUR_ACCESS_TOKEN'; // 예시용 토큰
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
super.onRequest(options, handler);
}
}
// ApiService 생성자에 인터셉터 추가
// final Dio _dio = Dio(...);
// _dio.interceptors.add(AuthInterceptor());
자주 마주치는 문제와 해결책
- CORS(Cross-Origin Resource Sharing) 오류: 브라우저(웹) 환경에서 플러터 앱을 실행할 때 주로 발생하는 문제입니다. 이는 서버 측에서 다른 도메인으로부터의 API 요청을 허용하지 않았기 때문입니다. 클라이언트(플러터) 코드로는 해결할 수 없으며, 서버 개발자에게 요청하여 해당 앱의 도메인을 허용 목록에 추가해야 합니다.
- 혼합 콘텐츠(Mixed Content) 오류: HTTPS를 사용하는 앱에서 HTTP 엔드포인트로 요청을 보낼 때 발생합니다. 보안상의 이유로 차단되는 것이므로, API 엔드포인트를 반드시 HTTPS로 변경해야 합니다. 개발 단계에서는 안드로이드의 경우 `AndroidManifest.xml`에 특정 설정을 추가하여 임시로 HTTP 통신을 허용할 수 있지만, 프로덕션에서는 권장되지 않습니다.
- JSON 파싱 오류: 서버에서 내려온 JSON의 구조가 데이터 모델 클래스와 일치하지 않을 때 발생합니다. `try-catch`로 파싱 부분을 감싸고, 오류 발생 시 서버 응답 로그를 자세히 확인하여 모델 클래스를 수정하거나 서버 측에 데이터 형식 수정을 요청해야 합니다.
지금까지 플러터 앱의 핵심 기능인 HTTP 통신에 대해 기본 원리부터 실전 CRUD 구현, 그리고 고급 주제까지 폭넓게 살펴보았습니다. 안정적이고 사용자 친화적인 앱을 만들기 위해서는 네트워크 통신에 대한 깊은 이해와 견고한 예외 처리, 그리고 체계적인 상태 관리가 반드시 뒷받침되어야 합니다. 이 글이 여러분의 플러터 개발 여정에 든든한 디딤돌이 되기를 바랍니다.
0 개의 댓글:
Post a Comment