2026/05/07

오늘의 이야기

휴게시간 (앱) Android Kotlin 프로젝트 현대화 계획


변경전 UI



📋 프로젝트 개요


프로젝트명: daycnt415 (날짜 카운팅 앱)



  • 현재 상태: 레거시 XML 레이아웃 기반, SQLiteOpenHelper 기반 직접 데이터 관리

  • 대상 SDK: 36 (Kotlin 2.2.10, Gradle 9.0.1)

  • 목표: Jetpack Compose, Hilt, Room, KSP를 활용한 모던 아키텍처 전환




🏗️ 현재 아키텍처 분석


현재 구조의 문제점










































문제 영향 심각도
UI와 비즈니스 로직 강한 결합 테스트 불가, 유지보수 어려움 🔴 높음
SQLiteOpenHelper 직접 사용 반복되는 쿼리 코드, 메모리 누수 위험 🔴 높음
의존성 주입 없음 하드코딩된 인스턴스, 테스트 어려움 🔴 높음
Activity 기반 상태 관리 화면 회전 시 데이터 손실, 메모리 누수 🟠 중간
Manual Cursor 관리 메모리 누수, null 안전성 부족 🟠 중간
반응형 데이터 흐름 부재 상태 동기화 어려움 🟠 중간

현재 사용 중인 라이브러리


✅ AndroidX (AppCompat, ConstraintLayout)
✅ View Binding
✅ Google Play Services (Ads, Billing, Review, App Update)
✅ Coroutines (1.10.2)
✅ Gson


주요 클래스 구조


com.billcoreatech.daycnt415/
├── MainActivity.kt (568줄) - 캘린더 UI, 날짜 계산, 제스처 처리
├── SettingActivity.kt (163줄) - 설정 화면, 결제 관리
├── InitActivity.kt - 초기화 화면
├── SettingActivity.kt
├── database/
│ ├── DBHelper.kt - SQLiteOpenHelper 상속
│ └── DBHandler.kt (160줄) - SQL 직접 실행
├── dayManager/
│ └── DayinfoBean.kt (9줄) - 데이터 클래스
├── billing/
│ └── BillingManager.kt (266줄) - 구글 인앱 결제
├── util/
│ ├── DayCntWidget.kt - 앱 위젯
│ ├── GridAdapter.kt - 캘린더 그리드 어댑터
│ ├── Holidays.kt - 휴일 관리
│ ├── LunarCalendar.kt - 음력 계산
│ └── 기타 유틸리티
└── res/
└── layout/ - XML 레이아웃 파일 (모두 XML 기반)



🎯 4단계 마이그레이션 전략


Phase 1: 기초 구축 및 의존성 설정 (1-2주)


1.1 build.gradle 의존성 추가


// === 프로젝트 레벨 build.gradle ===
buildscript {
ext.kotlin_version = '2.3.10'
dependencies {
classpath 'com.android.tools.build:gradle:9.0.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.59.2'
}
}

// === 앱 레벨 build.gradle ===
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'com.google.devtools.ksp' version '2.3.2'
id 'dagger.hilt.android.plugin'
}

dependencies {
// Hilt (의존성 주입)
implementation 'com.google.dagger:hilt-android:2.59.2'
ksp 'com.google.dagger:hilt-compiler:2.59.2'

// Hilt Navigation Compose
implementation 'androidx.hilt:hilt-navigation-compose:1.3.0'

// Hilt Work (WorkManager와 Hilt 통합)
implementation 'androidx.hilt:hilt-work:1.3.0'

// Room Database (로컬 데이터베이스)
implementation 'androidx.room:room-runtime:2.8.4'
implementation 'androidx.room:room-ktx:2.8.4'
ksp 'androidx.room:room-compiler:2.8.4'

// ViewModel & Lifecycle
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.10.0'
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.10.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0'

// Activity Compose
implementation 'androidx.activity:activity-compose:1.12.4'

// Core KTX
implementation 'androidx.core:core-ktx:1.17.0'

// Jetpack Compose BOM (Bill of Materials - 버전 자동 관리)
def composeBom = platform('androidx.compose:compose-bom:2026.02.00')
implementation composeBom
androidTestImplementation composeBom

// Compose UI
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-tooling-preview'
debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest'

// Compose Material 3
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.material3:material3-window-size-class'

// Compose Foundation
implementation 'androidx.compose.foundation:foundation'

// Compose Icons (옵션)
implementation 'androidx.compose.material:material-icons-extended'

// Navigation Compose
implementation 'androidx.navigation:navigation-compose:2.9.7'

// Glance (Widget용 Compose)
implementation 'androidx.glance:glance-appwidget:1.3.0'
implementation 'androidx.glance:glance-material3:1.3.0'

// Splash Screen
implementation 'androidx.core:core-splashscreen:1.3.0'

// Coroutines (비동기 처리)
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.2'

// WorkManager (백그라운드 작업)
implementation 'androidx.work:work-runtime-ktx:2.11.1'

// DataStore (SharedPreferences 대체)
implementation 'androidx.datastore:datastore-preferences:1.2.0'

// Network (옵션 - API 통신 필요시)
implementation 'com.squareup.retrofit2:retrofit:3.0.0'
implementation 'com.squareup.retrofit2:converter-gson:3.0.0'
implementation 'com.squareup.okhttp3:okhttp:5.3.2'
implementation 'com.squareup.okhttp3:logging-interceptor:5.3.2'

// Image Loading
implementation 'io.coil-kt:coil-compose:2.7.0'

// 기존 라이브러리 유지
implementation 'androidx.appcompat:appcompat:1.8.0'
implementation 'com.google.android.material:material:1.14.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.2'
implementation 'com.google.android.gms:play-services-ads:25.0.0'
implementation 'com.android.billingclient:billing:7.2.0'
implementation 'com.google.code.gson:gson:2.13.2'
implementation 'com.google.android.gms:play-services-appset:17.0.0'
implementation 'com.google.android.gms:play-services-ads-identifier:19.0.0'
implementation 'com.google.android.play:review:2.1.0'
implementation 'com.google.android.play:app-update:2.1.0'

// ML Kit & Vision (옵션 - 카메라/바코드 스캔 필요시)
implementation 'com.google.mlkit:barcode-scanning:18.3.1'
implementation 'com.google.mlkit:text-recognition:19.0.1'
implementation 'com.google.mlkit:text-recognition-korean:16.0.1'

// CameraX (옵션 - 카메라 기능 필요시)
implementation 'androidx.camera:camera-core:1.5.3'
implementation 'androidx.camera:camera-camera2:1.5.3'
implementation 'androidx.camera:camera-lifecycle:1.5.3'
implementation 'androidx.camera:camera-view:1.5.3'

// 테스트 의존성
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.17.0'
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.7.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2'
testImplementation 'androidx.arch.core:core-testing:2.3.0'
testImplementation 'app.cash.turbine:turbine:1.3.0'

androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
androidTestImplementation 'androidx.navigation:navigation-testing:2.9.7'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.59.2'
kspAndroidTest 'com.google.dagger:hilt-compiler:2.59.2'
}

주요 버전 정보 (2026년 2월 최신 검증된 버전):



  • Kotlin: 2.3.10

  • KSP: 2.3.2

  • AGP: 9.0.1

  • Compose BOM: 2026.02.00

  • Hilt: 2.59.2

  • Hilt Navigation Compose: 1.3.0

  • Hilt Work: 1.3.0

  • Room: 2.8.4

  • Lifecycle: 2.10.0

  • Activity Compose: 1.12.4

  • Navigation Compose: 2.9.7

  • Core KTX: 1.17.0

  • Glance: 1.3.0

  • WorkManager: 2.11.1

  • Retrofit: 3.0.0

  • OkHttp: 5.3.2

  • Coil: 2.7.0

  • Coroutines: 1.10.2

  • Coroutines Play Services: 1.10.2

  • Play Services Ads: 25.0.0

  • Billing: 7.2.0

  • Gson: 2.13.2

  • ML Kit Barcode: 18.3.1

  • ML Kit Text Recognition: 19.0.1

  • ML Kit Text Recognition Korean: 16.0.1

  • CameraX: 1.5.3

  • App Update: 2.1.0

  • JUnit: 4.13.2

  • JUnit Android: 1.3.0

  • Espresso Core: 3.7.0


