learn-flutter/content/docs/part6/json-serialization.md
ChangJoo Park(박창주) 900b0ab68b update markdown
2025-05-14 09:34:13 +09:00

28 KiB

JSON 직렬화 (freezed, json_serializable)

REST API와 통신할 때 JSON 형식의 데이터를 주고받는 경우가 많습니다. Flutter/Dart에서는 이러한 JSON 데이터를 Dart 객체로 변환하고, 반대로 Dart 객체를 JSON으로 직렬화하는 과정이 필요합니다. 이 장에서는 json_serializablefreezed 패키지를 활용한 JSON 직렬화 방법을 살펴보겠습니다.

JSON 직렬화의 필요성

외부 API에서 데이터를 가져오면 보통 JSON 형태로 제공됩니다. 이를 그대로 사용하면 다음과 같은 문제가 발생합니다:

  1. 타입 안전성 부재: JSON은 동적 타입이므로 컴파일 시점에 오류를 발견하기 어렵습니다.
  2. 코드 자동 완성 불가: IDE에서 속성 이름을 자동 완성할 수 없습니다.
  3. 리팩토링 어려움: 속성 이름이 변경될 때 모든 참조를 업데이트하기 어렵습니다.
  4. 문서화 부족: 속성의 의미와 타입이 명확하게 정의되지 않습니다.

이러한 문제를 해결하기 위해 JSON을 Dart 클래스로 변환하는 과정이 필요합니다.

수동 JSON 직렬화

가장 기본적인 방법은 JSON 변환 코드를 직접 작성하는 것입니다:

class User {
  final int id;
  final String name;
  final String email;
  final DateTime createdAt;

  User({
    required this.id,
    required this.name,
    required this.email,
    required this.createdAt,
  });

  // JSON에서 User 객체로 변환
  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'] as int,
      name: json['name'] as String,
      email: json['email'] as String,
      createdAt: DateTime.parse(json['created_at'] as String),
    );
  }

  // User 객체에서 JSON으로 변환
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'email': email,
      'created_at': createdAt.toIso8601String(),
    };
  }
}

이 방법은 간단한 모델에는 잘 작동하지만, 다음과 같은 단점이 있습니다:

  1. 반복적인 코드: 비슷한 코드를 여러 모델마다 작성해야 합니다.
  2. 오류 가능성: 수동으로 작성하다 보면 타입 캐스팅이나 누락된 필드 등의 오류가 발생할 수 있습니다.
  3. 유지보수 어려움: 모델이 변경될 때마다 JSON 변환 코드도 수정해야 합니다.

json_serializable 사용하기

json_serializable 패키지는 코드 생성을 통해 JSON 직렬화 코드를 자동으로 생성해 줍니다.

1. 패키지 설치

pubspec.yaml 파일에 필요한 패키지를 추가합니다:

dependencies:
  json_annotation: ^4.8.1

dev_dependencies:
  build_runner: ^2.4.6
  json_serializable: ^6.7.1

2. 모델 클래스 정의

import 'package:json_annotation/json_annotation.dart';

// 생성될 코드의 파일명을 지정합니다
part 'user.g.dart';

// 클래스에 어노테이션을 추가합니다
@JsonSerializable()
class User {
  final int id;
  final String name;
  final String email;

  // JSON 필드명이 Dart 속성명과 다른 경우 매핑
  @JsonKey(name: 'created_at')
  final DateTime createdAt;

  User({
    required this.id,
    required this.name,
    required this.email,
    required this.createdAt,
  });

  // 팩토리 메서드는 생성된 코드를 호출합니다
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);

  // toJson 메서드도 생성된 코드를 호출합니다
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

3. 코드 생성 실행

다음 명령어로 코드를 생성합니다:

flutter pub run build_runner build

변경 사항을 감시하면서 지속적으로 코드를 생성하려면:

flutter pub run build_runner watch

기존 생성 파일과 충돌이 있을 경우:

flutter pub run build_runner build --delete-conflicting-outputs

4. json_serializable 고급 기능

커스텀 변환기 사용

@JsonSerializable()
class Product {
  final int id;
  final String name;

  // 커스텀 변환기 사용
  @JsonKey(
    fromJson: _colorFromJson,
    toJson: _colorToJson,
  )
  final Color color;

  Product({
    required this.id,
    required this.name,
    required this.color,
  });

  factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);
  Map<String, dynamic> toJson() => _$ProductToJson(this);

  // 커스텀 변환 함수
  static Color _colorFromJson(String hex) => Color(int.parse(hex.substring(1), radix: 16) + 0xFF000000);
  static String _colorToJson(Color color) => '#${color.value.toRadixString(16).substring(2)}';
}

중첩 객체 처리

@JsonSerializable(explicitToJson: true)
class Order {
  final int id;
  final DateTime orderDate;
  final User customer; // 중첩 객체
  final List<OrderItem> items; // 중첩 객체 리스트

  Order({
    required this.id,
    required this.orderDate,
    required this.customer,
    required this.items,
  });

  factory Order.fromJson(Map<String, dynamic> json) => _$OrderFromJson(json);
  Map<String, dynamic> toJson() => _$OrderToJson(this);
}

@JsonSerializable()
class OrderItem {
  final int id;
  final String productName;
  final int quantity;
  final double price;

  OrderItem({
    required this.id,
    required this.productName,
    required this.quantity,
    required this.price,
  });

  factory OrderItem.fromJson(Map<String, dynamic> json) => _$OrderItemFromJson(json);
  Map<String, dynamic> toJson() => _$OrderItemToJson(this);
}

필드 포함/제외 설정

@JsonSerializable()
class User {
  final int id;
  final String name;
  final String email;

  // API에서는 받지만 JSON으로 변환할 때는 제외
  @JsonKey(includeToJson: false)
  final String? authToken;

  // JSON으로 변환할 때만 포함
  @JsonKey(includeFromJson: false)
  final bool isLoggedIn;

  User({
    required this.id,
    required this.name,
    required this.email,
    this.authToken,
    this.isLoggedIn = false,
  });

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

기본값 설정

@JsonSerializable()
class Settings {
  // 기본값 설정
  @JsonKey(defaultValue: 'light')
  final String theme;

  @JsonKey(defaultValue: true)
  final bool notifications;

  Settings({
    required this.theme,
    required this.notifications,
  });

  factory Settings.fromJson(Map<String, dynamic> json) => _$SettingsFromJson(json);
  Map<String, dynamic> toJson() => _$SettingsToJson(this);
}

null 안전성 처리

@JsonSerializable(includeIfNull: false) // null 값은 JSON에 포함하지 않음
class Profile {
  final int id;
  final String name;

  // null일 수 있는 필드
  final String? bio;
  final String? avatarUrl;

  // null일 경우 기본값 설정
  @JsonKey(defaultValue: 0)
  final int followersCount;

  Profile({
    required this.id,
    required this.name,
    this.bio,
    this.avatarUrl,
    required this.followersCount,
  });

  factory Profile.fromJson(Map<String, dynamic> json) => _$ProfileFromJson(json);
  Map<String, dynamic> toJson() => _$ProfileToJson(this);
}

freezed 패키지 소개

freezed는 불변(immutable) 모델 클래스를 위한 코드 생성 패키지로, 다음과 같은 기능을 제공합니다:

