JSON과 직렬화

웹서버와 통신하지 않거나 데이터를 저장하지 않는 모바일 앱은 상상이 가지 않습니다. 네트워크에 연결되는 앱을 만드는 경우에, JSON을 사용해야하는 기회가 찾아옵니다.

 

이 가이드는 Flutter에서 JSON을 사용하는 방법을 살펴봅니다. 다양한 시나리오에서 사용하기 위한 JSON 솔루션과 왜 그렇게 하는지에 대해서 설명합니다.

 

용어 :

인코딩과 직렬화 - 데이터 구조를 문자열로 바꾸는 것.

디코딩과 역직렬화 - 반대로, 문자열을 데이터 구조로 바꾸는 것.

하지만, 직렬화는 일반적으로 데이터 구조를 읽기 쉬운 형식으로 변환하는 전체 프로세스를 말합니다.

혼동을 피하기 위해 이 문서는 전체 프로세스를 직렬화라고 하고, 특정 프로세스를 찝어서 언급할 때는 인코딩이나 디코딩이라고 합니다.

 

 

나에게 맞는 JSON 직렬화 방법은 무엇일까?

이 글은 JSON을 다루기 위한 두가지 방법을 다룹니다.

  • 수동 직렬화
  • 코드 생성을 사용한 자동 직렬화

프로젝트마다 상황이 다릅니다. 개념을 확인하기 위한 작은 프로젝트나 프로토타입 프로젝트의 경우에는 코드 생성기를 사용하는게 과할 수 있습니다. JSON모델이 많은 앱은 손으로 인코딩하는 것이 지루하고 반복적이라 많은 에러가 발생할 수 있습니다.

 

 

소규모 프로젝트에는 수동 직렬화를 사용하세요.

수동 JSON 디코딩은 dart:convert의 내장 JSON 디코더를 사용하는 것을 말합니다. JSON문자열을 jsonDecoder() 함수를 사용해서 Map<String, dynamic>을 받아 원하는 값을 찾는 것을 수동 JSON 디코딩이라 합니다. 이는 외부 종속성을 추가하지 않아도 되고 특별히 설정할 것도 없습니다. 빠르게 데모용 프로젝트에 사용하기 좋습니다.

 

프로젝트가 커지면 수동 디코딩이 제대로 작동하지 않습니다. 디코딩 로직을 직접 작성하면 관리하기 어렵고 오류가 발생하기 쉽습니다. 존재하지 않는 JSON 필드에 접근할 때 오타가 있으면 실행중에 코드에서 오류가 발생합니다.

 

프로젝트에 JSON 모델이 별로 없는 경우 수동 직렬화를 사용하는 것이 좋습니다. 수동 인코딩의 예는 dart:convert를 사용한 수동 직렬화를 참고하세요.

 

 

중대형 프로젝트에는 코드생성을 사용하세요.

코드 생성을 사용한 JSON 직렬화는 외부 라이브러리가 인코딩 코드를 자동으로 생성하게 하는 것을 의미합니다. 초기 설정을 하고, 모델 클래스로부터 코드를 만드는 file watcher를 실행합니다. 예를 들어 json_serializablebuilt_value는 외부 라이브러리의 하나입니다.

 

이러한 방법은 큰 프로젝트에 적합합니다. 직렬화를 위한 상용구 코드를 직접 작성하지 않아도 되고, JSON필드에 접근하는 코드에 오타가 있다면 컴파일 할때 발견되어 문제가 생기지 않습니다. 코드 생성의 단점은 초기 설정이 필요하다는 것입니다. 그리고 생성된 소스파일은 프로젝트 탐색기를 보기 힘들게 할 수 있습니다.

 

중간 규모 이상의 프로젝트인 경우 JSON직렬화를 위해 코드 생성을 사용하기 원할 것 입니다. 코드 생성을 통한 JSON 인코딩의 예제는 코드생성 라이브러리를 통한 JSON 직렬화를 참고하세요.

 

 

Flutter에 GSON/Jackson/Moshi와 같은 것이 있나요?

간단히 말하면 없습니다.

 

이런 라이브러리는 runtime reflection을 사용해야합니다. 근데 runtime reflection은 플루터에서 사용되지 않습니다. Runtime reflection은 Dart가 오랬동안 지원해왔던 Tree shaking을 방해합니다. Tree shaking을 사용하면 릴리즈용으로 빌드할 때 사용하지않는 코드를 shake off(흔들어서 떨어뜨리는 의미)할 수 있습니다. 이렇게하면 앱의 크기가 크게 최적화됩니다.

 

