[Flutter] Flutter Freezed 플러그인! Entity Code Generation 은 이거 하나로 끝

서론

Flutter 는 Code Generation 기능이 상당히 많이 활성화되어 있어요. 흔히들 많이 사용하는 json_serializable 라이브러리도 있고 retrofit 및 chopper 라이브러리도 있습니다. 오늘 알려드릴 freezed 또한 데이터 클래스에 편의 기능들을 제공해주는 code generation 라이브러리입니다.

Freezed vs Json Serializable

Code Generation 이라는 말을 들었을때 "Freezed 라이브러리가 이미 흔히 사용되고있는 다른 Code Generation 라이브러리와 도대체 뭐가 다른데 또 Code Generation 라이브러리가 필요하지?" 라는 생각이 들 수 있습니다. Freezed 라이브러리는 데이터 클래스에서 흔히 필요한 기능들을 한번에 제공해주는 라이브러리입니다. 이런 비슷한 계얼에 jsonserializable 이 있는데 jsonserializable 과 혼합해서 freezed 는 copy 기능, toString override, union 클래스 등 필요한 편의성 기능들을 추가로 사용할 수 있게 해줍니다.

Freezed 사용하기

Freezed 를 사용하기 위해서는 아래 디펜던시들을 추가해야합니다.

dependencies:
  freezed_annotation:

dev_dependencies:
  build_runner:
  freezed:
  json_serializable:

jsonserializable 은 추가해도되고 안해도 상관은 없는데 만약에 toJson 기능과 fromJson 기능을 사용하고 싶다면 jsonserializable 을 추가해야합니다.

기본 문법

Freezed 패키지는 annotation 기능을 사용해서 Code Generation 기능을 실행합니다. 일반적으로 클래스에 property 를 선언할때 클래스 내부에 변수들을 미리 선언을 해두고 constructor 로 입력된 변수들을 클래스의 변수들에 입력을 해주게되는데 Freezed 의 경우에는 factory constructor 를 사용하면 클래스 내부에 변수정의를 따로 해줄 필요가 없습니다. 자동으로 mixin 을 통해 code generation 이 되기 때문이죠.

import 'package:freezed_annotation/freezed_annotation.dart';

part 'person.freezed.dart';
part 'person.g.dart';


class Person with _$Person {
  factory Person({
    required int id,
    required String name,
    required int age,
  }) = _Person;

  factory Person.fromJson(Map<String, dynamic> json) => _$PersonFromJson(json);
}

위 코드와 같이 Person 클래스에 id, name, age 값들을 저장하고 싶다면 해당 값들을 Person factory constructor 에 정의를 해주기만 하면됩니다. 추가적으로 json_serializable 의 fromJson 및 toJson 기능을 사용하고 싶으시다면 part 'filename.g.dart 를 추가하시고 fromJson factory 함수만 추가로 제작해주시면 됩니다.

원하는 코드를 작성하고 나서 code generation 을 실행해주시면 되겠습니다

flutter pub run build_runner build

이제 이 짧은 코드가 어떤 수많은 기능들을 제공해주게 되는지 확인해보겠습니다.

컨스트럭터 및 property 자동생성

final person1 = Person(id: 1, name: 'Code Factory', age: 52);

// 1
print(person1.id);

// Code factory
print(person2.name);

// 52
print(person3.age);

위와같이 자동으로 클래스 property 들을 제작해줘서 작성해야 할 코드가 적어집니다.

toString 및 toJson

final person1 = Person(id: 1, name: 'Code Factory', age: 52);

// Person(id: 1, name: Code Factory, age: 52)
print(person1);

// {id: 1, name: Code Factory, age: 52}
print(person1.toJson());

일반적으로 Dart 언어에서 클래스 인스턴스에 toString() 함수를 실행시키면 Instance of {클래스} 이런식으로 유용하지 않은 정보가 리턴됩니다. 이 부분은 toString 메소드를 override 하면서 조금 더 중요한 정보들을 제공해주는 형태로 변경이 가능한데 프로젝트가 커질수록 상당히 귀찮고 관리하기 어려운 작업이 됩니다. 하지만 freezed 를 사용하면 toString() 메소드가 자동으로 override 되어서 debugging 에 상당히 유용합니다.

== 및 hashCode override

freezed 는 == 함수 및 hasCode 함수 또한 자동으로 override 합니다. 글래스 인스턴스를 특별한 override 없이 서로 비교하게되면 메모리 위치를 서로 비교하게 됩니다. 결과적으로 같은 클래스의 인스턴스고 모든 필드가 다 같더라도 비교는 false 가 나오게되죠. 하지만 freezed 를 사용하면 자동으로 클래스의 모든 property 의 조합으로 == 및 hashCode 함수가 override 되어서 상식적인 비교를 진행할 수 있습니다.

final person1 = Person(id: 1, name: 'Code Factory', age: 52);
final person2 = Person(id: 1, name: 'Code Factory', age: 52);

// true
print(person1 == person2);

Assert 하기

class 컨스트럭터를 제작할때 assert 를 통해서 변수 값을 제한하고싶을때가 있습니다. freezed 패키지도 assert 기능을 사용할 수 있도록 annotation 을 따로 제공해주고 있습니다.


class Person with _$Person {
  ('name.length < 5', '이름은 5자 이하만 입력 가능합니다.')
  factory Person({
    required int id,
    required String name,
    required int age,
  }) = _Person;