  1. 불변성: 객체가 생성된 후 변경할 수 없도록 합니다.
  2. copyWith: 기존 객체를 기반으로 일부 속성만 변경한 새 객체를 쉽게 생성합니다.
  3. 동등성 비교: == 연산자와 hashCode를 자동으로 구현합니다.
  4. 직렬화: json_serializable과 통합되어 JSON 직렬화를 지원합니다.
  5. 패턴 매칭: 다양한 타입의 데이터를 안전하게 처리할 수 있습니다.
  6. 공용체(union types): 여러 타입을 하나의 타입으로 그룹화할 수 있습니다.

1. 패키지 설치

pubspec.yaml에 다음 패키지를 추가합니다:

dependencies:
  freezed_annotation: ^2.4.1
  json_annotation: ^4.8.1

dev_dependencies:
  build_runner: ^2.4.6
  freezed: ^2.4.5
  json_serializable: ^6.7.1

2. freezed 기본 모델 정의

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';

// 생성될 코드 파일명 지정
part 'user.freezed.dart';
part 'user.g.dart';

@freezed
class User with _$User {
  const factory User({
    required int id,
    required String name,
    required String email,
    @JsonKey(name: 'created_at') required DateTime createdAt,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

3. 코드 생성 실행

flutter pub run build_runner build --delete-conflicting-outputs

4. freezed 활용

copyWith 사용

final user = User(
  id: 1,
  name: '홍길동',
  email: 'hong@example.com',
  createdAt: DateTime.now(),
);

// 이름만 변경한 새 객체 생성
final updatedUser = user.copyWith(name: '김철수');

print(user.name);      // '홍길동'
print(updatedUser.name); // '김철수'

동등성 비교

final user1 = User(
  id: 1,
  name: '홍길동',
  email: 'hong@example.com',
  createdAt: DateTime.now(),
);

// 같은 값을 가진 새 객체
final user2 = User(
  id: 1,
  name: '홍길동',
  email: 'hong@example.com',
  createdAt: user1.createdAt,
);

print(user1 == user2); // true

5. Union Types (대수적 데이터 타입)

freezed의 강력한 기능 중 하나는 Union Types입니다. 이를 통해 여러 가능한 상태나 변형을 한 클래스로 표현할 수 있습니다.

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';

part 'api_result.freezed.dart';
part 'api_result.g.dart';

@freezed
class ApiResult<T> with _$ApiResult<T> {
  // 성공 상태
  const factory ApiResult.success({
    required T data,
  }) = Success<T>;

  // 에러 상태
  const factory ApiResult.error({
    required String message,
    @Default(400) int statusCode,
  }) = Error<T>;

  // 로딩 상태
  const factory ApiResult.loading() = Loading<T>;

  factory ApiResult.fromJson(Map<String, dynamic> json) =>
      _$ApiResultFromJson(json);
}

Union Types 사용 예

Future<ApiResult<User>> fetchUser(int id) async {
  try {
    // 로딩 상태 반환
    ApiResult<User> loadingResult = ApiResult.loading();

    // API 호출
    final response = await dio.get('/users/$id');

    // 성공 상태 반환
    return ApiResult.success(data: User.fromJson(response.data));
  } catch (e) {
    // 에러 상태 반환
    return ApiResult.error(message: '사용자 정보를 가져오는 데 실패했습니다');
  }
}

// 위젯에서 사용
Widget build(BuildContext context) {
  return FutureBuilder<ApiResult<User>>(
    future: fetchUser(1),
    builder: (context, snapshot) {
      if (!snapshot.hasData) {
        return CircularProgressIndicator();
      }

      // when 메서드로 패턴 매칭
      return snapshot.data!.when(
        success: (user) => UserInfoWidget(user: user),
        error: (message, statusCode) => ErrorWidget(message: message),
        loading: () => LoadingWidget(),
      );
    },
  );
}

6. freezed의 고급 기능

Default Values

@freezed
class Settings with _$Settings {
  const factory Settings({
    @Default('light') String theme,
    @Default(true) bool notifications,
    @Default([]) List<String> favorites,
  }) = _Settings;

  factory Settings.fromJson(Map<String, dynamic> json) => _$SettingsFromJson(json);
}

커스텀 직렬화

@freezed
class Product with _$Product {
  const factory Product({
    required int id,
    required String name,
    @JsonKey(
      fromJson: _colorFromJson,
      toJson: _colorToJson,
    )
    required Color color,
  }) = _Product;

  factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);

  // 정적 메서드는 클래스 바디에 정의
  static Color _colorFromJson(String hex) => Color(int.parse(hex.substring(1), radix: 16) + 0xFF000000);
  static String _colorToJson(Color color) => '#${color.value.toRadixString(16).substring(2)}';
}

중첩 freezed 모델

@freezed
class Order with _$Order {
  const factory Order({
    required int id,
    required DateTime orderDate,
    required User customer,
    required List<OrderItem> items,
  }) = _Order;

  factory Order.fromJson(Map<String, dynamic> json) => _$OrderFromJson(json);
}

@freezed
class OrderItem with _$OrderItem {
  const factory OrderItem({
    required int id,
    required String productName,
    required int quantity,
    required double price,
  }) = _OrderItem;

  factory OrderItem.fromJson(Map<String, dynamic> json) => _$OrderItemFromJson(json);
}

생성자 매개변수 검증

@freezed
class User with _$User {
  // 프라이빗 생성자로 검증 로직 추가
  const factory User({
    required int id,
    required String name,
    required String email,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);

  // named 생성자를 통해 팩토리 패턴 구현
  factory User.create({
    required int id,
    required String name,
    required String email,
  }) {
    // 이메일 유효성 검사
    if (!_isValidEmail(email)) {
      throw ArgumentError('Invalid email format');
    }

    return User(id: id, name: name, email: email);
  }

  // 프라이빗 헬퍼 메서드
  static bool _isValidEmail(String email) {
    return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email);
  }
}

freezed와 json_serializable의 통합 활용

실제 앱에서는 freezed와 json_serializable을 함께 사용하여 강력한 모델 클래스를 만들 수 있습니다. 다음은 일반적인 사용 패턴입니다:

1. API 응답 모델 정의

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';

part 'api_response.freezed.dart';
part 'api_response.g.dart';

@Freezed(genericArgumentFactories: true)
class ApiResponse<T> with _$ApiResponse<T> {
  const factory ApiResponse({
    required bool success,
    String? message,
    T? data,
    @Default([]) List<String> errors,
  }) = _ApiResponse<T>;

  factory ApiResponse.fromJson(
    Map<String, dynamic> json,
    T Function(Object? json) fromJsonT,
  ) =>
      _$ApiResponseFromJson(json, fromJsonT);
}

2. 도메인별 모델 정의

@freezed
class User with _$User {
  const factory User({
    required int id,
    required String name,
    required String email,
    @JsonKey(name: 'created_at') required DateTime createdAt,
    @Default([]) List<String> roles,
    String? avatarUrl,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

@freezed
class Product with _$Product {
  const factory Product({
    required int id,
    required String name,
    required double price,
    @Default(0) int stock,
    String? description,
    @Default([]) List<String> categories,
    @Default(false) bool isFeatured,
  }) = _Product;

  factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);
}

3. API 서비스에서 활용

class ApiService {
  final Dio _dio;

  ApiService(this._dio);

  Future<ApiResponse<List<User>>> getUsers() async {
    try {
      final response = await _dio.get('/users');
      return ApiResponse.fromJson(
        response.data,
        (json) => (json as List)
            .map((item) => User.fromJson(item as Map<String, dynamic>))
            .toList(),
      );
    } catch (e) {
      return ApiResponse(
        success: false,
        message: '사용자 목록을 가져오는데 실패했습니다',
        errors: [e.toString()],
      );
    }
  }

  Future<ApiResponse<User>> getUserById(int id) async {
    try {
      final response = await _dio.get('/users/$id');
      return ApiResponse.fromJson(
        response.data,
        (json) => User.fromJson(json as Map<String, dynamic>),
      );
    } catch (e) {
      return ApiResponse(
        success: false,
        message: '사용자 정보를 가져오는데 실패했습니다',
        errors: [e.toString()],
      );
    }
  }
}

4. 뷰 모델에서 활용

class UserViewModel extends ChangeNotifier {
  final ApiService _apiService;

  UserViewModel(this._apiService);

  ApiResult<List<User>> _users = ApiResult.loading();
  ApiResult<List<User>> get users => _users;

  Future<void> fetchUsers() async {
    _users = ApiResult.loading();
    notifyListeners();

    try {
      final response = await _apiService.getUsers();

      if (response.success && response.data != null) {
        _users = ApiResult.success(data: response.data!);
      } else {
        _users = ApiResult.error(
          message: response.message ?? '알 수 없는 오류가 발생했습니다',
        );
      }
    } catch (e) {
      _users = ApiResult.error(message: e.toString());
    }

    notifyListeners();
  }
}

5. UI에서 활용

class UsersScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final viewModel = Provider.of<UserViewModel>(context);

    return Scaffold(
      appBar: AppBar(title: Text('사용자 목록')),
      body: viewModel.users.when(
        loading: () => Center(child: CircularProgressIndicator()),
        success: (users) => ListView.builder(
          itemCount: users.length,
          itemBuilder: (context, index) {
            final user = users[index];
            return ListTile(
              title: Text(user.name),
              subtitle: Text(user.email),
              trailing: Icon(Icons.arrow_forward_ios),
              onTap: () => Navigator.pushNamed(
                context,
                '/user-details',
                arguments: user.id,
              ),
            );
          },
        ),
        error: (message, _) => Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('오류: $message', style: TextStyle(color: Colors.red)),
              SizedBox(height: 16),
              ElevatedButton(
                onPressed: () => viewModel.fetchUsers(),
                child: Text('다시 시도'),
              ),
            ],
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => viewModel.fetchUsers(),
        child: Icon(Icons.refresh),
      ),
    );
  }
}

실제 예제: Todo 앱 모델

Todo 앱을 위한 데이터 모델을 freezedjson_serializable을 사용하여 구현해 보겠습니다:

// todo.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';

part 'todo.freezed.dart';
part 'todo.g.dart';

@freezed
class Todo with _$Todo {
  const factory Todo({
    required String id,
    required String title,
    @Default('') String description,
    @Default(false) bool completed,
    @JsonKey(name: 'created_at') required DateTime createdAt,
    @JsonKey(name: 'updated_at') DateTime? updatedAt,
    @Default('normal') String priority,
    @Default([]) List<String> tags,
  }) = _Todo;

  factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);

  // 추가 팩토리 메서드
  factory Todo.create({
    required String title,
    String description = '',
    String priority = 'normal',
    List<String> tags = const [],
  }) {
    return Todo(
      id: DateTime.now().millisecondsSinceEpoch.toString(),
      title: title,
      description: description,
      createdAt: DateTime.now(),
      priority: priority,
      tags: tags,
    );
  }
}

// todo_status.dart
@freezed
class TodoStatus with _$TodoStatus {
  const factory TodoStatus.initial() = _Initial;
  const factory TodoStatus.loading() = _Loading;
  const factory TodoStatus.loaded({required List<Todo> todos}) = _Loaded;
  const factory TodoStatus.error({required String message}) = _Error;
}

// todo_repository.dart
class TodoRepository {
  final Dio _dio;

  TodoRepository(this._dio);

  Future<List<Todo>> getTodos() async {
    try {
      final response = await _dio.get('/todos');
      return (response.data as List)
          .map((json) => Todo.fromJson(json as Map<String, dynamic>))
          .toList();
    } catch (e) {
      throw Exception('할 일 목록을 가져오는데 실패했습니다: $e');
    }
  }

  Future<Todo> createTodo(Todo todo) async {
    try {
      final response = await _dio.post(
        '/todos',
        data: todo.toJson(),
      );
      return Todo.fromJson(response.data as Map<String, dynamic>);
    } catch (e) {
      throw Exception('할 일을 생성하는데 실패했습니다: $e');
    }
  }

