[Flutter] JsonCodable, 그게 뭔데? 2편

Cody Yun
Flutter Seoul
Published in
20 min readMay 21, 2024

이전 포스팅에서는 Flutter의 JsonCodable 매크로를 활용하여 JSON 데이터를 쉽게 다루는 방법을 살펴봤습니다.

[Flutter] JsonCodable, 그게 뭔데? 1편

Preview 단계인 Dart 매크로를 사용하기 위해 Flutter 채널을 마스터로 변경을 하고, 간단한 카운터 앱에 SharedPreferences와 함께 JsonCodable 매크로를 사용해 데이터를 로컬에 저장하고 불러오도록 구현했습니다.

@JsonCodable()
class Counter {
int _count = 0;
int get count => _count;

Counter();

void increment() => _count++;
}

데이터 클래스에 JsonCodable 매크로를 추가하면, 컴파일 시 자동으로 toJson, fromJson 메서드가 생성되어 JSON 데이터 처리가 훨씬 간편해 집니다. VS Code에서는 JsonCodable 매크로 아래에 생기는 Go to Augmentation 메뉴를 통해 컴파일 후 생성될 코드를 미리 확인할 수 있습니다.

augment library 'package:deepdive_macro/main.dart';

import 'dart:core' as prefix0;

augment class Counter {
external Counter.fromJson(prefix0.Map<prefix0.String, prefix0.Object?> json);
external prefix0.Map<prefix0.String, prefix0.Object?> toJson();
augment Counter.fromJson(prefix0.Map<prefix0.String, prefix0.Object?> json, )
: this._count = json['_count'] as prefix0.int;
augment prefix0.Map<prefix0.String, prefix0.Object?> toJson() {
final json = <prefix0.String, prefix0.Object?>{};
json['_count'] = this._count;
return json;
}
}

build_runner를 통해 코드 생성을 하는 json_serializable과 비슷한 결과를 얻을 수 있지만, JsonCodable 매크로는 컴파일 이전 단계에서 동작하며 더욱 유연하고 강력한 기능을 제공합니다. 이번 포스팅에서는 JsonCodable 매크로의 핵심 원리와 작동 방식을 자세히 알아보겠습니다.

JSON 직렬화와 역직렬화

이번에는 JsonCodable 매크로나 json_serializable과 같은 외부 라이브러리 를 사용하지 않고 Counter 클래스에 toJson과 fromJson 메서드를 직접 구현하여 JSON 직렬화/역직렬화를 처리하는 방법을 알아보겠습니다.

class Counter {
int _count = 0;
int get count => _count;
Counter();
void increment() => _count++;

Map<String, dynamic> toJson() => {'count': _count};
Counter.fromJson(Map<String, dynamic> json) : _count = json['count'];
}

플러터에서 JSON 데이터를 다룰 때 toJson메서드는 객체의 _counter 속성을 'counter' 키로 매핑하여 Map<String, dynamic> 형태로 변환합니다. 반대로 fromJson생성자는 Map<String, dynamic> 형태의 데이터에서 'counter' 키에 해당하는 값을 추출하여 객체의 _counter 속성을 초기화합니다. 이렇게 JSON 직렬화 및 역직렬화를 위해 각 속성에 대한 키와 값 매핑을 일일이 코드로 작성하는건 번거로운 일입니다. dart:mirror 패키지를 활용하면 런타임에 처리할 수 있지만, 다트에서 이러한 리플렉션(또는 Run-Time Type Information) 을 사용하는건 성능 문제로 인해 배포 빌드에서는 사용이 제한됩니다.

이러한 문제를 해결하기 위해 기존에는 json_annotation, build_runner, json_serializable 외부 라이브러리를 활용해 컴파일 이전에 toJson, fromJson 코드를 자동 생성하는 방식이 많은 플러터 프로젝트에서 사용되고 있습니다 . 이러한 방식은 몇 가지 문제가 있기 때문에 JSON 데이터 처리의 효율성을 높이기 위해 JsonCodable 매크로를 도입했습니다. JsonCodable 매크로를 사용하면 JSON 직렬화 및 역직렬화에 필요한 코드를 컴파일 타임에 자동으로 생성해주므로 개발자가 일일이 코드를 작성할 필요가 없습니다.

