여러 Android 앱이 하나의 Bluetooth HID 세션을 나눠 쓰는 방법

키보드·트랙패드·PPT 리모컨을 별도 앱으로 내면서도 host에게는 한 대의 장치로 보이게 하는 방법. 정체성을 맞추고, 세션을 명시적으로 넘기고, 같은 키로 서명하면 됩니다.

구현 노트 · 2026년 8월 16일

제약. Android는 Bluetooth HID Device 프로필을 BluetoothHidDevice로 제공하고, 폰 한 대에 등록된 앱 하나만 허용합니다. 다른 앱이 등록을 쥐고 있는 동안 두 번째 앱이 registerApp()을 호출하면 대기열에 들어가는 것이 아니라 그냥 실패합니다. 키보드·마우스·PPT 리모컨을 별도 앱으로 배포하는 제품군이라면 이 문제를 반드시 명시적으로 풀어야 합니다.

손쉬운 대안은 둘 다 좋지 않습니다. 하나의 앱으로 합치면 사용자는 원하지 않는 기능까지 설치해야 합니다. 각 앱이 시작할 때마다 프로필을 붙잡게 두면 마지막에 연 앱이 이기는 경쟁 상태가 되고, 증상은 "그냥 연결이 안 됨"으로 나타나며 로그에는 쓸 만한 단서가 남지 않습니다.

아래는 도토리 HID 앱들이 쓰는 구조입니다. host가 한 대의 장치로 보도록 정체성을 공유하고, 형제 앱만 세션을 반납시킬 수 있도록 signature 권한으로 보호하며, Google Play로 배포한 뒤에도 그 권한이 실제로 부여되도록 서명을 맞춥니다.

host는 세 대가 아니라 한 대로 봐야 합니다

Bluetooth host는 SDP 레코드를 페어링하는 시점에 읽어서 캐시합니다. 재연결할 때마다 다시 읽지 않습니다. 이 사실 하나가 설계를 좌우합니다. 앱마다 서비스 이름이나 HID report descriptor가 다르면, 같은 폰에서 나온 것이라도 host는 서로 다른 장치로 취급합니다. 그리고 캐시해 둔 쪽 하나만 인식하고 나머지는 거부합니다.

정체성을 공유하지 않았을 때 실제로 관찰된 증상은 이렇습니다. Windows에서 "장치가 범위를 벗어났습니다"가 반복되고, 상호 인증에 실패하며, 끝내 링크 키가 제거됩니다. 즉 페어링 자체가 깨져서 사용자가 다시 페어링해야 합니다.

그래서 아래 세 값은 앱별 리소스가 아니라 공유 상수여야 합니다.

규칙
SDP name / description / provider모든 앱에서 동일한 문자열. 앱 이름도, 패키지 이름도 아님
HID report descriptor바이트 단위로 동일. report ID로 구분한 키보드+마우스 통합 descriptor 하나
subclass모든 앱이 같은 SUBCLASS1_COMBO를 선언

SDP provider를 packageName에서 파생하지 마세요. 편해 보이고 실제로 잘 동작하다가, applicationId가 바뀌는 날 정체성이 조용히 함께 바뀌면서 기존 페어링이 전부 깨집니다. 리팩터링처럼 보이지만 실제로는 Bluetooth 프로토콜 수준의 breaking change입니다. 고정 문자열로 박아 두면 비용 없이 이 실패 경로가 사라집니다.

이렇게 생각하면 이해하기 쉽습니다. report descriptor는 이 장치가 무엇을 보낼 수 있는가를 기술하고, 그 답은 사용자가 지금 타이핑을 하든 포인터를 움직이든 슬라이드를 넘기든 동일합니다. 어느 앱이 그 리포트를 만들었는지는 host가 보지도 않고 알 필요도 없는 구현 세부사항입니다.

세션 넘기기

등록을 한 앱만 쥘 수 있으므로, 시작하는 앱이 현재 보유자에게 반납을 요청해야 합니다. 수단은 대상을 명시하고 권한으로 보호한 broadcast입니다.

