본문 바로가기

📚 Flutter 애드몹 보상형 광고 구현 완전 가이드 - Riverpod과 SharedPreferences 활용법

ironwhale 2025. 8. 4.

Flutter 앱 개발에서 수익화를 고민하고 계신가요? 배너 광고만으로는 충분한 광고 수익을 얻기 어렵고, 사용자 경험을 해치지 않는 광고 모델이 필요하죠. 이번 포스트에서는 보상형 광고(RewardedAd)를 통해 사용자 참여도와 광고 수익을 동시에 높이는 방법을 구현해보겠습니다. 30분 보상 시간 관리, 자동 재시도 로직, 상태 기반 UI 관리까지 모든 기능을 담은 완전한 솔루션을 제공합니다. Riverpod과 SharedPreferences를 활용한 실무급 구현 방법까지 한번에 알아보세요!

🔧 애드몹 보상형 광고 핵심 개념

보상형 광고(RewardedAd)란?

보상형 광고는 사용자가 동영상 광고를 끝까지 시청하고 명확한 보상을 받는 광고 형식입니다.

// 기본 보상형 광고 구조
RewardedAd.load(
  adUnitId: 'YOUR_AD_UNIT_ID',
  request: const AdRequest(),
  rewardedAdLoadCallback: RewardedAdLoadCallback(
    onAdLoaded: (RewardedAd ad) {
      // 광고 로드 성공
    },
    onAdFailedToLoad: (LoadAdError error) {
      // 광고 로드 실패
    },
  ),
);

코드 설명:

  • adUnitId: AdMob 콘솔에서 생성한 광고 단위 ID
  • AdRequest: 광고 요청 설정 (타겠팅, 키워드 등)
  • RewardedAdLoadCallback: 광고 로드 결과를 처리하는 콜백
  • 사용 이유: 배너나 전면 광고와 달리 사용자에게 명확한 가치를 제공하여 참여도가 높음

상태 기반 광고 관리 시스템

복잡한 광고 생명주기를 체계적으로 관리하기 위해 5가지 상태로 구분합니다.

enum RewardedAdState { 
  unrewarded,  // 보상 없음 - 광고 시청 필요
  loading,     // 광고 로딩 중 - 사용자 대기
  loaded,      // 광고 로드 완료 - 시청 가능
  failed,      // 광고 로드 실패 - 재시도 필요
  rewarded     // 보상 획득 - 30분간 혜택 제공
}

상태별 역할:

  • unrewarded: 초기 상태, 광고 로드 시작 트리거
  • loading: UI에서 로딩 인디케이터 표시
  • loaded: "보상 받기" 버튼 활성화
  • failed: "다시 시도" 버튼으로 변경
  • rewarded: 보상 활성화, 버튼 비활성화

🔍 프로젝트 설정 및 기본 구조

필수 패키지 추가

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  google_mobile_ads: ^6.0.0    # 구글 애드몹 SDK
  hooks_riverpod: ^2.6.1       # 상태 관리
  shared_preferences: ^2.0.0   # 로컬 데이터 저장

패키지 선택 이유:

  • google_mobile_ads: 구글 공식 광고 SDK로 안정성과 최신 기능 보장
  • hooks_riverpod: 복잡한 상태 관리를 간단하게 처리
  • shared_preferences: 앱 재시작 후에도 보상 시간 유지

상수 및 기본 설정

// lib/providers/reward_ad_provider.dart
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';

part 'reward_ad_provider.g.dart';

// 비즈니스 로직 상수
const int rewardValidityMinutes = 30;  // 보상 유효 시간
const int maxRetryAttempts = 3;        // 최대 재시도 횟수

// 광고 단위 ID (실제 서비스에서는 환경별로 분리)
const String adRewardId = Platform.isAndroid
    ? 'ca-app-pub-3940256099942544/5224354917'  // Android 테스트 ID
    : 'ca-app-pub-3940256099942544/1712485313'; // iOS 테스트 ID