  factory Person.fromJson(Map<String, dynamic> json) => _$PersonFromJson(json);
}

// 에러 -> 이름은 5자 이하만 입력 가능합니다.
final person1 = Person(id: 1, name: 'Code Factory', age: 52);

Assert 키워드를 통해서 String 값으로 코드를 작성해야 하는게 아쉽긴 하지만 보통 assert 조건은 까다롭게 작성되는 경우가 잘 없기때문에 크게 문제가 될 것 같지는 않습니다. 여러개의 Assert 를 작성하고싶으면 factory 컨스트럭터 위에 계속 추가하시면 됩니다.

커스텀 method 및 getter 작성하기

freezed 패키지로 class 를 생성해도 당연히 원하는 method 또는 getter 를 작성할 수 있습니다. 하지만 class 에 한줄의 internal constructor 를 필수적으로 추가 해줘야합니다.


class Person with _$Person {
  factory Person({
    required int id,
    required String name,
    required int age,
  }) = _Person;

  factory Person.fromJson(Map<String, dynamic> json) => _$PersonFromJson(json);

  // 메소드나 custom getter 작성시 필수로 추가
  Person._();

  get nameLength => name.length;

  void sayHello() {
    print('hello');
  }
}

위와같이 internal constructor 를 하나만 추가해주면 일반적으로 저희가 사용하는대로 custom getter 와 method 를 작성할 수 있습니다.

Copy

freezed 는 기본적으로 class 를 immutable 하게 사용하는걸 목적으로 하기때문에 setter 를 설정하는건 불가능합니다. 하지만 여느 OOP 언어와 같이 일반적으로 copy 메소드를 정의해서 사용하게 되는데 이또한 freezed 는 자동으로 생성을 해줍니다.

final person1 = Person(id: 1, name: 'Code Factory', age: 52);

final person2 = person1.copyWith(id:2);

// Person(id:2, name: Code Factory, age: 52)
print(person2);

Deep Copy

freezed 패키지는 deep copy 기능또한 간단하게 제공합니다. 아래처럼 여러 클래스를 nesting 해보도록 하겠습니다.


class Person with _$Person {
  factory Person({
    required int id,
    required String name,
    required int age,
    required Group group,
  }) = _Person;
}


class Group with _$Group {
  factory Group({
    required int id,
    required String name,
    required School school,
  }) = _Group;
}


class School with _$School {
  factory School({
    required int id,
    required String name,
  }) = _School;
}

final school1 = School(id: 3, name: 'Harvard');
final group1 = Group(id: 2, name: 'Coding Group', school: school1);
final person1 = Person(id: 1, name: 'Code Factory', age: 52, group: group1);

만약에 person1 변수의 school 을 변경하고싶으면 freezed 패키지 없이 copyWith 만 존재할때를 가정했을대 아래와 같은 코드를 작성해야합니다.

final person2 = person1.copyWith(
  group: group1.copyWith(
    school: school1.copyWith(name: 'Stanford'),
  ),
);

// person2.group.school.name 만 Stanford 로 변경
print(person2);

하지만 freezed 의 deep copy 기능을 사용하면 이렇게 복잡한 nesting 을 할 필요가 없습니다.

final person3 = person1.copyWith.group.school(name: 'Stanford');

// person3.group.school.name 만 Stanford 로 변경
print(person3);

훨씬 더 적은 코드를 작성해도 되고 편리하죠? Caching 관련 작업을 할때 상당히 유용한 기능들입니다.

Union

freezed 의 union 기능을 사용해서 간단하게 내부 클래스들을 정의하고 컨스트럭터별로 다른 클래스 인스턴스들을 돌려주는것도 가능합니다.


class Person with _$Person {
  factory Person({
    required int id,
    required String name,
    required int age,
    int? statusCode,
  }) = _Person;

  factory Person.loading({int? statusCode}) = _Loading;

  factory Person.error(String message, {int? statusCode}) = _Error;
}


final person =
    Person(id: 1, name: 'Code Factory', age: 52, statusCode: 200);
final personLoading = Person.loading();
final personError = Person.error('failed to fetch', statusCode: 401);

// 200
print(person.statusCode);


// null 
print(personLoading.statusCode);

// 401 
print(personError.statusCode);

Union 을 사용할때는 모든 컨스트럭터에서 공통으로 제공하는 변수만 직접 가져올 수 있습니다. 각각 특화된 컨스트럭터에서 제공하는 파라미터는 when, maybeWhen, map, maybeMap 등을 사용해 불러올 수 있습니다.

generalizeWhen(Person person) {
  return person.when(
    (id, name, age, statusCode) =>
        print('id: $id name: $name age: $age statusCode: $statusCode'),
    loading: (int? statusCode) => print('loading'),
    error: (String message, int? statusCode) => print('error : $message'),
  );
}

final person =
    Person(id: 1, name: 'Code Factory', age: 52, statusCode: 200);
final personLoading = Person.loading();
final personError = Person.error('failed to fetch', statusCode: 401);

// id: 1 name: Code Factory age: 52 statusCode: 200
generalizeWhen(person);

// loading
generalizeWhen(personLoading);

// error : failed to fetch
generalizeWhen(personError);

maybeWhen, map, maybeMap 사용법은 영상을 참조해주세요!

©Code Factory All Rights Reserved