[Flutter 공부] JSON 파싱과 직렬화
- Developer/Flutter
- 2025. 3. 13.
JSON 파싱과 직렬화 - 데이터 모델 구현과 JSON 변환
API 통신할 때마다 복잡한 JSON 파싱 코드로 고통받고 계신가요? 효율적인 데이터 모델링으로 이 문제를 해결해봅시다!
목차
JSON 기초와 Flutter에서의 중요성
JSON(JavaScript Object Notation)은 데이터 교환 형식으로, 거의 모든 API가 이 형식을 사용하고 있어요. 간단하게 말하자면 키-값 쌍으로 이루어진 데이터 구조인데, 인간도 읽기 쉽고 기계도 파싱하기 쉬운 형태죠. Flutter 앱 개발할 때 서버와 통신하거나 로컬 스토리지에 데이터 저장할 때 거의 필수적으로 사용하게 됩니다.
근데 JSON을 그냥 문자열 상태로 앱에서 사용하기는 너무 불편하잖아요? 그래서 우리는 이걸 Dart 객체로 변환해서 사용해요. 이 과정을 '파싱(Parsing)'이라고 하죠. 반대로 Dart 객체를 JSON 문자열로 변환하는 걸 '직렬화(Serialization)'라고 합니다. 이 두 과정을 효율적으로 처리하는 것이 Flutter 앱 개발의 핵심 중 하나예요.
요즘 Flutter로 거의 모든 앱이 어떤 형태로든 백엔드 서버와 통신하고 있어요. 사용자 정보, 상품 목록, 게시글... 이런 데이터들이 전부 JSON 형태로 오가죠. 이런 데이터를 효율적으로 처리하지 못하면 앱의 성능이 떨어지고, 코드 유지보수도 어려워집니다. 특히 복잡한 중첩 구조의 JSON을 다룰 땐 더욱 그렇죠.
"좋은 앱은 데이터 모델링에서부터 시작된다" - 이건 제가 선배 개발자에게 들은 말인데, 정말 공감돼요. 데이터 구조가 탄탄해야 UI도, 비즈니스 로직도 깔끔하게 구현할 수 있으니까요.
수동 파싱 vs 자동 직렬화 비교
Flutter에서 JSON을 다루는 방법은 크게 두 가지로 나눌 수 있어요. 하나는 수동으로 직접 파싱하는 방법, 다른 하나는 라이브러리를 이용한 자동 직렬화 방법이죠. 각각의 장단점을 비교해볼게요.
특성 | 수동 파싱 | 자동 직렬화 |
---|---|---|
구현 난이도 | 간단한 구조에서는 쉽지만, 복잡한 구조에서는 매우 번거로움 | 초기 설정만 잘하면 복잡한 구조에서도 쉽게 구현 가능 |
코드량 | 많음 (모든 필드를 직접 매핑해야 함) | 적음 (자동 생성 코드 활용) |
유지보수 | 모델 변경 시 파싱 코드도 수정해야 함 | 모델만 수정하면 직렬화 코드는 자동 업데이트 |
타입 안전성 | 실수하기 쉬움 (타입 캐스팅 오류 발생 가능) | 컴파일 타임에 타입 체크 가능 |
성능 | 직접 최적화 가능하므로 잠재적으로 더 빠름 | 라이브러리에 따라 다소 오버헤드 발생 가능 |
학습 곡선 | 기본 Dart 지식만 있으면 시작 가능 | 라이브러리별 문법과 설정 방법 학습 필요 |
솔직히 말하자면, 작은 프로젝트나 JSON 구조가 단순하다면 수동 파싱도 나쁘지 않아요. 아래처럼 `dart:convert` 패키지의 `jsonDecode` 함수로 간단히 구현할 수 있거든요.
// 수동 파싱 예제
Map<String, dynamic> jsonMap = jsonDecode(jsonString);
var user = User(
id: jsonMap['id'],
name: jsonMap['name'],
email: jsonMap['email'],
);
// 수동 직렬화 예제
Map<String, dynamic> userMap = {
'id': user.id,
'name': user.name,
'email': user.email,
};
String jsonString = jsonEncode(userMap);
하지만 프로젝트가 커지고 모델이 복잡해지면 이런 방식은 정말... 끔찍해져요. 수동으로 모든 필드를 매핑하는 건 시간 낭비이기도 하고, 오타 같은 인간적 실수로 버그가 발생할 확률도 높아지죠.
효율적인 데이터 모델 구현 방법
이제 효율적인 데이터 모델을 만드는 방법에 대해 알아볼게요. 기본적으로 클래스 기반의 모델링이 가장 많이 사용되는데, 몇 가지 중요한 원칙이 있어요.
- 불변(immutable) 객체로 설계하기 - 생성 후 데이터가 변경되지 않도록
- 필요한 필드는 required로 표시하여 null safety 활용
- 각 모델마다 fromJson과 toJson 메서드 구현
- 중첩된 객체도 모델 클래스로 정의
- copyWith 메서드 추가하여 객체 복사 및 수정 용이하게
- toString, hashCode, == 연산자 오버라이드로 디버깅 및 비교 편의성 제공
아래는 이런 원칙을 따르는 간단한 모델 클래스 예시예요. 완전한 불변 객체는 아니지만, 기본적인 구조를 보여드리려고 해요.
class User {
final int id;
final String name;
final String email;
final Address address; // 중첩 객체
User({
required this.id,
required this.name,
required this.email,
required this.address,
});
// JSON에서 객체로 변환
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
name: json['name'],
email: json['email'] ?? '',
address: Address.fromJson(json['address']),
);
}
// 객체에서 JSON으로 변환
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'address': address.toJson(),
};
}
// 객체 복사 및 수정을 위한 메서드
User copyWith({
int? id,
String? name,
String? email,
Address? address,
}) {
return User(
id: id ?? this.id,
name: name ?? this.name,
email: email ?? this.email,
address: address ?? this.address,
);
}
// 객체 비교를 위한 연산자 오버라이드
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is User &&
id == other.id &&
name == other.name &&
email == other.email &&
address == other.address;
@override
int get hashCode => id.hashCode ^ name.hashCode ^ email.hashCode ^ address.hashCode;
@override
String toString() {
return 'User{id: $id, name: $name, email: $email, address: $address}';
}
}
음... 근데 이렇게 수동으로 모든 걸 작성하면 복잡한 모델의 경우 코드가 너무 길어지고 실수할 가능성도 커져요. 특히 프로젝트가 진행되면서 모델 구조가 자주 바뀌는 경우엔 유지보수도 어려워지죠. 그래서 다음 섹션에서는 이런 작업을 자동화해주는 패키지들에 대해 알아볼게요.
JSON 직렬화를 위한 패키지 비교
JSON 직렬화와 파싱을 자동화해주는 여러 패키지들이 있는데, 각각 장단점이 있어요. 프로젝트 특성에 맞는 패키지를 선택하는 게 중요합니다. 가장 인기 있는 세 가지 패키지를 비교해볼게요.
1. json_serializable
Flutter 팀에서도 권장하는 패키지로, 코드 생성 방식을 사용해요. 애노테이션을 기반으로 필요한 fromJson/toJson 메서드를 자동으로 생성해줍니다. 코드가 깔끔하고 타입 안전성이 높아요.
저도 가장 많이 애용하는 패키지 중에 하나입니다.
// pubspec.yaml 의존성 추가
// dependencies:
// json_annotation: ^4.8.1
// dev_dependencies:
// build_runner: ^2.4.6
// json_serializable: ^6.7.1
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart'; // 자동 생성될 파일
@JsonSerializable()
class User {
final int id;
final String name;
@JsonKey(defaultValue: '')
final String email;
final Address address;
User({
required this.id,
required this.name,
required this.email,
required this.address,
});
// 팩토리 생성자와 toJson 메서드 선언
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
이렇게 코드를 작성한 후 flutter pub run build_runner build
명령어를 실행하면 자동으로 파싱/직렬화 코드가 생성됩니다. 모델이 변경될 때마다 이 명령을 다시 실행해야 하는 번거로움이 있지만, 대규모 프로젝트에선 정말 시간을 절약해줘요.
2. freezed
freezed는 json_serializable을 확장한 패키지로, 불변 객체를 쉽게 만들 수 있게 해줘요. 코드 생성 방식을 사용하며, JSON 직렬화 외에도 copyWith, ==, hashCode, toString 메서드까지 자동 생성해줍니다. 덤으로 패턴 매칭 기능도 제공하죠.
// pubspec.yaml 의존성 추가
// dependencies:
// freezed_annotation: ^2.4.1
// dev_dependencies:
// build_runner: ^2.4.6
// freezed: ^2.4.5
// json_serializable: ^6.7.1
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,
@Default('') String email,
required Address address,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
freezed는 최근에 인기가 많아진 패키지예요. 보일러플레이트 코드를 엄청나게 줄여주고, 모델 변경 시 안전성도 높여줍니다. 다만 문법이 조금 특이해서 처음엔 적응이 필요할 수 있어요.
그래서 사실 저도 게으른지라 json_serializable을 더 많이 사용하고 freezed는 거의 사용하고 있진 않긴 합니다.
3. built_value
built_value는 구글에서 만든 패키지로, 철저한 불변 객체를 구현하고 싶을 때 좋은 선택이에요. 다른 패키지들보다 더 엄격한 타입 안전성을 제공하며, 직렬화 성능도 뛰어납니다. 다만 설정이 복잡하고 보일러플레이트 코드가 많은 편이죠.
// pubspec.yaml 의존성 추가
// dependencies:
// built_value: ^8.6.1
// built_collection: ^5.1.1
// dev_dependencies:
// build_runner: ^2.4.6
// built_value_generator: ^8.6.1
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
part 'user.g.dart';
abstract class User implements Built<User, UserBuilder> {
int get id;
String get name;
String get email;
Address get address;
User._();
factory User([void Function(UserBuilder) updates]) = _$User;
static Serializer get serializer => _$userSerializer;
}
솔직히 말하면 built_value는 러닝 커브가 좀 높은 편이에요. 하지만 대규모 프로젝트에서 안정성을 최우선으로 한다면 고려해볼 만한 옵션입니다.
제 경험상, 소규모 프로젝트는 json_serializable로도 충분하고, 중규모 이상이거나 불변 객체의 이점을 최대한 활용하고 싶다면 freezed가 가장 균형 잡힌 선택인 것 같아요. 처음에는 설정이 좀 번거롭지만, 익숙해지면 엄청난 생산성 향상을 경험할 수 있죠.
복잡한 JSON 구조 다루기
실제 프로젝트에서는 단순한 구조보다는 복잡한 JSON을 다루는 경우가 많아요. 중첩된 객체, 배열, 다양한 타입의 필드 등이 섞여 있죠. 이런 복잡한 JSON 구조를 효과적으로 다루는 방법에 대해 알아볼게요.
복잡한 구조 | 처리 방법 | 사용 패키지 예시 |
---|---|---|
중첩된 객체 | 각 중첩 객체도 모델 클래스로 정의 | json_serializable, freezed |
객체 배열 | List로 매핑하고 각 아이템 변환 | json_serializable + @JsonKey |
동적 키를 가진 맵 | Map<String, dynamic>으로 처리 | freezed + Map 타입 |
다형성 객체(polymorphic) | 타입 식별자 필드로 구분 | freezed + @Freezed(unionKey) |
날짜/시간 필드 | 커스텀 변환기 사용 | @JsonKey(fromJson, toJson) |
누락 가능한 필드 | nullable 또는 기본값 설정 | @JsonKey(defaultValue) |
중첩 객체와 배열 처리하기
중첩 객체와 배열은 가장 흔하게 만나는 복잡한 구조예요. 여러 객체가 계층적으로 포함되어 있거나, 리스트 형태로 데이터가 제공되는 경우죠. 이런 경우 json_serializable을 사용하면 다음과 같이 처리할 수 있어요:
@JsonSerializable()
class User {
final int id;
final String name;
final Address address; // 중첩 객체
final List posts; // 객체 배열
User({
required this.id,
required this.name,
required this.address,
required this.posts,
});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
@JsonSerializable()
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) => _$AddressFromJson(json);
Map<String, dynamic> toJson() => _$AddressToJson(this);
}
@JsonSerializable()
class Post {
final int id;
final String title;
final String body;
Post({
required this.id,
required this.title,
required this.body,
});
factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
Map<String, dynamic> toJson() => _$PostToJson(this);
}
다형성 객체 처리하기
다형성 객체란 같은 필드에 여러 타입의 객체가 올 수 있는 경우를 말해요. 예를 들어, 메시지 앱에서 텍스트, 이미지, 비디오 등 다양한 타입의 메시지가 있을 수 있죠. 이런 경우 freezed를 사용하면 깔끔하게 처리할 수 있어요:
@Freezed(unionKey: 'type')
class Message with _$Message {
// 텍스트 메시지
@FreezedUnionValue('text')
const factory Message.text({
required String id,
required String content,
required DateTime timestamp,
}) = TextMessage;
// 이미지 메시지
@FreezedUnionValue('image')
const factory Message.image({
required String id,
required String imageUrl,
String? caption,
required DateTime timestamp,
}) = ImageMessage;
// 비디오 메시지
@FreezedUnionValue('video')
const factory Message.video({
required String id,
required String videoUrl,
required int durationSeconds,
required DateTime timestamp,
}) = VideoMessage;
factory Message.fromJson(Map<String, dynamic> json) => _$MessageFromJson(json);
}
이렇게 하면 JSON의 'type' 필드 값에 따라 자동으로 적절한 타입의 객체로 변환해줍니다. 그리고 패턴 매칭을 통해 타입별로 다른 처리를 할 수도 있어요:
Widget buildMessage(Message message) {
return message.when(
text: (id, content, timestamp) => TextMessageWidget(content: content),
image: (id, imageUrl, caption, timestamp) => ImageMessageWidget(
imageUrl: imageUrl,
caption: caption,
),
video: (id, videoUrl, durationSeconds, timestamp) => VideoMessageWidget(
videoUrl: videoUrl,
duration: Duration(seconds: durationSeconds),
),
);
}
프로젝트 적용 모범 사례와 성능 최적화
이제 실제 프로젝트에 적용할 때 알아두면 좋은 모범 사례와 성능 최적화 방법에 대해 알아볼게요. 잘 설계된 JSON 파싱 구조는 앱의 유지보수성과 성능에 큰 영향을 미치니까요.
- 모델은 도메인 계층에 위치시키기 - UI 로직과 분리하여 관리
- API 응답 모델과 UI 모델 분리하기 - API 응답 그대로 UI에 사용하지 말고 변환 레이어 두기
- JSON 파싱은 백그라운드 isolate에서 처리하기 - 메인 스레드 블로킹 방지
- 에러 처리 로직 추가하기 - 예상치 못한 JSON 구조에 대비
- 변환된 객체 캐싱하기 - 반복적인 파싱 작업 줄이기
- 테스트 코드 작성하기 - 샘플 JSON으로 파싱 테스트 자동화
비동기 파싱과 Isolate 활용
대용량 JSON을 파싱할 때는 메인 스레드에서 처리하면 UI가 버벅거릴 수 있어요. 이런 경우 compute 함수나 Isolate를 활용하면 백그라운드에서 파싱 작업을 할 수 있죠. 예를 들어:
import 'package:flutter/foundation.dart';
Future<List> parseUsers(String jsonString) async {
// compute 함수를 사용해 별도 isolate에서 파싱
return compute(_parseUsersJson, jsonString);
}
// 별도 isolate에서 실행될 함수
List _parseUsersJson(String jsonString) {
final List jsonList = jsonDecode(jsonString);
return jsonList.map((json) => User.fromJson(json)).toList();
}
// 사용 예시
void fetchUsers() async {
final response = await http.get(Uri.parse('https://api.example.com/users'));
if (response.statusCode == 200) {
final users = await parseUsers(response.body);
setState(() {
this.users = users;
});
}
}
리포지토리 패턴으로 구조화하기
대규모 프로젝트에서는 리포지토리 패턴을 활용해 데이터 접근 로직을 추상화하는 것이 좋아요. API 호출과 JSON 파싱을 캡슐화하고, 비즈니스 로직에는 도메인 모델만 노출하는 방식이죠. 간단한 예시를 보여드릴게요:
// 추상 인터페이스
abstract class UserRepository {
Future<List> getUsers();
Future getUserById(int id);
Future updateUser(User user);
}
// 구현체
class ApiUserRepository implements UserRepository {
final HttpClient client;
ApiUserRepository(this.client);
@override
Future<List> getUsers() async {
final response = await client.get(Uri.parse('https://api.example.com/users'));
if (response.statusCode == 200) {
return compute(_parseUsers, response.body);
} else {
throw Exception('Failed to load users');
}
}
@override
Future getUserById(int id) async {
// 구현...
}
@override
Future updateUser(User user) async {
// 구현...
}
static List _parseUsers(String responseBody) {
final List jsonList = jsonDecode(responseBody);
return jsonList.map((json) => User.fromJson(json)).toList();
}
}
이런 구조를 사용하면 데이터 소스가 바뀌어도(API에서 로컬 DB로 변경 등) 상위 레이어의 코드는 수정할 필요가 없어요. 테스트도 더 쉬워지고, 관심사 분리로 코드 품질도 높아지죠.
실제 프로젝트에서는 위 패턴을 Provider, Riverpod, Bloc 같은 상태 관리 라이브러리와 함께 사용하면 더 효과적이에요. 각 상태 관리 솔루션에 맞게 리포지토리를 통합하는 방법이 다를 수 있으니, 사용 중인 아키텍처에 맞게 적용해보세요.
자주 묻는 질문 (FAQ)
대부분의 프로젝트에서는 freezed를 추천해요. 보일러플레이트 코드를 획기적으로 줄여주면서도 불변 객체 생성, 패턴 매칭 등 강력한 기능을 제공하기 때문이죠. 작은 프로젝트라면 json_serializable로도 충분하고, 아주 엄격한 타입 안전성이 필요하다면 built_value를 고려해볼 수 있어요. 결국 프로젝트 규모와 팀의 선호도에 따라 선택하는 것이 좋습니다.
JSON 파싱 에러는 크게 두 종류로 나눌 수 있어요. 하나는 JSON 문법 자체가 잘못된 경우(잘못된 형식), 다른 하나는 예상한 필드가 없거나 타입이 다른 경우(스키마 불일치)입니다. 전자는 try-catch로 감싸서 처리하고, 후자는 모델 클래스에서 기본값을 제공하거나 nullable 타입을 사용해 처리하는 게 좋아요. 또한 중요한 API 응답은 파싱 테스트 코드를 작성해두는 것이 안전합니다. 재시도 메커니즘을 구현하거나, 파싱에 실패해도 앱이 크래시되지 않도록 fallback 모델을 사용하는 방법도 고려해보세요.
네, 가능합니다! json_serializable이나 freezed 패키지에서는 @JsonKey 애노테이션을 사용해 필드명을 매핑할 수 있어요. 예를 들어, 서버에서는 snake_case로 오는데 Dart에서는 camelCase를 사용하고 싶다면 다음과 같이 할 수 있죠:
@JsonKey(name: 'user_name')
final String userName;
또한 전체 클래스에 snake_case를 자동 변환해주는 옵션도, 생성된 파일의 설정을 조정하면 됩니다.
대량의 JSON 데이터를 처리할 때는 몇 가지 전략이 있어요. 첫째, compute() 함수나 isolate를 사용해 백그라운드 스레드에서 파싱 작업을 수행하세요. 메인 스레드를 블로킹하지 않아 UI가 버벅거리지 않습니다. 둘째, 페이지네이션을 구현해 한 번에 모든 데이터를 로드하지 않고 필요한 만큼만 가져오세요. 셋째, 파싱된 결과를 캐싱해 동일한 데이터를 반복해서 파싱하지 않도록 하세요. 넷째, JSON 스트리밍 파싱을 고려해보세요. 전체 JSON을 메모리에 로드하지 않고 청크 단위로 처리할 수 있습니다. 이런 방법들을 조합하면 대용량 데이터도 효율적으로 처리할 수 있어요.
일반적으로는 성능 차이가 크지 않아요. 자동 생성되는 코드는 결국 개발자가 수동으로 작성했을 코드와 유사한 구조를 가지기 때문이죠. 다만, 매우 성능에 민감한 부분이라면 수동 파싱이 약간 더 효율적일 수 있어요. 수동으로 작성할 때 필요한 최적화를 정확히 적용할 수 있으니까요. 하지만 대부분의 앱에서는 그 차이가 체감되지 않을 정도로 미미합니다. 오히려 자동 생성 방식이 타입 안전성과 유지보수성 측면에서 얻는 이점이 훨씬 크죠. 실제로 앱 성능에 영향을 미치는 병목은 보통 JSON 파싱 자체보다는 네트워크 지연이나 렌더링 측면에 있는 경우가 많아요.
API에서 날짜/시간은 보통 ISO 8601 형식의 문자열(예: "2023-09-17T14:30:00Z")이나 타임스탬프(예: 1694958600000)로 전달되요. json_serializable이나 freezed를 사용할 때는 @JsonKey 애노테이션으로 커스텀 변환 함수를 지정할 수 있죠:
// ISO 8601 문자열을 DateTime으로 변환
@JsonKey(
fromJson: _dateTimeFromJson,
toJson: _dateTimeToJson,
)
final DateTime createdAt;
// 변환 함수들
static DateTime _dateTimeFromJson(String date) => DateTime.parse(date);
static String _dateTimeToJson(DateTime date) => date.toIso8601String();
타임스탬프의 경우, 밀리초나 초 단위인지 확인하고 적절한 변환 함수를 작성해야 해요. 날짜/시간 처리에 더 다양한 기능이 필요하다면 intl 패키지를 활용하는 것도 좋습니다.
마무리
지금까지 Flutter에서 JSON 파싱과 직렬화에 대해 다양한 방법들을 살펴봤습니다. 솔직히 말하자면, 처음에는 이 부분이 정말 까다롭게 느껴질 수 있어요. 저도 처음에는 무턱대고 모든 걸 수동으로 파싱하다가 한참 고생했거든요... 😅
하지만 이제는 json_serializable이나 freezed 같은 패키지를 활용하면 훨씬 수월하게 작업할 수 있어요. 특히 복잡한 API 연동 프로젝트를 하고 있다면, 처음부터 이런 도구들을 활용하는 습관을 들이는 게 좋을 것 같아요. 코드량도 줄고 실수할 여지도 줄어들거든요.
그리고 꼭 기억했으면 하는 건, 데이터 모델링은 앱의 근간이라는 거예요. 튼튼한 기초가 있어야 그 위에 세워진 건물도 오래가듯이, 탄탄한 데이터 모델이 있어야 앱도 안정적으로 동작하고 유지보수하기도 쉬워진답니다.
'Developer > Flutter' 카테고리의 다른 글
[Flutter 공부] 로컬 데이터 저장: SharedPreferences (0) | 2025.03.15 |
---|---|
[Flutter 공부] Provider와 상태 관리 (0) | 2025.03.15 |
[Flutter 공부] HTTP 통신과 RESTful API 연동 (0) | 2025.03.11 |
[Flutter 공부] 간단한 애니메이션 구현 방법 (0) | 2025.03.11 |
[Flutter 공부하기] 커스텀 위젯 만들기: 재사용 가능한 위젯 설계와 구현 (0) | 2025.03.11 |