feat(arch): GetIt DI + IIAPService/IAdService 인터페이스 도입

- core/di/: service_locator, IIAPService, IAdService 생성
- IAPService/AdService: implements 인터페이스 + GetIt 위임
- main.dart: setupServiceLocator() 호출
- 기존 .instance getter 호환성 100% 유지
- test/helpers/test_setup.dart: 테스트용 서비스 로케이터 초기화
This commit is contained in:
JiWoong Sul
2026-03-30 19:41:13 +09:00
parent c382d6d770
commit e051bd451a
11 changed files with 150 additions and 15 deletions

View File

@@ -1,6 +1,9 @@
import 'package:asciineverdie/src/app.dart'; import 'package:asciineverdie/src/app.dart';
import 'package:asciineverdie/src/core/di/service_locator.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
void main() { void main() {
// 서비스 로케이터(service locator) 초기화
setupServiceLocator();
runApp(const AskiiNeverDieApp()); runApp(const AskiiNeverDieApp());
} }

View File

@@ -0,0 +1,31 @@
import 'package:asciineverdie/src/core/engine/ad_service.dart';
/// 광고 서비스 인터페이스 (interface)
///
/// 광고 표시 기능의 추상화 계층.
/// 테스트 시 목(mock) 구현체로 교체 가능.
abstract class IAdService {
/// AdMob SDK 초기화
Future<void> initialize();
/// 리워드 광고(rewarded ad) 준비 여부
bool get isRewardedAdReady;
/// 인터스티셜 광고(interstitial ad) 준비 여부
bool get isInterstitialAdReady;
/// 리워드 광고 표시
Future<AdResult> showRewardedAd({
required AdType adType,
required void Function() onRewarded,
});
/// 인터스티셜 광고 표시
Future<AdResult> showInterstitialAd({
required AdType adType,
required void Function() onComplete,
});
/// 리소스 해제
void dispose();
}

View File

@@ -0,0 +1,34 @@
import 'package:asciineverdie/src/core/engine/iap_service.dart';
/// IAP 서비스 인터페이스 (interface)
///
/// 인앱 구매 기능의 추상화 계층.
/// 테스트 시 목(mock) 구현체로 교체 가능.
abstract class IIAPService {
/// IAP 서비스 초기화
Future<void> initialize();
/// 광고 제거 구매 여부
bool get isAdRemovalPurchased;
/// 스토어 가용성(store availability)
bool get isStoreAvailable;
/// 광고 제거 상품 가격 문자열
String get removeAdsPrice;
/// 광고 제거 구매
Future<IAPResult> purchaseRemoveAds();
/// 구매 복원(restore purchases)
Future<IAPResult> restorePurchases();
/// 디버그 모드 IAP 시뮬레이션(debug simulation) 활성화 여부
bool get debugIAPSimulated;
/// 디버그 모드 IAP 시뮬레이션 토글
set debugIAPSimulated(bool value);
/// 리소스 해제
void dispose();
}

View File

@@ -0,0 +1,21 @@
import 'package:get_it/get_it.dart';
import 'package:asciineverdie/src/core/di/i_ad_service.dart';
import 'package:asciineverdie/src/core/di/i_iap_service.dart';
import 'package:asciineverdie/src/core/engine/ad_service.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart';
/// 전역 서비스 로케이터(service locator) 인스턴스
final GetIt sl = GetIt.instance;
/// 서비스 로케이터 초기화
///
/// 앱 시작 시 한 번 호출하여 모든 서비스를 등록합니다.
/// 점진적 도입: IAPService, AdService만 먼저 등록.
void setupServiceLocator() {
// IAP 서비스 (싱글톤 등록)
sl.registerLazySingleton<IIAPService>(() => IAPService.createInstance());
// 광고 서비스 (싱글톤 등록)
sl.registerLazySingleton<IAdService>(() => AdService.createInstance());
}

View File

