코드 제너레이션(Code Generation)

thumbnail

최근, 한 작은 Flutter 커뮤니티에서, 코드 제너레이션(Code Generation)에 대한 논쟁이 있었습니다. 그래서 이번 글에서는 이 논쟁에서 제시된, 코드 제너레이션을 사용해도 되는지에 대한 갑론을박을 간단히 정리하고, 제가 왜 코드 제너레이션을 쓰기로 결정했는지 작성합니다.

코드 제너레이션이란?

코드 제너레이션(Code Generation), 혹은 코드 젠(Code gen)이나 코드 생성이라고 부르기도 합니다. 말 그대로 코드를 자동으로 생성하는 행위입니다. 그리고 코드를 자동으로 생성해 주는 도구를 Code Generator라고 합니다.

 

코드 제너레이션은 주로 반복되는 구조의 코드를 자동으로 생성할 때 많이 사용됩니다. 그래서 코드의 복잡성과 반복성을 줄여주고, 개발자가 매 반복되는 구조의 코드를 작성하지 않도록 하여 보일러 플레이트 코드를 줄여주기도 합니다.

사용 예시

Flutter에서는 freezed, json_serializable, retrofit, riverpod generator 등이 대표적으로 자주 사용되는 코드 제너레이션입니다.

 

예를 들어서 freezed를 통해서 코드 제너레이션을 해보겠습니다.

 

아래 코드를 봅시다. 간단한 title, price, thumbnail 만 있는 모델 클래스인데, 이 클래스에서 자주 사용되는 메소드를 전부 정의하느라 코드의 양이 너무 길어진 상태입니다.

@immutable
class EventModel {
  final String title;
  final int price;
  final String thumbnail;

  const EventModel({
    required this.title,
    required this.price,
    required this.thumbnail,
  });

  EventModel copyWith({
    String? title,
    int? price,
    String? thumbnail,
  }) {
    return EventModel(
      title: title ?? this.title,
      price: price ?? this.price,
      thumbnail: thumbnail ?? this.thumbnail,
    );
  }

  factory EventModel.fromJson(Map<String, dynamic> json) {
    return EventModel(
      title: json['title'] as String,
      price: json['price'] as int,
      thumbnail: json['thumbnail'] as String,
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'title': title,
      'price': price,
      'thumbnail': thumbnail,
    };
  }

  @override
  String toString() {
    return 'EventModel{title: $title, price: $price, thumbnail: $thumbnail}';
  }

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      (other is EventModel &&
          runtimeType == other.runtimeType &&
          title == other.title &&
          price == other.price &&
          thumbnail == other.thumbnail);

  @override
  int get hashCode => title.hashCode ^ price.hashCode ^ thumbnail.hashCode; 
}

Flutter를 하시면 자주 느끼겠지만, fromJson, toJson은 너무나 자주 사용되는데, 작성하는 건 매우 귀찮죠. copyWithtoString, 혹은 operator== 역시 마찬가지로 매번 모델 클래스를 정의할 때마다 반복적으로 작성하는 코드입니다.

freezed를 사용하면 위 코드를 아래와 같이 줄일 수 있습니다.

part 'event_model.freezed.dart';
part 'event_model.g.dart';

