Skip to content
Published on

FingerScore 하드웨어 4 — BLE 무선 통신 설계(GATT부터 모바일 연동까지)

Authors

들어가며

FingerScore는 라켓 스포츠(테니스, 배드민턴, 탁구, 스쿼시)를 위한 스마트 점수기록 플랫폼입니다. 손가락에 끼는 작은 BLE 링이 있고, 간단한 제스처(손가락을 가볍게 톡톡 치거나 버튼을 누르는 동작)로 점수를 올리면 그 정보가 무선으로 폰이나 웹 앱에 전달됩니다. 경기 중에 점수판으로 달려가거나 종이에 적을 필요 없이, 그냥 손가락 동작 한 번으로 점수가 기록되는 것이죠.

이 시리즈에서는 그 하드웨어를 실제로 만들 수 있도록 단계별로 가르칩니다. 1~3편에서는 마이크로컨트롤러 선택, 센서(가속도계로 제스처 감지), 펌웨어 기초를 다뤘습니다. 이번 4편의 주제는 무선 통신입니다. 링이 아무리 똑똑하게 제스처를 감지해도, 그 신호를 폰까지 안정적으로 보내지 못하면 아무 소용이 없습니다.

왜 하필 BLE(Bluetooth Low Energy)일까요? 왜 Wi-Fi나 일반 클래식 블루투스가 아닐까요? 어떻게 "점수가 1 올랐다"는 정보를 폰에 전달할까요? 페어링은 안전한가요? 이 글에서 그 모든 것을 처음부터 설명합니다. 전자공학 전공자가 아니어도 따라올 수 있도록, 비유와 구체적인 코드 골격을 함께 제공합니다.


1. 왜 BLE인가 — 저전력의 철학

무선 통신 후보들

손가락에 끼는 링은 배터리가 아주 작습니다. 코인셀(CR2032) 하나, 혹은 손톱만 한 LiPo 배터리가 전부죠. 그래서 무선 방식을 고를 때 가장 중요한 기준은 전력입니다.

방식평균 소비전력거리데이터량FingerScore 적합성
Wi-Fi매우 높음(수백 mA)길다매우 큼부적합(배터리 몇 시간)
클래식 Bluetooth높음(수십 mA)중간부적합
BLE매우 낮음(평균 수십 uA)중간(10m 내외)작음(점수 몇 바이트)최적
ANT+낮음중간작음가능하나 폰 지원 약함
LoRa낮음매우 길다매우 작음과함(거리 불필요)

FingerScore가 보내는 데이터는 "점수가 1 올랐다", "세트가 끝났다" 같은 아주 작은 이벤트입니다. 한 번에 몇 바이트면 충분하죠. 큰 데이터를 빠르게 보낼 필요가 전혀 없습니다. 반면 배터리는 며칠에서 몇 주를 버텨야 합니다. 이 조건에 가장 잘 맞는 것이 BLE입니다.

BLE는 어떻게 전력을 아끼나

핵심 아이디어는 **"대부분의 시간 동안 자고, 아주 잠깐만 깬다"**입니다. BLE 무선 칩은 평소에는 거의 꺼져 있다가, 정해진 짧은 시간(connection interval)에만 깨어나 데이터를 주고받습니다. 깨어 있는 시간이 1%도 안 되는 경우가 많습니다.

BLE 라디오의 시간 사용 (개념도)

전력
  ^
  |   .         .         .         .
  |   |         |         |         |     <- 짧은 송수신 구간 (수 ms)
  |   |         |         |         |
  |___|_________|_________|_________|____> 시간
      <-------->                          <- 대부분 sleep (수십~수백 ms)
       interval

이 "잠깐 깨어나는 주기"를 connection interval이라고 하며, 뒤에서 자세히 다룹니다. 이 주기를 길게 잡을수록 전력은 절약되지만 반응 속도는 느려집니다. 트레이드오프죠.


2. BLE의 두 기둥 — GAP과 GATT