json_serializable과 JsonCodable 비교

json_serializable로 코드를 생성하려면 fromJson, toJson 메소드를 구현하고, 생성될 코드를 part로 import합니다. fromJson과 toJson 메소드에서는 생성될 함수 _$Counter2FromJson과 _$Counter2ToJson 함수를 호출합니다. JSON 직렬화와 역직렬화하는 코드를 작성할 필요는 없지만, 생성될 코드를 사용하기 위한 위한 코드를 작성해야 하는건 여전히 불편합니다. 생성될 파일을 part로 지정하고, 네이밍 규칙에 맞춰 생성될 함수를 호출하기 때문에 build_runner를 실행하기 전에는 컴파일 오류가 잔뜩 발생합니다.

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

@JsonSerializable()
class Counter2 {
int count = 0;
Counter2();

void increment() => count++;
factory Counter2.fromJson(Map<String, dynamic> json) =>
_$Counter2FromJson(json);
Map<String, dynamic> toJson() => _$Counter2ToJson(this);
}

생성될 함수를 호출하기 위한 코드를 작성하고, build_runner 실행 전 컴파일 오류가 잔뜩 표시되는 문제 외에도 생성될 _$Counter2FromJson이나 _$Counter2ToJson 함수에서 JSON 직렬화와 역직렬화할 프로퍼티에 접근할 수 있어야 하기 때문에 _를 붙인 private 속성은 직렬화, 역직렬화를 할 수 없습니다. 직렬화와 역직렬화를 위해 필드의 접근 속성을 변경한다는건 좋지 못한 방법인데, 외부 함수를 통해 JSON 직렬화와 역직렬화를 처리하기 때문에 대안이 없습니다. (JsonKey 어노테이션을 사용하는 방법이 있긴 하지만 불편한 부분이 존재합니다.)

JsonCodable 매크로는 어떤가요? 생성될 파일을 import 하거나 생성될 함수를 호출하는 코드를 작성할 필요가 없습니다. 그저 JsonCodable 매크로만 추가하면 그만입니다.

@JsonCodable()
class Counter {
int _count = 0;
int get count => _count;
Counter();
void increment() => _count++;
}

JsonSerilizable 어노테이션을 통해 컴파일 이전에 코드를 생성하는것과 JsonCodable 매크로만 지정하면 컴파일 시 기존 코드에 코드를 추가하는 동작 방식은 큰 차이가 있습니다. JsonCodable 매크로는 컴파일 시 자동으로 기존 코드에 새로운 코드가 자동으로 추가되고, 추가된 코드는 private 필드에 접근할 수 있어 json_serializable의 사용 시 생기는 문제를 해결해줍니다.

augment library 'package:deepdive_macro/counter_library.dart';

import 'dart:core' as prefix0;

augment class Counter {
external Counter.fromJson(prefix0.Map<prefix0.String, prefix0.Object?> json);
external prefix0.Map<prefix0.String, prefix0.Object?> toJson();
augment Counter.fromJson(prefix0.Map<prefix0.String, prefix0.Object?> json, )
: this._count = json['_count'] as prefix0.int;
augment prefix0.Map<prefix0.String, prefix0.Object?> toJson() {
final json = <prefix0.String, prefix0.Object?>{};
json['_count'] = this._count;
return json;
}
}

JsonCodable 뜯어보기

JsonCodable 매크로는 ClassDeclarationsMacro와 ClassDefinitionMacro 인터페이스를 구현하고 있습니다. ClassDeclarationsMacro는 선언부를 생성하는 매크로 메소드이고, ClassDefinitionMacro는 구현부를 생성하는 매크로입니다.

