Embedded ESP32 Low Power

ESP32 딥슬립 슬롯 구조 설계

2026년 1월 15일 읽는 시간: 8분

문제 상황

배터리로 동작하는 ESP32 센서 노드를 개발하면서 전력 소비가 예상보다 높아, CR2032 코인셀 배터리(220mAh)로 2주밖에 동작하지 못하는 문제가 발생했습니다.

원인 분석

전류 프로파일을 측정한 결과:

  • Active 모드: ~160mA (Wi-Fi/BLE 동작)
  • Light Sleep: ~800μA (여전히 높음)
  • Deep Sleep: ~10μA (목표치)

문제는 센서를 5분마다 읽고 BLE로 전송하는데, Active 모드가 3~5초 지속되어 평균 전류가 ~500μA로 높았습니다.

해결 방법: 타임슬롯 최적화

1. Wake-up 시간 최소화

void setup() {
    // RTC 메모리에 상태 저장 (재부팅 후에도 유지)
    esp_sleep_enable_timer_wakeup(5 * 60 * 1000000); // 5분
}

void loop() {
    uint32_t start = millis();
    
    // 센서 읽기 (I2C)
    float temp = sht31.readTemperature();    // ~50ms
    float humid = sht31.readHumidity();
    
    // BLE 광고 (1초만 활성화)
    BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
    pAdvertising->start();
    delay(1000);  // 1초간 광고
    pAdvertising->stop();
    
    uint32_t elapsed = millis() - start;
    Serial.printf("Active time: %dms\n", elapsed);  // ~1050ms
    
    // Deep Sleep
    esp_deep_sleep_start();
}

2. 전류 프로파일 개선

상태 전류 시간 전하량 (mAh)
Deep Sleep 10μA 298.95초 0.00083
Active (센서+BLE) 80mA 1.05초 0.0233
평균 (5분 주기) ~80μA 300초 0.00667/5분

배터리 수명 계산:

220mAh / 0.080mA = 2,750시간 = 114일 ≈ 4개월

추가로 자가방전(~20%)을 고려하면 약 3개월 이상 동작합니다.

추가 최적화 기법

1. Brown-out Detector 비활성화

// platformio.ini
board_build.f_cpu = 80000000L  // 240MHz → 80MHz
board_build.partitions = min_spiffs.csv

// setup()
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_OFF);

2. Wi-Fi 완전 비활성화

// BLE만 사용, Wi-Fi는 비활성화
esp_wifi_stop();
esp_wifi_deinit();

3. RTC 메모리 활용

RTC_DATA_ATTR int bootCount = 0;
RTC_DATA_ATTR float lastTemp = 0;

void loop() {
    bootCount++;
    
    // 변화가 없으면 전송 생략 (전력 절약)
    if (abs(temp - lastTemp) < 0.5) {
        esp_deep_sleep_start();
        return;
    }
    
    lastTemp = temp;
    // BLE 광고
}

결과

평균 전류

80μA

500μA → 80μA (6.25배 개선)

배터리 수명

3~4개월

CR2032 220mAh 기준

Active 시간

1.05초

5분 주기 (0.35% duty cycle)

교훈

  • 저전력 설계는 "평균 전류"가 핵심 - Active 시간을 최소화
  • RTC 메모리로 상태 유지하면 재부팅 오버헤드 제거
  • 변화 감지 알고리즘으로 불필요한 전송 줄이기
  • 실측이 중요 - 시뮬레이션보다 전류계 측정
Wireless LoRa Tuning

LoRa SF/BW/CR 튜닝과 수신 안정화

2026년 1월 10일 읽는 시간: 10분

문제 상황

LoRa 게이트웨이 프로젝트에서 300m 거리에서 패킷 손실률이 20%를 넘어, 안정적인 데이터 수집이 어려웠습니다. 기본 설정(SF7/BW125/CR4/5)을 그대로 사용했는데 실외 환경에서 신뢰성이 낮았습니다.

LoRa 파라미터 이해

1. Spreading Factor (SF)