1.2 패키지 구조 재설계


com.billcoreatech.daycnt415/
├── presentation/
│ ├── ui/
│ │ ├── screens/
│ │ │ ├── MainScreen.kt
│ │ │ ├── SettingScreen.kt
│ │ │ └── InitScreen.kt
│ │ ├── components/
│ │ │ ├── CalendarGrid.kt
│ │ │ ├── DayCard.kt
│ │ │ └── 기타 재사용 컴포넌트
│ │ └── theme/
│ │ ├── Color.kt
│ │ ├── Typography.kt
│ │ └── Theme.kt
│ └── viewmodel/
│ ├── MainViewModel.kt
│ ├── SettingViewModel.kt
│ └── InitViewModel.kt
├── domain/
│ ├── model/
│ │ ├── DayInfo.kt (엔티티)
│ │ ├── Holiday.kt
│ │ └── UiState.kt
│ ├── repository/
│ │ ├── IDayInfoRepository.kt
│ │ └── IPreferenceRepository.kt
│ └── usecase/
│ ├── GetDayInfoUseCase.kt
│ ├── SaveDayInfoUseCase.kt
│ └── GetHolidaysUseCase.kt
├── data/
│ ├── local/
│ │ ├── database/
│ │ │ ├── AppDatabase.kt
│ │ │ ├── entity/
│ │ │ │ └── DayInfoEntity.kt
│ │ │ └── dao/
│ │ │ └── DayInfoDao.kt
│ │ ├── preferences/
│ │ │ └── PreferencesDataStore.kt
│ │ └── datasource/
│ │ ├── LocalDayInfoDataSource.kt
│ │ └── LocalPreferenceDataSource.kt
│ └── repository/
│ ├── DayInfoRepositoryImpl.kt
│ └── PreferenceRepositoryImpl.kt
├── di/
│ ├── DatabaseModule.kt
│ ├── RepositoryModule.kt
│ ├── UseCaseModule.kt
│ └── ManagerModule.kt
├── MyApplication.kt (@HiltAndroidApp)
└── MainActivity.kt (Compose 기반 진입점)

1.3 Hilt 애플리케이션 클래스 생성


@HiltAndroidApp
class MyApplication : Application()



Phase 2: 데이터 계층 현대화 (2-3주)


2.1 Room Entity 정의


// DBHelper.kt와 DBHandler.kt를 대체
@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
)

2.2 Room DAO 인터페이스


@Dao
interface DayInfoDao {
@Query("SELECT * FROM dayinfo ORDER BY mdate DESC")
fun getAllDayInfo(): Flow<List<DayInfoEntity>>

@Query("SELECT * FROM dayinfo WHERE mdate <= :targetDate ORDER BY mdate DESC LIMIT 1")
fun getTodayMsg(targetDate: String): Flow<DayInfoEntity?>

@Query("SELECT isholiday FROM dayinfo WHERE mdate = :targetDate")
suspend fun getIsHoliday(targetDate: String): String?

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertDayInfo(dayInfo: DayInfoEntity)

@Delete
suspend fun deleteDayInfo(dayInfo: DayInfoEntity)

@Update
suspend fun updateDayInfo(dayInfo: DayInfoEntity)
}

2.3 Room Database 클래스


@Database(
entities = [DayInfoEntity::class],
version = 1
)
abstract class AppDatabase : RoomDatabase() {
abstract fun dayInfoDao(): DayInfoDao

companion object {
const val DB_NAME = "HolidayInfo"
}
}

2.4 Repository 인터페이스 정의


interface IDayInfoRepository {
fun getAllDayInfo(): Flow<List<DayInfo>>
fun getTodayMsg(targetDate: String): Flow<DayInfo?>
suspend fun getIsHoliday(targetDate: String): String?
suspend fun saveDayInfo(dayInfo: DayInfo)
suspend fun deleteDayInfo(dayInfo: DayInfo)
}

interface IPreferenceRepository {
fun getStartTime(): Flow<String>
fun getCloseTime(): Flow<String>
suspend fun saveStartTime(time: String)
suspend fun saveCloseTime(time: String)
fun isBilled(): Flow<Boolean>
suspend fun setBilled(billed: Boolean)
}

2.5 Repository 구현


@Singleton
class DayInfoRepositoryImpl @Inject constructor(
private val dayInfoDao: DayInfoDao
) : IDayInfoRepository {
override fun getAllDayInfo(): Flow<List<DayInfo>> =
dayInfoDao.getAllDayInfo()
.map { entities -> entities.map { it.toDomain() } }

override suspend fun saveDayInfo(dayInfo: DayInfo) {
dayInfoDao.insertDayInfo(dayInfo.toEntity())
}
// ... 기타 메서드
}

2.6 Hilt 모듈 설정


@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Singleton
@Provides
fun provideAppDatabase(
@ApplicationContext context: Context
): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
AppDatabase.DB_NAME
).build()
}

@Provides
fun provideDayInfoDao(database: AppDatabase): DayInfoDao {
return database.dayInfoDao()
}
}

@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
@Singleton
@Provides
fun provideDayInfoRepository(
dayInfoDao: DayInfoDao
): IDayInfoRepository {
return DayInfoRepositoryImpl(dayInfoDao)
}
}



Phase 3: 프레젠테이션 계층 마이그레이션 (3-4주)


3.1 ViewModel 작성


// MainActivity.kt의 로직을 ViewModel으로 분리
@HiltViewModel
class MainViewModel @Inject constructor(
private val dayInfoRepository: IDayInfoRepository,
private val preferenceRepository: IPreferenceRepository
) : ViewModel() {

// UI 상태 데이터 클래스 (UiState 패턴)
data class UiState(
val dayInfoList: List<DayInfo> = emptyList(),
val currentDate: String = "",
val isLoading: Boolean = false,
val error: String? = null
)

private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()

// 초기화
init {
viewModelScope.launch {
dayInfoRepository.getAllDayInfo()
.catch { error ->
_uiState.update { it.copy(error = error.message) }
}
.collect { dayInfoList ->
_uiState.update { it.copy(dayInfoList = dayInfoList) }
}
}
}

fun onDateSelected(date: String) {
_uiState.update { it.copy(currentDate = date) }
}

fun saveDayInfo(dayInfo: DayInfo) {
viewModelScope.launch {
dayInfoRepository.saveDayInfo(dayInfo)
}
}
}

3.2 Jetpack Compose 스크린 작성


@Composable
fun MainScreen(
viewModel: MainViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 상단 정보 표시
HourTermDisplay(uiState.currentDate)

// 캘린더 그리드
CalendarGrid(
dayInfoList = uiState.dayInfoList,
onDateSelected = { date ->
viewModel.onDateSelected(date)
}
)

// 날짜 정보 목록
DayInfoList(dayInfoList = uiState.dayInfoList)

// 에러 표시
uiState.error?.let {
ErrorSnackbar(message = it)
}
}
}

// 재사용 가능한 컴포넌트들
@Composable
fun CalendarGrid(
dayInfoList: List<DayInfo>,
onDateSelected: (String) -> Unit
) {
LazyVerticalGrid(
columns = GridCells.Fixed(7),
modifier = Modifier.fillMaxWidth()
) {
items(dayInfoList.size) { index ->
DayCard(
dayInfo = dayInfoList[index],
onSelected = { onDateSelected(it.date) }
)
}
}
}

@Composable
fun DayCard(
dayInfo: DayInfo,
onSelected: (DayInfo) -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onSelected(dayInfo) },
colors = CardDefaults.cardColors(
containerColor = if (dayInfo.isHoliday == "Y")
Color.Red else Color.White
)
) {
Text(
text = dayInfo.date,
modifier = Modifier.padding(8.dp)
)
}
}

3.3 Navigation 구조 (Navigation Compose)


