본문으로 건너뛰기

ESP32로 무선 IoT 센서 만들기 — BLE + WiFi 듀얼 모드 완전 정복

·1279 단어수·7 분
작성자
Engineer

ESP32를 처음 접했을 때, 솔직히 이게 이렇게 오래 살아남을 칩이 될 거라고는 몰랐다. 2016년에 나온 칩인데 2026년 현재도 IoT 프로젝트의 기본값이고, 심지어 Espressif는 CES 2026에서 WiFi 6E를 지원하는 ESP32-E22까지 발표했다. 이 글은 그런 ESP32의 핵심 강점 중 하나인 BLE + WiFi 듀얼 모드를 제대로 활용하는 방법에 대한 이야기다.

왜 BLE와 WiFi를 동시에 쓰는가
#

가장 흔한 IoT 패턴은 “WiFi로 직접 클라우드 연결"이다. 간단하고 코드도 짧다. 문제는 배터리다. WiFi 연결 유지에 드는 전력은 BLE에 비해 수십 배 이상이다. 배터리로 동작하는 센서 노드에 WiFi를 켜두면 AA 배터리 두 개가 일주일을 버티기 어렵다.

반면 BLE만 쓰면 소비전력은 해결되지만, 인터넷 연결을 위해 스마트폰이나 별도 게이트웨이가 반드시 필요하다. 결국 실제 현장에서 많이 쓰는 패턴은 이렇다:

  • 센서 노드: BLE Peripheral 모드, 딥슬립으로 배터리 수명 극대화
  • 게이트웨이: BLE Central + WiFi, 여러 노드의 데이터를 수집해 클라우드로 전송

혹은 노드 자체가 WiFi 직접 연결을 하되, 측정 후 즉시 딥슬립에 빠지는 방식도 유효하다.

ESP32 BLE + WiFi 듀얼 모드 IoT 센서 아키텍처

하드웨어 구성
#

내가 가장 자주 쓰는 조합이다.

부품 역할 가격 (대략)
ESP32-WROOM-32E 메인 MCU ₩2,500
DHT22 온도 + 습도 ₩2,000
BMP280 기압 + 고도 ₩1,500
MQ-135 공기질 (CO2, VOC) ₩3,000
18650 리튬 배터리 전원 ₩5,000
TP4056 모듈 배터리 충전 ₩800

총 부품 단가 약 15,000원. 케이스와 PCB 포함해도 3만 원 이하로 꽤 쓸만한 환경 모니터링 노드가 나온다.

배선 포인트:

  • DHT22: GPIO4 (단선 프로토콜, 4.7kΩ 풀업 저항 필수)
  • BMP280: I2C → GPIO21(SDA), GPIO22(SCL)
  • MQ-135: ADC → GPIO34 (입력 전용 핀)
  • 배터리 전압 모니터링: GPIO35 (분압 저항으로 3.3V 이하로 낮춰야 함)

펌웨어 코드 (ESP-IDF + Arduino 스타일)
#

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
#include <Arduino.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <DHT.h>
#include <Wire.h>
#include <Adafruit_BMP280.h>
#include <esp_sleep.h>

#define DHTPIN 4
#define DHTTYPE DHT22
#define SLEEP_DURATION_US (5 * 60 * 1000000ULL)  // 5분

// BLE UUIDs
#define SERVICE_UUID        "12345678-1234-1234-1234-123456789abc"
#define CHAR_TEMP_UUID      "12345678-1234-1234-1234-123456789ab1"
#define CHAR_HUMID_UUID     "12345678-1234-1234-1234-123456789ab2"
#define CHAR_PRESS_UUID     "12345678-1234-1234-1234-123456789ab3"

DHT dht(DHTPIN, DHTTYPE);
Adafruit_BMP280 bmp;
BLEServer* pServer = nullptr;
BLECharacteristic* pTempChar = nullptr;
BLECharacteristic* pHumidChar = nullptr;
BLECharacteristic* pPressChar = nullptr;
bool deviceConnected = false;

class MyServerCallbacks: public BLEServerCallbacks {
  void onConnect(BLEServer* pServer) { deviceConnected = true; }
  void onDisconnect(BLEServer* pServer) { deviceConnected = false; }
};

