🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Riverpod 3.0 쇼핑 앱 종합 프로젝트 완벽 가이드 - 슬라이드 1/8
A

AI Generated

2025. 12. 11. · 98 Views

Riverpod 3.0 쇼핑 앱 종합 프로젝트 완벽 가이드

Flutter와 Riverpod 3.0을 활용한 실무 수준의 쇼핑 앱 개발 과정을 단계별로 학습합니다. 상품 목록, 장바구니, 주문, 인증, 검색 기능까지 모든 핵심 기능을 구현하며 상태 관리의 실전 노하우를 익힙니다.


목차

  1. 프로젝트 구조 설계
  2. 상품 목록 (FutureProvider + family)
  3. 장바구니 (Notifier + Offline)
  4. 주문하기 (Mutation)
  5. 사용자 인증 (AsyncNotifier)
  6. 검색과 필터 (select)
  7. 테스트 코드 작성

1. 프로젝트 구조 설계

어느 날 김개발 씨는 팀장님으로부터 새로운 프로젝트를 맡았습니다. "김 개발자님, 이번에 쇼핑 앱을 새로 만들 건데 Riverpod 3.0으로 해보죠." 막막한 마음에 선배 박시니어 씨에게 물었습니다.

"쇼핑 앱 같은 큰 프로젝트는 어떻게 시작해야 할까요?"

쇼핑 앱은 상품 목록, 장바구니, 주문, 사용자 인증 등 다양한 기능이 복잡하게 얽혀 있습니다. 처음부터 명확한 구조 설계가 없으면 나중에 코드가 스파게티처럼 엉키게 됩니다.

Feature-First 구조Provider 분리 전략을 세우는 것이 성공의 핵심입니다.

다음 코드를 살펴봅시다.

lib/
├── main.dart
├── features/
│   ├── products/
│   │   ├── models/product.dart
│   │   ├── providers/product_providers.dart
│   │   └── screens/product_list_screen.dart
│   ├── cart/
│   │   ├── models/cart_item.dart
│   │   ├── providers/cart_provider.dart
│   │   └── screens/cart_screen.dart
│   ├── orders/
│   └── auth/
└── shared/
    ├── widgets/
    └── utils/

박시니어 씨가 화이트보드 앞에 섰습니다. "쇼핑 앱을 만든다고 생각해보세요.

무엇이 필요할까요?" 김개발 씨는 잠시 생각하다 대답했습니다. "상품 목록이요.

그리고 장바구니, 주문하기..." 박시니어 씨가 고개를 끄덕였습니다. "맞아요.

바로 그런 기능들이 있죠." 이때 중요한 것이 바로 구조 설계입니다. 마치 집을 지을 때 설계도를 먼저 그리는 것처럼, 앱도 처음부터 어떻게 구성할지 정해야 합니다.

많은 초보 개발자들이 하는 실수가 있습니다. 그냥 main.dart에 모든 코드를 넣거나, providers 폴더에 모든 Provider를 몰아넣는 것입니다.

처음에는 편해 보이지만 프로젝트가 커지면 어디에 무엇이 있는지 찾기조차 힘들어집니다. 그래서 등장한 것이 Feature-First 구조입니다.

Feature-First는 말 그대로 "기능 우선" 구조입니다. 상품 관련 코드는 products 폴더에, 장바구니 관련 코드는 cart 폴더에 모두 모아두는 것입니다.

마치 서류를 정리할 때 주제별로 파일철을 만드는 것과 같습니다. 이렇게 하면 어떤 이점이 있을까요?

첫째, 코드를 찾기 쉽습니다. 장바구니 기능에 버그가 생기면 cart 폴더만 열어보면 됩니다.

둘째, 팀 작업이 편해집니다. 한 명은 products를 개발하고, 다른 한 명은 orders를 개발해도 충돌이 적습니다.

셋째, 기능을 추가하거나 제거하기 쉽습니다. 새로운 기능이 필요하면 폴더 하나만 추가하면 됩니다.

각 feature 폴더 안에는 무엇이 들어갈까요? models 폴더에는 데이터 구조를 정의합니다.

Product 클래스, CartItem 클래스 같은 것들이죠. providers 폴더에는 상태 관리 로직이 들어갑니다.

Riverpod Provider들이 여기 모입니다. screens 폴더에는 UI 화면들이 들어갑니다.

shared 폴더는 여러 기능에서 공통으로 사용하는 것들을 모아둡니다. 공통 위젯이나 유틸리티 함수 같은 것들이죠.

마치 공용 창고 같은 역할입니다. 김개발 씨가 물었습니다.

"그런데 Provider는 어떻게 나눠야 하나요?" 박시니어 씨가 미소 지었습니다. "좋은 질문이에요." Provider를 나누는 기준도 중요합니다.

하나의 Provider는 하나의 책임만 가져야 합니다. 상품 목록을 가져오는 Provider, 장바구니를 관리하는 Provider, 주문을 처리하는 Provider.

이렇게 명확하게 나누는 것입니다. 실제 쇼핑몰 서비스를 운영하는 기업들도 이런 구조를 사용합니다.

무신사, 쿠팡 같은 큰 앱들도 기능별로 모듈을 나누어 관리합니다. 규모가 커질수록 이런 구조의 중요성은 더욱 커집니다.

초보자들이 자주 하는 또 다른 실수는 너무 세분화하는 것입니다. 파일 하나에 코드 10줄만 있는 식으로 과도하게 나누면 오히려 복잡해집니다.

적절한 균형이 필요합니다. 김개발 씨는 화이트보드에 그려진 구조도를 보며 고개를 끄덕였습니다.

"이제 어디서부터 시작해야 할지 보이네요!" 좋은 구조 설계는 프로젝트의 성공을 절반쯤 보장합니다. 나머지 절반은 여러분의 실력이겠지만, 탄탄한 기초 위에서 개발하면 훨씬 수월합니다.

실전 팁