Intent(ACTION_RELEASE).setPackage(siblingPackage)
context.sendBroadcast(intent, permission)

여기서 세 가지가 중요합니다. intent가 명시적이라(setPackage) 아무나 듣는 방송이 아니라 지정한 앱에만 전달됩니다. 권한과 함께 보내므로 제3자 앱이 수신할 수 없습니다. 그리고 수신 측 receiver도 같은 권한을 선언하므로 제3자 앱이 위조해서 보낼 수도 없습니다.

요청을 받은 앱은 foreground service를 종료하고 등록을 놓습니다. 시작하는 앱은 첫 시도 전에 잠시 기다려야 합니다. 종료가 즉시 끝나지 않기 때문입니다. 정착 대기 250ms 정도를 두고, 첫 registerApp()이 여전히 충돌하면 1.5초쯤 뒤에 한 번 더 시도합니다. 이 값들은 실제 종료가 최대 1.5초까지 걸리는 것을 관찰해서 잡았습니다.

권한 자체는 signature로 선언합니다.

<permission android:name="…hid.session.permission.RELEASE"
            android:protectionLevel="signature" />
<uses-permission android:name="…hid.session.permission.RELEASE" />

권한 이름은 제품군 전체에서 동일하게 유지하고 앱별로 버전을 붙이지 않습니다. 다만 build type별로는 나눠야 합니다. RELEASE와 별도로 DEBUG 변형을 두어, 폰에 설치된 debug 빌드는 debug끼리만 통신하게 합니다. debug에서 release로 넘어가는 fallback은 편해 보이지만, 출시 전에 반드시 봐야 할 실패를 정확히 가려 버립니다.

서명 키가 성패를 가릅니다

여기가 놓치기 쉬운 부분입니다. 위 내용이 소스에서 전부 맞더라도 배포 후에 실패할 수 있기 때문입니다.

signature 권한은 실제로 설치된 APK의 인증서가 같을 때만 부여됩니다. 로컬 개발에서는 그것이 debug 키라서 인계가 한 번에 성공하고, 다 됐다는 착각을 줍니다. Google Play에서는 사용자가 설치하는 APK가 Play 앱 서명 키로 서명됩니다. 업로드 키가 아닙니다. 두 앱이 업로드 keystore를 공유해도 앱 서명 키가 서로 다를 수 있고, 그러면 권한은 끝내 부여되지 않으며 인계 broadcast는 조용히 버려집니다.

역할제품군에서 일치해야 하나?
업로드 키AAB를 올리는 주체를 증명편의상 같게 쓰지만, 권한을 부여하는 기준은 아님
Play 앱 서명 키사용자가 설치하는 APK에 서명예 — 이것이 기준입니다

Play Console에서는 앱 서명 → 키 변경 → 이 개발자 계정의 다른 앱과 같은 키 사용입니다. 결정적인 것은 시점입니다.

이 선택지는 앱을 공개 트랙에 게시하는 순간 닫힙니다. 게시 전에는 선택할 수 있고 비용도 사실상 없습니다. 설치 사용자도 없고 올린 번들도 없는 앱은 잃을 것이 없기 때문입니다. 게시 후에는 선택지가 사라지고, Google Play는 applicationId를 앱에 영구히 묶어 두므로(앱을 삭제해도 그 식별자는 다시 쓸 수 없습니다) 복구하려면 새 패키지 이름으로 다시 내는 수밖에 없습니다.

새 형제 앱을 낼 때의 실무 순서는 이렇습니다. 앱을 만들고, 첫 번들을 내부 테스트 트랙에 올리고, 곧바로 서명 키를 바꾼 다음 확인합니다. 이 사이에 공개 출시를 끼워 넣지 않습니다. 확인이란 Console에서 두 앱의 앱 서명 인증서 지문을 대조하는 것이며, 표시되는 모든 축을 봐야 합니다. 양자 내성 키가 함께 표시된다면 그것도 포함합니다. 업로드 인증서 일치는 별도로 확인하며, 업로드 인증서만 봐서는 이 질문에 답할 수 없습니다.

