LY Corporation Tech Blog

LY Corporation과 LY Corporation Group(LINE Plus, LINE Taiwan and LINE Vietnam)의 기술과 개발 문화를 알립니다.

Flutter Riverpod 200% 활용하기

Flutter에는 다양한 상태 관리 라이브러리가 존재합니다. 대표적으로 Provider와 GetX, BLoC 등이 있는데요. 이전에 Flutter 인기 아키텍처 라이브러리 3종 비교 분석 - GetX vs BLoC vs Provider라는 글에서 이들 라이브러리의 고유한 특징과 장단점을 비교해 봤습니다.

이번 글에서는 최근 인기를 얻고 있는 Riverpod을 소개하려고 합니다. Riverpod은 기존 Provider 라이브러리의 한계를 보완하기 위해 개발된 라이브러리로, 더 유연하면서 강력한 기능을 제공해 개발자가 보다 쉽게 상태를 관리할 수 있도록 돕습니다. 먼저 Riverpod을 소개하고, Riverpod의 특징과 응용법, 유의할 점을 알아보면서 공식 사이트에는 없는 저만의 몇 가지 사용 기법을 공유하겠습니다.

상태 관리 라이브러리란?

'상태'란 앱의 현재 상태를 나타내며, 다음과 같은 요소들이 상태에 해당합니다.

  • 로그인 상태
  • 뉴스 기사 목록
  • 장바구니에 담긴 상품 목록

상태 관리는 앱의 복잡성과 사용자 경험에 직접적인 영향을 미칩니다. 잘못된 상태 관리는 앱의 성능 저하, 예기치 않은 오류, 복잡한 디버깅 과정을 초래할 수 있습니다. 따라서 올바른 상태 관리 도구를 선택하는 것은 매우 중요한데요. 일반적으로 상태 관리 라이브러리는 다음과 같은 기능을 제공합니다.

Basic features of the state management libraries

Riverpod으로 구현한 샘플 앱

Riverpod을 처음 접하는 독자들을 위해 간단한 사용법부터 소개하겠습니다. Flutter에서 새 프로젝트를 생성하면 아래와 같은 ‘버튼을 클릭하면 숫자가 증가하는 간단한 앱’이 기본 샘플로 제공되는데요. 이렇게 제공되는 기본 코드와 Riverpod을 이용해 구현한 코드를 비교해 보겠습니다.

Flutter sample app

아래 코드는 샘플 앱 생성 시 자동으로 생성되는 코드입니다.

class CounterViewState extends State<CounterView> {

  int count = 0;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = '${count}';
    return Scaffold(
      body: Text(count),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          setState(() => count++);
        },
      ),
    );
  }

다음은 Riverpod으로 같은 예제를 구현한 코드입니다. 먼저 상태를 관리할 provider를 정의하고, 이를 화면(view)에서 사용하면 됩니다. 개발자가 정의한 provider의 생명 주기는 Riverpod이 자동으로 관리해 주기 때문에 개발자는 따로 신경 쓸 필요 없이 코드 작성에만 집중하면 됩니다.

// Model: 상태를 정의합니다.
class CounterModel {
  int count;
}

// Provider
// 변경된 상태를 외부에 전달하거나 상태를 업데이트하는 메서드를 제공합니다. 
// 모든 provider는 최초 상태를 리턴하는 build 메서드를 구현해야 합니다. 
// build 메서드의 반환값은 상황에 따라 동기, 비동기(Future), Stream 3가지 중 하나로 구현할 수 있습니다.
@riverpod
class Counter extends _$Counter {

  // provider 생성 시, 기본으로 오버라이드해야 하는 메서드
  @override
  Future<CounterModel> build() async {
    return CounterModel(0);
  }
 
  // 현재 카운트를 1씩 올리는 메서드 제공
  void increment() {
    final count = state.value?.count ?? 0;
    state = AsyncData(CounterModel(count + 1));
  }
}