  Future<Todo> updateTodo(Todo todo) async {
    try {
      final response = await _dio.put(
        '/todos/${todo.id}',
        data: todo.toJson(),
      );
      return Todo.fromJson(response.data as Map<String, dynamic>);
    } catch (e) {
      throw Exception('할 일을 업데이트하는데 실패했습니다: $e');
    }
  }

  Future<void> deleteTodo(String id) async {
    try {
      await _dio.delete('/todos/$id');
    } catch (e) {
      throw Exception('할 일을 삭제하는데 실패했습니다: $e');
    }
  }
}

// todo_view_model.dart
class TodoViewModel extends ChangeNotifier {
  final TodoRepository _repository;

  TodoViewModel(this._repository);

  TodoStatus _status = TodoStatus.initial();
  TodoStatus get status => _status;

  Future<void> fetchTodos() async {
    _status = TodoStatus.loading();
    notifyListeners();

    try {
      final todos = await _repository.getTodos();
      _status = TodoStatus.loaded(todos: todos);
    } catch (e) {
      _status = TodoStatus.error(message: e.toString());
    }

    notifyListeners();
  }

  Future<void> createTodo(String title, String description) async {
    try {
      final newTodo = Todo.create(
        title: title,
        description: description,
      );

      await _repository.createTodo(newTodo);
      await fetchTodos(); // 목록 새로고침
    } catch (e) {
      _status = TodoStatus.error(message: '할 일을 생성하는데 실패했습니다: ${e.toString()}');
      notifyListeners();
    }
  }

  Future<void> toggleTodoCompleted(Todo todo) async {
    try {
      final updatedTodo = todo.copyWith(
        completed: !todo.completed,
        updatedAt: DateTime.now(),
      );

      await _repository.updateTodo(updatedTodo);
      await fetchTodos(); // 목록 새로고침
    } catch (e) {
      _status = TodoStatus.error(message: '할 일 상태를 변경하는데 실패했습니다: ${e.toString()}');
      notifyListeners();
    }
  }

  Future<void> deleteTodo(String id) async {
    try {
      await _repository.deleteTodo(id);
      await fetchTodos(); // 목록 새로고침
    } catch (e) {
      _status = TodoStatus.error(message: '할 일을 삭제하는데 실패했습니다: ${e.toString()}');
      notifyListeners();
    }
  }
}

// todo_screen.dart
class TodoScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final viewModel = Provider.of<TodoViewModel>(context);

