GATT: 데이터를 담는 메뉴판
운동 앱은 처음 보는 회사의 심박계와도 연결해 심박수를 띄운다. 체중계 앱은 어느 회사 체중계든 몸무게를 읽어 온다. 서로 만난 적 없는 기기끼리 “심박수는 어디에, 어떤 형식으로 있는가”를 어떻게 알까? 답은 LE의 데이터 교환이 모두 같은 틀, GATT 위에서 이루어진다는 데 있다. 기기 안의 데이터는 번호 붙은 칸에 종류 이름표를 달고 놓여 있고, 상대는 그 칸을 찾아 읽고, 쓰고, 바뀌면 알려 달라고 요청한다. 이 장에서는 심박계 하나를 놓고 그 칸들을 직접 열어 본다.
- ATT의 속성이 핸들·종류(UUID)·값·권한으로 이루어진다는 것과 16비트 UUID가 128비트 기본 UUID의 줄임이라는 것을 안다.
- GATT가 속성들을 서비스 → 특성 → 디스크립터로 묶는 방식과 선언(0x2800, 0x2803)의 역할을 이해한다.
- 클라이언트가 서비스와 특성을 찾는 과정, 읽기·쓰기·명령의 차이를 ATT PDU 바이트로 따라간다.
- CCCD에 0x0001을 써서 알림을 켜고, 알림(Notification)과 표시(Indication)의 차이와 처리량 차이를 계산한다.
- ATT_MTU 23의 유래와 MTU 교환, 긴 값(최대 512바이트)을 여러 PDU로 나누는 법을 안다.
속성: 번호 붙은 칸에 이름표 달기
LE 기기 안의 데이터는 속성(attribute)이라는 칸에 담긴다. 속성 하나는 네 가지로 이루어진다.
- 핸들(handle): 0x0001~0xFFFF의 16비트 번호. 이 서버 안에서 칸의 주소다.
- 종류(attribute type): 이 칸에 무엇이 들었는지를 나타내는 UUID(Universally Unique Identifier). “심박 측정값”, “배터리 잔량” 같은 이름표다.
- 값(value): 실제 바이트. 최대 512바이트.
- 권한(permissions): 읽기·쓰기 가능 여부와 암호화·인증 요구. 권한은 서버만 알고, 무선으로 전송되지 않는다.
데이터를 가진 쪽을 서버(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)는 특별한 종류의 속성(선언)을 칸 사이사이에 끼워 구조를 만든다.
- 서비스 선언(종류 0x2800 Primary Service): 값이 서비스의 UUID다. 여기서 다음 서비스 선언 직전까지가 한 서비스(service)다.
- 특성 선언(종류 0x2803 Characteristic): 값이 [속성 비트 1바이트][특성 값의 핸들 2바이트][특성 UUID]다. 바로 다음 칸이 실제 값을 담은 특성 값 칸이다. 둘을 합쳐 특성(characteristic)이라 한다.
- 디스크립터(descriptor): 특성 값 뒤에 붙는 부가 정보. 가장 중요한 것이 CCCD(Client Characteristic Configuration Descriptor, 0x2902)다. 클라이언트가 여기에 0x0001을 쓰면 알림, 0x0002를 쓰면 표시를 받겠다는 뜻이다. 값은 클라이언트마다 따로 저장된다.
| 특성 속성 비트 | 값 | 허용하는 일 |
|---|---|---|
| Broadcast | 0x01 | 값을 광고로 방송 |
| Read | 0x02 | 읽기 |
| Write Without Response | 0x04 | 응답 없는 쓰기(명령) |
| Write | 0x08 | 응답 있는 쓰기 |
| Notify | 0x10 | 알림 (확인 없음, CCCD 필수) |
| Indicate | 0x20 | 표시 (확인 있음, CCCD 필수) |
| (이전에 쓰던 값) | 0x40 | 서명된 쓰기(Authenticated Signed Writes)였으나 6.3에서 데이터 서명이 삭제되어 쓰지 않는다 |
| Extended Properties | 0x80 | 확장 속성 디스크립터 있음 |
서비스 선언 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 사양이 정한다. 이 덕분에 어느 회사 심박계든 같은 앱으로 읽을 수 있다.
심박계 GATT 서버 들여다보기: 찾기 → 읽기 → 알림 켜기
읽기, 쓰기, 알림, 표시
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 채널(베어러)에서 동시에 돌려, 큰 응답 하나가 다른 앱의 작은 요청을 막는 “줄 서기” 문제를 줄였다.
알림 vs 표시: 확인을 기다리는 값
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).
값이 ATT_MTU에 다 들어가지 않으면 두 층에서 쪼갠다. ATT 층에서는 긴 값 읽기(READ_BLOB_REQ로 오프셋을 바꿔 가며 여러 번)와 긴 값 쓰기(PREPARE_WRITE_REQ 여러 번 + EXECUTE_WRITE_REQ)를 쓴다. ATT PDU 하나가 링크 계층 페이로드보다 크면 L2CAP이 여러 링크 계층 패킷으로 나눈다(LLID 10 = 시작, 01 = 이어짐). 둘 다 공짜가 아니다. 쪼갤 때마다 헤더와 왕복이 늘어난다.
긴 값 하나를 보내는 데 드는 PDU와 왕복
스마트워치 앱이 100바이트짜리 운동 기록을 알림으로 보내려는데, 휴대폰이 ATT_MTU 교환을 하지 않아 기본값 23이다. 어떻게 될까?
위 계산기에서 MTU 23, “알림”을 고르고 값 길이를 100으로 해 보자.
찾기 비용과 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)
핵심 정리
- 속성 = 핸들(16비트 번호) + 종류(UUID) + 값(≤ 512바이트) + 권한. 16비트 UUID는 기본 UUID 0000xxxx-0000-1000-8000-00805F9B34FB의 줄임이다.
- ATT는 클라이언트·서버의 요청·응답 규칙(LE에서 L2CAP 채널 0x0004), GATT는 그 위의 구조다. 서비스 선언 0x2800 → 특성 선언 0x2803(속성 비트·값 핸들·UUID) → 특성 값 → 디스크립터.
- CCCD(0x2902)에 0x0001을 쓰면 알림, 0x0002를 쓰면 표시. 값의 형식(예: 심박 측정값의 플래그 바이트)은 Core 밖의 서비스 사양이 정한다.
- 읽기·쓰기·명령·알림·표시. ATT는 응답을 기다리는 요청·표시를 한 번에 하나만 허용하므로 표시는 대략 연결 간격당 하나, 알림·명령은 한 이벤트에 여러 개가 가능하다. 링크 계층 재전송은 모두에게 적용된다.
- LE 기본 ATT_MTU = 23(= 27 − L2CAP 4). 알림 값은 MTU − 3바이트까지. MTU 교환으로 키우며, 247 + DLE 251이 짝이 좋다. 긴 값은 READ_BLOB·PREPARE/EXECUTE_WRITE로 나눈다.
- 찾기는 왕복이 많아 느리다. 본딩한 기기는 결과를 캐시하고, Service Changed와 Database Hash(5.1)로 바뀐 것을 확인한다.
확인 퀴즈
16비트 UUID 0x2A19(배터리 잔량)의 실제 128비트 값은?
특성 선언(0x2803)의 값에 들어 있지 않은 것은?
심박계의 심박수 알림을 받으려면 클라이언트가 먼저 해야 할 일은?
연결 간격이 100 ms일 때 표시(Indication)로 보낼 수 있는 값은 대략 초당 몇 개인가?
LE의 기본 ATT_MTU가 23인 이유는?
펌웨어 업데이트 뒤 서버의 GATT 구조가 바뀌었는데, 클라이언트가 예전 핸들로 값을 읽는 문제를 막는 장치는?