왜 MQTT인가
IoT 디바이스가 5초마다 보내는 온도값을 어떻게 받아 화면에 띄울까요. HTTP 폴링으로 처리하면 디바이스가 100대만 되어도 서버는 1초에 20번씩 요청을 받아내야 합니다. 배터리로 동작하는 센서라면 매번 TCP 핸드셰이크와 헤더 전송에 전력을 소비합니다.
MQTT는 이 문제를 정조준해서 만든 프로토콜입니다. 1999년 OASIS 표준으로 등장해 25년 넘게 산업 IoT의 사실상 표준 자리를 지키고 있고, 2024년 OASIS 통계에 따르면 글로벌 IoT 메시징의 70% 이상이 MQTT를 사용합니다. 헤더가 단 2바이트로 시작하고, 한 번 연결하면 양방향으로 메시지가 흐르며, 끊겼을 때 마지막 의지(Last Will)까지 자동으로 알려주는 설계 덕분입니다.
핵심 모델 — Pub/Sub와 Topic
MQTT의 가장 중요한 특징은 발행자와 구독자가 서로를 모른다는 점입니다. 모든 메시지는 브로커를 통해 토픽 이름으로 라우팅됩니다.
토픽은 슬래시로 계층화합니다. home/livingroom/temp처럼 의미 있는 트리 구조로 설계하면 와일드카드 구독이 강력해집니다.
+— 한 단계만 매칭.home/+/temp는 모든 방의 온도를 한 번에 구독#— 끝까지 매칭.home/#은 home 아래 모든 토픽
처음 토픽을 설계할 때 가장 흔한 실수가 temp1, temp2처럼 ID를 옆으로 나열하는 것입니다. 시간이 지나면 와일드카드가 안 통하고, 한 디바이스에 여러 센서가 붙으면 변경 비용이 큽니다. 장소 → 디바이스 → 측정값 순으로 계층을 잡는 게 정석입니다.
QoS — 메시지를 어디까지 보장할 것인가
| QoS | 의미 | 비용 | 쓸 곳 |
|---|---|---|---|
| 0 | 한 번 발행, 잃어도 OK | 가장 가벼움 | 빈번한 텔레메트리 |
| 1 | 최소 1회 도달 (중복 가능) | 중간 | 알림, 상태 변경 |
| 2 | 정확히 1회 (4-way) | 무거움 | 결제, 명령 실행 |
대부분의 IoT 서비스는 QoS 1로 충분합니다. QoS 2는 4단계 핸드셰이크 비용이 상당해서 정말 중복이 치명적인 경우에만 씁니다.
ESP32에서 발행하기
#include <WiFi.h>
#include <PubSubClient.h>
WiFiClient wifi;
PubSubClient mqtt(wifi);
void setup() {
WiFi.begin("SSID", "password");
mqtt.setServer("broker.example.com", 8883);
mqtt.connect("esp32-livingroom", "user", "pw");
mqtt.setKeepAlive(60); // 60초 ping
}
void loop() {
float temp = dht.readTemperature();
char buf[16];
snprintf(buf, sizeof(buf), "{\"v\":%.1f}", temp);
mqtt.publish("home/livingroom/temp", buf, true); // retained
delay(5000);
}
retained=true로 발행하면 브로커가 마지막 값을 저장해 새 구독자가 즉시 받을 수 있습니다. 대시보드를 새로 열어도 곧장 현재값이 채워지는 비결입니다.
웹 대시보드 — MQTT.js + WebSocket
브라우저는 raw TCP 소켓을 못 열기 때문에 MQTT를 WebSocket 위로 운반합니다. Mosquitto는 listener 8083 + protocol websockets 두 줄이면 활성화됩니다.
import mqtt from "mqtt";
const client = mqtt.connect("wss://broker.example.com:8083", {
username: "web",
password: process.env.NEXT_PUBLIC_MQTT_PW,
clientId: `web-${crypto.randomUUID()}`,
reconnectPeriod: 2000,
});
client.on("connect", () => {
client.subscribe("home/+/+", { qos: 1 });
});
client.on("message", (topic, payload) => {
const [, room, sensor] = topic.split("/");
const value = JSON.parse(payload.toString()).v;
setReadings((prev) => ({ ...prev, [`${room}/${sensor}`]: value }));
});
React에서 이 패턴을 쓸 때 자주 만나는 함정 두 가지를 미리 짚어두겠습니다.
- clientId 충돌 — 같은 ID로 두 번 접속하면 브로커가 이전 세션을 끊습니다. 탭을 두 개 열면 핑퐁이 나니 항상 고유값을 사용합니다.
- 재연결 시 구독 손실 —
clean: true(기본)로 접속하면 재연결 후 다시 subscribe해야 합니다. 대시보드처럼 항상 같은 토픽을 보는 경우clean: false+ 같은 clientId로 영속 세션을 만들면 끊겼다 붙는 동안의 메시지까지 받을 수 있습니다.
보안 — TLS와 인증은 필수
운영 환경에서 1883 평문 포트를 그대로 열면 안 됩니다. 권장 구성은 다음 세 단계입니다.
- 8883 TLS — Let''s Encrypt 인증서로 wss/mqtts 강제
- 클라이언트 인증서 — 디바이스마다 X.509 발급. 도난당한 키만 폐기하면 됨
- ACL —
home/livingroom/#은 livingroom 디바이스만 publish 가능하도록 토픽 단위 권한 분리
EMQX, HiveMQ Cloud 같은 매니지드 브로커를 쓰면 위 세 단계가 GUI 몇 클릭으로 끝납니다.
데이터 영구화 — 브로커는 큐, 저장은 따로
브로커는 메시지 라우팅 전용이지 데이터베이스가 아닙니다. 운영에서는 백엔드가 따로 구독해서 시계열 DB(TimescaleDB·InfluxDB)에 저장하고, 대시보드는 그걸 조회하는 구조가 표준입니다. 실시간 그래프는 MQTT를 직구독, 과거 차트는 REST API로 분리하면 양쪽 모두 단순해집니다.
다음 단계
MQTT가 손에 익으면 자연스럽게 다음 도구들이 따라옵니다. Sparkplug B는 산업 IoT용 페이로드 표준이고, MQTT 5의 사용자 프로퍼티는 메시지에 메타데이터를 붙일 수 있어 라우팅 로직을 줄여줍니다. 메시지가 폭증하면 EMQX의 클러스터링이나 NATS·Kafka 같은 옵션을 검토할 시점입니다. 출발은 가볍게, 확장은 필요할 때.