Ch 04. HTTP 통신 — Dart 백엔드와 연결
앱의 UI는 준비되었고, 상태 관리 구조도 잡혔습니다. 이제 실제 데이터를 가져올 차례입니다. PART 06에서 만든 todo_server에 HTTP 요청을 보내고 응답을 받아 화면에 표시합니다.
ApiService 클래스 설계
모든 HTTP 통신 로직을 ApiService 한 곳에 모읍니다. 화면이 직접 http 패키지를 호출하지 않게 합니다. 이렇게 하면 서버 URL이 바뀌거나 HTTP 클라이언트를 교체할 때 ApiService만 수정하면 됩니다.
// 새 파일: lib/services/api_service.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/todo.dart';
class ApiException implements Exception {
final int statusCode;
final String message;
const ApiException(this.statusCode, this.message);
@override
String toString() => 'ApiException($statusCode): $message';
}
class ApiService {
final String baseUrl;
String? _token;
ApiService({this.baseUrl = 'http://localhost:8080'});
void setToken(String token) => _token = token;
void clearToken() => _token = null;
Map<String, String> get _headers {
final headers = <String, String>{
'Content-Type': 'application/json',
};
if (_token != null) {
headers['Authorization'] = 'Bearer $_token';
}
return headers;
}
Future<Map<String, dynamic>> _handleResponse(http.Response response) async {
final body = jsonDecode(response.body) as Map<String, dynamic>;
if (response.statusCode >= 200 && response.statusCode < 300) {
return body;
}
final message = body['message'] as String? ?? '알 수 없는 오류입니다.';
throw ApiException(response.statusCode, message);
}
// 로그인
Future<String> login(String username, String password) async {
final response = await http.post(
Uri.parse('$baseUrl/auth/login'),
headers: _headers,
body: jsonEncode({'email': username, 'password': password}),
);
final body = await _handleResponse(response);
final data = body['data'] as Map<String, dynamic>;
final token = data['token'] as String;
setToken(token);
return token;
}
// 할 일 목록 조회
Future<List<Todo>> getTodos() async {
final response = await http.get(
Uri.parse('$baseUrl/todos'),
headers: _headers,
);
final body = await _handleResponse(response);
final data = body['data'] as List<dynamic>;
return data
.map((e) => Todo.fromJson(e as Map<String, dynamic>))
.toList();
}
// 할 일 생성
Future<Todo> createTodo(String title) async {
final response = await http.post(
Uri.parse('$baseUrl/todos'),
headers: _headers,
body: jsonEncode({'title': title}),
);
final body = await _handleResponse(response);
return Todo.fromJson(body);
}
// 할 일 수정 (완료 토글)
Future<Todo> updateTodo(int id, {bool? completed, String? title}) async {
final payload = <String, dynamic>{};
if (completed != null) payload['completed'] = completed;
if (title != null) payload['title'] = title;
final response = await http.put(
Uri.parse('$baseUrl/todos/$id'),
headers: _headers,
body: jsonEncode(payload),
);
final body = await _handleResponse(response);
return Todo.fromJson(body);
}
// 할 일 삭제
Future<void> deleteTodo(int id) async {
final response = await http.delete(
Uri.parse('$baseUrl/todos/$id'),
headers: _headers,
);
if (response.statusCode != 204) {
final body = jsonDecode(response.body) as Map<String, dynamic>;
final message = body['message'] as String? ?? '삭제 실패';
throw ApiException(response.statusCode, message);
}
}
}
ApiException 커스텀 예외 클래스를 만들어서 HTTP 오류를 Dart 예외로 변환합니다. 화면에서 try-catch로 잡으면 사용자에게 적절한 메시지를 보여줄 수 있습니다.
TodoProvider에 ApiService 연결
// 수정: lib/providers/todo_provider.dart
import 'package:flutter/foundation.dart';
import '../models/todo.dart';
import '../services/api_service.dart';
enum LoadingState { idle, loading, success, error }
class TodoProvider extends ChangeNotifier {
final ApiService _api;
TodoProvider({ApiService? api})
: _api = api ?? ApiService();
List<Todo> _todos = [];
LoadingState _state = LoadingState.idle;
String? _errorMessage;
String? _token;
List<Todo> get todos => List.unmodifiable(_todos);
LoadingState get state => _state;
String? get errorMessage => _errorMessage;
bool get isLoggedIn => _token != null;
void _setLoading() {
_state = LoadingState.loading;
_errorMessage = null;
notifyListeners();
}
void _setError(String message) {
_state = LoadingState.error;
_errorMessage = message;
notifyListeners();
}
Future<bool> login(String username, String password) async {
_setLoading();
try {
final token = await _api.login(username, password);
_token = token;
_state = LoadingState.idle;
notifyListeners();
return true;
} on ApiException catch (e) {
_setError(e.message);
return false;
} catch (e) {
_setError('서버에 연결할 수 없습니다.');
return false;
}
}
Future<void> loadTodos() async {
_setLoading();
try {
final todos = await _api.getTodos();
_todos = todos;
_state = LoadingState.success;
notifyListeners();
} on ApiException catch (e) {
_setError(e.message);
} catch (e) {
_setError('할 일 목록을 불러오지 못했습니다.');
}
}
Future<void> createTodo(String title) async {
try {
final todo = await _api.createTodo(title);
_todos = [..._todos, todo];
notifyListeners();
} on ApiException catch (e) {
_setError(e.message);
}
}
Future<void> toggleTodo(Todo todo) async {
final updated = await _api.updateTodo(
todo.id,
completed: !todo.completed,
);
_todos = _todos.map((t) => t.id == updated.id ? updated : t).toList();
notifyListeners();
}
Future<void> deleteTodo(int id) async {
try {
await _api.deleteTodo(id);
_todos = _todos.where((t) => t.id != id).toList();
notifyListeners();
} on ApiException catch (e) {
_setError(e.message);
}
}
void logout() {
_api.clearToken();
_token = null;
_todos = [];
_state = LoadingState.idle;
notifyListeners();
}
}
생성자에서 ApiService를 주입받도록 설계했습니다. 나중에 테스트할 때 가짜(Mock) ApiService를 주입할 수 있습니다.
JSON 파싱 처리
서버가 반환하는 JSON 형식과 Todo.fromJson이 맞지 않으면 런타임 오류가 발생합니다. dart:developer의 log로 응답 내용을 확인하는 방법을 알아 둡니다.
import 'dart:developer' as dev;
// 개발 중에만 사용 (배포 빌드에서는 출력 안 됨)
dev.log('서버 응답: ${response.body}', name: 'ApiService');
실제 서버 응답이 다음 형식이라고 가정합니다.
{ "data": [ { "id": 1, "title": "Flutter 공부하기", "completed": false, "createdAt": "2024-01-15T09:00:00.000Z" } ]}
Todo.fromJson에서 json['createdAt']을 DateTime.parse로 파싱할 때 서버 시간대가 UTC인지 확인합니다. 서버가 로컬 시간으로 반환한다면 DateTime.parse 결과를 .toLocal()로 변환합니다.
에러 처리 패턴
네트워크 오류의 종류를 구분해서 사용자에게 더 나은 메시지를 제공합니다.
Future<void> loadTodos() async {
_setLoading();
try {
final todos = await _api.getTodos();
_todos = todos;
_state = LoadingState.success;
notifyListeners();
} on ApiException catch (e) {
if (e.statusCode == 401) {
// 인증 만료
logout();
_setError('로그인이 만료되었습니다. 다시 로그인하세요.');
} else {
_setError(e.message);
}
} catch (e) {
_setError('인터넷 연결을 확인해주세요.');
}
}
401 응답은 토큰 만료를 의미합니다. 자동으로 로그아웃 처리하고 로그인 화면으로 이동하는 것이 좋은 UX입니다.
CORS 설정 (웹에서 실행할 때)
Flutter 웹 앱을 크롬에서 실행하고 로컬 서버에 요청하면 CORS 오류가 발생합니다. todo_server에 CORS 미들웨어를 추가해야 합니다.
// 수정: todo_server/lib/middleware/cors_middleware.dart
import 'package:dart_frog/dart_frog.dart';
Middleware corsMiddleware() {
return (handler) {
return (context) async {
final response = await handler(context);
return response.copyWith(
headers: {
...response.headers,
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
);
};
};
}
실제 운영 환경에서는 * 대신 Flutter 앱의 도메인만 허용합니다.
이번 챕터 정리
ApiService클래스로 모든 HTTP 통신을 캡슐화했습니다.ApiException으로 HTTP 오류를 Dart 예외로 변환했습니다.TodoProvider에ApiService를 주입하여 비즈니스 로직과 HTTP 통신을 연결했습니다.- 401 인증 만료 처리, CORS 설정 등 실전에서 필요한 처리를 다뤘습니다.
다음 챕터에서는 모든 화면을 완성하여 동작하는 앱을 만들겠습니다.