BLE를 처음 배울 때 가장 헷갈리는 게 GAP과 GATT라는 두 약어입니다. 비유로 정리해 봅시다.

  • GAP(Generic Access Profile): "누가 어떻게 연결되는가"를 다루는 규칙. 파티에서 서로를 발견하고 인사하고 짝을 짓는 단계라고 생각하세요.
  • GATT(Generic Attribute Profile): "연결된 다음 무슨 데이터를 어떻게 주고받는가"를 다루는 규칙. 인사가 끝난 뒤 실제로 나누는 대화의 형식입니다.

GAP — 발견과 연결

BLE 기기에는 보통 두 가지 역할이 있습니다.

  • Peripheral(주변기기): 자기 존재를 광고(advertising)하는 쪽. FingerScore 링이 여기에 해당합니다.
  • Central(중심기기): 광고를 스캔하고 연결을 거는 쪽. 보통 폰이 여기에 해당합니다.

링은 주기적으로 "나 여기 있어요, FingerScore 링이에요"라는 작은 패킷(advertising packet)을 방송합니다. 폰은 주변을 스캔하다가 이 패킷을 발견하면 연결을 시도합니다.

[FingerScore 링]  --- advertising --->  (공중)  <--- scanning ---  [폰]
   Peripheral                                                       Central

발견 후:
[링] <========= connection established =========> [폰]

광고 패킷에는 기기 이름, 서비스 UUID(아래에서 설명), 송신 출력 등을 담을 수 있습니다. 다만 크기 제한(레거시 광고 기준 31바이트)이 있어 꼭 필요한 것만 넣어야 합니다.

GATT — 데이터의 구조

연결이 되면 이제 GATT의 세계입니다. GATT는 데이터를 **서비스(Service) → 캐릭터리스틱(Characteristic) → 디스크립터(Descriptor)**라는 계층으로 정리합니다.

  • 서비스(Service): 관련된 데이터의 묶음. 예) "점수 서비스", "배터리 서비스".
  • 캐릭터리스틱(Characteristic): 실제 데이터 한 조각. 예) "현재 점수", "세트 번호", "배터리 잔량".
  • 디스크립터(Descriptor): 캐릭터리스틱에 대한 메타데이터. 예) 사람이 읽을 설명, 알림 설정.

비유하자면 서비스는 서랍장, 캐릭터리스틱은 서랍 하나, 디스크립터는 서랍에 붙은 라벨입니다.

GATT 서버 (FingerScore 링) 구조

Service: Battery Service (표준, UUID 0x180F)
  └─ Characteristic: Battery Level (0x2A19)  [Read, Notify]

Service: FingerScore Score Service (커스텀 128-bit UUID)
  ├─ Characteristic: Score State        [Read, Notify]
  ├─ Characteristic: Match Control      [Write]
  └─ Characteristic: Device Config      [Read, Write]

여기서 중요한 점: Peripheral(링)이 GATT 서버, Central(폰)이 GATT 클라이언트가 되는 경우가 일반적입니다. 즉 데이터를 보관하고 제공하는 쪽이 링입니다.


3. 캐릭터리스틱의 동작 — Read, Write, Notify

캐릭터리스틱은 몇 가지 속성(property)을 가집니다. FingerScore에서 가장 중요한 세 가지를 봅시다.

속성방향의미FingerScore 예시
Read폰이 링에게 요청폰이 값을 읽어감현재 점수 한 번 조회
Write폰이 링에게 보냄폰이 값을 씀"경기 리셋" 명령 전송
Notify링이 폰에게 밀어줌값이 바뀌면 링이 알림점수가 오르면 즉시 통보

FingerScore의 핵심은 Notify입니다. 폰이 계속 "점수 바뀌었어?"라고 물어보는(polling) 방식은 전력 낭비가 큽니다. 대신 점수가 실제로 바뀌었을 때만 링이 폰에게 "방금 바뀌었어!"라고 알려주는 게 효율적이죠.

Notify가 동작하는 원리

폰은 연결 후, 알림을 받고 싶은 캐릭터리스틱의 특수 디스크립터인 **CCCD(Client Characteristic Configuration Descriptor)**에 값을 씁니다. 이 값을 1로 쓰면 "알림 켜줘"라는 뜻입니다. 그 뒤로 링이 점수 캐릭터리스틱 값을 갱신할 때마다 자동으로 폰에 전달됩니다.