reflection은 모든 코드를 암시적으로 사용하기 때문에 tree shaking을 어렵게만듭니다. tree shaking용 툴은 runtime에 사용되지 않는 코드를 알기 힘들어져 제거하기 어려워집니다. 그래서 reflection을 사용하면 앱 크기를 최적하하기 어렵습니다.

 

Flutter에서 runtime reflection을 사용할 수 없지만, 몇몇 라이브러리는 유사한 쉬운 API를 제공합니다. 이는 코드 생성기반입니다. 이 방식은 코드 생성 라이브러리를 다루는 섹션에서 자세히 설명합니다.

 

 

dart:convert를 사용한 수동으로 JSON 직렬화하기

Flutter의 기본 JSON 직렬화는 간단합니다. Flutter에는 간단한 JSON 인코더와 디코더가 포함된 내장 라이브러리 dart:convert 라이브러리가 있습니다.

 

다음 샘플 JSON은 간단한 유저 모델을 구현한 것 입니다.

{
  "name": "John Smith",
  "email": "john@example.com"
}

 

dart:convert를 사용하면, 당신은 JSON모델을 두가지 방법으로 직렬화할 수 있습니다.

 

 

JSON 인라인 직렬화

dart:convert문서를 보면, JSON을 jsonDecode()를 사용해서 디코드할 수 있는 것을 확인할 수 있다. JSON 문자열을 매개변수로 사용한다.

 

Map<String, dynamic> user = jsonDecode(jsonString);

print('Howdy, ${user['name']}!');
print('We sent the verification link to ${user['email']}.');

 

아쉽게도, jsonDecode()Map<String, dynamic>를 반환합니다. 즉, 런타임까지 값의 타입을 알 수 없습니다. 이 방법을 사용하면 정적 타입 언어 기능(타입 안전, 자동완성 그리고 가장 중요한 컴파일 타임 예외)을 대부분 사용할 수 없습니다. 코드에 오류가 생기기 쉬워집니다.

 

예를 들어, 이름이나 이메일 필드에 접근할 때마다 오타가 발생할 수 있습니다. JSON이 맵구조이기 때문에 컴파일러가 발견하지 못하는 오타입니다.

 

 

모델 클래스 내에서 JSON 직렬화하기

유저라는 일반적인 모델 클레스를 사용해 위에서 언급된 문제를 해결해봅시다.

유저 클래스 안에서 다음과 같은 것이 있습니다:

  • 맵 구조로부터 새로운 인스턴스를 만들어내는 User.fromJson() 생성자.
  • 유저 인스턴스를 map으로 바꾸는 toJson()메서드.

 

이 방식을 사용하면, 타입 안전, 필드 자동완성 그리고 컴파일 타임 예외가 가능해집니다. 올바른 문자열이 아니거나, 필드를 정수로 처리하면 런타임에 앱이 중단되는 것이 아니라 컴파일 되지 않습니다.

 

user.dart

class User {
  final String name;
  final String email;

  User(this.name, this.email);

  User.fromJson(Map<String, dynamic> json)
      : name = json['name'],
        email = json['email'];

  Map<String, dynamic> toJson() =>
    {
      'name': name,
      'email': email,
    };
}

 

디코딩 로직의 책임은 이제 모델에게 넘어갔습니다. 이 새로운 방식을 사용하면 유저를 쉽게 디코드할 수 있습니다.

 

Map userMap = jsonDecode(jsonString);
var user = User.fromJson(userMap);

print('Howdy, ${user.name}!');
print('We sent the verification link to ${user.email}.');

 

유저를 인코딩하려면 User객체를 jsonEncode()함수에 전달하세요. jsonEncode()로 작동되므로, toJson()메서드를 호출하지 않아도 됩니다.

 

String json = jsonEncode(user);

 

이 방식을 사용하면 위 메서드를 호출하는 부분의 코드는 JSON 직렬화에 대해 전혀 신경쓸 필요가 없습니다. 하지만 모델 클래스는 신경써야할 것이 남아있습니다. 생성된 앱에서, 직렬화가 올바르게 작동하는지 확인하고 싶을 것 입니다. 실제로 User.fromJson()User.toJson() 메서드가 올바르게 작동하는지 확인하기 위해 유닛테스트가 필요합니다.

 

The cookbook contains a more comprehensive worked example of using JSON model classes, using an isolate to parse the JSON file on a background thread. This approach is ideal if you need your app to remain responsive while the JSON file is being decoded.

 

그러나 실제 시나리오가 항상 간단하지 않습니다. JSON API 결과가 자체 모델클래스를 통해 파싱해야하는 중첩된 JSON 객체 클래스를 포함해 JSON API 결과가 더 복잡한 경우도 있습니다.

 