@freezed
class EventModel with _$EventModel {
  const factory EventModel({
    required String title,
    required int price,
    required String thumbnail,
  }) = _EventModel;

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

이렇게 간결하게 작성을 했는데, 사용할 때는 처음에 정의한 fromJson, toJson, toString, copyWith, operator==까지 모두 사용 가능합니다. freezed 라는 코드 제너레이터가 위 메소드를 전부 알아서 생성해주기 때문입니다.

 

코드 제너레이션을 하는 방법은 간단합니다. 터미널에서 다음 명령어를 실행하면 됩니다.

$ flutter pub run build_runner build

코드 제너레이션에 대한 갑론을박

그러나 이렇게만 보면 코드 제너레이션이 좋아 보입니다.

 

그러나 코드 제너레이션을 사용하길 꺼려하는 사람들도 있습니다. 이 글을 보는 당신이 코드 제너레이션의 안티일 수도 있습니다. 그래서 코드 제너레이션을 사용했을 때의 반대파의 의견과 찬성파의 의견을 소개합니다.

 

일단 본격적으로 글을 시작하기 전에, 저는 코드 제너레이션의 큰 팬입니다. 코드 제너레이션을 알게 된 이후로 항상 freezed, retrofit, json_serializable을 대동하고 다닙니다. 그래서 이 글은 코드 제너레이션에 우호적인 방향으로 작성된다는 점을 말씀드립니다.

 

그러나 저 역시도 코드 제너레이션을 사용할 때 불편한 점들이 있었고, 몇몇 제너레이터(특히 Riverpod Generator)는 사용하지 않습니다. 결론부터 말하고서 글을 시작합니다.

반대파의 의견

코드 제너레이션의 모호함 / 코드가 숨겨진다

코드 제너레이터가 생성해 준 코드는, 프로그래머가 의도한 대로 작성된 것이 아니기 때문에 모호합니다. 코드가 코드 베이스에 표현되지 않고, 가려져 있기 때문입니다.

 

특히 일반적으로 코드 제너레이션에 의해서 생성된 파일은 git 같은 코드 베이스 관리 시스템에 올리지 않는 경우가 대부분입니다. 그래서 코드 제너레이션에 익숙하지 않은 사람들은, 정의되지 않은 함수를 막 쓴다는 거부감이 들기도 합니다.

 

그리고 로컬에서도 코드 제너레이션 빌드 이전까지는, 코드가 숨겨집니다. 애초에 빌드 결과물인 *.g.dart 파일이나 *.freezed.dart 파일들은 코드 베이스에 올라가는 파일들이 아니다 보니깐, 내부 구조를 담고 있는 이 파일들이 숨겨지게 됩니다.

 

협업하는 입장에서, 이 함수에서 문제가 발생했는데, 이 함수가 사람이 만든 게 아닌 코드 제너레이션의 결과물이고, 이 결과물이 내 로컬 환경에서 가려져 있다고 한다면, 그리고 이 상황이 계속 반복된다면, 생산성이 오히려 저하되는 결과를 가져올 수 있습니다.

강력한 의존 관계의 형성

코드 제너레이션은 결국 특정한 코드 제너레이터에게 의존적일 수밖에 없습니다.

 

그런데 만약에, 해당 코드 제너레이터의 유지 보수, 지원이 중단된다면 어떨까요?

 

그러한 일이 실제로 Flutter에서 아주 유명한 http 통신 패키지인, dio에서 일어났습니다 [1][2]. 지난 23년 2월, dio의 개발팀은 dio 패키지의 유지 보수가 더 이상 힘들다고, 공식적으로 지원을 중단했습니다. 이 일은 Flutter 개발자들에게 언제든지 자신이 사용하는 오픈소스 생태계가 무너질 수 있다는 충격을 안겨 주었습니다.

 

그리고 이것이 바로 오픈소스 패키지에 강력한 의존성이 발생했을 때의 문제점입니다.

 

어느 날 갑자기 코드 베이스 이곳저곳에 사용되는 retrofit이 지원을 중단한다고 공식 발표를 해버리면 어떨까요? 이것을 전부 거둬내야 할까요? 유쾌한 작업은 아닐 것입니다.

커스터마이징 문제 - 코드 제너레이터가 원하는 대로 작동하지 않는다면?

코드 제너레이터가 원하는 대로 작동하지 않는다면 어떻게 해야 할까요?

 

이는 제가 실제로 최근에 겪었던 일입니다.

 

freezedjson_serializable을 같이 사용해서 모델 클래스를 만드는 도중, fromJsontoJson 메소드에서, 서버와 클라이언트 간 이름 규칙이 달라서 문제가 되었습니다.

 

Flutter는 기본적으로 camelCase를 기본 변수명으로 짓습니다. 그러나 django로 개발된 서버는, 기본적으로 snake_case를 변수명으로 짓습니다.

 

그래서 freezed를 사용할 때, snake_case로 JSON 직렬화/역직렬화를 해달라고 code generator에게 알려야 합니다.

part 'subject_model.freezed.dart';
part 'subject_model.g.dart';

@freezed
class SubjectModel with _$SubjectModel {
  @JsonSerializable(fieldRename: FieldRename.snake)
  const factory SubjectModel({
    required int subjectId,
    required String subjectName,
    required String explanation,
    required DateTime createdTime,
    required DateTime updatedTime,
  }) = _SubjectModel;

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

이는 위와 같이, @JsonSerializable(fieldRename: FieldRename.snake) Annotation을 통해서 구현할 수 있습니다.

_$_SubjectModel _$$_SubjectModelFromJson(Map<String, dynamic> json) =>
    _$_SubjectModel(
      subjectId: json['subject_id'] as int,
      subjectName: json['subject_name'] as String,
      explanation: json['explanation'] as String,
      createdTime: DateTime.parse(json['created_time'] as String),
      updatedTime: DateTime.parse(json['updated_time'] as String),
    );

Map<String, dynamic> _$$_SubjectModelToJson(_$_SubjectModel instance) =>
    <String, dynamic>{
      'subject_id': instance.subjectId,
      'subject_name': instance.subjectName,
      'explanation': instance.explanation,
      'created_time': instance.createdTime.toIso8601String(),
      'updated_time': instance.updatedTime.toIso8601String(),
    };

근데 문제는 뭐냐면, 이렇게 되면 Flutter Lint가 말썽입니다.

Linter
말썽을 일으키는 Linter

왜냐면 freezed가 내부적으로 사용하는 JsonSerializable은 반대로 freezed에 사용될 것이라고 염두를 하지 않았기에, class 앞부분이 아닌 class constructor 앞에 사용되어서 Linter의 경고를 받는 상황입니다.

 

이 린터 경고를 끄는 방법은 // ignore_for_file: invalid_annotation_target 라는 주석을 파일에 추가하는 수밖에 없습니다. 사실 린터 경고를 강제로 끄는 것을 매우 꺼려하는 저에게는 굉장히 찝찝한 상황입니다.

 

이보다 더 큰 문제가 이름이 가끔 원하는 대로 snake 케이스로 변환이 되지 않는 경우도 계속해서 보고되고 있습니다. 그럴 때는 필드명 앞에다가 @JsonKey(name: 'subject_id') 와 같이 변환이 될 키 이름을 작성해야 합니다. 코드 제너레이터를 쓰는 이유가 반복되는 코드를 매번 작성해 주는 귀찮음을 덜기 위해서인데, 이를 하나하나 확인해 주는 것 자체가 또 다른 번거로움을 가져옵니다. 코드 작성뿐만 아니라 디버깅 시에도요.

 

특히 제가 Riverpod Generator를 사용하지 않는 가장 큰 이유이기도 한데, 복잡한 StateNotifier와 StateNotifierProvider를 Code Generation으로 구현하질 못 합니다. 제가 방법을 찾지 못한 것인지, 아니면 그냥 지원을 하지 않는 것인지 모르겠지만, 분명 Code generation만으로 Riverpod을 다루기에는 한계가 있습니다.

 

Code generation을 사용하지 않았을 때:

class RestaurantStateNotifier extends StateNotifier<CursorPagination> {
    final RestaurantRepository repository;

    RestaurantStateNotifier({
        required this.repository,
    }) : super(CursorPagination(meta: meta, data: data)) {
        paginate();
    }

    paginate() async {
        final response = await repository.paginate();

        state = response.data;
    }
}

Code Generation을 시도했을 때:

@Riverpod(keepAlive: true)
class Restaurant extends _$Restaurant {
  final RestaurantRepository repository;

  Restaurant({
    required this.repository,
  }) {
    paginate();
  }