심볼을 확산하는 비율. 높을수록 장거리/신뢰성 향상, 낮을수록 속도 향상.

  • SF7: 가장 빠름, 짧은 거리
  • SF12: 가장 느림, 장거리 (SF7 대비 64배 시간)

2. Bandwidth (BW)

사용하는 주파수 대역폭. 넓을수록 속도 빠르지만 간섭 증가.

  • BW125: 표준, 간섭 적음
  • BW250/500: 2배/4배 빠르지만 SNR 6dB/9dB 손실

3. Coding Rate (CR)

에러 정정 비율. 높을수록 신뢰성 향상하지만 오버헤드 증가.

  • CR4/5: 20% 오버헤드 (권장)
  • CR4/8: 100% 오버헤드 (극한 환경)

실측 데이터 (SX1276, 920MHz, 20dBm)

SF BW CR 전송시간 (20바이트) 감도 (dBm) 실측 거리 PER (300m)
SF7 125 4/5 41ms -123 ~500m 18%
SF8 125 4/5 74ms -126 ~700m 8%
SF9 125 4/5 144ms -129 ~1km 3%
SF10 125 4/5 288ms -132 ~1.5km 1%
SF7 125 4/8 62ms -123 ~500m 12%

해결 방법

1. 파라미터 변경: SF7 → SF9

// Arduino LoRa library
LoRa.setSpreadingFactor(9);      // SF9
LoRa.setSignalBandwidth(125E3);  // BW125
LoRa.setCodingRate4(5);          // CR4/5
LoRa.setTxPower(20);             // 20dBm (최대)

// 전송시간: 41ms → 144ms (3.5배)
// PER: 18% → 3% (6배 개선)

2. 재전송 메커니즘 추가

// 센서 노드: ACK 없으면 재전송
send_packet(data);
wait_for_ack(2000);  // 2초 대기
if (!ack_received) {
    send_packet(data);  // 재전송
}

3. RSSI/SNR 로깅

// 게이트웨이: 수신 품질 모니터링
int rssi = LoRa.packetRssi();
float snr = LoRa.packetSnr();
log_to_db(node_id, rssi, snr);

// RSSI < -120dBm 경고 → 배터리 교체 또는 위치 조정

환경별 권장 설정

도심 실내 (~100m)

SF7/BW125/CR4/5 - 빠른 응답, 간섭 적음

도심 실외 (~500m)

SF8~9/BW125/CR4/5 - 장애물 고려

개활지 (~2km)

SF10~11/BW125/CR4/5 - 최대 거리

극한 환경 (공장/지하)

SF10/BW125/CR4/8 - 에러 정정 강화

결과

PER 개선

18% → 3%

SF7 → SF9 변경 후

통신 거리

~1.2km

실외 개활지, 안정적 수신

전송시간

144ms

20바이트 페이로드 기준

교훈

  • 기본 설정(SF7)은 실험용 - 현장에선 SF8~10 권장
  • RSSI/SNR 로깅으로 통신 품질 지속 모니터링
  • 재전송 로직 필수 (LoRa는 ACK가 기본이 아님)
  • 안테나 위치/방향이 10dB 이상 차이 발생
Backend MQTT WebSocket

MQTT WebSocket 실시간 뷰어 구조

2026년 1월 5일 읽는 시간: 7분

문제 상황

센서 데이터를 MQTT로 수집하는데, 고객이 "브라우저에서 실시간으로 데이터를 보고 싶다"는 요청을 했습니다. MQTT 클라이언트 설치 없이 웹에서 바로 확인할 방법이 필요했습니다.

아키텍처 설계

[센서 노드]  →  [Mosquitto MQTT Broker]  →  [Node.js 브릿지]  →  [브라우저]
  (ESP32)        (TCP 1883)                 (Socket.IO)        (Chart.js)
                                               ↓
                                          [InfluxDB]
                                          (데이터 저장)
                        

구현 (Node.js + Socket.IO)

1. MQTT 구독 및 WebSocket 브로드캐스트

// server.js
const mqtt = require('mqtt');
const io = require('socket.io')(3000);

// MQTT 연결
const client = mqtt.connect('mqtt://localhost:1883');