sealed class NavigationEvent {
object ToMain : NavigationEvent()
object ToSetting : NavigationEvent()
data class ToDetail(val dayId: Int) : NavigationEvent()
}

@Composable
fun NavGraph() {
val navController = rememberNavController()

NavHost(
navController = navController,
startDestination = "main"
) {
composable("main") {
MainScreen(
onNavigateToSetting = {
navController.navigate("setting")
}
)
}

composable("setting") {
SettingScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}

composable("init") {
InitScreen(
onNavigateToMain = {
navController.navigate("main") {
popUpTo("init") { inclusive = true }
}
}
)
}
}
}

3.4 Activity → Compose 진입점 (최소화)


@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

setContent {
DaycntTheme {
NavGraph()
}
}
}
}



Phase 4: 기능 통합 및 최적화 (2-3주)


4.1 BillingManager Hilt 통합


@Module
@InstallIn(SingletonComponent::class)
object ManagerModule {
@Singleton
@Provides
fun provideBillingManager(
@ApplicationContext context: Context
): BillingManager {
return BillingManager(context)
}
}

// ViewModel에서 사용
@HiltViewModel
class SettingViewModel @Inject constructor(
private val billingManager: BillingManager,
private val preferenceRepository: IPreferenceRepository
) : ViewModel() {
// ...
}

4.2 Widget 현대화 (Glance로 전환 검토)


// Glance 기반 위젯 (기존 방식 대체)
class DayCntGlanceWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
// Jetpack Compose 스타일의 선언형 위젯 UI
provideContent {
GlanceTheme {
Surface {
Box(
modifier = GlanceModifier
.fillMaxSize()
.padding(16.dp)
) {
Text(
text = "오늘 통계",
modifier = GlanceModifier.fillMaxWidth()
)
}
}
}
}
}
}

4.3 Firebase/Crashlytics 통합 (권장)


implementation 'com.google.firebase:firebase-analytics:21.5.0'
implementation 'com.google.firebase:firebase-crashlytics:18.6.1'



📊 마이그레이션 타임라인











































Phase 기간 주요 작업 산출물
1 1-2주 Gradle, 패키지 구조, Hilt 기초 의존성 설정 완료
2 2-3주 Room DB, Repository, Hilt 모듈 데이터 계층 현대화
3 3-4주 ViewModel, Compose UI, Navigation 프레젠테이션 계층 현대화
4 2-3주 통합, Widget, 테스트, 최적화 배포 준비 완료
8-12주 전체 마이그레이션 프로덕션 출시



🛠️ 점진적 마이그레이션 전략


Hybrid 접근 방식 (기존 + 신규 공존)



  • Phase 1-2: 기존 Activity + XML 유지하면서 Room/Repository 도입

  • Phase 3: 신규 Compose 스크린 추가, Activity 병렬 운영

  • Phase 4: 기존 Activity 제거, Compose로 완전 전환


데이터 마이그레이션 (자동화)


// 기존 SQLite → Room으로 자동 데이터 이전
class DatabaseMigrationHelper @Inject constructor(
private val database: AppDatabase,
@ApplicationContext private val context: Context
) {
suspend fun migrateFromLegacyDatabase() {
val legacyDb = DBHelper(context).readableDatabase
// 기존 데이터 읽고 → Room DB에 저장
}
}



🧪 테스트 전략


Unit 테스트 (JUnit + Mockito)


@RunWith(MockitoJUnitRunner::class)
class MainViewModelTest {
@get:Rule
val instantExecutorRule = InstantTaskExecutorRule()

@Mock
private lateinit var repository: IDayInfoRepository

private lateinit var viewModel: MainViewModel

@Before
fun setup() {
viewModel = MainViewModel(repository, preferenceRepository)
}

@Test
fun testLoadDayInfoSuccess() = runTest {
val mockData = listOf(DayInfo(...))
whenever(repository.getAllDayInfo()).thenReturn(
flowOf(mockData)
)

// 검증
advanceUntilIdle()
assertEquals(mockData, viewModel.uiState.value.dayInfoList)
}
}

Room DB 통합 테스트


@RunWith(AndroidJUnit4::class)
class DayInfoDaoTest {
@get:Rule
val databaseRule = DatabaseTestRule(AppDatabase::class)

private lateinit var dayInfoDao: DayInfoDao

@Before
fun setup() {
dayInfoDao = databaseRule.database.dayInfoDao()
}

@Test
fun testInsertAndRetrieve() = runBlocking {
val dayInfo = DayInfoEntity(date = "20240225", ...)
dayInfoDao.insertDayInfo(dayInfo)

val result = dayInfoDao.getAllDayInfo().first()
assertTrue(result.contains(dayInfo))
}
}

Compose UI 테스트


@RunWith(AndroidJUnit4::class)
class MainScreenTest {
@get:Rule
val composeTestRule = createComposeRule()

@Test
fun testCalendarGridDisplay() {
composeTestRule.setContent {
MainScreen()
}

composeTestRule.onNodeWithText("오늘 통계").assertIsDisplayed()
}
}



⚠️ 주요 도전 과제 및 해결 방안

















































도전 원인 영향 해결책
Calendar Grid 복잡도 기존 CustomGridView 기능 높음 LazyVerticalGrid + Canvas 조합, 프로토타입 검증
데이터 마이그레이션 기존 사용자의 SQLite DB 높음 자동 마이그레이션 코드, 테스트 필수
Widget 호환성 Glance 제약사항 중간 Glance 먼저 검증, 필요시 기존 방식 병행
성능 저하 Room 쿼리 최적화 필요 중간 Index 설정, 쿼리 최적화, Profiling
메모리 누수 Coroutines 취소 중간 viewModelScope 사용, Lifecycle 관찰
디자인 변경 Compose Material 3 도입 낮음 기존 디자인 재현 또는 새로 정의



📈 성능 최적화 체크리스트


Room Database



  • 자주 쿼리되는 컬럼에 Index 설정

  • 복합 쿼리 최적화 (JOIN 사용)

  • 페이징 처리 (PagingLibrary 도입 검토)


Compose UI



  • Recomposition 최소화 (State 분리)

  • LazyColumn/LazyVerticalGrid 사용

  • remember, derivedStateOf 활용


메모리 관리



  • Coroutines 취소 확인

  • Lifecycle 관찰 (collectAsStateWithLifecycle)

  • 큰 객체는 ViewModel에서 캐싱




📚 참고 리소스


공식 문서



추천 라이브러리



  • Navigation: Navigation Compose

  • 상태 관리: Jetpack Compose + ViewModel + StateFlow

  • 이미지 로딩: Coil (Jetpack Compose 지원)

  • HTTP 클라이언트: Retrofit + OkHttp (향후 필요시)

  • 테스트: JUnit 4, Mockito, Turbine (Flow 테스트)




✅ 체크리스트


시작 전 확인



  • 팀 내 Compose/Hilt 숙련도 평가

  • 기존 코드 백업 및 Git 세팅

  • 테스트 인프라 구축 (CI/CD)

  • 데이터 마이그레이션 계획 수립


Phase별 확인



  • Phase 1: 의존성 충돌 테스트, 컴파일 확인

  • Phase 2: Room 쿼리 성능 테스트, 데이터 무결성 확인

  • Phase 3: UI 복잡도 검증, Navigation 테스트

  • Phase 4: 전체 통합 테스트, 성능 Profiling


배포 전 확인



  • 단위/통합/E2E 테스트 완료

  • Crashlytics로 에러 모니터링 설정

  • Beta 테스트 (Google Play Console)

  • 사용자 피드백 수집




🎓 학습 곡선


예상 난이도: 중상(Medium-High)


팀이 이미 알고 있는 것


✅ Kotlin 기본
✅ Android 기본 (Activity, Intent)
✅ XML 레이아웃
✅ View Binding


새로 배워야 할 것


📚 Jetpack Compose: 3-5일
📚 Hilt DI: 2-3일
📚 Room Database: 2-3일
📚 Flow & StateFlow: 2-3일
📚 MVVM + Clean Architecture: 3-5일


총 학습 기간: 약 2-3주 (병렬 진행 시)