  // 초기 상태는 Loading 상태로
  @override
  CursorPaginationBase build() {
    return CursorPaginationLoading();
  }

  paginate() async {
    final response = await repository.paginate();

    state = response;
  }
}

결론부터 말하면 위 코드는 의도한 대로 작동하지 않았고, 또한 Riverpod의 경우 굳이 Code Generation을 사용할 필요성을 못 느껴서, 아직도 Riverpod은 Code Generation을 사용하지 않고 있습니다.

 

이렇게 코드 제너레이션을 사용하는데 커스터마이징 문제는 가장 큰 걸림돌이 됩니다. 만약에 특정한 행동을 필요로 하는 커스터마이징을 지원하지 않는다면, 코드 제너레이션 자체를 사용할 수 없게 됩니다.

코드의 형태가 익숙지 않다

의외로 협업하는 관점에서 중요한 문제입니다.

 

또한, 코드 제너레이션을 사용하게 되면 코드 제너레이션이 원하는 구조로 코드를 작성해주어야 하기 때문에, 일반적으로 흔히 보게 되는 자연스러운 코드 구조가 망가집니다. 해당 코드 제너레이터를 처음 보는 사람들에게는, 처음 보는 형태(extends _$Restaurant 와 같은)의 코드에 당황할 수밖에 없습니다.

 

특히 이런 구조가 언어 문법 상의 구조도 아니고, 그냥 코드 제너레이터가 “이런 식으로 짜놓아야 해!”라고 강제하는 구조를 외워야 하다 보니깐, 이런 곳에서 생기는 부자연스러움은 어쩔 수 없습니다. 이게 단순히 적응의 문제는 아닌 것 같습니다.

 

특히 협업하는 입장에서는, 협업하는 동료가 코드 제너레이션에 익숙하지 않다면, 큰 걸림돌로 작용합니다. 특히 내 컴퓨터에서는 돌아가는 데 동료 컴퓨터에서는 안 돌아간다와 같은 상황이, 코드 제너레이터 때문에 발생한다고 생각해 보세요. 정말 끔찍한 상황입니다.

매번 빌드해주어야 한다

코드를 정상적으로 사용하려면, 매번 생성 과정을 거쳐야 합니다.

 

코드 제너레이션의 빌드가 끝나기 전까지는, 그냥 빨간 문법상 경고만 계속해서 뜨는 상태입니다. 즉 코드 자체로 완전성을 갖추지 않습니다. 그래서 에디터가 주는 자동 완성 기능을 이용할 수 없는 것도 문제입니다.

 

그리고 보통 코드 제너레이션은 빠르게 끝나긴 하지만, 사용하는 기기의 성능이 안 좋은 경우 코드 제너레이션의 시간 자체가 오래 걸릴 수도 있습니다. 이것도 단점이라면 단점입니다.

너무 편리해서, 개발자들이 코드 제너레이션만 사용한다

겉으로 보기에는 좀 꼰대같은 이야기이지만, 사실 이 문제는 전체 개발 조직의 생산성을 생각했을 때 꽤 진지하게 고민해 볼 만한 문제입니다.

 

이전에 비슷한 논지의 글을 작성한 적이 있습니다. 구글에서 Exception을 어떻게 다루는지 리뷰한 글입니다 [3]. 여기서도 비슷한 사례로, exception을 던지는 게 개발자들 입장에서 너무 편리하다 보니깐, 오류를 해결할 수 있는 여러 가지 수단을 진지하게 고민하지 않고, 그냥 exception throw 함으로써 다른 개발자들에게 책임을 넘겨 버린다는 문제를 겪었다고 합니다. 결국에 이 문제를 심각하게 고민하던 Google은, C++를 사용할 때 아예 Exception을 사용하지 않기로 결정했습니다.

 

또한, ‘토비의 스프링’의 저자 이일민 님 역시 개인 SNS를 통해서 과도한 Annotation의 사용으로 인해 개발자들이 해이해지고, 이로 인해 설계 상의 문제가 발생한다고 지적한 바가 있습니다.

이일민 님의 SNS
이일민 님의 SNS 인용

Annotation이 주는 장점과 편리함은 부정할 수 없지만, 이를 개발자들이 오용하는 것을 경계해야 합니다. 코드 제너레이션 역시 마찬가지입니다.

AI, Extension 등 더 좋은 대안이 많이 생겨났다

그리고 가장 결정적인 문제가 남아 있습니다. 이제 코드 제너레이션을 쓸 이유가 애초에 많이 사라졌습니다.

 

특히 Copilot, ChatGPT 등의 생성형 AI가 등장하고 상용화되기 시작하면서, 위에서 서술한 단점들을 감수하면서까지 코드 제너레이션을 사용할 이유가 남아있지 않습니다.

 

사실 생성형 AI가 코드 제너레이션보다 더 유연하게, 사용자가 원하는 스타일 대로 코드를 알아서 생성을 해주는데, 굳이 이것저것 지켜야 할 게 많고 제약 사항들도 많은 코드 제너레이션을 쓸 이유가 없긴 합니다.

 

굳이 생성형 AI가 아니더라도, 이제는 코드 에디터, IDE의 발달로 코드 제너레이션의 기능을 수행할 수도 있습니다.

 

예를 들어, 위에서 소개된 Flutter freezed 패키지의 경우, VS Code의 Extension 중 하나인 Dart Data Class Generator로도 같은 역할을 수행할 수 있습니다.

Dart Data Class Generator
Dart Data Class Generator

이 패키지는 Dart 언어에서 class 사용에 필요한 operator==, fromJson, toJson, Constructor 등을 자동으로 생성해 주는 패키지입니다.

PersonModel
PersonModel (Generation 이전)

이렇게 class property를 정해주고 클래스 이름에 cmd + .을 누르면

Options
참고로 저 옵션들 대부분은 Dart Data Class Extension을 설치해야 나옵니다

이렇게 여러 가지 옵션들이 뜨는 것을 볼 수 있는데

Generate 이후 자동 완성된 코드

[Generate data class]를 눌러주면, 순식간에 코드들이 자동으로 생성됩니다.

class PersonModel {
  final String firstName;
  final String lastName;
  final int age;
  PersonModel({
    required this.firstName,
    required this.lastName,
    required this.age,
  });

