Chapter 07

GATT: 데이터를 담는 메뉴판

운동 앱은 처음 보는 회사의 심박계와도 연결해 심박수를 띄운다. 체중계 앱은 어느 회사 체중계든 몸무게를 읽어 온다. 서로 만난 적 없는 기기끼리 “심박수는 어디에, 어떤 형식으로 있는가”를 어떻게 알까? 답은 LE의 데이터 교환이 모두 같은 틀, GATT 위에서 이루어진다는 데 있다. 기기 안의 데이터는 번호 붙은 칸에 종류 이름표를 달고 놓여 있고, 상대는 그 칸을 찾아 읽고, 쓰고, 바뀌면 알려 달라고 요청한다. 이 장에서는 심박계 하나를 놓고 그 칸들을 직접 열어 본다.

속성: 번호 붙은 칸에 이름표 달기

LE 기기 안의 데이터는 속성(attribute)이라는 칸에 담긴다. 속성 하나는 네 가지로 이루어진다.

데이터를 가진 쪽을 서버(server), 묻는 쪽을 클라이언트(client)라 한다. 링크 계층의 센트럴·페리페럴과는 별개다. 보통 심박계(페리페럴)가 서버, 휴대폰(센트럴)이 클라이언트지만 반대도 가능하고, 한 기기가 둘 다일 수도 있다. 둘이 주고받는 요청·응답 규칙이 ATT(Attribute Protocol)이고, LE에서는 L2CAP 고정 채널 0x0004를 쓴다.

UUID는 원래 128비트(16바이트)다. 매번 16바이트를 보내기는 아까우므로, SIG가 정한 항목은 블루투스 기본 UUID 00000000-0000-1000-8000-00805F9B34FB의 앞부분 16비트만 바꾼 값으로 정하고, 그 16비트만 보낸다. 예를 들어 심박 서비스 0x180D는 0000180D-0000-1000-8000-00805F9B34FB의 줄임이다. 회사가 자기 기능을 만들 때는 이 범위를 피한 무작위 128비트 UUID를 쓴다.

속성의 핸들·종류·값·권한, 값 최대 512바이트: [Vol 3] Part F §3.2 · Bluetooth_Base_UUID와 16비트 별칭: [Vol 3] Part B §2.5.1 · ATT 고정 채널 0x0004, LE 기본 ATT_MTU 23: [Vol 3] Part G §5.2

카페의 메뉴판

GATT 서버는 메뉴판이다. 메뉴판은 “음료”, “디저트” 같은 분류(서비스)로 나뉘고, 각 분류 아래에 “아메리카노 4,500원” 같은 항목(특성)이 있다. 항목 옆에는 “ICE 가능” 같은 작은 메모(디스크립터)가 붙는다. 메뉴판의 모든 줄에는 번호(핸들)가 있어서 “7번 주세요”라고 하면 된다. 그리고 “아메리카노 나오면 진동벨로 알려 주세요”라고 부탁해 두면(CCCD 쓰기), 준비될 때마다 가게가 먼저 알려 준다(알림).

서비스, 특성, 디스크립터

ATT는 칸을 늘어놓을 뿐 묶는 법은 모른다. GATT(Generic Attribute Profile)는 특별한 종류의 속성(선언)을 칸 사이사이에 끼워 구조를 만든다.

특성 속성 비트값허용하는 일
Broadcast0x01값을 광고로 방송
Read0x02읽기
Write Without Response0x04응답 없는 쓰기(명령)
Write0x08응답 있는 쓰기
Notify0x10알림 (확인 없음, CCCD 필수)
Indicate0x20표시 (확인 있음, CCCD 필수)
(이전에 쓰던 값)0x40서명된 쓰기(Authenticated Signed Writes)였으나 6.3에서 데이터 서명이 삭제되어 쓰지 않는다
Extended Properties0x80확장 속성 디스크립터 있음

서비스 선언 0x2800/0x2801, 특성 선언 0x2803: [Vol 3] Part G §3.1·§3.3.1 · 특성 속성(Table 3.5): §3.3.1.1(6.3에서 0x40은 “Previously used”) · 데이터 서명 삭제: [Vol 1] Part C §17.2 · CCCD: [Vol 3] Part G §3.3.3.3

모든 LE 서버에는 기본 서비스 두 개가 있다. 장치 이름과 외형을 담는 GAP 서비스(0x1800)와, 서버의 구조가 바뀌었음을 알리는 Service Changed 특성을 가진 GATT 서비스(0x1801)다. 그 뒤에 기기의 쓸모를 담은 서비스가 온다. 심박 서비스(0x180D)와 그 안의 심박 측정값(0x2A37) 특성의 바이트 형식은 Core 사양이 아니라 별도의 Heart Rate Service 사양이 정한다. 이 덕분에 어느 회사 심박계든 같은 앱으로 읽을 수 있다.

