제네릭(Generic) 클래스를 활용하여 Flutter에서 효과적으로 데이터 상태 관리하기

Ximya·2023년 11월 26일
5

서버로부터 비동기 데이터를 호출하여 화면에 보여줄 때 신경 써야 할 부분이 여러 가지 있습니다. 데이터의 상태(state)에 따라 화면에 적절한 UI를 보여주는 것도 그중 하나죠. 일반적으로 데이터 호출의 성공, 실패 그리고 로딩 상태를 고려해야 됩니다.

데이터 상태를 정의하고 관리하기 위한 여러 가지 접근 방법들이 있는데, 관련해서 레딧(reddit)에 흥미로운 글을 하나 발견했습니다.

BLoC 환경에서 데이터의 상태를 정의할 때 어떤 접근 방식이 더 효과적인지에 물어보는 글이었고, 작성자는 두 가지 접근 방식을 소개했습니다.

  • 각 상태에 대한 개별 클래스를 정의
  • Enum(열거형)을 사용하여 여러 상태를 정의

해당 포스팅에 여러 의견이 달렸고, 정답이 정해져 있는 문제가 아니기 때문에 결론을 내릴 수 없지만 각 방식에 대한 장단점은 명확해 보였습니다.

장점을 유지하고 단점을 보완하는 방법은 없을지 고민했고, 이 2가지 방식의 개념을 결합하는 것에서 힌트를 얻었습니다.

그래서 이번 포스팅에서는 레딧에서 언급된 데이터의 상태를 정의하는 2가지 방식에 대한 장단점들을 분석하고, 기존 방식의 장점을 살리고 단점을 보완할 수 있는 새로운 접근 방식을 소개 드려볼까 합니다.

레딧 포스팅에는 BLoC 상태 관리 라이브러리를 전제로 하고 있지만, 본 포스팅에서 다루는 방법은 다른 기타 상태 관리 라이브러리 (Provider, Getx)에서도 적용 가능하니 참고 해주세요.


1. 각 상태에 대한 개별 클래스를 정의

sealed class ProfileState {}  
  
class ProfileFetchedState extends ProfileState {  
  ProfileFetchedState({required this.info});  
  
  final User info;  
}  
  
class ProfileLoadingState extends ProfileState {}  
  
class ProfileFailedState extends ProfileState {}

첫 번째로, 각 상태에 대한 개별 클래스를 정의하는 방법을 분석해 봅시다.
'ProfileState'라는 공통 추상 클래스가 존재하고 각 상태에 대한 개별 클래스가 해당 추상 클래스를 상속하여 데이터 상태를 정의하는 형태입니다. 이 방식은 각 상태의 특징을 명확하게 식별할 수 있는 장점이 있습니다.

return switch(state) {
  ProfileSuccessState() => ProfileCard(),
  ProfileLoadingState() => CircularProgressIndicator(),
  ProfileFailedState() => ErrorIndicator(),
};

또한, 공통 state 추상 클래스를 sealed 클래스로 선언하면 패턴 매칭 구문을 활용하여 클래스 타입 유형을 검사하여 직관적으로 state 상태에 따라 UI를 분기하여 반환할 수 있습니다

그러나 이 방식은 상태를 정의하기 위해 boilerplate 코드가 많아지는 단점이 있습니다. 상태에 대한 각 클래스를 별도로 정의하고 필요 이상으로 많은 코드를 작성해야 하며, 상태의 구조가 다른 클래스와 유사하거나 간단한 경우에도 각 클래스를 일일이 작성해야 하는 불편함이 동반됩니다.


2. enum(열거형)을 사용하여 데이터 상태를 정의

enum ProfileState { fetched, loading, failed }

class Profile {  
  final User? info;  
  final ProfileState state;  
  
  ProfileInfo({required this.userInfo, required this.state});  
}