💡 권장사항


즉시 시작 가능한 작업



  1. ✅ 팀원들의 Compose/Hilt 튜토리얼 스터디

  2. ✅ 간단한 Compose 프로토타입 작성

  3. ✅ 기존 코드 상세 분석 및 마이그레이션 대상 파악

  4. ✅ Git 브랜치 전략 수립 (feature/phase1, phase2, ...)


Phase별 우선순위


🔴 필수: Phase 1 (기초), Phase 2 (데이터)
🟠 높음: Phase 3 (UI)
🟡 중간: Phase 4 (최적화, Widget)


리스크 최소화



  • 각 Phase마다 별도 브랜치에서 작업

  • 병렬 테스트 (기존 + 신규 코드)

  • 자동 마이그레이션 도구 활용 (가능시)

  • 사용자 피드백 조기 수집 (Beta 테스트)




📞 추가 질문 사항


본 계획을 검토하시면서 다음 사항을 명확히 하시면 더 자세한 구현 가이드를 제공할 수 있습니다:



  1. 우선순위: 어느 화면부터 Compose로 전환할 것인가?

  2. Widget 전략: 기존 Widget 방식 유지 vs. Glance 전환?

  3. 디자인: Material 3 새 디자인 도입 vs. 기존 디자인 유지?

  4. 일정: 팀의 개발 속도에 따른 Phase 조정?

  5. 테스트: 테스트 커버리지 목표 설정?

  6. 외부 의존성: 추가 API 연동 등의 계획?


이 계획은 유연하게 조정 가능하므로 팀의 상황에 맞춰 최적화할 수 있습니다.




작성일: 2026년 2월 25일
버전: 1.0
상태: 초안 (팀 검토 대기)





오늘의 이야기

 


 


🛠 Wear + Phone Altitude 동기화 & 설정화면 컴파일 오류 해결 요약


워치이미지



 


개요 (Intro)



  • 오늘의 목표: 고도(Altitude) 측정 데이터 Wear→Phone 실시간 전송, 설정 화면에 단위/최근 갱신 시각 표시, SegmentedButton 컴파일 오류 해결

  • 배경: 기존 Steps 동기화는 동작하지만 고도 및 제스처(큰절) 인식 흐름 표시 요소 부족 + Material3 실험 API로 인해 빌드 실패

  • 사용 기술 스택: Kotlin, Jetpack Compose (Material3), Hilt, Health Connect, Health Services, Wearable Data/Message API, Sensor API


📅 날짜: 2025.12.01
🎯 목표: Altitude 실시간 반영 + SettingsScreen 안정화 + Wear/Phone namespace 정렬
🧰 기술: Kotlin, Compose, Hilt, Wear OS, Health Connect, Sensors, DataClient/MessageClient

문제 정의 (Problem / Motivation)



  • SettingsScreen에서 Material3 SegmentedButton 관련 심볼(Unresolved reference)로 컴파일 실패

  • Wear AVD에서 가속도/고도 센서 이벤트가 들어오지 않아 실측값 테스트 어려움

  • 고도 전송 이후 Phone UI에서 최근 갱신 시각을 안정적으로 식별할 상태 변수가 부재

  • Namespace / ApplicationId를 Phone과 Wear 간 통일 필요 (데이터 교환 및 추적 용이)


// (오류 예시) SegmentedButtonRow 미존재로 컴파일 실패
// implementation(libs.androidx.compose.material3) 버전에서 제공 안 됨
SegmentedButtonRow { /* ... */ } // Unresolved reference

해결 과정 (How I Solved It)



  • 고도 수신을 위한 ViewModel 상태 추가: altitudeMeters, altitudeTimestamp (StateFlow)

  • WearDataSyncManager.Listener에 onAltitudeUpdate(...) 콜백 정의하여 Phone 측 반영

  • SettingsScreen: 실험적 SegmentedButton 제거 → Button + OutlinedButton 토글 구현

  • 최근 갱신 시각: altitudeTimestamp 우선, 없으면 마지막 steps 페이로드 timestamp 폴백

  • 에뮬레이터 한계 대응: 실제 센서 없을 경우 ‘-’ 출력 및 Mock 옵션 향후 추가 계획 수립

  • Wear 모듈 build.gradle.kts에 compose.compiler 및 namespace=phone 앱 동일 값 적용


// ViewModel 고도 상태 및 리스너 발췌
private val _altitudeMeters = MutableStateFlow<Double?>(null)
val altitudeMeters: StateFlow<Double?> = _altitudeMeters
private val _altitudeTimestamp = MutableStateFlow<Long?>(null)
val altitudeTimestamp: StateFlow<Long?> = _altitudeTimestamp

wearDataSyncManager.listener = object : WearDataSyncManager.Listener {
override fun onAltitudeUpdate(altitudeMeters: Double, timestamp: Long, source: String) {
_altitudeMeters.value = altitudeMeters
_altitudeTimestamp.value = timestamp
}
// ... steps, gesture 등 다른 콜백
}

// SettingsScreen 고도 단위 토글 (Button / OutlinedButton)
Row {
Text("고도 단위: ")
if (unit == "m") {
Button(onClick = { unit = "m" }) { Text("m") }
OutlinedButton(onClick = { unit = "ft" }) { Text("ft") }
} else {
OutlinedButton(onClick = { unit = "m" }) { Text("m") }
Button(onClick = { unit = "ft" }) { Text("ft") }
}
}

결과 (Result)



  • 빌드 실패 원인이었던 SegmentedButton 관련 심볼 제거 후 컴파일 성공

  • 고도 실시간 값 및 최근 갱신 시각 UI 노출 로직 정상 반영 (실기기 테스트 필요)

  • Wear/Phone 동일 namespace & applicationId 적용으로 코드/로그 추적 용이

  • 제스처 인식 시작/종료 UI 명확해져 사용자 경험 개선


✅ SettingsScreen 컴파일 오류 제거
✅ Altitude 실시간 상태 흐름(StateFlow) 구축
⚠️ AVD 센서 미동작: 실제 기기 테스트 대기

느낀 점 / 회고 (Reflection)



  • 실험적 Compose API는 버전 호환을 먼저 확인해야 낭비 시간을 줄일 수 있다.

  • 센서 기반 기능은 에뮬레이터 한계가 커서 Mock/실기기 병행 전략이 필수.

  • 데이터 전송 시 timestamp를 분리 관리하면 UI 표시/디버깅이 훨씬 명확해진다.

  • 단위 토글을 단순화하니 유지보수성과 접근성이 모두 향상되었다.


참고자료 (References)





다음 개선 아이디어



  • Mock 센서 토글 추가 (AVD에서 흐름 검증)

  • Altitude smoothing (이동평균) & 최소 전송 간격 조정

  • 큰절 인식 정확도 향상을 위한 패턴 분석/버퍼링

  • Locale 안전한 숫자 포맷 적용 (String.format(Locale,...))





오늘의 이야기



#스치니1000프로젝트 #재미 #행운기원 #Compose #Firebase

🎯 야 너 토요일마다 로또 확인하냐?
나도 맨날 “혹시나~” 하면서 봤거든 ㅋㅋ

근데 이제는 그냥 안 해
AI한테 맡겼어 🤖✨

그것도 구글 Gemini로다가!

그래서 앱 하나 만들었지
👉 “로또 예상번호 by Gemini” 🎱

AI가 분석해서 번호 딱! 뽑아줌
그냥 보고 참고만 하면 됨

재미로 해도 좋고…
혹시 모르는 거잖아? 😏


https://play.google.com/store/apps/details?id=com.billcorea.gptlotto1127




오늘의 이야기


#스하리1000명프로젝트,
แพ้เกาหลีเหรอ? แม้ว่าคุณจะพูดภาษาเกาหลีไม่ได้ แต่แอปนี้จะช่วยให้คุณเดินทางได้อย่างง่ายดาย
เพียงพูดภาษาของคุณ ระบบจะแปล ค้นหา และแสดงผลลัพธ์เป็นภาษาของคุณ
เหมาะสำหรับนักเดินทาง! รองรับมากกว่า 10 ภาษา รวมถึงภาษาอังกฤษ ญี่ปุ่น จีน เวียดนาม และอื่นๆ อีกมากมาย
ลองตอนนี้!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127




