[Android] 워치랑 데이터 주고 받기

Inhyeop Lee·2026년 5월 23일

안드로이드

목록 보기
7/8

이전까지는 Wear OS에서 제공하는 API를 통해 심박수를 실시간으로,
또 지속적으로 측정하는 방법에 대해서 알아보았습니다.

이제 측정한 심박수를 디바이스에 직접 전송하거나 디바이스에서 데이터에 액세스해야 합니다.
이를 구현하기 위해 제가 찾은 방법은 다음과 같습니다.

  • Data Layer API를 이용해 워치에서 디바이스로 전송
  • 디바이스에서 Health Connect API를 이용해 심박수 조회
  • 블루투스 또는 와이파이를 이용해 직접 연결
  • Nearby Connections API 이용 (지원 중단)

이 중에서 블루투스 또는 와이파이를 이용하는 방법은 BluetoothSocket을 이용하는 것인데,
구현하기도 까다롭고 데이터를 바이트 배열로 변환해야 하기 때문에 번거로움이 있다고 생각했습니다.

대부분의 헬스 케어 앱은 Health Connect를 사용하는 것으로 알고 있는데,
Health Services를 통해 측정한 데이터가 자동으로 기록되고 이를 조회하면 되기 때문에
구현하는데에 큰 어려움이 없다는 장점이 존재합니다.

다만 심박수의 경우 실시간으로 측정된 데이터를 즉각 조회하기 어렵고,
찾아본 결과 특정 기간 내의 심박수만을 조회할 수 있기 때문에 개발 중인 앱에는 어울리지 않았습니다.

따라서 다른 방법에 비해 비교적 안정적이고 구현 난이도가 낮은 Data Layer API를 이용하기로 했습니다.

Data Layer

DataLayer API는 구글이 Wear OS와 Android 디바이스 간의 데이터 통신을 위해 제공하는 API입니다.

Bluetooth나 Wi-Fi 연결 상태를 개발자가 직접 관리할 필요 없이,
두 디바이스 사이의 데이터 동기화와 메시지 전송을 간편하게 구현할 수 있도록 설계되었습니다.

쉽게 말해, 워치와 폰 사이에 전용 데이터 파이프라인을 구글이 대신 만들어주는 셈입니다.

또한 디바이스 사이의 연결이 일시적으로 끊겨도 데이터를 자동으로 큐에 보관했다가 재전송하고,
인터넷 없이 로컬 통신만으로 지연을 최소화하며 데이터를 빠르게 주고받는 데 최적화되었습니다.

블루투스가 끊겨있지만 네트워크에 연결된 경우에는 구글 서버의 네트워크 노드를 통해 데이터 전송을 처리하기도 합니다.

클라이언트 유형

Data Layer API는 상황에 따라 선택할 수 있는 3가지 방식을 제공합니다.

DataClient

DataClient는 두 디바이스 간에 데이터를 동기화된 상태로 유지할 때 주로 사용되며,
DataItemAsset이라는 객체를 통해 데이터를 읽거나 쓸 수 있습니다.

DataItem은 영구적으로 저장되며 삭제되기 전까지 기기에서 데이터를 읽을 수 있습니다.
Asset은 이미지 혹은 미디어 파일과 같이 용량이 큰 데이터를 처리할 때 사용합니다.

또한 데이터가 클라우드에 백업되기 때문에 더욱 안전하게 데이터를 처리할 수 있습니다.

private val dataClient: DataClient by lazy { Wearable.getDataClient(context) }

override fun sendHeartRate(heartRate: Int) {
	val putDataReq =
	PutDataMapRequest.create(HEART_RATE_PATH).run {
		dataMap.putInt(HEART_RATE_KEY, heartRate)
		dataMap.putLong(TIMESTAMP_KEY, System.currentTimeMillis())
		asPutDataRequest().setUrgent()
	}
	dataClient
		.putDataItem(putDataReq)
		.addOnFailureListener { e ->
			Log.i("Widyu", "Error sending request to device", e)
		}
}

데이터를 전송할 때는 PutDataRequest라는 객체를 생성하고, putDataItem이라는 메소드의 인자로 전달합니다.

val dataMap = DataMapItem.fromDataItem(item).dataMap
val heartRate = dataMap.getInt(HEART_RATE_KEY)
val timestamp = dataMap.getLong(TIMESTAMP_KEY)

또한 위와 같이 DataClient에 저장된 데이터를 조회할 수 있습니다.

