Flutter 튜토리얼 32편: JSON 파싱과 직렬화
요약
핵심 요지
- 문제 정의: 서버에서 받은 JSON 문자열을 Dart 객체로 변환해야 앱에서 사용할 수 있다. 직접 파싱하면 타입 안전성이 없고 오타에 취약하다.
- 핵심 주장: 모델 클래스에
fromJson/toJson메서드를 정의하면 타입 안전성과 자동완성을 얻을 수 있다. - 주요 근거: 수동 직렬화는 작은 프로젝트에 적합하고,
json_serializable은 중대형 프로젝트에서 보일러플레이트를 줄여준다. - 실무 기준: 모델이 5개 이상이면 코드 생성을 고려하고, JSON이 크면
Isolate1로 백그라운드 파싱한다. - 한계: 런타임 리플렉션이 없어서 GSON/Jackson 같은 자동 매핑은 불가능하다.
문서가 설명하는 범위
- dart
JSON 수동 파싱 - 모델 클래스에서 fromJson/toJson 구현
- json_serializable 패키지 설정과 사용법
- @JsonKey 어노테이션으로 필드 커스터마이징
- 중첩 객체 처리 (explicitToJson)
- Isolate로 대용량 JSON 백그라운드 파싱
읽는 시간: 18분 | 난이도: 초급~중급
참고 자료
- JSON and serialization - JSON 직렬화 공식 가이드
- Parse JSON in the background - 백그라운드 파싱 가이드
- json_serializable package - JSON 코드 생성 패키지
- json_annotation package - JSON 어노테이션 패키지
문제 상황
29편에서 HTTP로 서버와 통신하는 방법을 배웠습니다. 서버 응답은 대부분 JSON 형식입니다.
JSON을 직접 사용할 때의 문제
// 서버 응답 JSON// {"name": "John Smith", "email": "[email protected]"}
final response = await http.get(Uri.parse('https://api.example.com/user'));final json = jsonDecode(response.body);
// 문제 1: 타입 안전성 없음print(json['name']); // dynamic 타입print(json['naem']); // 오타! 런타임에서야 발견
// 문제 2: 자동완성 불가// json. 을 입력해도 어떤 필드가 있는지 모름
// 문제 3: 복잡한 중첩 구조 처리 어려움final address = json['user']['address']['street']; // null 체크 없음문제는 다음과 같습니다.
해결책: JSON을 Dart 클래스로 변환하면 이 모든 문제가 해결됩니다.
해결 방법
JSON 직렬화에는 두 가지 접근법이 있습니다. 프로젝트 규모에 따라 선택하면 됩니다.
챕터 1: dart 수동 직렬화
Why
NOTE
dart:convert4는 Dart 기본 라이브러리입니다. 별도 패키지 없이 바로 사용할 수 있습니다.작은 프로젝트나 빠른 프로토타입에 적합합니다. 모델이 몇 개 안 되고 구조가 단순할 때 사용하세요.
What
NOTEJSON 직렬화 용어를 정리합니다.
- 인코딩(Encoding) = 직렬화(Serialization): Dart 객체 → JSON 문자열
- 디코딩(Decoding) = 역직렬화(Deserialization): JSON 문자열 → Dart 객체
import 'dart:convert';// 디코딩: JSON 문자열 → Mapfinal map = jsonDecode(jsonString) as Map<String, dynamic>;// 인코딩: Map → JSON 문자열final encoded = jsonEncode(map);
How
TIP인라인으로 JSON을 바로 사용하는 방법입니다.
import 'dart:convert';Future<void> fetchUser() async {final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/users/1'),);if (response.statusCode == 200) {// JSON → Map 변환final user = jsonDecode(response.body) as Map<String, dynamic>;// Map에서 값 추출print('이름: ${user['name']}');print('이메일: ${user['email']}');}}간단하지만
user['name']은dynamic타입입니다. 타입 안전성과 자동완성을 원하면 모델 클래스가 필요합니다.
Watch out
WARNINGdynamic의 위험성: 오타를 컴파일러가 잡지 못합니다.
// 이 코드는 컴파일은 되지만 런타임에 에러print(user['naem']); // 'name'의 오타// 타입 에러도 런타임에서야 발견final age = user['age'] as String; // 실제로는 int일 수 있음null 처리: JSON에 필드가 없으면 null이 반환됩니다.
// 안전한 접근final name = user['name'] as String? ?? '이름 없음';
결론: 인라인 방식은 빠르지만 안전하지 않습니다. 모델 클래스를 사용하세요.
챕터 2: 모델 클래스로 타입 안전성 확보
Why
NOTE모델 클래스를 만들면 세 가지 이점이 있습니다.
- 타입 안전성: 컴파일 타임에 오류 발견
- 자동완성: IDE가 필드를 제안
- 유지보수성: JSON 구조 변경 시 한 곳만 수정
// 전: dynamic 타입, 오타 위험print(user['naem']); // 런타임 에러// 후: 타입 안전, 자동완성print(user.name); // 컴파일 타임 검사
What
NOTE모델 클래스에는 두 가지 메서드가 필요합니다.
fromJson: JSON(Map) → Dart 객체 (역직렬화)toJson: Dart 객체 → JSON(Map) (직렬화)class User {final String name;final String email;User({required this.name, required this.email});// 역직렬화: Map → Userfactory User.fromJson(Map<String, dynamic> json) {return User(name: json['name'] as String,email: json['email'] as String,);}// 직렬화: User → MapMap<String, dynamic> toJson() {return {'name': name,'email': email,};}}
How
TIP실제 사용 예제입니다.
// JSON 문자열 받기// 역직렬화final userMap = jsonDecode(jsonString) as Map<String, dynamic>;final user = User.fromJson(userMap);// 이제 타입 안전하게 사용 가능print('이름: ${user.name}'); // 자동완성 지원print('이메일: ${user.email}');// 직렬화final json = jsonEncode(user.toJson());jsonEncode와 toJson:
jsonEncode()는 객체에toJson()메서드가 있으면 자동으로 호출합니다.// 두 코드는 동일한 결과jsonEncode(user.toJson());jsonEncode(user); // toJson()이 자동 호출됨
Watch out
WARNING타입 캐스팅 실패: JSON 값 타입이 예상과 다를 수 있습니다.
// 서버가 id를 문자열로 보내는 경우// {"id": "123", "name": "John"}factory User.fromJson(Map<String, dynamic> json) {return User(// 안전한 처리: int 또는 String 모두 처리id: json['id'] is int? json['id'] as int: int.parse(json['id'] as String),name: json['name'] as String,);}null 처리: 선택적 필드는 nullable로 처리합니다.
class User {final String name;final String? nickname; // 선택적 필드factory User.fromJson(Map<String, dynamic> json) {return User(name: json['name'] as String,nickname: json['nickname'] as String?, // null 허용);}}
결론: 모델 클래스를 사용하면 타입 안전성과 유지보수성이 크게 향상됩니다.
챕터 3: json_serializable로 코드 생성
Why
NOTE모델이 많아지면
fromJson/toJson을 직접 작성하는 게 번거로워집니다.
- 필드가 10개면 코드가 30줄 이상
- 오타 위험 증가
- 필드 추가 시 두 군데 수정 필요
json_serializable5은 이 코드를 자동 생성해줍니다.
What
NOTE
json_serializable은 어노테이션 기반 코드 생성 도구입니다.
- 클래스에
@JsonSerializable()어노테이션 추가fromJson/toJson스텁 작성build_runner6로 코드 생성생성된 코드는
*.g.dart파일에 저장됩니다.
How
TIP1단계: 패키지 설치
Terminal window flutter pub add json_annotationflutter pub add dev:build_runnerflutter pub add dev:json_serializable2단계: 모델 클래스 작성
import 'package:json_annotation/json_annotation.dart';// 생성될 파일 연결 (user.g.dart)part 'user.g.dart';@JsonSerializable()class User {final String name;final String email;User({required this.name, required this.email});// 생성된 코드를 호출하는 스텁factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);Map<String, dynamic> toJson() => _$UserToJson(this);}3단계: 코드 생성 실행
Terminal window # 한 번 실행dart run build_runner build --delete-conflicting-outputs# 파일 변경 감시 (개발 중 편리)dart run build_runner watch --delete-conflicting-outputs생성된
user.g.dart파일:// GENERATED CODE - DO NOT MODIFY BY HANDpart of 'user.dart';User _$UserFromJson(Map<String, dynamic> json) => User(name: json['name'] as String,email: json['email'] as String,);Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{'name': instance.name,'email': instance.email,};
Watch out
WARNINGpart 선언 필수:
part 'user.g.dart';를 빠뜨리면 에러가 납니다.// 에러: Target of URI hasn't been generated: 'user.g.dart'// 해결: build_runner 실행dart run build_runner build --delete-conflicting-outputs코드 생성 후 핫 리로드:
.g.dart파일이 변경되면 핫 리스타트가 필요할 수 있습니다.
결론: json_serializable은 중대형 프로젝트에서 보일러플레이트를 크게 줄여줍니다.
챕터 4: @JsonKey로 필드 커스터마이징
Why
NOTE서버와 클라이언트의 명명 규칙이 다른 경우가 많습니다.
- 서버:
snake_case(예:user_name)- Dart:
lowerCamelCase(예:userName)
@JsonKey7로 이 차이를 해결할 수 있습니다.
What
NOTE
@JsonKey는 필드별 JSON 매핑을 커스터마이징합니다.@JsonSerializable()class User {// JSON 키 이름 매핑@JsonKey(name: 'user_name')final String userName;// 기본값 설정@JsonKey(defaultValue: false)final bool isActive;// 필수 필드 (없으면 에러)@JsonKey(required: true)final String id;// JSON 변환에서 제외@JsonKey(includeFromJson: false, includeToJson: false)final String localCache;User({required this.userName,required this.isActive,required this.id,this.localCache = '',});factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);Map<String, dynamic> toJson() => _$UserToJson(this);}
How
TIP전체 클래스에 naming 전략을 적용할 수도 있습니다.
// 모든 필드를 snake_case로 변환@JsonSerializable(fieldRename: FieldRename.snake)class User {final String userName; // JSON: "user_name"final String emailAddress; // JSON: "email_address"// ...}FieldRename 옵션:
옵션 Dart 필드 JSON 키 noneuserName userName snakeuserName user_name kebabuserName user-name pascaluserName UserName
Watch out
WARNINGnull과 defaultValue: null이 오면 defaultValue가 적용됩니다.
// JSON: {"is_active": null}@JsonKey(defaultValue: false)final bool isActive; // false로 설정됨required와 에러:
required: true인 필드가 없으면 예외가 발생합니다.// JSON에 id가 없으면 MissingRequiredKeysException 발생@JsonKey(required: true)final String id;
결론: @JsonKey로 서버와 클라이언트 간의 데이터 형식 차이를 유연하게 처리할 수 있습니다.
챕터 5: 중첩 객체 처리
Why
NOTE실제 API 응답은 객체 안에 객체가 있는 중첩 구조가 많습니다.
{"name": "John","address": {"street": "123 Main St","city": "New York"}}중첩 객체도 올바르게 직렬화해야 합니다.
What
NOTE중첩 객체를 처리하려면 두 가지가 필요합니다.
- 중첩 객체용 별도 모델 클래스
explicitToJson: true옵션address.dart @JsonSerializable()class Address {final String street;final String city;Address({required this.street, required this.city});factory Address.fromJson(Map<String, dynamic> json) =>_$AddressFromJson(json);Map<String, dynamic> toJson() => _$AddressToJson(this);}// user.dart@JsonSerializable(explicitToJson: true) // 중요!class User {final String name;final Address address; // 중첩 객체User({required this.name, required this.address});factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);Map<String, dynamic> toJson() => _$UserToJson(this);}
How
TIP
explicitToJson이 없으면 중첩 객체가 제대로 변환되지 않습니다.final address = Address(street: '123 Main St', city: 'New York');final user = User(name: 'John', address: address);// explicitToJson: false (기본값)print(user.toJson());// 출력: {name: John, address: Instance of 'Address'} // 잘못됨!// explicitToJson: trueprint(user.toJson());// 출력: {name: John, address: {street: 123 Main St, city: New York}} // 정상!리스트 중첩 객체도 동일하게 처리됩니다.
@JsonSerializable(explicitToJson: true)class User {final String name;final List<Address> addresses; // 중첩 객체 리스트// ...}
Watch out
WARNINGexplicitToJson 누락: 가장 흔한 실수입니다. Firebase 등에 데이터를 저장할 때
Instance of 'ClassName'에러가 발생합니다.// 항상 explicitToJson: true를 사용하세요@JsonSerializable(explicitToJson: true)class User {// ...}순환 참조 주의: A가 B를 참조하고 B가 A를 참조하면 무한 루프가 발생합니다.
// 위험: 순환 참조class User {final Team team; // Team에 User가 있으면 순환}class Team {final List<User> members;}
결론: 중첩 객체가 있으면 반드시 explicitToJson: true를 사용하세요.
챕터 6: Isolate로 대용량 JSON 백그라운드 파싱
Why
NOTEJSON 파싱은 CPU를 사용하는 동기 작업입니다. 대용량 JSON(수천 개 항목)을 메인 스레드에서 파싱하면 앱이 버벅거립니다.
16ms 규칙: 60fps를 유지하려면 각 프레임을 16ms 안에 처리해야 합니다. JSON 파싱이 16ms를 넘으면 화면이 끊깁니다.
What
NOTE
Isolate는 Dart의 독립 실행 환경입니다. 메인 스레드와 별도로 작업을 수행해서 UI 버벅거림을 방지합니다.
compute()함수는 Isolate를 쉽게 사용하게 해줍니다.// compute(함수, 인자) → 백그라운드에서 함수 실행final result = await compute(parsePhotos, jsonString);
How
TIP대용량 JSON을 백그라운드에서 파싱하는 예제입니다.
import 'dart:convert';import 'package:flutter/foundation.dart'; // compute 함수import 'package:http/http.dart' as http;// 모델 클래스class Photo {final int id;final String title;final String thumbnailUrl;const Photo({required this.id,required this.title,required this.thumbnailUrl,});factory Photo.fromJson(Map<String, dynamic> json) {return Photo(id: json['id'] as int,title: json['title'] as String,thumbnailUrl: json['thumbnailUrl'] as String,);}}// 파싱 함수 (최상위 함수여야 함)List<Photo> parsePhotos(String responseBody) {final parsed = jsonDecode(responseBody) as List;return parsed.map((json) => Photo.fromJson(json as Map<String, dynamic>)).toList();}// 데이터 가져오기Future<List<Photo>> fetchPhotos() async {final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/photos'),);// 백그라운드에서 파싱return compute(parsePhotos, response.body);}핵심:
compute(parsePhotos, response.body)가 별도 Isolate에서 파싱을 수행합니다.
Watch out
WARNINGcompute 함수 제약:
compute에 전달하는 함수는 최상위 함수이거나 static 메서드여야 합니다.// 올바름: 최상위 함수List<Photo> parsePhotos(String body) { ... }// 올바름: static 메서드class Parser {static List<Photo> parsePhotos(String body) { ... }}// 에러: 인스턴스 메서드는 불가class Parser {List<Photo> parsePhotos(String body) { ... } // compute에서 사용 불가}Isolate 간 전달 제한:
Future,http.Response같은 복잡한 객체는 Isolate 간에 전달할 수 없습니다. 단순한 값(String, List, Map)만 전달하세요.// 잘못됨: Response 객체 전달compute(parseResponse, response); // 에러!// 올바름: 문자열만 전달compute(parsePhotos, response.body); // OK
결론: 대용량 JSON은 compute()로 백그라운드에서 파싱하면 UI가 끊기지 않습니다.
한계
Flutter는 런타임 리플렉션을 지원하지 않습니다. 그래서 Java의 GSON이나 Kotlin의 Moshi처럼 자동으로 JSON을 객체에 매핑하는 라이브러리가 없습니다.
런타임 리플렉션이 없는 이유:
- Tree Shaking: 사용하지 않는 코드를 제거해서 앱 크기를 줄입니다.
- 리플렉션을 사용하면 어떤 코드가 실행될지 컴파일 타임에 알 수 없습니다.
- 결과적으로 모든 코드를 포함해야 해서 앱 크기가 커집니다.
대안:
| 방식 | 장점 | 단점 |
|---|---|---|
| 수동 직렬화 | 간단, 의존성 없음 | 보일러플레이트 많음 |
| json_serializable | 코드 생성, 타입 안전 | 빌드 단계 필요 |
| freezed | 불변 클래스 + JSON | 학습 곡선 있음 |
다음 튜토리얼에서는 로컬 저장소에 데이터를 저장하는 방법을 알아봅니다.
Footnotes
-
Isolate(아이솔레이트): Dart의 독립 실행 환경으로, 메인 스레드와 별도의 메모리 공간에서 코드를 실행한다. ↩
-
jsonDecode(제이슨디코드): JSON 문자열을 Dart 객체(Map 또는 List)로 변환하는 dart
함수다. ↩ -
dynamic(다이내믹): 컴파일 타임에 타입이 결정되지 않는 Dart의 특수 타입이다. ↩
-
dart
(다트 컨버트): JSON, UTF-8 등의 인코딩/디코딩을 제공하는 Dart 내장 라이브러리다. ↩ -
json_serializable(제이슨 시리얼라이저블): fromJson/toJson 코드를 자동 생성해주는 Flutter 공식 권장 패키지다. ↩
-
build_runner(빌드 러너): 코드 생성 도구를 실행하는 Dart 빌드 시스템이다. ↩
-
@JsonKey(제이슨키): JSON 필드 매핑을 커스터마이징하는 어노테이션이다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!