  PersonModel copyWith({
    String? firstName,
    String? lastName,
    int? age,
  }) {
    return PersonModel(
      firstName: firstName ?? this.firstName,
      lastName: lastName ?? this.lastName,
      age: age ?? this.age,
    );
  }

  Map<String, dynamic> toMap() {
    return <String, dynamic>{
      'firstName': firstName,
      'lastName': lastName,
      'age': age,
    };
  }

  factory PersonModel.fromMap(Map<String, dynamic> map) {
    return PersonModel(
      firstName: map['firstName'] as String,
      lastName: map['lastName'] as String,
      age: map['age'] as int,
    );
  }

  String toJson() => json.encode(toMap());

  factory PersonModel.fromJson(String source) => PersonModel.fromMap(json.decode(source) as Map<String, dynamic>);

  @override
  String toString() => 'PersonModel(firstName: $firstName, lastName: $lastName, age: $age)';

  @override
  bool operator ==(covariant PersonModel other) {
    if (identical(this, other)) return true;

    return 
      other.firstName == firstName &&
      other.lastName == lastName &&
      other.age == age;
  }

  @override
  int get hashCode => firstName.hashCode ^ lastName.hashCode ^ age.hashCode;
}

보시면 알겠지만 굉장히 정확하게 생성되는 것을 알 수 있습니다. toString, toJson, operator==, copyWith 등의 일반적으로 자주 쓰이는 함수들이 자동으로 생성이 됩니다.

 

이렇게 생성된 코드는 개발자가 직접 관리 가능한 코드 베이스이므로, 예를 들어 fromJson, toJson의 이름 규칙을 snake_case로 바꾸고 싶다고 하면 직접 바꾸면 됩니다.

 

그리고 코드 제너레이터를 이용할 때 Linter가 말썽을 피울 이유도 없고, 생성 속도도 코드 제너레이션에 비해서 훨씬 빠른 편입니다.

그리고 대부분의 플러터 개발자들에게 익숙한 형태의 코드가 생성됩니다.

 

이처럼 시대가 바뀌면서 IDE, Code editor들의 부가 기능들이 이제는 워낙 강력하게 만들어져 있기 때문에, 굳이 코드 제너레이션을 안 쓰고도 생산성을 극대화시킬 수 있습니다.

찬성파의 의견

그럼에도 불구하고, 코드 제너레이션을 쓰는 것을 고려해야 하는 이유가 있습니다.

중복의 해악 없애기 및 자동화

앤드류 헌트는 그의 저서 실용주의 프로그래머에서, 중복의 해악을 말하면서, 이를 해결하기 위한 한 가지 방법으로 코드 생성기를 제안합니다. 코드 생성기는 또한 자동화 도구의 일환이기도 합니다. 생산성을 끌어올리기 위한 방법으로 코드 생성기를 이용할 수 있다면 최대한 이용하라고 합니다.

 

단순한 Flutter에서의 코드 생성기뿐만 아니라, 데이터베이스 스키마로부터 코드를 생성해 내는 능동적 코드 생성기, 타이핑을 줄여주는 템플릿 형태의 수동적 코드 생성기 등 사용할 수 있는 코드 생성기는 모두 이용하라고 제안합니다.

 

또한 반복되는 구조의 경우, 이를 자동화할 템플릿을 만드는 것 역시 중복의 해악을 없애는 한 가지 방법일 수 있습니다.

코드 베이스의 간결화

코드 베이스는, 최대한 간결하게 유지되어야 합니다.

 

이것이 의미하는 게 코드를 함축적으로 쓰고 이런 것을 의미하는 게 아니라, 자동으로 생성 가능한 것들은 최대한 코드 베이스에서 제외하고, 오로지 논리를 표현하는 코드에만 집중하는 것이, 코드 베이스를 오랫동안 유지하는 비결이라 합니다.

 

일반적으로, copyWith, fromJson, toJson 과 같은 유틸성 메소드들은, 형태가 반복되어서, 읽는 독자가 굳이 읽지 않아도, 무슨 역할을 하는 함수인지 알 수 있습니다.

 

비슷한 예로 retrofit에 의해서 생성되는 http API 통신 관련 메소드들 역시, 보통 로직이 거의 비슷합니다. 달라지는 경우가 거의 없습니다.

이런 경우 차라리 코드 베이스에서 아예 제외시키는 게, 가독성을 더 높이는 방법일 수 있습니다.

코드 제너레이션은 일종의 선언형 프로그래밍이다

코드 제너레이션 역시 일종의 선언형 프로그래밍입니다. 매우 흥미로운 관점이죠?

 

선언형 프로그래밍은, ‘어떻게(how)’보다 ‘무엇을(what)’에 더 집중하는 프로그래밍 패러다임입니다. 선언형 프로그래밍에서는, 어떤 로직을 사용한다는 것에만 관심이 있지, 이 로직을 어떻게 구현해 나가는지는 관심을 가지지 않습니다.

 

로직을 실제로 구성하는 레이어는 밑에 숨겨져 있습니다. 상위 레이어에서는, 이미 로직이 완성이 되었다고 가정하고서, 이 로직을 통해서 무엇을 구현할 지에 집중합니다.

 

이렇게 구현과 사용을 분리함으로써 개발 과정에서의 depth가 분명해지고, 가독성이 더 올라가기에, 선언형 프로그래밍은 현대 프로그래밍 패러다임에서 빠지지 않고 등장하고 있습니다.

 

코드 제너레이션 역시, 일종의 선언형 프로그래밍이라 볼 수 있습니다.

part 'webtoon_repository.g.dart';

@RestApi()
abstract class WebtoonRepository {
  factory WebtoonRepository(Dio dio, {String baseUrl}) = _WebtoonRepository;