// View: ViewModel의 데이터를 구독해 UI를 구성하고, 사용자 입력을 처리합니다
class CounterView extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ViewModel의 Model을 구독
    final counter = ref.watch(counterProvider);

    // Model의 데이터 출력
    final count = '${counter.value?.count}';

    return Scaffold(
      body: Text(count),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // ViewModel의 메서드 호출
          ref.read(counterProvider.notifier).increment();
        },
      ),
    );
  }
}

Riverpod만의 특징

Riverpod의 사용법을 간단히 살펴봤는데요. 다음으로 다른 상태 관리 라이브러리와는 다른 Riverpod만의 특징을 소개하겠습니다.

특징 1: 서버 데이터 처리에 최적화

다른 상태 관리 라이브러리와 다르게 Riverpod은 서버에서 데이터를 가져와 출력하는 애플리케이션 구현에 초점을 두고 있으며, 이를 위해 아래와 같은 기능을 제공합니다. 

  • 서버나 외부 모듈에서 데이터를 가져오는 동안 로딩 상태를 표현함
  • 로딩 중인 요청 취소
  • 당겨서 새로 고침(pull to refresh)
  • 한 번 불러온 데이터의 재사용과 유효 기간 설정

다른 상태 관리 라이브러리는 위와 같은 기능을 구현하려면 별도의 상태를 기록하거나 로직을 작성해야 하는데요. 반면 Riverpod에서는 이런 기능들을 기본적으로 제공하고 있기 때문에 반복적인 코드 작성을 줄일 수 있습니다. 이는 개발 생산성을 높일 뿐 아니라 테스트할 항목도 줄이고 유지 보수 부담도 줄여 줍니다.

@riverpod
class ItemList extends _$ItemList {
  @override
  Future<ItemListModel> build() async {
    final apiClient = ApiClient();

    // Provider가 더 이상 유효하지 않게 되면, API 호출을 취소합니다.
    ref.onDispose(apiClient.cancel);

    // API 호출
    return await apiClient.getItemList();
  }
}

class ItemListView extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final provider = ref.watch(itemListProvider);

    // Provider가 로딩 상태입니다.
    final isLoading = provider.isLoading;

    // Provider가 에러 상태입니다.
    final hasError = provider.hasError;

    // Provider에 정상적인 데이터가 있습니다.
    final hasValue = provider.hasValue;

    // Provider를 강제로 무효화해서 데이터를 다시 로딩하게 합니다. 
    // 당겨서 새로 고침 등에서 호출합니다.
    ref.refresh(itemListProvider);
  }
}

특징 2: 의존성 주입

Riverpod은 사용자가 작성한 provider의 생명 주기와 데이터 참조를 관리해 줍니다. 어디에서든 provider의 데이터를 필요로 할 때 이를 알아서 연결해 줍니다. 따라서 개발자는 복잡한 순서를 신경 쓸 필요가 없습니다. 또한 계층 구조가 아니기 때문에 provider가 특정 화면 구조에 묶여 있지 않습니다.

Expressing free referencing between providers

Riverpod 응용하기

지금부터는 공식 사이트에도 없는 활용 방법을 소개해 드리고자 합니다. 이를 위해 예시로 글 목록과 상세 화면이 있는 SNS 앱을 만들었는데요. 이 앱에서는 PostListFilter, PostList, PostDetail, 이렇게 총 세 개의 provider를 생성합니다.

  • PostListFilter: 글 목록에서 글쓴이로 검색하거나, 즐겨찾기 항목만 볼 수 있도록 필터를 제공합니다.
  • PostList: 서버에서 글 목록을 불러와 화면 객체에 전달합니다. 리스트에서 특정 글에 대해 즐겨찾기 기능을 제공합니다.
  • PostDetail: 서버에서 글의 상세 내용을 불러와 화면 객체에 전달합니다. 즐겨찾기 기능을 제공합니다.

Sample SNS app with article list detail view

응용하기 1: Provider 간 상태 구독

글 목록 화면에서 상단의 필터 조건이 변경되면 자동으로 글 목록을 새로 받아와 업데이트하도록 만들 수 있습니다. 즉, 글 목록 필터를 조작하는 코드를 일일이 찾아서 PostListrebuild하는 기능을 넣을 필요가 없습니다.

State subscription between providers

