Google Play In-App Update API (v2.1.0)를 이용하여 DayCnt415 앱에 인앱 업데이트 기능을 추가했습니다.
구현 날짜: 2026-03-10 상태: ✅ 완료 및 테스트 가능
🎯 기능 설명
1. 두 가지 업데이트 모드 지원
✅ IMMEDIATE 모드 (강제 업데이트)
언제 사용: 중요한 보안 업데이트나 필수 기능 업데이트
사용자 경험: 스킵 불가능, 뒤로가기 버튼 비활성화
트리거 조건: 우선순위 ≥ 4 또는 버전 차이 > 5
UI: 설치 진행률 표시, 중단 불가
✅ FLEXIBLE 모드 (선택적 업데이트)
언제 사용: 일반 기능 개선사항 또는 버그 픽스
사용자 경험: 나중에, 지금 업데이트 버튼 제공
트리거 조건: 모든 업데이트 사용 가능 (우선순위 < 4)
UI: 다운로드 진행률 표시, 유연한 설치 옵션
🏗️ 아키텍처
레이어 구조
AppUpdateProvider (Compose Root) ↓ UpdateViewModel (상태 관리) ↓ AppUpdateService (비즈니스 로직) ↓ Google Play Core Library (네이티브 API)
컴포넌트 설명
컴포넌트
파일경로
책임
AppUpdateService
domain/service/AppUpdateService.kt
Play Core API 래핑, 라이프사이클 관리, 상태 모니터링
UpdateViewModel
presentation/viewmodel/UpdateViewModel.kt
StateFlow 기반 상태 관리, UI 로직
AppUpdateDialog
presentation/ui/components/AppUpdateDialog.kt
Compose UI (IMMEDIATE/FLEXIBLE 모드)
UpdateAvailableBanner
presentation/ui/components/AppUpdateDialog.kt
저우선순위 업데이트 배너
AppUpdateProvider
presentation/ui/screens/AppUpdateProvider.kt
Compose 루트 래퍼, 라이프사이클 동기화
UpdateModule
di/UpdateModule.kt
Hilt 의존성 주입 설정
📁 생성된 파일
1. 도메인 계층
app/src/main/java/com/billcoreatech/daycnt415/domain/service/ └── AppUpdateService.kt ├── AppUpdateService 클래스 (Play Core API 래핑) ├── UpdateInstallState sealed class (설치 상태) └── UpdateAvailableState data class (업데이트 정보)
주요 메서드:
checkForAppUpdate(): Task<AppUpdateInfo> - 업데이트 확인
startImmediateUpdateFlow() - 강제 업데이트 시작
startFlexibleUpdateFlow() - 선택적 업데이트 시작
completeUpdate() - 다운로드된 업데이트 설치
registerInstallStateUpdatedListener() - 상태 모니터링 시작
unregisterInstallStateUpdatedListener() - 상태 모니터링 종료
2. 프레젠테이션 계층 (ViewModel)
app/src/main/java/com/billcoreatech/daycnt415/presentation/viewmodel/ └── UpdateViewModel.kt ├── UpdateViewModel 클래스 (@HiltViewModel) ├── UpdateUiState data class └── UpdateType enum
주요 메서드:
checkForUpdate() - 업데이트 확인 (자동 호출)
startImmediateUpdate(activity) - 강제 업데이트 시작
startFlexibleUpdate(activity) - 선택적 업데이트 시작
installUpdate() - 다운로드 완료 후 설치
dismissUpdateDialog() - 다이얼로그 닫기
onActivityResumed() / onActivityPaused() - 라이프사이클 관리
data class UpdateUiState( val updateAvailable: Boolean = false, // 업데이트 가능 여부 val updatePriority: Int = 0, // 우선순위 (0-5) val clientVersionStalenessDays: Int? = null, // 버전 경과 일수 val appUpdateInfo: AppUpdateInfo? = null, // 업데이트 정보 val isCheckingForUpdate: Boolean = false, // 확인 중 val isUpdating: Boolean = false, // 업데이트 중 val isDownloading: Boolean = false, // 다운로드 중 val isDownloadCompleted: Boolean = false, // 다운로드 완료 val downloadProgress: Int = 0, // 진행률 (0-100) val shouldShowUpdateDialog: Boolean = false, // 다이얼로그 표시 val updateType: UpdateType = UpdateType.NONE, // IMMEDIATE/FLEXIBLE val errorMessage: String? = null // 에러 메시지 )
🛠️ 통합 방법
1. MainActivity 수정 (이미 완료됨)
@AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge()
@HiltAndroidApp class MyApplication : Application() { override fun onCreate() { super.onCreate() // Hilt 자동으로 초기화됨 } }
3. AndroidManifest.xml (변경 없음)
기존 권한 유지
Play Core 라이브러리는 매니페스트 병합으로 자동 추가
📊 상태 구독 예시 (필요시 추가 화면에서 사용)
@Composable fun SomeScreen() { val updateViewModel: UpdateViewModel = hiltViewModel() val uiState by updateViewModel.uiState.collectAsStateWithLifecycle()
// 업데이트 진행률 표시 if (uiState.isDownloading) { Text("다운로드: ${uiState.downloadProgress}%") }
// 커스텀 UI 추가 가능 if (uiState.updateAvailable && !uiState.shouldShowUpdateDialog) { UpdateAvailableBanner( isVisible = true, updatePriority = uiState.updatePriority, onUpdateClick = { updateViewModel.startFlexibleUpdate(context as Activity) }, onDismiss = { updateViewModel.dismissUpdateDialog() } ) } }
🧪 테스트 방법
Google Play Console 설정
내부 테스트 트랙 활용:
Google Play Console → 출시 → 내부 테스트
새 버전 APK 업로드 (versionCode 증가)
테스트 기기 추가
테스트 우선순위 설정:
Google Play Console → 설정 → 우선순위
IMMEDIATE: 우선순위 4 이상
FLEXIBLE: 우선순위 1-3
로컬 테스트 (선택):
Play Core 라이브러리의 FakeAppUpdateManager 사용
단위 테스트 작성
테스트 체크리스트
IMMEDIATE 모드 업데이트 다이얼로그 표시
FLEXIBLE 모드 업데이트 다이얼로그 표시
다운로드 진행률 표시 (FLEXIBLE 모드)
"나중에" 버튼 동작 (FLEXIBLE 모드)
"지금 설치" 버튼 동작 (FLEXIBLE 모드)
설치 완료 후 자동 재시작
네트워크 오류 처리
Activity 회전 시 상태 유지
🔐 에러 처리
UpdateViewModel의 에러 처리 전략
// 1. 업데이트 확인 실패 try { val appUpdateInfo = appUpdateService.checkForAppUpdate().await() } catch (e: Exception) { _uiState.value = _uiState.value.copy( errorMessage = e.message ?: "Failed to check for update" ) }
// 2. 업데이트 시작 실패 if (!success) { _uiState.value = _uiState.value.copy( errorMessage = "Failed to start update" ) }
// 3. 설치 실패 UpdateInstallState.Failed → errorMessage 업데이트
✅ Google Play Console 디버그 기호 문제 해결
🤔 문제: 디버그 기호가 업로드되지 않았다는 메시지
메시지 의미
이 App Bundle 아티팩트 유형은 네이티브 코드를 포함하며
아직 디버그 기호가 업로드되지 않았습니다.
•
네이티브 코드: Google Play Services (AdMob, Firebase 등)에 포함된 C/C++ 코드
•
디버그 기호: 크래시 발생 시 스택 트레이스를 읽을 수 있게 해주는 기호 정보
•
경고: 필수는 아니지만, 앱 크래시 분석 시 도움이 됨
📊 네이티브 코드 출처
앱의 다음 의존성에서 네이티브 코드가 포함됩니다:
1.
Google Play Services (AdMob)
implementation(libs.play.services.ads)
2.
Firebase Crashlytics
implementation(libs.firebase.crashlytics.ktx)
3.
기타 Google GMS 라이브러리
◦
kotlinx-coroutines-play-services
◦
play-services-wearable (Wear 앱의 경우)
✅ 해결 방법 3가지
방법 1: 자동 업로드 (권장) ⭐
build.gradle.kts의 release buildType에 다음 추가:
buildTypes {
release {
// ...기존 설정...
// Google Play에 디버그 기호 자동 업로드 활성화
ndk {
debugSymbolLevel = "full"
}
}
}
장점:
•
✅ 가장 간단함
•
✅ 자동으로 처리됨
•
✅ Google Play에 자동 업로드
이 방법으로 이미 수정됨!
방법 2: 수동 업로드
1.
App Bundle 빌드
./gradlew bundleRelease
2.
기호 파일 추출
# Android Studio Terminal에서
./gradlew extractDebugSymbols
3.
Google Play Console 업로드
◦
릴리즈 관리 → 앱 릴리즈 → 기호 파일 → 업로드
장점:
•
수동 제어 가능
•
특정 버전에만 기호 업로드 가능
단점:
•
매번 수동으로 해야 함
방법 3: 기호 업로드 비활성화 (비권장)
기호 생성 자체를 비활성화:
ndk {
debugSymbolLevel = "none" // 기호 없음
}
주의: 크래시 분석이 어려워지므로 권장하지 않음!
📈 debugSymbolLevel 옵션
ndk {
debugSymbolLevel = "full" // 전체 기호 (권장)
// debugSymbolLevel = "partial" // 부분 기호
// debugSymbolLevel = "none" // 기호 없음
}
옵션
기호 크기
분석
권장
full
크게 증가
최상
⭐⭐⭐
partial
중간 증가
양호
⭐⭐
none
변화 없음
불가
비권장
🔍 적용 결과
수정 전
⚠️ 이 App Bundle 아티팩트 유형은 네이티브 코드를 포함하며
아직 디버그 기호가 업로드되지 않았습니다.
수정 후 (예상)
✅ 디버그 기호가 포함된 App Bundle
크래시 분석 시 스택 트레이스 해석 가능
🚀 다음 단계
1. 빌드
# Clean Build
Build → Clean Project
Build → Build Bundle(s) / APK(s) → Build Bundle(s)
2. App Bundle 생성
./gradlew bundleRelease
출력 위치: app/release/app-release.aab
3. Google Play Console 업로드
1.
Google Play Console 접속
2.
릴리즈 관리 → 프로덕션
3.
App Bundle 업로드
4.
"기호 파일 포함됨" 확인
📋 기호 파일 정보
기호 파일이란?
•
목적: 컴파일된 바이너리 코드를 읽을 수 있는 형태로 변환
•
포함 정보:
◦
함수/메서드 이름
◦
변수 이름
◦
파일 경로 및 줄 번호
◦
소스 코드 위치
크래시 분석 예시
기호 없음:
java.lang.NullPointerException
at cohttp://m.google.android.gms.internal.ads.a.b(Unknown Source)
at cohttp://m.google.android.gms.internal.ads.c.d(Unknown Source)
기호 있음:
java.lang.NullPointerException
at cohttp://m.google.android.gms.internal.ads.zzaki.zza(zzaki.java:123)
at cohttp://m.google.android.gms.internal.ads.zzalc.onAdLoaded(zzalc.java:456)
at AdMobInterstitialAd.onLoadComplete(AdMobInterstitialAd.java:789)
⚠️ 주의사항
빌드 크기 증가
debugSymbolLevel = "full" 사용 시:
- App Bundle 크기: +30~50%
- 실제 앱 크기: 변화 없음 (기호는 Play Console에만 저장)
Google Play에서의 기호 관리
최대 저장: 90일 (자동 삭제)
수동 삭제: Google Play Console에서 가능
다운로드: Android Studio Profiler에서 자동 다운로드
🔗 관련 설정 파일
build.gradle.kts
buildTypes {
release {
// ... 기존 설정 ...
ndk {
debugSymbolLevel = "full"
}
}
}
Gradle 7.1+ (또는 AGP 7.1+) 필수
•
현재 프로젝트: AGP 9.0.1 ✅ (지원함)
📚 추가 최적화
1. ProGuard 규칙 확인
# proguard-rules.pro
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
2. Gradle 캐시
# 캐시 초기화
./gradlew clean
3. 재빌드
./gradlew bundleRelease --refresh-dependencies
✅ 최종 체크리스트
•
[x] ndk { debugSymbolLevel = "full" } 추가
•
[ ] ./gradlew clean 실행
•
[ ] ./gradlew bundleRelease 빌드
•
[ ] app/release/app-release.aab 생성 확인
•
[ ] Google Play Console에 업로드
•
[ ] "기호 파일 포함됨" 메시지 확인
🎯 결론
이 해결책으로:
•
✅ 앱 크래시 분석 개선
•
✅ Google Play Console 경고 제거
•
✅ 사용자 문제 신속한 대응
•
✅ 앱 품질 향상
수정 완료 시간: 2026-02-16
AGP 버전: 9.0.1
상태: ✅ build.gradle.kts 수정 완료
#스하리1000명프로젝트,
In Korea verloren? Auch wenn Sie kein Koreanisch sprechen, hilft Ihnen diese App dabei, sich problemlos fortzubewegen.
Sprechen Sie einfach Ihre Sprache – es übersetzt, sucht und zeigt Ergebnisse in Ihrer Sprache an.
Ideal für Reisende! Unterstützt mehr als 10 Sprachen, darunter Englisch, Japanisch, Chinesisch, Vietnamesisch und mehr.
Probieren Sie es jetzt aus!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127
// ✅ Billing Library 8.x 방식 .enablePendingPurchases(PendingPurchasesParams.newBuilder().build())
// ❌ 이전 방식 (제거된 메서드) .enablePendingPurchases( PendingPurchasesParams.newBuilder() .enableOneTimeProducts() // 제거됨 .enablePrepaidPlans() // 제거됨 .build() )
변경 이유: Billing Library 8.x에서는 모든 구매 타입(구독, 일회성, 선불)이 기본적으로 활성화됨
영향: 별도의 활성화 메서드 호출 불필요
b) purchaseProduct 메서드 개선
개선: Null safety 체크 추가, 명확한 에러 처리
c) onPurchasesUpdated 간소화
불필요한 null 체크 제거 (BillingResult는 non-null)
private fun purchaseProduct(productDetails: ProductDetails) : BillingResult {
// Null safety 강화
val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken
if (offerToken == null) {
Log.e(TAG, "구독 상품에 대한 offer token을 찾을 수 없습니다.")
return BillingResult.newBuilder()
.setResponseCode(BillingClient.BillingResponseCode.ERROR)
.build()
}
// ProductDetailsParams 생성
val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.setOfferToken(offerToken)
.build()
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
.build()
✅ txtDayToDay: "MM-dd HH:mm ~ MM-dd HH:mm" 형식으로 표시
✅ 프로그레스바: 0-100 값으로 정확히 동작
✅ SharedPreferences에서 startTime/closeTime 읽기
✅ 주중/휴일 로직 구현
🔄 TODO: DB 연동 (bfDay, afDay, isHoliday 실제 데이터로 교체)
남은 작업
DB 연동: dayInfoRepository에서 bfDay, afDay, isHoliday 가져오기
시간 경과 후 다음 기간으로 자동 전환
월 변경 기능 (onPreviousMonth, onNextMonth) 활성화
2026-02-26 (계속 2)
Phase 3: CalendarSection 날짜 표시 구현
배경
MainScreen에 CalendarSection이 있지만 날짜와 데이터가 표시되지 않음
레거시 MainActivity의 getDisplayMonth(), setCalendarDate() 로직 분석 필요
GridAdapter의 getView() 로직을 DayCard Composable로 구현 필요
레거시 로직 분석
캘린더 날짜 리스트 생성 (setCalendarDate):
해당 월의 1일이 무슨 요일인지 확인
1일 이전(일요일~1일 전날)을 빈 칸으로 채움
해당 월의 모든 날짜를 yyyyMMdd 형식으로 추가
마지막 날 이후를 빈 칸으로 채워 7의 배수 맞춤
GridAdapter의 날짜 표시 로직:
날짜는 dd만 표시 (yyyyMMdd의 마지막 2자리)
빈 셀은 아무것도 표시 안함
오늘 날짜는 회색 배경 + 흰색 텍스트
일요일은 빨간색 (softred)
토요일은 파란색 (softblue)
휴일(isHoliday == "Y")은 빨간색
DB에서 메시지를 가져와 날짜 아래 표시
구현 내용
1. MainViewModel - generateCalendar() 메서드 추가
private fun generateCalendar() { viewModelScope.launch { val dayList = ArrayList<DayInfo>() val mCal = Calendar.getInstance() val year = calendar.get(Calendar.YEAR) val month = calendar.get(Calendar.MONTH)
// 1일 - 요일 매칭 시키기 위해 공백 add for (_ in 1 until dayNum) { dayList.add(DayInfo(date = "", ...)) }
// 해당 월의 모든 날짜 추가 val maxDay = mCal.getActualMaximum(Calendar.DAY_OF_MONTH) for (i in 0 until maxDay) { mCal.set(Calendar.DAY_OF_MONTH, i + 1) val dateStr = sdf.format(Date(mCal.timeInMillis)) dayList.add(getDayInfoFromDB(dateStr)) }
// 나머지 빈칸도 채우기 for (_ in lastDayOfWeek..6) { dayList.add(DayInfo(date = "", ...)) }
private fun getDayInfoFromDB(dateStr: String): DayInfo { val sdf = SimpleDateFormat("yyyyMMdd", Locale.KOREAN) val date = sdf.parse(dateStr) val cal = Calendar.getInstance() cal.time = date ?: Date() val weekOfDay = cal.get(Calendar.DAY_OF_WEEK)
// 요일 문자열 생성 val dayOfWeekStr = when (weekOfDay) { Calendar.SUNDAY -> "일" Calendar.MONDAY -> "월" // ... }
Log.e("MainViewModel", "Generating calendar for $year-${month + 1}") Log.e("MainViewModel", "Calendar generated with ${dayList.size} items")
// 데이터가 있는 항목 확인 val dataItems = dayList.filter { it.message.isNotEmpty() || it.isHoliday == "Y" } Log.e("MainViewModel", "Items with data: ${dataItems.size}") dataItems.forEach { Log.e("MainViewModel", "Data item: date=${it.date}, message=${it.message}, isHoliday=${it.isHoliday}") }
getDayInfoFromDB()에 로그 추가:
Log.e("MainViewModel", "Fetching data for date: $dateStr")
dayInfoRepository.getTodayMsg(dateStr).collect { dayInfo -> if (dayInfo != null) { message = dayInfo.message isHoliday = dayInfo.isHoliday Log.e("MainViewModel", "Found data: date=$dateStr, message=$message, isHoliday=$isHoliday") } else { Log.e("MainViewModel", "No data found for date: $dateStr") } }
에러 로그 추가:
catch (e: Exception) { Log.e("MainViewModel", "Error fetching data for $dateStr", e) // ... }
4. DB 스키마 불일치 문제 해결
문제 발견
앱 실행 시 DB 스키마 불일치 오류 발생:
java.lang.IllegalStateException: Pre-packaged database has an invalid schema Expected: id (notNull=true) Found: _id (notNull=false)
원인 분석
Room Entity 정의 (DayInfoEntity.kt):
컬럼명: id (PrimaryKey)
Not null: true
모든 필드가 non-nullable
레거시 DB 테이블 구조:
컬럼명: _id (PrimaryKey)
Not null: false
모든 필드가 nullable
해결 방법
1. DayInfoEntity를 레거시 스키마에 맞춤
변경 전:
@Entity(tableName = "dayinfo") data class DayInfoEntity( @PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "mdate") val date: String,
@ColumnInfo(name = "msg") val message: String,
@ColumnInfo(name = "dayOfweek") val dayOfWeek: String,
@ColumnInfo(name = "isholiday") val isHoliday: String )
변경 후:
@Entity(tableName = "dayinfo") data class DayInfoEntity( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "_id") // 레거시 컬럼명 사용 val id: Int? = null, // nullable로 변경
@ColumnInfo(name = "mdate") val date: String? = null, // nullable로 변경
@ColumnInfo(name = "msg") val message: String? = null, // nullable로 변경
@ColumnInfo(name = "dayOfweek") val dayOfWeek: String? = null, // nullable로 변경
@ColumnInfo(name = "isholiday") val isHoliday: String? = null // nullable로 변경 )
2. 확장 함수 수정 (null 안전성 처리)
toDomain():
fun DayInfoEntity.toDomain(): DayInfo = DayInfo( id = id ?: 0, date = date ?: "", message = message ?: "", dayOfWeek = dayOfWeek ?: "", isHoliday = isHoliday ?: "N" )
toEntity():
fun DayInfo.toEntity(): DayInfoEntity = DayInfoEntity( id = if (id == 0) null else id, // 0이면 null (autoGenerate) date = date, message = message, dayOfWeek = dayOfWeek, isHoliday = isHoliday )
3. DatabaseModule 수정
@Provides fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase { return Room.databaseBuilder( context, AppDatabase::class.java, AppDatabase.DB_NAME ) .fallbackToDestructiveMigration() // 스키마 불일치 시 DB 재생성 .build() }
주요 변경 사항
항목
변경 전
변경 후
Primary Key 컬럼명
id
_id
모든 필드 nullable
Non-nullable (String, Int)
Nullable (String?, Int?)
Entity 기본값
id = 0만 기본값
모든 필드 null 기본값
toDomain()
직접 매핑
null 체크 후 기본값 제공
toEntity()
직접 매핑
id=0일 때 null 처리
Database Builder
기본 설정
fallbackToDestructiveMigration 추가
스키마 매핑 상세
레거시 DB 컬럼
Room Entity 필드
타입
Nullable
_id
id
Int?
✅
mdate
date
String?
✅
msg
message
String?
✅
dayOfweek
dayOfWeek
String?
✅
isholiday
isHoliday
String?
✅
동작 설명
기존 레거시 DB 사용:
앱이 이미 설치되어 있고 레거시 DB가 있는 경우
Entity 정의가 레거시 스키마와 일치하므로 정상 동작
기존 데이터 보존
새 설치 또는 스키마 변경:
fallbackToDestructiveMigration() 설정
스키마 불일치 시 기존 DB 삭제 후 새로 생성
데이터 손실 발생하지만 앱 실행은 정상
Null 안전성:
Entity에서 nullable 필드 사용
Domain Model 변환 시 기본값 제공 (toDomain)
Domain Model은 여전히 non-nullable 유지
빌드 결과
✅ Kotlin 컴파일: 경고만 있음 (에러 없음)
✅ Room 스키마: 레거시 DB와 일치
✅ DB 접근 에러 해결 예상
테스트 방법
앱 재실행 후 확인:
앱 데이터 삭제 (설정 → 앱 → DayCnt → 저장공간 → 데이터 삭제)
앱 재실행
Logcat 확인:
D/MainViewModel: Generating calendar for 2026-2 D/MainViewModel: Fetching data for date: 20260201 D/MainViewModel: No data found for date: 20260201 D/MainViewModel: Calendar generated with 35 items D/MainViewModel: Items with data: 0
최종 상태
✅ DayInfoEntity: 레거시 DB 스키마 완벽 매칭
✅ _id 컬럼명 사용
✅ 모든 필드 nullable 처리
✅ null 안전 변환 함수 구현
✅ fallbackToDestructiveMigration 추가
🔄 TODO: 앱 재실행하여 DB 에러 해결 확인
다음 단계
앱 데이터 삭제 후 재실행
DB 정상 동작 확인
테스트 데이터 입력하여 UI 표시 확인
2026-02-26 (계속 5)
캘린더 섹션 표시 문제 디버깅
문제 보고
캘린더 섹션에 아무것도 표시되지 않음
DB 스키마 에러는 해결했지만 UI에 날짜가 안보임
디버깅 로직 추가
1. getDayInfoFromDB() - DB 에러 내부 처리
DB 조회 실패해도 날짜는 표시되도록 수정:
// Repository에서 실제 DB 데이터 가져오기 (DB 에러 시 무시) var message = "" var isHoliday = "N"
try { Log.e("MainViewModel", "Fetching data for date: $dateStr")