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분 | 난이도: 초급~중급


참고 자료#


문제 상황#

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 체크 없음

문제는 다음과 같습니다.

  • jsonDecode()2dynamic3 타입을 반환해서 컴파일 타임 검사가 안 됩니다.
  • 필드명 오타를 런타임에서야 발견합니다.
  • 자동완성과 IDE 지원을 받을 수 없습니다.

해결책: JSON을 Dart 클래스로 변환하면 이 모든 문제가 해결됩니다.


해결 방법#

JSON 직렬화에는 두 가지 접근법이 있습니다. 프로젝트 규모에 따라 선택하면 됩니다.

graph TD A[JSON 직렬화 방식 선택] --> B{모델 개수?} B -->|5개 미만| C[수동 직렬화] B -->|5개 이상| D[코드 생성] C --> E[dart:convert 직접 사용] D --> F[json_serializable]

챕터 1: dart 수동 직렬화#

Why#

NOTE

dart:convert4는 Dart 기본 라이브러리입니다. 별도 패키지 없이 바로 사용할 수 있습니다.

작은 프로젝트나 빠른 프로토타입에 적합합니다. 모델이 몇 개 안 되고 구조가 단순할 때 사용하세요.

What#

NOTE

JSON 직렬화 용어를 정리합니다.

  • 인코딩(Encoding) = 직렬화(Serialization): Dart 객체 → JSON 문자열
  • 디코딩(Decoding) = 역직렬화(Deserialization): JSON 문자열 → Dart 객체
import 'dart:convert';
// 디코딩: JSON 문자열 → Map
final jsonString = '{"name": "John", "email": "[email protected]"}';
final map = jsonDecode(jsonString) as Map<String, dynamic>;
// 인코딩: Map → JSON 문자열
final encoded = jsonEncode(map);
print(encoded); // {"name":"John","email":"[email protected]"}

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#

WARNING

dynamic의 위험성: 오타를 컴파일러가 잡지 못합니다.

// 이 코드는 컴파일은 되지만 런타임에 에러
print(user['naem']); // 'name'의 오타
// 타입 에러도 런타임에서야 발견
final age = user['age'] as String; // 실제로는 int일 수 있음

null 처리: JSON에 필드가 없으면 null이 반환됩니다.

// 안전한 접근
final name = user['name'] as String? ?? '이름 없음';

결론: 인라인 방식은 빠르지만 안전하지 않습니다. 모델 클래스를 사용하세요.


챕터 2: 모델 클래스로 타입 안전성 확보#

Why#

NOTE

모델 클래스를 만들면 세 가지 이점이 있습니다.

  1. 타입 안전성: 컴파일 타임에 오류 발견
  2. 자동완성: IDE가 필드를 제안
  3. 유지보수성: 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 → User
factory User.fromJson(Map<String, dynamic> json) {
return User(
name: json['name'] as String,
email: json['email'] as String,
);
}
// 직렬화: User → Map
Map<String, dynamic> toJson() {
return {
'name': name,
'email': email,
};
}
}

How#

TIP

실제 사용 예제입니다.

// JSON 문자열 받기
final jsonString = '{"name": "John Smith", "email": "[email protected]"}';
// 역직렬화
final userMap = jsonDecode(jsonString) as Map<String, dynamic>;
final user = User.fromJson(userMap);
// 이제 타입 안전하게 사용 가능
print('이름: ${user.name}'); // 자동완성 지원
print('이메일: ${user.email}');
// 직렬화
final json = jsonEncode(user.toJson());
print(json); // {"name":"John Smith","email":"[email protected]"}

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은 어노테이션 기반 코드 생성 도구입니다.

  1. 클래스에 @JsonSerializable() 어노테이션 추가
  2. fromJson/toJson 스텁 작성
  3. build_runner6로 코드 생성

생성된 코드는 *.g.dart 파일에 저장됩니다.

How#

TIP

1단계: 패키지 설치

Terminal window
flutter pub add json_annotation
flutter pub add dev:build_runner
flutter pub add dev:json_serializable

2단계: 모델 클래스 작성

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 HAND
part 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#

WARNING

part 선언 필수: 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 키
noneuserNameuserName
snakeuserNameuser_name
kebabuserNameuser-name
pascaluserNameUserName

Watch out#

WARNING

null과 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

중첩 객체를 처리하려면 두 가지가 필요합니다.

  1. 중첩 객체용 별도 모델 클래스
  2. 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: true
print(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#

WARNING

explicitToJson 누락: 가장 흔한 실수입니다. 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#

NOTE

JSON 파싱은 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#

WARNING

compute 함수 제약: 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#

  1. Isolate(아이솔레이트): Dart의 독립 실행 환경으로, 메인 스레드와 별도의 메모리 공간에서 코드를 실행한다.

  2. jsonDecode(제이슨디코드): JSON 문자열을 Dart 객체(Map 또는 List)로 변환하는 dart 함수다.

  3. dynamic(다이내믹): 컴파일 타임에 타입이 결정되지 않는 Dart의 특수 타입이다.

  4. dart(다트 컨버트): JSON, UTF-8 등의 인코딩/디코딩을 제공하는 Dart 내장 라이브러리다.

  5. json_serializable(제이슨 시리얼라이저블): fromJson/toJson 코드를 자동 생성해주는 Flutter 공식 권장 패키지다.

  6. build_runner(빌드 러너): 코드 생성 도구를 실행하는 Dart 빌드 시스템이다.

  7. @JsonKey(제이슨키): JSON 필드 매핑을 커스터마이징하는 어노테이션이다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 32편: JSON 파싱과 직렬화
https://moodturnpost.net/posts/flutter/flutter-json-serialization/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차