void setupBLE() {
  BLEDevice::init("ESP32-Sensor-Node");
  pServer = BLEDevice::createServer();
  pServer->setCallbacks(new MyServerCallbacks());

  BLEService* pService = pServer->createService(SERVICE_UUID);

  pTempChar = pService->createCharacteristic(
    CHAR_TEMP_UUID,
    BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY
  );
  pTempChar->addDescriptor(new BLE2902());

  pHumidChar = pService->createCharacteristic(
    CHAR_HUMID_UUID,
    BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY
  );
  pHumidChar->addDescriptor(new BLE2902());

  pPressChar = pService->createCharacteristic(
    CHAR_PRESS_UUID,
    BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY
  );
  pPressChar->addDescriptor(new BLE2902());

  pService->start();

  BLEAdvertising* pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(SERVICE_UUID);
  pAdvertising->setScanResponse(true);
  BLEDevice::startAdvertising();
}

void readAndPublish() {
  float temp = dht.readTemperature();
  float humid = dht.readHumidity();
  float pressure = bmp.readPressure() / 100.0F;

  if (isnan(temp) || isnan(humid)) {
    Serial.println("DHT 읽기 실패");
    return;
  }

  // BLE Notify
  char buf[16];
  snprintf(buf, sizeof(buf), "%.1f", temp);
  pTempChar->setValue(buf);
  pTempChar->notify();

  snprintf(buf, sizeof(buf), "%.1f", humid);
  pHumidChar->setValue(buf);
  pHumidChar->notify();

  snprintf(buf, sizeof(buf), "%.1f", pressure);
  pPressChar->setValue(buf);
  pPressChar->notify();

  Serial.printf("온도: %.1f°C, 습도: %.1f%%, 기압: %.1fhPa\n",
                temp, humid, pressure);
}

void setup() {
  Serial.begin(115200);
  dht.begin();

  if (!bmp.begin(0x76)) {
    Serial.println("BMP280 초기화 실패");
  }

  setupBLE();
  Serial.println("BLE 광고 시작");
}

void loop() {
  readAndPublish();

  if (!deviceConnected) {
    // 연결된 클라이언트 없으면 30초 후 딥슬립
    delay(30000);
    Serial.println("딥슬립 진입");
    esp_deep_sleep(SLEEP_DURATION_US);
  }

  delay(2000);  // 연결 중에는 2초마다 갱신
}

이 코드의 핵심은 연결 여부에 따라 동작을 다르게 하는 것이다. BLE 클라이언트(게이트웨이 또는 스마트폰 앱)가 연결되어 있으면 2초마다 데이터를 notify하고, 연결이 없으면 30초 대기 후 딥슬립에 빠진다.

게이트웨이 구현 (Python, 라즈베리파이 또는 PC)
#

게이트웨이는 BLE Central 역할을 하며 수집한 데이터를 MQTT 브로커로 올린다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import asyncio
from bleak import BleakClient, BleakScanner
import paho.mqtt.client as mqtt
import json

DEVICE_NAME = "ESP32-Sensor-Node"
MQTT_BROKER = "localhost"
MQTT_TOPIC_BASE = "home/sensors"

CHAR_UUIDS = {
    "temperature": "12345678-1234-1234-1234-123456789ab1",
    "humidity":    "12345678-1234-1234-1234-123456789ab2",
    "pressure":    "12345678-1234-1234-1234-123456789ab3",
}

mqtt_client = mqtt.Client()
mqtt_client.connect(MQTT_BROKER, 1883, 60)
mqtt_client.loop_start()

sensor_data = {}

def notification_handler(key):
    def handler(sender, data):
        value = float(data.decode())
        sensor_data[key] = value
        payload = json.dumps({"value": value, "unit": key})
        mqtt_client.publish(f"{MQTT_TOPIC_BASE}/{key}", payload)
        print(f"[{key}] {value}")
    return handler

async def main():
    print("BLE 스캔 중...")
    devices = await BleakScanner.discover()
    target = next((d for d in devices if d.name == DEVICE_NAME), None)

    if not target:
        print("디바이스를 찾을 수 없습니다")
        return

    print(f"연결: {target.address}")
    async with BleakClient(target.address) as client:
        for key, uuid in CHAR_UUIDS.items():
            await client.start_notify(uuid, notification_handler(key))

        print("데이터 수신 중... Ctrl+C로 종료")
        while True:
            await asyncio.sleep(1.0)

asyncio.run(main())

배터리 수명 최적화 — 실제 측정값
#

딥슬립 5분 주기로 운용했을 때 실제 측정값:

상태 전류
WiFi 활성 ~160mA
BLE Advertising ~15mA
BLE Connected (Notify) ~25mA
딥슬립 ~10µA
데이터 읽기 + Notify (2초) 평균 ~18mA