  @GET('/today')
  Future<List<WebtoonModel>> getTodayToon();
}
@RestApi()
abstract class RestaurantRatingRepository
    implements IBasePaginationRepository<RatingModel> {
  // baseUrl = http://$ip/restaurant/:rid/rating
  factory RestaurantRatingRepository(Dio dio, {String baseUrl}) =
      _RestaurantRatingRepository;

  @override
  @GET('/')
  @Headers({'accessToken': 'true'})
  Future<CursorPagination<RatingModel>> paginate({
    @Queries() PaginationParams? paginationParams = const PaginationParams(),
  });
}

Retrofit을 사용할 때, http API 통신을 어떻게 구현할 지에 대해서 개발자는 신경 쓰지 않습니다. 단지 이미 http API 통신이 구현되었다고 가정하고, 이 메소드를 가져다 사용해서 상위 비즈니스 로직을 표현하는데 집중합니다.

 

코드 제너레이션은 기본적인 하위 로직을 알아서 생성해 줍니다. 따라서 선언형 프로그래밍 패러다임에 잘 맞아떨어집니다.

 

코드 제너레이션이 일종의 선언형 프로그래밍이라 본다면, 앞서 반대파가 주장했던 코드 제너레이션의 단점들 중 많은 것들을 반박할 수 있게 됩니다.

  1. 코드가 숨겨진다?
    • 선언형 프로그래밍에서는 하위 로직을 신경 쓸 필요가 애초에 없습니다. 코드가 숨겨진다는 것은 명령형 프로그래밍 체계에서나 단점으로 작용하지, 오히려 선언형에서는 하위 로직을 분리하고 숨김으로써 상위 비즈니스 로직만 신경 쓰게 된다는 장점으로 작용합니다.
  2. 코드베이스에서 관리가 안 된다?
    • 마찬가지 이유로, 하위 비즈니스 로직을 굳이 코드 베이스에 올릴 필요가 없다면, 올리지 않는 게 더 장점이 될 수 있습니다.
    • 코드베이스 상에서는 상위 비즈니스 로직에만 집중하고, 자동으로 생성 가능한 하위 비즈니스 로직은 코드 제너레이션을 통해서 필요할 때마다 빌드하는 전략이 선언형 프로그래밍의 의의와 잘 맞아떨어집니다.

구현체와 선언체와의 의존 관계 분리 - 의존성 역전

선언형 프로그래밍의 연장된 관점으로, 의존 관계의 역전(Dependency Inversion)이 일어납니다.

 

위에서 retrofit의 예시를 가지고 왔습니다. 만약에 retrofit을 사용하지 않는다면, dio client 코드를 갖고서 직접 구현을 하게 됩니다.

 

그러나 retrofit을 사용한다면, repository에서는 abstract classDioClient에 의존하고, 동작 자체는 코드 제너레이터에 의해서 생성된 구현체인 _DioClient에 의존함으로써 의존성을 약화시킵니다.

패키지에 의존적이다? - 반박

코드 제너레이션의 반대파의 주장 중에는 패키지에 의존적이라는 단점이 있습니다. 그래서 만약에 오픈소스 패키지가 지원이 중단될 경우에 발생하는 문제가 있을 수 있다는 게 그 입장입니다.

 

여기에 대한 찬성파의 반박이 있습니다.