JSON 인코딩과 디코딩을 처리해주는 무언가가 있다면 좋을 것 입니다. 다행히도 있습니다.

 

 

코드생성 라이브러리를 사용한 JSON 직렬화

사용할 수 있는 여러가지 라이브러리가 있지만, 이 가이드에서는 json_serializable를 사용합니다. JSON 직렬화 boilplate를 만들어 주는 자동 소스코드 생성기 입니다.

 

라이브러리 선택하기: pub.dev에 JSON 직렬화 코드를 생성하기 위한 Flutter Favorite 패키지는 두개가 있습니다. json_serializablebuilt_value. 두 패키지 중에서 어떤것을 골라야 할까요? json_serializable 패키지는 annotation을 사용해 일반 클래스를 직렬화 합니다. built_value 패키지는 immutable value classes를 정의하는 고 수준의 방법을 제공합니다.

 

직렬화 코드가 수동으로 작성되거나 유지관리 되지 않으므로, 런타임에 직렬화 오류가 발생할 위험을 줄입니다.

 

 

프로젝트에서 json_serializable 설정하기

프로젝트에 json_serializable을 추함하려면 하나의 정규 종속성과 두개의 개발 종속성이 필요합니다. 간단히 말해 개발 종속성은 앱 소스코드에 포함되지 않은 개발환경에서만 사용되는 종속성입니다.

 

JSON 직렬화할 수 있는 예제에서 pubspec file파일을 보면 이러한 필수적인 종속성의 최신 버전을 볼 수 있습니다.

 

pubspec.yaml

dependencies:
  # Your other regular dependencies here
  json_annotation: <latest_version>

dev_dependencies:
  # Your other dev_dependencies here
  build_runner: <latest_version>
  json_serializable: <latest_version>

프로젝트 루트 폴더에서 flutter pub get을 터미널에서 입력하거나, 에디터에서 패키지 가져오기를 클릭해 새로운 종속성을 사용할 수 있게 해주세요.

 

 

json_serializable 방식으로 클래스 모델 작성하기

다음은 유저 클래스를 json_serializable 클래스로 변환하는 방법을 보여줍니다. 코드의 단순성을 위해 이전 예제의 단순화된 JSON 모델을 사용합니다.

 

user.dart

import 'package:json_annotation/json_annotation.dart';

/// This allows the `User` class to access private members in
/// the generated file. The value for this is *.g.dart, where
/// the star denotes the source file name.
part 'user.g.dart';

/// An annotation for the code generator to know that this class needs the
/// JSON serialization logic to be generated.
@JsonSerializable()

class User {
  User(this.name, this.email);

  String name;
  String email;

  /// A necessary factory constructor for creating a new User instance
  /// from a map. Pass the map to the generated `_$UserFromJson()` constructor.
  /// The constructor is named after the source class, in this case, User.
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);

  /// `toJson` is the convention for a class to declare support for serialization
  /// to JSON. The implementation simply calls the private, generated
  /// helper method `_$UserToJson`.
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

 

이 설정으로 소스코드 생성기는 JSON에서 이름, 이메일 필드를 인코딩/디코딩하기 위한 코드를 생성합니다.

 

명명법을 바꾸고싶다면 쉽게 설정할 수 있습니다. 예를들어, API가 snake_case를 사용하여 객체를 반환하고 모델에서 lowerCamelCase를 사용하려고 하는 경우 name 매개변수와 함께 @JsonKey 주석을 사용할 수 있습니다.

 

/// Tell json_serializable that "registration_date_millis" should be
/// mapped to this property.
@JsonKey(name: 'registration_date_millis')
final int registrationDateMillis;

 

가장 좋은 방법은 서버와 클라이언트 모두 동일한 명명법을 사용하는 것입니다. @JsonSerializable()은 dart 필드들을 JSON 키로 변환하기 위해 fieldRename enum을 제공합니다.

 

@JsonSerializable(fieldRename: FieldRename.snake)를 수정하는 것은 각 필드에 @JsonKey(name: '')를 추가하는것과 같습니다.

 

때때로 서버 데이터가 불확실하므로 클라이언트의 데이터를 확인하고 보호해야합니다. 일반적으로 사용되는 @Jsonkey annotation은 다음과 같습니다.

/// Tell json_serializable to use "defaultValue" if the JSON doesn't
/// contain this key or if the value is `null`.
@JsonKey(defaultValue: false)
final bool isAdult;

/// When `true` tell json_serializable that JSON must contain the key, 
/// If the key doesn't exist, an exception is thrown.
@JsonKey(required: true)
final String id;

/// When `true` tell json_serializable that generated code should 
/// ignore this field completely. 
@JsonKey(ignore: true)
final String verificationCode;

 