1. 폰: Score State 캐릭터리스틱의 CCCD에 0x0001 Write  (알림 구독)
2. 링: 제스처 감지 -> 점수 +1 -> Score State 값 갱신
3. 링: 갱신된 값을 즉시 폰에 Notify 전송
4. 폰: 콜백으로 새 점수 수신 -> UI 갱신

참고로 Notify와 비슷한 Indicate가 있습니다. 차이는 확인 응답(ACK) 여부입니다. Notify는 보내고 끝(빠르지만 유실 가능), Indicate는 폰이 받았다고 답을 줘야 다음을 보냅니다(느리지만 신뢰성↑). 점수처럼 누락되면 안 되는 데이터는 Indicate가 안전할 수 있으나, 보통은 Notify에 시퀀스 번호를 붙여 유실을 감지하는 방식을 씁니다. 이건 4절 패킷 설계에서 다룹니다.


4. 커스텀 GATT 서비스 설계 — 점수 이벤트

표준 서비스(배터리, 심박 등)는 UUID가 정해져 있지만, FingerScore의 점수 서비스는 우리만의 것이므로 128비트 커스텀 UUID를 직접 만들어 씁니다. UUID 생성기로 무작위 UUID를 하나 뽑아 베이스로 삼고, 서비스/캐릭터리스틱마다 일부 바이트만 바꿔 쓰는 게 관례입니다.

Base UUID:   6E40XXXX-B5A3-F393-E0A9-E50E24DCCA9E   (예시)

Score Service       : 6E400001-...
 ├ Score State char : 6E400002-...   [Notify, Read]
 ├ Match Control char: 6E400003-...   [Write]
 └ Device Config char: 6E400004-...   [Read, Write]

점수 패킷 설계

"점수가 올랐다"를 어떻게 바이트로 표현할까요? 너무 단순하게 점수 숫자 하나만 보내면, 패킷이 유실됐을 때 폰이 알 방법이 없습니다. 그래서 작지만 똑똑한 패킷 구조를 설계합니다.

Score State 패킷 (8 bytes)

Offset  Size  Field          설명
0       1     seq            시퀀스 번호(0~255 순환). 유실 감지용
1       1     event_type     0=점수, 1=세트종료, 2=경기종료, 3=리셋
2       1     player         0=나, 1=상대
3       1     score_self     내 현재 점수
4       1     score_oppo     상대 현재 점수
5       1     set_self       내 세트 수
6       1     set_oppo       상대 세트 수
7       1     battery_pct    배터리 잔량(%)

이 설계의 장점:

  • 시퀀스 번호로 폰이 "5번 다음에 7번이 왔네? 6번 유실됐다"를 감지할 수 있습니다. 그러면 폰이 Read로 현재 상태를 다시 읽어와 동기화합니다.
  • 점수를 누적값(현재 점수)으로 보내므로, 한두 개 패킷이 유실돼도 다음 패킷으로 자동 복구됩니다(증분이 아니라 절댓값이라 안전).
  • 8바이트는 BLE 기본 MTU(23바이트, 실제 페이로드 20바이트) 안에 충분히 들어갑니다.

설계 원칙: 작은 IoT 기기에서는 "증분(+1)"보다 "현재 상태 전체"를 보내는 게 유실에 강합니다. 패킷 하나가 사라져도 다음 패킷이 진실을 담고 있으니까요.


5. 펌웨어 코드 골격 (Peripheral 측)

실제 칩(예: Nordic nRF52, ESP32)마다 SDK가 다르지만, 개념은 비슷합니다. 여기서는 의사코드에 가까운 C 골격으로 흐름을 보여줍니다. 중괄호와 포인터는 모두 코드 펜스 안에 있으니 안전합니다.

// 점수 상태를 담는 구조체
typedef struct {
    uint8_t seq;
    uint8_t event_type;
    uint8_t player;
    uint8_t score_self;
    uint8_t score_oppo;
    uint8_t set_self;
    uint8_t set_oppo;
    uint8_t battery_pct;
} score_packet_t;

