ABOUT ME

남들보다 한삽 더뜨는 개발자 Email: niketjssun31@gmail.com Tel: 010.9565.3242

Today
Yesterday
Total
  • [Flutter] '상태'관리는 어떻게 해야할까요? feat. sealed class
    Flutter/study 2024. 1. 24. 12:37
    퍼블리싱만 하던 단계에선 상태관리가 무엇인지 신경 쓰지 않고 setState()를 남발하면서 만들었습니다. 하지만 api와 연동할 때쯤에 프로젝트가 난잡해져서 결국 눈물을 머금고 setState()를 걷어내다가 모든 UI를 갈아엎었던 뼈아픈 경험을 했죠. 이번에는 상태관리의 필요성을 몸으로 느꼈던 그때를 회상하며 글을 남겨보도록 하겠습니다.

     
    모든 개발자들에게 클라이언트단이나 프론트를 처음 접하고 가장 재미를 느꼈던 부분을 하나 고르라고 한다면 인터페이스를 통해 실제로 조작되는 순간을 많이 꼽을 것 같습니다. 픽셀로 이루어진 화면이 내가 만져서 작동을 하는 모습을 보면 그만큼 신기한 게 없기때문이죠.
     
    이처럼 간략하게 말하자면 상태관리는 사용자와 상호작용을 통해 데이터가 변경되는 과정을 관리하는 것을 의미합니다.
    예시와 함께 자세히 알아볼까요?
     

     
    메뉴를 장바구니에 추가할 때, 버튼을 통해 유저가 직접 수량을 조절하는 기능이 있다고 생각해봅시다.

    • 수량 조절 기능
      1. '+, -' 버튼을 클릭하면 수량이 1씩 증가하고 감소한다.
      2. 유저에게 현재 수량을 보여준다.

    클릭을 하는 행위는 사용자와의 상호작용을 의미하고 그로 인해 변경된 수량은 '상태'라고 볼 수 있습니다.
     
     

    ✓ StatefulWidget의 단점

    기본적으로 상태를 변경하기 위해선 보통 StatefulWidget을 먼저 접하게 됩니다. 이름부터 '상태저장'이라는 의미가 담겨있는 것처럼 상태를 변경할 수 있는 setState() 함수가 있고 바뀐 상태는 위젯에 즉각적인 반영이 가능합니다.
    하지만 statefulWidget을 잘못 사용하면 다음과 같은 문제점이 발생 할수도 있습니다.
     
     

    📌 rebuild

    StatefulWidget lifeCycle

     
    StatefulWidget의 생명주기를 보면 상태를 변화시키는 setState()를 호출하면 dirty 상태로 갔다가 사용자의 인터페이스를 그리는 build()를 호출하는 것을 볼 수 있습니다.
    수량을 1에서 2로 변경하면 '2'가 포함된 새로운 화면을 반환해야 한다는 것은 너무나 당연합니다.

    문제는 해당 위젯이 속해는 트리에 있는 모든 위젯들이 다시 빌드된다는 것에 있습니다.

     

    어떤 위젯이 setState()를 통해 rebuild가 되었을때, subTree의 위젯들 및 상태를 공유하는 위젯, 그 자식 위젯들까지 리렌더링된다.

     
    트리가 깊어질수록 다시 빌드되는 위젯이 많아지므로 직접적인 앱의 성능 저하로 이어집니다.
    그렇기 때문에 얕은 트리에 속하는 위젯이 아니면 가급적 setState()를 쓰지 않는 방법으로 개발을 진행하는 게 좋습니다.
     

    ✓ State란?

    "whatever data you need in order to rebuild your UI at any moment in time"

     
    flutter 공식문서에 따르면 상태(State)는 "언제든지 UI를 재구축하기 위해 필요한 모든 데이터"라고 말하고 있습니다.
    처음에 예시로 들었던 '수량(데이터)'도 상태라고 할 수 있습니다. StatefulWidget은 위젯 내에서 state를 변경할 때 사용하는 것이지만 상태가 꼭 하나의 위젯에 종속되는 상황만 존재하진 않습니다.
     

     

    장바구니에 메뉴를 담으면 장바구니 아이콘 위에 배지 형태로 담겨있는 메뉴 항목의 개수를 알려주는 모습에 집중해 봅시다.

     
    '가게 상세화면'에 있는 장바구니 아이콘 배지의 숫자와 '장바구니 화면'에 있는 메뉴 항목의 수는 항상 같은 걸 볼 수 있습니다.
    다시 말하면 위젯은 다르지만 장바구니에 담긴 메뉴 항목의 개수, 즉 state를 공유하고 있다는 것입니다.
    공식문서에서는 이렇게 하나의 위젯 내부에서 관리하는 상태와 여러 위젯에서 공유되는 상태를 분리해서 정의하고 있습니다.
     
     

    📌 Ephemeral state(임시 상태)

    • 단일 위젯에 깔끔하게 포함할 수 있는 상태 (UI 상태 또는 로컬 상태라고도 한다)
    • 일시적이며 특정 컴포넌트나 화면에 국한된 상태
    • ex. 물품의 수량을 변경하고 변경된 데이터를 유저에게 보여줌

    위젯 트리의 다른 부분은 이런 종류의 상태에 액세스 할 필요가 거의 없습니다. 따라서 직렬화할 필요도 없고 복잡한 방식으로 변경되지도 않습니다. 사용자가 해당 위젯을 벗어나도 이 상태를 유지할 필요가 없으며 해당 위젯이나 화면이 활성화되어있을 때만 유효합니다.
    이런 종류의 상태는 상태관리 패키지(ex. Riverpod, Bloc, GetX 등)를 사용할 필요가 없습니다.
     
     

    📌 App state(앱 상태)

    • 일시적이지 않고 앱의 여러 위젯에서 공유하는 등 사용자 세션 간에 유지하려는 상태
    • 사용자와의 상호작용, 앱의 전반적인 동작에 영향을 미치는 상태
    • ex. 장바구니 안의 메뉴 항목 수량 상태

    앱 전체에서 공유되며 실행 중인 동안 지속되고 앱이 종료되거나 다시 시작할 때까지 유지되며 주로 앱의 라우팅, 사용자 인증 상태, 네트워크 연결 상태 등과 같이 앱 전반에 걸친 정보를 포함합니다.
    Riverpod, Bloc, GetX 등의 상태관리 툴을 쓰는 이유가 바로 App State를 관리하기 위함이라고 할 수 있습니다.
     

    state의 종류를 나누는 기준, 출처: flutter 공식문서

     
     

    ✓ 네트워크 연결 상태관리

     

    success, loading, error state

     
    네트워크 연결 상태는 controller에서 요구하는 상태의 종류에 따라 가짓수가 달라지지만 기본적으로 '데이터 로딩 성공 상태', '데이터 로딩 중 상태', '에러 상태'로 분류할 수 있습니다.
     
    네트워크 연결 상태는 다음 두 가지 특징을 가집니다.

    • 불변성
      • 네트워크 연결 상태는 개발자가 먼저 정의해 놓기 때문에 상태가 런타임에 추가되거나 변질될 일이 없다.
      • 이처럼 불변성의 특성을 가지기 때문에 enum화 해서 관리하면 개발자가 정한 바운더리 안에서 관리가 가능하다.
    • 데이터 포함
      • 데이터가 성공적으로 불러와졌다면 위젯에 반영할 데이터, 즉 model도 success 상태에 포함되어서 컨트롤러에 전해져야 한다.(에러 상태의 경우도 마찬가지다)

     
    📌 Sealed Class with @freezed

    Legacy: Dart3.0부터 Sealed 클래스에서 패턴매칭 기능이 내장되어 있기 때문에 현재 권장되는 방법은 아닙니다.
    현재는 freezed가 필요 없으며 패턴 매칭 시 when()이 아닌 map()을 사용하는 것을 권장합니다.

     
    sealed class와 @freezed를 함께 사용하면 클래스를 Union type처럼 사용이 가능합니다.
    freezed 공식문서를 참고하면 다음과 같이 사용할 수 있다고 설명하고 있습니다.

    @freezed
    sealed class Union with _$Union {
      const factory Union.data(int value) = Data; // success
      const factory Union.loading() = Loading;
      const factory Union.error([String? message]) = Error;
    }

     
     
    예제에도 있다시피 네트워크 통신 상태를 정의할 때 사용하기 좋습니다.
    저는 가게 상세페이지에 대한 상태를 정의할때 다음과 같이 작성했습니다.

    @freezed
    sealed class StoreDetail with _$StoreDetail {
      const factory StoreDetail.data([StoreDetailModel? model]) = StoreDetailData;
      const factory StoreDetail.loading() = StoreDetailLoading;
      const factory StoreDetail.error(ErrorMapper errorMapper) = StoreDetailError;
    }

     
    이 3가지 상태는 컨트롤러에 반영되며 컨트롤러의 상태를 watch 하고 있는 screen에서 상태에 따라 view가 변경됩니다.
     
    시나리오를 정리하자면

    1. api 통신이 일어난다.
    2. 서버에서 반환값을 받는다(Data or Error)
    3. 반환 값에 따라 service(비즈니스 로직)에서 해당되는 초기 상태를 컨트롤러에 전달한다.
    4. 컨트롤러는 현재 상태를 service에서 받은 상태로 업데이트한다.
    5. 컨트롤러의 상태를 watch 하고 있던 Screen에서 상태에 따라 view나 widget을 변경한다.

    그럼 Screen에서 어떻게 상태에따라 view를 바꾸는지 알아보겠습니다.

    저는 상태관리 패키지로 Riverpod을 선택했기때문에 리버팟 베이스로 예시 코드를 작성했습니다.

     

    class StoreDetailScreen extends ConsumerStatefulWidget {
      static String get routeName => 'store_detail';
      final String id;
      const StoreDetailScreen({
        required this.id,
        super.key,
      });
    
      @override
      ConsumerState<StoreDetailScreen> createState() => _StoreDetailScreenState();
    }
    
    class _StoreDetailScreenState extends ConsumerState<StoreDetailScreen> {
      @override
      Future<void> didChangeDependencies() async {
    	//생략
      }
    
      @override
      Widget build(BuildContext context) {
        final state = ref.watch(storeDetailControllerProvider);
    
        return state.when(
          loading: () => const StoreDetailLoadingView(),
          data: (data) => StoreDetailView(model: data),
          error: (error) => StoreDetailErrorView(
              errorFun: () => ref
                  .read(storeDetailControllerProvider.notifier)
                  .getStoreDetail(storeId: int.parse(widget.id))),
        );
      }
    }

     
    build() 메서드에 있는 state 변수에 주목 해 봅시다.
     

    build()

     
    'line 3'을 보면 ref.watch()를 통해 컨트롤러의 provider를 가져와 현재 상태를 state변수에 담아서 반환을 하고 있습니다.


    ✍🏼 ref.watch()를 사용하는 이유

    • ref.watch()
      • provider를 가져와 현재 상태를 반환한다.
      • 수신된 provider가 변경될 때마다 공급자가 무효화되고 다음 프레임이나 다음 읽기에서 다시 빌드된다.
      • on change와 같은 사이트 이펙트에 의존하지 않는다.이는 StatelessWidget과 작동방식이 유사하다.

     
    ✍🏼 패턴 매칭
    state는 sealed Class에 정의했던 객체 중 하나입니다. (loading, data, error)
    아까 sealed 사용하면 불변성이 보장된다고 했었고 이는 Enum과 Union type과 같은 성격을 띤다는 것을 의미합니다.


    바로 여기에서 freezed를 이용해 코드를 구성했던 이유가 나타납니다.

     

    • when()
      • freezed가 제공하는 패턴매칭에 사용되는 메서드다.
      • when() 메서드는 클래스의 각 하위 클래스에 대한 콜백을 정의하고, 현재 객체의 타입에 따라 해당하는 콜백을 실행한다. 
      • 컴파일 단계에서 콜백을 정의했는지 체킹이 된다.

    컴파일 단계에서 callback을 정의했는지 체크하는 모습

     
     
    패턴매칭을 작성하는 코드 안에 상태에 따라 작성해 둔 widget을 넣으면 다음과 같이 작동합니다.

    로딩 모습(500 milliseconds 딜레이)

     
     


     
    상태관리는 네트워크 상태 말고도 쓰이는 부분이 많지만 프로젝트를 하면서 개인적으로 네트워크 상태를 다루는 것이 상태를 관리한다는 측면에서 가장 잘 보여주는 것 같았고, 처음 네트워크 상태에 대해 고민했을 때 쉽게 자료를 찾지 못한 기억도 있어서 포스팅을 한번 해봤습니다. (부족한 글이지만 이걸 보신 분들은 저처럼 삽질하지 마시길 바랍니다..🥲)
     
    긴 글 읽어주셔서 감사합니다!
     

    이 글이 꼭 정답은 아닙니다. 잘못된 부분이나 부족한 부분을 알려주시면 학습 후 수정하겠습니다.
Designed by Tistory.