INTERACTIVE

심박계 GATT 서버 들여다보기: 찾기 → 읽기 → 알림 켜기

클라이언트(휴대폰)가 할 일
심박수—
마지막 알림 값 (바이트)—
해석—
표의 줄을 누르면 그 속성을 읽는 ATT 요청과 응답의 바이트가 아래에 나온다(주황 = 옵코드, 청록 = 핸들, 파랑 = 값, 모두 리틀 엔디언). 해볼 것: ① 버튼을 차례로 눌러 보자. ①은 ATT_READ_BY_GROUP_TYPE_REQ(0x10)로 0x2800 선언을 모아 서비스 세 개를 찾고, ②는 ATT_READ_BY_TYPE_REQ(0x08)로 심박 서비스 범위의 0x2803 선언을 모은다. ② ④를 누르면 CCCD에 01 00이 쓰이고, 그 뒤 1초마다 ATT_HANDLE_VALUE_NTF(0x1B)가 온다. 측정값의 첫 바이트는 플래그(비트 0 = 심박이 16비트인가, 비트 1~2 = 피부 접촉, 비트 4 = RR 간격 포함)다. 심박 측정값 형식은 Heart Rate Service 사양(Core 밖)을 따랐다.

읽기, 쓰기, 알림, 표시

ATT의 대화 방식은 네 가지로 나뉜다. 차이는 누가 먼저 말하는가와 확인을 기다리는가다.

방식방향ATT PDU (옵코드)확인대표 쓰임
읽기클라이언트 → 서버 → 클라이언트READ_REQ (0x0A) / READ_RSP (0x0B)응답이 곧 확인배터리 잔량, 장치 이름
쓰기클라이언트 → 서버WRITE_REQ (0x12) / WRITE_RSP (0x13)있음설정 바꾸기, CCCD
명령(응답 없는 쓰기)클라이언트 → 서버WRITE_CMD (0x52)없음펌웨어 업데이트 데이터, 빠른 제어
알림서버 → 클라이언트HANDLE_VALUE_NTF (0x1B)없음심박수, 센서 스트림
표시서버 → 클라이언트HANDLE_VALUE_IND (0x1D) / CFM (0x1E)있음혈압 측정 결과처럼 잃으면 안 되는 값

ATT PDU 옵코드: [Vol 3] Part F §3.4 (0x0A READ_REQ, 0x12 WRITE_REQ, 0x52 WRITE_CMD, 0x1B NTF, 0x1D IND, 0x1E CFM)

“확인 없음”이 “잃어버려도 된다”는 뜻은 아니다. 6장의 링크 계층이 SN/NESN으로 모든 패킷을 확인하고 재전송하므로, 연결이 살아 있는 한 알림도 결국 도착한다. 확인의 차이는 앱 수준에서 생긴다. ATT는 한 베어러에서 응답을 기다리는 요청이나 표시를 한 번에 하나만 허용한다. 그래서 표시를 보내면 상대의 확인(CFM)이 올 때까지 다음 표시를 못 보낸다. 확인은 보통 다음 연결 이벤트에서야 돌아오므로, 표시는 연결 간격당 하나 정도가 한계다. 알림과 명령은 기다리지 않으므로 한 이벤트에 여러 개를 쏟아낼 수 있다.

5.2의 EATT(Enhanced ATT)는 ATT를 여러 L2CAP 채널(베어러)에서 동시에 돌려, 큰 응답 하나가 다른 앱의 작은 요청을 막는 “줄 서기” 문제를 줄였다.

CALCULATOR

알림 vs 표시: 확인을 기다리는 값

알림 최대—
표시 최대—
알림 / 표시—
위 줄은 알림, 아래 줄은 표시를 1초 동안 보낼 때 이벤트마다 오가는 값(막대)과 확인(✓)이다. 해볼 것: 연결 간격을 7.5 ms에서 400 ms로 늘려 보자. 표시는 초당 2~3개로 줄어든다. 그래서 심박 측정값처럼 자주 바뀌는 값은 알림으로, 혈압 측정처럼 드물지만 꼭 받아야 하는 값은 표시로 정의한다. 가정: 표시의 확인은 다음 연결 이벤트에서 돌아온다(서버는 확인을 받은 뒤 그 이벤트에서 다음 표시를 보낸다), 링크 손실 없음.