static score_packet_t g_score;
static uint16_t score_char_handle;  // GATT 캐릭터리스틱 핸들

// 제스처 감지 콜백 (가속도계 인터럽트에서 호출)
void on_gesture_score(uint8_t which_player) {
    g_score.seq++;                       // 시퀀스 증가
    g_score.event_type = 0;              // 점수 이벤트
    g_score.player = which_player;
    if (which_player == 0) g_score.score_self++;
    else                   g_score.score_oppo++;
    g_score.battery_pct = battery_read_percent();

    // 캐릭터리스틱 값 갱신 + Notify 전송
    ble_notify(score_char_handle,
               (uint8_t*)&g_score,
               sizeof(g_score));
}

핵심은 제스처 인터럽트가 들어오면 구조체를 갱신하고 ble_notify()를 호출하는 것뿐입니다. 평소에 MCU는 슬립 상태로 있다가, 가속도계가 "톡!" 하는 충격을 감지하면 인터럽트로 깨어나 이 함수를 실행하고 다시 잠듭니다.

Write 처리 (폰 → 링 명령)

폰이 "경기 리셋" 같은 명령을 보내면 Write 콜백이 호출됩니다.

// Match Control 캐릭터리스틱에 Write가 들어올 때
void on_match_control_write(const uint8_t *data, uint16_t len) {
    if (len < 1) return;
    uint8_t cmd = data[0];
    switch (cmd) {
        case 0x01:  // 경기 리셋
            memset(&g_score, 0, sizeof(g_score));
            break;
        case 0x02:  // 세트 시작
            g_score.score_self = 0;
            g_score.score_oppo = 0;
            break;
        default:
            break;  // 알 수 없는 명령 무시
    }
}

6. 페어링과 보안 — LE Secure Connections

남의 폰이 내 점수를 가로채거나, 가짜 링이 폰에 붙으면 곤란합니다. BLE는 이를 위한 보안 절차를 제공합니다.

보안의 3단계

  1. Pairing(페어링): 두 기기가 서로 임시 키를 교환해 암호화 채널을 만드는 과정.
  2. Bonding(본딩): 페어링 후 키를 저장해, 다음에 다시 만나면 자동으로 신뢰하는 것.
  3. Encryption(암호화): 실제 데이터를 암호화해서 도청을 막는 것.

최신 BLE(4.2 이상)는 LE Secure Connections를 지원합니다. 이는 ECDH(타원곡선 디피-헬만) 키 교환을 사용해, 중간자(MITM)가 패킷을 다 엿봐도 키를 알아낼 수 없게 합니다. 구형의 LE Legacy Pairing보다 훨씬 안전합니다.

페어링 방식 선택

FingerScore 링에는 디스플레이도 키패드도 없습니다. 이런 기기는 보통 다음 중 하나를 씁니다.

방식필요 UIMITM 방어FingerScore
Just Works없음약함기본 사용(편의 우선)
Passkey Entry숫자 입력강함UI 없어 불가
Numeric Comparison화면 2개강함UI 없어 불가
OOB별도 채널(NFC 등)강함NFC 추가 시 가능

링에는 UI가 없으니 보통 Just Works로 시작하되, 점수 데이터는 민감도가 낮으므로 실용적으로 괜찮습니다. 다만 펌웨어 업데이트 같은 민감한 동작에는 추가 인증(앱 계정 기반 토큰 등)을 얹는 게 좋습니다.


7. 모바일·웹 연동

Web Bluetooth (웹 앱)

크롬 계열 브라우저는 Web Bluetooth API로 BLE 기기에 직접 접근할 수 있습니다. 사용자가 버튼을 눌러 명시적으로 기기를 선택해야만 연결됩니다(보안상).

const SCORE_SERVICE = '6e400001-b5a3-f393-e0a9-e50e24dcca9e';
const SCORE_CHAR = '6e400002-b5a3-f393-e0a9-e50e24dcca9e';