@riverpod
class PostList extends _$PostList {
  @override
  Future<PostListModel> build() async {
    // postListFilter가 변경되면, 이 아래에 있는 코드들이 새로 호출됩니다.
    final filter = ref.watch(postListFilterProvider);
 
    // API 호출
    return api.getPostList(filter.value);
  }
}

응용하기 2: 캐시 활용

글의 상세 화면으로 이동할 때 이미 불러온 내용은 로딩 없이 먼저 표시할 수 있습니다.

Cash utilization

@riverpod
class PostDetail extends _$PostDetail {
  @override
  Stream<PostListModel> build(String postId) async* {
    // 1. 리스트를 통해 불러왔던 내용은 로딩 없이 바로 표시합니다.
    yield ref.read(postListProvider.notifier).getPostDetail(postId);

    // 2. 나머지 내용들은 로딩이 완료되는 대로 화면을 업데이트합니다.
    yield api.getPostDetail(postId);
  }
}

응용하기 3: 오프라인 데이터 처리

네트워크 상태에 따라 온라인과 오프라인 데이터를 유연하게 결합해 사용자에게 끊김 없는 화면을 제공하고, 오프라인 환경에서도 안정적으로 작동하는 앱을 구현할 수 있습니다.

Offline data processing

@riverpod
class PostList extends _$PostList {
  @override
  Stream<PostListModel> build() async*  {
    // 로컬에 저장된 데이터를 먼저 보여줍니다.
    yield local.getPostList();
 
    final serverData = await api.getPostList(filter.value);
    if (serverData.isSuccessful) {
 
        // 서버에서 불러온 데이터로 화면을 업데이트합니다.
        yield serverData;
         
        // 로컬 데이터를 업데이트합니다.
        local.updateDB(serverData);
    }
  }
}

응용하기 4: 화면 간 데이터 동기화

글의 상세 화면에서 업데이트된 데이터가 글 목록에도 반영되게 만들 수 있습니다.

Data synchronization between views

@riverpod
class PostDetail extends _$PostDetail {
  ...

  Future<void> setStar(bool flag) async {
    // 1. 서버 데이터를 변경합니다.
    api.setStar(postId, flag);

    // 2. 현재 화면의 데이터를 업데이트합니다.
    state = AsyncData(state.value.copyWith(star: flag));

    // 3. 리스트에 있는 데이터도 업데이트합니다.
    ref.read(postListProvider.notifier).setStar(postId, flag);
  }
}

간단한 기능이라면 위와 같이 구현해도 되지만, 위 구조는 잠재적으로 문제가 발생할 수 있습니다. 즐겨찾기 기능이 있는 화면이 추가될 때마다 기존에 작성한 PostDetail도 수정해야 하기 때문입니다. 기능이 추가될 때마다 기존 기능에 부작용이 생길 수 있는 구조는 좋은 구조가 아니므로, 아래와 같이 공통 부분을 분리해 PostProvider를 추가로 생성하는 구조로 만드는 것이 좋습니다. 이곳에서는 글 목록 불러오기, 글 상세 불러오기, 즐겨찾기를 제공합니다.

Architecture for separating the common part and adding PostProvider

Riverpod 사용 시 유의할 점

다음으로 Rivepod 사용 시 유의해야 할 점 세 가지를 알아보겠습니다. 

유의할 점 1: 설계의 단순화

Provider 간 참조가 많아질수록 코드가 복잡해질 수 있습니다. 이는 유지 보수를 어렵게 만들 수 있으며, 특히 새로운 개발자가 프로젝트에 참여할 때 이해하기 어렵게 만들 수 있습니다. 따라서 provider를 설계할 때에는 가능한 한 단순하게 유지하는 것이 좋습니다. 각 provider의 책임이 명확해지도록 설계하고, 불필요한 참조를 최소화해 복잡성을 줄이는 게 좋습니다. 이와 관련해 두 가지 원칙을 적어 봤습니다.

  • Provider 간 참조는 되도록 한 방향으로만 이뤄지도록 합니다. 예를 들어 즐겨찾기 provider가 사용자 프로필 provider를 참조한다면 반대 방향의 참조는 피합니다.
  • 양방향 참조가 필요하다면 중앙에서 상태를 관리하는 중간 provider를 만드는 것을 고려합니다. 앞서 '응용하기 4: 화면 간 데이터 동기화'가 그 예제입니다.