2026/05/06

오늘의 이야기

 


 



WebSquare Studio에서 FusionChart 버블 차트 구현기


chart예시


 


오늘은 WebSquare Studio에서 FusionChart의 Bubble Chart를 구현하면서 겪은 시행착오와 해결 방법을 정리해보려 합니다. 데이터 바인딩, 텍스트 표시, 색상 처리, 다운로드 기능까지 다양한 이슈를 경험했는데요, 그 과정을 공유합니다.




1. dataList vs ref: 데이터 바인딩 방식


WebSquare에서는 FusionChart에 데이터를 바인딩할 때 dataList 속성이나 ref="data:dcBubble"와 같은 ref 속성을 사용할 수 있습니다.



  • dataList: DataCollection을 직접 연결

  • ref: WebSquare의 데이터 바인딩 규칙을 따름


하지만 FusionChart는 x, y, z, label, displayValue 구조를 요구하므로, 단순히 ref만 연결해서는 원하는 형태로 출력되지 않았습니다. JavaScript로 데이터를 가공해주는 과정이 필요했습니다.




2. showValues와 displayValue의 관계


버블 안에 텍스트를 표시하려면 showValuesdisplayValue의 관계를 이해해야 합니다.



  • showValues="1"이면 기본적으로 x 값이 표시됩니다.

  • 원하는 텍스트(예: 코드)를 표시하려면 displayValue를 사용해야 합니다.

  • 이때 showValues"0"으로 꺼야 displayValue가 적용됩니다.


{
"chart": {
"showValues": "0"
},
"dataset": [{
"data": [
{ "x": "20", "y": "30", "z": "15", "displayValue": "A" }
]
}]
}



3. series/columns 선언의 중요성


WebSquare Studio에서는 FusionChart를 사용할 때 <series><columns> 선언이 필수입니다. 이 선언이 없으면 차트가 렌더링되지 않거나 버블이 표시되지 않습니다.


<series>
<columns>
<column id="x"/>
<column id="y"/>
<column id="z"/>
<column id="label"/>
<column id="displayValue"/>
</columns>
</series>

데이터 구조와 이 선언이 일치해야 차트가 정상적으로 작동합니다.




4. 색상 처리: 버블과 텍스트


FusionChart에서는 다음과 같은 방식으로 색상을 제어할 수 있습니다.



  • 버블 색상: 각 데이터에 color 속성을 지정하여 개별 설정 가능

  • 텍스트 색상: chart.valueFontColor로 전체 텍스트 색상 지정


버블마다 텍스트 색상을 다르게 지정하는 기능은 기본적으로 지원되지 않습니다. 대신 툴팁에 HTML 스타일을 적용해 색상을 다르게 표현할 수 있습니다.


"chart": {
"valueFontColor": "#FF0000",
"plotToolText": "<div style='color:#00AAFF'><b>$displayValue</b></div>"
}



5. exportEnabled로 이미지 다운로드 기능 추가


차트를 이미지나 PDF로 저장하려면 exportEnabled 속성을 사용하면 됩니다.


"chart": {
"exportEnabled": "1",
"exportFileName": "bubble_chart"
}

이렇게 하면 차트 우측 상단에 Export 메뉴가 생기고, PNG, JPG, SVG, PDF로 저장할 수 있습니다. 또한 JavaScript API를 통해 직접 다운로드를 트리거할 수도 있습니다.


chartInstance.exportChart({ exportFormat: 'png' });



마무리하며


이번 경험을 통해 WebSquare Studio와 FusionChart의 연동 방식, 데이터 구조, 시각화 옵션에 대해 깊이 이해할 수 있었습니다. 특히 displayValueshowValues의 관계, series/columns 선언의 중요성은 꼭 기억해야 할 포인트입니다.


앞으로도 다양한 차트 유형을 테스트하며 더 나은 시각화를 구현해보고자 합니다.






오늘의 이야기

⌚ Android Wear & Phone 연동 디버깅 | 고도 수집, 상태 동기화, Hilt 순환 참조 정리


워치앱



 


개요 (Intro)


오늘은 Wear OS 앱과 폰 앱 사이에서 다음 세 가지를 중점적으로 작업했다. - Wear의 TimeText 스타일 수정 (텍스트 색상 변경) - 폰 앱 설정 화면에서 시작/중지 액션을 보냈을 때, 워치 메인 화면의 상태 표시 및 고도(기압) 목록 표시 동기화 - Hilt DI 구성에서 발생한 WearDataSaver 순환 참조 오류 해결 및 SyncModule 정리


📅 날짜: 2025.12.25
🎯 목표: 폰 ↔ 워치 측정 상태/고도 데이터 동기화 및 Hilt 순환 참조 제거
🧰 기술: Kotlin, Android, Wear OS, Jetpack Compose, Hilt, Gradle



문제 정의 (Problem / Motivation)


이번에 정리한 문제들은 크게 네 가지였다. 1. Wear TimeText 색상 변경 - Wear OS의 TimeText() 컴포저블에서 시간 텍스트 색을 바꾸고 싶었다. - 문서를 보면 timeTextStyle을 통해 스타일을 주입할 수 있으나, 기본 샘플에서는 색상 변경이 적용되지 않고 있었다. 2. 폰 설정 화면의 측정 시작/중지 상태가 워치 메인 화면에 반영되지 않음 - 폰 앱 설정 화면에서: - "위치측정시작", "워치측정시작" 버튼 클릭 시 - 워치 메인 화면의 StatusSection"측정시작" / "측정중지" 같은 상태가 실시간(or 가깝게) 반영되길 원했다. - 하지만 현재 구현에서는, 폰 쪽에서 상태를 바꿔도 워치 UI에 반영이 되지 않거나, 반영 타이밍이 이상했다. 3. 고도(기압) 데이터 recentAltitudes 리스트가 비어 있음 - 설정 화면에서 "워치측정시작/중지"를 누르면 고도(기압) 데이터가 수신되고 있다고 log 에서는 보였지만, - UI 쪽에서 참조하는 recentAltitudessize가 계속 0으로 나왔다. - 즉, 실제로는 데이터 업데이트가 되고 있는데, UI에 연결되는 리스트에 값이 반영되지 않는 문제. 4. 빌드 시 Hilt 순환 참조 오류 발생 Gradle 빌드시 아래와 같은 에러가 발생했다.



error: [Dagger/DependencyCycle] Found a dependency cycle:
com.billcoreatech.health501.sync.WearDataSaver is injected at
[SingletonC] SyncModule.provideWearDataSaver(…, saver)
com.billcoreatech.health501.sync.WearDataSaver is injected at
[SingletonC] SyncModule.provideWearDataSaver(…, saver)
...

The cycle is requested via:
WearDataSaver is injected at
StepCounterApplication.wearDataSaver
StepCounterApplication is injected at
StepCounterApplication_GeneratedInjector.injectStepCounterApplication

- WearDataSaver 를 제공하는 Hilt 모듈에서 Application 과의 순환 의존이 생긴 상태였다. - DI 구성이 꼬여 있어서 Hilt 컴파일 단계에서 막힌 상황. 간단한 예시로, Hilt 모듈이 순환 참조를 만들 때의 전형적인 패턴은 다음과 같다.



// 예시 코드 (문제 상황 예시)

@Module
@InstallIn(SingletonComponent::class)
object SampleModule {

@Provides
@Singleton
fun provideSomething(app: MyApplication): Something {
// MyApplication 이 다시 Something 을 주입받고 있다면
// Dagger 입장에서는 순환 참조가 생김
return app.something
}
}



해결 과정 (How I Solved It)


각 문제를 단계별로 정리했다.


1. Wear TimeText 색상 변경: timeTextStyle 사용


