JSON을 Dart 모델 클래스로 변환하는 것은 Flutter 개발에서 가장 일반적인 작업 중 하나입니다. REST API에서 데이터를 가져오든 로컬 설정을 읽든, JSON 데이터를 안전하게 파싱하고 사용하려면 잘 구조화된 Dart 클래스가 필요합니다.
모델 클래스가 필요한 이유
- 타입 안전성 — 런타임이 아닌 컴파일 타임에 오류 포착
- IDE 자동완성 및 리팩토링 지원
- 프론트엔드와 백엔드 간의 명확한 데이터 계약
- 쉬운 테스트 및 유지보수
기본 JSON-Dart 타입 매핑
| JSON 타입 | Dart 타입 | 예시 |
|---|---|---|
| string | String | "hello" → "hello" |
| number (int) | int | 42 → 42 |
| number (float) | double | 3.14 → 3.14 |
| boolean | bool | true → true |
| null | Null / dynamic | null → null |
| array | List<T> | [1,2,3] → [1,2,3] |
| object | Map / Class | {} → User() |
수동 모델 클래스 (fromJson / toJson)
가장 직접적인 방법은 모델 클래스를 수동으로 작성하는 것입니다:
// JSON input
{
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"isActive": true,
"score": 95.5
}
// Dart model class
class User {
final int id;
final String name;
final String email;
final bool isActive;
final double score;
User({
required this.id,
required this.name,
required this.email,
required this.isActive,
required this.score,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as int,
name: json['name'] as String,
email: json['email'] as String,
isActive: json['isActive'] as bool,
score: (json['score'] as num).toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'isActive': isActive,
'score': score,
};
}
}중첩 객체 처리
JSON에 중첩 객체가 포함된 경우 각 레벨에 별도의 Dart 클래스를 만듭니다:
// JSON with nested object
{
"id": 1,
"name": "John",
"address": {
"street": "123 Main St",
"city": "Springfield",
"zipCode": "62701"
}
}
// Dart classes
class Address {
final String street;
final String city;
final String zipCode;
Address({required this.street, required this.city, required this.zipCode});
factory Address.fromJson(Map<String, dynamic> json) {
return Address(
street: json['street'] as String,
city: json['city'] as String,
zipCode: json['zipCode'] as String,
);
}
Map<String, dynamic> toJson() => {
'street': street, 'city': city, 'zipCode': zipCode,
};
}
class User {
final int id;
final String name;
final Address address;
User({required this.id, required this.name, required this.address});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as int,
name: json['name'] as String,
address: Address.fromJson(json['address'] as Map<String, dynamic>),
);
}
Map<String, dynamic> toJson() => {
'id': id, 'name': name, 'address': address.toJson(),
};
}리스트와 배열 처리
JSON 배열은 Dart List 타입에 매핑됩니다:
// JSON with arrays
{
"name": "John",
"hobbies": ["coding", "reading", "gaming"],
"orders": [
{"id": 1, "product": "Laptop", "price": 999.99},
{"id": 2, "product": "Mouse", "price": 29.99}
]
}
// Dart
class User {
final String name;
final List<String> hobbies;
final List<Order> orders;
User({required this.name, required this.hobbies, required this.orders});
factory User.fromJson(Map<String, dynamic> json) {
return User(
name: json['name'] as String,
hobbies: List<String>.from(json['hobbies'] as List),
orders: (json['orders'] as List)
.map((e) => Order.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
}Null Safety 모범 사례
Dart의 sound null safety에서는 nullable 필드를 올바르게 처리해야 합니다:
- 항상 존재해야 하는 필드에는 `required` 키워드 사용
- nullable 타입에는 `?` 접미사 사용 (예: `String?`)
- fromJson에서 `??` 연산자로 기본값 제공
- 값이 초기화될 것이 확실한 경우에만 `late` 키워드 사용
class User {
final int id;
final String name;
final String? bio; // nullable
final String avatarUrl; // with default
User({
required this.id,
required this.name,
this.bio,
this.avatarUrl = 'https://example.com/default-avatar.png',
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as int,
name: json['name'] as String,
bio: json['bio'] as String?,
avatarUrl: json['avatarUrl'] as String? ?? 'https://example.com/default-avatar.png',
);
}
}json_serializable 패키지 사용
대규모 프로젝트에서는 json_serializable 패키지가 보일러플레이트를 자동화합니다:
단계 1: 의존성 추가
# pubspec.yaml
dependencies:
json_annotation: ^4.8.1
dev_dependencies:
json_serializable: ^6.7.1
build_runner: ^2.4.6단계 2: 어노테이션이 있는 모델 생성
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart';
@JsonSerializable()
class User {
final int id;
final String name;
final String email;
@JsonKey(name: 'is_active')
final bool isActive;
@JsonKey(defaultValue: 0.0)
final double score;
User({
required this.id,
required this.name,
required this.email,
required this.isActive,
required this.score,
});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}단계 3: Build Runner 실행
dart run build_runner build --delete-conflicting-outputsFreezed로 불변 모델 만들기
freezed 패키지는 copyWith, 동등성, JSON 직렬화가 포함된 불변 클래스를 생성합니다:
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,
@Default(false) bool isActive,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}일반적인 실수와 해결법
| 실수 | 해결법 |
|---|---|
| null 체크 없이 `json['key']` 사용 | `json['key'] as String? ?? ''` 사용 또는 null 처리 |
| 중첩 객체 변환을 잊음 | 객체에는 `NestedClass.fromJson(json['key'])` 호출 |
| 모든 숫자에 `int` 사용 | `num` 사용 후 변환: `(json['price'] as num).toDouble()` |
| 누락된 키를 처리하지 않음 | `json.containsKey('key')` 사용 또는 기본값 제공 |
| toJson에서 잘못된 타입 반환 | 중첩 객체가 자체 `.toJson()` 호출을 확인 |
자주 묻는 질문
JSON을 Dart 클래스로 변환하는 가장 좋은 방법은?
소규모 프로젝트에서는 수동 변환이 효과적입니다. 대규모 프로젝트에서는 json_serializable 또는 freezed 패키지로 보일러플레이트 코드를 자동 생성합니다.
Dart에서 동적 JSON 키를 어떻게 처리하나요?
알 수 없는 키가 있는 객체에는 Map<String, dynamic>을 사용합니다.
json_serializable과 freezed 중 어떤 것을 사용해야 하나요?
간단한 JSON 직렬화에는 json_serializable. 불변성, copyWith, union 타입도 필요하면 freezed를 사용합니다.
JSON-Dart 변환에서 날짜를 어떻게 처리하나요?
DateTime.parse(json['date'])로 ISO 8601 문자열을 파싱합니다. toJson에서는 dateTime.toIso8601String()을 사용합니다.