Firebase에 질렸다. 매달 나오는 요금 청구서, 갑자기 바뀌는 API, '이거 Google이 버리면 어쩌지?'라는 불안감. 그래서 Supabase + Riverpod 조합으로 갈아탔다. 결론부터 말하면? 최고의 선택이었지만, 그 과정에서 RLS 설정 하나 잘못해서 식은땀 흘린 적도 있다.

왜 이 조합인가?

Riverpod 선택 이유

Flutter의 상태관리 솔루션은 다양합니다. Provider, Bloc, GetX, Riverpod 등 각각 장단점이 있지만, Riverpod을 선택한 이유는:

Supabase 선택 이유

Firebase의 대안으로 Supabase를 선택했습니다:

프로젝트 구조

Feature-first 구조를 채택했습니다:

lib/
├── main.dart
├── core/
│   ├── config/
│   │   └── supabase_config.dart
│   ├── providers/
│   │   └── supabase_provider.dart
│   └── utils/
│       └── logger.dart
├── features/
│   ├── auth/
│   │   ├── data/
│   │   │   ├── repositories/
│   │   │   └── models/
│   │   ├── domain/
│   │   │   └── entities/
│   │   └── presentation/
│   │       ├── providers/
│   │       ├── screens/
│   │       └── widgets/
│   ├── game/
│   │   └── ...
│   └── leaderboard/
│       └── ...
└── shared/
    ├── widgets/
    └── extensions/

핵심 구현 패턴

1. Supabase 클라이언트 Provider

// core/providers/supabase_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

part 'supabase_provider.g.dart';

@Riverpod(keepAlive: true)
SupabaseClient supabaseClient(SupabaseClientRef ref) {
  return Supabase.instance.client;
}

@Riverpod(keepAlive: true)
Stream<AuthState> authStateChanges(AuthStateChangesRef ref) {
  final client = ref.watch(supabaseClientProvider);
  return client.auth.onAuthStateChange;
}

2. Repository 패턴

// features/game/data/repositories/score_repository.dart
@riverpod
class ScoreRepository extends _$ScoreRepository {
  @override
  FutureOr<void> build() {}

  Future<void> saveScore(int score, String gameId) async {
    final client = ref.read(supabaseClientProvider);
    final userId = client.auth.currentUser?.id;
    
    await client.from('scores').insert({
      'user_id': userId,
      'game_id': gameId,
      'score': score,
      'created_at': DateTime.now().toIso8601String(),
    });
  }

  Future<List<Score>> getTopScores(String gameId, {int limit = 10}) async {
    final client = ref.read(supabaseClientProvider);
    
    final response = await client
        .from('scores')
        .select('*, profiles(username, avatar_url)')
        .eq('game_id', gameId)
        .order('score', ascending: false)
        .limit(limit);
    
    return response.map((json) => Score.fromJson(json)).toList();
  }
}

3. AsyncValue 활용

Riverpod의 AsyncValue를 활용하면 로딩, 에러, 데이터 상태를 깔끔하게 처리할 수 있습니다:

@riverpod
Future<List<Score>> topScores(TopScoresRef ref, String gameId) async {
  final repository = ref.watch(scoreRepositoryProvider.notifier);
  return repository.getTopScores(gameId);
}

// UI에서 사용
class LeaderboardScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final scoresAsync = ref.watch(topScoresProvider(gameId));
    
    return scoresAsync.when(
      data: (scores) => ListView.builder(
        itemCount: scores.length,
        itemBuilder: (_, i) => ScoreTile(score: scores[i]),
      ),
      loading: () => const Center(child: CircularProgressIndicator()),
      error: (e, _) => ErrorWidget(message: e.toString()),
    );
  }
}

shared_game_kit 패키지

여러 게임에서 공통으로 사용하는 기능을 별도 패키지로 분리했습니다:

shared_game_kit/
├── lib/
│   ├── src/
│   │   ├── ads/           # AdMob 통합
│   │   ├── analytics/     # 분석 이벤트
│   │   ├── audio/         # 효과음/BGM
│   │   ├── auth/          # 인증 공통 로직
│   │   ├── iap/           # 인앱 결제
│   │   └── ui/            # 공통 위젯
│   └── shared_game_kit.dart
└── pubspec.yaml

각 게임 앱에서는 이 패키지를 의존성으로 추가합니다:

# pubspec.yaml
dependencies:
  shared_game_kit:
    path: ../packages/shared_game_kit
보안 사고 미수 경험: 사내 재고관리 앱을 Flutter + Riverpod + Supabase 스택으로 만들었을 때 아찔한 경험이 있습니다. 개발 단계에서 빠르게 기능을 만들려고 Supabase RLS(Row Level Security) 정책을 대충 설정했는데, QA 단계에서 A지점 직원이 B지점의 재고 데이터를 볼 수 있다는 것을 발견했습니다. 원인은 RLS 정책에서 branch_id 필터링을 빠뜨린 것이었습니다. 만약 이게 프로덕션에 나갔다면 각 지점의 매입 단가 같은 민감한 정보가 노출될 뻔했습니다. 이후로는 RLS 정책을 작성할 때 반드시 "다른 조직의 데이터에 접근 시도" 테스트 케이스를 먼저 만들고, Supabase의 SQL 에디터에서 다른 사용자 컨텍스트로 쿼리를 직접 날려보는 것을 필수 프로세스로 만들었습니다.

실전 팁

1. keepAlive 사용 시 주의

keepAlive: true는 Provider가 dispose되지 않도록 합니다. 인증 상태처럼 앱 전체에서 유지해야 하는 경우에만 사용하세요.

2. 에러 처리 중앙화

extension AsyncValueX<T> on AsyncValue<T> {
  Widget buildWidget({
    required Widget Function(T data) data,
    Widget Function()? loading,
    Widget Function(Object error)? error,
  }) {
    return when(
      data: data,
      loading: loading ?? () => const LoadingIndicator(),
      error: (e, _) => error?.call(e) ?? ErrorDisplay(error: e),
    );
  }
}

3. Supabase RLS 활용

Row Level Security로 데이터 접근을 제어합니다:

-- 사용자는 자신의 데이터만 수정 가능
CREATE POLICY "Users can update own data" ON profiles
  FOR UPDATE USING (auth.uid() = user_id);

이 조합을 추천하는 경우와 아닌 경우

이 아키텍처로 BitRoom Factory의 여러 게임을 빠르게 개발하고 있습니다. 핵심은 공통 로직의 재사용과 일관된 패턴 적용입니다.

다음 글에서는 AdMob 통합과 수익화 전략에 대해 다루겠습니다.

Jaeseong
Jaeseong

10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.

GitHub →

💬 댓글