Today's

길을 나서지 않으면 그 길에서 만날 수 있는 사람을 만날 수 없다

모바일 앱(안드로이드)

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

Billcorea 2025. 12. 25. 15:24
반응형

⌚ 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)

반응형