  1. 단순히 코드 제너레이션 패키지를 가져다 사용한다고 해도, 강력한 의존 관계가 형성된다고 보기는 힘들다
    • 패키지는 단순히 패키지일 뿐이고, 코드 제너레이션은 코드 제너레이션일 뿐입니다. 이를 가져다 사용한다고 해서, 해당 패키지에 강력한 의존 관계가 형성된다고 보기는 어렵습니다.
    • 특히 런타임 의존성개발 의존성의 개념을 분리해서 생각해야 합니다. 개발 의존성이 올라간다고 해서 반드시 런타임 의존성이 올라간다고 확언할 수 없습니다.
  2. 커스터마이징이 불가능해서 동작 방식을 강제시킨다?
    • 코드 제너레이션의 내부 작동 방식을 정확히 이해한다면, 코드 제너레이션에 의존되는 게 아니라 적절한 상황에서 코드 제너레이션을 이용할 수 있게 됩니다.
    • 사실, 많은 코드 제너레이터가 동작 방식을 커스터마이징 가능하게 설계하고 있습니다. 지원하지 않는 커스터마이징의 경우, 그때는 직접 구현체를 작성하면 되는 문제입니다.
  3. 설계 방법의 무력화?
    • 이는 코드 제너레이션 그 자체의 문제가 아니라, 좋은 설계에 코드 제너레이션을 적절히 가져다 쓰는 방법에 대한 문제일 수 있습니다.

또한 패키지가 유지보수를 종료할 수 있다는 것에 대해서, 오히려 Dio는 유명한 오픈소스는 쉽게 망하지 않는다는 반증이 되기도 합니다.

 

지난 23년 2월 Dio는 공식적으로 유지 보수를 중단하였음에도, 여전히 Flutter 개발 시에 Dio는 http 통신에 널리 사용되는 주요한 패키지입니다.

 

오픈소스 생태계에서 유지 보수가 중단되었다는 말은, 마치 한국에서 맥도널드가 철수했다는 말과는 다릅니다. 지원이 중단되었어도 여전히 이전 버전을 사용할 수 있습니다. 이미 해당 오픈소스를 이용해서 잘 프로그램을 만들고 있었다면, 굳이 이를 걷어내서 새롭게 리팩토링할 이유가 없습니다. 지원이 중단된다고 해서 갑자기 해당 소스 코드가 작동이 되지 않는 것도 아니니, 그냥 이용하던 대로 이용하면 됩니다.

 

물론 분명히, 지원이 중단된 패키지는 서서히 시장에서 퇴출될 것입니다. Dio 역시 다른 대체제에 의해서, 서서히 사라질 것입니다. 여전히 이전 버전을 사용할 수 있더라도, 유지 보수가 되지 않으면 버그 픽스, 새로운 기능 추가, 문서화 관리, 신 버전에 대한 호환성 등 여러 문제가 시간이 지남에 따라서 대두되기 시작하고, 그때즈음이면 새로운 기술로 대체될 것입니다.

 

그러나 그전까지는 큰 문제가 없는 한 그냥 사용하던 대로 사용하면 됩니다.

결론

결론적으로, 저는 사용합니다. 여러 가지 이유가 있지만, 일단 코드베이스를 단순하게 가져간다는 점, 구현체와 선언체를 분리해서 사용한다는 점 등 여러 가지 메리트와 생산성의 향상을 느끼고 있는 참이었고, 이는 생성형 AI가 주는 유연함과는 다른 느낌이라고 생각이 듭니다.

 

특히 코드 제너레이션은, 생성형 AI에 비해서 견고하다는 느낌이 듭니다. 생성형 AI는, 매번 생성되는 결과물이 달라집니다. 간단한 로직의 경우 거의 틀리지 않지만, 어쩌다 한 번 틀릴 수 있다는 불안감을 지울 수 없습니다. 그러나 코드 제너레이션은 일단 확실한 생성 로직이 있기 때문에, 개발자가 컨트롤할 수 있다는 확신을 줍니다.

 

또한 코드 베이스의 관리가 쉬워집니다. 예를 들어서, 필드명을 바꾼다고 가정합시다. 그러면 코드 제너레이션의 경우 코드를 재생성해 주면 됩니다.

 

그러나 fromJson, toJson 등이 코드 베이스 상에서 관리되는 경우, String 값을 전부 찾아서 변환해주어야 할 수도 있습니다. 이를 수정하다가 실수할 가능성도 있습니다. 따라서 코드 제너레이션을 일종의 안전장치로 활용하는 편입니다.

 

그러나 코드 제너레이션의 단점도 분명한 바, 단점을 고려하면서 사용합니다.

 

특히 개발자의 해이에 대해서는 저도 어느 정도 공감을 하고 있는 바가 있습니다. 그렇기 때문에 코드 제너레이션을 사용한다고 하더라도, 코드 제너레이션을 사용하지 않고서도 같은 로직을 구현할 수 있도록 충분히 이해하고 사용하려고 합니다. 필요하다면 직접 구현합니다.

 

그리고 가장 결정적으로, 팀 내에서 가장 생산성이 높아지는 방법을 채택해야 합니다. 협업하는 데 코드 제너레이션이 오히려 방해가 되는 상황이라면, 과감히 포기할 줄 알아야 합니다. 물론 이 상황에서도 단순히 직접 코딩하는 것은 말이 안 됩니다. IDE의 부가 기능이든 생성형 AI든, 코드 제너레이터를 대체할 다른 생산성을 높일 자동화 방법을 강구해야 합니다. 그러한 대안 없이 단순히 내치는 건 어리석은 결정이겠죠.

Flutter에서 자주 사용하는 코드 제너레이터

Flutter에서 자주 사용되는 코드 제너레이터를 소개하고 글을 마무리하겠습니다.

 

참고로 여러 코드 제너레이터를 소개할 때, 다소 처음 보거나 생소한 구조의 코드를 보게 될 것입니다. 주의할 것은 이 영역은 이해하는 영역이 아니라, 그냥 외우거나 필요할 때 찾아보는 영역입니다. 코드 제너레이터 만든 사람들이 이런 구조로 코드 작성하면, 나머지는 우리가 생성해 줄게 이렇게 말하는 거라서, 거기에 이유는 없습니다.

Json Serializable - Json 직렬화를 더 쉽게

위에서 freezed를 사용할 때, 내부적으로 Json Serializable이라는 패키지를 사용합니다.

 

Json Serializable은, Dart에서 Json 직렬화/역직렬화를 도와주는 코드 제너레이터입니다.

 

Json Serializable을 이용하기 위해선, 반드시 파일의 맨 위에 part '*.g.dart; 를 작성해주어야 합니다.

part 'rating_model.g.dart';

@JsonSerializable()
class RatingModel {
  final String id;

  final UserModel user;

  final int rating;

  final String content;

  @JsonKey(fromJson: DataUtils.listPathsToUrls)
  final List<String> imgUrls;

  RatingModel({
    required this.id,
    required this.user,
    required this.rating,
    required this.content,
    required this.imgUrls,
  });

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

    Map<String, dynamic> toJson() => _$RatingModelToJson(this);
}

이런 식으로 모델 클래스를 작성하고, fromJson 메소드를 작성하면 됩니다.

 

만약에 직렬화/역직렬화 시에 별도의 이름 규칙을 적용하고 싶다면, @JsonKey Annotation을 통해서 이름 규칙을 설정해 줄 수 있습니다. 자세한 내용은 공식 문서를 참고하세요.

 

참고로 JsonSerializable은 fromJson, toJson 둘 중에 하나만 생성해 줄 수도 있습니다. 원하지 않는 메소드가 있다면 굳이 안 써서 코드의 양을 줄일 수 있습니다. 그리고 JSON 직렬화 / 역직렬화 외에 제공되는 코드는 없습니다.

데이터 모델 클래스에 유용한 freezed

JSON 직렬화, 역직렬화 외에 다양한 함수들을 사용하고 싶다면, freezed를 이용합니다.

 

part *.freezed.dart; 를 선언해야 합니다. 만약에 Json Serializable까지 이용한다면 part *.g.dart;도 선언해서 사용해야 합니다.

 

freezed는 Json Serializable에다가 여러 가지 함수들을 덧붙였다는 장점이 있지만, 형태가 Json Serializable에 비해서는 익숙하지 않을 수 있어서, 취향대로 골라 쓰면 됩니다.

 

다만 freezed는 상속(extends)를 공식적으로 지원하지 않습니다. 어떻게 해서 되는 경우도 있으나, 기본적으로 상속 관계를 이용하는 모델 클래스의 경우 적용이 되는지 잘 확인해 보고 사용해야 합니다. 단, implements 는 가능합니다.

retrofit - RESTful API 통신을 더 쉽게

retrofit은 RESTful API 통신을 더 쉽게 구현하도록 도와줍니다.

 

Flutter에만 있는 라이브러리는 아니고, 원래 Android 계열에서도 유명한 라이브러리라고 합니다.

 

내부적으로 Dio를 이용합니다. 정확히는 생성자 주입 방식으로 Dio 객체를 주입받고, 이를 http 통신에 이용하게 됩니다.

part *.g.dart;를 앞에 선언하고 사용해야 합니다.

import 'package:json_annotation/json_annotation.dart';
import 'package:retrofit/retrofit.dart';
import 'package:dio/dio.dart';

part 'example.g.dart';

@RestApi(baseUrl: "https://5d42a6e2bc64f90014a56ca0.mockapi.io/api/v1/")
abstract class RestClient {
  factory RestClient(Dio dio, {String baseUrl}) = _RestClient;

  @GET("/tasks")
  Future<List<Task>> getTasks();
}

RestClient는 abstract class 로 선언하며 첫 번째 인자로 Dio, 두 번째 인자로 baseUrl을 받게 됩니다.

@RestApi(baseUrl: "https://~")와 같이, Annotation에다가 직접 baseUrl을 표시해 줄 수도 있습니다.

final dio = Dio(); // Provide a dio instance
dio.options.headers["Demo-Header"] = "demo header"; // config your dio headers globally
final client = RestClient(dio); // baseUrl이 기본 지정되어 있기에, dio만 inject 해주면 됨

client.getTasks().then((it) => logger.i(it));

사용할 때는 위와 같이 Dio를 주입해서 사용하면 됩니다.

import 'package:dio/dio.dart' hide Headers;
import 'package:retrofit/retrofit.dart';

part 'restaurant_rating_repository.g.dart';

final restaurantRatingRepositoryProvider =
    Provider.family<RestaurantRatingRepository, String>((ref, id) {
  final dio = ref.watch(dioProvider);

  return RestaurantRatingRepository(dio,
      baseUrl: 'http://$ip/restaurant/$id/rating');
});

@RestApi()
abstract class RestaurantRatingRepository
    implements IBasePaginationRepository<RatingModel> {
  // baseUrl = http://$ip/restaurant/:rid/rating
  factory RestaurantRatingRepository(Dio dio, {String baseUrl}) =
      _RestaurantRatingRepository;

  @override
  @GET('/')
  @Headers({'accessToken': 'true'})
  Future<CursorPagination<RatingModel>> paginate({
    @Queries() PaginationParams? paginationParams = const PaginationParams(),
  });
}

위와 같이 riverpod의 Provider와 같이 사용할 수 있습니다.

 

http Parameter를 전달하려면 @Queries() Annotation을, header를 추가하려면 @Headers Annotation 안에 key : value map pair로 전달하면 된답니다.

 

참고로 Headers annotation은 dio와 retrofit 두 군데에 선언되어 있습니다. 그래서 그냥 사용하게 될 경우 Dart가 어떤 Headers를 의미하는지 몰라서 에러를 일으키게 됩니다.

 

그래서 import 'package:dio/dio.dart' hide Headers; 로, dio의 Headers Annotation을 사용하지 않을 거라고 컴파일러에게 알려야 합니다. 주의하세요.

RiverPod Generator

https://pub.dev/packages/riverpod_generator

 

riverpod_generator | Dart Package

A code generator for Riverpod. This both simplifies the syntax empowers it, such as allowing stateful hot-reload.

pub.dev

솔직히 말해서 riverpod generator는 제가 잘 사용하지 않습니다. 복잡한 StateNotifier를 구성하게 될 때 한계가 있기 때문입니다. 그러나 간단한 Provider를 만들 경우, 유용하게 사용할 수 있을 것 같습니다.

 

특히 riverpod에는 Provider, FutureProvider, StreamProvider 등 다양한 종류의 Provider가 존재하고, 어떤 상황에서 어떤 Provider를 써야 할지 고민할 때가 많습니다. 혹은 family 는 한 개의 인자만 받을 수 있는데 여러 개의 Parameter를 전달해야 한다면, 이를 복잡한 방법으로 해결할 수밖에 없었습니다. 이 상황에서 Code Generation을 이용하면 굉장히 깔끔하게 코드 베이스를 가져갈 수 있습니다.

 

이러한 이유로 riverpod 팀에서도 code generation을 사용할 것을 공식적으로 권장하고 있습니다.

 

기존:

final fetchUserProvider = FutureProvider.autoDispose.family<User, int>((ref, userId) async {
  final json = await http.get('api/user/$userId');
  return User.fromJson(json);
});

Code Generator 사용:

part 'user_provider.g.dart';

@riverpod
Future<User> fetchUser(FetchUserRef ref, {required int userId}) async {
  final json = await http.get('api/user/$userId');
  return User.fromJson(json);
}

Async - Future Provider

Functional

@riverpod
Future<String> example(ExampleRef ref) async {
  return Future.value('foo');
}

Class-Based

@riverpod
class Example extends _$Example {
  @override
  Future<String> build() async {
    return Future.value('foo');
  }

  // Add methods to mutate the state
}

Async - Stream Provider

Functional

@riverpod
Stream<String> example(ExampleRef ref) async* {
  yield 'foo';
}

Class-Based

@riverpod
class Example extends _$Example {
  @override
  Stream<String> build() async* {
    yield 'foo';
  }