💡 - 기능이 3개 파일 이상 필요하면 별도 feature 폴더로 분리하세요

  • Provider 파일명은 기능을 명확히 드러내게 작성하세요 (예: product_list_provider.dart)
  • 공통 코드는 너무 일찍 shared로 옮기지 말고, 2번 이상 재사용될 때 옮기세요

2. 상품 목록 (FutureProvider + family)

프로젝트 구조를 잡은 김개발 씨는 첫 번째 기능인 상품 목록을 만들기 시작했습니다. API에서 상품 데이터를 가져와 화면에 보여주면 되는데, 카테고리별로 다른 상품을 보여줘야 했습니다.

"카테고리가 여러 개인데 Provider를 어떻게 만들어야 하지?"

FutureProvider는 비동기 데이터를 가져오는 가장 간단한 방법입니다. API 호출 같은 일회성 비동기 작업에 최적입니다.

family 수정자를 함께 사용하면 파라미터에 따라 다른 데이터를 캐싱할 수 있어서, 카테고리별 상품 목록 같은 경우에 완벽합니다.

다음 코드를 살펴봅시다.

// models/product.dart
class Product {
  final String id;
  final String name;
  final int price;
  final String category;

  Product({required this.id, required this.name, required this.price, required this.category});
}

// providers/product_providers.dart
@riverpod
Future<List<Product>> productList(ProductListRef ref, String category) async {
  // API 호출 시뮬레이션
  await Future.delayed(Duration(seconds: 1));
  return [
    Product(id: '1', name: '노트북', price: 1000000, category: category),
    Product(id: '2', name: '마우스', price: 30000, category: category),
  ];
}

박시니어 씨가 김개발 씨의 모니터를 들여다봤습니다. "상품 목록을 만들고 있군요.

카테고리는 몇 개나 되나요?" 김개발 씨가 기획서를 펼쳤습니다. "전자제품, 의류, 식품...

총 10개 정도요." 박시니어 씨가 고개를 끄덕이며 말했습니다. "그럼 FutureProviderfamily를 붙여서 만들어 보세요." FutureProvider는 무엇일까요?

간단히 말하면, Future를 반환하는 함수를 Provider로 만들어주는 것입니다. 마치 택배를 주문하면 나중에 물건이 도착하는 것처럼, API를 호출하면 나중에 데이터가 도착합니다.

이런 "나중에 올 데이터"를 관리하는 것이 FutureProvider입니다. Riverpod 3.0에서는 @riverpod 어노테이션을 사용합니다.

예전 방식보다 훨씬 간결하고 타입 안전합니다. 그런데 여기서 문제가 생깁니다.

카테고리가 10개인데, Provider를 10개 만들어야 할까요? 그것도 아니고, 카테고리가 바뀔 때마다 새로 API를 호출해야 할까요?

바로 이런 상황을 위해 family가 있습니다. family는 "가족"이라는 뜻입니다.

하나의 Provider가 파라미터에 따라 여러 개의 인스턴스를 만드는 것이죠. 마치 한 가족에 여러 식구가 있는 것처럼 말입니다.

코드를 자세히 살펴보겠습니다. 함수 시그니처를 보면 productList(ProductListRef ref, String category)처럼 두 번째 파라미터로 category를 받습니다.

이것이 family의 핵심입니다. 첫 번째 파라미터는 항상 Ref이고, 두 번째부터가 실제 파라미터입니다.

코드 생성기는 이 함수를 보고 자동으로 family Provider를 만들어줍니다. 사용할 때는 ref.watch(productListProvider('전자제품'))처럼 카테고리를 전달하면 됩니다.

여기서 중요한 점이 있습니다. 캐싱입니다.

'전자제품' 카테고리로 한 번 호출하면, Riverpod은 그 결과를 메모리에 저장합니다. 다시 '전자제품'을 요청하면 API를 또 호출하는 것이 아니라 저장된 데이터를 바로 반환합니다.

하지만 '의류'를 요청하면 이것은 새로운 파라미터이므로 API를 새로 호출합니다. 실제 쇼핑 앱에서 사용자 행동을 생각해보세요.

전자제품 카테고리를 보다가 의류로 갔다가 다시 전자제품으로 돌아올 수 있습니다. family를 사용하면 이미 본 카테고리는 즉시 보여줄 수 있습니다.

사용자 경험이 훨씬 좋아지는 것이죠. 김개발 씨가 궁금한 표정으로 물었습니다.

"그럼 메모리가 계속 쌓이는 거 아닌가요?" 박시니어 씨가 설명을 이어갔습니다. Riverpod은 똑똑합니다.

더 이상 사용하지 않는 Provider는 자동으로 dispose됩니다. 예를 들어 전자제품 화면에서 벗어나 다른 화면으로 가면, 전자제품 Provider는 자동으로 정리됩니다.

다시 돌아오면 그때 새로 만들어지죠. 실무에서는 API 응답을 Product 모델로 변환하는 코드도 추가해야 합니다.

JSON을 파싱하는 부분이죠. 하지만 핵심 로직은 위와 같습니다.

초보자들이 자주 하는 실수는 family 파라미터로 복잡한 객체를 넘기는 것입니다. family 파라미터는 ==와 hashCode로 비교되므로, 간단한 타입(String, int 등)이나 Freezed로 만든 불변 객체를 사용해야 합니다.

김개발 씨는 코드를 작성하며 신기해했습니다. "이렇게 간단한 코드로 카테고리별 캐싱까지 다 되다니!" FutureProvider와 family의 조합은 Riverpod에서 가장 많이 쓰이는 패턴 중 하나입니다.

상품 목록뿐 아니라 사용자 상세 정보, 게시글 목록 등 다양한 곳에 활용할 수 있습니다.

실전 팁

💡 - family 파라미터는 String, int 같은 기본 타입이나 Freezed 클래스를 사용하세요

  • API 에러 처리는 try-catch로 감싸고 적절한 예외를 던지세요
  • 로딩 상태는 위젯에서 AsyncValue.when()으로 쉽게 처리할 수 있습니다