Wear OS Compose에서 TimeText의 텍스트 색상은 timeTextStyle 파라미터를 통해 바꿀 수 있다. 핵심 아이디어는: - TimeTexttimeTextStyle = TimeTextDefaults.timeTextStyle(color = ...) 처럼 전달하거나 - 또는 TextStyle을 직접 만들어서 넘겨주는 것. 예시 코드는 아래와 같이 작성할 수 있다.



@Composable
fun SampleTimeText() {
// 검정색 텍스트 스타일 정의
val blackTimeTextStyle = TimeTextDefaults.timeTextStyle(
color = Color.Black // 여기서 텍스트 색상을 지정
)

TimeText(
timeTextStyle = blackTimeTextStyle
)
}

위와 같이 TimeTextDefaults.timeTextStyle()에 color 를 명시적으로 지정하면, 기본 테마 색 대신 우리가 원하는 색(예: 검정색)으로 시간 텍스트가 표시된다.


2. 폰 설정 화면의 측정 시작/중지 → 워치 StatusSection 동기화


폰 앱에서 버튼을 누르면 워치로 명령을 보내고, 워치 메인 화면의 상태 UI 가 이를 반영하게 만드는 흐름은 대략 다음과 같이 잡았다. 1. 폰 앱 설정 화면에서 ViewModel 통해 startWatchMeasurement(), stopWatchMeasurement() 같은 함수를 호출한다. 2. 이 함수는 WearDataSyncManager (또는 유사한 sync/transport 클래스)를 사용해서, 워치로 "시작" 또는 "중지" 메시지를 보낸다. 3. 워치 측에서는 해당 메시지를 수신하는 리시버/서비스에서 MutableStateFlow 혹은 MutableLiveData에 상태를 업데이트한다. 4. 워치 메인 화면의 StatusSection 컴포저블은 이 상태 Flow 를 collect 해서 텍스트를 바꾼다. 예시 구조는 대략 이런 식이다.



// (워치 쪽) 상태를 노출하는 ViewModel 예시

class WatchMainViewModel @Inject constructor(
private val statusRepository: StatusRepository
) : ViewModel() {

// 현재 측정 상태를 나타내는 Flow (예: IDLE, RUNNING, STOPPED 등)
val measurementStatus: StateFlow<MeasurementStatus> =
statusRepository.measurementStatus.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = MeasurementStatus.Idle
)
}

@Composable
fun StatusSection(viewModel: WatchMainViewModel = hiltViewModel()) {
val status by viewModel.measurementStatus.collectAsStateWithLifecycle()

val statusText = when (status) {
MeasurementStatus.Idle -> "대기 중"
MeasurementStatus.Started -> "측정시작"
MeasurementStatus.Stopped -> "측정중지"
}

Text(text = statusText)
}

폰에서 "워치측정시작" 버튼을 눌렀을 때는, 워치 쪽 StatusRepositoryMeasurementStatus.Started가 반영되도록 message/data layer 를 통해 값을 전송해 주면 된다. 핵심은 "폰 액션" → "워치 ViewModel 상태" → "StatusSection UI"로 이어지는 단방향 데이터 흐름을 명확히 만든 것이다.


3. recentAltitudes 리스트 0 사이즈 문제 & UI 표시


관찰한 현상은 다음과 같았다. - 로그 상으로는 "Altitude updated" 같은 메시지가 잘 찍히고 있었음. - 하지만 UI 쪽에서 바라보는 recentAltitudessize는 계속 0이었다. 이 경우 주로 의심해야 할 포인트는 다음 세 가지다. 1. 데이터를 추가하는 리스트 인스턴스와, UI에서 관찰하는 리스트 인스턴스가 다른가? 2. immutable 리스트를 갱신한 뒤 새로 할당하지 않고 같은 레퍼런스를 쓰고 있는가? 3. Flow/LiveData 를 관찰하는 위치와 스레드가 올바른가? 일반적인 패턴으로, 최근 고도 10개만 관리하고 UI에 보여주려면 다음과 같이 구현할 수 있다.



// 고도 데이터 모델

data class AltitudeEntry(
val timestamp: Long, // 수신 시각 (millis)
val altitude: Float // 고도(또는 기압 값)
)

class AltitudeRepository @Inject constructor() {

// 최근 고도 리스트를 StateFlow 로 노출
private val _recentAltitudes = MutableStateFlow<List<AltitudeEntry>>(emptyList())
val recentAltitudes: StateFlow<List<AltitudeEntry>> = _recentAltitudes

/**
* 새 고도 데이터를 추가하면서, 최근 10개만 유지한다.
*/
fun addAltitude(altitude: Float) {
val newEntry = AltitudeEntry(
timestamp = System.currentTimeMillis(),
altitude = altitude
)

// 기존 리스트를 복사해서 새 리스트 생성
val updated = (_recentAltitudes.value + newEntry)
.takeLast(10) // 최근 10개만 유지

_recentAltitudes.value = updated
}
}

위처럼 기존 리스트에 요소를 추가한 새 리스트를 만들고, 이를 다시 Flow 에 넣어주는 방식이면, Composable 에서 collectAsState() 시 변화가 잘 감지된다. 그리고 UI 에서는 다음과 같이 그려줄 수 있다.



@Composable
fun AltitudeHistoryCard(
altitudeRepository: AltitudeRepository = hiltViewModel<YourViewModel>().altitudeRepository
) {
val recentAltitudes by altitudeRepository.recentAltitudes
.collectAsStateWithLifecycle()

// 리스트가 비어 있으면 아무것도 그리지 않도록 요구사항을 반영
if (recentAltitudes.isEmpty()) {
// 요구사항: 값이 없으면 UI에 표시하지 않음
return
}

Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Column(modifier = Modifier.padding(8.dp)) {
Text(text = "최근 고도(기압) 10개", style = MaterialTheme.typography.titleMedium)

Spacer(modifier = Modifier.height(8.dp))

// 시간순(옛날 → 최근)으로 정렬해서 표시
val sorted = recentAltitudes.sortedBy { it.timestamp }

sorted.forEach { entry ->
val timeText = remember(entry.timestamp) {
// 간단한 시:분 포맷 예시
SimpleDateFormat("HH:mm:ss", Locale.getDefault())
.format(Date(entry.timestamp))
}

Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = timeText)
Text(text = String.format(Locale.getDefault(), "%.1f m", entry.altitude))
}
}
}
}
}

위 구조가 유지되는지 점검하면서, recentAltitudes size 가 0 인 이유를 다음 순서로 검증했다. 1. addAltitude() 가 실제로 호출되는지 로그로 확인. 2. addAltitude 안에서 _recentAltitudes.value 가 변경되는지 (디버거 또는 로그). 3. UI에서 collectAsState()로 보고 있는 recentAltitudes가 동일 인스턴스인지 (같은 Repository / 같은 ViewModel 인지 확인). 최종적으로, 데이터를 업데이트하는 쪽과 UI에서 구독하는 쪽을 같은 Repository/Flow 인스턴스로 맞추고, 리스트를 불변 리스트로 재할당하도록 수정해서 recentAltitudes size가 0이 아닌 값으로 정상적으로 올라오는 것을 확인했다.


4. Hilt 순환 참조 해결: SyncModule 정리


Hilt에서 보고해 준 순환 참조 문제는 대략 다음 구조였다. - StepCounterApplicationWearDataSaver 를 주입받고 있음 - WearDataSaver 를 제공하는 Hilt 모듈이 다시 Application 을 참조하거나, 그 반대의 형태로 이어지면서 순환 이 문제를 해결하기 위해 DI 구성을 단순화했다. 현재 SyncModule.kt은 다음처럼 정리되어 있다.



package com.billcoreatech.health501.di

import android.content.Context
import com.billcoreatech.health501.sync.WearDataSyncManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object SyncModule {
@Provides
@Singleton
fun provideWearDataSyncManager(@ApplicationContext context: Context): WearDataSyncManager =
WearDataSyncManager(context)
}