💡 : 테스트 단계에서는 반드시 구글 제공 테스트 ID를 사용하세요. 실제 ID로 테스트하면 계정 정지 위험이 있습니다.

📝 Riverpod 상태 관리 구현

RewardState 클래스 기본 구조

@riverpod
class RewardState extends _$RewardState {
  static const String _rewardedTimeKey = 'rewarded_time';

  RewardedAd? _rewardedAd;
  int retryAttempt = 0;

  @override
  RewardedAdState build() {
    _checkReward(); // 앱 시작 시 보상 상태 확인
    return RewardedAdState.unrewarded;
  }

  // 메모리 정리 메서드
  void dispose() {
    _rewardedAd?.dispose();
    _rewardedAd = null;
  }
}

핵심 특징:

  • _rewardedAd: 현재 로드된 광고 인스턴스를 보관
  • retryAttempt: 네트워크 불안정 상황에서 재시도 횟수 추적
  • dispose(): 메모리 누수 방지를 위한 정리 작업

보상 유효성 검사 로직

Future<void> _checkReward() async {
  final prefs = await SharedPreferences.getInstance();
  final rewardedTimeMillis = prefs.getInt(_rewardedTimeKey);

  if (rewardedTimeMillis != null) {
    final rewardedTime = DateTime.fromMillisecondsSinceEpoch(rewardedTimeMillis);
    final now = DateTime.now();
    final timeDifference = now.difference(rewardedTime);

    // 30분 이내면 보상 상태 유지
    if (timeDifference.inMinutes < rewardValidityMinutes) {
      state = RewardedAdState.rewarded;
      return;
    }
  }

  // 보상 시간 만료 또는 최초 실행
  state = RewardedAdState.unrewarded;
  loadAd(); // 자동으로 광고 로드 시작
}

동작 흐름:

1단계: 저장된 보상 시간 조회

SharedPreferences에서 마지막 보상 획득 시간을 밀리초 단위로 조회합니다.

2단계: 시간 차이 계산

현재 시간과 보상 시간의 차이를 분 단위로 계산하여 30분 이내인지 확인합니다.

3단계: 상태 결정

유효하면 rewarded 상태로 설정, 만료되면 새로운 광고 로드를 시작합니다.

⚠️ 주의사항: 시간 조작 방지가 중요한 서비스라면 서버 시간 검증을 추가로 구현하세요.

🔄 광고 로드 및 재시도 시스템

스마트 재시도 로직

void loadAd() {
  state = RewardedAdState.loading;

  RewardedAd.load(
    adUnitId: adRewardId,
    request: const AdRequest(),
    rewardedAdLoadCallback: RewardedAdLoadCallback(
      onAdLoaded: (RewardedAd ad) {
        _rewardedAd = ad;
        state = RewardedAdState.loaded;
        retryAttempt = 0; // 성공 시 재시도 횟수 초기화
        _setFullScreenContentCallback();
      },
      onAdFailedToLoad: (LoadAdError error) {
        print('광고 로드 실패: ${error.message}');
        _rewardedAd = null;

        // 최대 3회까지 재시도 with 지수적 백오프
        if (retryAttempt < maxRetryAttempts) {
          retryAttempt++;
          final delaySeconds = retryAttempt * retryAttempt; // 1초, 4초, 9초
          Future.delayed(
            Duration(seconds: delaySeconds), 
            loadAd
          );
        } else {
          state = RewardedAdState.failed;
          retryAttempt = 0; // 실패 후 재시도 횟수 초기화
        }
      },
    ),
  );
}

재시도 전략 세부사항:

  • 지수적 백오프: 1초 → 4초 → 9초로 점진적 대기
  • 최대 3회 제한: 무한 재시도로 인한 사용자 경험 저하 방지
  • 네트워크 고려: 임시적 연결 불량 상황을 효과적으로 처리