코드 생성 유틸리티 실행하기

json_serializable클래스를 처음 만들면, 아래 이미지에 표시된 것과 유사한 오류가 발생합니다.

 

IDE warning when the generated code for a model class does not exist yet.

 

이런 오류는 정상입니다. 단순히 모델 클래스에 대해 생성된 코드가 없어서 생긴 오류입니다. 이 문제를 해결하려면 직렬화 boilerplate를 작성하는 코드 생성기를 실행하세요.

 

코드 생성기를 실행하는 방법에는 두가지가 있습니다.

 

 

일회성 코드 생성

터미널로 프로젝트 루트에서 flutter pub run build_runner build빌드를 실행하면 필요할 때마다 JSON직렬화 코드를 생성하세요. 이렇게하면 소스파일을 거쳐 관련 파일을 선택하고 필요한 직렬화 코드를 생성하는 일회성 빌드가 작동합니다.

 

이 방법은 편리하지만, 모델클래스를 변경할 때마다 빌드를 수동으로 실행하지 않는 경우에 좋습니다.

 

 

지속적으로 코드 생성하기

watcher는 소스코드 생성 프로세스를 편리하게 만들어 줍니다. 프로젝트 파일의 변경사항을 감시하고 필요한 경우 파일을 자동으로 작성합니다. 프로젝트 루트에서 flutter pub run build_runner watch를 실행해 watcher를 작동시킵니다.

 

watcher를 작동시키고 백그라운드에 두어도 괜찮습니다.

 

json_serializable 모델 사용하기

JSON문자열을 json_serializable 방식으로 디코딩하기 위해서, 기존 코드를 변경할 필요가 없습니다.

 

Map userMap = jsonDecode(jsonString);
var user = User.fromJson(userMap);

 

인코딩도 마찬가지 입니다. 호출 API는 이전과 같습니다.

 

String json = jsonEncode(user);

 

json_serializable을 사용하면 수동 JSON를 직렬화를 잊을 수 있습니다. 소스코드 생성기는 user.g.dart라는 파일을 만듭니다. 이 파일은 직렬화에 필요한 모든 로직을 가지고 있습니다. 직렬화가 잘 작동하는지 확인하기위해 더이상 자동화된 테스트를 작성할 필요가 없습니다. 이제 직렬화가 제대로 작동하는지 확인하는 것은 라이브러리의 책임입니다.

 

 

중첩 클래스를 위한 코드생성하기

클래스 안에 중첩클래스가 있을 수 있습니다. 이런 경우, (파이어베이스와 같은)서비스의 인수로 JSON형식의 클래스를 전달하려 하면, Invalid argument(잘못된 인수) 오류를 경험할 수 있습니다.

 

다음 Address 클래스를 참고하세요:

 

import 'package:json_annotation/json_annotation.dart';
part 'address.g.dart';

@JsonSerializable()
class Address {
  String street;
  String city;

  Address(this.street, this.city);

  factory Address.fromJson(Map<String, dynamic> json) => _$AddressFromJson(json);
  Map<String, dynamic> toJson() => _$AddressToJson(this);
}

 

주소 클래스는 유저 클래스안에 중첩됩니다.

 

import 'address.dart';
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart';

@JsonSerializable()
class User {
  String firstName;
  Address address;

  User(this.firstName, this.address);

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

 

flutter pub run build_runner build를 실행하면 *.g.dart파일이 생성되지만 _$UserToJson() 함수는 다음과 같습니다:

 

(
Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
  'firstName': instance.firstName,
  'address': instance.address,
};

 

괜찮아 보이지만, 유저 객체에서 print()를 사용하는 경우:

 

Address address = Address("My st.", "New York");
User user = User("John", address);
print(user.toJson());

 

 

그 결과는 다음과 같습니다.:

{name: John, address: Instance of 'address'}

 

아마 당신이 원하는 것은 다음일 것 입니다:

{name: John, address: {street: My st., city: New York}}

 

이 작업을 수행하려면, 클래스에 @JsonSerializable() annotation을 통해 explicitToJson: true를 전달하세요.

 

import 'address.dart';
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart';

@JsonSerializable(explicitToJson: true)
class User {
  String firstName;
  Address address;

  User(this.firstName, this.address);

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

 

자세한 정보는, json_annotation 패키지의 JsonSerializable 클래스에서 explicitToJson를 참조하세요.

 

 

추가로 참고할 문서

자세한 내용은 다음 문서를 참고하세요.

출처 : https://flutter.dev/docs/development/data-and-backend/json

 

JSON and serialization

How to use JSON with Flutter.

flutter.dev

 

+ Recent posts