3. 장바구니 (Notifier + Offline)

상품 목록을 완성한 김개발 씨는 다음 기능인 장바구니를 만들기 시작했습니다. 사용자가 상품을 추가하고 삭제하고 수량을 변경하는 기능입니다.

"이건 FutureProvider로는 안 될 것 같은데요?" 박시니어 씨가 웃으며 대답했습니다. "맞아요.

이제 Notifier를 배울 시간이네요."

Notifier는 변경 가능한 상태를 관리하는 Riverpod의 핵심입니다. 상태를 읽고, 수정하고, 알림을 보내는 모든 로직을 한곳에 모을 수 있습니다.

장바구니처럼 사용자 액션에 따라 계속 변하는 데이터를 다룰 때 완벽한 선택입니다.

다음 코드를 살펴봅시다.

// models/cart_item.dart
class CartItem {
  final Product product;
  final int quantity;

  CartItem({required this.product, required this.quantity});

  CartItem copyWith({Product? product, int? quantity}) {
    return CartItem(
      product: product ?? this.product,
      quantity: quantity ?? this.quantity,
    );
  }
}

// providers/cart_provider.dart
@riverpod
class Cart extends _$Cart {
  @override
  List<CartItem> build() {
    // 초기 상태는 빈 리스트
    return [];
  }

  void addProduct(Product product) {
    // 이미 있으면 수량만 증가
    final existingIndex = state.indexWhere((item) => item.product.id == product.id);
    if (existingIndex >= 0) {
      state = [
        for (var i = 0; i < state.length; i++)
          if (i == existingIndex)
            state[i].copyWith(quantity: state[i].quantity + 1)
          else
            state[i],
      ];
    } else {
      // 없으면 새로 추가
      state = [...state, CartItem(product: product, quantity: 1)];
    }
  }

  void removeProduct(String productId) {
    state = state.where((item) => item.product.id != productId).toList();
  }

  int get totalPrice {
    return state.fold(0, (sum, item) => sum + (item.product.price * item.quantity));
  }
}

박시니어 씨가 화이트보드에 그림을 그렸습니다. "장바구니를 생각해보세요.

물건을 담고, 빼고, 수량을 바꾸고... 계속 변하죠?" 김개발 씨가 고개를 끄덕였습니다.

"네, FutureProvider는 한 번 가져오면 끝이지만, 장바구니는 계속 바뀌니까요." 바로 그 차이가 FutureProviderNotifier의 차이입니다. FutureProvider는 마치 책을 읽는 것과 같습니다.

한 번 읽으면 내용이 변하지 않습니다. 하지만 Notifier는 노트에 메모하는 것과 같습니다.

언제든 내용을 지우고, 추가하고, 수정할 수 있습니다. Notifier를 만들 때는 클래스를 사용합니다.

클래스 이름은 Cart이고, _$Cart를 상속받습니다. 이 _$Cart는 코드 생성기가 만들어주는 클래스입니다.

build 메서드가 제일 먼저 보입니다. 이것은 초기 상태를 정의하는 곳입니다.

장바구니는 처음에 비어있으므로 빈 리스트를 반환합니다. 그다음은 상태를 변경하는 메서드들입니다.

addProduct, removeProduct 같은 것들이죠. 이것이 Notifier의 핵심입니다.

상태를 어떻게 변경할지를 메서드로 정의하는 것입니다. addProduct를 자세히 보겠습니다.

먼저 같은 상품이 이미 있는지 확인합니다. indexWhere로 찾습니다.

만약 있다면 수량만 1 증가시킵니다. 없다면 새로운 CartItem을 추가합니다.

여기서 중요한 패턴이 있습니다. state를 직접 수정하지 않고 새로운 리스트를 만듭니다.

state = [...] 이렇게요. 이것은 불변성 원칙입니다.

왜 불변성이 중요할까요? Flutter는 위젯을 다시 그릴지 판단할 때 객체의 참조를 비교합니다.

같은 객체를 수정하면 Flutter는 변화를 감지하지 못합니다. 하지만 새로운 객체를 만들면 "아, 바뀌었구나!" 하고 알아챕니다.

실제로 초보자들이 가장 많이 하는 실수가 이것입니다. state.add(item)처럼 직접 수정하면 화면이 업데이트되지 않습니다.

반드시 state = [...state, item]처럼 새 리스트를 만들어야 합니다. removeProduct는 더 간단합니다.

where로 필터링해서 해당 상품을 제외한 새 리스트를 만듭니다. getter로 totalPrice도 만들었습니다.

이것은 computed property라고 부릅니다. 상태에서 계산된 값을 제공하는 것이죠.

장바구니의 총 금액을 계산합니다. 실제 쇼핑몰에서 장바구니는 더 복잡합니다.

할인 쿠폰, 배송비, 재고 확인 등이 있죠. 하지만 기본 원리는 같습니다.

상태를 정의하고, 그 상태를 변경하는 메서드를 만드는 것입니다. 김개발 씨가 코드를 작성하다 멈췄습니다.

"그런데 이 데이터는 앱을 껐다 켜면 다 사라지지 않나요?" 박시니어 씨가 미소 지었습니다. "좋은 질문이에요.

실제로는 로컬 저장소에 저장해야죠." Riverpod에는 상태를 자동으로 저장하고 복원하는 기능은 없습니다. 하지만 직접 만들기는 어렵지 않습니다.

build 메서드에서 SharedPreferences나 Hive에서 데이터를 읽어오고, 상태가 변경될 때마다 저장하면 됩니다. 또 다른 방법은 ref.listenSelf를 사용하는 것입니다.

build 메서드에서 ref.listenSelf를 등록하면, 상태가 바뀔 때마다 콜백이 호출됩니다. 그 콜백에서 저장하면 됩니다.

Notifier는 Riverpod 3.0에서 가장 많이 쓰이는 패턴입니다. 설정, 사용자 프로필, 필터 조건 등 앱의 거의 모든 상태를 Notifier로 관리할 수 있습니다.