사용 이유: 광고 재고 부족이나 일시적 네트워크 문제를 자동으로 해결하여 사용자가 수동으로 재시도할 필요를 줄입니다.

전체 화면 광고 콜백 설정

void _setFullScreenContentCallback() {
  if (_rewardedAd == null) return;

  _rewardedAd!.fullScreenContentCallback = FullScreenContentCallback(
    onAdShowedFullScreenContent: (RewardedAd ad) {
      print('보상형 광고가 표시되었습니다.');
      // 앱의 다른 기능 일시 정지 (음악, 타이머 등)
      _pauseAppFunctions();
    },

    onAdDismissedFullScreenContent: (RewardedAd ad) {
      print('사용자가 광고를 종료했습니다.');
      ad.dispose(); // 메모리에서 광고 객체 제거
      _rewardedAd = null;

      // 중요: 보상 상태를 다시 확인하고 필요시 새 광고 로드
      _checkReward(); 
      _resumeAppFunctions(); // 앱 기능 재개
    },

    onAdFailedToShowFullScreenContent: (RewardedAd ad, AdError error) {
      print('광고 표시 실패: ${error.message}');
      ad.dispose();
      _rewardedAd = null;
      state = RewardedAdState.failed;

      // 사용자에게 오류 피드백
      _showErrorSnackBar(error.message);
    },
  );
}

// 앱 기능 일시 정지/재개 헬퍼 메서드
void _pauseAppFunctions() {
  // 예: 배경 음악 일시 정지, 게임 타이머 정지
}

void _resumeAppFunctions() {
  // 예: 배경 음악 재개, 게임 타이머 재시작
}

콜백 실행 순서:

사용자 "보상 받기" 클릭
    ↓
onAdShowedFullScreenContent
    ↓
사용자 광고 시청 완료
    ↓  
onUserEarnedReward (showAd에서 설정)
    ↓
사용자 광고 창 닫기
    ↓
onAdDismissedFullScreenContent

🎨 광고 표시 및 보상 처리

보상 지급 로직

void showAd() {
  if (_rewardedAd == null) {
    // 광고가 없으면 다시 로드 시도
    loadAd();
    return;
  }

  _rewardedAd!.show(
    onUserEarnedReward: (AdWithoutView ad, RewardItem reward) async {
      print('보상 획득: ${reward.type} ${reward.amount}');

      // 보상 시간을 현재 시간으로 저장
      final prefs = await SharedPreferences.getInstance();
      await prefs.setInt(
        _rewardedTimeKey, 
        DateTime.now().millisecondsSinceEpoch
      );

      state = RewardedAdState.rewarded;

      // 보상 관련 비즈니스 로직 실행
      await _processReward(reward);
    },
  );
}

// 보상 처리 비즈니스 로직
Future<void> _processReward(RewardItem reward) async {
  // 예시: 사용자 포인트 증가, 아이템 지급 등
  // await userService.addPoints(reward.amount);
  // await analyticsService.logRewardEarned(reward.type);
}

핵심 특징:

  • onUserEarnedReward: 사용자가 광고를 끝까지 시청했을 때만 실행
  • 즉시 저장: 보상 시간을 밀리초 단위로 정확히 저장
  • 확장성: 포인트 지급, 아이템 제공 등 다양한 보상 로직 추가 가능

보상 초기화 기능 (개발/테스트용)

// 관리자 기능 또는 개발 중 테스트용
Future<void> resetReward() async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.remove(_rewardedTimeKey);
  _checkReward(); // 상태 재확인
}

// 남은 보상 시간 조회
Future<int> getRemainingMinutes() async {
  final prefs = await SharedPreferences.getInstance();
  final rewardedTimeMillis = prefs.getInt(_rewardedTimeKey);

  if (rewardedTimeMillis == null) return 0;

  final rewardedTime = DateTime.fromMillisecondsSinceEpoch(rewardedTimeMillis);
  final now = DateTime.now();
  final remaining = rewardValidityMinutes - now.difference(rewardedTime).inMinutes;

  return remaining > 0 ? remaining : 0;
}