@@ -5,6 +5,9 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:get_it/get_it.dart';
import 'package:asciineverdie/src/core/di/i_ad_service.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart'; import 'package:asciineverdie/src/core/engine/iap_service.dart';
/// 광고 타입 /// 광고 타입
@@ -44,16 +47,14 @@ enum AdResult {
/// ///
/// AdMob 리워드/인터스티셜 광고 로드 및 표시를 관리합니다. /// AdMob 리워드/인터스티셜 광고 로드 및 표시를 관리합니다.
/// 디버그 모드에서는 광고 ON/OFF 토글이 가능합니다. /// 디버그 모드에서는 광고 ON/OFF 토글이 가능합니다.
class AdService { class AdService implements IAdService {
AdService._(); AdService._();
static AdService? _instance; /// GetIt 등록용 팩토리 메서드
factory AdService.createInstance() => AdService._();
/// 싱글톤 인스턴스 /// 싱글톤 인스턴스 (GetIt 서비스 로케이터에 위임)
static AdService get instance { static IAdService get instance => GetIt.instance<IAdService>();
_instance ??= AdService._();
return _instance!;
}
// =========================================================================== // ===========================================================================
// 광고 단위 ID // 광고 단위 ID
@@ -126,6 +127,7 @@ class AdService {
// =========================================================================== // ===========================================================================
/// AdMob SDK 초기화 /// AdMob SDK 초기화
@override
Future<void> initialize() async { Future<void> initialize() async {
if (_isInitialized) return; if (_isInitialized) return;
@@ -197,6 +199,7 @@ class AdService {
} }
/// 리워드 광고 준비 여부 /// 리워드 광고 준비 여부
@override
bool get isRewardedAdReady => _rewardedAd != null || _shouldSkipAd; bool get isRewardedAdReady => _rewardedAd != null || _shouldSkipAd;
/// 리워드 광고 표시 /// 리워드 광고 표시
@@ -204,6 +207,7 @@ class AdService {
/// [adType] 광고 타입 (로깅용) /// [adType] 광고 타입 (로깅용)
/// [onRewarded] 보상 지급 콜백 /// [onRewarded] 보상 지급 콜백
/// Returns: 광고 결과 /// Returns: 광고 결과
@override
Future<AdResult> showRewardedAd({ Future<AdResult> showRewardedAd({
required AdType adType, required AdType adType,
required void Function() onRewarded, required void Function() onRewarded,
@@ -308,6 +312,7 @@ class AdService {
} }
/// 인터스티셜 광고 준비 여부 /// 인터스티셜 광고 준비 여부
@override
bool get isInterstitialAdReady => _interstitialAd != null || _shouldSkipAd; bool get isInterstitialAdReady => _interstitialAd != null || _shouldSkipAd;
/// 인터스티셜 광고 표시 /// 인터스티셜 광고 표시
@@ -315,6 +320,7 @@ class AdService {
/// [adType] 광고 타입 (로깅용) /// [adType] 광고 타입 (로깅용)
/// [onComplete] 광고 완료 콜백 (보상 지급) /// [onComplete] 광고 완료 콜백 (보상 지급)
/// Returns: 광고 결과 /// Returns: 광고 결과
@override
Future<AdResult> showInterstitialAd({ Future<AdResult> showInterstitialAd({
required AdType adType, required AdType adType,
required void Function() onComplete, required void Function() onComplete,
@@ -386,6 +392,7 @@ class AdService {
// =========================================================================== // ===========================================================================
/// 리소스 해제 /// 리소스 해제
@override
void dispose() { void dispose() {
_rewardedAd?.dispose(); _rewardedAd?.dispose();
_rewardedAd = null; _rewardedAd = null;

View File

@@ -12,7 +12,10 @@ import 'package:pointycastle/asn1/primitives/asn1_bit_string.dart';
import 'package:pointycastle/asn1/primitives/asn1_integer.dart'; import 'package:pointycastle/asn1/primitives/asn1_integer.dart';
import 'package:pointycastle/asn1/primitives/asn1_sequence.dart'; import 'package:pointycastle/asn1/primitives/asn1_sequence.dart';
import 'package:get_it/get_it.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/src/core/di/i_iap_service.dart';
/// IAP 상품 ID /// IAP 상품 ID
class IAPProductIds { class IAPProductIds {
@@ -62,16 +65,14 @@ String get _googlePlayPublicKey => _gpKeyPart1 + _gpKeyPart2;
/// ///
/// 인앱 구매 (광고 제거) 처리를 담당합니다. /// 인앱 구매 (광고 제거) 처리를 담당합니다.
/// flutter_secure_storage를 사용하여 구매 상태를 보안 저장합니다. /// flutter_secure_storage를 사용하여 구매 상태를 보안 저장합니다.
class IAPService { class IAPService implements IIAPService {
IAPService._(); IAPService._();
static IAPService? _instance; /// GetIt 등록용 팩토리 메서드
factory IAPService.createInstance() => IAPService._();
/// 싱글톤 인스턴스 /// 싱글톤 인스턴스 (GetIt 서비스 로케이터에 위임)
static IAPService get instance { static IIAPService get instance => GetIt.instance<IIAPService>();
_instance ??= IAPService._();
return _instance!;
}
// =========================================================================== // ===========================================================================
// 상수 // 상수
@@ -109,6 +110,7 @@ class IAPService {
// =========================================================================== // ===========================================================================
/// IAP 서비스 초기화 /// IAP 서비스 초기화
@override
Future<void> initialize() async { Future<void> initialize() async {
if (_isInitialized) return; if (_isInitialized) return;
@@ -201,9 +203,11 @@ class IAPService {
// =========================================================================== // ===========================================================================
/// 디버그 모드 IAP 시뮬레이션 활성화 여부 /// 디버그 모드 IAP 시뮬레이션 활성화 여부
@override
bool get debugIAPSimulated => _debugIAPSimulated; bool get debugIAPSimulated => _debugIAPSimulated;
/// 디버그 모드 IAP 시뮬레이션 토글 /// 디버그 모드 IAP 시뮬레이션 토글
@override
set debugIAPSimulated(bool value) { set debugIAPSimulated(bool value) {
_debugIAPSimulated = value; _debugIAPSimulated = value;
if (kDebugMode) { if (kDebugMode) {
@@ -217,18 +221,21 @@ class IAPService {
// =========================================================================== // ===========================================================================
/// 광고 제거 구매 여부 /// 광고 제거 구매 여부
@override
bool get isAdRemovalPurchased { bool get isAdRemovalPurchased {
if (kDebugMode && _debugIAPSimulated) return true; if (kDebugMode && _debugIAPSimulated) return true;
return _adRemovalPurchased; return _adRemovalPurchased;
} }
/// 스토어 가용성 /// 스토어 가용성
@override
bool get isStoreAvailable => _isAvailable; bool get isStoreAvailable => _isAvailable;
/// 광고 제거 상품 정보 /// 광고 제거 상품 정보
ProductDetails? get removeAdsProduct => _removeAdsProduct; ProductDetails? get removeAdsProduct => _removeAdsProduct;
/// 광고 제거 상품 가격 문자열 /// 광고 제거 상품 가격 문자열
@override
String get removeAdsPrice { String get removeAdsPrice {
if (_removeAdsProduct != null) { if (_removeAdsProduct != null) {
return _removeAdsProduct!.price; return _removeAdsProduct!.price;
@@ -246,6 +253,7 @@ class IAPService {
// =========================================================================== // ===========================================================================
/// 광고 제거 구매 /// 광고 제거 구매
@override
Future<IAPResult> purchaseRemoveAds() async { Future<IAPResult> purchaseRemoveAds() async {
// 디버그 모드 시뮬레이션 // 디버그 모드 시뮬레이션
if (kDebugMode && _debugIAPSimulated) { if (kDebugMode && _debugIAPSimulated) {
@@ -290,6 +298,7 @@ class IAPService {
// =========================================================================== // ===========================================================================
/// 구매 복원 /// 구매 복원
@override
Future<IAPResult> restorePurchases() async { Future<IAPResult> restorePurchases() async {
// 디버그 모드 시뮬레이션 // 디버그 모드 시뮬레이션
if (kDebugMode && _debugIAPSimulated) { if (kDebugMode && _debugIAPSimulated) {
@@ -489,6 +498,7 @@ class IAPService {
// =========================================================================== // ===========================================================================
/// 리소스 해제 /// 리소스 해제
@override
void dispose() { void dispose() {
_subscription?.cancel(); _subscription?.cancel();
_subscription = null; _subscription = null;

View File

@@ -333,6 +333,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
get_it:
dependency: "direct main"
description:
name: get_it
sha256: ae78de7c3f2304b8d81f2bb6e320833e5e81de942188542328f074978cc0efa9
url: "https://pub.dev"
source: hosted
version: "8.3.0"
glob: glob:
dependency: transitive dependency: transitive
description: description:

View File

@@ -52,6 +52,8 @@ dependencies:
pointycastle: ^3.9.1 pointycastle: ^3.9.1
# 앱 버전 정보 # 앱 버전 정보
package_info_plus: ^8.3.0 package_info_plus: ^8.3.0
# 의존성 주입(Dependency Injection) 서비스 로케이터
get_it: ^8.0.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -4,6 +4,8 @@ import 'package:asciineverdie/src/features/new_character/new_character_screen.da
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_setup.dart';
/// 테스트용 MaterialApp 래퍼 (localization 포함) /// 테스트용 MaterialApp 래퍼 (localization 포함)
/// locale을 영어로 고정하여 테스트 텍스트와 일치시킴 /// locale을 영어로 고정하여 테스트 텍스트와 일치시킴
Widget _buildTestApp(Widget child) { Widget _buildTestApp(Widget child) {
@@ -16,6 +18,10 @@ Widget _buildTestApp(Widget child) {
} }
void main() { void main() {
// 서비스 로케이터(service locator) 초기화
setUpAll(() {
TestSetup.ensureServiceLocator();
});
testWidgets('NewCharacterScreen renders main sections', (tester) async { testWidgets('NewCharacterScreen renders main sections', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
_buildTestApp( _buildTestApp(

View File

@@ -1,6 +1,9 @@
import 'package:asciineverdie/src/core/audio/audio_service.dart'; import 'package:asciineverdie/src/core/audio/audio_service.dart';
import 'package:asciineverdie/src/core/di/i_iap_service.dart';
import 'package:asciineverdie/src/core/di/service_locator.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
/// 위젯 테스트에서 사용하는 공통 셋업/정리 유틸리티 /// 위젯 테스트에서 사용하는 공통 셋업/정리 유틸리티
/// ///
@@ -22,6 +25,15 @@ class TestSetup {
); );
} }
/// 서비스 로케이터(service locator) 초기화
///
/// 테스트에서 GetIt 의존성이 필요한 경우 setUpAll에서 호출
static void ensureServiceLocator() {
if (!GetIt.instance.isRegistered<IIAPService>()) {
setupServiceLocator();
}
}
/// 모든 싱글톤 서비스 정리 /// 모든 싱글톤 서비스 정리
/// ///
/// tearDown에서 호출하여 타이머 및 리소스 정리 /// tearDown에서 호출하여 타이머 및 리소스 정리

View File

@@ -4,9 +4,10 @@ import 'package:flutter_test/flutter_test.dart';
import 'helpers/test_setup.dart'; import 'helpers/test_setup.dart';
void main() { void main() {
// SharedPreferences 모킹 // SharedPreferences 모킹 및 서비스 로케이터 초기화
setUpAll(() { setUpAll(() {
TestSetup.mockSharedPreferences(); TestSetup.mockSharedPreferences();
TestSetup.ensureServiceLocator();
}); });
// 각 테스트 후 싱글톤 서비스 정리 (타이머 누수 방지) // 각 테스트 후 싱글톤 서비스 정리 (타이머 누수 방지)