김개발 씨는 장바구니 기능을 완성하며 뿌듯해했습니다. "상태 관리가 이렇게 깔끔할 수가 있네요!"

실전 팁

💡 - 상태는 절대 직접 수정하지 말고 항상 새 객체를 만드세요 (state = newValue)

  • copyWith 패턴을 활용하면 일부 속성만 변경하기 쉽습니다
  • 복잡한 로직은 private 메서드로 분리해서 코드를 깔끔하게 유지하세요

4. 주문하기 (Mutation)

장바구니까지 완성한 김개발 씨는 이제 주문하기 기능을 만들 차례입니다. "주문하기 버튼을 누르면 서버에 데이터를 보내야 하는데, 이것도 Notifier로 하나요?" 박시니어 씨가 고개를 저었습니다.

"이건 조금 다릅니다. AsyncNotifier를 써야 해요."

AsyncNotifier는 비동기 작업을 수행하는 상태 관리자입니다. 로딩, 성공, 에러 상태를 자동으로 관리해주며, API 호출 같은 비동기 작업에 완벽합니다.

특히 Mutation(데이터 변경) 작업을 처리할 때 빛을 발합니다.

다음 코드를 살펴봅시다.

// models/order.dart
class Order {
  final String id;
  final List<CartItem> items;
  final int totalPrice;
  final DateTime createdAt;

  Order({required this.id, required this.items, required this.totalPrice, required this.createdAt});
}

// providers/order_provider.dart
@riverpod
class OrderSubmit extends _$OrderSubmit {
  @override
  FutureOr<Order?> build() {
    // 초기 상태는 null (아직 주문 안 함)
    return null;
  }

  Future<void> submit(List<CartItem> items) async {
    // 로딩 상태로 변경
    state = const AsyncValue.loading();

    // API 호출
    state = await AsyncValue.guard(() async {
      final totalPrice = items.fold(0, (sum, item) => sum + item.product.price * item.quantity);

      // 서버에 주문 전송 (시뮬레이션)
      await Future.delayed(Duration(seconds: 2));

      // 성공하면 주문 객체 생성
      return Order(
        id: DateTime.now().millisecondsSinceEpoch.toString(),
        items: items,
        totalPrice: totalPrice,
        createdAt: DateTime.now(),
      );
    });

    // 성공하면 장바구니 비우기
    if (state.hasValue) {
      ref.read(cartProvider.notifier).clear();
    }
  }
}

김개발 씨가 코드를 보다가 고개를 갸우뚱했습니다. "FutureOr이라는 게 뭐죠?

처음 봐요." 박시니어 씨가 설명을 시작했습니다. "Notifier와 FutureProvider의 중간 형태라고 생각하면 됩니다." 일반 Notifier는 동기적입니다.

state를 바로 변경할 수 있죠. 하지만 주문하기는 서버와 통신해야 합니다.

시간이 걸리는 비동기 작업입니다. 이럴 때 AsyncNotifier를 사용합니다.

AsyncNotifier의 상태는 AsyncValue입니다. AsyncValue는 세 가지 상태를 가질 수 있습니다.

loading은 작업이 진행 중일 때입니다. 로딩 스피너를 보여줄 때죠.

data는 작업이 성공했을 때입니다. 결과 데이터를 가지고 있습니다.

error는 작업이 실패했을 때입니다. 에러 정보를 담고 있습니다.

이것은 마치 택배 배송과 같습니다. 배송 중(loading), 배송 완료(data), 배송 실패(error).

세 가지 상태가 있죠. build 메서드를 보면 FutureOr<Order?>를 반환합니다.

FutureOr은 "Future이거나 Or 동기 값"이라는 뜻입니다. 초기 상태는 비동기 작업 없이 바로 null을 반환하므로 그냥 null을 리턴합니다.

핵심은 submit 메서드입니다. 먼저 state = const AsyncValue.loading()으로 로딩 상태를 만듭니다.

이 순간 위젯에서는 로딩 스피너를 보여줄 수 있습니다. 그다음 AsyncValue.guard를 사용합니다.

이것은 매우 유용한 헬퍼입니다. 안에 있는 비동기 함수를 실행하고, 성공하면 data 상태로, 실패하면 error 상태로 자동 변환해줍니다.

try-catch를 직접 쓸 필요가 없습니다. guard 안에서는 실제 주문 로직이 들어갑니다.

총 금액을 계산하고, 서버에 주문을 전송하고, 주문 객체를 생성합니다. 실제로는 HTTP 요청을 보내겠지만, 여기서는 Future.delayed로 시뮬레이션했습니다.

주문이 성공하면 어떻게 될까요? state.hasValue로 성공 여부를 확인합니다.

성공했다면 장바구니를 비워야겠죠. ref.read(cartProvider.notifier).clear()로 다른 Provider에 접근할 수 있습니다.

이것이 Provider들 간의 상호작용입니다. 위젯에서는 이렇게 사용합니다.


