Skip to content

Conversation

@tgyuuAn
Copy link
Member

@tgyuuAn tgyuuAn commented Jun 7, 2025

1. ⭐️ 변경된 내용

  • 인앱결제 구현

~~ ing

2. 🖼️ 스크린샷(선택)

image

3. 💡 알게된 부분

구독제 상품이 아니라, 일회성 상품일 경우 enablePendingPurchase()를 반드시 설정해야 함!

image




하지 않으면 아래와 같은 에러 직면하게 됨...!

image
private val pendingPurchasesParams = PendingPurchasesParams.newBuilder()
    .enableOneTimeProducts()
    .build()
        
private val billingClient = BillingClient.newBuilder(context)
    .setListener(purchasesUpdatedListener)
    .enablePendingPurchases(pendingPurchasesParams) // <--- 요거
    .build()





+++

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 인앱 구매 기능 추가로 앱 내에서 직접 결제 가능
    • 스토어 화면을 다양한 화면 크기에 맞게 최적화
    • 프로모션 및 일반 상품 목록 개선
  • 개선 사항

    • 스토어 기능 활성화로 더 나은 쇼핑 경험 제공
    • 적응형 레이아웃으로 모든 기기에 대응
  • 제거

    • 일시정지 기능 삭제

@tgyuuAn tgyuuAn self-assigned this Jun 7, 2025
@tgyuuAn tgyuuAn added 기능 ⚒️ 새로운 기능 구현 ⚒️ ㅌㄱ태규 ☀️ 훗날 크게될 ENFP 남성, tgyuuAn labels Jun 7, 2025
@coderabbitai
Copy link

coderabbitai bot commented Jun 7, 2025

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

📥 Commits

Reviewing files that changed from the base of the PR and between 32704da and 0256605.

📒 Files selected for processing (7)
  • core/network/src/main/java/com/puzzle/network/api/PieceApi.kt (2 hunks)
  • feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingScreen.kt (3 hunks)
  • feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt (1 hunks)
  • feature/setting/src/main/java/com/puzzle/setting/graph/main/SettingScreen.kt (0 hunks)
  • presentation/build.gradle.kts (1 hunks)
  • presentation/src/main/java/com/puzzle/presentation/navigation/AppNavHost.kt (2 hunks)
  • settings.gradle.kts (2 hunks)
 _____________________________________________________________
< Oompa Loompa doompadee doo, I've got a code review for you. >
 -------------------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).

Tip

You can disable the changed files summary in the walkthrough.

Disable the reviews.changed_files_summary setting in your project's settings in CodeRabbit to disable the changed files summary in the walkthrough.

요약

이 PR은 Google Play 결제 기능을 추가하고 Pause 기능을 제거하는 대규모 변경사항입니다. 새로운 결제 및 청구 모듈을 도입하고, 청구 도우미와 결제 저장소를 구현하며, Store 화면을 적응형 레이아웃으로 리팩토링합니다.

워크스루

이 PR은 Google Play 결제 기능을 추가하여 상품 조회, 결제 처리, 구매 확인을 지원합니다. 청구 모듈, 결제 저장소, 도메인 모델을 신규 추가하고 Pause 기능을 완전 제거하며 Store UI를 적응형 레이아웃으로 개선합니다.

변경사항