macro class JsonCodable
with _Shared, _FromJson, _ToJson
implements ClassDeclarationsMacro, ClassDefinitionMacro {
const JsonCodable();

/// Declares the `fromJson` constructor and `toJson` method, but does not
/// implement them.
@override
Future<void> buildDeclarationsForClass(
ClassDeclaration clazz, MemberDeclarationBuilder builder) async {
final mapStringObject = await _setup(clazz, builder);

await (
_declareFromJson(clazz, builder, mapStringObject),
_declareToJson(clazz, builder, mapStringObject),
).wait;
}

/// Provides the actual definitions of the `fromJson` constructor and `toJson`
/// method, which were declared in the previous phase.
@override
Future<void> buildDefinitionForClass(
ClassDeclaration clazz, TypeDefinitionBuilder builder) async {
final introspectionData =
await _SharedIntrospectionData.build(builder, clazz);

await (
_buildFromJson(clazz, builder, introspectionData),
_buildToJson(clazz, builder, introspectionData),
).wait;
}
}

JsonCodable2 매크로를 직접 만들며 JsonCodable 매크로의 동작 원리를 살펴봅시다. JsonCodable2 매크로를 구현할 json_codable2.dart 파일을 생성합니다. macro의 제약 중 하나는 패키지로 분리되어야 한다는 제약이 존재하기 때문에 파일로 분리 후 패키지로 imort해서 사용하게됩니다. json_codable2.dart 파일에 dart:async와 package:macros/macros.dart 패키지를 추가합니다. JsonCodable2 클래스를 선언하고 macro 키워드를 추가합니다. ClassDeclarationsMacro 인터페이스를 지정하고, buildDeclarationsForClass 메소드를 구현합니다.

// json_codable2.dart
import 'dart:async';
import 'package:macros/macros.dart';


macro class JsonCodable2 implements ClassDeclarationsMacro {
const JsonCodable2();

@override
FutureOr<void> buildDeclarationsForClass(ClassDeclaration clazz, MemberDeclarationBuilder builder) async {
}
}

JsonCodable 클래스와 buildDeclarationsForClass 메소드를 살펴보면, _Shared 믹스인의 _setup 메소드를 호출해 NamedTypeAnnotationCode를 가져옵니다. NamedTypeAnnotation은 Map<String, Object> 타입을 사용하기 위한 코드 정보를 사용하도록 해줍니다. _Shared 믹스인의 _setup 메소드를 JsonCodable에서 사용할 수 있도록 with _Shared로 지정해주고 있지만, _로 시작하는 네이밍으로 인해 외부에서의 사용은 불가합니다.

mixin _Shared {
/// 중략
/// Does some basic validation on [clazz], and shared setup logic.
///
/// Returns a code representation of the [Map<String, Object?>] class.
Future<NamedTypeAnnotationCode> _setup(
ClassDeclaration clazz, MemberDeclarationBuilder builder) async {
if (clazz.typeParameters.isNotEmpty) {
throw DiagnosticException(Diagnostic(DiagnosticMessage(
// TODO: Target the actual type parameter, issue #55611
'Cannot be applied to classes with generic type parameters'),
Severity.error));
}

final (map, string, object) = await (
builder.resolveIdentifier(_dartCore, 'Map'),
builder.resolveIdentifier(_dartCore, 'String'),
builder.resolveIdentifier(_dartCore, 'Object'),
).wait;
return NamedTypeAnnotationCode(name: map, typeArguments: [
NamedTypeAnnotationCode(name: string),
NamedTypeAnnotationCode(name: object).asNullable
]);
}
}

_setup 메소드를 JsonCodable2에 구현하고, buildDeclarationsForClass 메소드에서 호출해 Map<String, Object> 타입 사용을 위한 객체를 생성합니다. JsonCodable 매크로에서는 ClassDeclaration, MemberDeclarationBuilder, NamedTypeAnnotationCode의 객체를 _declareFromJson, _declareToJson 메소드에 전달해 fromJson, toJson 선언부를 생성합니다. fromJson 메소드는 _FromJson 믹스인을 통해 JsonCodable에서 사용되고, toJson 메소드는 _ToJson 믹스인을 통해 JsonCodable에서 사용됩니다.