(버튼을 비활성화하거나 스피너 표시)     },   ); }); ``` **ref.listen**은 상태 변화를 감지해서 부수 효과를 실행합니다. 화면 이동, 다이얼로그 표시 같은 것들이죠.

실무에서는 더 복잡합니다. 결제 연동, 재고 확인, 주문 번호 생성 등이 필요하죠.

하지만 패턴은 동일합니다. AsyncValue로 상태를 관리하고, guard로 에러를 처리하고, listen으로 부수 효과를 실행하는 것입니다.

김개발 씨가 감탄했습니다. "로딩, 에러, 성공 상태를 일일이 관리할 필요가 없네요!" 바로 그것이 AsyncNotifier의 장점입니다.

보일러플레이트 코드가 크게 줄어듭니다. 직접 bool isLoading, String?

error 같은 변수를 만들 필요가 없습니다. AsyncValue가 다 해줍니다.

초보자들이 자주 하는 실수는 AsyncValue를 직접 만들려고 하는 것입니다. `AsyncValue.data(value)` 이렇게요.

대신 guard를 사용하면 훨씬 간단합니다. 또 다른 실수는 에러를 무시하는 것입니다.

네트워크가 끊기거나 서버 에러가 날 수 있습니다. 항상 error 케이스를 처리해야 합니다.

김개발 씨는 주문하기 기능을 완성하고 테스트해봤습니다. 버튼을 누르면 로딩 스피너가 나타나고, 2초 후 성공 메시지가 뜹니다.

완벽합니다!

**실전 팁**

💡 - AsyncValue.guard()를 사용하면 에러 처리가 자동으로 됩니다
- ref.listen()으로 화면 이동 같은 부수 효과를 처리하세요
- 로딩 중에는 버튼을 비활성화해서 중복 요청을 방지하세요

---

## 5. 사용자 인증 (AsyncNotifier)

프로젝트가 점점 완성되어 가는데, 팀장님이 새로운 요구사항을 가져왔습니다. "로그인 기능을 추가해주세요.

사용자 정보를 계속 유지해야 하고, 토큰도 관리해야 합니다." 김개발 씨는 박시니어 씨에게 물었습니다. "이것도 AsyncNotifier인가요?"

**사용자 인증**은 앱에서 가장 중요한 기능 중 하나입니다. 로그인 상태, 사용자 정보, 토큰 관리를 모두 해야 합니다.

AsyncNotifier로 비동기 로그인을 처리하고, 상태를 앱 전체에서 공유하며, 로컬 저장소와 연동하는 완벽한 인증 시스템을 만들 수 있습니다.

다음 코드를 살펴봅시다.

```dart
// models/user.dart
class User {
  final String id;
  final String email;
  final String name;
  final String token;

  User({required this.id, required this.email, required this.name, required this.token});
}

// providers/auth_provider.dart
@riverpod
class Auth extends _$Auth {
  @override
  FutureOr<User?> build() async {
    // 앱 시작 시 저장된 토큰 확인
    final prefs = await SharedPreferences.getInstance();
    final token = prefs.getString('auth_token');

    if (token != null) {
      // 토큰으로 사용자 정보 가져오기
      return await _fetchUserByToken(token);
    }
    return null;
  }

  Future<void> login(String email, String password) async {
    state = const AsyncValue.loading();

    state = await AsyncValue.guard(() async {
      // 로그인 API 호출 (시뮬레이션)
      await Future.delayed(Duration(seconds: 1));

      final user = User(
        id: '1',
        email: email,
        name: '김개발',
        token: 'fake_token_12345',
      );

      // 토큰 저장
      final prefs = await SharedPreferences.getInstance();
      await prefs.setString('auth_token', user.token);

      return user;
    });
  }

  Future<void> logout() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.remove('auth_token');
    state = const AsyncValue.data(null);
  }

  Future<User?> _fetchUserByToken(String token) async {
    // 토큰으로 사용자 정보 조회 (시뮬레이션)
    await Future.delayed(Duration(milliseconds: 500));
    return User(id: '1', email: 'dev@example.com', name: '김개발', token: token);
  }
}

박시니어 씨가 커피를 한 모금 마시고 말했습니다. "인증은 까다로워요.

단순히 로그인만 하면 되는 게 아니거든요." 김개발 씨가 노트를 펼쳤습니다. "어떤 게 필요한가요?" 박시니어 씨가 손가락을 꼽았습니다.

"첫째, 로그인 상태를 앱 전체에서 알아야 합니다. 둘째, 앱을 껐다 켜도 로그인이 유지되어야 합니다.

셋째, 로그아웃하면 모든 데이터를 정리해야 합니다." 바로 이런 요구사항을 위해 Auth Provider를 만듭니다. build 메서드를 보면 async 키워드가 붙어있습니다.

왜냐하면 앱이 시작될 때 저장된 토큰을 확인해야 하기 때문입니다. 이것은 비동기 작업이죠.

SharedPreferences는 Flutter에서 간단한 키-값 데이터를 저장하는 방법입니다. 마치 작은 로컬 데이터베이스 같은 것이죠.

토큰을 여기에 저장합니다. 앱이 시작되면 이런 순서로 진행됩니다.

먼저 SharedPreferences에서 'auth_token'을 찾습니다. 토큰이 있다면 그 토큰으로 서버에 사용자 정보를 요청합니다.

서버가 유효한 사용자를 반환하면 로그인 상태가 됩니다. 토큰이 없거나 만료되었다면 null을 반환하고 로그인이 필요한 상태가 됩니다.

이 과정은 자동으로 일어납니다. 사용자는 아무것도 하지 않아도 앱을 열면 바로 로그인되어 있는 것이죠.

login 메서드를 살펴보겠습니다. 주문하기와 비슷하게 AsyncValue.guard를 사용합니다.

이메일과 비밀번호로 서버에 로그인 요청을 보냅니다. 성공하면 서버는 사용자 정보와 토큰을 반환합니다.

여기서 중요한 것은 토큰을 반드시 저장해야 한다는 것입니다. prefs.setString('auth_token', user.token)으로 저장합니다.

이렇게 해야 다음번에 앱을 열 때 자동으로 로그인됩니다. 실제 앱에서는 토큰이 두 가지 있습니다.

Access TokenRefresh Token입니다. Access Token은 짧게 유효하고(예: 1시간), Refresh Token은 길게 유효합니다(예: 2주).

Access Token이 만료되면 Refresh Token으로 새로 발급받습니다. 이것은 보안을 위한 것이죠.

logout 메서드는 간단합니다. 저장된 토큰을 삭제하고, state를 null로 만듭니다.

이러면 로그인 화면으로 돌아가게 됩니다. 위젯에서는 이렇게 사용합니다.