두 번째 방법은 하나의 클래스 내에서 Enum(열거형)을 활용하여 각 상태를 표현하는 것입니다. 이 방식은 별도의 클래스를 생성하지 않고도 각 상태에 대한 정보를 클래스 내에서 직접 표현할 수 있어 코드의 간결성유연성을 높일 수 있습니다.

boilerplate 코드 없이 간결하게 상태를 정의할 수 있다는 장점이 있지만, 이런 구조는 자칫 null 지옥에 빠질 수 있다는 단점이 있습니다.

예를 들어, 예제로 제공한 Profile 클래스에는 'user' 필드가 nullable로 선언이 되었습니다. 그 이유는 해당 필드의 값이 특정 상태에서는 존재하지 않을 수 있기 때문입니다. 데이터를 로드하거나 가져오는 데 실패한 경우 user 필드에 값이 초기화되지 않겠죠.

Profile data;  
  
switch (data.state) {  
  case ProfileState.fetched:  
    return ProfileCard(data.info!); // ❗null 체크가 필요합니다
  case ProfileState.loading:  
    return CircularProgressIndicator();  
  case ProfileState.eror:  
    return ErrorIndicator();  
}

그러므로 데이터를 정상적으로 불러온 상태에서도 항상 null 체크를 수행해야 하는 번거로움이 발생할 수 있습니다.


3. 제네릭 클래스를 활용한 데이터 상태 정의

앞서 소개한 두 가지 방식은 각각의 장단점을 지니고 있습니다. 그렇다면 이러한 단점을 보완하고 동시에 기존의 장점을 유지하며 데이터 상태를 정의할 수 있는 방법은 없을까요?

이에 대한 해결책으로 공통 제네릭 클래스를 활용하는 방법을 제안합니다. 이 방법은 두 가지 방법의 개념을 짬뽕(?)한 것으로 볼 수 있습니다.

우선, 몇 가지 기본 모듈을 만들어야 하므로 단계별로 설명하겠습니다.

공통 제네릭 클래스

enum DataState {  
  fetched,  
  loading,  
  failed;  
}

sealed class Ds<T> {  
  Ds({required this.state, this.error});  
  
  T? valueOrNull;  
  Object? error;  
  DataState state;  

  
  T get value => valueOrNull!;  
}

먼저 데이터 상태를 나타내는 EnumDs(Data State)라는 제네릭 추상 클래스를 만들어 줍니다. 이 추상 클래스는 데이터 상태(enum)와 실제 데이터 값을 제네릭한 형태로 받아들이며, 에러 정보도 포함합니다. 자세한 내용은 다음과 같습니다.

  • T? valueOrNull: 제네릭 타입 T의 데이터 또는 null을 나타내는 변수입니다. 데이터가 null이 아닌 경우 해당 값을 반환하는 value 메서드가 정의되어 있습니다.

  • Object? error: 에러 정보를 나타내는 변수입니다. 이 변수에는 발생한 에러에 대한 정보가 들어갑니다. null일 수 있습니다.

  • DataState state: 데이터의 상태를 나타내는 변수로, DataState enum의 값 중 하나가 들어갑니다. 데이터가 성공적으로 가져와진 경우 DataState.fetched, 로딩 중일 경우 DataState.loading, 실패한 경우 DataState.failed가 해당됩니다.

  • T get value => valueOrNull!: 데이터가 null이 아닌 경우 해당 값을 반환하는 value 메서드입니다.


제네릭 클래스를 상속 받는 상태 클래스

다음으로 앞서 구현한 Ds<T> 추상 클래스를 상속받아 각각의 데이터 상태를 나타내는 하위 클래스들을 만들어 줍시다.

// 성공적으로 데이터를 가져왔을 때의 데이터 상태 클래스
class Fetched<T> extends Ds<T> {  
  final T data;  
  
  Fetched(this.data) : super(state: DataState.fetched, valueOrNull: data);  
}

// 로딩 중일 때의 데이터 상태 클래스
class Loading<T> extends Ds<T> {  
  Loading() : super(state: DataState.loading);  
}