2600mAh 18650 배터리 기준으로 계산하면:

  • WiFi 상시 연결: 약 16시간
  • BLE + 딥슬립 5분 주기: 약 5~6개월

이 차이가 BLE를 선택하는 이유다. 실내 고정 센서라면 USB 전원을 써서 WiFi를 쓰는 게 낫지만, 야외나 배터리 의존 환경에서는 BLE 구조가 압도적으로 유리하다.

클라우드 연동 — Grafana + InfluxDB
#

MQTT 브로커(Mosquitto)에 올라온 데이터를 InfluxDB에 저장하고 Grafana로 시각화하는 구성이다. Docker Compose로 5분이면 세팅된다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# docker-compose.yml
version: '3'
services:
  mosquitto:
    image: eclipse-mosquitto:2
    ports:
      - "1883:1883"
    volumes:
      - ./mosquitto/config:/mosquitto/config

  influxdb:
    image: influxdb:2.7
    ports:
      - "8086:8086"
    environment:
      - INFLUXDB_DB=sensors
      - INFLUXDB_ADMIN_USER=admin
      - INFLUXDB_ADMIN_PASSWORD=password

  telegraf:
    image: telegraf:1.29
    volumes:
      - ./telegraf/telegraf.conf:/etc/telegraf/telegraf.conf
    depends_on:
      - influxdb
      - mosquitto

  grafana:
    image: grafana/grafana:10.4.0
    ports:
      - "3000:3000"
    depends_on:
      - influxdb

Telegraf가 MQTT를 구독해서 InfluxDB에 쓰는 브리지 역할을 한다. telegraf.conf의 핵심 부분:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[[inputs.mqtt_consumer]]
  servers = ["tcp://mosquitto:1883"]
  topics = ["home/sensors/#"]
  data_format = "json"
  json_string_fields = []

[[outputs.influxdb_v2]]
  urls = ["http://influxdb:8086"]
  token = "your-token"
  organization = "home"
  bucket = "sensors"

실전에서 겪은 문제들
#

문제 1: BLE와 WiFi 동시 사용 시 간섭

ESP32에서 BLE와 WiFi를 동시에 쓰면 같은 2.4GHz 대역을 공유하므로 간섭이 발생한다. 특히 WiFi 대역폭이 큰 작업(OTA 업데이트 등)을 할 때 BLE 연결이 끊기는 경우가 있다. 해결책은 시분할 방식으로 운용하거나, 업무 특성상 동시 동작이 필수라면 WiFi 채널을 1, 6, 11 중 하나로 고정하고 BLE 채널과 겹침을 최소화하는 설정을 쓰는 것이다.

문제 2: DHT22 타이밍 민감성

DHT22는 1-wire 프로토콜을 쓰는데, ESP32에서 인터럽트나 WiFi 처리가 겹치면 타이밍이 틀어져서 읽기 실패가 빈번하다. dht.readTemperature()가 NaN을 반환하면 그냥 이전 값을 유지하거나 한 번 더 읽는 로직을 반드시 넣어야 한다. 아니면 처음부터 I2C 방식의 SHT31을 쓰는 걸 추천한다. 약간 비싸지만 훨씬 안정적이다.

문제 3: 딥슬립 후 재부팅 시 BLE 재초기화

ESP32에서 딥슬립에서 깨어나면 사실상 전체 재부팅이 일어난다. BLE 스택도 다시 초기화해야 한다. 이 과정에서 메모리 단편화가 쌓이면 몇 주 후에 힙 오버플로가 발생하기도 한다. esp_restart()를 주기적으로 넣어두거나 RTC 메모리를 활용해 상태를 보존하는 구조가 필요하다.

다음 단계
#

이 기반 위에 다음 확장을 추천한다:

  1. 메시 네트워킹: ESP-NOW 또는 BLE Mesh를 써서 여러 노드가 서로 데이터를 릴레이하는 구조
  2. OTA 업데이트: ArduinoOTA 또는 ESP-IDF의 OTA 파티션을 써서 펌웨어 원격 업데이트
  3. 엣지 AI: ESP32-S3는 내장 벡터 연산 유닛이 있어서 TensorFlow Lite Micro로 간단한 이상 감지 모델을 돌릴 수 있다

ESP32 에코시스템은 2026년 기준으로 정말 성숙해졌다. 10년 전에 라즈베리파이로 하던 작업의 상당 부분을 ESP32 하나로 처리할 수 있고, 소비전력은 비교도 안 될 정도로 낮다. IoT를 처음 시작한다면 이 플랫폼이 여전히 가장 좋은 출발점이라고 생각한다.