데이터가 영구적으로 지속되는 클라이언트

MessageClient

MessageClient는 일회성 신호를 보낼 때 주로 사용됩니다.
워치에서 음악 플레이어를 제어하거나, 카메라 셔터를 클릭하는 등의 경우가 공식 문서에서 설명하는 유즈케이스입니다.

private lateinit var nodeClient: NodeClient
private lateinit var messageClient: MessageClient

private fun sendHeartRateStatus(status: String) {
	nodeClient.connectedNodes.addOnSuccessListener { nodes ->
		if (nodes.isEmpty()) return@addOnSuccessListener
            nodes.forEach { node ->
                messageClient
                    .sendMessage(node.id, HEART_RATE_STATUS_PATH, status.toByteArray())
                    .addOnFailureListener { Log.i("Widyu", "Error in sendHeartRateStatus", it) }
            }
        }
    }

위 코드와 같이 sendMessage()를 통해 단방향 요청을 보내거나,
sendRequest()를 통한 요청 및 응답 통신에 적합하다고 나와있습니다.

또한 NodeClient를 통해 현재 연결되어 있는 디바이스를 탐색할 수 있는데,
이를 통해 주변 기기에만 메시지를 전송하여 배터리를 절약할 수 있게 됩니다.

단방향 일회성 신호에 적합한 클라이언트

ChannelClient

ChannelClient는 두 디바이스 간 스트림 통신을 제공합니다.

생성되는 채널은 통신에 사용되는 파이프라인으로, 마이크를 통해 녹음된 음성과 같은 스트림된 데이터를 전송하거나 MessageClient를 통해 처리할 수 없는 대용량의 데이터를 전송할 때 사용됩니다.

또한 데이터를 전송할 때 DataClient는 로컬 기기에 데이터의 복사본을 생성한 뒤 데이터를 전송하지만,
ChannelClient는 그렇지 않기 때문에 디스크 용량을 절약할 수 있습니다.

하지만 연결 지향 방식이기 때문에 연결이 끊기면 데이터가 유실될 수 있습니다.

스트림 처리를 위한 연결 지향 클라이언트

데이터 전송 구현

개발 환경 설정

Data Layer API를 사용하기 위해서는 다음과 같은 의존성 추가가 필요합니다.

dependencies {
    implementation("com.google.android.gms:play-services-wearable:19.0.0")
}

또한 워치 앱과 스마트폰 앱의 패키지 명이 반드시 동일해야 합니다!!
이것때문에 다 구현해놓고 안돼서 당황했습니다..

구현

이제 구현 과정에 대해 알아보겠습니다.

제 경우에는 워치에서 스마트폰으로 심박수를 전송하고, 스마트폰에서 워치로 심박수 상태를 전송해야 했습니다.
또한 측정된 심박수는 서버로 전송하여 사용자의 평균 심박수 계산에 사용되어야 하기 때문에,
워치와 스마트폰 사이의 연결이 끊기더라도 데이터가 유실되는 일은 없어야 했습니다.

따라서 워치에서 스마트폰으로 심박수를 전송할 때는 DataClient를,
스마트폰에서 워치로 심박수 상태를 전송할 때는 MessageClient를 사용하기로 했습니다.

사실 위 과정이 지속적으로 이루어져야 하기 때문에 ChannelClient를 사용해도 되지만
단순한 문자열이기 때문에 MessageClient를 사용하는 것이 더 효율적일 것이라 판단했습니다.

DataClient를 통한 데이터 전송

DataClient 객체는 다음과 같이 생성할 수 있습니다.

private val dataClient: DataClient by lazy { Wearable.getDataClient(context) }

이제 PutDataRequest 객체를 생성하고 전송하고자 하는 데이터를 추가하면 됩니다.

val putDataReq = PutDataMapRequest.create(HEART_RATE_PATH).run {
	dataMap.putInt(HEART_RATE_KEY, heartRate)
	dataMap.putLong(TIMESTAMP_KEY, System.currentTimeMillis())
	asPutDataRequest().setUrgent()
}

private const val HEART_RATE_PATH = "/heart_rate"
private const val HEART_RATE_KEY = "heart_rate"
private const val TIMESTAMP_KEY = "timestamp"

PutDataRequest를 생성할 때는 경로를 지정해주어야 하며,
해당 경로로 생성된 DataItem을 조회할 때는 동일한 경로를 입력해야 합니다.