⚙️ UI 컴포넌트 구현

보상 버튼 위젯

class RewardedAdButton extends ConsumerWidget {
  const RewardedAdButton({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final rewardedAdState = ref.watch(rewardStateProvider);
    final rewardedAdNotifier = ref.read(rewardStateProvider.notifier);

    return ElevatedButton(
      onPressed: _getButtonAction(rewardedAdState, rewardedAdNotifier),
      style: ElevatedButton.styleFrom(
        backgroundColor: _getButtonColor(rewardedAdState),
        minimumSize: const Size(200, 48),
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(24),
        ),
      ),
      child: _buildButtonContent(rewardedAdState),
    );
  }

  VoidCallback? _getButtonAction(
    RewardedAdState state, 
    RewardState notifier
  ) {
    // 보상 획득 상태 또는 로딩 중일 때 버튼 비활성화
    if (state == RewardedAdState.rewarded || 
        state == RewardedAdState.loading) {
      return null;
    }

    return () {
      switch (state) {
        case RewardedAdState.loaded:
          notifier.showAd();
          break;
        case RewardedAdState.failed:
        case RewardedAdState.unrewarded:
          notifier.loadAd();
          break;
        default:
          break;
      }
    };
  }

  Widget _buildButtonContent(RewardedAdState state) {
    if (state == RewardedAdState.loading) {
      return const Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          SizedBox(
            width: 16,
            height: 16,
            child: CircularProgressIndicator(
              strokeWidth: 2,
              color: Colors.white,
            ),
          ),
          SizedBox(width: 8),
          Text('광고 로딩 중...'),
        ],
      );
    }

    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Icon(_getButtonIcon(state)),
        const SizedBox(width: 8),
        Text(_getButtonText(state)),
      ],
    );
  }

  IconData _getButtonIcon(RewardedAdState state) {
    switch (state) {
      case RewardedAdState.loaded:
        return Icons.play_circle_filled;
      case RewardedAdState.failed:
        return Icons.refresh;
      case RewardedAdState.rewarded:
        return Icons.check_circle;
      default:
        return Icons.card_giftcard;
    }
  }

  String _getButtonText(RewardedAdState state) {
    switch (state) {
      case RewardedAdState.loading:
        return '광고 로딩 중...';
      case RewardedAdState.loaded:
        return '보상 받기';
      case RewardedAdState.failed:
        return '다시 시도';
      case RewardedAdState.rewarded:
        return '보상 획득 완료';
      case RewardedAdState.unrewarded:
        return '보상 받기';
    }
  }

  Color _getButtonColor(RewardedAdState state) {
    switch (state) {
      case RewardedAdState.loaded:
        return Colors.green;
      case RewardedAdState.failed:
        return Colors.orange;
      case RewardedAdState.rewarded:
        return Colors.grey;
      default:
        return Colors.blue;
    }
  }
}

보상 상태 표시 위젯