client.on('connect', () => {
    client.subscribe('sensors/#');  // 모든 센서 토픽 구독
    console.log('MQTT connected');
});

// MQTT 메시지 → WebSocket 브로드캐스트
client.on('message', (topic, message) => {
    try {
        const data = JSON.parse(message.toString());
        
        // 모든 연결된 클라이언트에게 전송
        io.emit('sensor_data', {
            topic: topic,
            value: data.value,
            unit: data.unit,
            timestamp: Date.now()
        });
        
        // InfluxDB 저장 (선택)
        saveToInfluxDB(topic, data);
        
    } catch (err) {
        console.error('Parse error:', err);
    }
});

2. 클라이언트 (HTML + Chart.js)

<!-- index.html -->
<canvas id="chart"></canvas>

<script src="/socket.io/socket.io.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>

<script>
const socket = io('http://localhost:3000');

// Chart.js 초기화
const ctx = document.getElementById('chart').getContext('2d');
const chart = new Chart(ctx, {
    type: 'line',
    data: {
        labels: [],
        datasets: [{
            label: 'Temperature',
            data: [],
            borderColor: 'rgb(75, 192, 192)'
        }]
    },
    options: {
        scales: {
            x: { display: false }
        }
    }
});

// WebSocket 데이터 수신
socket.on('sensor_data', (data) => {
    if (data.topic === 'sensors/temp_01') {
        // 차트 업데이트
        chart.data.labels.push(new Date().toLocaleTimeString());
        chart.data.datasets[0].data.push(data.value);
        
        // 최대 50개 포인트만 유지
        if (chart.data.labels.length > 50) {
            chart.data.labels.shift();
            chart.data.datasets[0].data.shift();
        }
        
        chart.update();
    }
});
</script>

성능 최적화

1. 토픽 필터링

// 클라이언트가 관심 있는 토픽만 구독
socket.on('subscribe', (topics) => {
    socket.topics = topics;  // 클라이언트별 토픽 저장
});

// 브로드캐스트 시 필터링
client.on('message', (topic, message) => {
    io.sockets.sockets.forEach((socket) => {
        if (socket.topics && socket.topics.includes(topic)) {
            socket.emit('sensor_data', data);
        }
    });
});

2. 샘플링 (너무 빠른 데이터 간솔)

// 1초에 최대 1개만 전송 (throttle)
const lastSent = {};

client.on('message', (topic, message) => {
    const now = Date.now();
    if (!lastSent[topic] || now - lastSent[topic] > 1000) {
        io.emit('sensor_data', data);
        lastSent[topic] = now;
    }
});

배포 (Docker Compose)

# docker-compose.yml
version: '3'
services:
  mosquitto:
    image: eclipse-mosquitto
    ports:
      - "1883:1883"
    volumes:
      - ./mosquitto.conf:/mosquitto/config/mosquitto.conf
  
  bridge:
    build: .
    ports:
      - "3000:3000"
    depends_on:
      - mosquitto
    environment:
      - MQTT_URL=mqtt://mosquitto:1883

결과

지연시간

< 1초

센서 → 화면 표시

동시 접속

50명

Raspberry Pi 4 기준

CPU 사용률

15%

10개 센서, 1Hz 전송

교훈

  • MQTT ↔ WebSocket 브릿지는 간단하지만 강력
  • Socket.IO로 실시간 양방향 통신 쉽게 구현
  • 토픽 필터링으로 클라이언트 부하 감소
  • Chart.js는 간단한 시각화에 최적

더 많은 노트

Infrastructure

Proxmox 운영 팁: 디스크 패스스루와 백업 전략

VM에 물리 디스크 직접 할당하는 방법과 자동 백업 스크립트

2025.12.28 준비 중
PCB

EasyEDA RF 회로 설계: 임피던스 매칭 실전

920MHz LoRa 모듈 안테나 매칭과 2층 PCB 레이아웃 가이드

2025.12.20 준비 중
Debugging

ESP32 메모리 릭 디버깅: Heap 모니터링과 Stack Overflow

FreeRTOS 환경에서 메모리 누수를 찾고 해결하는 방법

2025.12.15 준비 중