스마트폰 주머니에서 손을 뺄 필요 없이, 손목 위에서 즉각적인 데이터를 주고받는 워치 OS 통신 구조를 해부합니다.
1. 왜 워치는 네이티브로 가야 하는가?
* 배터리와 리소스 최적화: 손목이라는 제한된 환경에서 화면을 누른 상태를 유지하며 진동을 지속적으로 울리려면, 네이티브 환경(Kotlin/Swift)에서 라이프사이클을 직접 관리해야 햅틱 지연과 앱 강제 종료를 막을 수 있습니다.
2. 통신 아키텍처 설계 (하이브리드 vs 풀 네이티브)
A. 하이브리드 브릿지 (Flutter Mobile ↔ Kotlin Wear OS)
크로스 플랫폼의 효율을 극대화하는 현실적인 타협안입니다.
* 역할 분담: UI 레이아웃과 결제, 통계 로직 등 모바일 앱 본체는 Flutter로 단일화하여 생산성을 높이고, Wear OS 앱은 Kotlin과 Jetpack Compose for Wear로 네이티브 개발합니다.
* 데이터 동기화: 사용자가 모바일에서 설정한 목표 시간이나 정보는 Flutter의 채널 패키지(예: watch_connectivity)를 브릿지로 활용해 네이티브 워치 앱으로 동기화합니다.
B. 풀 네이티브 다이렉트 (Kotlin Mobile ↔ Kotlin Wear OS)
안드로이드 생태계의 공용어인 코틀린을 양쪽에 모두 사용하여 가장 빠르고 안정적인 통신 파이프라인을 구축합니다. 안드로이드 공식 Wearable Data Layer API를 활용합니다.
* MessageClient (단발성 명령): "지금 진동 울려!", "타이머 정지!"처럼 한 기기에서 다른 기기로 즉각적인 이벤트(RPC)를 쏠 때 사용합니다. 네트워크 유실 가능성이 있지만 속도가 빠릅니다.
* DataClient (상태 동기화): 사용자의 설정값, 현재 걸음 수 등 지속적으로 유지되어야 하는 '상태(State)'를 동기화합니다. 연결이 끊겨도 OS가 캐싱해 두었다가 재연결 시 자동으로 동기화(Sync)해 줍니다.
3. 범용 MessageClient 통신 코드 (Mobile 발신 ↔ Watch 수신)
모바일 기기의 버튼을 눌렀을 때, 연결된 스마트워치로 특정 액션(예: 알림 진동)을 트리거하는 가장 표준적인 로직입니다.
모바일 (발신 측: 명령 쏘기)
import com.google.android.gms.wearable.Wearable
// 연결된 모든 워치 노드(Node)를 찾아 특정 경로(path)로 메시지 전송
fun sendCommandToWatch(context: Context, command: String) {
Wearable.getNodeClient(context).connectedNodes.addOnSuccessListener { nodes ->
for (node in nodes) {
Wearable.getMessageClient(context).sendMessage(
node.id,
"/app_action", // 통신을 위한 고유 경로
command.toByteArray() // 전송할 데이터 페이로드
).addOnSuccessListener {
// 전송 성공 로그 처리
}.addOnFailureListener {
// 전송 실패 예외 처리
}
}
}
}
// 사용 예시: 버튼 클릭 시 호출
// sendCommandToWatch(context, "TRIGGER_VIBRATION")
Wear OS (수신 측: 백그라운드 대기 및 네이티브 하드웨어 제어)
import android.os.VibrationEffect
import android.os.Vibrator
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.WearableListenerService
// 매니페스트에 등록되어 백그라운드에서 메시지를 수신하는 리시버
class WatchCommandService : WearableListenerService() {
override fun onMessageReceived(messageEvent: MessageEvent) {
super.onMessageReceived(messageEvent)
// 설정한 경로(/app_action)가 맞는지 확인
if (messageEvent.path == "/app_action") {
val payload = String(messageEvent.data)
when (payload) {
"TRIGGER_VIBRATION" -> executeNativeVibration()
"OTHER_COMMAND" -> { /* 다른 범용 비즈니스 로직 처리 */ }
}
}
}
// Wear OS 네이티브 햅틱(진동) 제어
private fun executeNativeVibration() {
val vibrator = getSystemService(VIBRATOR_SERVICE) as Vibrator
if (vibrator.hasVibrator()) {
// 0.5초 동안 기본 강도로 진동
val effect = VibrationEffect.createOneShot(500L, VibrationEffect.DEFAULT_AMPLITUDE)
vibrator.vibrate(effect)
}
}
}이 범용 브릿지 코드를 통해 모바일 앱에서 워치의 하드웨어(진동, 화면 켜짐 등)를 원격으로 제어할 수 있는 통로가 뚫렸습니다.
단발성 명령(RPC)을 쏘는 MessageClient와 달리, 안드로이드의 DataClient는 모바일과 워치 간의 '상태(State)'를 영구적으로 동일하게 맞추는 거울 역할을 합니다. 유저가 스마트폰을 집에 두고 워치만 찬 채로 외출하여 연결이 끊어지더라도, OS 시스템 단에서 변경된 데이터를 캐싱해 두었다가 블루투스가 재연결되는 순간 완벽하게 상태를 동기화(Sync)해 줍니다. 앱의 설정값, 테마, 누적 통계치 등을 다룰 때 필수적인 범용 아키텍처입니다.
1. 상태 밀어넣기 (모바일 ➡️ 워치)
모바일에서 유저가 앱의 테마 설정이나 프로필 이름을 변경했을 때, 이를 워치 데이터 레이어에 등록합니다. PutDataMapRequest를 사용해 Key-Value 형태로 데이터를 묶어 던집니다.
import com.google.android.gms.wearable.PutDataMapRequest
import com.google.android.gms.wearable.Wearable
fun syncUserSettingsToWatch(context: Context, isDarkMode: Boolean, userName: String) {
val putDataReq = PutDataMapRequest.create("/user_settings").run {
dataMap.putBoolean("DARK_MODE", isDarkMode)
dataMap.putString("USER_NAME", userName)
// DataClient는 데이터가 '변경'될 때만 이벤트를 발생시키므로,
// 덮어쓰기 강제 트리거를 위해 타임스탬프를 함께 보내는 것이 정석입니다.
dataMap.putLong("TIMESTAMP", System.currentTimeMillis())
asPutDataRequest()
}
// 백그라운드 데이터 레이어에 저장
Wearable.getDataClient(context).putDataItem(putDataReq)
}
2. 상태 수신 및 UI 반영 (워치 백그라운드)
워치 앱에서는 WearableListenerService를 통해 데이터 변경을 감지합니다. 이 데이터가 들어오면 로컬 DB(Room)나 DataStore를 업데이트하여 Compose UI가 자연스럽게 리렌더링 되도록 연결합니다.
import com.google.android.gms.wearable.DataEvent
import com.google.android.gms.wearable.DataEventBuffer
import com.google.android.gms.wearable.DataMapItem
import com.google.android.gms.wearable.WearableListenerService
class WatchDataSyncService : WearableListenerService() {
override fun onDataChanged(dataEvents: DataEventBuffer) {
for (event in dataEvents) {
// 데이터가 변경되었고, 지정한 경로(/user_settings)가 맞는지 확인
if (event.type == DataEvent.TYPE_CHANGED && event.dataItem.uri.path == "/user_settings") {
val dataMap = DataMapItem.fromDataItem(event.dataItem).dataMap
val isDarkMode = dataMap.getBoolean("DARK_MODE")
val userName = dataMap.getString("USER_NAME")
// TODO: 워치의 로컬 상태 관리자(ViewModel, DataStore)에 값 업데이트
}
}
}
}
'Kotlin' 카테고리의 다른 글
| [Kotlin 실전 (7)] 서버 없이 굴러가는 앱의 심장: WorkManager로 백그라운드 푸시 제어하기 (0) | 2026.09.20 |
|---|---|
| [Kotlin 실전 (6)] 안드로이드 홈 위젯 기초와 라우팅 (0) | 2026.09.13 |
| [Kotlin 실전 (5)] 플러터 개발자의 코틀린 비동기 해부: Future와 Stream을 Coroutines와 Flow로 (1) | 2026.09.07 |
| Kotlin 클래스와 객체 (4) (0) | 2024.03.01 |
| Kotlin 함수 정의하기 (3) (1) | 2024.02.27 |