class RewardStatusWidget extends ConsumerWidget {
  const RewardStatusWidget({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final rewardedAdState = ref.watch(rewardStateProvider);

    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: _getStatusColor(rewardedAdState).withOpacity(0.1),
        borderRadius: BorderRadius.circular(12),
        border: Border.all(
          color: _getStatusColor(rewardedAdState),
          width: 2,
        ),
      ),
      child: Column(
        children: [
          Icon(
            _getStatusIcon(rewardedAdState),
            size: 48,
            color: _getStatusColor(rewardedAdState),
          ),
          const SizedBox(height: 12),
          Text(
            _getStatusMessage(rewardedAdState),
            style: TextStyle(
              color: _getStatusColor(rewardedAdState),
              fontWeight: FontWeight.bold,
              fontSize: 16,
            ),
          ),
          const SizedBox(height: 8),
          if (rewardedAdState == RewardedAdState.rewarded)
            FutureBuilder<String>(
              future: _getRemainingTimeText(),
              builder: (context, snapshot) {
                return Text(
                  snapshot.data ?? '계산 중...',
                  style: TextStyle(
                    fontSize: 14,
                    color: Colors.grey[600],
                  ),
                );
              },
            )
          else
            Text(
              _getStatusDescription(rewardedAdState),
              style: TextStyle(
                fontSize: 14,
                color: Colors.grey[600],
              ),
              textAlign: TextAlign.center,
            ),
        ],
      ),
    );
  }

  Future<String> _getRemainingTimeText() async {
    final prefs = await SharedPreferences.getInstance();
    final rewardedTimeMillis = prefs.getInt('rewarded_time');

    if (rewardedTimeMillis == null) return '남은 시간: 0분';

    final rewardedTime = DateTime.fromMillisecondsSinceEpoch(rewardedTimeMillis);
    final now = DateTime.now();
    final remaining = rewardValidityMinutes - now.difference(rewardedTime).inMinutes;

    return remaining > 0 ? '남은 시간: ${remaining}분' : '보상 시간 만료';
  }

  IconData _getStatusIcon(RewardedAdState state) {
    switch (state) {
      case RewardedAdState.rewarded:
        return Icons.check_circle;
      case RewardedAdState.loading:
        return Icons.hourglass_empty;
      case RewardedAdState.loaded:
        return Icons.play_circle;
      case RewardedAdState.failed:
        return Icons.error;
      default:
        return Icons.card_giftcard;
    }
  }

  Color _getStatusColor(RewardedAdState state) {
    switch (state) {
      case RewardedAdState.rewarded:
        return Colors.green;
      case RewardedAdState.loading:
        return Colors.orange;
      case RewardedAdState.loaded:
        return Colors.blue;
      case RewardedAdState.failed:
        return Colors.red;
      default:
        return Colors.grey;
    }
  }

  String _getStatusMessage(RewardedAdState state) {
    switch (state) {
      case RewardedAdState.rewarded:
        return '프리미엄 기능 활성화 중';
      case RewardedAdState.loading:
        return '광고 준비 중';
      case RewardedAdState.loaded:
        return '광고 준비 완료';
      case RewardedAdState.failed:
        return '광고 로드 실패';
      default:
        return '보상 대기 중';
    }
  }

  String _getStatusDescription(RewardedAdState state) {
    switch (state) {
      case RewardedAdState.loading:
        return '잠시만 기다려주세요';
      case RewardedAdState.loaded:
        return '버튼을 눌러 광고를 시청하세요';
      case RewardedAdState.failed:
        return '네트워크를 확인하고 다시 시도해주세요';
      default:
        return '광고를 시청하면 30분간 프리미엄 기능을 이용할 수 있습니다';
    }
  }
}

🚀 완전한 구현 예시 및 활용법

메인 애플리케이션 구조

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ProviderScope(
      child: MaterialApp(
        title: '보상형 광고 데모',
        theme: ThemeData(
          primarySwatch: Colors.blue,
          visualDensity: VisualDensity.adaptivePlatformDensity,
        ),
        home: const MainScreen(),
      ),
    );
  }
}

class MainScreen extends ConsumerStatefulWidget {
  const MainScreen({super.key});

  @override
  ConsumerState<MainScreen> createState() => _MainScreenState();
}