핵심 포인트는: - Application 자체를 의존성으로 주입받지 않고, @ApplicationContext 로 제공되는 Context 만 사용하도록 변경했다는 점. - WearDataSaver 처럼 Application 과 서로 물고 물리던 타입을 모듈에서 제거하거나, 의존성을 단방향이 되도록 재구성했다. 이렇게 구성하면 Hilt 입장에서 "Application ↔ WearDataSaver" 사이의 순환 참조를 끊을 수 있어 컴파일 에러가 사라진다. 또한 WearDataSyncManager 는 Context 만 필요하므로, SingletonComponent 범위에 안전하게 둘 수 있다.




결과 (Result)


이번 수정으로 다음과 같은 결과를 얻었다.


✅ Wear TimeText 에서 timeTextStyle을 이용해 텍스트 색상을 원하는 색(검정색)으로 적용
✅ 폰 설정 화면의 측정 시작/중지 액션이 워치 메인 화면 StatusSection에 제대로 반영
✅ 고도(기압) 데이터가 recentAltitudes 리스트로 정상 수집되고, 최근 10개만 카드 UI로 표시
✅ Hilt DI의 순환 참조(WearDataSaver 관련) 오류 제거 및 SyncModule 정리

빌드 로그에서도 더 이상 [Dagger/DependencyCycle] 에러가 발생하지 않으며, 앱이 정상적으로 빌드 및 실행되는 것을 확인했다.




느낀 점 / 회고 (Reflection)


- Wear OS UI 는 일반 Compose 와 매우 비슷하지만, TimeText 같이 플랫폼 특화 컴포저블은 스타일 지정 방법을 한번 더 문서로 확인하는 게 좋다는 걸 느꼈다. - recentAltitudes 문제처럼 "로그는 찍히는데 UI 리스트는 비어 있는" 상황은, 대체로 상태 흐름(Flow/LiveData) 설계와 불변 리스트 재할당 문제로 귀결되는 경우가 많았다. - Hilt/Dagger 의 순환 참조 에러 메시지는 처음 보면 복잡하지만, "어떤 타입이 어떤 경로로 다시 자기 자신에게 돌아오는지"를 천천히 따라가다 보면 구조적인 문제를 바로잡는 계기가 된다. - 이번 정리를 통해, 폰 ↔ 워치 간 상태 및 데이터 동기화를 조금 더 명확한 단방향 흐름으로 정리할 수 있었다는 점이 가장 큰 수확이었다.




참고자료 (References)






오늘의 이야기

이 앱은 가끔식 가는 절(불교사찰)에서 108배를 해야 하는 데, 하니씩 세다 보면 간혹 혼란(?)이 오는 경우를 위해 구상하게 되었습니다. 


 


동작감지기



wear 가 시작 되면 오른쪽 그림과 같이 그날 걸음수를 화면에 표시 합니다.  폰에서 측정 시작을 하게 되면 왼쪽 그림과 같이 3,2,1 카운트를 하고 가운데 그림과 같이 동작을 측정합니다.  


 


설정화면



폰에서 설정은 사용자키 선택, 1일 목표 걸음수 선택하여 "모든 설정 저장 및 wear 전송" 을 클릭 하여 wear 와 동기를 처리 합니다. 


이때 워치 에서는 상대 높이을 계산하여 현재 워치 높이를 초기화 합니다. 측정시작을 했을 떄, 동작을 감지 하기 위한 위치가 초기화 됩니다. 


 


"측정시작" 하면 워치에서 측정이 시작 되고, "측정 종료"를 선택 하면 측정이 종료 됩니다.  또한 측정 시작전에 선택한 최대 회수에 도달하는 경우 측정은 자동으로 종료 되고, 기록으로 이동 합니다. 


 


메인화면



 


앱의 메인 화면에서는 일일 걸음수가 표시 됩니다.  이 걸음수는 헬스커넥터에서 측정된 걸음수를 동기화 하여 표시 하므로 걸음수 표시에 지연이 발생 할 수 있습니다. 


 


동작감지 리스트



워치를 통해 수신된 동작 감지 리스트는 동작 이력에서 표시 됩니다.  표시되는 내용은 종료시간, 감지된 횟수, 소요된 시간이 표시 됩니다. (심박수는 감지된 경우에만, 최대bpm, 최소bpm 으로 구분 되어 표시 됩니다.)


 


 


** 이 앱은 아래 링크에서 설치해 보세요.


동작감지기(108배, wear os 지원 버전) - Google Play 앱



 


동작감지기(108배, wear os 지원 버전) - Google Play 앱


동작을 감지 하는 앱 입니다. wear 버전도 포함 됩니다. 108배 하다가 잊으셨나요? 이제 그런 일은 없습니다.


play.google.com




 


예시 영상은 아래 링크에서 보실 수 있습니다.


https://youtube.com/shorts/029ZVgwfrmM?feature=share





 





오늘의 이야기



#스치니1000프로젝트 #재미 #행운기원 #Compose #Firebase

🎯 야 너 토요일마다 로또 확인하냐?
나도 맨날 “혹시나~” 하면서 봤거든 ㅋㅋ

근데 이제는 그냥 안 해
AI한테 맡겼어 🤖✨

그것도 구글 Gemini로다가!

그래서 앱 하나 만들었지
👉 “로또 예상번호 by Gemini” 🎱

AI가 분석해서 번호 딱! 뽑아줌
그냥 보고 참고만 하면 됨

재미로 해도 좋고…
혹시 모르는 거잖아? 😏


https://play.google.com/store/apps/details?id=com.billcorea.gptlotto1127




오늘의 이야기


#스하리1000명프로젝트

오늘 내가 만든앱 하나 알려주고 싶어, 이 앱은 알림수집기 라고 이름을 붙였는 데,
내 폰에 표시 되는 알림을 읽어서 내가 지정한 단어가 들어 있고, 지출기록을 남겨야 하는 알림이
있으면 수집하고, 카카오톡으로 친구에게 전달해 주는 기능을 구현해 줄꺼야. 📲

이번 패치에서는 하루 한번 지정한 시간에 나에게 알림(노티) 하도록 기능을 추가 했어. 🙏
한번 써보고 불편한 거 있으면 말해줘.

앱 바로가기
👉 https://play.google.com/store/apps/details?id=com.nari.notify2kakao





오늘의 이야기

🦾 Android | 하단 바 + Navigation Compose + 보안 키 주입(ResValue) 적용기


하단바 만들기 예제



개요 (Intro)



  • 오늘의 목표 / 배경: 앱에 하단 바를 도입하고 Compose Navigation으로 홈/설정/광고 탭을 구성. 외부 API 키를 local.properties에서 안전하게 읽어 리소스로 주입.

  • 어떤 문제를 해결하려 했는지: Gradle Kotlin DSL에서 local.properties 로딩 시 IDE 경고/오류, 네비게이션 구성 시 일부 import 누락 및 경고.

  • 사용한 기술 스택: Kotlin, Jetpack Compose, Navigation-Compose, Hilt, Gradle Kotlin DSL


📅 날짜: 2025.11.23
🎯 목표: 바텀 네비게이션 3탭(홈/설정/광고) + 키 주입(resValue) + Gradle DSL 정리
🧰 기술: Kotlin, Compose, Navigation, Hilt, Gradle Kotlin DSL

문제 정의 (Problem / Motivation)



  • local.properties 값을 build.gradle.kts에서 java.util.Properties로 직접 로딩 시 IDE가 Unresolved reference: util, load 등 오탐 경고를 표시.

  • Compose 네비게이션 구현 중, Modifier.padding(innerPadding) 관련 import 누락으로 Unresolved reference 'padding' 오류.

  • 하단 바와 NavHost를 연결하는 구조에서 패키지 경로, 아이콘 import, 경고(예: hiltViewModel deprecate 알림) 확인 필요.


// (이슈 예시) build.gradle.kts에서 직접 Properties 로딩 시 IDE 경고 발생 가능
val localProps = java.util.Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) file.inputStream().use { load(it) } // load 참조 경고/오류 표기 사례
}
val cupangAccessKey = localProps.getProperty("cupangAccessKey") ?: ""

