BLE GATT Profile 설계
BLE 디바이스의 데이터 구조를 설계하는 것이 GATT Profile 설계다. 서비스와 캐릭터리스틱을 어떻게 나누느냐에 따라 Service Discovery 시간, RAM 사용량, 통신 효율이 달라진다. GAP/GATT 기본 구조를 알고 있다는 전제로, 실제 프로파일을 설계할 때 고려해야 할 점을 다룬다.
ATT 핸들과 GATT 테이블 구조
GATT 테이블은 ATT(Attribute Protocol) 핸들의 배열이다. 서비스 하나, 캐릭터리스틱 하나를 추가할 때마다 여러 개의 ATT 핸들이 소모된다.
서비스 1개:
├── Service Declaration → 핸들 1개
├── Characteristic 1
│ ├── Characteristic Declaration → 핸들 1개
│ ├── Characteristic Value → 핸들 1개
│ └── CCCD (Notify/Indicate 시) → 핸들 1개
└── Characteristic 2
├── Characteristic Declaration → 핸들 1개
└── Characteristic Value → 핸들 1개
Notify/Indicate가 필요한 캐릭터리스틱은 CCCD(Client Characteristic Configuration Descriptor)가 추가되므로 핸들 3개, 아니면 2개다.
핸들 수 계산 공식:
총 핸들 = (서비스 수 × 1) + Σ(캐릭터리스틱별 2 또는 3)
예: 서비스 3개, 캐릭터리스틱 10개(이 중 Notify 6개)
3 + (4 × 2) + (6 × 3) = 3 + 8 + 18 = 29 핸들
ATT 핸들은 16비트이므로 이론상 65,535개까지 가능하지만, 실제 제약은 RAM이다.
핸들 수가 퍼포먼스에 미치는 영향
1. Service Discovery 시간
클라이언트가 연결 직후 GATT 테이블 전체를 읽는 과정이 Service Discovery다. 핸들이 많을수록 오래 걸린다.
Discovery는 ATT 요청-응답 패킷을 반복하는 방식이다. MTU 23 기준으로 한 응답에 캐릭터리스틱 정보 2~3개가 들어간다.
| 핸들 수 | Discovery 왕복 횟수 (MTU 23) | 예상 시간 (CI 30ms) |
|---|---|---|
| ~10 | 5~8회 | 150~250ms |
| ~30 | 15~20회 | 450~600ms |
| ~60 | 30~40회 | 900ms~1.2s |
| ~100+ | 50회+ | 1.5s+ |
MTU를 올리면 Discovery도 빨라진다. MTU 247이면 한 응답에 더 많은 attribute 정보가 들어가므로 왕복 횟수가 줄어든다.
Discovery는 연결할 때마다 발생한다. 재연결이 잦은 디바이스(웨어러블, 비콘 등)에서 핸들 수가 체감 성능에 직접 영향을 준다.
2. RAM 사용량
GATT 테이블은 RAM에 상주한다. 핸들 하나당 소모되는 RAM은 스택 구현마다 다르지만 대략:
| 플랫폼 | 핸들당 RAM | 비고 |
|---|---|---|
| STM32WB | ~28바이트 | SRAM2 공유 영역 사용 |
| nRF5 SDK | ~20바이트 | SoftDevice 관리 영역 |
| ESP-IDF | ~40바이트 | Bluedroid 스택 |
| NimBLE | ~16바이트 | 경량 스택 |
30개 핸들이면 500~1200바이트. 큰 수치는 아니지만, STM32WB처럼 BLE 스택이 쓸 수 있는 RAM이 제한된 환경에서는 SHCI_C2_BLE_Init의 attribute 수 설정을 정확히 맞춰야 한다. 과하게 잡으면 낭비, 부족하면 서비스 등록이 silent fail한다.
3. Notification/Indication 처리량
캐릭터리스틱이 많다고 throughput이 떨어지는 건 아니다. Notification은 ATT 핸들 번호를 지정해서 보내므로, 테이블 크기와 무관하게 동일한 속도로 전송된다.
다만, 여러 캐릭터리스틱에서 동시에 Notify를 보내면 connection event 안에서 패킷을 나눠 써야 하므로 개별 캐릭터리스틱의 update rate가 낮아질 수 있다.
서비스/캐릭터리스틱 구성 패턴
패턴 1: 기능별 서비스 분리
가장 일반적인 구조. 기능 단위로 서비스를 나누고, 각 서비스 안에 관련 캐릭터리스틱을 모은다.
Device Info Service (SIG 표준 0x180A)
├── Manufacturer Name (Read)
├── Firmware Revision (Read)
└── Hardware Revision (Read)
Sensor Service (Custom UUID)
├── Temperature (Read/Notify)
├── Humidity (Read/Notify)
└── Measurement Interval (Read/Write)
Control Service (Custom UUID)
├── Command (Write)
└── Status (Read/Notify)
장점: 클라이언트가 필요한 서비스만 discovery할 수 있다 (UUID 기반 필터링). 모듈 단위로 코드를 분리하기 좋다.
적합한 경우: 범용 디바이스, 서드파티 앱과 연동, 표준 프로파일 재사용.
패턴 2: Serial Channel (RX/TX)
캐릭터리스틱 2개로 양방향 직렬 통신 채널을 만드는 패턴. Nordic UART Service(NUS)가 대표적.
UART Service (Custom UUID)
├── RX Characteristic (Write/Write Without Response)
└── TX Characteristic (Notify)
클라이언트 → 서버: RX에 Write 서버 → 클라이언트: TX로 Notify
상위 레이어에서 자체 프로토콜을 정의한다.
/* 자체 프로토콜 패킷 예시 */
typedef struct {
uint8_t cmd; /* 명령어 */
uint8_t seq; /* 시퀀스 번호 */
uint16_t len; /* 페이로드 길이 */
uint8_t payload[]; /* 가변 데이터 */
} ble_packet_t;
장점: GATT 테이블이 최소화(핸들 ~5개). Discovery 시간 최소. 프로토콜 변경 시 GATT 구조를 바꿀 필요 없다.
단점: 클라이언트가 GATT만 보고 디바이스 기능을 파악할 수 없다. 양쪽 모두 자체 프로토콜 파서가 필요하다.
적합한 경우: 자사 앱 전용, 프로토콜이 자주 바뀌는 경우, OTA 채널.
패턴 3: 혼합
표준 서비스 + Serial Channel 조합.
Device Info Service (0x180A) ← 표준
Battery Service (0x180F) ← 표준
Custom UART Service ← 자체 프로토콜
OTA Service ← DFU 전용
표준 서비스로 기본 정보를 노출하고, 비즈니스 로직은 Serial Channel로 처리한다. 실무에서 가장 많이 쓰이는 구조다.
펌웨어 설계 실무
서비스 등록 코드 구조 (STM32WB 예시)
/* custom_stm.c — 서비스/캐릭터리스틱 등록 */
void Custom_STM_Init(void)
{
/* 1. 서비스 등록 — 필요한 attribute 수를 미리 계산 */
uint16_t uuid = CUSTOM_SENSOR_SVC_UUID;
aci_gatt_add_service(
UUID_TYPE_128,
(Service_UUID_t *)&uuid,
PRIMARY_SERVICE,
8, /* 이 서비스가 쓸 최대 attribute 수 */
&svc_handle
);
/* 2. 캐릭터리스틱 등록 */
aci_gatt_add_char(
svc_handle,
UUID_TYPE_128,
(Char_UUID_t *)&temp_char_uuid,
4, /* value 최대 길이 */
CHAR_PROP_READ | CHAR_PROP_NOTIFY,
ATTR_PERMISSION_NONE,
GATT_NOTIFY_ATTRIBUTE_WRITE,
10, /* encryption key size */
CHAR_VALUE_LEN_VARIABLE,
&temp_char_handle
);
}
aci_gatt_add_service의 마지막 인자가 이 서비스에 할당할 attribute 수다. 부족하면 이후 aci_gatt_add_char 호출이 실패한다. 계산법:
service attribute 수 = 1 + Σ(캐릭터리스틱별 2 또는 3)
+ (User Description 등 추가 descriptor 수)
Attribute 수 사전 할당 (STM32WB)
/* app_conf.h */
#define CFG_BLE_NUM_GATT_ATTRIBUTES 30 /* GATT 테이블 전체 attribute 수 */
#define CFG_BLE_NUM_GATT_SERVICES 5 /* 서비스 수 */
#define CFG_BLE_ATT_MTU_MAX 247 /* MTU */
이 값들이 M0+의 SRAM2 할당량을 결정한다. 실제 등록할 attribute보다 넉넉하게 잡되, 과하게 잡으면 앱에서 쓸 수 있는 SRAM이 줄어든다.
Notification 전송 패턴
/* 값이 바뀔 때만 Notify — 불필요한 전송 방지 */
void sensor_update(int32_t new_temp)
{
if (new_temp == last_temp) return;
last_temp = new_temp;
aci_gatt_update_char_value(
svc_handle,
temp_char_handle,
0, /* offset */
sizeof(new_temp),
(uint8_t *)&new_temp
);
}
Service Changed Indication
GATT 테이블 구조가 바뀌면(OTA 후 서비스 추가/삭제) 클라이언트의 캐시가 무효화된다. Service Changed Indication을 보내야 클라이언트가 재 discovery를 수행한다.
/* GATT 테이블 변경 후 */
aci_gatt_update_char_value(
gap_svc_handle,
service_changed_handle,
0, 4,
(uint8_t[]){
0x01, 0x00, /* start handle */
0xFF, 0xFF /* end handle — 전체 범위 */
}
);
이걸 빠뜨리면 iOS에서 특히 문제가 된다. iOS는 GATT 캐시를 공격적으로 유지하므로, 테이블이 바뀌었는데 Service Changed가 없으면 캐시된 구형 핸들로 접근해서 실패한다.
설계 판단 기준
| 상황 | 권장 구조 | 이유 |
|---|---|---|
| 서드파티 앱 연동 | 기능별 서비스 분리 | 표준 UUID로 자동 인식 |
| 자사 앱 전용 | Serial Channel | 최소 핸들, 프로토콜 유연성 |
| 재연결 빈번 | 핸들 수 최소화 | Discovery 시간 절약 |
| 한 번 연결 후 상시 유지 | 핸들 수 덜 중요 | Discovery가 1회 |
| 센서 데이터 다수 | 기능별 분리 + Notify | 개별 구독 가능 |
| 펌웨어 업데이트 빈번 | Service Changed 필수 구현 | 캐시 무효화 |
메모
- 16비트 UUID vs 128비트 UUID: SIG 표준 서비스(0x180A 등)는 16비트 UUID를 쓰고, 커스텀 서비스는 128비트다. 16비트 UUID가 Discovery 패킷에서 공간을 덜 차지하므로 표준 서비스가 있으면 반드시 재사용한다
- 캐릭터리스틱 하나에 여러 값 묶기: 센서 3개의 값을 캐릭터리스틱 3개로 나누는 것보다, 하나의 캐릭터리스틱에 구조체로 묶는 것이 핸들 수를 줄인다. 단, 개별 구독이 불가능해진다
- Read vs Notify: Read는 클라이언트가 polling하는 구조. 변화를 감지하려면 주기적으로 읽어야 한다. 값이 변하면 서버가 알려주는 Notify가 대부분의 경우 더 효율적이다
- Write vs Write Without Response: Write는 ACK를 기다리므로 느리지만 신뢰성이 있다. Write Without Response는 빠르지만 유실 가능. 명령 전송처럼 신뢰성이 필요하면 Write, throughput이 중요하면 Write Without Response
- iOS의 GATT 캐시: iOS는 앱 단위가 아니라 시스템 레벨에서 GATT를 캐시한다. 개발 중에 서비스를 바꿨는데 반영이 안 되면 Bluetooth 설정에서 디바이스를 “이 기기 지우기” 해야 한다