class _MainScreenState extends ConsumerState<MainScreen> {
  @override
  void dispose() {
    // 메모리 정리
    ref.read(rewardStateProvider.notifier).dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('보상형 광고 데모'),
        centerTitle: true,
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            const SizedBox(height: 32),

            // 상태 표시 위젯
            const RewardStatusWidget(),

            const SizedBox(height: 32),

            // 보상 버튼
            const RewardedAdButton(),

            const SizedBox(height: 24),

            // 안내 메시지
            Container(
              padding: const EdgeInsets.all(16),
              decoration: BoxDecoration(
                color: Colors.blue[50],
                borderRadius: BorderRadius.circular(8),
                border: Border.all(color: Colors.blue[200]!),
              ),
              child: Column(
                children: [
                  Icon(
                    Icons.info_outline,
                    color: Colors.blue[600],
                    size: 32,
                  ),
                  const SizedBox(height: 8),
                  Text(
                    '광고 시청 혜택',
                    style: TextStyle(
                      fontSize: 16,
                      fontWeight: FontWeight.bold,
                      color: Colors.blue[800],
                    ),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    '• 30분간 광고 없는 프리미엄 경험\n'
                    '• 추가 기능 및 콘텐츠 이용 가능\n'
                    '• 앱 재시작 후에도 혜택 유지',
                    style: TextStyle(
                      color: Colors.blue[700],
                      height: 1.5,
                    ),
                  ),
                ],
              ),
            ),

            const SizedBox(height: 24),

            // 개발자 도구 (디버그 모드에서만 표시)
            if (kDebugMode) _buildDeveloperTools(),
          ],
        ),
      ),
    );
  }

  Widget _buildDeveloperTools() {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.grey[100],
        borderRadius: BorderRadius.circular(8),
        border: Border.all(color: Colors.grey[300]!),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            '개발자 도구',
            style: TextStyle(
              fontSize: 14,
              fontWeight: FontWeight.bold,
              color: Colors.grey[700],
            ),
          ),
          const SizedBox(height: 8),
          ElevatedButton(
            onPressed: () {
              ref.read(rewardStateProvider.notifier).resetReward();
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(content: Text('보상 상태가 초기화되었습니다')),
              );
            },
            style: ElevatedButton.styleFrom(
              backgroundColor: Colors.red,
              foregroundColor: Colors.white,
            ),
            child: const Text('보상 상태 초기화'),
          ),
        ],
      ),
    );
  }
}

실제 서비스 적용 시 고려사항

1단계: 환경별 광고 ID 관리

class AdConfig {
  static String get rewardedAdId {
    if (kDebugMode) {
      // 테스트 환경
      return Platform.isAndroid
          ? 'ca-app-pub-3940256099942544/5224354917'
          : 'ca-app-pub-3940256099942544/1712485313';
    } else {
      // 프로덕션 환경
      return Platform.isAndroid
          ? 'ca-app-pub-YOUR_PUBLISHER_ID/YOUR_ANDROID_REWARD_ID'
          : 'ca-app-pub-YOUR_PUBLISHER_ID/YOUR_IOS_REWARD_ID';
    }
  }
}

2단계: 사용자 경험 최적화

// 앱 시작 시 광고 미리 로드
class AppInitializer {
  static Future<void> initialize() async {
    // AdMob 초기화
    await MobileAds.instance.initialize();

    // 광고 설정 최적화
    await MobileAds.instance.updateRequestConfiguration(
      RequestConfiguration(
        testDeviceIds: kDebugMode ? ['YOUR_TEST_DEVICE_ID'] : [],
        tagForChildDirectedTreatment: TagForChildDirectedTreatment.unspecified,
      ),
    );
  }
}

3단계: 분석 이벤트 연동

// Firebase Analytics 연동 예시
class AdAnalytics {
  static Future<void> logRewardedAdEvent(String eventName, {
    Map<String, dynamic>? parameters,
  }) async {
    // await FirebaseAnalytics.instance.logEvent(
    //   name: eventName,
    //   parameters: parameters,
    // );
  }

  static Future<void> logAdLoaded() async {
    await logRewardedAdEvent('rewarded_ad_loaded');
  }

  static Future<void> logAdShown() async {
    await logRewardedAdEvent('rewarded_ad_shown');
  }