해결 과정 (How I Solved It)



  • local.properties 로딩은 Gradle의 providers.gradleProperty API로 대체하여 IDE/Gradle 모두에서 안정적으로 동작하도록 변경.

  • 앱 모듈의 defaultConfig에서 resValue로 문자열 리소스로 주입.

  • 하단 바 + NavHost 구조를 분리 파일로 구성하고, 필요한 Compose import를 명시하여 컴파일 오류 제거.


// app/build.gradle.kts (발췌) — Gradle providers API 사용
val cupangAccessKey: String = providers.gradleProperty("cupangAccessKey").orNull ?: ""
val cupangSecretKey: String = providers.gradleProperty("cupangSecretKey").orNull ?: ""

android {
defaultConfig {
resValue("string", "cupang_access_key", cupangAccessKey)
resValue("string", "cupang_secret_key", cupangSecretKey)
}
}

// 하단 바 + 네비게이션 (발췌)
sealed class AppScreen(val route: String, val label: String, val icon: ImageVector) {
data object Home: AppScreen("home", "홈", Icons.Filled.Home)
data object Settings: AppScreen("settings", "설정", Icons.Filled.Settings)
data object Ads: AppScreen("ads", "광고", Icons.Filled.Campaign)
}

@Composable
fun AppNavHost(navController: NavHostController, viewModel: HealthConnectViewModel = hiltViewModel()) {
NavHost(navController = navController, startDestination = AppScreen.Home.route) {
composable(AppScreen.Home.route) { MainScreen(viewModel) }
composable(AppScreen.Settings.route) { SettingsScreen() }
composable(AppScreen.Ads.route) { AdsScreen() }
}
}

@Composable
fun AppScaffoldWithBottomBar() {
val navController = rememberNavController()
Scaffold(bottomBar = { BottomBar(navController) }) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) { AppNavHost(navController) }
}
}

결과 (Result)



  • 하단 바 3탭(홈/설정/광고) + NavHost 라우팅이 정상 작동.

  • local.propertiescupangAccessKey/cupangSecretKeyR.string으로 노출, 화면/Compose에서 getString으로 참조 가능.

  • Gradle Kotlin DSL의 IDE 경고를 줄이고, 스크립트 단순화로 유지보수성 개선.


✅ 바텀 네비게이션 안정화 및 화면 전환 확인
🔐 키 주입(resValue) 동작 확인, 보안 노출 리스크는 추후 서버 프록시로 추가 보완 예정
🧹 Gradle DSL 경고 제거로 개발 경험 개선

느낀 점 / 회고 (Reflection)



  • Gradle 스크립트에서 표준 API를 직접 다루기보다는 Gradle 제공 API를 활용하는 편이 IDE/빌드 모두에 일관적.

  • 네비게이션은 파일 분리(화면/바/호스트)로 가독성이 크게 좋아짐.

  • 민감 키는 최종적으로 서버 서명/프록시로 이동하는 게 안전. 일단은 resValue로 편의 제공.


참고자료 (References)






오늘의 이야기


LLM 설치해 보기



 


🐍 Python | Hugging Face 모델, 왜 요약을 못할까? (Base vs. Instruct 모델, 버전 충돌 해결기)


개요 (Intro)


Hugging Face 모델 로딩 시 마주쳤던 인증 오류(401, 403)를 해결한 후, 새로운 문제에 직면했다. 모델이 요약 지시를 제대로 따르지 않았고, 이를 해결하는 과정에서 예상치 못한 라이브러리 버전 충돌까지 겪었다. 오늘의 일지는 이 두 가지 문제를 해결한 과정을 상세히 기록한다.


📅 날짜: 2025.11.09
🎯 목표: Gemma 모델이 뉴스 기사를 의미 있는 한국어로 요약하도록 만들기
🧰 기술: Python, Hugging Face (transformers, torch, torchvision)

문제 정의 (Problem / Motivation)


인증 문제를 모두 해결하고 `google/gemma-2b` 모델을 성공적으로 로드했지만, 정작 중요한 요약 기능이 제대로 동작하지 않았다. 게다가 문제를 해결하려다 새로운 오류까지 발생했다.


문제 1: 모델이 지시를 따르지 않음


분명 "요약해줘"라고 요청했지만, 모델은 엉뚱한 텍스트를 생성하거나 입력한 프롬프트를 그대로 반복할 뿐, 의미 있는 요약문을 만들어내지 못했다.


문제 2: `torchvision` 버전 충돌 오류


라이브러리 버전 문제 해결을 위해 `pip install --upgrade`를 실행한 후, 이전에는 없던 새로운 오류가 발생했다. 모델 로딩 단계에서 `RuntimeError: operator torchvision::nms does not exist` 라는 메시지와 함께 프로그램이 중단되었다.



ModuleNotFoundError: Could not import module 'GemmaForCausalLM'. Are this object's requirements defined correctly?
... (Caused by)
RuntimeError: operator torchvision::nms does not exist

해결 과정 (How I Solved It)


두 문제는 별개의 원인을 가지고 있었고, 하나씩 해결해 나갔다.



  1. Base 모델 vs. Instruction-Tuned 모델의 차이 이해:
    첫 번째 문제의 원인은 모델 선택에 있었다. 내가 사용한 `google/gemma-2b`는 **기반(Base) 모델**로, 다음에 올 단어를 예측할 뿐 지시를 따르도록 훈련되지 않았다. "요약"과 같은 특정 작업을 수행하려면, 지시를 따르도록 미세조정(Fine-tuning)된 **Instruction-Tuned 모델 (`google/gemma-2b-it`)**을 사용해야 했다.

    # 수정 전
    # model_name = "google/gemma-2b"

    # 수정 후: Instruction-Tuned 모델로 변경
    model_name = "google/gemma-2b-it"

    또한, 모델이 생성한 전체 텍스트에서 입력 프롬프트를 제외하고 순수 요약 결과만 추출하도록 코드 로직을 개선했다.

  2. 라이브러리 버전 충돌 해결:
    두 번째 문제의 원인은 `torch`와 `torchvision`의 버전 호환성이 깨졌기 때문이었다. `pip install --upgrade`가 각 라이브러리를 개별적으로 최신화하면서 발생한 문제로 추정된다. 가장 확실한 해결책은 관련 라이브러리를 완전히 삭제하고 재설치하는 것이었다.

    # 1. 기존 라이브러리 완전 삭제
    pip uninstall torch torchvision -y

    # 2. 호환되는 버전으로 재설치
    pip install torch
    pip install torchvision



결과 (Result)


위의 과정을 통해 모든 문제를 해결하고, 마침내 `gemma-2b-it` 모델이 정상적으로 다운로드되는 것을 확인했다.


✅ `torch`와 `torchvision` 버전 충돌 문제 해결!
✅ Base 모델 대신 Instruction-Tuned 모델(`gemma-2b-it`)을 사용하도록 코드 수정 완료!
⏳ 현재 새로운 `gemma-2b-it` 모델 다운로드 진행 중 (100% 완료되면 요약 기능 정상 동작 기대)

느낀 점 / 회고 (Reflection)



  • LLM을 사용할 때 **Base 모델**과 **Instruction-Tuned 모델**의 차이를 아는 것이 매우 중요하다는 것을 깨달았다. 특정 작업을 시키려면 반드시 `-it`나 `-instruct`가 붙은 모델을 써야 한다.

  • 라이브러리 버전 문제는 언제나 복병이다. `pip install --upgrade`가 만능 해결책이 아니며, 때로는 깨끗하게 삭제 후 재설치하는 것이 더 확실한 방법이다.

  • 오류 로그를 차근차근 읽는 것이 문제 해결의 가장 빠른 길이다. `torchvision::nms`라는 키워드를 통해 `torch`와 `torchvision`의 관계를 의심해 볼 수 있었다.


참고자료 (References)






오늘의 이야기

#스하리1000명프로젝트 오늘 내가 만든앱 하나 알려주고 싶어, 이 앱은 알림수집기 라고 이름을 붙였는 데, 내 폰에 표시 되는 알림을 읽어서 내가 지정한 단어가 들어 있고, 지출기록을 남겨야 하는 알림이 있으면 수집하고, 카카오톡으로 친구에게...