코호트 / 파일 변경 요약
버전 및 매니페스트
app/build.gradle.kts, app/src/main/AndroidManifest.xml
버전 코드 22→26, 버전명 1.0.21→1.0.25로 업데이트. com.android.vending.BILLING 권한 추가
청구 모듈 (신규)
core/billing/build.gradle.kts, core/billing/src/main/java/com/puzzle/billing/BillingHelper.kt, core/billing/src/main/java/com/puzzle/billing/model/PieceProduct.kt
BillingHelper 인터페이스, PieceProduct 모델(NormalProduct, PromotionProduct) 신규 추가. Android Billing Client 의존성 선언
결제 도메인 모델
core/domain/src/main/java/com/puzzle/domain/model/payment/BasicCashProduct.kt, core/domain/src/main/java/com/puzzle/domain/model/payment/PurchaseProduct.kt
CashProduct, Product(sealed), BasicCashProduct, PromotionProduct, PurchaseProduct, Store enum 신규 추가
결제 저장소 (도메인)
core/domain/src/main/java/com/puzzle/domain/repository/PaymentRepository.kt
PaymentRepository 인터페이스(getAvailableProduct, purchaseProduct) 신규 추가
인증 저장소
core/domain/src/main/java/com/puzzle/domain/repository/AuthRepository.kt
saveUserInfoForTest 메서드 추가
네트워크 결제 모델 및 API
core/network/src/main/java/com/puzzle/network/model/payment/GetAvailableProductResponse.kt, core/network/src/main/java/com/puzzle/network/model/payment/VerifyPurchaseProductRequest.kt, core/network/src/main/java/com/puzzle/network/api/PieceApi.kt
GetAvailableProductResponse, BasicCashProductResponse, PromotionProductResponse, VerifyPurchaseProductRequest 신규 추가. API 엔드포인트 2개 추가(getAvailableProduct, verifyPurchaseProduct)
네트워크 데이터 소스
core/network/src/main/java/com/puzzle/network/source/payment/PaymentDataSource.kt, core/network/src/main/java/com/puzzle/network/source/payment/PaymentDataSourceImpl.kt
PaymentDataSource 인터페이스 및 구현체 신규 추가
결제 저장소 구현
core/data/src/main/java/com/puzzle/data/repository/PaymentRepositoryImpl.kt
PaymentRepositoryImpl 구현체 신규 추가
청구 헬퍼 구현
core/data/src/main/java/com/puzzle/data/billing/BillingHelperImpl.kt
Google Play 청구 클라이언트 통합, 상품 조회, 구매 처리 로직 신규 추가
DI 바인딩
core/data/src/main/java/com/puzzle/data/di/DataModule.kt, core/network/src/main/java/com/puzzle/network/di/NetworkModule.kt, core/network/src/main/java/com/puzzle/network/di/RetrofitModule.kt
PaymentRepository, BillingHelper 바인딩 신규 추가. 기존 bindsXxx 메서드 이름을 bindXxx로 변경. OkHttpClient 디버그 로깅 추가
인증 저장소 구현
core/data/src/main/java/com/puzzle/data/repository/AuthRepositoryImpl.kt
saveUserInfoForTest 메서드 신규 추가
디자인 시스템
core/designsystem/build.gradle.kts
Coil SVG 의존성 추가
Pause 기능 제거
feature/pause/build.gradle.kts, feature/pause/src/main/java/com/puzzle/pause/...
Pause 모듈 전체 제거(PauseScreen, PauseViewModel, PauseIntent, 네비게이션)
Matching 기능 수정
feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingScreen.kt, feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt, feature/matching/src/main/java/com/puzzle/matching/graph/main/contract/MatchingIntent.kt
OnNoticeClick 콜백 및 인텐트 제거. OnStoreClick 네비게이션 활성화
Store 기능 대폭 개선
feature/store/build.gradle.kts, feature/store/src/main/java/com/puzzle/store/StoreScreen.kt, feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt, feature/store/src/main/java/com/puzzle/store/contract/StoreIntent.kt, feature/store/src/main/java/com/puzzle/store/contract/StoreState.kt, feature/store/src/main/java/com/puzzle/store/ui/AdaptiveLayout.kt
적응형 레이아웃(Compact/Expanded) 추가. 상품 리스트 상태 관리, 구매 플로우 통합. OnPurchaseClick 인텐트 신규 추가. StoreSideEffect 신규 추가
Store UI 카드 리팩토링
feature/store/src/main/java/com/puzzle/store/ui/card/StoreCard.kt, feature/store/src/main/java/com/puzzle/store/ui/card/PromotionStoreCard.kt
StoreCard API 변경(onClick 제거, name/price 파라미터 추가). PromotionStoreCard 파일 전체 제거
Setting 화면 소수 정리
feature/setting/src/main/java/com/puzzle/setting/graph/main/SettingScreen.kt
불필요한 공백 2줄 제거
의존성 및 설정
gradle/libs.versions.toml, build-logic/src/main/java/com/puzzle/build/logic/KotlinAndroid.kt, presentation/build.gradle.kts, presentation/src/main/java/com/puzzle/presentation/MainViewModel.kt, presentation/src/main/java/com/puzzle/presentation/navigation/AppNavHost.kt, settings.gradle.kts
Android Billing(8.0.0), Coil(3.3.0), Compose Material3 Adaptive(1.1.0), Immutable Collections(0.4.0) 버전 추가/업데이트. core.billing, feature.store 모듈 추가. StoreNavigation 등록