ATT_MTU: 23바이트의 유래와 긴 값 쪼개기

ATT_MTU는 ATT PDU 하나의 최대 크기다. LE의 기본값은 23바이트다. 숫자의 유래는 6장에 있다. 4.2 이전 링크 계층 페이로드 27바이트에서 L2CAP 헤더 4바이트를 빼면 23이다. 알림 PDU는 옵코드 1 + 핸들 2바이트를 쓰므로 값은 ATT_MTU − 3 = 20바이트까지 실린다. 클라이언트가 ATT_EXCHANGE_MTU_REQ로 더 큰 값을 제안하면 둘 중 작은 값으로 정해진다. 4.2의 DLE(251바이트)와 짝을 맞춘 247이 흔한 선택이다(247 + 4 = 251).

링크 계층 패킷 프리앰블 접근 주소 헤더 2 B 페이로드: 기본 27 B (DLE면 최대 251 B) CRC 3 B L2CAP 프레임 길이 2 B CID 0x0004 (ATT) ATT PDU: 최대 ATT_MTU ATT 알림 PDU 옵코드 0x1B · 1 B 핸들 2 B 값: 최대 ATT_MTU − 3 기본: 27 − 4 = ATT_MTU 23 → 값 20바이트 · DLE 251 − 4 = 247 → 값 244바이트
그림 7-1. 알림 하나가 공중으로 나가는 모양. ATT PDU는 L2CAP 머리말(길이·채널 번호 4바이트)을 달고 링크 계층 패킷의 페이로드에 실린다. 4.2 이전 링크 계층 페이로드 27바이트에서 L2CAP 머리말을 빼면 기본 ATT_MTU 23바이트가 되고, 옵코드와 핸들 3바이트를 빼면 알림 값은 20바이트까지 실린다. 암호화된 연결이면 페이로드 뒤에 MIC 4바이트가 더 붙는다.

값이 ATT_MTU에 다 들어가지 않으면 두 층에서 쪼갠다. ATT 층에서는 긴 값 읽기(READ_BLOB_REQ로 오프셋을 바꿔 가며 여러 번)와 긴 값 쓰기(PREPARE_WRITE_REQ 여러 번 + EXECUTE_WRITE_REQ)를 쓴다. ATT PDU 하나가 링크 계층 페이로드보다 크면 L2CAP이 여러 링크 계층 패킷으로 나눈다(LLID 10 = 시작, 01 = 이어짐). 둘 다 공짜가 아니다. 쪼갤 때마다 헤더와 왕복이 늘어난다.

CALCULATOR

긴 값 하나를 보내는 데 드는 PDU와 왕복

ATT_MTU
링크 계층 페이로드
방식
ATT 요청·응답 쌍—
링크 계층 데이터 패킷—
헤더 오버헤드—
막대 하나가 링크 계층 패킷 하나다(파랑 = 값, 주황 = ATT 헤더, 회색 = L2CAP 헤더). 해볼 것: ① 300바이트 값을 기본값(MTU 23, 페이로드 27)으로 읽으면 READ 1번 + READ_BLOB 13번, 왕복 14번이 필요하다. 표시나 응답이 연결 간격마다 하나라면 수백 ms가 걸린다. ② MTU 247 + DLE 251이면 왕복 2번이다. ③ 알림은 한 번에 ATT_MTU − 3바이트까지만 보낸다. 더 긴 값은 앱이 직접 나눠 보내거나 클라이언트가 읽어 가야 한다. 긴 값 읽기: 첫 READ_RSP에 MTU − 1바이트, 이후 READ_BLOB_RSP마다 MTU − 1바이트([Vol 3] Part F §3.4.4).
예측해 보기

스마트워치 앱이 100바이트짜리 운동 기록을 알림으로 보내려는데, 휴대폰이 ATT_MTU 교환을 하지 않아 기본값 23이다. 어떻게 될까?

위 계산기에서 MTU 23, “알림”을 고르고 값 길이를 100으로 해 보자.

알림의 값은 ATT_MTU − 3 = 20바이트까지다. L2CAP은 ATT PDU가 링크 페이로드보다 클 때 쪼갤 뿐, ATT_MTU를 넘는 PDU는 만들지 않는다. 그래서 연결 직후 MTU 교환이 거의 관례가 되었다.

찾기 비용과 GATT 캐싱

