블로그로 돌아가기
Transport15 min read

BLE 트랜스포트 설계 — 메시지와 파일 다운로드

이 글에서는 PlainApp이 LAN이나 Wi-Fi Aware를 사용할 수 없을 때 Bluetooth Low Energy를 통해 채팅 메시지를 푸시하고 파일을 다운로드하는 방법을 설명합니다. BLE는 보장된 폴백입니다: 느리지만, IP 연결이 전혀 없어도 작동합니다. 이 글은 와이어 포맷, 2계층 청킹 설계, 동시 트래픽이 우선순위를 갖는 방식(그리고 갖지 않는 방식), 그리고 모든 연결이 각 요청 후 해체되는 이유를 다룹니다.

이 트랜스포트를 사용하는 더 넓은 채팅 아키텍처는 Chat Architecture를 참조하십시오. 모든 BLE 페이로드를 암호화하는 데 사용되는 공유 ChaCha20 키를 두 기기가 어떻게 얻는지는 Pairing Flow를 참조하십시오.

목차

BLE 트랜스포트가 필요한 이유 {#why-a-ble-transport-at-all}

PlainApp은 서버리스이고 오프라인 우선입니다. 트랜스포트 계층은 순서화된 폴백 체인입니다: LAN → Wi-Fi Aware → BLE. LAN이 정상 경로입니다(Wi-Fi 위의 HTTPS, ~10ms 왕복). Wi-Fi Aware는 서브넷을 가로지르는 피어(다른 SSID, 게스트 대 IoT VLAN)를 커버합니다. 둘 다 어떤 형태의 IP 연결이 필요합니다. BLE는 다음 상황에서 작동하는 유일한 트랜스포트입니다:

  • 기기들이 동일한 IP 네트워크에 전혀 없을 때.
  • Wi-Fi가 꺼져 있거나 비행기 모드일 때(BLE 라디오는 별도임).
  • Wi-Fi Aware가 지원되지 않을 때(Android < 13, PlainApp의 모든 iOS 변형).

BLE는 느립니다 — 수십 KB/s, 요청당 수 초의 지연 — 하지만 보장되어 있습니다. 필요한 것이 피어의 clientId뿐이며, 이는 항상 BLE 스캔 응답에 브로드캐스트되기 때문입니다.

Diagram 1
1

GATT 서비스 레이아웃 {#gatt-service-layout}

PlainApp은 두 가지 특성(characteristic)을 가진 단일 커스텀 GATT 서비스를 광고합니다. 등록된 16비트 UUID는 없습니다 — 이 서비스는 트레일링 바이트가 ASCII로 디코딩 시 plpai\x01이 되는 128비트 UUID를 사용합니다:

Diagram 2
2

두 가지 특성인 이유?

두 프로토콜은 완전히 다른 신뢰 모델과 페이로드 형태를 갖습니다:

  • NEARBY는 페어링 메시지를 전달합니다. 이 메시지는 피어가 페어링되기 에 도착합니다(아직 공유 키 없음), 따라서 자체 Ed25519 서명된 JSON 페이로드와 자체 접두사 라우팅을 사용합니다. 본문은 평범한 문자열입니다.
  • HTTP는 페어링 이후의 모든 트래픽(채팅, 파일, 프레전스)을 전달합니다. 항상 공유 키로 ChaCha20 암호화되며, LAN Ktor 서버와 동일한 HttpRouteRegistry를 사용하므로 라우트 핸들러(/peer_graphql, /fs, /peer_status)가 한 번 작성되어 두 트랜스포트 모두에 재사용됩니다.

읽기 대신 알림인 이유?

BLE ATT 프로토콜은 단일 속성 읽기를 512바이트로 제한합니다. GraphQL 응답이나 16KB 파일 청크는 훨씬 클 수 있습니다. PlainApp은 실제 데이터에 readCharacteristic결코 사용하지 않음으로써 이를 회피합니다 — 서버의 onCharacteristicReadRequestGATT_SUCCESS와 함께 빈 페이로드를 반환합니다. 대신, 클라이언트는 요청을 특성에 쓰고, 서버는 클라이언트가 재조립하는 청크된 알림 시퀀스를 보내 응답합니다. 이는 BleDeviceApi.requestAsync, BleServerProtocol.handleWrite, AndroidBleGattServer.sendChunkedResponse에 문서화되어 있습니다.

피어 식별: MAC이 아닌 shortId {#peer-identification-shortid-not-mac}

BLE 광고 패킷은 작고(31바이트), BLE MAC 주소는 Android에서 약 15분마다 무작위화되므로 안정적인 식별자로 사용할 수 없습니다. PlainApp은 대신 스캔 응답에서 9바이트 serviceData 페이로드를 브로드캐스트합니다:

Diagram 3
3

전체 clientId 대신 잘린 해시인 이유?

13문자 clientId는 13바이트에 들어맞겠지만, PlainApp은 두 가지 이유로 8바이트 잘린 SHA-256을 선택합니다:

  1. 안정적인 바이트 예산. 총 9바이트는 서비스 UUID(16바이트), 길이, 타입 필드와 함께 31바이트 광고 페이로드에 여유 있게 들어맞습니다(약 27바이트 사용, 4바이트 여유).
  2. 프라이버시. BLE를 스캔하는 수동적 관찰자는 shortId에서 clientId를 복구할 수 없습니다(SHA-256 해시의 8바이트 접두사는 실제로 되돌릴 수 없음). 이미 같은 shortId를 광고하는 피어를 본 적이 있다면 인식만 할 수 있습니다 — PlainApp 사용자를 열거할 수는 없습니다.

전체 clientId는 실제로 GATT를 통해 연결하고 DDiscoverReply를 교환한 피어, 즉 사용자가 상호작용하기로 선택한 피어에게만 공개됩니다.

2계층 청킹 설계 {#two-layer-chunking-design}

이것은 BLE 트랜스포트에서 가장 미묘한 부분이며, 두 계층 모두 크기와 목적이 완전히 다르므로 이해하는 것이 필수적입니다:

Diagram 4
4

380문자인 이유?

협상된 ATT MTU는 Android에서 517바이트(requestMtu(517) — BLE 사양이 허용하는 최대값)이고, iOS에서는 ~185+(CoreBluetooth가 자동 협상)입니다. ATT 헤더(~3바이트)와 BleSegmentData의 JSON 래퍼 오버헤드({"d":"...","s":N}이 약 12바이트 추가)를 빼면, 380문자의 페이로드가 양 플랫폼에서 단일 ATT MTU 안에 편안하게 들어맞습니다. 이 값은 대칭적(클라이언트 요청 프래그먼트와 서버 알림 프래그먼트 모두 380 사용)이며, 코드를 단순하게 유지합니다.

파일 청크가 16KiB인 이유?

16KiB 파일 청크는 base64 인코딩 시 ~22KiB JSON이 되며, 이는 ~58개의 GATT 알림 세그먼트로 분할됩니다. BLE에서 각 requestAsync 왕복은 수 초가 걸리므로, 더 적지만 더 큰 청크가 청크당 오버헤드를 줄입니다. 훨씬 크게 가면 BLE RPC 타임아웃에 걸릴 위험이 있고 진행 피드백이 나빠집니다(사용자는 청크당 한 번만 진행이 갱신되는 것을 봄). 16KiB는 경험적으로 튜닝한 최적점입니다 — 처리량에는 충분히 크고, 반응적 진행 UI에는 충분히 작습니다.

RPC 프리미티브: BleDeviceApi.requestAsync {#the-rpc-primitive-bledeviceapirequestasync}

모든 BLE 채팅 메시지와 모든 파일 청크는 BleDeviceApi.requestAsync(service, requestData)에 대한 한 번의 호출입니다 — BleResult를 반환하는 suspend 함수입니다. 호출자 관점에서 동기식입니다: 하나의 요청 → 하나의 완전히 재조립된 응답, 파이프라이닝 없음.

Diagram 5
5

핵심 불변 조건

  1. 하나의 요청 → 하나의 응답. requestAsync는 호출자 관점에서 동기식입니다 — 전체 응답이 재조립된 후에만 반환됩니다. 파이프라이닝은 없습니다.
  2. 호출마다 알림 활성화. 클라이언트는 모든 requestAsync 시작 시 CCCD를 쓰고 끝에 비활성화합니다. 이는 낭비(호출당 두 번의 추가 GATT 쓰기)이지만 프로토콜을 무상태로 유지합니다 — 서버가 어떤 클라이언트가 "수신 중"인지 추적할 필요가 없습니다.
  3. RPC 내 재시도 없음. 단일 writeCharacteristic이 타임아웃(5초)되면 전체 RPC가 중단됩니다. ensureConnected만 재시도합니다(연결 실패 시 3회 시도). 거친 트랜스포트 수준의 백오프는 RPC 계층이 아닌 PeerCircuitBreaker가 제공합니다.

와이어 봉투 포맷 {#wire-envelope-format}

Layer A 세그먼트 내의 페이로드는 중첩된 JSON 봉투입니다. 프래그멘테이션을 벗겨내면 논리적 구조는 다음과 같습니다:

Diagram 6
6

응답 형태

응답은 동일한 Layer A 프래그멘테이션을 통해 반대 방향으로 흐르지만, 내부 JSON은 세 필드를 가진 BleHttpResponse입니다: s(HTTP 상태 코드), h(응답 헤더 맵), b(본문). 본문은 BleHttpCall.encodeResponse()에 의해 항상 base64 인코딩되며, 비어 있을 때도 마찬가지입니다 — 응답은 바이너리일 수 있고(암호화된 GraphQL 바이트, 날것의 /fs 파일 바이트), BLE 트랜스포트는 문자열 전용이므로, 동일한 JSON 봉투가 텍스트와 바이너리 페이로드 모두를 전달합니다.

메시지 전송 경로(종단 간) {#message-send-path-end-to-end}

모두 합쳐서 — 채팅 메시지가 BLE로 전송될 때 일어나는 일:

Diagram 7
7

주목할 만한 설계 선택

  • LAN과 동일한 키. 페어링의 ChaCha20 공유 키가 BLE에 재사용됩니다 — 별도의 BLE 키는 없습니다. LanTransport가 사용하는 OkHttp 암호화 인터셉터와 BleTransport의 수동 chaCha20Encrypt/chaCha20Decrypt는 동일한 프리미티브이며, 단지 호출 방식이 다를 뿐입니다.
  • LAN과 동일한 라우트 핸들러. BleHttpRequestHttpRouteRegistry.matchRoute(path)를 통해 디스패치되며, 이는 Ktor LAN 서버가 사용하는 것과 동일한 레지스트리입니다. 따라서 /peer_graphql, /fs, /peer_status 등은 정확히 한 번 구현되어 두 트랜스포트에서 동일하게 작동합니다.
  • 연결 재사용 없음. finally { scanner.teardownConnection(client) } 블록은 항상 실행됩니다. 각 메시지는 전체 connect→discoverServices→MTU 비용(~수 초)을 지불합니다. 이는 의도적인 트레이드오프입니다 — 설계 트레이드오프를 참조하십시오.

파일 다운로드 경로(종단 간) {#file-download-path-end-to-end}

BLE를 통한 다운로드는 스트리밍입니다 — 파일은 16KiB 청크로 읽혀 도착하는 대로 임시 파일에 쓰여지므로, 10MB 파일이 10MB의 RAM을 필요로 하지 않습니다. 핵심은 각 청크의 RPC가 별개의 requestAsync 호출이며, 청크들은 소비자가 동시에 읽는 ByteChannel에 푸시된다는 것입니다.

Diagram 8
8

하나의 큰 RPC 대신 스트리밍인 이유?

10MB 파일을 단일 RPC로 보내면 ~280 000개의 알림 세그먼트가 되며, 응답이 시작되기도 전에 양쪽에서 메모리에 보관되어야 합니다 — 게다가 전체 전송이 성공해야만 진행이 보고됩니다. 더 나쁜 점은, 중간에 단 하나의 알림이라도 누락되면 전체가 손상된다는 것입니다.

청크 설계는 세 가지 이점이 있습니다:

  1. 일정한 메모리. 한 번에 하나의 16KiB 청크만 전송 중입니다.
  2. 실시간 진행. DownloadQueue.notifyProgressUpdate()가 매 초 발생하며, UI는 다운로드 바를 표시합니다.
  3. 회복력. 실패한 청크는 독립적으로 재시도할 수 있습니다(DownloadQueue는 작업 수준에서 일시정지/재개/재시도를 지원합니다; 중간 스트림 실패는 부분 임시 파일을 남기지만, 현재 다운로더는 실패 시 이를 삭제합니다 — 트레이드오프 참조).

onClose가 다운로드 잡을 취소하는 이유

DownloadedResponse.onClose 콜백은 downloadJob.cancel()을 호출합니다. 이것은 필수적인데, 다운로드 루프가 자식 코루틴에서 실행되며, 소비자가 채널을 일찍 포기하면(예: 사용자가 Pause 탭) 그대로 영원히 실행될 것이기 때문입니다. DownloadedResponseAutoCloseable 계약은 소비자의 use { ... } 블록이 종료 시 자동으로 onClose를 호출하여, BLE 다운로드 코루틴을 취소하고 코루틴의 finally 블록에서 GATT 연결을 해체합니다.

우선순위: 실제에서 채팅이 파일을 이기는 방법 {#prioritization-how-chat-beats-files-in-practice}

이것은 모든 채팅 애플리케이션에서 가장 중요한 질문입니다: 느린 BLE 파일 다운로드가 진행 중일 때, 새 채팅 메시지가 앞지를 수 있습니까?

솔직한 답: 명시적 우선순위 체계가 없습니다

BLE 코드나 다운로드 큐 어디에도 우선순위 필드, 우선순위 큐, 선점이 없습니다. 저는 철저한 grep으로 이를 확인했습니다 — shared/src에서 priority가 매치되는 것은 로그 우선순위 수준과 EXIF 메타데이터뿐이며, 메시지 대 다운로드 순서와는 관련이 없습니다.

대신 존재하는 것은 원하는 행동을 창발적 속성으로 만들어내는 아키텍처 분리 집합입니다:

Diagram 9
9

실제로 작동하는 이유

채팅이 "우선순위를 가진 것처럼 느껴지게" 만드는 분리는 구조적입니다:

  1. 채팅 전송은 DownloadQueue를 거치지 않습니다. PeerGraphQLClientPeerTransportRouterBleTransport.send에 의해 직접 발행됩니다. 따라서 채팅 메시지는 파일 다운로드 큐 뒤에 앉지 않습니다.
  2. BleTransport 호출은 자체 GATT 연결을 엽니다. 하나의 연결을 점유한 장기 실행 다운로드가 채팅 전송이 동일한 피어에 두 번째 연결을 여는 것을 막지 않습니다. Android는 다수의 동시 GATT 연결을 지원합니다.
  3. 채팅 RPC는 짧습니다. 단일 채팅 메시지는 한 번의 requestAsync 왕복입니다 (연결 후 ~1초). 라디오가 다운로드로 바쁘더라도, 채팅 전송은 수 초 내에 완료됩니다.

설계가 부족한 부분

"명시적 우선순위 없음"의 트레이드오프:

  • 연결 지연. 채팅과 다운로드 모두 연결이 재사용되지 않으므로, 매번 connect→discover→MTU 비용(~수 초)을 지불합니다. 다운로드 중에 도착한 채팅 메시지는 다운로드의 기존 연결에 편승할 수 없습니다 — 새 연결을 엽니다.
  • Android의 정적 큐. AndroidBleGattClient의 프로세스 전체 operationQueue는 모든 피어, 모든 연결에 걸쳐 GATT 오퍼레이션을 직렬화합니다. 따라서 두 GATT 연결이 공존할 수 있지만, 그것들의 write/read/notify 오퍼레이션은 큐 수준에서 인터리브됩니다. 실제로는 문제없지만(각 오퍼레이션은 ~ms), 높은 동시성 하에서는 미묘한 전역 병목입니다.
  • 선점 없음. 진행 중인 다운로드는 채팅 메시지가 통과하도록 일시정지될 수 없습니다. 채팅 전송은 단순히 동시에 실행되며 라디오 시간을 두고 경쟁합니다.

미래의 개선 사항은 BleTransport.senddownloadFile 주변에 피어별 Mutex와 큐의 우선순위 필드일 수 있습니다 — 하지만 현재 설계는 채팅 RPC가 경쟁이 사용자에게 거의 보이지 않을 만큼 짧다는 사실에 의존합니다.

동시성 제어와 정적 GATT 큐 {#concurrency-control--the-static-gatt-queue}

이것은 Android BLE 구현에서 가장 미묘한 측면이므로 별도의 섹션이 필요합니다.

Diagram 10
10

정적(프로세스 전체)인 이유?

Android BLE 스택은 단일 BluetoothGatt 인스턴스에서 동시 GATT 오퍼레이션을 허용하지 않습니다 — 다른 쓰기가 진행 중일 때 writeCharacteristic을 호출하면 false를 반환하고 두 번째 쓰기를 조용히 폐기합니다. 표준 회피법은 BluetoothGatt별 큐입니다. PlainApp은 한 발 더 나아가 프로세스 전체 큐(companion object 내)를 사용하며, 이는 지나치게 보수적이지만 정확합니다: 앱 어디에서도 두 GATT 오퍼레이션이 동시에 실행되지 않음을 보장합니다.

비용은 긴 BLE 파일 다운로드의 write/read/notify 오퍼레이션이 다른 피어의 GATT 오퍼레이션 뒤에(그리고 뒤에) 큐잉된다는 것입니다. 각 개별 오퍼레이션이 ~ms이므로, 이는 거의 사용자에게 보이는 병목이 아닙니다 — 하지만 다수의 피어에 대한 무거운 동시 BLE 트래픽 하에서는 병목이 될 수 있습니다.

트랜스포트 계층의 피어별 잠금 없음

BleDeviceApi.requestAsync뮤텍스도, 큐도, 피어별 직렬화도 없는 평범한 suspend fun입니다. 동일한 피어에 대한 두 개의 동시 BleTransport.send 호출은 각각 자체 GATT 연결을 열고 독립적으로 진행됩니다. 직렬화는 GATT 오퍼레이션 수준에서 (Android의 정적 큐를 통해, 또는 iOS의 순차적 await를 통해) 암시적으로 일어납니다.

연결 라이프사이클과 MTU 협상 {#connection-lifecycle--mtu-negotiation}

Diagram 11
11

requestMtu(517)인 이유?

기본 ATT MTU는 23바이트(3바이트 ATT 헤더 후 20바이트 페이로드)입니다. 기본 MTU에서는 모든 380문자 세그먼트가 1회 대신 ~19회의 GATT 쓰기를 필요로 합니다 — 19배의 속도 저하입니다. BLE 사양이 허용하는 최대 MTU(517바이트)를 요청하면 380문자 세그먼트가 단일 ATT 오퍼레이션에 들어맞아, 처리량이 극적으로 향상됩니다.

iOS는 명시적 MTU 요청 API를 노출하지 않습니다 — CoreBluetooth가 연결 중 주변기기와 자동으로 협상합니다. 현대 iOS 기기는 일반적으로 ~185바이트를 협상하며, 이는 여전히 380문자 세그먼트를 편안하게 수용합니다(ATT 헤더 + JSON 래퍼 오버헤드를 뺀 후).

알림을 위한 흐름 제어 {#flow-control-for-notifications}

서버는 알림으로 응답 프래그먼트를 보내지만, BLE 알림은 기본 흐름 제어가 없습니다 — 서버가 컨트롤러가 전송할 수 있는 것보다 빠르게 알림을 보내면, 조용히 폐기됩니다. PlainApp은 명시적 ack 기반 흐름 제어를 구현합니다:

Diagram 12
12

이 흐름 제어가 없으면, 백투백 알림은 BLE 컨트롤러의 내부 전송 큐가 찼을 때 조용히 폐기됩니다 — BleGattServer 인터페이스 주석에 문서화된 잘 알려진 Android BLE 문제입니다. 기기별 단일 전송 중 규칙은 모든 알림이 전송되거나 타임아웃을 트리거(이후 트랜스포트 실패로 처리)함을 보장합니다.

오류 처리: TransportUnavailable과 실제 실패 {#error-handling-transportunavailable-vs-real-failure}

TransportUnavailablePeerTransportRouter에게 다음 트랜스포트로 넘어가라고 알리는 신호입니다. 그 외의 모든 것은 호출자에게 반환되는 실제 실패입니다.

Diagram 13
13

다운로드 실패의 미묘함

BleTransport.downloadFileDownloadedResponse(200, channel, onClose)즉시 반환합니다 — 청크 다운로드 루프는 채널에 쓰는 백그라운드 코루틴에서 실행됩니다. 청크 RPC가 중간에 실패하면 루프는 channel.close(TransportUnavailable(...))을 호출하며, 이는 소비자(PeerFileDownloader.downloadAsync)가 channel.readAvailable(buf)에서 throw된 예외로 오류를 보게 함을 의미합니다.

이는 PeerTransportRouter.downloadFile 호출 자체는 성공(DownloadedResponse 반환)했으므로, circuit breaker가 중간 스트림 다운로드 오류에 대해 실패를 기록 하지 않음을 의미합니다. 연결 시점과 스캔 시점의 실패만 라우터에 잡힙니다. 이는 의도적인 설계 선택입니다 — 중간 스트림 실패가 해당 피어에 대해 BLE를 영구적으로 비활성화해서는 안 됩니다(피어가 일시적으로 범위를 벗어났을 수 있음).

주요 상수 참조 {#key-constants-reference}

상수위치목적
BleDeviceApi.CHUNK_SIZE380GATT 세그먼트 프래그멘테이션BleSegmentData.data의 크기(JSON 오버헤드 후 ATT MTU 내에 들어맞음)
BleTransport.CHUNK_SIZE16 384 (16 KiB)파일 다운로드 바이트 범위/fs 청크 요청의 크기
BleTransport.SCAN_TIMEOUT_MS10 000BLE 스캔scanner.findOne의 타임아웃
BleDeviceApi.NOTIFY_TIMEOUT_MS15 000RPC 응답requestAsync의 알림당 대기
AndroidBleGattClient MTU517연결 설정requestMtu(517) — BLE 사양이 허용하는 최대값
AndroidBleGattClient connect timeout10 000연결 설정STATE_CONNECTED 대기
AndroidBleGattClient MTU timeout5 000연결 설정onMtuChanged 대기
AndroidBleGattClient write timeout5 000GATT 쓰기onCharacteristicWrite 대기
AndroidBleGattClient read timeout10 000GATT 읽기onCharacteristicRead 대기 (실제 데이터에는 미사용)
AndroidBleGattClient notify-state timeout5 000CCCD 쓰기CCCD 디스크립터 쓰기 대기
ensureConnected retries3연결 설정최대 4회 총 시도 (0..3)
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS10 000알림 흐름 제어onNotificationSent 대기
AndroidBleGattServer notifyChunkSize380응답 프래그멘테이션BleDeviceApi.CHUNK_SIZE와 동일
IosBleGattServer retry cap10알림 흐름 제어포기 전 최대 updateValue 재시도
PeerCircuitBreaker.WINDOW_MS30 000트랜스포트 circuit breaker임계값 후 열린 기간
PeerCircuitBreaker.MAX_FAILURES2트랜스포트 circuit breaker열기 위한 윈도우 내 실패
DownloadQueue.MAX_CONCURRENT3다운로드 워커 풀동시 다운로드 코루틴
BleServiceData.SHORT_ID_BYTES8피어 식별잘린 SHA256 접두사 바이트
BleServiceData.PAYLOAD_BYTES9피어 식별1 플래그 바이트 + 8 shortId 바이트
BleSegmentData.STATE_START_BIT1Layer A EOF 시그널링다중 세그먼트 메시지의 첫 세그먼트
BleSegmentData.STATE_END_BIT2Layer A EOF 시그널링마지막 세그먼트 (또는 단일 세그먼트)

설계 트레이드오프 요약 {#design-trade-offs-recap}

Diagram 14
14

더 읽어보기

  • Chat ArchitectureBleTransportLAN → Aware → BLE 폴백 체인과 더 넓은 채팅 송수신 파이프라인에 어떻게 들어맞는지.
  • Pairing Flow — 모든 BLE 페이로드가 사용하는 공유 ChaCha20 키가 어떻게 설정되는지, 그리고 NEARBY 특성이 페어링 핸드셰이크에 어떻게 사용되는지.