mixin _FromJson on _Shared {
/// 중략
/// Emits an error [Diagnostic] if there is an existing `fromJson`
/// constructor on [clazz].
///
/// Returns `true` if the check succeeded (there was no `fromJson`) and false
/// if it didn't (a diagnostic was emitted).
Future<bool> _checkNoFromJson(
DeclarationBuilder builder, ClassDeclaration clazz) async {
final constructors = await builder.constructorsOf(clazz);
final fromJson =
constructors.firstWhereOrNull((c) => c.identifier.name == 'fromJson');
if (fromJson != null) {
builder.report(Diagnostic(
DiagnosticMessage(
'Cannot generate a fromJson constructor due to this existing '
'one.',
target: fromJson.asDiagnosticTarget),
Severity.error));
return false;
}
return true;
}
/// Declares a `fromJson` constructor in [clazz], if one does not exist
/// already.
Future<void> _declareFromJson(
ClassDeclaration clazz,
MemberDeclarationBuilder builder,
NamedTypeAnnotationCode mapStringObject) async {
if (!(await _checkNoFromJson(builder, clazz))) return;

builder.declareInType(DeclarationCode.fromParts([
// TODO(language#3580): Remove/replace 'external'?
' external ',
clazz.identifier.name,
'.fromJson(',
mapStringObject,
' json);',
]));
}
}

mixin _ToJson on _Shared {
/// 중략
/// Emits an error [Diagnostic] if there is an existing `toJson` method on
/// [clazz].
///
/// Returns `true` if the check succeeded (there was no `toJson`) and false
/// if it didn't (a diagnostic was emitted).
Future<bool> _checkNoToJson(
DeclarationBuilder builder, ClassDeclaration clazz) async {
final methods = await builder.methodsOf(clazz);
final toJson =
methods.firstWhereOrNull((m) => m.identifier.name == 'toJson');
if (toJson != null) {
builder.report(Diagnostic(
DiagnosticMessage(
'Cannot generate a toJson method due to this existing one.',
target: toJson.asDiagnosticTarget),
Severity.error));
return false;
}
return true;
}
/// Declares a `toJson` method in [clazz], if one does not exist already.
Future<void> _declareToJson(
ClassDeclaration clazz,
MemberDeclarationBuilder builder,
NamedTypeAnnotationCode mapStringObject) async {
if (!(await _checkNoToJson(builder, clazz))) return;
builder.declareInType(DeclarationCode.fromParts([
// TODO(language#3580): Remove/replace 'external'?
' external ',
mapStringObject,
' toJson();',
]));
}
}

_declareFromJson 메서드에서는 _checkNoFromJson 메소드를 호출해 fromJson 메소드가 구현되어 있는지 아닌지 예외처리를 하고, _declareToJson 메서드에서는 _checkNoToJson 메소드를 호출해 toJson 메소드가 구현되어 있는지 아닌지 예외처리를 하고 있습니다. _declareToJson, _declareFromJson, _checkNoFromJson, _checkNoToJson 메소드를 JsonCodable2에 구현합니다. _checkNoFromJson, _checkNoToJson에서 사용하고 있는 firstWhereOrNull 사용을 위해 collection 패키지를 import 합니다. 이제 JsonCodable2 매크로를 Counter 클래스에 지정해 매크로에 의해 생성된 코드를 살펴봅시다.

@JsonCodable2()
class Counter {
int _count = 0;
int get count => _count;
Counter();
void increment() => _count++;
}
augment library 'package:deepdive_macro/main.dart';

import 'dart:core' as prefix0;

augment class Counter {
external Counter.fromJson(prefix0.Map<prefix0.String, prefix0.Object?> json);
external prefix0.Map<prefix0.String, prefix0.Object?> toJson();
}

끝으로

toJson과 fromJson의 구현부는 아직 없지만 비교적 간단한 로직으로 선언부가 만들어지는 과정을 살펴봤습니다. 구현부는 없어 사용할 수 없지만 매크로의 동작 원리를 살펴볼 수 있었습니다. 다음 포스팅에서는 JsonCodable의 ClassDefinitionMacro 인터페이스의 buildDefinitionForClass 메소드 구현을 통해 JSON 직렬화와 역직렬화 코드가 생성되는 핵심적인 로직을 살펴보겠습니다.

언제나 그렇듯 Happy Coding👨‍💻

--

--

Cody Yun
Flutter Seoul

I wanna be a full stack software engineer at the side of user-facing application.