dart final authState = ref.watch(authProvider); authState.when( data: (user) { if (user != null) { // 로그인됨 - 홈 화면 표시 return HomeScreen(); } else { // 로그인 안 됨 - 로그인 화면 표시 return LoginScreen(); } }, error: (err, stack) => ErrorScreen(), loading: () => LoadingScreen(), ); 앱의 루트 위젯에서 이렇게 체크하면, 로그인 여부에 따라 자동으로 화면이 바뀝니다. 김개발 씨가 궁금해했습니다.

"그럼 다른 Provider에서 사용자 정보가 필요하면 어떻게 하나요?" 좋은 질문입니다. 예를 들어 주문할 때 사용자 ID가 필요하다면, 주문 Provider에서 ref.read(authProvider)로 가져올 수 있습니다.

dart Future<void> submit() async { final user = await ref.read(authProvider.future); if (user == null) { throw Exception('로그인이 필요합니다'); } // user.id를 주문에 포함 } 실무에서는 더 복잡한 시나리오가 있습니다. 소셜 로그인(구글, 애플), 2단계 인증, 비밀번호 재설정 등이죠.

하지만 기본 패턴은 같습니다. AsyncNotifier로 상태를 관리하고, SharedPreferences로 지속성을 확보하는 것입니다.

초보자들이 자주 하는 실수는 토큰을 메모리에만 두는 것입니다. 앱을 껐다 켜면 로그인이 풀립니다.

반드시 로컬 저장소에 저장해야 합니다. 또 다른 실수는 에러 처리를 안 하는 것입니다.

네트워크가 끊기면? 서버가 다운되면?

비밀번호가 틀리면? 모든 경우를 처리해야 사용자 경험이 좋아집니다.

김개발 씨는 로그인 기능을 완성하며 뿌듯해했습니다. "이제 제대로 된 앱 같네요!"

실전 팁

💡 - 토큰은 반드시 로컬 저장소에 저장하세요 (SharedPreferences 또는 flutter_secure_storage)

  • 민감한 정보는 flutter_secure_storage를 사용하는 것이 더 안전합니다
  • 토큰 만료 시 자동으로 Refresh하는 로직을 추가하면 사용자 경험이 좋아집니다

6. 검색과 필터 (select)

앱이 거의 완성되어 가는데, 사용자 테스트 중 피드백이 들어왔습니다. "상품이 너무 많아요.

검색하거나 가격대로 필터링할 수 있으면 좋겠어요." 김개발 씨는 고민에 빠졌습니다. "검색어가 바뀔 때마다 전체 상품 목록을 다시 불러와야 하나요?"

select는 Provider의 일부분만 구독하는 최적화 기법입니다. 검색어나 필터 조건이 바뀌어도 필요한 부분만 다시 계산하고, 불필요한 리빌드를 방지합니다.

대량의 데이터를 다룰 때 성능을 크게 향상시킵니다.

다음 코드를 살펴봅시다.

// providers/product_filter_provider.dart
@riverpod
class ProductFilter extends _$ProductFilter {
  @override
  ProductFilterState build() {
    return ProductFilterState(
      searchQuery: '',
      minPrice: 0,
      maxPrice: 1000000,
      category: null,
    );
  }

  void setSearchQuery(String query) {
    state = state.copyWith(searchQuery: query);
  }

  void setPriceRange(int min, int max) {
    state = state.copyWith(minPrice: min, maxPrice: max);
  }
}

class ProductFilterState {
  final String searchQuery;
  final int minPrice;
  final int maxPrice;
  final String? category;

  ProductFilterState({
    required this.searchQuery,
    required this.minPrice,
    required this.maxPrice,
    this.category,
  });

  ProductFilterState copyWith({
    String? searchQuery,
    int? minPrice,
    int? maxPrice,
    String? category,
  }) {
    return ProductFilterState(
      searchQuery: searchQuery ?? this.searchQuery,
      minPrice: minPrice ?? this.minPrice,
      maxPrice: maxPrice ?? this.maxPrice,
      category: category ?? this.category,
    );
  }
}

// 필터링된 상품 목록
@riverpod
Future<List<Product>> filteredProducts(FilteredProductsRef ref) async {
  final products = await ref.watch(productListProvider('전자제품').future);

  // searchQuery만 구독 - 다른 필터가 바뀌어도 리빌드 안 됨
  final searchQuery = ref.watch(productFilterProvider.select((state) => state.searchQuery));
  final minPrice = ref.watch(productFilterProvider.select((state) => state.minPrice));
  final maxPrice = ref.watch(productFilterProvider.select((state) => state.maxPrice));

  return products.where((p) {
    final matchesSearch = p.name.toLowerCase().contains(searchQuery.toLowerCase());
    final matchesPrice = p.price >= minPrice && p.price <= maxPrice;
    return matchesSearch && matchesPrice;
  }).toList();
}

박시니어 씨가 김개발 씨의 화면을 보며 말했습니다. "검색할 때마다 화면 전체가 깜빡이네요.

성능 문제가 있어 보입니다." 김개발 씨가 코드를 살펴봤습니다. "필터 상태가 바뀔 때마다 모든 위젯이 다시 그려지고 있어요." 박시니어 씨가 고개를 끄덕였습니다.

"select를 써야 할 때네요." Flutter 앱에서 성능은 매우 중요합니다. 특히 리스트가 길고 필터가 복잡할 때는 더욱 그렇습니다.

문제는 이렇습니다. ProductFilterState에는 검색어, 최소가격, 최대가격, 카테고리 네 가지 값이 있습니다.

만약 검색어만 바뀌었는데 나머지 세 개 때문에 불필요한 리빌드가 일어나면 어떻게 될까요? 낭비입니다.

select는 이 문제를 해결합니다. ref.watch(productFilterProvider.select((state) => state.searchQuery))를 보세요.

이것은 "productFilterProvider 중에서 searchQuery만 구독한다"는 뜻입니다. searchQuery가 바뀔 때만 이 위젯이 리빌드됩니다.

minPrice나 maxPrice가 바뀌어도 영향을 받지 않습니다. 마치 신문을 구독할 때 스포츠면만 보는 것과 같습니다.

정치면이 바뀌어도 신경 쓰지 않습니다. 스포츠면만 체크하는 것이죠.

코드를 단계별로 살펴보겠습니다. 먼저 ProductFilter Notifier가 있습니다.

이것은 필터 조건을 담는 상태 관리자입니다. 검색어를 바꾸는 메서드, 가격 범위를 바꾸는 메서드가 있습니다.

ProductFilterState는 불변 클래스입니다. copyWith 메서드로 일부만 변경할 수 있습니다.

이것은 Flutter에서 매우 일반적인 패턴입니다. filteredProducts Provider가 핵심입니다.

먼저 전체 상품 목록을 가져옵니다. 그다음 필터 조건을 하나씩 구독합니다.

여기서 중요한 것은 각각 select로 구독한다는 것입니다. 만약 select 없이 ref.watch(productFilterProvider)로 했다면 어떻게 될까요?

필터의 어느 값이라도 바뀌면 무조건 리빌드됩니다. searchQuery가 바뀌어도, minPrice가 바뀌어도, 심지어 category가 바뀌어도 리빌드됩니다.

하지만 select를 사용하면 정확히 필요한 값만 구독합니다. searchQuery가 바뀔 때만, 그것을 구독하는 위젯만 리빌드되는 것이죠.

필터링 로직은 간단합니다. where로 조건에 맞는 상품만 골라냅니다.

이름에 검색어가 포함되어 있고, 가격이 범위 안에 있으면 통과입니다. 실제 쇼핑몰에서는 더 복잡한 필터가 있습니다.

브랜드, 색상, 사이즈, 평점, 배송 옵션 등이죠. 필터가 많아질수록 select의 중요성은 더 커집니다.

김개발 씨가 테스트해봤습니다. "와, 이제 검색어를 입력할 때 부드럽게 움직여요!" select는 성능 최적화의 핵심 도구입니다.

하지만 모든 곳에 남발할 필요는 없습니다. 성능 문제가 실제로 느껴질 때, 또는 상태가 자주 바뀌는데 일부만 필요할 때 사용하면 됩니다.

초보자들이 자주 하는 실수는 select를 너무 복잡하게 쓰는 것입니다. 예를 들어 select((state) => state.a + state.b)처럼 계산을 넣으면 매번 새로운 값이 생성되어 오히려 더 자주 리빌드됩니다.

select는 단순한 값을 추출할 때만 쓰세요. 또 다른 팁은 Equatable이나 Freezed를 사용하는 것입니다.

이것들은 값 비교를 자동으로 해줍니다. select가 "값이 진짜 바뀌었는지" 판단할 때 도움이 됩니다.

박시니어 씨가 정리해줬습니다. "성능 최적화는 측정부터 해야 합니다.

느린 게 확실할 때 select를 쓰세요. 미리 쓸 필요는 없어요." 김개발 씨는 검색 기능을 완성하며 Riverpod의 강력함을 다시 한번 느꼈습니다.

실전 팁

💡 - select는 성능 문제가 실제로 있을 때만 사용하세요 (premature optimization 주의)

  • select 안에서 계산하지 말고 단순한 값만 추출하세요
  • Freezed를 사용하면 값 비교가 자동으로 되어 select와 궁합이 좋습니다

7. 테스트 코드 작성

모든 기능을 완성한 김개발 씨는 뿌듯한 마음으로 팀장님께 보고했습니다. "기능 다 완성했습니다!" 팀장님이 물었습니다.

"테스트는 작성했나요?" 김개발 씨는 당황했습니다. "테스트요?" 박시니어 씨가 다가와 말했습니다.

"Riverpod은 테스트하기 정말 쉬워요. 같이 해볼까요?"

Riverpod은 테스트 친화적으로 설계되었습니다. ProviderContainer를 사용하면 실제 앱 없이도 Provider를 테스트할 수 있고, Mock 객체로 의존성을 쉽게 대체할 수 있습니다.

비즈니스 로직을 검증하는 단위 테스트부터 위젯 테스트까지 모두 가능합니다.

다음 코드를 살펴봅시다.

// test/providers/cart_provider_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';

void main() {
  test('장바구니에 상품 추가 테스트', () {
    // ProviderContainer 생성 (앱 없이 Provider만 테스트)
    final container = ProviderContainer();

    // 초기 상태 확인
    expect(container.read(cartProvider), isEmpty);

    // 상품 추가
    final product = Product(id: '1', name: '노트북', price: 1000000, category: '전자제품');
    container.read(cartProvider.notifier).addProduct(product);

    // 상태 검증
    final cart = container.read(cartProvider);
    expect(cart.length, 1);
    expect(cart.first.product.name, '노트북');
    expect(cart.first.quantity, 1);

    // 같은 상품 다시 추가
    container.read(cartProvider.notifier).addProduct(product);

    // 수량이 증가했는지 확인
    final updatedCart = container.read(cartProvider);
    expect(updatedCart.length, 1);
    expect(updatedCart.first.quantity, 2);

    // 정리
    container.dispose();
  });

  test('장바구니 총 금액 계산 테스트', () {
    final container = ProviderContainer();

    final product1 = Product(id: '1', name: '노트북', price: 1000000, category: '전자제품');
    final product2 = Product(id: '2', name: '마우스', price: 30000, category: '전자제품');

    container.read(cartProvider.notifier).addProduct(product1);
    container.read(cartProvider.notifier).addProduct(product2);
    container.read(cartProvider.notifier).addProduct(product2); // 마우스 2개

    // 총 금액: 1,000,000 + (30,000 * 2) = 1,060,000
    final total = container.read(cartProvider.notifier).totalPrice;
    expect(total, 1060000);

    container.dispose();
  });

  test('AsyncNotifier 테스트 (주문하기)', () async {
    final container = ProviderContainer();

    // 장바구니에 상품 추가
    final product = Product(id: '1', name: '노트북', price: 1000000, category: '전자제품');
    container.read(cartProvider.notifier).addProduct(product);
    final items = container.read(cartProvider);

    // 주문 실행
    await container.read(orderSubmitProvider.notifier).submit(items);

    // 주문 성공 확인
    final orderState = container.read(orderSubmitProvider);
    expect(orderState.hasValue, true);
    expect(orderState.value?.items.length, 1);

    container.dispose();
  });
}

박시니어 씨가 테스트 파일을 열었습니다. "테스트는 귀찮게 느껴질 수 있지만, 나중에 시간을 엄청나게 절약해줍니다." 김개발 씨가 고개를 갸우뚱했습니다.

"왜 필요한가요? 직접 실행해보면 되잖아요." 좋은 질문입니다.

테스트가 왜 필요할까요? 첫째, 자신감을 줍니다.

코드를 수정해도 기존 기능이 깨지지 않았다는 것을 확인할 수 있습니다. 둘째, 문서 역할을 합니다.

테스트를 보면 코드가 어떻게 동작하는지 알 수 있습니다. 셋째, 리팩토링을 안전하게 만듭니다.

구조를 바꿔도 테스트만 통과하면 문제없습니다. 특히 Riverpod은 테스트하기 정말 쉽습니다.

ProviderContainer가 그 비밀입니다. 이것은 Provider들을 담는 컨테이너입니다.

실제 앱에서는 ProviderScope가 자동으로 만들어주지만, 테스트에서는 직접 만듭니다. 첫 번째 테스트를 보겠습니다.

"장바구니에 상품 추가 테스트"입니다. 먼저 ProviderContainer()로 컨테이너를 만듭니다.

이것만 있으면 모든 Provider를 사용할 수 있습니다. Flutter 위젯이 없어도 됩니다.

container.read(cartProvider)로 상태를 읽습니다. 초기에는 비어있어야 하므로 isEmpty를 확인합니다.

이것이 assertion입니다. 기대하는 값이 맞는지 검증하는 것이죠.

그다음 상품을 추가합니다. container.read(cartProvider.notifier).addProduct(product).

실제 앱에서 하는 것과 똑같습니다. 다시 상태를 읽어서 검증합니다.

길이가 1인지, 상품 이름이 맞는지, 수량이 1인지 확인합니다. 모든 것이 기대대로라면 테스트 통과입니다.

같은 상품을 다시 추가해봅니다. 이번에는 수량이 증가해야겠죠.

역시 검증합니다. 길이는 여전히 1이지만 수량이 2로 늘어났는지 확인합니다.

마지막에 container.dispose()를 호출합니다. 메모리 정리입니다.

두 번째 테스트는 총 금액 계산입니다. 이것은 비즈니스 로직 테스트입니다.

계산이 정확한지 검증하는 것이죠. 상품 두 개를 넣고, 하나는 2개 수량으로 만들고, 총 금액을 확인합니다.

실제로 쇼핑몰 버그의 상당수가 이런 계산 오류입니다. 할인이 잘못 적용되거나, 수량이 곱해지지 않거나.

테스트가 있으면 이런 버그를 미리 잡을 수 있습니다. 세 번째 테스트는 비동기 테스트입니다.

test 함수 앞에 async가 붙어있고, submit 호출 앞에 await가 있습니다. AsyncNotifier는 비동기로 동작하므로 기다려야 합니다.

주문을 실행하고 결과를 확인합니다. hasValue로 성공했는지, value?.items.length로 주문 항목 개수가 맞는지 검증합니다.

김개발 씨가 감탄했습니다. "위젯 없이 Provider만 테스트할 수 있다니!" 바로 그것이 Riverpod의 장점입니다.

비즈니스 로직을 UI와 완전히 분리할 수 있습니다. 로직만 빠르게 테스트하고, UI는 따로 위젯 테스트로 검증하면 됩니다.

실무에서는 더 복잡한 시나리오를 테스트합니다. 예를 들어 네트워크 에러가 났을 때, 토큰이 만료되었을 때, 동시에 여러 요청이 들어왔을 때 등이죠.

Mock 객체를 사용하면 더 강력해집니다. 실제 API를 호출하지 않고 가짜 응답을 만들 수 있습니다.

이렇게 하면 테스트가 빠르고 안정적입니다. dart final container = ProviderContainer( overrides: [ // 실제 Provider를 Mock으로 대체 productListProvider.overrideWith((ref, category) async { return [Product(id: '1', name: 'Test', price: 100, category: category)]; }), ], ); overrides를 사용하면 Provider를 다른 구현으로 바꿀 수 있습니다.

테스트용 데이터를 반환하는 것이죠. 초보자들이 자주 하는 실수는 테스트를 너무 세세하게 쓰는 것입니다.

모든 줄을 테스트할 필요는 없습니다. 중요한 비즈니스 로직과 엣지 케이스에 집중하세요.

또 다른 실수는 테스트가 서로 의존하는 것입니다. 각 테스트는 독립적이어야 합니다.

순서가 바뀌어도, 하나만 실행해도 통과해야 합니다. 박시니어 씨가 정리했습니다.

"테스트는 처음에는 시간이 걸리지만, 장기적으로 보면 엄청난 시간 절약입니다. 버그를 배포하기 전에 잡으니까요." 김개발 씨는 테스트를 작성하며 코드에 대한 확신이 생겼습니다.

"이제 안심하고 배포할 수 있겠어요!" 대형 기업들은 모두 테스트를 강조합니다. 구글, 페이스북, 넷플릭스 모두 높은 테스트 커버리지를 유지합니다.

Riverpod은 그런 프로페셔널한 개발 방식을 쉽게 만들어줍니다.

실전 팁

💡 - 비즈니스 로직이 복잡할수록 테스트의 가치가 커집니다

  • 엣지 케이스(빈 목록, null 값, 경계값 등)를 꼭 테스트하세요
  • CI/CD에 테스트를 통합하면 배포 전 자동으로 검증할 수 있습니다

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Flutter#Riverpod#StateManagement#ShoppingApp#AsyncNotifier#Flutter,Riverpod

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.