또 데이터를 저장할 때 사용되는 DataMap은 안드로이드의 Bundle과 유사한 키-값 형식입니다.
따라서 다양한 유형의 데이터를 지원하고 있습니다.

그래서 개발자가 생성한 직렬화 가능한 커스텀 객체도 저장할 수 있을 것 같아서 해봤는데,
여기까지는 지원하지 않는 것 같습니다.
(직접 바이트 배열로 변환해서 넣거나 하면 될 것 같은데 그냥 분리해서 넣는 것이 좋을 것 같습니다..)

DataMap에 데이터를 저장하고 난 뒤 asPutDataRequest()를 호출하면 바이트 배열로 직렬화됩니다.

만약 데이터의 실시간성을 보장해줘야 한다면 setUrgent()를 호출해서
데이터를 즉시 전송할 수 있게끔 할 수 있습니다.

dataClient
	.putDataItem(putDataReq)
    .addOnFailureListener { e ->
		Log.i("Widyu", "Error sending request to device", e)
	}

마지막으로 putDataItem()을 호출하면 내부적으로 DataItem이 생성됩니다.
Task를 반환하기 때문에 위처럼 실패, 성공, 취소 시 콜백으로 추가적인 처리를 할 수도 있습니다.

DataClient를 통한 데이터 수신

이제 위에서 전송한 데이터를 수신해보겠습니다.
데이터를 수신하는 방법은 크게 2가지가 있습니다.

  • WearableListenerService를 상속받은 서비스 클래스 구현
  • DataClient.OnDataChangedListener 인터페이스 상속 또는 구현

여기서 WearableListenerService는 매니페스트에 등록되고, 실제 Service처럼 앱이 실행되고 있지 않은 상태에서 앱을 실행할 수 있습니다.

따라서 사용자와 앱이 상호작용하는 순간에만 데이터를 수신해야 한다면 두번째 방법을,
그렇지 않다면 첫번째 방법을 사용하는 것이 바람직할 것입니다.

제 경우에는 포그라운드 상태가 아닐 때에도 계속해서 심박수를 처리해주어야 하기 때문에
WearableListenerService를 이용했습니다.

public abstract class WearableListenerService extends Service 
implements DataApi.DataListener, 
MessageApi.MessageListener, 
NodeClient.OnNodeMigratedListener, 
CapabilityApi.CapabilityListener, 
ChannelApi.ChannelListener, 
MessageClient.RpcService

WearableListenerService는 안드로이드의 Service를 확장한 클래스이기 때문에
Service의 기능을 모두 사용할 수 있습니다.

그래서 다양한 처리를 할 수 있지만 크게 신경써야 할 것은 아래 3가지입니다.

  • onDataChanged() : DataItem이 생성, 제거, 변경되었을 때 호출
  • onMessageReceived() : MessageClient를 통해 메시지가 전송되었을 때 호출
  • onCapabilityChanged() : 디바이스의 기능이 변경되었을 때 호출

DataClient를 통해 전송된 데이터를 처리해야 하기 때문에 onDataChanged()만 처리하면 됩니다.

class HeartRateListenerService : WearableListenerService() {
    override fun onDataChanged(dataEvents: DataEventBuffer) {
        dataEvents.forEach { event ->
            if (event.type == DataEvent.TYPE_CHANGED) {
                val item = event.dataItem
                if (item.uri.path == HEART_RATE_PATH) {
                    val dataMap = DataMapItem.fromDataItem(item).dataMap
                    val heartRate = dataMap.getInt(HEART_RATE_KEY)
                    val timestamp = dataMap.getLong(TIMESTAMP_KEY)
                    HeartRatePlugin.sendHeartRate(heartRate, timestamp)
                }
            }
        }
    }

    companion object {
        private const val HEART_RATE_PATH = "/heart_rate"
        private const val HEART_RATE_KEY = "heart_rate"
        private const val TIMESTAMP_KEY = "timestamp"
    }
}

onDataChanged의 인자에는 DataEventBuffer 타입의 객체가 전달되는데,
이름 그대로 DataEvent의 목록이라고 생각하면 됩니다.

또한 DataEvent에는 DataItem과 관련된 이벤트가 정의되어 있고,
TYPE_CHANGEDTYPE_DELETED라는 상수가 존재합니다.

여기서는 새롭게 추가된 데이터를 처리해야 하기 때문에 조건문으로 TYPE_DELETED를 걸러냅니다.