시퀀스 다이어그램

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
Loading

코드 리뷰 예상 난이도

🎯 4 (복잡함) | ⏱️ ~60분

분석:

  • 범위: 50개 이상의 파일 변경, 새로운 모듈 추가(core/billing), 모듈 제거(feature/pause)
  • 구조적 복잡성:
    • Google Play 청구 클라이언트 통합 (BillingHelperImpl)
    • 다층 결제 플로우 (네트워크 → 데이터 → 도메인)
    • 적응형 레이아웃 구현 (UI 리팩토링)
  • 다양성: 청구 로직, DI 바인딩, UI 개선, 기능 제거 등 이질적인 변경
  • 논리 밀도: BillingHelperImpl, PaymentRepositoryImpl, StoreViewModel의 새 로직이 상당함
  • 테스트 고려사항: 새로운 결제 플로우, 상태 관리, 네트워크 호출 검증 필요

관련 PR

🐰 새로운 결제의 길을 열며,
청구 헬퍼가 준비되고,
상품들이 가득 펼쳐지니,
Pause는 작별하고,
Store는 화면에 맞춰 춤을 춘다! 💳✨

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description Check ⚠️ Warning PR 설명이 제공된 템플릿 구조를 따르지만 완성되지 않았습니다. "1. ⭐️ 변경된 내용" 섹션이 미완성 상태("~~ ing")로 표시되어 있고, "4. 📌 이 부분은 꼭 봐주세요!" 섹션이 완전히 누락되어 있습니다. 섹션 2는 선택 사항이므로 선택적으로 포함되었고, 섹션 3은 펀딩 퍼체이즈 설정에 대한 기술적 세부 정보로 잘 작성되었습니다. 하지만 필수 섹션인 "변경된 내용"이 완전하지 않고 "이 부분은 꼭 봐주세요" 섹션이 누락된 점은 검토자가 핵심 변경 사항과 주의해야 할 부분을 명확하게 파악하기 어렵게 합니다. PR 설명을 완성하기 위해 다음 사항을 수정해주세요: (1) "1. ⭐️ 변경된 내용" 섹션에서 "~~ ing"를 제거하고 추가된 주요 파일(BillingHelper, PaymentRepository, 도메인 모델 등)과 구조적 변경사항을 명확하게 설명해주세요. (2) "4. 📌 이 부분은 꼭 봐주세요!" 섹션을 추가하여 검토자가 특별히 주목해야 할 부분(예: BillingHelperImpl의 구현, Pause 기능 제거 사유 등)을 명시해주세요.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (1 passed)
Check name Status Explanation
Title Check ✅ Passed PR 제목 "PC-958 인앱결제 구현"은 변경 사항의 핵심을 정확하게 반영하고 있습니다. 이 PR의 주요 목적은 인앱결제 기능을 구현하는 것이며, 새로운 billing 모듈, Google Play Billing 라이브러리 통합, PaymentRepository 및 BillingHelper 인터페이스 추가, 그리고 Store 기능 업데이트가 모두 이를 지원합니다. 제목은 간결하고 명확하며, 개발자가 변경 사항을 빠르게 이해할 수 있을 정도로 구체적입니다.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

tgyuuAn and others added 20 commits June 11, 2025 13:19
- PromotionProduct 모델 수정
- PromotionProductResponse 모델 수정
- PreviewPromotionCard UI 삭제 이후 PieceImage로 대체
@tgyuuAn tgyuuAn marked this pull request as ready for review October 18, 2025 02:06
Copy link