// 데이터 가져오기 실패했을 때의 데이터 상태 클래스
class Failed<T> extends Ds<T> {  
  final Object error;  
  
  Failed(this.error) : super(state: DataState.failed, error: error);  
}

각 클래스의 생성자에서는 super 키워드를 사용하여 데이터의 상태를 나타내는 state 필드 적합한 enum 값을 전달하여 초기화 시켜주고 있고, valueOrNullerror 상위 클래스의 멤버 변수를 필요에 따라 조건별로 초기화 시켜주고 있습니다.

예를 들면 Fetched<T> 클래스는 데이터를 성공적으로 가져왔을 때의 경우이므로 T 타입의 데이터를 생성자로 전달받아 상위 클래스의 valueOrNull 필드 값을 초기화합니다. 반대로 Loading<T>Failed<T> 클래스의 경우 데이터를 전달받지 않기 때문에 별도의 valueOrNull 필드 초기화 구문이 필요하지 않습니다. 또한 Failed<T> 클래스에서는 Object 타입의 에러 객체를 전달받고 초기화할 수 있도록 설계했습니다.

그런데 코드가 조금 익숙해 보이지 않으신가요?

sealed class ProfileState {}  
  
class ProfileFetchedState extends ProfileState {  
  ProfileFetchedState({required this.info});  
  
  final User info;  
}  
  
class ProfileLoadingState extends ProfileState {}  
  
class ProfileFailedState extends ProfileState {}

눈치가 빠르시다면 앞서 소개한 첫 번째 방식과 완전히 유사한 구조로 구성되어 있음을 캐치하셨을 겁니다. 다른 점이 있다면 기존과 달리 공통 state 클래스가 제네릭 타입으로 선언되어 있고 각 state 클래스에는 상태에 맞는 enum 값이 매핑되어 있다는 것이겠죠.


적용하기

자, 이제 모든 준비가 끝났습니다. 앞서 구현한 모듈을 기반으로 데이터 상태를 정의하고 화면에 데이터를 불러오는 방법을 예제로 확인해 보겠습니다.

  1. 초기 데이터 선언
Ds<Profile> profileInfo = Loading(); // or Loading<Profile>();

프로필 정보를 담을 profileInfo 변수를 Ds<Profile> 타입으로 선언하고 Loading 값을 할당합니다. 이는 초기에 어떤 데이터도 로드되지 않은 상태를 나타냅니다.

참고로 LoadingDs의 하위 클래스이므로 profileInfo 변수에 할당할 수 있습니다.

  1. 데이터 호출
Future<void> fetchData() async {  
  try {  
    profileInfo = Fetched(  
      User(  
        imgUrl: 'https://avatars.githubusercontent.com/u/75591730?v=4',  
        name: 'Ximya',  
        description:  
            'Lorem ipsum dolor sit amet, consectetur adipiscing ...',  
      ),  
    ); 
    log('데이터를 성공적으로 호출하였습니다');  
  } catch (e) {  
    profileInfo = Failed(e);  
    log('데이터 호출에 실패했습니다. ${e}');  
  }  
}

데이터를 호출하는 부분입니다. 성공적으로 데이터를 가져오면, 해당 프로필 정보를 가진 User 객체를 생성하여 Fetched 클래스로 감싸 profileInfo에 할당합니다. 반대로 데이터 호출에 실패하면 Failed 상태로 에러 정보를 저장합니다.

  1. 상태별로 위젯 반환하기
final profile = controller.profileInfo;  
  
return switch (profile) {  
  Fetched() => ProfileCard(profile.value),  
  Failed() => ErrorIndicator(profile.error),  
  Loading() => CircularProgressIndicator(),  
};

