단순한 코드 생성 도구가 아닌, 모델 설계 철학의 이야기입니다.
Flutter 프로젝트에서 데이터 모델을 정의하는 방법은 다양합니다. 단순히 class를 직접 작성할 수도 있고, json_serializable만 단독으로 사용할 수도 있습니다. 그러나 프로젝트 규모가 커질수록 모델 관리의 복잡도가 함께 증가하며, 이때 Freezed가 실질적인 해결책이 됩니다.
이번 글에서는 실제 프로젝트에 Freezed를 도입한 경험을 바탕으로, 왜 Freezed를 선택했는지 그 이유를 정리합니다.
먼저 일반 클래스로 모델을 작성하는 경우를 보겠습니다.
class User {
final String name;
final int age;
User({required this.name, required this.age});
User copyWith({String? name, int? age}) {
return User(
name: name ?? this.name,
age: age ?? this.age,
);
}
factory User.fromJson(Map<String, dynamic> json) {
return User(name: json['name'], age: json['age']);
}
Map<String, dynamic> toJson() => {'name': name, 'age': age};
bool operator ==(Object other) =>
other is User && other.name == name && other.age == age;
int get hashCode => name.hashCode ^ age.hashCode;
}
필드가 2개뿐인데도 코드가 상당합니다. 필드가 10개가 넘어가면 유지보수가 매우 번거로워집니다.
class User with _$User {
const factory User({
required String name,
required int age,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
코드 생성 명령어 한 번이면 나머지는 자동으로 처리됩니다.
flutter pub run build_runner build
Freezed로 생성된 모델은 기본적으로 불변(immutable)입니다. 한 번 생성된 객체의 값을 외부에서 임의로 변경할 수 없습니다.
final user = User(name: "홍렬", age: 30);
// 컴파일 에러 — 불변 객체이므로 직접 수정 불가
// user.name = "김철수"; // ❌
이는 상태 관리에서 예측 가능성을 높이고, 의도치 않은 사이드 이펙트를 방지합니다. 특히 Riverpod, BLoC 같은 상태 관리 라이브러리와 함께 사용할 때 그 장점이 두드러집니다.
객체가 불변이라면 값을 어떻게 바꿀까요? 바로 copyWith를 사용합니다. 기존 객체를 기반으로 일부 필드만 변경한 새 객체를 생성합니다.
final user = User(name: "홍렬", age: 30);
final updatedUser = user.copyWith(age: 31);
print(user.age); // 30 — 원본 불변
print(updatedUser.age); // 31 — 새 객체
Freezed는 copyWith를 자동으로 생성해줍니다. 필드가 아무리 많아도 별도로 작성할 필요가 없습니다.
서버 API 응답을 모델로 변환하는 작업은 모든 앱에서 반복적으로 필요합니다. Freezed는 json_serializable과 연동하여 fromJson / toJson을 자동 생성합니다.
// 서버 응답 → 모델 변환
final user = User.fromJson(response.data);
// 모델 → JSON 변환
final json = user.toJson();
직접 작성하던 파싱 코드가 사라지고, 필드 추가/수정 시에도 코드 생성만 다시 실행하면 됩니다.
Freezed의 가장 강력한 기능 중 하나입니다. 하나의 타입이 여러 상태를 표현해야 할 때 유용합니다. API 응답 상태나 UI 상태를 모델링할 때 특히 빛을 발합니다.
class ApiState<T> with _$ApiState<T> {
const factory ApiState.loading() = _Loading;
const factory ApiState.success(T data) = _Success;
const factory ApiState.error(String message) = _Error;
}
when으로 각 상태를 명확하게 분기 처리할 수 있습니다.
final state = ApiState.success(user);
state.when(
loading: () => CircularProgressIndicator(),
success: (data) => Text(data.name),
error: (message) => Text('오류: $message'),
);
if-else나 switch로 타입을 직접 체크하는 방식보다 훨씬 안전하고, 컴파일 타임에 모든 케이스 처리를 강제합니다. 빠뜨린 분기가 있으면 빌드 자체가 되지 않기 때문에 런타임 에러를 사전에 방지할 수 있습니다.
| 일반 클래스 | json_serializable | equatable | Freezed | |
|---|---|---|---|---|
| Immutable | 수동 | 수동 | 수동 | ✅ 자동 |
| copyWith | 수동 | 수동 | 수동 | ✅ 자동 |
| fromJson / toJson | 수동 | ✅ 자동 | 수동 | ✅ 자동 |
| == 비교 | 수동 | 수동 | ✅ 자동 | ✅ 자동 |
| toString | 수동 | 수동 | 수동 | ✅ 자동 |
| Union Type | ❌ | ❌ | ❌ | ✅ 자동 |
| 코드 생성 필요 | ❌ | ✅ | ❌ | ✅ |
Freezed가 장점만 있는 것은 아닙니다. 도입 전에 아래 사항을 고려하는 것이 좋습니다.
Freezed는 build_runner를 통한 코드 생성에 의존합니다. 모델을 수정할 때마다 아래 명령어를 실행해야 합니다.
flutter pub run build_runner build --delete-conflicting-outputs
파일이 많아질수록 빌드 시간이 길어질 수 있고, 자동화되지 않은 환경에서는 생성 파일을 실수로 빠뜨리는 경우도 생깁니다. watch 모드를 활용하면 어느 정도 완화할 수 있습니다.
flutter pub run build_runner watch --delete-conflicting-outputs
처음 접하는 개발자에게는 _$User, _User, factory 패턴이 낯설게 느껴질 수 있습니다. 특히 팀에 Flutter 입문자가 많다면 온보딩 비용을 고려해야 합니다.
모델이 2~3개 수준의 프로토타입이나 소규모 프로젝트에서는 일반 클래스로도 충분합니다. Freezed의 초기 설정 비용 대비 실익이 크지 않을 수 있습니다.
.g.dart 파일 관리코드 생성 결과물인 .g.dart, .freezed.dart 파일을 Git에 포함시킬지 여부도 팀 내에서 사전에 합의가 필요합니다. 일반적으로는 포함시키는 것이 CI/CD 환경에서 안정적입니다.
Freezed는 단순한 코드 생성 도구가 아닙니다. 모델의 불변성을 강제하고, 반복적인 보일러플레이트를 제거하며, 팀 전체가 일관된 방식으로 모델을 작성할 수 있게 합니다. Union Type을 활용하면 상태 모델링까지 안전하게 처리할 수 있습니다.
물론 소규모 프로젝트에서는 일반 클래스나 json_serializable만으로도 충분할 수 있습니다. 그러나 API 모델이 20개를 넘고 상태 관리가 복잡해지는 시점부터는 Freezed의 가치가 분명하게 드러납니다.
초기에 구조를 잡아두면 이후 개발 속도와 코드 품질 모두 향상됩니다.