  // Add methods to mutate the state
}

autoDispose 끄기

참고로, riverpod에는 autoDispose라는 속성이 있는데, Code generator 사용 시 기본적으로 autoDispose가 붙어서 생성됩니다.

 

그래서 ref.watch, ref.listen 등 Provider를 리스닝하는 함수가 없을 때 자동으로 dispose 됩니다.

만약에 이를 막고 싶다면, @Riverpod(keepAlive: true) 로 Annotation을 붙입니다.

// AutoDispose provider (keepAlive is false by default)
@riverpod
String example1(Example1Ref ref) => 'foo';

// Non autoDispose provider
@Riverpod(keepAlive: true)
String example2(Example2Ref ref) => 'foo';

example1의 경우는 아래와 같은 의미입니다.

final example1Provider = Provider.autoDispose<String>(
    (ref) => 'foo',
);

arguments 전달하기 (family 이용)

Riverpod code generation을 사용하는 가장 큰 이점으로, Provider에 인자 전달이 간편해진다는 것에 있습니다.

 

이전에는 반드시 family modifier를 사용해야 하는데, family의 가장 큰 불편한 점으로는 단일 인자만 전달 가능하다는 한계가 있습니다. 그래서 기존에는 여러 복합적인 인자를 전달하기 위해서는, 별도의 구조체 클래스를 만들어서 전달해야 했습니다.

/// 구조체 클래스
class Parameter {
    final int number1;
    final int number2;

    Parameter({
        required this.number1,
        required this.number2,
    });
}

final familyProvider = Provider.family<int, Parameter>(
    (ref, parameter) => parameter.number1 + parameter.number2,
);

그러나 code generator를 이용하게 되면 아래와 같이 굉장히 깔끔하게 코드를 가져갈 수 있습니다.

@riverpod
int familyProvider(FamilyProviderRef ref, {
    required int number1,
    required int number2,
}) {
    return number1 + number2;
}

복수 arguments 전달은 아래와 같이 functional, class-based 둘 다 지원합니다.

Functional

@riverpod
String example(
  ExampleRef ref,
  int param1, {
  String param2 = 'foo',
}) {
  return 'Hello $param1 & param2';
}

Class-Based

@riverpod
class Example extends _$Example {
  @override
  String build(
    int param1, {
    String param2 = 'foo',
  }) {
    return 'Hello $param1 & param2';
  }

  // Add methods to mutate the state
}

Flutter에서 Code Generator 이용하기

Code Generator 사용하기

셋업

flutter pub add --dev build_runner

build_runner를 dev dependency에 추가합니다.

 

그리고 *.g.dart 파일이나 *.freezed.dart 파일들이 Linter에 포함되지 않도록, analysis_options.yaml 파일에 다음과 같이 추가해 줍니다.

# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.

# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
  exclude:
    - lib/**.g.dart
        - lib/**.freezed.dart

혹은 이렇게 추가해도 됩니다:

analyzer:
  errors:
    invalid_annotation_target: ignore

빌드

Code generator를 실행하려면 다음 명령어를 입력합니다.

$ flutter pub run build_runner build

하지만 어느 시점부터 위 명령어는 depreciated 되었다는 경고가 뜹니다. 신경 쓰인다면 다음 명령어를 쓰면 됩니다(완전히 동일한 명령어입니다).

$ dart pub run build_runner build

watch

build 는 한 가지 단점이 있는데, Annotation이 붙어있는 대상 파일이 바뀌게 되면 새롭게 build 해주어야 하는 번거로움이 있습니다.

사실 freezed나 retrofit 같은 Annotation이 붙어있는 코드(파일)들은 한 번 작성해 놓으면 거의 수정하는 경우가 드물긴 합니다. 원래 모델 클래스나 REST 통신 코드는 생각보다 많이 안 바뀝니다.

 

그래도 만약에 코드가 변경될 때마다 자동으로 build를 실행해 주려면, flutter pub run build_runner build 대신 flutter pub run build_runner watch를 입력합니다. build runner가 daemon으로 실행되면서, Annotation이 붙어있는 파일이 변경될 때마다 새롭게 빌드를 실행합니다.

$ flutter pub run build_runner watch

vs code setting 팁

Code Generation을 하게 되면, 한 가지 단점이 여러 *.g.dart 파일이나 *.freezed.dart 파일이 생성되면서, 파일을 찾기가 굉장히 어려워집니다.

Code Generation 문제점 - 매우 많은 생성 파일들
이런 생성 파일들이 거슬릴 때가 정말 많습니다

이를 해결하는 방법이 하나 있습니다.

{
    "explorer.fileNesting.enabled": true,
    "explorer.fileNesting.patterns": {
        "pubspec.yaml": "pubspec.lock,pubspec_overrides.yaml,.packages,.flutter-plugins,.flutter-plugins-dependencies,.metadata",
        "*.dart": "${capture}.g.dart,${capture}.freezed.dart"
    }
}

Flutter project의 .vscode/settings.json 파일에 다음과 같이 설정합니다.

 

File Nesting 기능입니다.

네스팅 기능 후
File Nesting 기능

그럼 이렇게, 생성 파일들이 전부 Nested 되어서 숨겨집니다.

File Nesting 기능
File Nesting 기능

필요할 경우 이렇게 밑으로 내려서 찾을 수 있습니다.

 

어차피 freezed.dart, g.dart 파일 같은 생성 파일들은 거의 볼 일이 없기에, 이렇게 Nesting 기능을 활용하면 코드 에디터에서 불필요한 파일들을 숨길 수 있습니다. 제가 항상 애용하는 기능입니다.

 

제가 Android Studio를 안 써서 이 글에는 없기는 한데, Android Studio도 File Nesting 기능을 지원합니다. 없으면 정말 불편하니, 꼭 키고 사용하세요.

📚 참고 자료

그 외 플러터 오픈채팅 방에서 있던 다수의 의견을 포함하고 있습니다.