@coderabbitai coderabbitai bot left a 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.purchaseStatePurchase.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) 가드가 이미 적용되어 있어 프로덕션 환경에서는 로깅이 비활성화됩니다. 따라서 배포 위험은 제한적입니다. 다만 다음 선택적 개선사항을 검토하세요:

  1. HttpLoggingInterceptor에 redactHeader() 적용: 알려진 민감한 헤더(Authorization, Cookie 등)에 redactHeader()를 사용하여 로깅 시 자동으로 마스킹되도록 하는 것이 권장 사항입니다.

  2. Authorization 토큰 부분 마스킹: 66번 줄에서 전체 토큰을 로깅하는 대신, 토큰의 일부만 노출하는 것이 좋습니다.

  3. 스타일 이슈: 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 참조 전달 대신 대체 패턴 고려

OnPurchaseClickActivity 인스턴스를 직접 전달받고 있습니다. 이는 다음과 같은 문제를 야기할 수 있습니다:

  • Activity 참조로 인한 메모리 누수 위험
  • 테스트 가능성 저하
  • 레이어 간 결합도 증가

다음 대안을 고려해주세요:

  1. ActivityResultLauncher를 Composable에서 생성하여 콜백으로 전달
  2. BillingHelper에서 Context만 사용하도록 리팩토링
  3. 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
     ): NotificationRepository
feature/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

📥 Commits

Reviewing files that changed from the base of the PR and between 86ad9de and 90c05d1.

⛔ Files ignored due to path filters (1)
  • app/debug/app-debug.apk.zip is 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.ktdata 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:storecore: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.procom.android.vending.billing.** keep 규칙 추가는 권장됩니다.

Comment on lines 1 to 21
{
"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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

빌드 아티팩트를 버전 관리에서 제외해야 합니다

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.

Comment on lines 9 to 15
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>
}
Copy link

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 -A20

Length 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 -C2

Length 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 -C3

Length 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.

Comment on lines +79 to +95
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()
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

프로덕션 노출된 테스트용 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().

Comment on lines 186 to 213
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) }
)
}
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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 -S

Length 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 -A3

Length 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 -60

Length 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 -C2

Length 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 -80

Length 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 -S

Length 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 -100

Length of output: 1680


🏁 Script executed:

#!/bin/bash
# Find PieceImage composable in Image.kt
cat core/designsystem/src/main/java/com/puzzle/designsystem/component/Image.kt

Length 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 -80

Length 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 -50

Length of output: 4418


🏁 Script executed:

#!/bin/bash
# Check the custom clickable modifier implementation
rg -nP 'fun.*clickable|class.*Clickable' core/common -S -A10 | head -50

Length 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 -A2

Length 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.

Comment on lines 51 to 53
billingHelper.purchaseProduct(
activity = intent.activity,
purchaseProduct = intent.purchaseProduct
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Comment on lines 88 to 101
// 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)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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 -S

Length of output: 1954


추가 검증이 필요합니다. 다음을 확인하겠습니다:

Now I need to verify if paymentRepository.purchaseProduct() handles consumption internally:

기존 리뷰 코멘트를 검증하기 위해 추가 정보를 확인하겠습니다:

미소비(unconsumed) 내역을 하나만 처리함

비소비 상품의 경우 consumeAsync() 호출이 필수이며, 이는 자동으로 구매를 승인합니다. 현재 코드에서 BillingHelperImplconsumeAsync가 주석 처리되어 있으므로, 목록이 여러 건일 때 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.

Comment on lines 60 to 74
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,
)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

세일 표시에서 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

Comment on lines 174 to 193
// 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)
}
}
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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 -50

Length 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' -C2

Length 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' -C5

Length 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' -A30

Length 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.kt

Length 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.

Copy link
Member Author

@tgyuuAn tgyuuAn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

근형님 고생하셨습니다!!!!!!!!!!

약간의 코멘트와 궁금한 사항 질문 남겨봤어요~!

Copy link
Member Author

@tgyuuAn tgyuuAn Oct 18, 2025

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())
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p1)

이거 일단 store쪽만 들어가는 라이브러리라서 공통 플러그인에 넣을 필요는 없을 것 같아요. 일단은 store 쪽에만 dependency로 넣어주는 게 좋을 것 같아요!