async function connectRing() {
  // 사용자에게 기기 선택 팝업 표시
  const device = await navigator.bluetooth.requestDevice({
    filters: [{ services: [SCORE_SERVICE] }],
  });

  const server = await device.gatt.connect();
  const service = await server.getPrimaryService(SCORE_SERVICE);
  const ch = await service.getCharacteristic(SCORE_CHAR);

  // Notify 구독
  await ch.startNotifications();
  ch.addEventListener('characteristicvaluechanged', (e) => {
    const v = e.target.value; // DataView
    const seq = v.getUint8(0);
    const scoreSelf = v.getUint8(3);
    const scoreOppo = v.getUint8(4);
    updateScoreboard(seq, scoreSelf, scoreOppo);
  });
}

Android (Kotlin) 개요

안드로이드 네이티브는 BluetoothGatt API를 씁니다. 콜백 기반이라 흐름이 비슷합니다.

private val gattCallback = object : BluetoothGattCallback() {
    override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
        if (newState == BluetoothProfile.STATE_CONNECTED) {
            g.discoverServices()   // 서비스 탐색 시작
        }
    }

    override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
        val ch = g.getService(SCORE_SERVICE_UUID)
            .getCharacteristic(SCORE_CHAR_UUID)
        g.setCharacteristicNotification(ch, true)
        // CCCD에 알림 활성화 값 쓰기
        val cccd = ch.getDescriptor(CCCD_UUID)
        cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
        g.writeDescriptor(cccd)
    }

    override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic) {
        val data = ch.value
        val scoreSelf = data[3].toInt() and 0xFF
        val scoreOppo = data[4].toInt() and 0xFF
        // UI 스레드에서 점수판 갱신
    }
}

iOS는 CoreBluetooth(CBCentralManager, CBPeripheral)로 거의 동일한 개념을 다룹니다.


8. 전력 절약 — connection interval과 패킷 빈도

연결이 유지되는 동안에도 전력을 더 아낄 수 있습니다. 핵심 파라미터는 다음과 같습니다.

파라미터의미트레이드오프
Connection Interval깨어나 통신하는 주기(7.5ms~4s)짧으면 빠름·전력↑, 길면 느림·전력↓
Slave Latency보낼 게 없으면 건너뛸 수 있는 횟수높이면 전력↓, 반응성은 유지
Supervision Timeout이 시간 응답 없으면 연결 끊김 판단너무 짧으면 오작동, 길면 끊김 감지 느림

FingerScore는 점수가 자주 바뀌지 않습니다(몇 초~몇십 초에 한 번). 그래서 긴 connection interval + 높은 slave latency 조합이 이상적입니다. 평소엔 거의 자다가, 점수가 바뀐 순간 Notify로 깨어나면 됩니다.

전력 vs 반응성 (개념)

interval 짧음(15ms)  : 즉각 반응, 배터리 며칠
interval 중간(100ms) : 거의 즉각, 배터리 1~2주
interval 김(500ms)   : 0.5초 지연, 배터리 몇 주

점수 기록은 0.1~0.5초 지연이 문제 안 됨 -> 긴 interval 선택

펌웨어에서 연결 후 connection parameter update를 요청해 폰에게 "더 느긋한 주기로 바꾸자"고 제안할 수 있습니다.


9. 안테나와 전파 — 2.4GHz와 인체

BLE는 2.4GHz 대역을 씁니다. 이 주파수는 물에 잘 흡수되는데, 사람 몸의 대부분이 물입니다. 링은 손가락에 끼므로 안테나가 피부에 매우 가깝죠. 이게 통신 거리를 줄이는 주요 원인입니다.

대응 방법:

  • 안테나 배치: 손가락 안쪽(살에 닿는 면)이 아니라 바깥쪽(공기 쪽)으로 안테나가 향하게 PCB를 배치합니다.
  • 그라운드 키프아웃: 안테나 주변에 구리(그라운드)를 비워 둬야 방사가 잘 됩니다. 이건 6편 PCB 설계에서 깊이 다룹니다.
  • 출력 조절: 송신 출력(TX power)을 적절히 높이되, 너무 높이면 배터리를 갉아먹습니다.