패턴 매칭 구문을 활용하여 profile의 실제 타입을 확인하며, 각 상태에 맞게 적절한 위젯을 반환합니다. 데이터를 성공적으로 불러왔을 경우 .value 구문을 사용하여 데이터에 접근하고, 이 값을 ProfileCard 위젯에 전달하여 반환합니다. 실패한 경우에는 .error 구문을 활용하여 에러 데이터에 접근하고, 이 값을 ErrorIndicator 위젯에 전달하여 반환합니다. 그리고 로딩 중일 때는 CircularProgressIndicator를 반환하여 로딩 상태를 보여주도록 설계했습니다.

기존 방식의 장점

  • 패턴 매칭 구문을 사용하여 직관적으로 state 상태에 따라 UI를 분기하여 반환
  • 각 상태의 특징을 쉽게 식별 가능
  • 코드의 간결성과 유연성을 높임

기존 방식의 단점

  • 지나친 boilerplate 코드
  • 번거로운 null 체크

이렇게 구현된 코드는 기존 2가지 방식의 장점은 유지하고 단점을 보완하면서 데이터 상태에 따라 간편하게 동작을 처리할 수 있게 됩니다.


함수형 프로그래밍 스타일로 위젯 분기하기

현재 코드도 패턴 매칭 구문으로 데이터의 상태에 따라 위젯을 직관적으로 반환하고 있지만, 조금 더 함수형 프로그래밍스럽게 코드를 반환하는 방법이 있습니다. 기존 코드에서 두 가지 부분을 추가해 주시면 됩니다.

1. getter 메소드 추가

enum DataState {  
  fetched,  
  loading,  
  failed;  

  // 추가된 코드
  bool get isFetched => this == DataState.fetched;  
  bool get isLoading => this == DataState.loading;  
  bool get isFailed => this == DataState.failed;  
}

DataState Emum에 새로운 getter 메소드를 추가해 줍니다. 이 getter 메소드는 상태가 fetched, loading, failed인지 여부를 확인할 수 있게 됩니다.

2. onState 메소드 추가

sealed class Ds<T> {  
  Ds({required this.state, this.error, this.valueOrNull});  
  
  T? valueOrNull; 
  DataState state;
  T get value => valueOrNull!;  

  // 추가된 코드
  R onState<R>({  
    required R Function(T data) fetched,  
    required R Function(Object error) failed,  
    required R Function() loading,  
  }) {  
    if (state.isFailed) {  
      return failed(error!);  
    } else if (state.isLoading) {  
      return loading();  
    } else {  
      return fetched(valueOrNull as T);  
    }  
  }  
}

그다음 Ds<T> 클래스에 onState라는 새로운 메소드를 추가합니다. 이 고차함수는 제네릭 타입 T에 대한 데이터 상태에 따라 세 가지 함수를 받아와서, 현재 데이터 상태에 따라 적절한 함수를 호출하고 그 결과를 반환합니다.

onState 메소드 적용

final profile = controller.profileInfo; 

return profile.onState(  
  fetched: (value) => ProfileCard(value),  
  failed: (e) => ErrorIndicator(e),  
  loading: () => CircularProgressIndicator(),  
);

이제 onsState 메소드를 사용하여 상태에 따라 위젯을 분기하는 로직을 수행할 수 있습니다. 개인적으로 패턴 매칭 구문보다 조금 더 간결한 것 같네요.


마무리하면서

이번 포스팅에서는 제네릭 클래스를 사용하여 데이터 상태를 정의하고 관리하는 방법에 대해 알아보았습니다. 글 초반부에 언급했듯이 관점에 따라 정답이 달라질 수 있는 문제이기 때문에 제가 소개해 드린 방식이 완벽한 Best Practice가 아닌 점을 말씀드립니다. 그래도 코드의 중복을 줄이고 간결하게 데이터 상태를 정의할 수 있는 부분이 마음에 드네요.

본 포스팅에서 다룬 예제 코드가 궁금하시다면 제 깃허브 레포지터리에서 확인하실 수 있습니다.

읽어주셔서 감사합니다!

profile
https://medium.com/@ximya

0개의 댓글