Flutter에서 JSON 직렬화는 어떻게 처리하나요?
질문
Flutter에서 JSON 직렬화 및 역직렬화 방법과 모범 사례에 대해 설명해주세요.
답변
Flutter 애플리케이션에서 JSON 처리는 API 통신, 파일 저장, 앱 구성 관리 등 다양한 용도로 필수적입니다. Flutter에서는 JSON을 Dart 객체로 변환하고(역직렬화), Dart 객체를 JSON으로 변환하는(직렬화) 여러 방법을 제공합니다.
1. 수동 JSON 직렬화/역직렬화
가장 기본적인 방법으로, dart:convert
라이브러리에서 제공하는 jsonEncode
와 jsonDecode
함수를 사용합니다.
import 'dart:convert';
class User {
final int id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
// JSON에서 User 객체로 변환 (역직렬화)
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
name: json['name'],
email: json['email'],
);
}
// User 객체에서 JSON으로 변환 (직렬화)
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
};
}
}
// 사용 예시
void main() {
// JSON 문자열
String jsonString = '{"id": 1, "name": "홍길동", "email": "hong@example.com"}';
// JSON 문자열을 Map으로 디코딩
Map<String, dynamic> userData = jsonDecode(jsonString);
// Map에서 User 객체 생성
User user = User.fromJson(userData);
print('사용자 이름: ${user.name}');
// User 객체를 Map으로 변환
Map<String, dynamic> userMap = user.toJson();
// Map을 JSON 문자열로 인코딩
String encodedJson = jsonEncode(userMap);
print('인코딩된 JSON: $encodedJson');
}
장점:
- 추가 라이브러리 불필요
- 간단한 JSON 구조에 적합
- 직관적인 코드
단점:
- 복잡한 JSON 구조에서 코드가 장황해질 수 있음
- 오타나 타입 오류 가능성
- 반복적인 코드 작성 필요
2. 코드 생성을 사용한 JSON 직렬화
복잡하거나 큰 JSON 구조를 처리할 때 권장되는 방법입니다. 주로 json_serializable
패키지를 사용합니다.
2.1 json_serializable 설정
먼저 필요한 패키지를 pubspec.yaml
에 추가합니다:
dependencies:
json_annotation: ^4.8.0
dev_dependencies:
build_runner: ^2.3.3
json_serializable: ^6.6.1
2.2 클래스 정의
import 'package:json_annotation/json_annotation.dart';
// 생성될 코드 참조
part 'user.g.dart';
// 클래스에 어노테이션 추가
@JsonSerializable()
class User {
final int id;
final String name;
@JsonKey(name: 'email_address') // JSON 필드명 지정
final String email;
User({required this.id, required this.name, required this.email});
// 코드 생성을 위한 팩토리 메서드
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
// 코드 생성을 위한 toJson 메서드
Map<String, dynamic> toJson() => _$UserToJson(this);
}
2.3 코드 생성 실행
다음 명령어를 실행하여 직렬화/역직렬화 코드를 생성합니다:
flutter pub run build_runner build
또는 개발 중 변경사항을 자동으로 감지하려면:
flutter pub run build_runner watch
2.4 사용 예시
import 'dart:convert';
import 'user.dart';
void main() {
String jsonString = '{"id": 1, "name": "홍길동", "email_address": "hong@example.com"}';
// JSON 문자열을 Map으로 변환
Map<String, dynamic> userData = jsonDecode(jsonString);
// 생성된 fromJson 메서드 사용
User user = User.fromJson(userData);
print('사용자 이름: ${user.name}, 이메일: ${user.email}');
// 생성된 toJson 메서드 사용
Map<String, dynamic> userMap = user.toJson();
String encodedJson = jsonEncode(userMap);
print('인코딩된 JSON: $encodedJson');
}
장점:
- 반복적인 코드 작성 감소
- 타입 안전성 향상
- 오류 가능성 감소
- 복잡한 JSON 구조에 적합
단점:
- 추가 패키지 및 설정 필요
- 코드 생성 단계 필요
- 간단한 JSON에는 오버헤드가 될 수 있음
3. 중첩 객체 및 리스트 처리
실제 애플리케이션에서는 중첩된 객체와 리스트를 포함하는 복잡한 JSON 구조를 처리해야 합니다.
3.1 수동 방식으로 중첩 객체 처리
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'],
city: json['city'],
zipCode: json['zip_code'],
);
}
Map<String, dynamic> toJson() {
return {
'street': street,
'city': city,
'zip_code': zipCode,
};
}
}
class User {
final int id;
final String name;
final Address address;
final List<String> hobbies;
User({
required this.id,
required this.name,
required this.address,
required this.hobbies,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
name: json['name'],
address: Address.fromJson(json['address']),
hobbies: List<String>.from(json['hobbies']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'address': address.toJson(),
'hobbies': hobbies,
};
}
}
3.2 json_serializable로 중첩 객체 처리
@JsonSerializable(explicitToJson: true)
class Address {
final String street;
final String city;
@JsonKey(name: 'zip_code')
final String zipCode;
Address({required this.street, required this.city, required this.zipCode});
factory Address.fromJson(Map<String, dynamic> json) => _$AddressFromJson(json);
Map<String, dynamic> toJson() => _$AddressToJson(this);
}
@JsonSerializable(explicitToJson: true)
class User {
final int id;
final String name;
final Address address;
final List<String> hobbies;
User({
required this.id,
required this.name,
required this.address,
required this.hobbies,
});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
참고:
explicitToJson: true
옵션은 중첩 객체를 직렬화할 때toJson()
메서드를 명시적으로 호출하도록 합니다.
4. 특수 타입 처리
4.1 날짜 처리
JSON은 기본적으로 날짜 타입을 지원하지 않으므로, 날짜를 문자열이나 타임스탬프로 변환해야 합니다.
// 수동 처리
class Event {
final String title;
final DateTime date;
Event({required this.title, required this.date});
factory Event.fromJson(Map<String, dynamic> json) {
return Event(
title: json['title'],
date: DateTime.parse(json['date']), // ISO 8601 형식의 문자열을 DateTime으로 변환
);
}
Map<String, dynamic> toJson() {
return {
'title': title,
'date': date.toIso8601String(), // DateTime을 ISO 8601 형식의 문자열로 변환
};
}
}
json_serializable을 사용한 날짜 처리:
@JsonSerializable()
class Event {
final String title;
@JsonKey(
fromJson: _dateTimeFromJson,
toJson: _dateTimeToJson,
)
final DateTime date;
Event({required this.title, required this.date});
static DateTime _dateTimeFromJson(String date) => DateTime.parse(date);
static String _dateTimeToJson(DateTime date) => date.toIso8601String();
factory Event.fromJson(Map<String, dynamic> json) => _$EventFromJson(json);
Map<String, dynamic> toJson() => _$EventToJson(this);
}
4.2 열거형(Enum) 처리
enum UserStatus { active, inactive, blocked }
// 수동 처리
class User {
final int id;
final String name;
final UserStatus status;
User({required this.id, required this.name, required this.status});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
name: json['name'],
status: UserStatus.values.firstWhere(
(e) => e.toString() == 'UserStatus.${json['status']}',
orElse: () => UserStatus.inactive,
),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'status': status.toString().split('.').last,
};
}
}
json_serializable을 사용한 열거형 처리:
enum UserStatus { active, inactive, blocked }
@JsonSerializable()
class User {
final int id;
final String name;
@JsonKey(
fromJson: _statusFromJson,
toJson: _statusToJson,
)
final UserStatus status;
User({required this.id, required this.name, required this.status});
static UserStatus _statusFromJson(String status) {
return UserStatus.values.firstWhere(
(e) => e.toString().split('.').last == status,
orElse: () => UserStatus.inactive,
);
}
static String _statusToJson(UserStatus status) {
return status.toString().split('.').last;
}
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
5. 누락된 필드 및 null 값 처리
실제 API 응답에서는 필드가 누락되거나 null
값이 포함될 수 있습니다. 이러한 상황을 적절히 처리해야 합니다.
@JsonSerializable(includeIfNull: false) // null 값은 JSON에 포함하지 않음
class User {
final int id;
final String name;
@JsonKey(defaultValue: "unknown@example.com") // 기본값 설정
final String email;
@JsonKey(name: 'phone_number', includeIfNull: false) // 특정 필드에 대한 제어
final String? phoneNumber; // null 허용
final Address? address; // null 허용
@JsonKey(defaultValue: []) // 빈 리스트를 기본값으로 설정
final List<String> tags;
User({
required this.id,
required this.name,
required this.email,
this.phoneNumber,
this.address,
required this.tags,
});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
6. JSON 직렬화 모범 사례
6.1 일관성 있는 접근 방식 사용
프로젝트 전체에서 일관된 JSON 직렬화 방식을 사용하세요. 수동 방식과 코드 생성 방식을 혼합해서 사용하면 혼란을 초래할 수 있습니다.
6.2 유효성 검사 추가
JSON 데이터가 예상대로 구조화되어 있는지 확인하고, 오류나 누락된 필드를 적절히 처리하세요.
factory User.fromJson(Map<String, dynamic> json) {
if (json['name'] == null) {
throw FormatException('이름 필드가 누락되었습니다.');
}
return User(
id: json['id'] ?? 0, // id가 없으면 기본값 0 사용
name: json['name'],
email: json['email'] ?? 'unknown@example.com',
);
}
6.3 비동기 직렬화 처리
대용량 JSON 데이터를 처리할 때는 비동기 방식을 고려하세요.
Future<User> fetchUser() async {
final response = await http.get(Uri.parse('https://api.example.com/user/1'));
// 별도의 isolate에서 JSON 파싱 수행
return compute(parseUser, response.body);
}
// compute 함수에서 사용할 정적 메서드
User parseUser(String responseBody) {
final Map<String, dynamic> json = jsonDecode(responseBody);
return User.fromJson(json);
}
6.4 버전 관리
API가 변경될 가능성이 있는 경우, 버전 관리를 고려하세요.
@JsonSerializable()
class UserV2 {
final int id;
final String name;
final String email;
final String? phoneNumber; // v2에서 추가된 필드
UserV2({
required this.id,
required this.name,
required this.email,
this.phoneNumber,
});
// v1 사용자를 v2로 변환
factory UserV2.fromV1(UserV1 user) {
return UserV2(
id: user.id,
name: user.name,
email: user.email,
phoneNumber: null,
);
}
factory UserV2.fromJson(Map<String, dynamic> json) => _$UserV2FromJson(json);
Map<String, dynamic> toJson() => _$UserV2ToJson(this);
}
6.5 테스트 작성
JSON 직렬화/역직렬화 로직에 대한 단위 테스트를 작성하세요.
test('User fromJson/toJson 테스트', () {
final json = {
'id': 1,
'name': '홍길동',
'email': 'hong@example.com',
};
final user = User.fromJson(json);
expect(user.id, 1);
expect(user.name, '홍길동');
expect(user.email, 'hong@example.com');
final serialized = user.toJson();
expect(serialized, json);
});
7. 실제 응용 예시: API 응답 처리
import 'dart:convert';
import 'package:http/http.dart' as http;
// API 응답을 위한 래퍼 클래스
@JsonSerializable(genericArgumentFactories: true)
class ApiResponse<T> {
final bool success;
final String message;
final T? data;
ApiResponse({
required this.success,
required this.message,
this.data,
});
factory ApiResponse.fromJson(
Map<String, dynamic> json,
T Function(Object? json) fromJsonT,
) {
return ApiResponse<T>(
success: json['success'] as bool,
message: json['message'] as String,
data: json['data'] != null ? fromJsonT(json['data']) : null,
);
}
}
// 사용 예시
Future<void> fetchUserData() async {
final response = await http.get(Uri.parse('https://api.example.com/user/1'));
if (response.statusCode == 200) {
final Map<String, dynamic> jsonResponse = jsonDecode(response.body);
final apiResponse = ApiResponse<User>.fromJson(
jsonResponse,
(json) => User.fromJson(json as Map<String, dynamic>),
);
if (apiResponse.success && apiResponse.data != null) {
final user = apiResponse.data!;
print('사용자 정보: ${user.name} (${user.email})');
} else {
print('오류: ${apiResponse.message}');
}
} else {
print('API 요청 실패: ${response.statusCode}');
}
}
결론
Flutter에서 JSON 직렬화는 API 통신, 저장소 작업 등 여러 측면에서 필수적입니다. 간단한 JSON 구조에는 수동 방식이 적합할 수 있지만, 복잡한 애플리케이션에서는 json_serializable
과 같은 코드 생성 라이브러리를 사용하는 것이 더 효율적입니다.
올바른 직렬화 방법을 선택할 때는 프로젝트의 복잡성, JSON 구조의 깊이, 팀의 선호도, 유지보수 용이성 등을 고려하세요. 어떤 방법을 선택하든, 일관성 있게 적용하고 적절한 오류 처리와 테스트를 통해 안정적인 직렬화 로직을 구현하는 것이 중요합니다.