  static Future<void> logRewardEarned(String rewardType, int amount) async {
    await logRewardedAdEvent('reward_earned', parameters: {
      'reward_type': rewardType,
      'reward_amount': amount,
    });
  }
}

고급 기능 확장

A/B 테스트를 위한 보상 시간 설정

class RewardConfig {
  static int getRewardValidityMinutes() {
    // Firebase Remote Config 등을 통한 동적 설정
    // return RemoteConfig.instance.getInt('reward_validity_minutes') ?? 30;
    return 30; // 기본값
  }

  static bool isRewardFeatureEnabled() {
    // 기능 플래그를 통한 제어
    // return RemoteConfig.instance.getBool('reward_feature_enabled') ?? true;
    return true;
  }
}

네트워크 상태 기반 재시도 로직

import 'package:connectivity_plus/connectivity_plus.dart';

class NetworkAwareAdLoader {
  static Future<bool> isNetworkAvailable() async {
    final connectivityResult = await Connectivity().checkConnectivity();
    return connectivityResult != ConnectivityResult.none;
  }

  static Future<void> loadAdWithNetworkCheck(VoidCallback loadAd) async {
    if (await isNetworkAvailable()) {
      loadAd();
    } else {
      // 네트워크 연결 대기 후 재시도
      await _waitForNetwork();
      loadAd();
    }
  }

  static Future<void> _waitForNetwork() async {
    await for (final result in Connectivity().onConnectivityChanged) {
      if (result != ConnectivityResult.none) {
        break;
      }
    }
  }
}

📋 핵심 구현 특징 및 활용법

핵심 구현 특징

이번 포스트에서는 Flutter 애드몹 보상형 광고 시스템에 대해 Riverpod 상태 관리SharedPreferences 데이터 영속성을 구현해보았습니다.

핵심 구현 사항:

  • 5단계 상태 관리: unrewarded → loading → loaded → rewarded 생명주기 관리
  • 스마트 재시도 시스템: 지수적 백오프 알고리즘으로 네트워크 불안정 상황 대응
  • 30분 보상 시간 추적: SharedPreferences를 통한 앱 재시작 후에도 지속되는 보상 관리
  • 자동 광고 로드: 앱 시작 시 자동으로 광고 준비하여 사용자 대기 시간 최소화

기술적 특징:

  • Riverpod StateNotifier: 복잡한 광고 상태를 반응형으로 관리
  • 메모리 최적화: dispose() 패턴으로 광고 객체 메모리 누수 방지
  • 오류 처리: 광고 로드 실패, 표시 실패 등 모든 예외 상황 처리
  • 사용자 경험: 로딩 인디케이터, 상태별 버튼 변화로 직관적인 UI 제공

사용자 경험:

  • 명확한 피드백: 각 상태별로 다른 버튼 색상과 텍스트로 현재 상황 표시
  • 자동 복구: 네트워크 오류 시 자동 재시도로 사용자 개입 최소화
  • 연속성 보장: 앱 종료 후 재시작해도 보상 시간 유지
  • 개발자 도구: 디버그 모드에서 쉬운 테스트를 위한 초기화 기능 제공

이 구현을 통해 사용자는 끊김 없는 보상 경험을 누리며, 개발자는 안정적인 광고 수익을 얻을 수 있습니다. Riverpod의 강력한 상태 관리SharedPreferences의 간단한 로컬 저장을 조합하여 실무에서 바로 활용 가능한 보상형 광고 시스템을 마련했습니다.

💡 추가 활용 팁: 이 시스템을 기반으로 여러 종류의 보상(포인트 지급, 아이템 제공, 기능 잠금 해제 등)을 구현할 수 있으며, Firebase Remote Config와 연동하여 보상 시간이나 혜택을 실시간으로 조정할 수 있습니다.

📚 참고 자료: Google AdMob 공식 문서, Riverpod 공식 가이드, SharedPreferences 사용법

댓글