Copy link
Collaborator

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>)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p5) consume은 서버에서 하는걸로 했으니 불필요한 주석은 제거해주는게 좋을 것 같아요. 맥락 없는 사람이 처음 보면 오히려 더 헷갈릴 느낌이 들어요.

Comment on lines 17 to 33
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, // 서버에서 내려줘야 함
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기도 마찬가지에요~!

Comment on lines +7 to +20
@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()
}
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p1)
이거 위와 마찬가지인데, 아직은 store쪽에만 들어가는 스펙이라 common 에 넣어버리면 다른 기능 모듈에서 불필요한 의존성이 생길 것 같아요.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그러면 일단은 store 안으로 다 넣는방식으로 수정할게요!

Comment on lines 180 to 185
@Composable
private fun ProductList(
products: List<PieceProduct>,
onPurchaseClick: (PieceProduct) -> Unit,
modifier: Modifier = Modifier
) {
Copy link
Member Author

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 처리하면 좋을 것 같아요.

Comment on lines 59 to 74
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)
}
}
}
}
}
Copy link
Member Author

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()
Copy link
Member Author

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를 쥐어주면 메모리 누수가 생길 위험이 있을 수 있으니까요

Copy link
Collaborator

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로 넘겨서 액티비티에서 처리해야한다는 뜻일까요?

Copy link
Member Author

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가 필요한 메소드를 호출한다는 말이었어요~!

Comment on lines 146 to 150
override fun onResume() {
super.onResume()
viewModel.processUnConsumedProducts()
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

질문! ) MainActivity에서 반드시 해당 로직 처리를 해야하나요?!
기획적으로 정해진건지 궁금해요~! 만약 onResume() 탔을 때 구매하지 못한 상품이 MainActivity에서 잡히면 어떤 흐름을 타게되나요?

Copy link
Collaborator

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처리 하는 흐름으로 갈 듯 합니다!

include(":feature:onboarding")
include(":feature:notification")
include(":feature:store")
include(":core:billing")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p5)

core는 위로 살짝 올려주면 좋을 것 같아요~!

@tgyuuAn tgyuuAn added the 머지 해도될듯염🌟 현재 코드를 기존 코드에 합쳐도 될 것 같다라고 판단..! 🌟 label Oct 20, 2025
* [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로 분리
Copy link

@coderabbitai coderabbitai bot left a 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-op
core/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 null
feature/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

📥 Commits

Reviewing files that changed from the base of the PR and between 90c05d1 and 32704da.

📒 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로 변환되어 안정성 측면도 좋습니다. 👍

Comment on lines +95 to +114
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}")
}
}
Copy link

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.

Comment on lines +116 to +130
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() {}
})
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
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.

Comment on lines +26 to +27
implementation(projects.core.common)
implementation(projects.core.billing)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

검증 필요: core.billingimplementation으로 변경 시 컴파일 오류 위험

이전 코드 리뷰에서 지적했듯이, core.billing이 이제 implementation으로만 포함되었습니다. 하지만 지난 검토에서 확인된 바와 같이, StoreState.ktStoreIntent.kt의 공개 API에서 여전히 PieceProduct 타입(billing 모듈 도메인 타입)이 노출되고 있다면, 의존하는 다른 모듈들에서 컴파일 오류가 발생할 수 있습니다.

core.billingimplementation으로 유지하려면, 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.billingimplementation으로 변경하면 안 됩니다 — 공개 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.billingimplementation이면 불가능합니다.

해결책 (둘 중 하나 선택):

  1. core.billingapi(projects.core.billing)로 변경 → billing 모듈을 전이 의존성으로 노출
  2. 또는 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).

@kkh725 kkh725 merged commit 96a587c into develop Oct 20, 2025
1 of 2 checks passed
@kkh725 kkh725 deleted the feature/PC-958 branch October 20, 2025 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ㄱㅎ근형 🌙 ㅌㄱ태규 ☀️ 훗날 크게될 ENFP 남성, tgyuuAn 기능 ⚒️ 새로운 기능 구현 ⚒️ 머지 해도될듯염🌟 현재 코드를 기존 코드에 합쳐도 될 것 같다라고 판단..! 🌟

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants