Sealed Class로 상태 패턴 사용하기

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과 같은 클래스로 객체를 생성해야 한다. 이때 상속을 받는 자식 클래스에서는, implementsextends를 둘 다 사용할 수 있다.

 

따라서 자식 클래스로 객체를 생성하는 다음 예시는 성공한다.

// 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