iBetter Books
수정

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:developerlog로 응답 내용을 확인하는 방법을 알아 둡니다.

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 예외로 변환했습니다.
  • TodoProviderApiService를 주입하여 비즈니스 로직과 HTTP 통신을 연결했습니다.
  • 401 인증 만료 처리, CORS 설정 등 실전에서 필요한 처리를 다뤘습니다.

다음 챕터에서는 모든 화면을 완성하여 동작하는 앱을 만들겠습니다.