Simplify the design

유의할 점 2: 성능 최적화

Provider 안에서 watch를 사용할 때에는 성능 이슈를 고려해야 합니다. 너무 많은 watch는 잦은 데이터 로딩을 발생시켜 성능을 저하할 수 있습니다. 예를 들어 리스트 화면을 보고 있지 않아도 리스트가 여러 번 업데이트되는 경우가 발생할 수 있으니 필요한 부분에서만 상태를 구독하도록 설계해야 합니다.

@riverpod
class PostList extends _$PostList {
  @override
  Future<PostListModel> build() async {
    // 이런 식으로 너무 많은 watch를 사용하면 
    // 리스트 화면을 보고 있지 않아도 리스트가 여러 번 업데이트되는 경우가 발생할 수 있습니다.
    final filter = ref.watch(postListFilterProvider);
    final api = ref.watch(apiClientProvider);
 	final local = ref.watch(localProvider);
    return api.getPostList(filter.value);
  }

  PostListModel getPostDetail(String postId) {
    return PostListModel();
  }
}

다중 watch 사용이 불가피한 경우에는 DebounceThrottle을 적절히 사용해 이를 회피할 수도 있습니다.

  • Debounce: 지정된 시간 동안 새로운 이벤트가 발생하지 않으면 마지막 이벤트만 방출합니다. 예를 들어, 검색창에서 사용자가 글자를 타이핑하는 동안에는 서버 요청을 하지 않다가 입력이 약 1초간 멈추면 그 시점의 검색어로 서버에 요청을 보내는 방식입니다.
  • Throttle: 지정된 시간 동안 발생한 이벤트 중 첫 번째 이벤트만 방출하는 방식 입니다. 예를 들어, 사용자가 ‘글 작성 완료’ 버튼을 여러 번 눌러도 첫 번째 요청만 서버에 전송되도록 처리하는 경우입니다.

Debouncethrottle에 대한 더욱 자세한 설명은 아래 링크를 참고하시기 바랍니다.

유의할 점 3: 생명 주기 관리

Riverpod이 provider의 생명 주기를 자동으로 관리해 주지만, 특정 환경에서는 사용자가 직접 개입해야 할 때도 있습니다. 예를 들어 탭 내비게이션의 화면에서 탭 전환 시 이전 탭의 데이터가 삭제되는 경우를 염두에 두고 provider의 위치를 조정해 문제를 해결하는 방안 등을 사용해야 합니다.

Provider life cycle management

마치며

이번 글에서는 Riverpod의 기본적인 사용법부터 몇 가지 응용 방법까지 살펴봤습니다. Flutter에서의 상태 관리는 앱의 복잡성과 유지 보수성을 크게 좌우하는 중요한 요소인데요. Riverpod은 다양한 상태 관리 라이브러리 중에서도 특히 개발 생산성을 높이고 반복적인 코드 작성을 줄이는 데 유용한 도구입니다. Riverpod을 활용하면 코드의 모듈화를 통해 더 깔끔하고 유지 보수하기 쉬운 구조를 만들 수 있습니다. 또한, 서버와의 데이터 통신을 효율적으로 관리할 수 있어 사용자 경험을 향상시키는 데 큰 도움이 됩니다.

마지막으로 말씀드리고 싶은 것은, 어떤 상태 관리 라이브러리를 선택하든 프로젝트의 요구 사항과 팀의 기술 스택에 맞는 도구를 선택하는 것이 중요하다는 것입니다. Riverpod은 강력한 기능을 제공하지만 모든 프로젝트에 적합한 것은 아닐 수 있습니다. 프로젝트의 특성과 팀의 역량을 고려해 적절한 도구를 선택하고, 이를 최대한 활용하는 것이 중요합니다.

앞으로도 Flutter와 Riverpod을 활용한 다양한 프로젝트를 통해 더 많은 경험과 노하우를 쌓아가시길 바라며 이만 마치겠습니다.