    return Scaffold(
      appBar: AppBar(title: Text('할 일 목록')),
      body: viewModel.status.when(
        initial: () {
          // 초기 데이터 로드
          WidgetsBinding.instance.addPostFrameCallback((_) {
            viewModel.fetchTodos();
          });
          return Center(child: Text('데이터를 로드합니다...'));
        },
        loading: () => Center(child: CircularProgressIndicator()),
        loaded: (todos) => todos.isEmpty
            ? Center(child: Text('할 일이 없습니다.'))
            : ListView.builder(
                itemCount: todos.length,
                itemBuilder: (context, index) {
                  final todo = todos[index];
                  return TodoItem(
                    todo: todo,
                    onToggle: () => viewModel.toggleTodoCompleted(todo),
                    onDelete: () => viewModel.deleteTodo(todo.id),
                  );
                },
              ),
        error: (message) => Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('오류: $message', style: TextStyle(color: Colors.red)),
              SizedBox(height: 16),
              ElevatedButton(
                onPressed: () => viewModel.fetchTodos(),
                child: Text('다시 시도'),
              ),
            ],
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddTodoDialog(context, viewModel),
        child: Icon(Icons.add),
      ),
    );
  }

  void _showAddTodoDialog(BuildContext context, TodoViewModel viewModel) {
    final titleController = TextEditingController();
    final descriptionController = TextEditingController();

    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('새 할 일 추가'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            TextField(
              controller: titleController,
              decoration: InputDecoration(labelText: '제목'),
            ),
            TextField(
              controller: descriptionController,
              decoration: InputDecoration(labelText: '설명'),
              maxLines: 3,
            ),
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text('취소'),
          ),
          ElevatedButton(
            onPressed: () {
              final title = titleController.text.trim();
              final description = descriptionController.text.trim();

              if (title.isNotEmpty) {
                viewModel.createTodo(title, description);
                Navigator.pop(context);
              }
            },
            child: Text('추가'),
          ),
        ],
      ),
    );
  }
}

// todo_item.dart
class TodoItem extends StatelessWidget {
  final Todo todo;
  final VoidCallback onToggle;
  final VoidCallback onDelete;

  const TodoItem({
    Key? key,
    required this.todo,
    required this.onToggle,
    required this.onDelete,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Dismissible(
      key: Key(todo.id),
      background: Container(
        color: Colors.red,
        alignment: Alignment.centerRight,
        padding: EdgeInsets.symmetric(horizontal: 16),
        child: Icon(Icons.delete, color: Colors.white),
      ),
      direction: DismissDirection.endToStart,
      onDismissed: (_) => onDelete(),
      child: ListTile(
        title: Text(
          todo.title,
          style: TextStyle(
            decoration: todo.completed ? TextDecoration.lineThrough : null,
            color: todo.completed ? Colors.grey : null,
          ),
        ),
        subtitle: todo.description.isNotEmpty ? Text(todo.description) : null,
        leading: Checkbox(
          value: todo.completed,
          onChanged: (_) => onToggle(),
        ),
        trailing: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            if (todo.priority == 'high')
              Icon(Icons.priority_high, color: Colors.red),
            Text(
              DateFormat.yMMMd().format(todo.createdAt),
              style: TextStyle(fontSize: 12),
            ),
          ],
        ),
      ),
    );
  }
}

요약

  • JSON 직렬화는 외부 API와 통신하는 Flutter 앱에서 필수적인 기능입니다.
  • 수동 직렬화는 간단한 모델에는 적합하지만, 모델이 복잡해질수록 코드 중복과 오류 가능성이 증가합니다.
  • json_serializable 패키지는 코드 생성을 통해 JSON 직렬화 코드를 자동으로 생성해 줍니다.
  • freezed 패키지는 불변성, 복사본 생성, 동등성 비교, JSON 직렬화, 유니온 타입 등 강력한 기능을 제공합니다.
  • freezed와 json_serializable의 통합을 통해 타입 안전하고 유지보수하기 쉬운 모델 클래스를 만들 수 있습니다.

이 장에서 배운 기술을 활용하면 API 통신 코드의 안정성과 유지보수성을 크게 향상시킬 수 있습니다. 특히 복잡한 데이터 구조를 다루는 대규모 앱에서는 이러한 코드 생성 도구가 필수적입니다.