그리고 이전에 PutDataRequest를 생성했던 경로와 동일한 경로에 존재하는 DataItem을 찾고,
데이터를 추가했던 키 값을 통해 저장된 데이터를 수신하면 됩니다.

DataEventBuffer는 콜백 종료 후 자동으로 해제되기 때문에 필요한 데이터는 콜백 내부에서 즉시 추출해줘야 합니다.

val item = event.dataItem

val node = item.uri.host
val path = item.uri.path

참고로 해당 데이터를 전송한 디바이스와 전송된 경로를 위처럼 확인할 수 있습니다.

<service
	android:name=".HeartRateListenerService"
	android:exported="true">
	<intent-filter>
		<action android:name="com.google.android.gms.wearable.DATA_CHANGED" />
		<data android:scheme="wear" android:host="*" android:pathPrefix="/heart_rate" />
	</intent-filter>
</service>

마지막으로 매니페스트에 WearableListenerService를 등록해주면 끝입니다.

여기서 데이터 태그에 해당 데이터를 저장한 경로를 잘 적어줘야 합니다.
경로를 생략하면 Data Layer의 모든 변경 이벤트를 수신하게 됩니다.

또 host에 와일드 카드(*)를 입력하면 모든 디바이스에서 오는 데이터를 수신하겠다는 뜻이므로
특정 디바이스만 지정하려면 노드 ID를 입력하면 됩니다.

MessageClient를 통한 데이터 전송

이제 스마트폰에서 워치 쪽으로 심박수 상태를 전송해보겠습니다.

private lateinit var nodeClient: NodeClient
private lateinit var messageClient: MessageClient

private fun sendHeartRateStatus(status: String) {
	nodeClient.connectedNodes.addOnSuccessListener { nodes ->
		if (nodes.isEmpty()) return@addOnSuccessListener
		nodes.forEach { node ->
			messageClient
				.sendMessage(node.id, HEART_RATE_STATUS_PATH, status.toByteArray())
				.addOnFailureListener { Log.i("Widyu", "Error in sendHeartRateStatus", it) }
		}
	}
}

MessageClient로 데이터를 전송하기 이전에 반드시 NodeClient로 디바이스를 탐색해야 하는데,
sendMessage에는 디바이스의 아이디를 인자로 전달해줘야 하기 때문입니다.

NodeClient로 탐색 가능한 디바이스는 블루투스로 연결된 물리적인 거리가 가까운 디바이스와,
구글 서버를 통해 연결된 디바이스입니다.

이 두 경우는 node.isNearby라는 플래그 값을 통해 처리할 수 있습니다.

MessageClient로 데이터를 전송할 때 주의해야 할 점은 DataClient와 달리 연결이 끊기면 재연결 매커니즘이 존재하지 않고 데이터는 그대로 유실됩니다.
이 점을 고려하여 구현하면 되겠습니다.

또한 데이터를 전송할 때 바이트 배열로 변환하여 전송해야 합니다.

MessageClient를 통한 데이터 수신

DataClient와 동일하게 WearableListenerService를 상속받거나,
혹은 MessageClient.OnMessageReceivedListener를 구현하면 됩니다.

심박수 상태는 사용자가 앱과 상호작용 중일 때 필요한 데이터이므로,
이번에는 리스너 인터페이스를 구현하는 방식을 택했습니다.

override fun fetchHeartRateStatus(): Flow<HeartRateStatus> =
	callbackFlow {
		val listener =
			MessageClient.OnMessageReceivedListener { messageEvent ->
				if (messageEvent.path == HEART_RATE_STATUS_PATH) {
					val data = String(messageEvent.data)
					val heartRateStatus = HeartRateStatus.from(data)
					trySend(heartRateStatus)
                }
            }

        messageClient.addListener(listener)

        awaitClose { messageClient.removeListener(listener) }
    }

위에서 데이터를 전송한 경로와 동일한 경로로 이벤트를 필터링한 뒤,
해당 이벤트에서 필요한 데이터를 추출해서 처리하면 됩니다.

OnMessageReceivedListener는 메소드가 하나만 존재하는 SAM 인터페이스이기 때문에
위와 같은 코드를 통해 인터페이스를 구현할 수 있습니다.

이렇게 구현한 리스너를 MessageClient에 등록하면 됩니다.

또한 사용자가 앱과 상호작용하고 있지 않을 때에는 리스너를 제거해주었습니다.

profile
Flutter, Android

0개의 댓글