패키지 가시성은 선택 사항이 아닙니다

Android 11 이상에서는 선언하지 않은 패키지를 조회하거나 그 패키지로 명시적 broadcast를 보낼 수 없습니다. 그래서 각 앱은 형제 앱을 build type 두 갈래로 모두 선언합니다.

<queries>
  <package android:name="com.example.keyboard" />
  <package android:name="com.example.keyboard.debug" />
  <package android:name="com.example.ppt" />
  <package android:name="com.example.ppt.debug" />
</queries>

이것을 빠뜨려도 예외는 나지 않습니다. 형제 조회가 "설치되지 않음"을 돌려주고, 반납 요청은 전송되지 않으며, 새 앱은 아무 이유 없이 등록에 실패합니다. merged manifest를 파싱해서 기대 집합과 정확히 대조하는 계약 테스트를 두는 편이 낫습니다. 증상만 봐서는 Bluetooth 문제와 구분되지 않기 때문입니다.

패키지 이름 변경 견디기

형제 앱은 보통 반납 broadcast 외에도 더 주고받습니다. 연결 상태용 content provider, 구독 데이터용 provider 같은 것들입니다. 그 authority는 직접 적지 말고 빌드 변수에서 파생시킵니다.

android:authorities="${applicationId}.hid-bridge"

형제 쪽은 런타임에 패키지 이름으로 조립합니다(content://$sibling.hid-bridge). authority 리터럴이 어디에도 없으면 패키지 이름 변경이 자동으로 전파됩니다. 그때 실제로 고쳐야 하는 곳은 몇 되지 않고 열거하기도 쉽습니다. applicationId, 형제 목록, 각 <queries> 블록, 그리고 계약 테스트입니다.

namespaceapplicationId가 별개라는 점도 알아 둘 만합니다. Kotlin 패키지와 스토어 식별자는 독립적이라, 스토어 쪽 이름만 바꾸면 소스 트리를 전혀 건드리지 않아도 됩니다. 둘 다 옮길지 스토어 식별자만 바꿀지는 어느 쪽 불일치를 안고 갈 것인가에 대한 판단입니다.

출시 전 확인할 것

  1. 로그가 아니라 물리로 판정합니다. host에 입력이 실제로 도착하는지 확인합니다. 앱 로그의 down/up 짝이 맞으면서도 PC에는 아무것도 도달하지 않을 수 있습니다. 링크 구간에서 유실되기 때문입니다.
  2. 기기에서 signer 일치를 확인합니다. 형제 앱들의 Play 배포본을 설치하고, 로컬 빌드가 아니라 설치된 APK의 signer를 비교합니다.
  3. 양방향 인계를 확인합니다. 키보드 → 마우스, 마우스 → 키보드를 기존 페어링 그대로, 재페어링 요구 없이 확인합니다.
  4. 옛 빌드를 먼저 제거합니다. 폰에 남은 이전 형제 앱이 등록을 쥐고 있으면서 새 앱을 모를 수 있습니다. 그 앱의 형제 목록은 새 앱보다 먼저 만들어졌으므로 반납 요청을 받지 못합니다. 이 비대칭은 찾아보기 전에는 드러나지 않습니다.
  5. merged manifest 계약 테스트를 둡니다. build type별 권한 이름, variant별 provider authority, 형제 매핑, 그리고 알 수 없는 패키지를 형제로 인정하지 않는지까지 봅니다.

정리. Bluetooth 쪽 작업(descriptor, SDP, transport)은 눈에 보이는 부분이지만 대개 어려운 부분은 아닙니다. 시간을 가장 많이 잡아먹는 실패는 공유 정체성이 조용히 어긋나는 것, 소스에서는 맞는데 배포 후 부여되지 않는 권한, 그리고 한 줄 빠진 것이 조용한 no-op으로 바뀌는 패키지 가시성 선언입니다.