인체 영향에 대해선, BLE의 송신 출력은 매우 낮습니다(보통 0dBm = 1mW 수준, 휴대폰 통화보다 훨씬 약함). 일반적인 사용에서 건강 문제는 보고된 바 없으며, 규제 기관의 SAR(전자파 흡수율) 기준 안에서 동작합니다. 다만 인증(FCC/KC)은 6편에서 다룹니다.


10. 디버깅 — 보이지 않는 무선을 보는 법

무선은 눈에 안 보여서 디버깅이 어렵습니다. 다행히 좋은 도구들이 있습니다.

  • nRF Connect (모바일 앱): 폰에서 주변 BLE 기기를 스캔하고, 서비스·캐릭터리스틱을 펼쳐 보고, 직접 Read/Write/Notify를 시험할 수 있습니다. 펌웨어 개발 초기에 필수입니다. 폰 앱이 없어도 이걸로 링의 GATT 구조가 제대로 만들어졌는지 확인합니다.
  • BLE 스니퍼: nRF52840 동글 + Wireshark 조합으로 공중의 패킷을 직접 캡처합니다. 연결이 왜 끊기는지, 어느 패킷이 유실되는지 등 깊은 문제를 추적할 때 씁니다.
  • 로깅(RTT/UART): 칩에서 시리얼 로그를 뽑아 펌웨어 내부 상태를 봅니다.
디버깅 단계별 도구

1단계: nRF Connect 앱으로 GATT 구조 확인 (서비스 보이나?)
2단계: nRF Connect로 Notify 구독 -> 제스처 시 값 바뀌나?
3단계: 안 되면 RTT 로그로 펌웨어 내부 확인
4단계: 그래도 안 되면 스니퍼로 공중 패킷 캡처

11. 흔한 함정

실제로 BLE 펌웨어를 만들면 거의 모두가 한 번씩 밟는 지뢰들입니다.

  • 연결이 자꾸 끊긴다: Supervision timeout이 너무 짧거나, 링이 슬립에 들어가며 라디오를 너무 오래 꺼서 응답을 놓치는 경우. 슬립과 connection interval의 균형을 맞춰야 합니다.
  • MTU 문제: 기본 MTU는 23바이트(실 페이로드 20). 더 큰 데이터를 보내려면 연결 후 MTU 협상을 해야 합니다. FingerScore 8바이트 패킷은 협상 없이도 충분하니, 일부러 패킷을 작게 유지한 설계가 빛을 발합니다.
  • CCCD 안 켜고 Notify 기대: 폰이 알림 구독(CCCD 쓰기)을 안 했는데 값만 갱신하면 폰은 아무것도 못 받습니다. 초보자가 가장 많이 막히는 지점입니다.
  • 광고를 너무 자주/드물게: 광고 주기가 너무 길면 폰이 링을 늦게 발견하고, 너무 짧으면 배터리를 낭비합니다. 보통 100ms~1s 사이에서 절충합니다.
  • 본딩 정보 꼬임: 폰에서 페어링을 지웠는데 링은 기억하고 있으면 재연결이 안 됩니다. 링에 "본딩 초기화" 동작(예: 길게 누르기)을 넣어 두면 좋습니다.

12. 마치며 — 다음 글 예고

이번 4편에서는 FingerScore의 무선 신경망인 BLE를 처음부터 설계했습니다. GAP/GATT의 구조, Notify 기반 점수 전달, 유실에 강한 패킷 설계, LE Secure Connections 보안, Web Bluetooth와 Android 연동, 그리고 전력 절약과 디버깅까지 짚었습니다. 이제 링은 점수를 똑똑하게, 또 아껴 가며 폰에 보낼 수 있습니다.

하지만 여기엔 큰 전제가 하나 있습니다. 링에 전원이 들어와 있어야 한다는 것이죠. 손가락만 한 기기 안에 어떻게 배터리를 넣고, 며칠을 버티게 하고, 안전하게 충전할까요?

다음 5편 전원·배터리·회로 설계에서는 전력 예산 계산부터 배터리 선택(코인셀 vs LiPo), 충전 IC와 보호회로, LDO와 벅 컨버터, 그리고 발열·ESD·안전까지 — 링을 진짜로 켜 두는 모든 것을 다룹니다.


참고 자료