JSON 직렬화 — json_serializable과 freezed

API 응답 JSON을 Dart 객체로 변환하는 작업은 매일 합니다. 수동으로 fromJson/toJson을 작성하면 실수가 잦으니, 코드 생성 도구를 활용하는 것이 실무 표준입니다.


수동 직렬화의 문제

DART
// 수동으로 작성 — 필드가 많아지면 실수하기 쉬움
class User {
  final int id;
  final String name;
  final String email;

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

  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'] as int,         // 타입 캐스팅 실수 가능
      name: json['name'] as String,   // 키 오타 가능
      email: json['email'] as String, // nullable 처리 누락 가능
    );
  }

  Map<String, dynamic> toJson() => {
    'id': id,
    'name': name,
    'email': email,
  };
}

필드가 10개, 20개가 되면 수동 작성은 비현실적입니다.


json_serializable

설치

YAML
dependencies:
  json_annotation: ^4.9.0

dev_dependencies:
  json_serializable: ^6.8.0
  build_runner: ^2.4.0

모델 정의

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

// 생성될 파일 이름
part 'user.g.dart';

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

  // JSON 키 이름이 다를 때
  @JsonKey(name: 'created_at')
  final DateTime createdAt;

  // null 허용
  @JsonKey(name: 'profile_image')
  final String? profileImage;

  // 기본값 지정
  @JsonKey(defaultValue: false)
  final bool isActive;

  const User({
    required this.id,
    required this.name,
    required this.email,
    required this.createdAt,
    this.profileImage,
    this.isActive = false,
  });

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

코드 생성

BASH
# 1회 실행
dart run build_runner build

# 파일 변경 감지 (개발 중 권장)
dart run build_runner watch

# 충돌 시 삭제 후 재생성
dart run build_runner build --delete-conflicting-outputs

중첩 객체

DART
@JsonSerializable(explicitToJson: true)  // 중첩 객체도 toJson 적용
class Post {
  final int id;
  final String title;
  final User author;  // 중첩 객체
  final List<Comment> comments;  // 중첩 리스트

  const Post({
    required this.id,
    required this.title,
    required this.author,
    required this.comments,
  });

  factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
  Map<String, dynamic> toJson() => _$PostToJson(this);
}

freezed — 불변 모델 + JSON 직렬화

freezed는 불변 데이터 클래스에 copyWith, ==, hashCode, toString, JSON 직렬화를 자동 생성합니다.

설치

YAML
dependencies:
  freezed_annotation: ^2.4.0
  json_annotation: ^4.9.0

dev_dependencies:
  freezed: ^2.5.0
  json_serializable: ^6.8.0
  build_runner: ^2.4.0

모델 정의

DART
import 'package:freezed_annotation/freezed_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,
    @JsonKey(name: 'profile_image') String? profileImage,
    @Default(false) bool isActive,
  }) = _User;

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

freezed가 생성해주는 것들

DART
final user = User(
  id: 1,
  name: '심정훈',
  email: 'test@email.com',
  createdAt: DateTime.now(),
);

// copyWith (불변 객체 복사)
final updatedUser = user.copyWith(name: '정훈');

// == 비교 (값 기반)
print(user == user.copyWith());  // true

// toString
print(user);  // User(id: 1, name: 심정훈, ...)

// JSON 직렬화
final json = user.toJson();
final fromJson = User.fromJson(json);

freezed의 Union Types

API 응답이나 상태 관리에서 매우 유용합니다.

DART
@freezed
class Result<T> with _$Result<T> {
  const factory Result.success(T data) = Success<T>;
  const factory Result.failure(String message) = Failure<T>;
  const factory Result.loading() = Loading<T>;
}

// 패턴 매칭으로 사용
Widget buildResult(Result<User> result) {
  return result.when(
    success: (user) => Text(user.name),
    failure: (message) => Text('에러: $message'),
    loading: () => const CircularProgressIndicator(),
  );
}

// map도 가능 (타입 유지)
result.map(
  success: (success) => Text(success.data.name),
  failure: (failure) => Text(failure.message),
  loading: (_) => const CircularProgressIndicator(),
);

실전 패턴: API 응답 모델

DART
@freezed
class ApiResponse<T> with _$ApiResponse<T> {
  const factory ApiResponse({
    required int status,
    required String message,
    T? data,
  }) = _ApiResponse<T>;

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

@freezed
class PaginatedResponse<T> with _$PaginatedResponse<T> {
  const factory PaginatedResponse({
    required List<T> items,
    required int totalCount,
    required int page,
    required int pageSize,
  }) = _PaginatedResponse<T>;
}

json_serializable vs freezed 비교

기능json_serializablefreezed
fromJson/toJsonOO
copyWithXO
== / hashCodeXO
toStringXO
Union typesXO
보일러플레이트적음매우 적음
빌드 시간빠름느림

면접 포인트: "freezed를 왜 사용하나요?"에 대한 답은 **불변성 **, ** 값 비교 **, copyWith, Union 타입 지원입니다. 상태 관리에서 상태 객체를 불변으로 관리하는 것이 버그를 줄이는 핵심입니다.


정리

  • 수동 JSON 직렬화는 실수가 잦으니, 코드 생성 도구를 사용하세요
  • json_serializable은 JSON 변환만 필요할 때 가볍게 사용합니다
  • freezed는 불변 데이터 클래스 + JSON + Union 타입을 한 번에 지원합니다
  • @JsonKey로 JSON 키 이름 매핑, 기본값, null 처리를 할 수 있습니다
  • dart run build_runner watch로 코드를 자동 생성하면 편합니다
댓글 로딩 중...