Sealed Class로 상태 패턴 사용하기
Sealed Class란?
Dart 3.0 버전부터 sealed
란 키워드가 class modifier로 새롭게 추가되었다. sealed
클래스는 enum의 확장판으로, class를 enum처럼 사용할 수 있게 해준다.
공식 문서에서 sealed
클래스의 용법을 찾아보면 다음과 같다.
sealed class Vehicle {}
class Car extends Vehicle {}
class Truck implements Vehicle {}
class Bicycle extends Vehicle {}
// ERROR: Cannot be instantiated
Vehicle myVehicle = Vehicle();
// Subclasses can be instantiated
Vehicle myCar = Car();
String getVehicleSound(Vehicle vehicle) {
// ERROR: The switch is missing the Bicycle subtype or a default case.
return switch (vehicle) {
Car() => 'vroom',
Truck() => 'VROOOOMM',
};
}
위 예제는 sealed
클래스에 대해서 많은 내용을 담고 있다. sealed
클래스는, 기본적으로 abstract
클래스이다. 그래서 다음 줄은 컴파일링에 실패한다.
// ERROR: Cannot be instantiated
Vehicle myVehicle = Vehicle();
Vehicle은 추상 클래스이기에, 무조건 Vehicle을 상속받는 클래스, 이를테면 Car, Truck, Bicycle과 같은 클래스로 객체를 생성해야 한다. 이때 상속을 받는 자식 클래스에서는, implements
와 extends
를 둘 다 사용할 수 있다.
따라서 자식 클래스로 객체를 생성하는 다음 예시는 성공한다.
// Subclasses can be instantiated
Vehicle myCar = Car();
Sealed Class의 장점은, switch 문을 간결하게 가져갈 수 있는 점도 장점이다. 여기서 오류를 찾아줄 수 있는 추가 기능도 제공한다.
다음 예시를 보자.
String getVehicleSound(Vehicle vehicle) {
// ERROR: The switch is missing the Bicycle subtype or a default case.
return switch (vehicle) {
Car() => 'vroom',
Truck() => 'VROOOOMM',
};
}
위 예시는 컴파일에 실패한다. Bicycle에 대해서는 리턴값이 정의되어 있지 않기 때문이다.
그러나 다음과 같이 사라진 Bicylce에 대해서 case를 정의해주면, 에러가 사라진다.
String getVehicleSound(Vehicle vehicle) {
return switch (vehicle) {
Car() => 'vroom',
Truck() => 'VROOOOMM',
Bicycle() => 'ring ring', // 정의되지 않은 case를 추가
};
}
만약에 위 예시를 sealed
클래스 없이 사용했다면, if-else 구문을 사용해서 다음과 같이 작성해야 했을 것이다.
String getVehicleSound(Vehicle vehicle) {
if (vehicle is Car) {
return 'vroom';
} else if (vehicle is Truck) {
return 'VROOOOMM';
} else if (vehicle is Bicycle) {
return 'ring ring';
} else {
return 'unknown'; // sealed class가 없다면, 무조건 default else를 정의해야 함
}
}
sealed class가 있다면 모든 subclass들을 알 수 있다. 그러나 일반 abstract class의 경우 어디에서 새롭게 subclass가 만들어지고 있는 지 알 수 없어서, 무조건 default value를 지정해주어야 한다.
이때 중요한 것은, sealed
클래스는 다른 dart 파일에서는 상속받지 못한다. 무조건 하나의 dart 파일 내에서만 상속받아야 한다.
// example/base_vehicle.dart
sealed class Vehicle {}
class Car extends Vehicle {} // ok
// truck.dart
import 'example/base_vehicle.dart';
// Error: The class 'Vehicle' can't be extended, implemented, or
// mixed in outside of its library because it's a sealed class.
class Truck extends Vehicle {}
Sealed class로 상태 관리하기
sealed class는 비교적 최근(올해 3월)에 등장한 탓에, 아직까지 Dart 세계의 많은 곳에서 정립되어 사용되고 있지는 않다. 그래도 sealed class를 유용하게 사용할 수 있는 부분 하나는 어느 정도 인정을 받고 사용되고 있는데, 바로 상태(State)를 나타내는 객체를 관리할 때이다.
상태 패턴은 이미 Flutter 카테고리 내 많은 글에서 사용했는데, 여기에 sealed class를 지정해서 사용할 수 있다.
예시: UserState 모델 구현
// 주의: import 문들은 모두 생략함
part 'user_model.freezed.dart';
part 'user_model.g.dart';
sealed class UserState {}
class UserError extends UserState {
final String message;
UserError({
required this.message,
});
}
class UserLoading extends UserState {}
@freezed
class UserModel extends UserState with _$UserModel {
const factory UserModel({
required String id,
required String username,
required String imageUrl,
}) = _UserModel;
factory UserModel.fromJson(Map<String, dynamic> json) =>
_$UserModelFromJson(json);
}
단순히 abstract
키워드에서 sealed
클래스로 바꾼 것인데, 다음 리팩토링이 가능하다.
final user = ref.read(userMeProvider);
if (user == null) {
// return ...
}
if (user is UserModel) {
// return ...
}
if (user is UserError) {
// return ...
}
return null; // default => null 반환
위 코드를 다음과 같이 작성할 수 있다:
final userState = ref.read(userMeProvider);
return switch (user) {
UserError() => logginIn ? null : '/login',
UserModel() => switch (logginIn || goState.fullPath == '/splash') {
true => '/',
false => null,
},
null => logginIn ? null : '/login', // user == null
_ => null, // default => null 반환
};
if - else 문에서 생략한 부분을 추가했기 떄문에, 생소하게 느껴질 수 있으나, 결국 위에서 봤던 switch 문과 정확히 동일하다.
무엇이 더 보기 편안한가? 취향의 영역이지만 새로 나온 방식 역시 충분히 적용할만 하다.
그 외 use case
Flutter에서 보통 한 모델에 대해 다음 세 가지 상태는 정의하게 된다.
LoadingState
SuccessState
ErrorState
이 외에도 예를 들어 Data의 상태를 나타내는 데 사용할 수도 있다.
TextData
ImageData
VideoData
Freezed와 함께 사용하기
이번에 글을 쓰려고 준비하다가 알게 된 건데, freezed와도 함께 사용할 수 있다.
part 'home_state.freezed.dart';
@freezed
sealed class HomeState with _$HomeState {
const factory HomeState.loading() = LoadingState;
const factory HomeState.loaded(String data) = LoadedState;
const factory HomeState.error(String message) = ErrorState;
}
이런 식으로 factory constructor를 이용해서, 각각의 loading, loaded (success), error 상태를 정의하자.
loaded에는 data, error에는 error message(여기서는 둘 다 String
타입)가 들어있다.
final homeStateProvider = StateNotifierProvider<HomeStateNotifier, HomeState>(
(ref) => HomeStateNotifier(),
);
class HomeStateNotifier extends StateNotifier<HomeState> {
HomeStateNotifier() : super(const HomeState.loading()) {
loadData();
}
void loadData() async {
state = const HomeState.loading();
try {
await Future.delayed(const Duration(seconds: 2));
// 여기에 data fetch logic 작성
state = const HomeState.loaded('Hello World');
} catch (e) {
state = HomeState.error(e.toString());
}
}
}
Provider는 다음과 같이 작성할 수 있다. loadData
부분을 보면, HomeState.loading()
과 같이 사용 방법을 확인할 수 있다.
String homeStateToString(HomeState state) {
return switch (state) {
LoadingState() => "we are loading",
LoadedState() => "we are loaded",
ErrorState() => "we are error"
};
}
이렇게 사용 가능하다.
📚 References
- Class modifiers, dart.dev
- Sealed Classes in Dart: Unlocking Powerful Features, Ali Ammar