-
Notifications
You must be signed in to change notification settings - Fork 0
[PC-958] 인앱결제 구현 #143
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[PC-958] 인앱결제 구현 #143
Conversation
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... 📒 Files selected for processing (7)
Tip You can disable the changed files summary in the walkthrough.Disable the 요약이 PR은 Google Play 결제 기능을 추가하고 Pause 기능을 제거하는 대규모 변경사항입니다. 새로운 결제 및 청구 모듈을 도입하고, 청구 도우미와 결제 저장소를 구현하며, Store 화면을 적응형 레이아웃으로 리팩토링합니다. 워크스루이 PR은 Google Play 결제 기능을 추가하여 상품 조회, 결제 처리, 구매 확인을 지원합니다. 청구 모듈, 결제 저장소, 도메인 모델을 신규 추가하고 Pause 기능을 완전 제거하며 Store UI를 적응형 레이아웃으로 개선합니다. 변경사항
시퀀스 다이어그램sequenceDiagram
participant User
participant StoreScreen
participant StoreViewModel
participant PaymentRepository
participant BillingHelper
participant GooglePlay
User->>StoreScreen: 상품 선택 (구매 클릭)
StoreScreen->>StoreViewModel: OnPurchaseClick(PieceProduct)
StoreViewModel->>StoreViewModel: StoreSideEffect.PurchaseProduct 발행
StoreScreen->>BillingHelper: purchaseProduct(Activity, PieceProduct)
BillingHelper->>GooglePlay: launchBillingFlow()
GooglePlay-->>BillingHelper: 구매 완료/취소
BillingHelper->>StoreViewModel: purchaseProduct StateFlow 업데이트
StoreViewModel->>PaymentRepository: purchaseProduct(PurchaseProduct)
PaymentRepository->>PaymentRepository: 서버에 구매 확인 전송
rect rgb(200, 220, 255)
Note over StoreScreen,PaymentRepository: 초기화 플로우
StoreViewModel->>PaymentRepository: getAvailableProduct()
PaymentRepository-->>StoreViewModel: CashProduct 반환
StoreViewModel->>BillingHelper: getAvailableProducts()
BillingHelper->>GooglePlay: queryProductDetails()
GooglePlay-->>BillingHelper: ProductDetails 반환
BillingHelper-->>StoreViewModel: ImmutableList<PieceProduct> 반환
StoreViewModel->>StoreViewModel: state 업데이트 (promotionList, normalList)
StoreViewModel-->>StoreScreen: 상태 업데이트
end
코드 리뷰 예상 난이도🎯 4 (복잡함) | ⏱️ ~60분 분석:
관련 PR
시
Pre-merge checks and finishing touches❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
- PromotionProduct 모델 수정 - PromotionProductResponse 모델 수정 - PreviewPromotionCard UI 삭제 이후 PieceImage로 대체
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 18
🧹 Nitpick comments (19)
core/domain/src/main/java/com/puzzle/domain/model/payment/PurchaseProduct.kt (1)
3-7: 필드명 개선을 고려하세요.
credential필드명이 다소 모호합니다. 이 필드가 구매 토큰을 담고 있다면purchaseToken과 같이 더 명시적인 이름을 사용하는 것이 코드 가독성과 유지보수성을 향상시킬 수 있습니다.data class PurchaseProduct( val uuid: String, - val credential: String, + val purchaseToken: String, val store: Store = Store.PLAY_STORE, )core/billing/src/main/java/com/puzzle/billing/model/PieceProduct.kt (1)
17-19: 서버 연동 필드에 대한 명확화가 필요합니다.주석으로 "서버에서 내려줘야 함"이라고 표시된 필드들이 있습니다. 이는 불완전한 구현을 시사합니다. 현재 구현이 완전한지 확인하고, 향후 서버 연동 계획이 있다면 이슈로 추적하는 것을 권장합니다.
이 필드들에 대한 구현 계획을 추적할 이슈를 생성하시겠습니까?
core/data/src/main/java/com/puzzle/data/billing/BillingHelperImpl.kt (5)
42-52: 구매 검증 로직 추가를 고려하세요.
purchasesUpdatedListener에서 구매가 성공적으로 완료되었는지 추가 검증 없이 바로_purchaseProduct에 값을 설정합니다.purchase.purchaseState가Purchase.PurchaseState.PURCHASED인지 확인하는 것이 안전합니다.private val purchasesUpdatedListener = PurchasesUpdatedListener { result, purchases -> Log.d("purchasesUpdateListener", "$result $purchases") if (result.responseCode == BillingResponseCode.OK && !purchases.isNullOrEmpty()) { purchases.forEach { purchase -> + if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) return@forEach val token = purchase.purchaseToken val uuid = purchase.products.firstOrNull() ?: return@forEach _purchaseProduct.value = PurchaseProduct(uuid = uuid, credential = token) } } }
64-91: 에러 처리 추가를 고려하세요.
queryProductDetails호출 시 에러가 발생할 수 있지만 현재 에러 처리가 없습니다. BillingClient 호출이 실패하는 경우를 처리하는 것이 좋습니다.override suspend fun getAvailableProducts(cashProducts: CashProduct): List<PieceProduct> { connectGooglePlayIfNeeded() val productList = cashProducts.products.map { QueryProductDetailsParams.Product.newBuilder() .setProductId(it.uuid) .setProductType(BillingClient.ProductType.INAPP) .build() } val params = QueryProductDetailsParams.newBuilder() .setProductList(productList) .build() - val result = withContext(Dispatchers.IO) { billingClient.queryProductDetails(params) } + val result = withContext(Dispatchers.IO) { + billingClient.queryProductDetails(params) + }.also { productDetailsResult -> + if (productDetailsResult.billingResult.responseCode != BillingResponseCode.OK) { + Log.e("Billing", "Failed to query products: ${productDetailsResult.billingResult.debugMessage}") + return@getAvailableProducts emptyList() + } + } val products = result.productDetailsList ?: emptyList()
93-112: LGTM! 에러 전파 고려사항이 있습니다.구매 플로우 구현이 올바릅니다. 다만 구매 실패 시 로그만 남기고 호출자에게 에러를 전파하지 않습니다. 필요한 경우 콜백이나 Result 타입으로 에러를 전달하는 것을 고려할 수 있습니다.
129-153: 에러 로깅 추가를 권장합니다.
queryPurchasesAsync가 실패할 때 빈 리스트를 반환하지만 에러를 로깅하지 않아 디버깅이 어려울 수 있습니다.) { result, purchases -> if (result.responseCode == BillingResponseCode.OK) { val list = purchases .filter { !it.isAcknowledged } .map { purchase -> PurchaseProduct( uuid = purchase.products.firstOrNull().orEmpty(), credential = purchase.purchaseToken ) } continuation.resume(list) { cause, _, _ -> } } else { + Log.e("Billing", "Failed to query purchases: ${result.debugMessage}") continuation.resume(emptyList()) { cause, _, _ -> } } }
155-169: 타임아웃 처리를 고려하세요.
startConnection호출이 무기한 대기할 수 있습니다. 네트워크 상황에 따라 연결이 오래 걸릴 수 있으므로 타임아웃을 추가하는 것을 고려하세요.private suspend fun connectGooglePlayIfNeeded() = withTimeout(30_000) { suspendCancellableCoroutine { continuation -> if (billingClient.isReady) { continuation.resume(Result.success(Unit)) return@suspendCancellableCoroutine } billingClient.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(result: BillingResult) { if (result.responseCode == BillingResponseCode.OK) continuation.resume(Unit) else continuation.resumeWithException(Exception("Billing setup failed: ${result.debugMessage}")) } override fun onBillingServiceDisconnected() {} }) } }core/network/src/main/java/com/puzzle/network/di/RetrofitModule.kt (1)
47-64: 선택적 보안 개선사항: 민감한 헤더 redaction 및 스타일 수정 권장코드 검증 결과,
if (BuildConfig.DEBUG)가드가 이미 적용되어 있어 프로덕션 환경에서는 로깅이 비활성화됩니다. 따라서 배포 위험은 제한적입니다. 다만 다음 선택적 개선사항을 검토하세요:
HttpLoggingInterceptor에 redactHeader() 적용: 알려진 민감한 헤더(Authorization, Cookie 등)에 redactHeader()를 사용하여 로깅 시 자동으로 마스킹되도록 하는 것이 권장 사항입니다.
Authorization 토큰 부분 마스킹: 66번 줄에서 전체 토큰을 로깅하는 대신, 토큰의 일부만 노출하는 것이 좋습니다.
스타일 이슈: 59번, 61번 줄의 조건문 괄호 앞에 공백을 추가하세요 (
if (→if (,}else{→} else {).선택적 개선 예시:
if (BuildConfig.DEBUG) { val loggingInterceptor = HttpLoggingInterceptor() loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY + loggingInterceptor.redactHeader("Authorization") builder.addInterceptor(loggingInterceptor) builder.addInterceptor { chain -> val request = chain.request() val authHeader = request.header("Authorization") - if (authHeader == null){ + if (authHeader == null) { Log.e("API_AUTH", "Authorization header is null") - }else{ - Log.d("API_AUTH", "Authorization header: $authHeader") + } else { + val redacted = authHeader.take(20) + "..." + Log.d("API_AUTH", "Authorization header present: $redacted") } chain.proceed(request) } }feature/store/src/main/java/com/puzzle/store/contract/StoreIntent.kt (1)
10-10: Activity 참조 전달 대신 대체 패턴 고려
OnPurchaseClick이Activity인스턴스를 직접 전달받고 있습니다. 이는 다음과 같은 문제를 야기할 수 있습니다:
- Activity 참조로 인한 메모리 누수 위험
- 테스트 가능성 저하
- 레이어 간 결합도 증가
다음 대안을 고려해주세요:
ActivityResultLauncher를 Composable에서 생성하여 콜백으로 전달BillingHelper에서Context만 사용하도록 리팩토링- Navigation component의 결과 처리 메커니즘 활용
예시:
// Composable에서 val launcher = rememberLauncherForActivityResult(...) { result -> // 결과 처리 } // Intent data class OnPurchaseClick(val purchaseProduct: PieceProduct) : StoreIntent() // ViewModel에서 fun onPurchaseClick(product: PieceProduct, launcher: ActivityResultLauncher) { // 구매 로직 }core/common-ui/src/main/java/com/puzzle/common/ui/AdaptiveLayout.kt (1)
7-20: 구현 깔끔합니다.사이즈 클래스 분기와 기본 슬롯 연결이 명확합니다. 간단한 KDoc로 의도(슬롯 우선순위/기본값)를 남겨두면 사용성이 더 좋아집니다.
core/domain/src/main/java/com/puzzle/domain/repository/PaymentRepository.kt (1)
6-9: 반환값 설계를 재검토해 주세요.
- purchaseProduct가 Unit이면 호출 측은 예외 흐름에만 의존합니다. 영수증/지갑잔액/서버 상태 등 확인이 필요하다면 결과 타입(예: VerifiedPurchase/Balance)을 반환하는 쪽이 안전합니다.
- getAvailableProduct가 단수인 이유(정책상 1종만 판매 등)를 주석/이름으로 명확히 해두면 좋습니다.
core/network/src/main/java/com/puzzle/network/model/payment/VerifyPurchaseProductRequest.kt (1)
6-17: 서버 컨트랙트 키/값을 확인하세요.
- JSON 키(productUUID, purchaseCredential, store)가 서버 스펙과 1:1로 일치하는지, 필요한 경우 @SerialName이 필요한지 확인 바랍니다.
- store는 enum.name을 사용합니다. 서버가 특정 대소문자/별칭을 요구하면 별도 매핑 테이블을 두세요.
feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt (2)
60-74: 중첩 launch 제거로 구조 단순화
collectPurchaseProducts 내부에서 다시 viewModelScope.launch를 호출해 중첩됩니다. 호출부(init)에서 launch를 하므로, 내부는 suspend collect로 단순화하세요.- private fun collectPurchaseProducts() { - viewModelScope.launch { - billingHelper.purchaseProduct.collectLatest { product -> + private suspend fun collectPurchaseProducts() { + billingHelper.purchaseProduct.collectLatest { product -> ... - } - } + } }init 블록에서는
collectPurchaseProducts()를 동일 코루틴에서 호출하면 됩니다.
76-86: 임시 로그/태그 정리
Log.d("test", ...)와 TAG 미사용 import가 보입니다. 정식 태그로 교체하거나 제거하세요.- Log.d("test", "viewModel : $availableProducts") + Log.d([email protected], "availableProducts: $availableProducts")core/network/src/main/java/com/puzzle/network/model/payment/GetAvailableProductResponse.kt (1)
13-16: 프로모션 우선 정렬 로직 확인
프로모션 + 일반 상품 연결 순서를 여기서 고정합니다. 이후 BillingHelperImpl에서도 재정렬을 수행합니다. 중복 정렬이 필요한지, 한 곳에서만 책임지도록 정리하는 것을 권장합니다.core/data/src/main/java/com/puzzle/data/di/DataModule.kt (1)
81-84: 메서드 네이밍 일관성
다른 바인딩은bindXxx로 통일되는데, 여기만bindsNotificationRepository입니다. 컨벤션을 맞추면 검색성과 가독성이 좋아집니다.- abstract fun bindsNotificationRepository( + abstract fun bindNotificationRepository( notificationRepositoryImpl: NotificationRepositoryImpl ): NotificationRepositoryfeature/store/src/main/java/com/puzzle/store/StoreScreen.kt (2)
1-1: 중복 OptIn 제거
ExperimentalLayoutApi가 두 번 선언되었습니다. 하나만 남기세요.-@file:OptIn(ExperimentalLayoutApi::class, ExperimentalLayoutApi::class) +@file:OptIn(ExperimentalLayoutApi::class)
122-123: 하드코딩된 퍼즐 개수 제거
count = 22는 하드코딩입니다. 상태에서 값을 받아 표시하도록 바꾸세요(예:state.puzzleCount).Also applies to: 155-178
gradle/libs.versions.toml (1)
28-30: androidBilling 버전 검증 완료 - 문서 링크 추가 권장최신 안정 버전인 com.android.billingclient:billing-ktx 8.0.0 (2025년 6월 30일 출시)이 현재 지정되어 있습니다. PendingPurchasesParams.enableOneTimeProducts는 Play Billing Library 7.0.0에서 도입되었으므로, 8.0.0은 해당 API 요구사항을 완전히 만족합니다. 버전 업그레이드는 불필요합니다.
선택사항으로 유지보수성 향상을 위해 공식 릴리스 노트 링크를 버전 주석에 추가하는 것을 권장합니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
app/debug/app-debug.apk.zipis excluded by!**/*.zip
📒 Files selected for processing (49)
app/build.gradle.kts(1 hunks)app/debug/output-metadata.json(1 hunks)app/src/main/AndroidManifest.xml(1 hunks)build-logic/src/main/java/com/puzzle/build/logic/configure/AndroidComposes.kt(1 hunks)core/billing/build.gradle.kts(1 hunks)core/billing/src/main/java/com/puzzle/billing/BillingHelper.kt(1 hunks)core/billing/src/main/java/com/puzzle/billing/model/PieceProduct.kt(1 hunks)core/common-ui/src/main/java/com/puzzle/common/ui/AdaptiveLayout.kt(1 hunks)core/data/build.gradle.kts(1 hunks)core/data/src/main/java/com/puzzle/data/billing/BillingHelperImpl.kt(1 hunks)core/data/src/main/java/com/puzzle/data/di/DataModule.kt(4 hunks)core/data/src/main/java/com/puzzle/data/repository/AuthRepositoryImpl.kt(1 hunks)core/data/src/main/java/com/puzzle/data/repository/PaymentRepositoryImpl.kt(1 hunks)core/designsystem/build.gradle.kts(1 hunks)core/domain/src/main/java/com/puzzle/domain/model/payment/BasicCashProduct.kt(1 hunks)core/domain/src/main/java/com/puzzle/domain/model/payment/PurchaseProduct.kt(1 hunks)core/domain/src/main/java/com/puzzle/domain/repository/AuthRepository.kt(1 hunks)core/domain/src/main/java/com/puzzle/domain/repository/PaymentRepository.kt(1 hunks)core/network/build.gradle.kts(1 hunks)core/network/src/main/java/com/puzzle/network/api/PieceApi.kt(2 hunks)core/network/src/main/java/com/puzzle/network/di/NetworkModule.kt(2 hunks)core/network/src/main/java/com/puzzle/network/di/RetrofitModule.kt(2 hunks)core/network/src/main/java/com/puzzle/network/interceptor/PieceInterceptor.kt(1 hunks)core/network/src/main/java/com/puzzle/network/model/payment/GetAvailableProductResponse.kt(1 hunks)core/network/src/main/java/com/puzzle/network/model/payment/VerifyPurchaseProductRequest.kt(1 hunks)core/network/src/main/java/com/puzzle/network/source/payment/PaymentDataSource.kt(1 hunks)core/network/src/main/java/com/puzzle/network/source/payment/PaymentDataSourceImpl.kt(1 hunks)feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingScreen.kt(0 hunks)feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt(1 hunks)feature/matching/src/main/java/com/puzzle/matching/graph/main/contract/MatchingIntent.kt(0 hunks)feature/pause/build.gradle.kts(0 hunks)feature/pause/src/main/java/com/puzzle/pause/PauseScreen.kt(0 hunks)feature/pause/src/main/java/com/puzzle/pause/PauseViewModel.kt(0 hunks)feature/pause/src/main/java/com/puzzle/pause/contract/PauseIntent.kt(0 hunks)feature/pause/src/main/java/com/puzzle/pause/navigation/pauseNavigation.kt(0 hunks)feature/setting/src/main/java/com/puzzle/setting/graph/main/SettingScreen.kt(0 hunks)feature/store/build.gradle.kts(1 hunks)feature/store/src/main/java/com/puzzle/store/StoreScreen.kt(2 hunks)feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt(1 hunks)feature/store/src/main/java/com/puzzle/store/contract/StoreIntent.kt(1 hunks)feature/store/src/main/java/com/puzzle/store/contract/StoreState.kt(1 hunks)feature/store/src/main/java/com/puzzle/store/ui/card/PromotionStoreCard.kt(0 hunks)feature/store/src/main/java/com/puzzle/store/ui/card/StoreCard.kt(4 hunks)gradle/libs.versions.toml(6 hunks)presentation/build.gradle.kts(1 hunks)presentation/src/main/java/com/puzzle/presentation/MainActivity.kt(1 hunks)presentation/src/main/java/com/puzzle/presentation/MainViewModel.kt(4 hunks)presentation/src/main/java/com/puzzle/presentation/navigation/AppNavHost.kt(2 hunks)settings.gradle.kts(1 hunks)
💤 Files with no reviewable changes (9)
- feature/matching/src/main/java/com/puzzle/matching/graph/main/contract/MatchingIntent.kt
- feature/pause/src/main/java/com/puzzle/pause/navigation/pauseNavigation.kt
- feature/setting/src/main/java/com/puzzle/setting/graph/main/SettingScreen.kt
- feature/store/src/main/java/com/puzzle/store/ui/card/PromotionStoreCard.kt
- feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingScreen.kt
- feature/pause/src/main/java/com/puzzle/pause/contract/PauseIntent.kt
- feature/pause/build.gradle.kts
- feature/pause/src/main/java/com/puzzle/pause/PauseViewModel.kt
- feature/pause/src/main/java/com/puzzle/pause/PauseScreen.kt
🧰 Additional context used
🧬 Code graph analysis (4)
presentation/src/main/java/com/puzzle/presentation/navigation/AppNavHost.kt (1)
feature/store/src/main/java/com/puzzle/store/navigation/StoreNavigation.kt (1)
storeNavigation(8-12)
presentation/src/main/java/com/puzzle/presentation/MainViewModel.kt (1)
core/common/src/main/java/com/puzzle/common/ResultUtil.kt (1)
suspendRunCatching(5-13)
feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt (5)
presentation/src/main/java/com/puzzle/presentation/MainViewModel.kt (1)
processUnConsumedProducts(175-193)core/data/src/main/java/com/puzzle/data/billing/BillingHelperImpl.kt (1)
getAvailableProducts(64-91)core/billing/src/main/java/com/puzzle/billing/BillingHelper.kt (1)
getAvailableProducts(11-11)core/common/src/main/java/com/puzzle/common/ResultUtil.kt (1)
suspendRunCatching(5-13)core/common-ui/src/main/java/com/puzzle/common/base/BaseViewModel.kt (1)
setState(39-41)
feature/store/src/main/java/com/puzzle/store/StoreScreen.kt (4)
core/common-ui/src/main/java/com/puzzle/common/ui/AdaptiveLayout.kt (1)
AdaptiveLayout(7-20)core/designsystem/src/main/java/com/puzzle/designsystem/component/TopBar.kt (1)
PiecePuzzleTopBar(127-186)core/designsystem/src/main/java/com/puzzle/designsystem/component/Image.kt (1)
PieceImage(11-24)feature/store/src/main/java/com/puzzle/store/ui/card/StoreCard.kt (1)
StoreCard(25-86)
🔇 Additional comments (23)
core/network/src/main/java/com/puzzle/network/interceptor/PieceInterceptor.kt (1)
35-35: 주석 처리된 경로에 대한 의도 명확히 필요합니다.Line 35의
"/api/cash-products"경로가 주석 처리되어 있습니다. 이것이 의도적인지 미완성인지 불명확합니다.현재 상태:
- 주석 처리됨 → 엔드포인트가 인증 필요 (line 36의
else -> true)명확히 필요한 사항:
"/api/cash-products"엔드포인트가 인증을 요구하지 않는 공개 API인지, 또는 사용자별 인증이 필요한지 확인- 의도적인 주석 처리라면, 그 이유를 문서화하는 주석 추가 권장
인앱결제 기능 구현 맥락상 상품 목록 조회는 보통 공개 엔드포인트이므로, 주석을 제거하고
false로 설정해야 할 수도 있습니다.feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt (1)
62-62: 스토어 네비게이션 구현이 완벽하게 설정되어 있습니다.StoreRoute는
core/navigation/src/main/java/com/puzzle/navigation/Route.kt에data object StoreRoute : Route형태로 정의되어 있고,feature/store/src/main/java/com/puzzle/store/navigation/StoreNavigation.kt에서 네비게이션 그래프에 등록되어 있습니다. MatchingViewModel의 OnStoreClick 인텐트 처리 구현은 올바르며, 인앱 결제 기능을 위한 스토어 화면으로의 네비게이션이 완벽하게 구성되었습니다.presentation/src/main/java/com/puzzle/presentation/navigation/AppNavHost.kt (1)
16-16: LGTM!스토어 네비게이션이 다른 네비게이션과 동일한 패턴으로 올바르게 등록되었습니다.
Also applies to: 36-36
app/src/main/AndroidManifest.xml (1)
19-19: LGTM!Google Play 인앱 결제를 위한 필수 권한이 올바르게 추가되었습니다.
core/domain/src/main/java/com/puzzle/domain/model/payment/PurchaseProduct.kt (1)
9-11: LGTM!멀티 플랫폼 지원을 위한 Store enum이 적절하게 설계되었습니다.
core/domain/src/main/java/com/puzzle/domain/model/payment/BasicCashProduct.kt (1)
5-25: LGTM!도메인 모델 설계가 우수합니다:
- Sealed interface를 활용한 Product 타입 구조화
- 금액 필드에 BigDecimal 사용으로 정확한 금액 계산 보장
- BasicCashProduct와 PromotionProduct의 명확한 분리
core/data/src/main/java/com/puzzle/data/billing/BillingHelperImpl.kt (1)
171-199: 매퍼 위치는 적절합니다.코드 주석에 "위치가 고민스럽네요"라고 되어 있는데, BillingHelperImpl 내부의 private 확장 함수로 배치하는 것은 적절합니다. 이 매핑 로직은 BillingHelper 구현에만 필요하므로 현재 위치가 합리적입니다.
core/data/build.gradle.kts (1)
15-15: LGTM!Billing 기능 구현을 위한 필수 의존성이 올바르게 추가되었습니다.
Also applies to: 18-18
app/build.gradle.kts (1)
15-16: LGTM!인앱 결제 기능 추가에 맞춰 버전이 적절하게 증가되었습니다.
core/designsystem/build.gradle.kts (1)
15-15: LGTM! SVG 지원 추가Coil SVG 의존성 추가가 적절합니다. 이미지 로딩 기능 확장에 도움이 됩니다.
presentation/build.gradle.kts (1)
37-37: LGTM! 스토어 모듈 의존성 추가프레젠테이션 레이어에 스토어 피처 모듈 의존성이 올바르게 추가되었습니다.
settings.gradle.kts (1)
48-49: LGTM! 새로운 모듈 추가인앱 결제 기능을 위한
feature:store와core:billing모듈이 올바르게 추가되었습니다.feature/store/build.gradle.kts (1)
26-26: LGTM! 버전 카탈로그 사용하드코딩된 의존성을 버전 카탈로그 별칭(
libs.android.billingclient)으로 변경한 것은 좋은 개선입니다. 버전 관리가 중앙화되어 유지보수가 용이해집니다.build-logic/src/main/java/com/puzzle/build/logic/configure/AndroidComposes.kt (1)
28-28: LGTM! Material3 Adaptive 라이브러리 추가적응형 레이아웃 지원을 위한 Material3 Adaptive 라이브러리 추가가 적절합니다. 스토어 화면의 반응형 UI 구현에 활용될 것으로 보입니다.
core/network/src/main/java/com/puzzle/network/api/PieceApi.kt (2)
20-21: LGTM! 결제 모델 임포트 추가인앱 결제 기능을 위한 새로운 네트워크 모델이 올바르게 임포트되었습니다.
193-197: LGTM! 인앱 결제 API 엔드포인트 추가인앱 결제를 위한 두 가지 엔드포인트가 적절하게 추가되었습니다:
getAvailableProduct(): 구매 가능한 상품 조회verifyPurchaseProduct(): 서버 측 구매 검증HTTP 메서드와 엔드포인트 경로가 RESTful 규칙을 따르고 있습니다.
presentation/src/main/java/com/puzzle/presentation/MainActivity.kt (1)
146-149: 미소비 상품 처리 로직 적절함
onResume()에서 미소비 상품을 처리하는 것은 Google Play Billing의 권장 사항입니다. 구매가 중단된 경우를 올바르게 처리하고 있습니다.core/billing/build.gradle.kts (1)
1-15: 청구 모듈 구조 적절함새로운
core.billing모듈이 올바르게 구성되었습니다. 의존성이 적절하고 BillingClient가 전용 모듈에 배치되어 관심사 분리가 잘 되어 있습니다.core/network/src/main/java/com/puzzle/network/di/NetworkModule.kt (2)
19-20: LGTM!PaymentDataSource 관련 import가 적절하게 추가되었습니다.
75-79: LGTM!PaymentDataSource의 DI 바인딩이 기존 패턴과 일관되게 구현되었습니다. Singleton 스코프도 적절합니다.
core/network/src/main/java/com/puzzle/network/source/payment/PaymentDataSource.kt (1)
1-9: LGTM!
PaymentDataSource인터페이스가 명확하게 정의되었습니다. suspend 함수 사용이 적절하고, 책임이 명확히 분리되어 있습니다.core/data/src/main/java/com/puzzle/data/di/DataModule.kt (1)
103-108: BillingHelper 싱글톤 및 Context 누수 점검 완료 - 문제 없음검증 결과, 다음과 같이 올바르게 구현되어 있습니다:
- ✓ BillingHelperImpl이 @ApplicationContext를 주입받아 BillingClient 생성 (line 58)
- ✓ purchaseProduct 메서드에서 Activity는 메서드 인자로만 전달되며 필드로 저장되지 않음 (line 93)
- ✓ Singleton 바인딩에서 ApplicationContext 사용으로 Activity 누수 방지됨
현재 구조는 Context 누수를 올바르게 방지하고 있습니다.
gradle/libs.versions.toml (1)
94-96: 추가 확인이 필요합니다. 프로젝트가 ProGuard를 사용하는지 R8을 사용하는지 확인하겠습니다:라이브러리 추가 관련 검증 결과
검증 결과 다음을 확인했습니다:
Compose Material3 Adaptive 1.1.0
- Stable release (Sept 2024)로 프로덕션 사용 가능
- BOM과 독립적으로 버전 관리되므로 2025.02.00과 호환성 문제 없음
Coil 3.3.0
- R8(기본 code shrinker)를 사용하면 커스텀 규칙 불필요
- ProGuard 사용 시에만 명시적 keep 규칙 추가 필요 (DecoderServiceLoaderTarget, FetcherServiceLoaderTarget 등)
- Coil 3.0+ SingletonImageLoader와 관련해 R8/ProGuard 호환성 이슈 보고됨 (Oct-Nov 2024)
Billing-ktx (android-billingclient)
- 최신 Billing 라이브러리는 자체 ProGuard 규칙을 제공하지 않으며, 필요 시 수동으로 추가해야 함
- 현재
core/billing/모듈의 ProGuard 파일들이 비어있음권장사항:
프로젝트가 R8을 기본값으로 사용하는 경우 Coil 규칙 추가는 선택사항이나, 보안상core/billing/consumer-rules.pro에com.android.vending.billing.**keep 규칙 추가는 권장됩니다.
app/debug/output-metadata.json
Outdated
| { | ||
| "version": 3, | ||
| "artifactType": { | ||
| "type": "APK", | ||
| "kind": "Directory" | ||
| }, | ||
| "applicationId": "com.puzzle.piece", | ||
| "variantName": "debug", | ||
| "elements": [ | ||
| { | ||
| "type": "SINGLE", | ||
| "filters": [], | ||
| "attributes": [], | ||
| "versionCode": 24, | ||
| "versionName": "1.0.23", | ||
| "outputFile": "app-debug.apk" | ||
| } | ||
| ], | ||
| "elementType": "File", | ||
| "minSdkVersionForDexing": 28 | ||
| } No newline at end of file |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
빌드 아티팩트를 버전 관리에서 제외해야 합니다
app/debug/output-metadata.json은 빌드 시 자동 생성되는 아티팩트입니다. 이러한 파일을 버전 관리 시스템에 포함하면:
- 불필요한 커밋 히스토리가 생성됩니다
- 머지 충돌의 원인이 될 수 있습니다
- 리포지토리 크기가 불필요하게 증가합니다
.gitignore에 다음 패턴을 추가하세요:
+# Build outputs
+**/output-metadata.json
+app/debug/
+app/release/그리고 이 파일을 Git에서 제거하세요:
git rm app/debug/output-metadata.json🤖 Prompt for AI Agents
In app/debug/output-metadata.json lines 1-21: this is a generated build artifact
that should be removed from version control; add a .gitignore entry (for
example: app/**/output-metadata.json) to ignore these generated files, remove
the tracked file from Git with git rm app/debug/output-metadata.json (or git rm
--cached if you want to keep it locally), and commit the .gitignore and removal
so the artifact is no longer version-controlled.
| interface BillingHelper { | ||
| val purchaseProduct: StateFlow<PurchaseProduct?> | ||
| suspend fun getAvailableProducts(cashProducts: CashProduct): List<PieceProduct> | ||
| fun purchaseProduct(activity: Activity, purchaseProduct: PieceProduct) | ||
| // fun consumeProductList(purchaseProductList: List<PurchaseProduct>) | ||
| suspend fun getUnconsumedProductList() : List<PurchaseProduct> | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
이름 충돌과 소비/승인(ack) API가 필요합니다.
- StateFlow 프로퍼티와 함수가 모두 purchaseProduct라 가독성이 떨어집니다. purchasedProduct 또는 purchaseState 등으로 변경을 권장합니다.
- 일회성 상품은 소비(consume), 구독은 승인(acknowledge)이 필수입니다. 인터페이스에 명시적 메서드(예: acknowledgeOrConsume, consumeProductList)를 추가하고, 서버 검증 성공 이후 호출하도록 계약을 정하세요.
- 구현체에서 BillingClient.enablePendingPurchases(PendingPurchasesParams.enableOneTimeProducts()) 설정을 반드시 수행하는지 확인 바랍니다.
🏁 Script executed:
#!/bin/bash
# BillingHelper 인터페이스 구현체 찾기
echo "=== BillingHelper 구현체 검색 ==="
rg -n "class\s+\w+.*BillingHelper" --type=kotlin
echo ""
echo "=== consume 또는 acknowledge 메서드 검색 ==="
rg -n -i "fun\s+(consume|acknowledge)" --type=kotlin -C2
echo ""
echo "=== enablePendingPurchases 설정 검색 ==="
rg -n "enablePendingPurchases" --type=kotlin
echo ""
echo "=== BillingHelper 인터페이스 전체 내용 ==="
rg -n "interface BillingHelper" --type=kotlin -A20Length of output: 5165
🏁 Script executed:
#!/bin/bash
# BillingHelperImpl.kt 세부 내용 확인
echo "=== BillingHelperImpl.kt enablePendingPurchases 주변 코드 ==="
rg -n "enablePendingPurchases" --type=kotlin -B5 -A10
echo ""
echo "=== BillingHelperImpl.kt 전체 구조 ==="
rg -n "^class\s|^interface\s|^fun\s|^val\s" core/data/src/main/java/com/puzzle/data/billing/BillingHelperImpl.kt
echo ""
echo "=== purchaseProduct 함수 호출처 검색 ==="
rg -n "purchaseProduct\(" --type=kotlin -g "!*test*" -C2
echo ""
echo "=== getUnconsumedProductList 사용처 ==="
rg -n "getUnconsumedProductList" --type=kotlin -C2Length of output: 10122
🏁 Script executed:
#!/bin/bash
# BillingHelperImpl.kt의 purchaseProduct 함수와 consume 관련 로직 세부 확인
echo "=== BillingHelperImpl.kt 라인 93-130 (purchaseProduct 및 소비 관련) ==="
sed -n '93,130p' core/data/src/main/java/com/puzzle/data/billing/BillingHelperImpl.kt
echo ""
echo "=== purchaseProduct 스트림 업데이트 확인 ==="
rg -n "_purchaseProduct\|purchaseProduct\.emit\|purchaseProduct\.value" --type=kotlin core/data/src/main/java/com/puzzle/data/billing/BillingHelperImpl.kt -C3Length of output: 1681
프로퍼티와 함수의 이름 충돌 리팩터링 필수
purchaseProduct 이름이 StateFlow 프로퍼티(라인 10)와 함수(라인 12)에 중복되어 있어 가독성이 떨어집니다. 프로퍼티를 purchasedProduct 또는 purchaseState 등으로 변경하세요.
참고: enablePendingPurchases() 설정은 이미 BillingHelperImpl에서 구현되어 있으며, 소비 처리는 서버 API 검증 단계에서 진행 중입니다.
🤖 Prompt for AI Agents
In core/billing/src/main/java/com/puzzle/billing/BillingHelper.kt around lines 9
to 15, the name purchaseProduct collides between the StateFlow property and the
function; rename the StateFlow property to purchasedProduct or purchaseState
(e.g., purchasedProduct: StateFlow<PurchaseProduct?>) and update the interface
signature accordingly, then update all implementations (BillingHelperImpl and
any consumers/tests) to use the new property name and keep the
purchaseProduct(activity, purchaseProduct: PieceProduct) function name
unchanged; ensure compilation by refactoring imports/usages and running tests.
core/data/src/main/java/com/puzzle/data/billing/BillingHelperImpl.kt
Outdated
Show resolved
Hide resolved
| override suspend fun saveUserInfoForTest( | ||
| accessToken: String, | ||
| refreshToken: String, | ||
| userRole: String | ||
| ) { | ||
| coroutineScope { | ||
| val accessTokenJob = launch { localTokenDataSource.setAccessToken(accessToken) } | ||
| val refreshTokenJob = launch { localTokenDataSource.setRefreshToken(refreshToken) } | ||
| val userRoleJob = launch { localUserDataSource.setUserRole(userRole) } | ||
|
|
||
| accessTokenJob.join() | ||
| refreshTokenJob.join() | ||
| userRoleJob.join() | ||
| } | ||
|
|
||
| notificationDataSource.postDeviceToken() | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
프로덕션 노출된 테스트용 API를 제한하세요.
saveUserInfoForTest는 누구나 토큰/역할을 강제 주입할 수 있어 보안/무결성 위험이 큽니다. 디버그 전용으로 이동하거나 BuildConfig.DEBUG 가드, @VisibleForTesting, 혹은 debug sourceSet 분리로 사용 범위를 제한하세요. 또한 userRole은 String 대신 UserRole로 받아 타입 안정성을 높이세요.
예시(인터페이스/호출부 동반 수정 필요):
-override suspend fun saveUserInfoForTest(
- accessToken: String,
- refreshToken: String,
- userRole: String
-) {
+override suspend fun saveUserInfoForTest(
+ accessToken: String,
+ refreshToken: String,
+ userRole: UserRole
+) {
coroutineScope {
val accessTokenJob = launch { localTokenDataSource.setAccessToken(accessToken) }
val refreshTokenJob = launch { localTokenDataSource.setRefreshToken(refreshToken) }
- val userRoleJob = launch { localUserDataSource.setUserRole(userRole) }
+ val userRoleJob = launch { localUserDataSource.setUserRole(userRole.name) }
accessTokenJob.join()
refreshTokenJob.join()
userRoleJob.join()
}
notificationDataSource.postDeviceToken()
}동시에, 단순/안전성을 위해 병렬 launch+join 대신 순차 호출로도 충분합니다.
- coroutineScope {
- val a = launch { localTokenDataSource.setAccessToken(accessToken) }
- val r = launch { localTokenDataSource.setRefreshToken(refreshToken) }
- val u = launch { localUserDataSource.setUserRole(userRole.name) }
- a.join(); r.join(); u.join()
- }
+ localTokenDataSource.setAccessToken(accessToken)
+ localTokenDataSource.setRefreshToken(refreshToken)
+ localUserDataSource.setUserRole(userRole.name)Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In core/data/src/main/java/com/puzzle/data/repository/AuthRepositoryImpl.kt
around lines 79-95, saveUserInfoForTest exposes a production API that allows
arbitrary token/role injection and uses String for userRole and unnecessary
parallel launch+join; restrict its usage and harden types: move this function to
a debug-only source set or annotate it with @VisibleForTesting and guard calls
with BuildConfig.DEBUG (or make it internal to a debug-only implementation),
change the userRole parameter to the UserRole enum/type and update the interface
and all callers accordingly, and simplify the implementation by replacing the
concurrent launch+join pattern with sequential calls to setAccessToken,
setRefreshToken, setUserRole before calling
notificationDataSource.postDeviceToken().
| val listState = rememberLazyListState() | ||
|
|
||
| LazyColumn( | ||
| state = listState, | ||
| verticalArrangement = Arrangement.spacedBy(12.dp), | ||
| modifier = modifier | ||
| ) { | ||
| items(products, key = { it.detail.productId }) { product -> | ||
| when (product) { | ||
| is PromotionProduct -> PieceImage( | ||
| model = product.imageUrl, | ||
| modifier = Modifier | ||
| .fillMaxWidth() | ||
| .clickable { onPurchaseClick(product) } | ||
| ) | ||
|
|
||
| is NormalProduct -> StoreCard( | ||
| name = product.name, | ||
| price = product.price, | ||
| isOnSale = product.isOnSale, | ||
| originPrice = product.originPrice, | ||
| salePercent = product.salePercent, | ||
| modifier = Modifier.clickable { onPurchaseClick(product) } | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
LazyColumn 키 안정성 및 클릭 처리 확인
- key로
it.detail.productId를 사용합니다. productId의 전역 유일성 보장 확인 필요. - 이미지/카드 클릭 시 영역이 충분히 크지만, 접근성 측면에서 contentDescription/semantics 부여를 검토하세요.
🏁 Script executed:
#!/bin/bash
rg -nP -C2 'data\s+class\s+PieceProduct\b' core -S
rg -nP -C2 'productId' core -SLength of output: 629
:::
🏁 Script executed:
#!/bin/bash
# Find Product, PromotionProduct, NormalProduct data class definitions
rg -nP 'sealed\s+class\s+.*Product|data\s+class\s+(PromotionProduct|NormalProduct|Product)\b' core feature -S -A5
# Find Detail data class definition
rg -nP 'data\s+class\s+Detail|\.detail\s*:' core feature -S -A3Length of output: 1787
🏁 Script executed:
#!/bin/bash
# Check PieceImage and StoreCard for accessibility semantics
rg -nP '@Composable.*fun\s+(PieceImage|StoreCard)' feature -S -A20 | head -60Length of output: 0
🏁 Script executed:
#!/bin/bash
# Check for contentDescription or semantics usage in store module
rg -nP 'contentDescription|Modifier\.semantics|SemanticsProperties' feature/store -S -C2Length of output: 1078
🏁 Script executed:
#!/bin/bash
# Find ProductDetails data class definition
rg -nP 'data\s+class\s+ProductDetails|sealed\s+class\s+ProductDetails' core feature -S -A8
# Find where productId is defined/used in context of ProductDetails
rg -nP 'val\s+productId|productId\s*:' core feature -S -B2 -A2 | head -80Length of output: 0
🏁 Script executed:
#!/bin/bash
# Find PieceImage and StoreCard composable definitions
fd -e 'kt' feature | xargs rg -l 'fun PieceImage|fun StoreCard'
# Alternative: search in common ui directories
fd 'PieceImage|StoreCard' feature -SLength of output: 326
🏁 Script executed:
#!/bin/bash
# Verify if productId is actually globally unique or scope-bounded
# Check where cashProducts.products and their uuid/productId come from
rg -nP 'cashProducts\.products|data\s+class.*Product.*uuid' core -S -B3 -A3 | head -100Length of output: 1680
🏁 Script executed:
#!/bin/bash
# Find PieceImage composable in Image.kt
cat core/designsystem/src/main/java/com/puzzle/designsystem/component/Image.ktLength of output: 783
🏁 Script executed:
#!/bin/bash
# Check StoreCard clickable implementation
cat feature/store/src/main/java/com/puzzle/store/ui/card/StoreCard.kt | head -80Length of output: 2719
🏁 Script executed:
#!/bin/bash
# Search for ProductDetails - it may be from Google Play billing library
rg -nP 'ProductDetails' core feature -S -C1 | head -50Length of output: 4418
🏁 Script executed:
#!/bin/bash
# Check the custom clickable modifier implementation
rg -nP 'fun.*clickable|class.*Clickable' core/common -S -A10 | head -50Length of output: 0
🏁 Script executed:
#!/bin/bash
# Verify how products reach StoreScreen - check if productId stability is guaranteed
rg -nP 'detail\.productId|product\.productId' feature/store core -S -B2 -A2Length of output: 1136
클릭 가능한 아이템의 접근성 개선 필요
- PieceImage와 StoreCard의 클릭 영역에
contentDescription또는semantics속성이 누락되어 있습니다.core/designsystem/src/main/java/com/puzzle/designsystem/component/Image.kt: PieceImage 함수에서contentDescription = null설정feature/store/src/main/java/com/puzzle/store/ui/card/StoreCard.kt: StoreCard 컴포저블에 접근성 속성 부재
productId는 Google Play의 기본 제공 속성이므로 전역 유일성이 보장되어 LazyColumn 키로 사용해도 문제없습니다.
🤖 Prompt for AI Agents
In feature/store/src/main/java/com/puzzle/store/StoreScreen.kt around lines
186-213 the clickable PieceImage and StoreCard are missing accessibility
metadata; add meaningful contentDescription (e.g., product.name or a localized
string like "Buy {name}") for the PieceImage instead of null and ensure
StoreCard exposes a contentDescription or semantics modifier describing the item
and its action; if StoreCard is a composable that doesn't accept
contentDescription, wrap its Modifier with .semantics { contentDescription =
"..."; onClick(action = { onPurchaseClick(product); true }) } or update
StoreCard API to accept a contentDescription parameter and set a Role.Button via
.semantics or Modifier.clickable(role = Role.Button) so screen readers announce
the element and the click action.
| billingHelper.purchaseProduct( | ||
| activity = intent.activity, | ||
| purchaseProduct = intent.purchaseProduct |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Composable에서 Activity 강제 캐스팅 crash 위험
LocalContext를 Activity로 단정하면 Preview/ContextWrapper 환경에서 크래시 납니다. 안전 캐스팅과 null 가드로 변경하세요.
다음과 같이 수정 제안:
- val context = LocalContext.current
- val activity = context as Activity
+ val activity = LocalContext.current as? Activity
...
- onPurchaseClick = { purchaseProduct ->
- viewModel.onIntent(StoreIntent.OnPurchaseClick(activity, purchaseProduct))
- },
+ onPurchaseClick = { purchaseProduct ->
+ activity?.let { viewModel.onIntent(StoreIntent.OnPurchaseClick(it, purchaseProduct)) }
+ },동일 패턴을 expandedContent에도 반영해 주세요.
Also applies to: 61-64, 71-74
🤖 Prompt for AI Agents
In feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt around lines
51-53 (and similarly 61-64, 71-74, plus the expandedContent pattern), you are
force-casting the Compose LocalContext to Activity which can crash in
Preview/ContextWrapper; change the code to use a safe cast (context as?
Activity) with a null check (guard return or no-op) before calling
billingHelper.purchaseProduct so it only runs when the Activity is non-null, and
apply the same safe-cast + null-guard pattern to the expandedContent
occurrences.
| // consume 되지 않은 상품 처리 | ||
| private suspend fun processUnConsumedProducts() { | ||
| val unConsumedProducts = billingHelper.getUnconsumedProductList() | ||
|
|
||
| if (unConsumedProducts.isNotEmpty()) { | ||
| suspendRunCatching { | ||
| // paymentRepository.purchaseProductList(unConsumedProducts) 로 변경 예정 | ||
| paymentRepository.purchaseProduct(unConsumedProducts.first()) | ||
| }.onSuccess { | ||
| Log.d(this@StoreViewModel.javaClass.name, "purchaseProduct api success") | ||
| }.onFailure { | ||
| Log.e(this@StoreViewModel.javaClass.name, "purchaseProduct api fail") | ||
| errorHelper.sendError(it) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
미소비(unconsumed) 내역을 하나만 처리함
목록이 여러 건일 때 first()만 처리하면 잔여 내역이 영구 잔류할 수 있습니다. 전체를 순회 처리하세요.
- if (unConsumedProducts.isNotEmpty()) {
- suspendRunCatching {
- // paymentRepository.purchaseProductList(unConsumedProducts) 로 변경 예정
- paymentRepository.purchaseProduct(unConsumedProducts.first())
- }.onSuccess {
+ if (unConsumedProducts.isNotEmpty()) {
+ for (p in unConsumedProducts) {
+ val res = suspendRunCatching { paymentRepository.purchaseProduct(p) }
+ if (res.isFailure) {
+ Log.e([email protected], "purchaseProduct api fail")
+ errorHelper.sendError(res.exceptionOrNull()!!)
+ break
+ }
+ }
+ Log.d([email protected], "purchaseProduct api success")
...
- }.onFailure {
- ...
- }
}구매 확인 후 acknowledge/consume이 BillingHelperImpl 내에서 수행되는지 확인 필요합니다:
🏁 Script executed:
#!/bin/bash
rg -nP -C3 'class\s+BillingHelperImpl\b' core -S
rg -nP -C2 'acknowledgePurchase|consumeAsync|consumePurchase' core -S
rg -nP -C2 'enablePendingPurchases' core -SLength of output: 1954
추가 검증이 필요합니다. 다음을 확인하겠습니다:
Now I need to verify if paymentRepository.purchaseProduct() handles consumption internally:
기존 리뷰 코멘트를 검증하기 위해 추가 정보를 확인하겠습니다:
미소비(unconsumed) 내역을 하나만 처리함
비소비 상품의 경우 consumeAsync() 호출이 필수이며, 이는 자동으로 구매를 승인합니다. 현재 코드에서 BillingHelperImpl의 consumeAsync가 주석 처리되어 있으므로, 목록이 여러 건일 때 first()만 처리하면 잔여 내역이 영구적으로 미소비 상태로 남게 됩니다.
enablePendingPurchases()는 pending 거래만 처리하고 실제 소비는 하지 않습니다. 전체 목록을 순회하며 처리해야 합니다.
- if (unConsumedProducts.isNotEmpty()) {
- suspendRunCatching {
- // paymentRepository.purchaseProductList(unConsumedProducts) 로 변경 예정
- paymentRepository.purchaseProduct(unConsumedProducts.first())
- }.onSuccess {
+ if (unConsumedProducts.isNotEmpty()) {
+ for (p in unConsumedProducts) {
+ val res = suspendRunCatching { paymentRepository.purchaseProduct(p) }
+ if (res.isFailure) {
+ Log.e([email protected], "purchaseProduct api fail")
+ errorHelper.sendError(res.exceptionOrNull()!!)
+ break
+ }
+ }
+ Log.d([email protected], "purchaseProduct api success")
...
- }.onFailure {
- ...
- }
}🤖 Prompt for AI Agents
In feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt around lines
88 to 101, the code only processes the first unconsumed product which leaves
other purchases permanently unconsumed; change the logic to iterate over the
entire unConsumedProducts list and handle each item (call
paymentRepository.purchaseProduct for each or switch to a bulk
purchaseProductList API), ensure consumption is performed for every purchase
(either by re-enabling/invoking BillingHelperImpl.consumeAsync for each purchase
or by making paymentRepository handle consumption), add per-item success/error
logging and per-item error reporting via errorHelper so failures on one item
don’t prevent processing the rest.
| if (isOnSale) { | ||
| Text( | ||
| text = "5%", | ||
| text = "${salePercent}%", | ||
| style = PieceTheme.typography.bodyMSB, | ||
| color = PieceTheme.colors.subDefault, | ||
| modifier = Modifier.padding(end = 4.dp), | ||
| ) | ||
| } | ||
|
|
||
| Text( | ||
| text = "57,000원", | ||
| text = "${price}원", | ||
| style = PieceTheme.typography.bodyMR, | ||
| color = PieceTheme.colors.black, | ||
| ) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
세일 표시에서 null이 그대로 노출되는 문제를 막아주세요.
isOnSale가 true여도 salePercent/originPrice가 null이면 "null%" 또는 "null"이 화면에 찍힙니다. 널 가드/유효성 체크를 추가하세요.
예시 수정:
- if (isOnSale) {
- Text(
- text = "${salePercent}%",
+ if (isOnSale && salePercent != null) {
+ Text(
+ text = "$salePercent%",
...
)
- }
+ }
...
- if (isOnSale) {
- Text(
- text = originPrice.toString(),
+ if (isOnSale && !originPrice.isNullOrBlank()) {
+ Text(
+ text = originPrice,
textDecoration = TextDecoration.LineThrough,
modifier = Modifier.align(Alignment.End)
)
- }
+ }추가 권장:
- salePercent 범위(0..100) 검증, originPrice/price 포맷 유효성 체크.
Also applies to: 76-84
| // consume 되지 않은 상품 처리 | ||
| internal fun processUnConsumedProducts() { | ||
| if (userRole.value == USER) { | ||
| viewModelScope.launch { | ||
| val unConsumedProducts = billingHelper.getUnconsumedProductList() | ||
|
|
||
| if (unConsumedProducts.isNotEmpty()) { | ||
| suspendRunCatching { | ||
| // paymentRepository.purchaseProductList(unConsumedProducts) 로 변경 예정 | ||
| paymentRepository.purchaseProduct(unConsumedProducts.first()) | ||
| }.onSuccess { | ||
| Log.d(this@MainViewModel.javaClass.name, "purchaseProduct api success") | ||
| }.onFailure { | ||
| Log.e(this@MainViewModel.javaClass.name, "purchaseProduct api fail") | ||
| errorHelper.sendError(it) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
역할 값 레이스, 미소비 결제 반복, 첫 항목만 처리 — 보완이 필요합니다.
- userRole.value를 즉시 읽으면 초기 NONE 상태에서 스킵될 수 있습니다. USER가 될 때 1회 처리하도록 flow를 기다리세요.
- 서버 검증 성공 후 소비/승인(acknowledge/consume)을 호출하지 않아 다음 onResume마다 재처리될 수 있습니다.
- 미소비 항목이 여러 개면 첫 항목만 처리합니다. 리스트 처리 또는 배치 API로 바꾸세요.
예시:
- internal fun processUnConsumedProducts() {
- if (userRole.value == USER) {
- viewModelScope.launch {
- val unConsumedProducts = billingHelper.getUnconsumedProductList()
- if (unConsumedProducts.isNotEmpty()) {
- suspendRunCatching {
- // paymentRepository.purchaseProductList(unConsumedProducts) 로 변경 예정
- paymentRepository.purchaseProduct(unConsumedProducts.first())
- }.onSuccess {
- Log.d([email protected], "purchaseProduct api success")
- }.onFailure {
- Log.e([email protected], "purchaseProduct api fail")
- errorHelper.sendError(it)
- }
- }
- }
- }
- }
+ internal fun processUnConsumedProducts() {
+ viewModelScope.launch {
+ userRole
+ .filter { it == USER }
+ .take(1)
+ .collect {
+ val unConsumed = billingHelper.getUnconsumedProductList()
+ if (unConsumed.isNotEmpty()) {
+ suspendRunCatching {
+ // TODO: paymentRepository.purchaseProductList(unConsumed)
+ unConsumed.forEach { paymentRepository.purchaseProduct(it) }
+ }.onSuccess {
+ // TODO: billingHelper.consumeProductList(unConsumed) 또는 acknowledge
+ }.onFailure { errorHelper.sendError(it) }
+ }
+ }
+ }
+ }구현체에서 실제로 acknowledge/consume을 어디서 수행하는지 확인 부탁드립니다. 없다면 BillingHelper에 API를 추가하고 이곳에서 호출하세요.
확인 스크립트:
🏁 Script executed:
#!/bin/bash
# 소비/승인 로직 존재 여부 확인
rg -nP -C3 '(acknowledge|consume)Purchas' --glob '!**/build/**'Length of output: 0
더 정확한 검증을 위해 추가 스크립트를 실행하겠습니다.
추가 스크립트를 실행하여 더 넓은 범위에서 검증하겠습니다.
Google Play 청구 라이브러리 요구사항 확인됨 — 원래 검토 의견이 정확하고 중요한 문제입니다.
모든 구매는 승인이 필요하며, 승인하지 않으면 자동으로 환불됩니다. 이는 3일 이내에 완료되어야 합니다. 소비 가능 상품은 consumeAsync()로 표시되어야 하며, 이를 호출하면 자동으로 승인됩니다.
코드베이스에서 acknowledge/consume 로직을 찾을 수 없었으므로, 원래 검토 의견의 우려사항이 모두 유효합니다.
역할 값 레이스, 미소비 결제 반복, 첫 항목만 처리 — 보완이 필요합니다.
- userRole.value를 즉시 읽으면 초기 NONE 상태에서 스킵될 수 있습니다. USER가 될 때 1회 처리하도록 flow를 기다리세요.
- 서버 검증 성공 후 소비/승인(acknowledge/consume)을 호출하지 않아 다음 onResume마다 재처리될 수 있습니다.
- 미소비 항목이 여러 개면 첫 항목만 처리합니다. 리스트 처리 또는 배치 API로 바꾸세요.
예시:
- internal fun processUnConsumedProducts() {
- if (userRole.value == USER) {
- viewModelScope.launch {
- val unConsumedProducts = billingHelper.getUnconsumedProductList()
- if (unConsumedProducts.isNotEmpty()) {
- suspendRunCatching {
- // paymentRepository.purchaseProductList(unConsumedProducts) 로 변경 예정
- paymentRepository.purchaseProduct(unConsumedProducts.first())
- }.onSuccess {
- Log.d([email protected], "purchaseProduct api success")
- }.onFailure {
- Log.e([email protected], "purchaseProduct api fail")
- errorHelper.sendError(it)
- }
- }
- }
- }
- }
+ internal fun processUnConsumedProducts() {
+ viewModelScope.launch {
+ userRole
+ .filter { it == USER }
+ .take(1)
+ .collect {
+ val unConsumed = billingHelper.getUnconsumedProductList()
+ if (unConsumed.isNotEmpty()) {
+ suspendRunCatching {
+ // TODO: paymentRepository.purchaseProductList(unConsumed)
+ unConsumed.forEach { paymentRepository.purchaseProduct(it) }
+ }.onSuccess {
+ // TODO: billingHelper.consumeProductList(unConsumed) 또는 acknowledge
+ }.onFailure { errorHelper.sendError(it) }
+ }
+ }
+ }
+ }구현체에서 실제로 acknowledge/consume을 어디서 수행하는지 확인하고, BillingHelper에 API를 추가한 후 이곳에서 호출하세요.
🏁 Script executed:
#!/bin/bash
# 1. consume 메서드 광범위 검색
echo "=== Search for 'consume' methods ==="
rg -nP '\b(consume|Consume)\b' --type=kt --type=java -C2 | head -50
# 2. acknowledge 메서드 광범위 검색
echo "=== Search for 'acknowledge' methods ==="
rg -nP '\b(acknowledge|Acknowledge)\b' --type=kt --type=java -C2 | head -50
# 3. BillingHelper 클래스 정의 찾기
echo "=== Find BillingHelper class ==="
rg -nP 'class BillingHelper|interface BillingHelper' --type=kt --type=java -A5
# 4. paymentRepository 구현 찾기
echo "=== Find paymentRepository related code ==="
rg -nP 'paymentRepository|purchaseProduct' --type=kt --type=java -C2 | head -50Length of output: 262
파일 타입 필터 문제를 해결하고 다시 검색하겠습니다.
파일 타입 필터를 수정하고 다시 실행합니다.
🏁 Script executed:
#!/bin/bash
# 1. Kotlin/Java 파일에서 consume 검색 (타입 필터 제거)
echo "=== Search for 'consume' ==="
rg -nP 'consume' --glob '*.kt' --glob '*.java' -C2 | head -100
# 2. Kotlin/Java 파일에서 acknowledge 검색
echo "=== Search for 'acknowledge' ==="
rg -nP 'acknowledge' --glob '*.kt' --glob '*.java' -C2 | head -100
# 3. BillingHelper 찾기
echo "=== Find BillingHelper ==="
rg -nP 'BillingHelper' --glob '*.kt' --glob '*.java' -C3
# 4. purchaseProduct 찾기
echo "=== Find purchaseProduct ==="
rg -nP 'purchaseProduct|fun purchaseProduct' --glob '*.kt' --glob '*.java' -C2Length of output: 29720
🏁 Script executed:
#!/bin/bash
# 1. Kotlin/Java 파일에서 consume 검색
echo "=== Search for 'consume' ==="
rg -n 'consume' --glob '*.kt' --glob '*.java' -C2 | head -80
# 2. acknowledge 검색
echo "=== Search for 'acknowledge' ==="
rg -n 'acknowledge' --glob '*.kt' --glob '*.java' -C2 | head -80
# 3. BillingHelper 클래스 찾기
echo "=== Find BillingHelper ==="
rg -n 'class BillingHelper|interface BillingHelper' --glob '*.kt' --glob '*.java' -A3
# 4. paymentRepository 정의 찾기
echo "=== Find paymentRepository definition ==="
rg -n 'val paymentRepository|var paymentRepository' --glob '*.kt' --glob '*.java' -C2
# 5. processUnConsumedProducts 함수 찾기
echo "=== Find processUnConsumedProducts ==="
rg -n 'processUnConsumedProducts' --glob '*.kt' --glob '*.java' -C5Length of output: 14886
서버 측 소비 로직 존재 여부를 최종 확인하겠습니다.
이제 서버 API 메서드를 확인하여 최종 검증을 완료하겠습니다.
🏁 Script executed:
#!/bin/bash
# 1. PaymentRepository 및 PaymentDataSource 메서드 확인
echo "=== PaymentRepository methods ==="
rg -n 'suspend fun|fun ' --glob '*.kt' --glob '*.java' core/domain/src/main/java/com/puzzle/domain/repository/PaymentRepository.kt -A1
echo ""
echo "=== PaymentDataSource methods ==="
rg -n 'suspend fun|fun ' --glob '*.kt' --glob '*.java' core/network/src/main/java/com/puzzle/network/source/payment/PaymentDataSource.kt -A1
# 2. 전체 서버 API 확인
echo ""
echo "=== All Payment-related API methods ==="
rg -n 'suspend fun|fun.*Payment|consume|acknowledge' --glob '*.kt' --glob '*.java' -C1 | grep -A1 -B1 'Payment'
# 3. StoreViewModel processUnConsumedProducts 전체 코드 확인
echo ""
echo "=== StoreViewModel processUnConsumedProducts full code ==="
rg -n 'private suspend fun processUnConsumedProducts' --glob '*.kt' --glob '*.java' -A30Length of output: 5448
🏁 Script executed:
#!/bin/bash
# PaymentRepository 와 PaymentDataSource 전체 조회
echo "=== PaymentRepository interface ==="
cat core/domain/src/main/java/com/puzzle/domain/repository/PaymentRepository.kt
echo ""
echo "=== PaymentDataSource interface ==="
cat core/network/src/main/java/com/puzzle/network/source/payment/PaymentDataSource.kt
echo ""
echo "=== PaymentRepositoryImpl ==="
cat core/data/src/main/java/com/puzzle/data/repository/PaymentRepositoryImpl.ktLength of output: 1399
역할 값 레이스, 미소비 결제 반복, 첫 항목만 처리 — 보완이 필요합니다.
- userRole.value를 즉시 읽으면 초기 NONE 상태에서 스킵될 수 있습니다. USER가 될 때 1회 처리하도록 flow를 기다리세요.
- 서버 검증 성공 후 소비/승인(consume/acknowledge)을 호출하지 않아 다음 onResume마다 재처리될 수 있습니다.
- 미소비 항목이 여러 개면 첫 항목만 처리합니다. 리스트 처리 또는 배치 API로 바꾸세요.
예시:
- internal fun processUnConsumedProducts() {
- if (userRole.value == USER) {
- viewModelScope.launch {
- val unConsumedProducts = billingHelper.getUnconsumedProductList()
- if (unConsumedProducts.isNotEmpty()) {
- suspendRunCatching {
- // paymentRepository.purchaseProductList(unConsumedProducts) 로 변경 예정
- paymentRepository.purchaseProduct(unConsumedProducts.first())
- }.onSuccess {
- Log.d([email protected], "purchaseProduct api success")
- }.onFailure {
- Log.e([email protected], "purchaseProduct api fail")
- errorHelper.sendError(it)
- }
- }
- }
- }
- }
+ internal fun processUnConsumedProducts() {
+ viewModelScope.launch {
+ userRole
+ .filter { it == USER }
+ .take(1)
+ .collect {
+ val unConsumed = billingHelper.getUnconsumedProductList()
+ if (unConsumed.isNotEmpty()) {
+ suspendRunCatching {
+ unConsumed.forEach { paymentRepository.purchaseProduct(it) }
+ }.onSuccess {
+ // 서버 API consumeProductList 호출 필요
+ }.onFailure { errorHelper.sendError(it) }
+ }
+ }
+ }
+ }PaymentRepository에 consume/acknowledge API를 추가하고 onSuccess에서 호출하세요. 현재 consumeProductList()는 BillingHelperImpl에서 주석 처리되어 있으며("consume 은 서버단에서 진행"), PaymentRepository에도 해당 API가 없습니다. 서버 검증 후 반드시 호출해야 Google Play 청구 정책을 준수합니다.
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
presentation/src/main/java/com/puzzle/presentation/MainViewModel.kt lines
174-193: current logic reads userRole.value directly (race), processes only the
first unconsumed product, and never calls a consume/acknowledge API after server
validation; update to (1) observe/wait for the userRole flow to emit USER before
running processing so initial NONE is not skipped, (2) iterate over the full
unConsumedProducts list (or call a batch purchase/verify API) instead of only
processing first(), (3) add and call a
PaymentRepository.consumeOrAcknowledge(...) method after successful server-side
validation on each item to mark purchases consumed/acknowledged, and (4) ensure
any billingHelper.consume* calls are invoked only after server verification and
protect against duplicate concurrent processing (e.g., mark as in-progress or
use mutex) so items aren’t reprocessed on resume.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
근형님 고생하셨습니다!!!!!!!!!!
약간의 코멘트와 궁금한 사항 질문 남겨봤어요~!
app/debug/output-metadata.json
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
p0)
app/debug, app/release 불필요한 파일들 제거해주세요~~!
| dependencies { | ||
| val bom = libs.findLibrary("androidx-compose-bom").get() | ||
| add("implementation", platform(bom)) | ||
| add("implementation", libs.findLibrary("compose.material3.adaptive").get()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
p1)
이거 일단 store쪽만 들어가는 라이브러리라서 공통 플러그인에 넣을 필요는 없을 것 같아요. 일단은 store 쪽에만 dependency로 넣어주는 게 좋을 것 같아요!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
음 추후 다른페이지에서도 사용하지 않을까 싶어서 이렇게 구현해놨는데 일단은 store쪽에서만 사용할까요?
| val purchaseProduct: StateFlow<PurchaseProduct?> | ||
| suspend fun getAvailableProducts(cashProducts: CashProduct): List<PieceProduct> | ||
| fun purchaseProduct(activity: Activity, purchaseProduct: PieceProduct) | ||
| // fun consumeProductList(purchaseProductList: List<PurchaseProduct>) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
p5) consume은 서버에서 하는걸로 했으니 불필요한 주석은 제거해주는게 좋을 것 같아요. 맥락 없는 사람이 처음 보면 오히려 더 헷갈릴 느낌이 들어요.
| val isOnSale: Boolean, // 서버에서 내려줘야 함 | ||
| val originPrice: String, // 서버에서 내려줘야 함 | ||
| val salePercent: Int, // 서버에서 내려줘야 함 | ||
| ) : PieceProduct | ||
|
|
||
| data class PromotionProduct( | ||
| override val detail: ProductDetails, | ||
| override val price: String, | ||
| val imageUrl : String, | ||
| // val eventName: String, // 서버에서 내려줘야 함 | ||
| // val eventDescription: String, // 서버에서 내려줘야 함 | ||
| // val priceAmountMicros: Long, | ||
| // val currencyCode: String, | ||
| // val originPrice: String, // 서버에서 내려줘야 함 | ||
| // val salePercent: Int, // 서버에서 내려줘야 함 | ||
| // val itemCount: Int, // 서버에서 내려줘야 함 | ||
| // val benefitDescription: String, // 서버에서 내려줘야 함 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
여기도 마찬가지에요~!
| @Composable | ||
| fun AdaptiveLayout( | ||
| compactContent: @Composable () -> Unit, | ||
| mediumContent: @Composable () -> Unit = compactContent, | ||
| expandedContent: @Composable () -> Unit = mediumContent | ||
| ) { | ||
| val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass | ||
|
|
||
| when (windowSizeClass.windowWidthSizeClass) { | ||
| WindowWidthSizeClass.COMPACT -> compactContent() | ||
| WindowWidthSizeClass.MEDIUM -> mediumContent() | ||
| WindowWidthSizeClass.EXPANDED -> expandedContent() | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
p1)
이거 위와 마찬가지인데, 아직은 store쪽에만 들어가는 스펙이라 common 에 넣어버리면 다른 기능 모듈에서 불필요한 의존성이 생길 것 같아요.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
그러면 일단은 store 안으로 다 넣는방식으로 수정할게요!
| @Composable | ||
| private fun ProductList( | ||
| products: List<PieceProduct>, | ||
| onPurchaseClick: (PieceProduct) -> Unit, | ||
| modifier: Modifier = Modifier | ||
| ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
제가 프로젝트하면서 챙기지 못했던 부분인데, List를 파라미터로 넘겨주면 unstable 처리가되어서 skippable 판정이 되지 못해요.
이 부분 ImutableList 의존성을 추가해서 uiState부터 ImmutableList 처리하면 좋을 것 같아요.
| private fun collectPurchaseProducts() { | ||
| viewModelScope.launch { | ||
| billingHelper.purchaseProduct.collectLatest { product -> | ||
| if (product != null) { | ||
| suspendRunCatching { | ||
| paymentRepository.purchaseProduct(product) | ||
| }.onSuccess { | ||
| Log.d(this@StoreViewModel.javaClass.name, "purchaseProduct api success") | ||
| }.onFailure { | ||
| Log.e(this@StoreViewModel.javaClass.name, "purchaseProduct api fail") | ||
| errorHelper.sendError(it) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
p2)
init에서 viewModelScope를 열어주고있어서 suspend로 바꾸고 scope 제거해줘도 될 것 같네요.
병렬성을 원하신거라면 init에서 launch로 감싸줘도 될 것 같아요.
| sealed class StoreIntent : UiIntent { | ||
| data object OnBackClick : StoreIntent() | ||
| data object OnInquiryClick : StoreIntent() | ||
| data class OnPurchaseClick(val activity: Activity, val purchaseProduct: PieceProduct) : StoreIntent() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이거 Intent로 activity를 넣어줄 필요가 있을까요? activity가 필요하다면 sideEffect로 넘겨야할 것 같아요.
ViewModel에 activity를 쥐어주면 메모리 누수가 생길 위험이 있을 수 있으니까요
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아래 메서드를 사용해 구글플레이를 호출하기위해선 필요하더라고요.
billingClient.launchBillingFlow(activity, billingFlowParams)
그래서 액티비티가 필요한데, sideEffect로 넘겨야한다는게, storeViewModel의 sideEffect를 만들어야한다는 말일까요, 아니면 eventHelper로 넘겨서 액티비티에서 처리해야한다는 뜻일까요?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@kkh725
ViewModel에 activity를 넘길필요없이, ViewModel에서는 SideEffect로 넘겨서 Composable에서 activity가 필요한 메소드를 호출한다는 말이었어요~!
| override fun onResume() { | ||
| super.onResume() | ||
| viewModel.processUnConsumedProducts() | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
질문! ) MainActivity에서 반드시 해당 로직 처리를 해야하나요?!
기획적으로 정해진건지 궁금해요~! 만약 onResume() 탔을 때 구매하지 못한 상품이 MainActivity에서 잡히면 어떤 흐름을 타게되나요?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
consume되지 않은 상품을 onResume 타이밍마다 검사하면 좋지 않을까 생각했어요.
오늘 회의 때 onResume타이밍마다 재검증 하는부분 말씀드렸어요.
onResume() 탔을 때 구매하지 못한 상품이 MainActivity에서 잡히면 어떤 흐름 ->
서버로 상품리스트를 request에 담아 보내고 서버에서 consume처리 하는 흐름으로 갈 듯 합니다!
settings.gradle.kts
Outdated
| include(":feature:onboarding") | ||
| include(":feature:notification") | ||
| include(":feature:store") | ||
| include(":core:billing") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
p5)
core는 위로 살짝 올려주면 좋을 것 같아요~!
* [PC-1298] app/debug 불필요한 파일들 제거 * [PC-1298] compose.material3.adaptive 의존성 store 에만 추가하는 방식으로 수정 * [PC-1298] consumeProductList 의미없는 주석 제거 * [PC-1298] 주석 제거 * [PC-1298] AdaptiveLayout -> feature:store 에서만 사용하게 수정 * [PC-1298] BillingHelperImpl.kt 에서만 사용하는 toPieceProduct 메서드 수정 * [PC-1298] feature:store 모듈에서 billingclient 의존성 제거 * [PC-1298] 주석 제거 * [PC-1298] kotlin.collection.immutable 의존성 추가 및 list->immutableList로 변환 * [PC-1298] 서버에서 consume 작업 및 unConsumed 상품 처리를 담당 -> 클라쪽 처리 로직 삭제. * [PC-1298] StoreState의 products -> promotion, normal 으로 분리, puzzleCount 상태 추가 * [PC-1298] 뷰모델에서 액티비티 제거 후 sideEffect로 분리
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
♻️ Duplicate comments (5)
core/billing/src/main/java/com/puzzle/billing/BillingHelper.kt (2)
10-14: 프로퍼티/함수 이름 충돌 — 읽기 어려움StateFlow 프로퍼티와 함수가 모두
purchaseProduct라 혼란스럽습니다. 프로퍼티를 상태 의미로 변경해 주세요.interface BillingHelper { - val purchaseProduct: StateFlow<PurchaseProduct?> + val purchaseState: StateFlow<PurchaseProduct?> suspend fun getAvailableProducts(cashProducts: CashProduct): ImmutableList<PieceProduct> fun purchaseProduct(activity: Activity, purchaseProduct: PieceProduct) }이전 리뷰 맥락 기준.
10-14: 승인/소비(ack/consume) 계약 부재 — 재처리·정책 위반 위험서버 검증 후 즉시 소비형(inapp)은 consume, 구독은 acknowledge가 필요합니다. 인터페이스에 명시적 API를 추가하고 구현체에서 BillingClient 호출을 보장해 주세요.
interface BillingHelper { - val purchaseProduct: StateFlow<PurchaseProduct?> + val purchaseState: StateFlow<PurchaseProduct?> suspend fun getAvailableProducts(cashProducts: CashProduct): ImmutableList<PieceProduct> fun purchaseProduct(activity: Activity, purchaseProduct: PieceProduct) + /** + * 서버 검증 성공 후 호출: inapp이면 consume, subscription이면 acknowledge 수행 + */ + suspend fun acknowledgeOrConsume(purchaseToken: String, isConsumable: Boolean) }이전 리뷰 맥락 기준.
feature/store/src/main/java/com/puzzle/store/contract/StoreState.kt (1)
10-12: 로딩/빈/에러 상태 분리 필요현재 상태만으로는 로딩 중과 실제 빈 목록을 구분하기 어렵습니다. 간단한 플래그를 추가해 UI 표현력을 높여주세요.
data class StoreState( val puzzleCount : Int = 20, val promotionProductList: ImmutableList<PromotionProduct> = persistentListOf(), - val normalProductList: ImmutableList<NormalProduct> = persistentListOf() + val normalProductList: ImmutableList<NormalProduct> = persistentListOf(), + val isLoading: Boolean = true, + val error: String? = null ) : UiState이전 리뷰 맥락 기준.
feature/store/src/main/java/com/puzzle/store/StoreScreen.kt (2)
221-228: LazyColumn 키와 접근성 보강(프로모션 아이템)
- 키 미지정: 안정성/성능을 위해 productId를 key로 지정하세요.
- 클릭 가능한 이미지에 contentDescription/semantics가 없습니다. 스크린리더 안내를 위해 설명과 Role을 추가하세요.
- items(promotionProductList) { product -> - PieceImage( - model = product.imageUrl, - modifier = Modifier - .fillMaxWidth() - .clickable { onPurchaseClick(product) } - ) - } + items( + items = promotionProductList, + key = { it.detail.productId } + ) { product -> + PieceImage( + model = product.imageUrl, + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = "프로모션 상품" + } + .clickable { onPurchaseClick(product) } + ) + }
55-71: Composable에서 Activity 강제 캐스팅 → 크래시 위험Preview/ContextWrapper 환경에서 크래시 납니다. 안전 캐스팅과 null 가드로 교체하세요.
- val context = LocalContext.current - val activity = context as Activity + val activity = LocalContext.current as? Activity ... - is StoreSideEffect.PurchaseProduct -> viewModel.billingHelper.purchaseProduct( - activity = activity, - purchaseProduct = sideEffect.product - ) + is StoreSideEffect.PurchaseProduct -> + activity?.let { viewModel.billingHelper.purchaseProduct(it, sideEffect.product) }
🧹 Nitpick comments (12)
feature/store/src/main/java/com/puzzle/store/contract/StoreSideEffect.kt (1)
5-7: 이벤트 네이밍 명확화 제안 (모델명과 혼동 방지)SideEffect 이름
PurchaseProduct는 도메인 모델PurchaseProduct와 혼동 소지가 있습니다. 동사형으로 의도를 드러내면 가독성이 좋아집니다.-sealed class StoreSideEffect { - data class PurchaseProduct(val product: PieceProduct) : StoreSideEffect() -} +sealed class StoreSideEffect { + data class RequestPurchase(val product: PieceProduct) : StoreSideEffect() +}core/billing/src/main/java/com/puzzle/billing/model/PieceProduct.kt (3)
5-8: 모델 경계 OK, 단 가격 필드 의미를 KDoc으로 명확히 해주세요.price가 이미 포맷된 문자열(예: ₩1,100)인지, 원시 금액/통화 정보를 표현하는지 명시하면 UI 레이어의 중복 포맷(원 추가 등)을 방지할 수 있습니다. 간단한 KDoc 추가 권장.
10-20: NormalProduct의 가격/통화 필드 일관성 정리 제안
- price(String)와 priceAmountMicros/currencyCode가 공존합니다. UI에서 어떤 값을 우선 사용할지 가이드가 없으면 중복 포맷 우려가 있습니다.
- 제안: UI에는 항상 formattedPrice만 쓰고(원문 그대로), 숫자/코드 필드는 백엔드 전송/로그용으로 문서화하세요. 또는 Price VO를 도입해 혼동을 줄이세요.
22-26: PromotionProduct에 접근성/라벨링을 고려한 메타데이터 확장 검토이미지 URL만으로는 접근성 라벨 구성(콘텐츠 설명)이 어렵습니다. name/title 등의 간단한 설명 필드 추가를 검토해 주세요. UI 측 코멘트와 연결됩니다.
feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt (3)
39-44: init의 중첩 launch 단순화불필요한 중첩입니다. 각각 별도 launch로 가독성/구조 단순화하세요.
- init { - viewModelScope.launch { - launch { getAvailableProducts() } - launch { collectPurchaseProducts() } - } - } + init { + viewModelScope.launch { getAvailableProducts() } + viewModelScope.launch { collectPurchaseProducts() } + }
63-79: 구매 이벤트를 StateFlow로 전달하면 동시 다건 업데이트 시 손실 위험purchasesUpdatedListener에서 여러 건을 set하면 마지막 값만 관찰됩니다. 이벤트성 스트림은 Channel/SharedFlow가 적합합니다. 인터페이스 변경 범위가 크다면 우선 순차 처리 보장(큐잉)으로 리스크 완화 검토 부탁드립니다. 상세 제안은 BillingHelperImpl 코멘트 참조.
64-70: 디버그 로그 정리"dd2", "dd3" 등의 임시 로그는 제거하거나 TAG를 상수로 통일하세요.
- Log.d("TAG", "getAvailableProducts: dd2") + // no-op ... - Log.d("TAG", "getAvailableProducts: dd3") + // no-opcore/data/src/main/java/com/puzzle/data/billing/BillingHelperImpl.kt (2)
41-53: 여러 구매 업데이트를 StateFlow로 덮어써 잃을 수 있습니다동일 콜백에서 다건이 올 경우 마지막 값만 남습니다. 이벤트 버퍼링을 위해 Channel/SharedFlow로 전환 권장.
- private val _purchaseProduct = MutableStateFlow<PurchaseProduct?>(null) - override val purchaseProduct: StateFlow<PurchaseProduct?> = _purchaseProduct.asStateFlow() + // Emits every purchase event; no replay. + private val _purchaseProduct = kotlinx.coroutines.flow.MutableSharedFlow<PurchaseProduct>(extraBufferCapacity = 64) + override val purchaseProduct: kotlinx.coroutines.flow.SharedFlow<PurchaseProduct> = _purchaseProduct ... - purchases.forEach { purchase -> + purchases.forEach { purchase -> val token = purchase.purchaseToken val uuid = purchase.products.firstOrNull() ?: return@forEach - _purchaseProduct.value = PurchaseProduct(uuid = uuid, credential = token) + _purchaseProduct.tryEmit(PurchaseProduct(uuid = uuid, credential = token)) }인터페이스(BillingHelper) 시그니처가 StateFlow라면 SharedFlow로의 마이그레이션을 함께 진행해야 합니다.
132-162: 불필요한 표현식 및 포맷 일관성
- Line 137~138: 사용되지 않는 표현식(productDetail.oneTimePurchaseOfferDetails?.formattedPrice) 제거.
- originPrice는 문자열 변환만 수행합니다. 포맷 일관성을 위해 서버/클라이언트 어느 쪽에서 포맷하는지 기준을 정해주세요(모델 코멘트와 연결).
- val offer = productDetail.oneTimePurchaseOfferDetails ?: return null - productDetail.oneTimePurchaseOfferDetails?.formattedPrice + val offer = productDetail.oneTimePurchaseOfferDetails ?: return nullfeature/store/src/main/java/com/puzzle/store/StoreScreen.kt (3)
1-1: 중복 Opt-in 제거ExperimentalLayoutApi가 중복 선언되어 있습니다. 한 번만 남기세요.
-@file:OptIn(ExperimentalLayoutApi::class, ExperimentalLayoutApi::class) +@file:OptIn(ExperimentalLayoutApi::class)
245-253: LazyColumn 키 지정 및 가격 표기 중복 주의(일반 상품)
- items에 key 지정 권장.
- StoreCard 내부가 "${price}원" 형식이면, product.price가 이미 formattedPrice(예: ₩1,100)일 때 "₩1,100원" 중복 표기됩니다. StoreCard에서 통화/단위를 처리하도록 위임하거나, 전달값을 숫자 문자열로 바꾸는 일관된 정책이 필요합니다.
- items(normalProductList) { product -> + items( + items = normalProductList, + key = { it.detail.productId } + ) { product -> StoreCard( name = product.name, - price = product.price, + price = product.price, // formattedPrice를 그대로 표시하도록 StoreCard 포맷 로직 점검 필요 isOnSale = product.isOnSale, originPrice = product.originPrice, salePercent = product.salePercent, modifier = Modifier.clickable { onPurchaseClick(product) } ) }
183-206: 뒤로가기 아이콘 클릭 가능 요소의 접근성 Role 부여시각 보조기기 안내 강화를 위해 Button 역할을 부여하세요.
Image( painter = painterResource(R.drawable.ic_arrow_left), contentDescription = "뒤로 가기 버튼", modifier = Modifier .padding(end = 8.dp) .size(32.dp) - .clickable(onClick = onBackClick) + .clickable(onClick = onBackClick) // clickable 확장에 role 지원 시 Role.Button 지정 고려 )
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (13)
build-logic/src/main/java/com/puzzle/build/logic/KotlinAndroid.kt(1 hunks)core/billing/src/main/java/com/puzzle/billing/BillingHelper.kt(1 hunks)core/billing/src/main/java/com/puzzle/billing/model/PieceProduct.kt(1 hunks)core/data/src/main/java/com/puzzle/data/billing/BillingHelperImpl.kt(1 hunks)feature/store/build.gradle.kts(1 hunks)feature/store/src/main/java/com/puzzle/store/StoreScreen.kt(2 hunks)feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt(1 hunks)feature/store/src/main/java/com/puzzle/store/contract/StoreIntent.kt(1 hunks)feature/store/src/main/java/com/puzzle/store/contract/StoreSideEffect.kt(1 hunks)feature/store/src/main/java/com/puzzle/store/contract/StoreState.kt(1 hunks)feature/store/src/main/java/com/puzzle/store/ui/AdaptiveLayout.kt(1 hunks)gradle/libs.versions.toml(6 hunks)presentation/src/main/java/com/puzzle/presentation/MainViewModel.kt(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- gradle/libs.versions.toml
🧰 Additional context used
🧬 Code graph analysis (2)
feature/store/src/main/java/com/puzzle/store/StoreScreen.kt (5)
feature/store/src/main/java/com/puzzle/store/ui/AdaptiveLayout.kt (1)
AdaptiveLayout(7-20)core/designsystem/src/main/java/com/puzzle/designsystem/component/TopBar.kt (1)
PiecePuzzleTopBar(127-186)core/designsystem/src/main/java/com/puzzle/designsystem/component/Image.kt (1)
PieceImage(11-24)feature/store/src/main/java/com/puzzle/store/ui/card/StoreCard.kt (1)
StoreCard(25-86)core/designsystem/src/main/java/com/puzzle/designsystem/foundation/Theme.kt (1)
PieceTheme(15-18)
feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt (4)
core/billing/src/main/java/com/puzzle/billing/BillingHelper.kt (1)
getAvailableProducts(12-12)core/data/src/main/java/com/puzzle/data/billing/BillingHelperImpl.kt (1)
getAvailableProducts(66-93)core/common/src/main/java/com/puzzle/common/ResultUtil.kt (1)
suspendRunCatching(5-13)core/common-ui/src/main/java/com/puzzle/common/base/BaseViewModel.kt (1)
setState(39-41)
🪛 detekt (1.23.8)
core/data/src/main/java/com/puzzle/data/billing/BillingHelperImpl.kt
[warning] 128-128: This empty block of code can be removed.
(detekt.empty-blocks.EmptyFunctionBlock)
🔇 Additional comments (6)
build-logic/src/main/java/com/puzzle/build/logic/KotlinAndroid.kt (1)
57-58: LGTM! 의존성 추가가 올바르게 구현되었습니다.
kotlinx-collections-immutable의존성이 올바른 패턴으로 추가되었습니다. 인앱 결제 기능의 도메인/데이터 모델에서ImmutableList를 사용하기 위한 적절한 설정입니다. 빈 줄 추가로 Firebase 관련 의존성과 구분되어 가독성도 개선되었습니다.feature/store/build.gradle.kts (1)
29-29: 의존성 검증 결과: 리뷰 의견이 부정확합니다검증 결과에 따르면 리뷰 의견의 핵심 주장이 잘못되었습니다.
발견 사항:
projects.core.billing의존성은 여전히 feature/store/build.gradle.kts의 27번째 줄에 존재합니다libs.compose.material3.adaptive는 새로운 의존성으로 추가되었으며, billing 라이브러리를 대체하지 않았습니다- Material3 Adaptive는 실제로 사용 중입니다 (feature/store/src/main/java/com/puzzle/store/ui/AdaptiveLayout.kt에서
currentWindowAdaptiveInfo()임포트 및 사용, StoreScreen.kt에서 호출)- 청구 관련 기능은 계속 정상적으로 작동합니다 (BillingHelper, billing 모델 클래스 등 활발히 사용 중)
결론:
Material3 Adaptive는 반응형 UI 레이아웃을 위한 기능이며, Android Billing Library는 인앱 결제를 위한 기능입니다. 이 두 라이브러리는 서로 다른 목적을 가지고 있으며 함께 공존합니다. 대체 관계가 아닌 추가 관계입니다.Likely an incorrect or invalid review comment.
presentation/src/main/java/com/puzzle/presentation/MainViewModel.kt (1)
21-21: 해당 변경은 동작 영향 없음단순 import 추가로 보이며 별도 코멘트 생략합니다.
feature/store/src/main/java/com/puzzle/store/contract/StoreIntent.kt (1)
10-10: 구매 트리거를 Intent로 분리한 점 좋습니다Activity를 직접 들고 다니지 않고, 구매 액션을 명시적 Intent로 모델링한 방향이 적절합니다. UI에서 SideEffect를 받아 BillingFlow를 실행하는 구조로 이어가면 됩니다.
feature/store/src/main/java/com/puzzle/store/ui/AdaptiveLayout.kt (1)
7-20: 적응형 레이아웃 구성 깔끔합니다윈도우 폭 클래스 기준 분기와 기본 인자 체이닝이 명료합니다. 특별한 이슈 없습니다.
feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt (1)
81-96: 제품 로딩 → 상태 반영 플로우 깔끔합니다에러는 errorHelper로 위임되고, UI 리스트는 ImmutableList로 변환되어 안정성 측면도 좋습니다. 👍
| override fun purchaseProduct(activity: Activity, purchaseProduct: PieceProduct) { | ||
| val offerToken = purchaseProduct.detail.oneTimePurchaseOfferDetails?.offerToken | ||
| ?: throw IllegalStateException("Product has no offer token") | ||
|
|
||
| val productDetailsParamsList = listOf( | ||
| BillingFlowParams.ProductDetailsParams.newBuilder() | ||
| .setProductDetails(purchaseProduct.detail) | ||
| .build() | ||
| ) | ||
|
|
||
| val billingFlowParams = BillingFlowParams.newBuilder() | ||
| .setProductDetailsParamsList(productDetailsParamsList) | ||
| .build() | ||
|
|
||
| val billingResult = billingClient.launchBillingFlow(activity, billingFlowParams) | ||
|
|
||
| if (billingResult.responseCode != BillingResponseCode.OK) { | ||
| Log.e("Billing", "Purchase failed: ${billingResult.debugMessage}") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
구매 시작 전 연결 보장 및 불필요 변수 제거
- purchaseProduct에서 connectGooglePlayIfNeeded 호출이 없어 초기 동작 타이밍에 따라 실패 가능. 안전하게 선호출하세요.
- offerToken을 읽지만 사용하지 않습니다(일회성 INAPP에서는 불필요). 제거하세요.
override fun purchaseProduct(activity: Activity, purchaseProduct: PieceProduct) {
- val offerToken = purchaseProduct.detail.oneTimePurchaseOfferDetails?.offerToken
- ?: throw IllegalStateException("Product has no offer token")
+ // For INAPP(one‑time) purchases, offerToken is not required.
+ // Ensure connection before launching flow
+ // (fire‑and‑forget; errors surfaced via listener/log)
+ // If you prefer strict sequencing, make this suspend and await.
+ // runCatching { connectGooglePlayIfNeeded() }
val productDetailsParamsList = listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(purchaseProduct.detail)
.build()
)또한 사용하지 않는 import(ConsumeParams, QueryPurchasesParams) 정리 권장.
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In core/data/src/main/java/com/puzzle/data/billing/BillingHelperImpl.kt around
lines 95 to 114, the purchaseProduct method does not ensure the BillingClient is
connected before launching the billing flow and it reads an unused offerToken;
update the method to call connectGooglePlayIfNeeded() (or equivalent
synchronous/await connection helper) at the start of purchaseProduct and only
proceed when the client is connected, remove the unused offerToken
reading/variable, and then build and launch the BillingFlowParams as before;
also remove unused imports such as ConsumeParams and QueryPurchasesParams from
the file.
| private suspend fun connectGooglePlayIfNeeded() = suspendCancellableCoroutine { continuation -> | ||
| if (billingClient.isReady) { | ||
| continuation.resume(Result.success(Unit)) | ||
| return@suspendCancellableCoroutine | ||
| } | ||
|
|
||
| billingClient.startConnection(object : BillingClientStateListener { | ||
| override fun onBillingSetupFinished(result: BillingResult) { | ||
| if (result.responseCode == BillingResponseCode.OK) continuation.resume(Unit) | ||
| else continuation.resumeWithException(Exception("Billing setup failed: ${result.debugMessage}")) | ||
| } | ||
|
|
||
| override fun onBillingServiceDisconnected() {} | ||
| }) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resume 타입 오류로 컴파일 실패 가능
continuation.resume에 Result를 넘기고 있습니다. Unit을 넘겨야 합니다.
- if (billingClient.isReady) {
- continuation.resume(Result.success(Unit))
- return@suspendCancellableCoroutine
- }
+ if (billingClient.isReady) {
+ continuation.resume(Unit)
+ return@suspendCancellableCoroutine
+ }또한 onBillingServiceDisconnected가 빈 블록(detekt 경고)입니다. 최소 로그/재시도 트리거를 추가하세요.
- override fun onBillingServiceDisconnected() {}
+ override fun onBillingServiceDisconnected() {
+ Log.w("Billing", "Service disconnected")
+ // enableAutoServiceReconnection 사용 중이더라도 방어적으로 재연결 시도 가능
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private suspend fun connectGooglePlayIfNeeded() = suspendCancellableCoroutine { continuation -> | |
| if (billingClient.isReady) { | |
| continuation.resume(Result.success(Unit)) | |
| return@suspendCancellableCoroutine | |
| } | |
| billingClient.startConnection(object : BillingClientStateListener { | |
| override fun onBillingSetupFinished(result: BillingResult) { | |
| if (result.responseCode == BillingResponseCode.OK) continuation.resume(Unit) | |
| else continuation.resumeWithException(Exception("Billing setup failed: ${result.debugMessage}")) | |
| } | |
| override fun onBillingServiceDisconnected() {} | |
| }) | |
| } | |
| private suspend fun connectGooglePlayIfNeeded() = suspendCancellableCoroutine { continuation -> | |
| if (billingClient.isReady) { | |
| continuation.resume(Unit) | |
| return@suspendCancellableCoroutine | |
| } | |
| billingClient.startConnection(object : BillingClientStateListener { | |
| override fun onBillingSetupFinished(result: BillingResult) { | |
| if (result.responseCode == BillingResponseCode.OK) continuation.resume(Unit) | |
| else continuation.resumeWithException(Exception("Billing setup failed: ${result.debugMessage}")) | |
| } | |
| override fun onBillingServiceDisconnected() { | |
| Log.w("Billing", "Service disconnected") | |
| // enableAutoServiceReconnection 사용 중이더라도 방어적으로 재연결 시도 가능 | |
| } | |
| }) | |
| } |
🧰 Tools
🪛 detekt (1.23.8)
[warning] 128-128: This empty block of code can be removed.
(detekt.empty-blocks.EmptyFunctionBlock)
🤖 Prompt for AI Agents
In core/data/src/main/java/com/puzzle/data/billing/BillingHelperImpl.kt around
lines 116-130, change the incorrect continuation.resume(Result.success(Unit)) to
continuation.resume(Unit) so the resumed value matches the coroutine's Unit
type, and replace the empty onBillingServiceDisconnected block with at least a
log statement and a retry trigger (e.g., log a warning about disconnection and
call billingClient.startConnection(...) to attempt reconnect) so detekt warnings
are resolved and the client will try to recover.
| implementation(projects.core.common) | ||
| implementation(projects.core.billing) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
검증 필요: core.billing을 implementation으로 변경 시 컴파일 오류 위험
이전 코드 리뷰에서 지적했듯이, core.billing이 이제 implementation으로만 포함되었습니다. 하지만 지난 검토에서 확인된 바와 같이, StoreState.kt와 StoreIntent.kt의 공개 API에서 여전히 PieceProduct 타입(billing 모듈 도메인 타입)이 노출되고 있다면, 의존하는 다른 모듈들에서 컴파일 오류가 발생할 수 있습니다.
core.billing을 implementation으로 유지하려면, store 모듈의 공개 API(contract 클래스)를 store 모듈의 독립적인 도메인 타입으로 리팩토링하여 billing 모듈 타입에 대한 의존성을 제거해야 합니다.
다음 스크립트로 계약 파일에서 billing 타입이 여전히 노출되는지 확인하세요:
🏁 Script executed:
#!/bin/bash
# StoreState.kt 및 StoreIntent.kt에서 PieceProduct 타입 사용 확인
echo "=== StoreState.kt 내용 ==="
cat feature/store/src/main/java/com/puzzle/store/contract/StoreState.kt 2>/dev/null || echo "파일 없음"
echo -e "\n=== StoreIntent.kt 내용 ==="
cat feature/store/src/main/java/com/puzzle/store/contract/StoreIntent.kt 2>/dev/null || echo "파일 없음"
# PieceProduct 타입 참조 확인
echo -e "\n=== PieceProduct 참조 확인 ==="
rg -n --type kotlin "PieceProduct|com\.puzzle\.billing" feature/store/src/main/java/Length of output: 2815
core.billing을 implementation으로 변경하면 안 됩니다 — 공개 API가 billing 타입을 노출 중입니다
검증 결과, 다음 파일들의 공개 계약이 여전히 com.puzzle.billing.model의 타입을 노출하고 있습니다:
- StoreIntent.kt (line 10):
OnPurchaseClick(val purchaseProduct: PieceProduct) - StoreState.kt (lines 3, 5):
NormalProduct,PromotionProduct - StoreSideEffect.kt (line 6):
PieceProduct - StoreScreen.kt (lines 102, 140, 211, 235): 함수 시그니처에서
PieceProduct노출
이들 모듈을 사용하는 다른 feature 또는 모듈에서 컴파일 오류가 발생합니다. store의 공개 API를 소비하려면 PieceProduct 등의 타입에 접근해야 하는데, core.billing이 implementation이면 불가능합니다.
해결책 (둘 중 하나 선택):
core.billing을api(projects.core.billing)로 변경 → billing 모듈을 전이 의존성으로 노출- 또는 store 모듈의 공개 계약을 리팩토링 →
PieceProduct대신 store 자체 도메인 타입 정의 및 변환 로직 추가 (권장)
🤖 Prompt for AI Agents
In feature/store/build.gradle.kts around lines 26 to 27, the review shows the
store module is exposing billing types (com.puzzle.billing.model.PieceProduct
and related types) via its public API while depending on core.billing with
implementation, causing downstream compilation failures; either change the
dependency to api(projects.core.billing) to make billing types transitively
available, or refactor the store public contracts (StoreIntent, StoreState,
StoreSideEffect, StoreScreen signatures) to use store-local domain types and
map/convert to PieceProduct internally (preferred).
1. ⭐️ 변경된 내용
~~ ing
2. 🖼️ 스크린샷(선택)
3. 💡 알게된 부분
구독제 상품이 아니라, 일회성 상품일 경우 enablePendingPurchase()를 반드시 설정해야 함!
하지 않으면 아래와 같은 에러 직면하게 됨...!
+++
Summary by CodeRabbit
릴리스 노트
새로운 기능
개선 사항
제거