처음 연결한 클라이언트는 서버의 메뉴판을 모른다. 그래서 위 시뮬레이터처럼 서비스 찾기 → 특성 찾기 → 디스크립터 찾기를 차례로 한다. 요청 하나가 응답을 받아야 다음 요청을 보낼 수 있으므로, 속성이 수백 개인 기기(이어폰, 시계)에서는 이 과정만 연결 간격 수십 개, 즉 1초 넘게 걸리기도 한다. 그래서 클라이언트는 본딩한(8장) 기기의 메뉴판을 저장해 두고 다음 연결 때 다시 쓴다.

문제는 서버가 펌웨어 업데이트로 메뉴판을 바꿨을 때다. 옛 메뉴판대로 “7번 주세요”라고 하면 엉뚱한 값을 읽는다. 해결책이 둘 있다. 서버가 GATT 서비스의 Service Changed 특성으로 바뀐 핸들 범위를 표시해 알리는 방법, 그리고 5.1의 GATT 캐싱이다. 서버는 메뉴판 전체의 해시(Database Hash)를 특성으로 제공하고, 클라이언트는 연결할 때 해시 하나만 읽어 저장해 둔 것과 같은지 확인한다. 다르면 다시 찾는다.

Service Changed, Database Hash, Robust Caching: [Vol 3] Part G §2.5.2·§7 · EATT 베어러: [Vol 3] Part G §5.3와 [Vol 3] Part A(Enhanced Credit Based Flow Control)

핵심 정리

  1. 속성 = 핸들(16비트 번호) + 종류(UUID) + 값(≤ 512바이트) + 권한. 16비트 UUID는 기본 UUID 0000xxxx-0000-1000-8000-00805F9B34FB의 줄임이다.
  2. ATT는 클라이언트·서버의 요청·응답 규칙(LE에서 L2CAP 채널 0x0004), GATT는 그 위의 구조다. 서비스 선언 0x2800 → 특성 선언 0x2803(속성 비트·값 핸들·UUID) → 특성 값 → 디스크립터.
  3. CCCD(0x2902)에 0x0001을 쓰면 알림, 0x0002를 쓰면 표시. 값의 형식(예: 심박 측정값의 플래그 바이트)은 Core 밖의 서비스 사양이 정한다.
  4. 읽기·쓰기·명령·알림·표시. ATT는 응답을 기다리는 요청·표시를 한 번에 하나만 허용하므로 표시는 대략 연결 간격당 하나, 알림·명령은 한 이벤트에 여러 개가 가능하다. 링크 계층 재전송은 모두에게 적용된다.
  5. LE 기본 ATT_MTU = 23(= 27 − L2CAP 4). 알림 값은 MTU − 3바이트까지. MTU 교환으로 키우며, 247 + DLE 251이 짝이 좋다. 긴 값은 READ_BLOB·PREPARE/EXECUTE_WRITE로 나눈다.
  6. 찾기는 왕복이 많아 느리다. 본딩한 기기는 결과를 캐시하고, Service Changed와 Database Hash(5.1)로 바뀐 것을 확인한다.

확인 퀴즈

16비트 UUID 0x2A19(배터리 잔량)의 실제 128비트 값은?

128비트 값 = 16비트 값 × 2⁹⁶ + 기본 UUID. 기본 UUID의 첫 32비트 자리(0000xxxx)에 들어간다.

특성 선언(0x2803)의 값에 들어 있지 않은 것은?

현재 값은 바로 다음 칸(특성 값 속성)에 있다. 선언은 “어디에 무엇이 있고 무엇을 할 수 있는지”만 알려 준다.

심박계의 심박수 알림을 받으려면 클라이언트가 먼저 해야 할 일은?

알림은 클라이언트가 CCCD로 켜야 한다. CCCD 값은 클라이언트마다 따로 저장되며, 본딩한 클라이언트에 대해서는 재연결 후에도 유지된다.

연결 간격이 100 ms일 때 표시(Indication)로 보낼 수 있는 값은 대략 초당 몇 개인가?

표시는 확인(CFM)을 받아야 다음 표시를 보낼 수 있고, 확인은 보통 다음 연결 이벤트에 돌아온다. 그래서 이벤트 한두 개당 하나, 초당 5~10개 정도다.

LE의 기본 ATT_MTU가 23인 이유는?

ATT PDU 하나가 링크 계층 패킷 하나에 쪼개지지 않고 들어가도록 맞춘 값이다. DLE 251과 짝을 이루는 MTU가 247인 것도 같은 계산이다.

펌웨어 업데이트 뒤 서버의 GATT 구조가 바뀌었는데, 클라이언트가 예전 핸들로 값을 읽는 문제를 막는 장치는?

서버는 Service Changed로 바뀐 범위를 알리고, 클라이언트는 Database Hash를 비교해 캐시가 낡았는지 확인한다.