MQTT 프로토콜과 발행-구독 메시징 모델 이해하기

들어가며: MQTT는 왜 등장했는가
MQTT(Message Queuing Telemetry Transport)는 1999년 IBM의 Andy Stanford-Clark와 Arcom의 Arlen Nipper가 석유 파이프라인 원격 감시 시스템을 위해 설계한 경량 메시징 프로토콜입니다. 당시 요구 조건은 명확했습니다. 위성 회선처럼 대역폭이 좁고 지연이 크며, 배터리로 동작하는 기기에서도 안정적으로 데이터를 주고받아야 했습니다. 이 배경 때문에 MQTT는 처음부터 최소한의 헤더, 단순한 상태 머신, 낮은 전력 소비를 목표로 설계되었고, 지금은 OASIS 표준(MQTT 3.1.1, 5.0)으로 자리 잡아 IoT 센서, 스마트홈, 커넥티드카, 산업 자동화(IIoT) 전반의 사실상 표준 프로토콜이 되었습니다.
REST API에 익숙한 개발자가 MQTT를 처음 접하면 “왜 굳이 또 다른 프로토콜이 필요한가”라는 의문이 들 수 있습니다. 핵심은 통신 모델 자체가 다르다는 점입니다. HTTP는 클라이언트가 서버에 요청을 보내고 응답을 받는 요청-응답(Request-Response) 모델인 반면, MQTT는 메시지를 보내는 쪽과 받는 쪽이 서로의 존재를 몰라도 되는 발행-구독(Publish-Subscribe, Pub/Sub) 모델을 씁니다. 이 차이가 IoT 환경에서 왜 중요한지, 지금부터 하나씩 살펴보겠습니다.
발행-구독(Pub/Sub) 메시징 모델의 동작 원리
Pub/Sub 모델에는 세 가지 주체가 있습니다. 메시지를 만들어 보내는 Publisher(발행자), 관심 있는 메시지를 받아 처리하는 Subscriber(구독자), 그리고 둘 사이를 중개하는 Broker(브로커)입니다. 여기서 가장 중요한 특징은 Publisher와 Subscriber가 서로를 전혀 알지 못한다는 점입니다. Publisher는 특정 Subscriber를 향해 메시지를 보내지 않고, 그저 “이 주제(Topic)로 메시지를 발행한다”고만 브로커에 알립니다. Subscriber도 마찬가지로 “이 Topic을 구독하겠다”고 브로커에 등록해 두면, 해당 Topic으로 발행되는 모든 메시지를 자동으로 전달받습니다.
이런 구조를 시간적·공간적 결합 해제(decoupling)라고 부릅니다. 공간적으로는 Publisher와 Subscriber가 서로의 IP 주소나 존재 여부를 알 필요가 없고, 시간적으로는 Publisher가 메시지를 보낼 때 Subscriber가 반드시 온라인 상태일 필요가 없습니다(뒤에서 설명할 Retained Message와 세션 지속 기능이 이를 보완합니다). 예를 들어 100개의 온도 센서가 있다고 가정하면, 각 센서는 sensors/room1/temperature 같은 Topic으로 값을 발행하기만 하면 됩니다. 이 값을 대시보드, 알람 시스템, 데이터 로거가 각각 독립적으로 구독해 사용할 수 있고, 센서 코드는 이들이 몇 개인지, 어디에 있는지 전혀 신경 쓸 필요가 없습니다. 새로운 구독자가 추가되어도 Publisher 쪽 코드는 단 한 줄도 바뀌지 않습니다.
sequenceDiagram participant S as 온도 센서(Publisher) participant Br as Broker participant Dash as 대시보드(Subscriber) participant Alarm as 알람 시스템(Subscriber) Dash->>Br: SUBSCRIBE sensors/room1/temperature Alarm->>Br: SUBSCRIBE sensors/room1/temperature S->>Br: PUBLISH sensors/room1/temperature = 24°C Br-->>Dash: PUBLISH 24°C Br-->>Alarm: PUBLISH 24°C Note over S,Alarm: Publisher는 Dash·Alarm이 몇 개인지, 존재하는지조차 모른다
핵심 개념: Broker, Topic, Publisher, Subscriber
Broker(브로커)
브로커는 MQTT 통신의 중심에 있는 서버 프로세스입니다. 모든 클라이언트(Publisher든 Subscriber든 하나의 TCP 연결을 맺으면 클라이언트로 취급됩니다)는 브로커와만 연결하며, 브로커가 어떤 클라이언트가 어떤 Topic을 구독하고 있는지 추적하고, 발행된 메시지를 해당 구독자들에게 라우팅합니다. Mosquitto, EMQX, HiveMQ가 대표적인 오픈소스/상용 브로커이고, AWS IoT Core, Azure IoT Hub 같은 클라우드 관리형 서비스도 널리 쓰입니다.
Topic(토픽)
Topic은 메시지를 분류하는 문자열 경로로, 슬래시(/)로 계층을 구분합니다. 예를 들어 home/livingroom/temperature, factory/line1/machine3/status처럼 실제 물리적 구조나 논리적 분류를 그대로 반영하는 것이 일반적인 관례입니다. 특히 유용한 것은 와일드카드 구독입니다.
+(단일 레벨 와일드카드): 한 단계만 임의의 값을 허용합니다.home/+/temperature로 구독하면home/livingroom/temperature와home/bedroom/temperature를 모두 받습니다.#(다중 레벨 와일드카드): 해당 위치 이후의 모든 하위 레벨을 포함합니다.home/#로 구독하면home아래의 모든 Topic 메시지를 받습니다. 반드시 Topic 필터의 마지막에만 쓸 수 있습니다.
와일드카드 덕분에 대시보드 하나가 factory/#처럼 넓은 범위를 구독해 전체 현황을 모니터링하면서, 개별 알람 로직은 factory/line1/machine3/status처럼 좁은 범위만 구독하는 식의 유연한 설계가 가능합니다.
Publisher와 Subscriber
하나의 클라이언트가 동시에 Publisher와 Subscriber 역할을 겸할 수 있습니다. 예를 들어 스마트 온도조절기는 현재 온도를 home/thermostat/temperature에 발행(Publish)하는 동시에, 사용자 앱이 보낸 목표 온도 명령을 home/thermostat/set에서 구독(Subscribe)해서 받아들입니다. 이렇게 하나의 연결로 양방향 통신이 가능하다는 점도 MQTT가 IoT 기기에 적합한 이유 중 하나입니다.
QoS(Quality of Service) 0/1/2 레벨
MQTT의 QoS는 메시지가 얼마나 확실하게 전달되는지를 발행자와 브로커, 브로커와 구독자 사이의 각 홉(hop)마다 개별적으로 보장하는 계약입니다. 즉 발행자가 QoS 1로 보내도, 브로커가 특정 구독자에게 전달할 때는 그 구독자가 구독 시 지정한 QoS와 발행 시 QoS 중 낮은 쪽으로 다시 협상됩니다.
| QoS | 이름 | 보장 수준 | 전달 절차 | 적합한 상황 |
|---|---|---|---|---|
| 0 | At most once | 최대 1회, 유실 가능 | PUBLISH 한 번 전송 후 응답 없이 종료 | 갱신 주기가 짧아 유실돼도 다음 값이 곧 오는 센서 스트림 |
| 1 | At least once | 최소 1회, 중복 가능 | PUBLISH → PUBACK, 응답이 없으면 재전송 | 중복 처리가 가능한 이벤트·알림·명령 |
| 2 | Exactly once | 정확히 1회 | PUBLISH → PUBREC → PUBREL → PUBCOMP (4-way 핸드셰이크) | 과금·결제·설비 제어 명령처럼 중복이 치명적인 경우 |
QoS 0은 TCP 위에서 한 번 쏘고 끝이라 가장 빠르고 가볍지만, 네트워크 단절 시점에 발행된 메시지는 그대로 사라질 수 있습니다. QoS 1은 확인 응답(PUBACK)을 기다렸다가 응답이 없으면 재전송하므로 유실을 방지하지만, 그 대가로 동일 메시지가 중복 수신될 수 있어 구독자 쪽에서 멱등 처리가 필요할 수 있습니다. QoS 2는 4단계 핸드셰이크로 중복과 유실을 모두 막지만, 왕복이 늘어나는 만큼 지연과 브로커의 상태 관리 부담이 커집니다. 실무에서는 “정말 정확히 한 번이 필요한 메시지가 무엇인가”를 먼저 좁히고, 대부분의 트래픽은 QoS 0이나 1로 처리하는 편이 전체 시스템 처리량에 유리합니다.
와이어 위에서 보면: 패킷과 연결 수명주기
MQTT는 TCP(보통 1883번 포트, TLS는 8883번) 위에서 제어 패킷(control packet)을 주고받는 프로토콜입니다. 앞에서 본 발행·구독도 실제로는 정해진 종류의 패킷이 순서대로 오가는 것이고, 문제가 생겼을 때 브로커 로그나 클라이언트 트레이스에 찍히는 것도 이 패킷 이름입니다.
| 패킷 | 방향 | 하는 일 |
|---|---|---|
CONNECT / CONNACK | 클라이언트 → 브로커 / 반대 | 접속 요청(클라이언트 ID, Keep Alive, Clean Session, Last Will, 인증 정보)과 결과 |
PUBLISH | 양방향 | 메시지 전달. 발행할 때도, 브로커가 구독자에게 전달할 때도 같은 패킷 |
PUBACK | 양방향 | QoS 1 메시지 수신 확인 |
PUBREC / PUBREL / PUBCOMP | 양방향 | QoS 2의 단계별 확인 |
SUBSCRIBE / SUBACK | 클라이언트 → 브로커 / 반대 | 구독 요청과, 토픽 필터마다 허용된 QoS(또는 거부) |
UNSUBSCRIBE / UNSUBACK | 클라이언트 → 브로커 / 반대 | 구독 해제 |
PINGREQ / PINGRESP | 클라이언트 → 브로커 / 반대 | 보낼 메시지가 없을 때 연결이 살아 있음을 확인 |
DISCONNECT | 클라이언트 → 브로커 (5.0에서는 양방향) | 정상 종료. 이 패킷으로 끊으면 Last Will이 발행되지 않음 |
AUTH | 양방향 (5.0만) | 챌린지-응답 방식의 확장 인증 |
모든 패킷은 고정 헤더로 시작합니다. 첫 바이트의 상위 4비트가 패킷 종류이고, 하위 4비트는 플래그입니다. PUBLISH라면 이 플래그에 중복 전송 표시(DUP), QoS(2비트), RETAIN이 들어갑니다. 그 뒤에는 나머지 패킷의 길이가 1~4바이트 가변 길이 정수로 옵니다. 그래서 가장 작은 패킷(PINGREQ)은 2바이트이고, 패킷 하나의 최대 크기는 268,435,455바이트(약 256MB)입니다. 앞의 비교표에서 “헤더 오버헤드 최소 2바이트”라고 한 것이 이 구조입니다.
고정 헤더 뒤에는 패킷 종류에 따라 가변 헤더와 payload가 붙습니다. PUBLISH의 가변 헤더에는 토픽 이름과(QoS 1·2라면) 패킷 ID가 들어가고, 그 뒤의 payload가 애플리케이션이 실제로 보내는 내용입니다.
payload: 브로커가 해석하지 않는 바이트
payload는 메시지의 내용물입니다. 택배로 비유하면 고정·가변 헤더는 송장이고 payload는 상자 안의 물건입니다. 브로커는 송장(토픽, QoS, retain)만 보고 배달할 뿐 payload의 내용은 열어 보지 않으므로, payload는 JSON이든 Protocol Buffers든 센서 값 4바이트든 아무 바이트열이나 될 수 있습니다. 형식은 발행자와 구독자 사이의 약속으로 정해야 하고, 그 약속을 표준으로 만든 것이 산업용 Sparkplug B나 물류 로봇용 VDA5050 같은 규격입니다(VDA5050은 “UTF-8 JSON, 메시지 종류별 JSON 스키마”로 정했습니다). 알아 둘 성질은 세 가지입니다.
- payload는 문자열이 아니라 바이트 배열입니다. C 라이브러리에서는 끝에 NUL 문자가 붙지 않고 길이를 따로 받으므로, 문자열 함수에 그대로 넘기면 버퍼 끝을 넘어 읽습니다.
- 명세상 한도(약 256MB)보다 브로커 설정이 먼저 걸립니다. Mosquitto 2.x는
max_packet_size로 제한할 수 있고, 클라우드 관리형 브로커는 대개 KB 단위의 한도를 둡니다. 큰 파일은 MQTT로 보내지 말고 다운로드 주소만 보내는 것이 일반적입니다. - retain을 켜고 빈 payload를 보내면 그 토픽의 retained 메시지가 삭제됩니다. 정리할 때 쓰는 기능이지만, 실수로 빈 값을 retain으로 보내면 새 구독자가 받아야 할 마지막 상태가 사라집니다.
연결 하나의 일생
sequenceDiagram participant C as 클라이언트 participant B as 브로커 C->>B: CONNECT (clientId, keepAlive=15, cleanSession, will) B-->>C: CONNACK (return code 0 = 수락) C->>B: SUBSCRIBE (devices/+/cmd, QoS 1) B-->>C: SUBACK (허용 QoS 1) C->>B: PUBLISH (devices/42/status, QoS 1, packetId=7) B-->>C: PUBACK (packetId=7) Note over C,B: 15초 동안 보낼 것이 없으면 C->>B: PINGREQ B-->>C: PINGRESP C->>B: DISCONNECT (정상 종료, Will 발행 안 됨)
CONNACK의 결과 코드는 접속 문제를 진단할 때 가장 먼저 볼 값입니다. MQTT 3.1.1에서는 0이 수락이고, 1은 지원하지 않는 프로토콜 버전, 2는 클라이언트 ID 거부, 3은 서버 사용 불가, 4는 사용자 이름·비밀번호 오류, 5는 권한 없음입니다. MQTT 5.0은 이를 더 자세한 reason code(예: 0x87 Not authorized)로 바꾸고, CONNACK뿐 아니라 거의 모든 확인 패킷에 결과 코드를 담습니다.
클라이언트 ID에도 규칙이 있습니다. 3.1.1 명세는 브로커가 영숫자로 된 1~23바이트 ID를 반드시 받아들이게 하고, 그보다 길거나 다른 문자가 섞인 ID는 받을지 말지를 브로커에 맡깁니다. 대부분의 브로커는 더 긴 ID도 받지만, ID 형식을 제한하는 환경을 만나면 identifier rejected(코드 2)가 납니다. 빈 ID를 보내면 브로커가 임의 ID를 붙여 주는데, 이때는 반드시 Clean Session으로 접속해야 합니다(세션을 이어 갈 ID가 없기 때문입니다).
토픽 규칙을 조금 더 정확하게
와일드카드는 앞에서 소개했지만, 실제로 구독이 안 맞는 문제는 대개 세부 규칙에서 생깁니다. 3.1.1 명세의 규칙을 정리하면 다음과 같습니다.
- 대소문자를 구분합니다.
Home/temp와home/temp는 다른 토픽입니다. - 빈 레벨도 레벨입니다.
/finance는 첫 레벨이 빈 문자열인 두 레벨 토픽이라+/+와/+에는 맞지만+에는 맞지 않습니다. 토픽을/로 시작하는 습관은 이런 혼란만 늘리므로 피하는 편이 좋습니다. +는 정확히 한 레벨입니다.sport/+는sport/tennis에는 맞지만sport/tennis/player1에는 맞지 않습니다.sport+처럼 다른 글자와 붙여 쓸 수 없습니다.#는 부모 레벨 자체도 포함합니다.sport/#는sport/tennis/player1뿐 아니라sport에도 맞습니다. 반드시 맨 끝에만 올 수 있습니다.- 와일드카드는 구독에만 씁니다. 발행하는 토픽 이름에
+나#를 넣을 수 없습니다. $로 시작하는 토픽은 와일드카드로 시작하는 필터에 맞지 않습니다. 브로커 내부 통계를 내는$SYS/...토픽이#구독에 섞여 들어오지 않게 하려는 규칙이라, 이 토픽들을 보려면$SYS/#처럼 명시적으로 구독해야 합니다.
공유 구독(shared subscription)도 알아 둘 만합니다. 같은 토픽을 여러 구독자가 구독하면 보통 모두가 사본을 받지만, $share/workers/jobs/+처럼 구독하면 같은 그룹(workers)의 구독자 중 하나에게만 메시지가 전달됩니다. 처리 서버를 여러 대로 늘려 부하를 나눌 때 쓰는 기능으로, 5.0에서 표준이 되었고 많은 브로커가 3.1.1 클라이언트에도 제공합니다.
QoS가 실제로 보장하는 것과 보장하지 않는 것
앞의 QoS 표에 몇 가지를 더하면 운영에서 마주치는 동작을 대부분 설명할 수 있습니다.
패킷 ID와 DUP 플래그. QoS 1·2 메시지에는 연결 안에서 고유한 패킷 ID가 붙고, 확인 패킷(PUBACK 등)은 그 ID로 어떤 메시지에 대한 응답인지 알립니다. 재전송할 때는 같은 ID에 DUP 플래그를 켜서 보냅니다. 받는 쪽이 DUP를 보고 중복을 걸러 주지는 않으므로, QoS 1 메시지의 중복 처리는 여전히 애플리케이션 몫입니다.
재전송 시점. 많은 사람이 “QoS 1은 ack가 안 오면 몇 초 뒤에 다시 보낸다”고 생각하지만, 3.1.1 명세가 재전송을 의무로 정한 경우는 하나뿐입니다. Clean Session을 끈 채 재접속했을 때, 확인받지 못한 PUBLISH와 PUBREL을 원래 패킷 ID로 다시 보내는 것입니다. 연결이 살아 있는 동안에는 TCP가 전송을 보장하므로 타이머 재전송이 필요 없다는 판단입니다(5.0은 아예 재접속 시에만 재전송하도록 정했습니다). 따라서 Clean Session으로 접속하는 클라이언트라면 연결이 끊기는 순간 전달 중이던 QoS 1 메시지가 사라질 수 있습니다. QoS 1을 쓰면서 Clean Session을 켜면 “최소 한 번”이라는 기대가 끊김 상황에서는 지켜지지 않는다는 뜻입니다.
QoS는 구간마다 따로입니다. 발행자→브로커와 브로커→구독자는 별개의 전달이고, 구독자가 받는 QoS는 발행 QoS와 구독 QoS 중 낮은 쪽입니다. 발행자가 QoS 2로 보내도 구독자가 QoS 0으로 구독했다면 그 구독자에게는 QoS 0으로 갑니다.
순서. 3.1.1 명세는 브로커가 기본적으로 각 토픽을 “순서 있는 토픽”으로 다루도록 정합니다. 한 발행자가 같은 토픽에 같은 QoS로 보낸 메시지는 보낸 순서대로 전달된다는 뜻입니다. 반대로 서로 다른 토픽 사이, 서로 다른 발행자 사이의 순서는 보장하지 않으므로 “명령 토픽과 상태 토픽의 도착 순서”에 기대는 설계는 피해야 합니다.
오프라인 구독자. 세션을 유지한 구독자가 끊겨 있는 동안 브로커가 보관해야 하는 것은 QoS 1·2 메시지이고, QoS 0 메시지 보관은 명세상 선택 사항입니다. Mosquitto도 기본값으로는 QoS 0 메시지를 쌓아 두지 않습니다. 또 Retained 메시지는 세션 상태와 별개라서 세션이 끝나도 지워지지 않습니다.
MQTT 5.0에서 추가된 것
5.0은 3.1.1과 패킷 구조는 같지만, 대부분의 패킷에 속성(property)을 붙일 수 있게 하면서 운영에 필요한 기능을 많이 더했습니다. 클라이언트와 브로커가 모두 5.0을 지원해야 쓸 수 있으므로, 쓰려는 기능이 있다면 양쪽 버전부터 확인해야 합니다.
| 기능 | 설명 |
|---|---|
| Reason code | CONNACK, PUBACK, SUBACK, DISCONNECT 등에 실패 사유가 담김. 3.1.1에서는 구독 거부나 발행 실패 이유를 알기 어려웠음 |
| Session Expiry Interval | Clean Session의 참/거짓 대신 세션을 몇 초 유지할지 지정 |
| Message Expiry Interval | 메시지별 유효 시간. 오래된 명령이 늦게 전달되는 것을 막음 |
| Will Delay Interval | Last Will 발행을 지정한 시간만큼 늦춤. 짧은 재접속에 offline이 깜빡이는 것을 방지 |
| Response Topic, Correlation Data | 요청-응답 패턴을 표준 방식으로 구현 |
| User Properties | HTTP 헤더처럼 키-값 메타데이터를 메시지에 붙임 |
| Topic Alias | 긴 토픽 이름을 숫자로 줄여 대역폭 절약 |
| Shared Subscription | $share/그룹/필터로 구독자 사이 부하 분산 (표준화) |
| 구독 옵션 | No Local(자기가 보낸 메시지는 받지 않음), Retain As Published, Retain Handling(구독할 때 retained 메시지를 받을지) |
| Receive Maximum, Maximum Packet Size | 흐름 제어와 패킷 크기 협상 |
| Payload Format Indicator, Content Type | payload가 UTF-8 텍스트인지, 어떤 형식인지 표시 |
VDA5050 2.x처럼 MQTT 3.1.1을 전제로 만들어진 규격도 많으므로, 5.0 기능을 쓰는 것이 항상 정답은 아닙니다. 기존 규격이 3.1.1 기준이라면 그 위에서 설계된 규칙(예: 상태 메시지 재전송으로 유실 보완)을 따르는 편이 상호 운용에 안전합니다.
HTTP·Kafka·RabbitMQ와 비교했을 때 MQTT의 위치
| 항목 | MQTT | HTTP | Kafka | RabbitMQ |
|---|---|---|---|---|
| 통신 모델 | Pub/Sub | 요청-응답 | Pub/Sub(로그 기반) | Pub/Sub·큐(AMQP) |
| 헤더 오버헤드 | 매우 작음(최소 2바이트) | 큼(텍스트 헤더) | 중간 | 중간 |
| 연결 유지 | 상시 연결(Keep Alive) | 요청마다 또는 커넥션 풀 | 상시 연결 | 상시 연결 |
| 대역폭·전력 최적화 | 매우 적합 | 상대적으로 무거움 | 고성능 서버 환경 전제 | 서버 환경 전제 |
| 저장·재처리 | 제한적(Retained 1개) | 없음 | 강력함(로그 보존) | 큐 기반 보존 |
| 주된 용도 | 기기 원격 측정·제어 | API 통신 | 대용량 이벤트 스트리밍 | 작업 큐·서비스 간 메시징 |
MQTT의 가장 큰 강점은 패킷 크기와 상태 관리 비용을 극단적으로 줄였다는 점입니다. 고정 헤더가 단 2바이트부터 시작하고, TCP 연결 하나를 계속 유지하기 때문에 매 메시지마다 새로 커넥션을 맺고 TLS 핸드셰이크를 반복하는 HTTP보다 배터리로 동작하는 임베디드 기기에 훨씬 유리합니다. 반면 Kafka는 대용량 이벤트를 디스크에 로그로 영속화하고 파티션 단위로 재처리·재생(replay)할 수 있다는 점에서 압도적이지만, 이는 브로커가 충분한 컴퓨팅·스토리지 자원을 갖춘 서버 환경을 전제로 합니다. 마이크로컨트롤러나 저전력 센서에는 Kafka 클라이언트 자체를 올리기 어려운 경우가 많습니다. RabbitMQ는 Exchange 기반의 유연한 라우팅과 신뢰성 있는 큐를 제공하지만, AMQP 프로토콜 오버헤드와 클라이언트 구현 복잡도가 MQTT보다 높습니다. 정리하면 “수천~수백만 개의 저전력·저대역폭 기기가 짧은 상태값을 자주 보내는” 시나리오에는 MQTT가, “대용량 이벤트를 안정적인 서버 인프라 사이에서 안전하게 처리·재처리”해야 하는 시나리오에는 Kafka나 RabbitMQ가 더 적합합니다.
브로커 준비: Mosquitto 실행하기
로컬에서 실습하려면 Docker로 Eclipse Mosquitto를 가장 빠르게 띄울 수 있습니다.
docker run -it --rm --name mosquitto \
-p 1883:1883 \
eclipse-mosquitto:2
기본 설정에서는 익명 접속이 막혀 있을 수 있으므로, 로컬 실습용 설정 파일을 하나 마운트해 두면 편리합니다.
# mosquitto.conf
listener 1883
allow_anonymous true
persistence true
persistence_location /mosquitto/data/
docker run -it --rm --name mosquitto \
-p 1883:1883 \
-v $(pwd)/mosquitto.conf:/mosquitto/config/mosquitto.conf \
eclipse-mosquitto:2
프로덕션 환경에서는 반드시 사용자 인증(username/password 또는 클라이언트 인증서)과 TLS(포트 8883)를 활성화해야 합니다. allow_anonymous true는 로컬 개발 전용 설정입니다.
운영 브로커의 보안 설정
Mosquitto 2.0부터는 기본 설정이 보수적으로 바뀌어, listener를 따로 적지 않으면 로컬 접속만 받고 익명 접속도 허용하지 않습니다. 운영 환경에서 챙길 것은 다음과 같습니다.
- TLS:
listener 8883에cafile·certfile·keyfile을 지정합니다. 사설망이라도 비밀번호가 평문으로 오가지 않게 하려면 TLS가 필요합니다. - 인증:
password_file로 사용자 이름·비밀번호를 관리하거나,require_certificate true와use_identity_as_username true로 클라이언트 인증서를 신원으로 씁니다. - ACL:
acl_file로 사용자·클라이언트마다 읽기·쓰기 가능한 토픽을 제한합니다.pattern readwrite devices/%u/#처럼 쓰면 각 기기가 자기 토픽에만 접근하게 할 수 있습니다. ACL에 막힌 발행은 3.1.1에서 클라이언트에 오류로 알려지지 않고 조용히 버려지므로, “보냈는데 안 온다”는 문제는 브로커 로그부터 봐야 합니다.
실전 예제: Python paho-mqtt
Python의 paho-mqtt는 가장 널리 쓰이는 MQTT 클라이언트 라이브러리입니다. 센서 데이터를 발행하는 쪽과 이를 구독해 처리하는 쪽을 나눠서 봅니다.
pip install paho-mqtt
Publisher: 온도 센서 시뮬레이터
# publisher.py
import time
import json
import random
import paho.mqtt.client as mqtt
BROKER_HOST = "localhost"
BROKER_PORT = 1883
TOPIC = "home/livingroom/temperature"
def on_connect(client, userdata, flags, reason_code, properties=None):
if reason_code == 0:
print("브로커 연결 성공")
else:
print(f"연결 실패, reason_code={reason_code}")
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="sensor-livingroom-01")
client.on_connect = on_connect
# Keep Alive 60초: 60초 이내에 PINGREQ/PUBLISH 등 아무 패킷도 없으면
# 브로커가 연결이 끊겼다고 판단합니다.
client.connect(BROKER_HOST, BROKER_PORT, keepalive=60)
client.loop_start() # 백그라운드 스레드에서 네트워크 루프 실행
try:
while True:
payload = json.dumps({
"temperature": round(random.uniform(18.0, 26.0), 1),
"timestamp": time.time(),
})
# qos=1: 브로커까지는 최소 1회 도달을 보장
client.publish(TOPIC, payload, qos=1, retain=True)
print(f"발행: {TOPIC} -> {payload}")
time.sleep(5)
except KeyboardInterrupt:
client.loop_stop()
client.disconnect()
client.loop_start()는 백그라운드 스레드를 하나 열어 네트워크 송수신과 Keep Alive용 PINGREQ 전송을 자동으로 처리해 줍니다. 이 루프를 직접 관리하지 않고 loop_forever()를 쓰는 방식도 있지만, 위처럼 메인 스레드에서 다른 작업(센서 값 읽기 등)을 병행해야 할 때는 loop_start()가 더 실용적입니다. retain=True를 주면 이 메시지가 Retained Message로 브로커에 저장되어, 이후 새로 구독하는 클라이언트가 즉시 마지막 온도값을 받을 수 있습니다.
Subscriber: 대시보드 백엔드
# subscriber.py
import json
import paho.mqtt.client as mqtt
BROKER_HOST = "localhost"
BROKER_PORT = 1883
TOPIC_FILTER = "home/+/temperature" # 모든 방의 온도를 구독
def on_connect(client, userdata, flags, reason_code, properties=None):
print("브로커 연결, 구독 시작")
# QoS 1로 구독: 브로커가 이 클라이언트에게 전달할 때도 최소 1회 보장
client.subscribe(TOPIC_FILTER, qos=1)
def on_message(client, userdata, msg):
data = json.loads(msg.payload.decode())
print(f"[{msg.topic}] 온도={data['temperature']}°C (retain={msg.retain})")
# 여기서 DB 저장, 알람 판정, 웹소켓 브로드캐스트 등을 수행합니다.
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="dashboard-backend")
client.on_connect = on_connect
client.on_message = on_message
client.connect(BROKER_HOST, BROKER_PORT, keepalive=60)
client.loop_forever() # 메인 스레드를 이 루프에 맡김 (재연결도 자동 처리)
구독 Topic으로 와일드카드 +를 사용해 home/livingroom/temperature, home/bedroom/temperature 등 방 이름과 무관하게 모든 온도 데이터를 한 번의 구독으로 받아냅니다. on_message 콜백의 msg.retain 값을 확인하면, 이 메시지가 새로 발행된 값인지 아니면 접속 직후 브로커가 보내준 Retained Message인지 구분할 수 있습니다.
실전 예제: Node.js mqtt.js
백엔드가 Node.js 기반이거나, 웹 대시보드에서 WebSocket으로 MQTT에 직접 붙어야 한다면 mqtt.js가 표준적인 선택입니다.
npm install mqtt
Publisher: 기기 상태 발행
// publisher.ts
import mqtt from 'mqtt';
const client = mqtt.connect('mqtt://localhost:1883', {
clientId: 'gateway-device-01',
keepalive: 60,
clean: true, // true면 Clean Session(재접속 시 이전 구독·큐 초기화)
// Last Will and Testament: 비정상 종료 시 브로커가 대신 발행
will: {
topic: 'devices/gateway-01/status',
payload: JSON.stringify({ online: false, reason: 'unexpected_disconnect' }),
qos: 1,
retain: true,
},
});
client.on('connect', () => {
console.log('브로커 연결 성공');
// 정상 연결되면 online 상태를 Retained로 발행
client.publish(
'devices/gateway-01/status',
JSON.stringify({ online: true }),
{ qos: 1, retain: true }
);
});
setInterval(() => {
const payload = JSON.stringify({
cpuUsage: Math.random() * 100,
timestamp: Date.now(),
});
client.publish('devices/gateway-01/metrics', payload, { qos: 0 });
}, 3000);
Last Will and Testament(LWT)는 연결 시점에 미리 브로커에 등록해 두는 “유언” 메시지입니다. 클라이언트가 정상적으로 DISCONNECT를 보내고 종료하면 이 메시지는 발행되지 않지만, Keep Alive 시간 안에 PINGREQ도 오지 않고 TCP 연결이 그냥 끊어지는 등 비정상 종료가 감지되면 브로커가 대신 이 메시지를 발행합니다. 위 예제처럼 기기의 온라인/오프라인 상태를 Retained + LWT 조합으로 관리하면, 대시보드는 별도의 헬스체크 폴링 없이도 기기가 갑자기 꺼졌는지를 실시간으로 알 수 있습니다.
Subscriber: 상태 모니터링
// subscriber.ts
import mqtt from 'mqtt';
const client = mqtt.connect('mqtt://localhost:1883', {
clientId: 'monitoring-service',
});
client.on('connect', () => {
// 모든 기기의 status와 metrics를 다중 레벨 와일드카드로 구독
client.subscribe('devices/+/status', { qos: 1 });
client.subscribe('devices/#', { qos: 0 });
});
client.on('message', (topic, payload) => {
const data = JSON.parse(payload.toString());
if (topic.endsWith('/status')) {
console.log(`[상태] ${topic}:`, data);
if (data.online === false) {
// 알람 발송, 인시던트 티켓 생성 등
console.warn(`경고: ${topic} 오프라인 감지`);
}
} else {
console.log(`[메트릭] ${topic}:`, data);
}
});
client.on('reconnect', () => {
console.log('재연결 시도 중...');
});
mqtt.js는 기본적으로 연결이 끊기면 자동 재연결을 시도합니다. clean: false(Persistent Session)로 연결하면, 구독자가 오프라인이었던 동안 QoS 1·2로 발행된 메시지를 브로커가 세션 큐에 보관했다가 재접속 시 전달해 줍니다. 반대로 clean: true(Clean Session)는 매번 깨끗한 상태로 시작하므로 구독 정보와 큐가 초기화됩니다. 배터리 소모를 줄이려고 자주 슬립하는 기기는 Persistent Session을, 상태를 매번 새로 받아도 무방한 대시보드류 클라이언트는 Clean Session을 선택하는 것이 일반적입니다.
실무에서 중요한 세부 기능
Keep Alive
클라이언트는 연결 시 Keep Alive 시간(초 단위)을 브로커에 알립니다. 이 시간의 1.5배 동안 아무 패킷도 오가지 않으면 브로커는 연결이 끊긴 것으로 간주하고 등록된 Last Will을 발행합니다. 값이 너무 짧으면 불필요한 PINGREQ 트래픽이 늘어 배터리를 소모하고, 너무 길면 실제 단절을 늦게 감지합니다. 실제 네트워크 환경(모바일 데이터, 위성, NAT 타임아웃)에 맞춰 30~120초 사이에서 조정하는 경우가 많습니다.
Retained Message
Topic마다 브로커는 가장 최근에 retain=true로 발행된 메시지 단 하나만 저장합니다. 새로운 구독자가 해당 Topic을 구독하는 순간, 브로커는 과거 발행 이력과 무관하게 이 저장된 값을 즉시 전달합니다. “현재 온도가 몇 도인가”, “기기가 지금 온라인인가” 같은 최신 상태 조회에 최적화된 기능이며, 이벤트 로그가 아니라 상태(state)를 표현하는 Topic에 사용하는 것이 원칙입니다. 빈 페이로드로 발행하면 Retained Message를 삭제할 수 있습니다.
Last Will and Testament(LWT)
앞서 예제에서 설명했듯, 클라이언트가 예기치 않게 끊겼을 때 브로커가 대신 발행해 주는 메시지입니다. LWT와 Retained를 함께 쓰면 “기기가 살아있을 때는 online, 죽으면 자동으로 offline”이라는 상태 전이를 애플리케이션 코드 없이 프로토콜 레벨에서 구현할 수 있습니다.
sequenceDiagram participant Dev as 기기(Publisher) participant Br as Broker participant Mon as 모니터링(Subscriber) Dev->>Br: CONNECT (Will: devices/x/status = offline) Dev->>Br: PUBLISH devices/x/status = online (retain) Br-->>Mon: status = online Note over Dev,Br: 네트워크 단절 · Keep Alive 초과 Br-->>Mon: status = offline (Broker가 대신 발행한 LWT)
Clean Session / Persistent Session (MQTT 5.0에서는 Clean Start + Session Expiry Interval)
세션을 유지할지 여부를 결정합니다. Persistent Session에서는 구독 정보뿐 아니라 오프라인 중 QoS 1·2로 발행된 메시지도 브로커가 보관했다가 재접속 시 전달합니다. MQTT 5.0은 이를 Session Expiry Interval로 세분화해, 세션을 얼마나 오래 유지할지 명시적으로 지정할 수 있습니다.
실제 사용 사례
- IoT 센서 네트워크: 온도·습도·조도 센서 수백~수천 개가 각자의 Topic에 값을 발행하고, 수집 서버가 와일드카드로 한꺼번에 구독해 시계열 DB(InfluxDB, TimescaleDB 등)에 적재하는 구성이 표준적입니다.
- 홈 오토메이션: Home Assistant, openHAB 같은 오픈소스 홈 오토메이션 플랫폼은 MQTT를 핵심 통합 계층으로 사용합니다. 조명·플러그·온도조절기가 각자 상태를 발행하고, 자동화 규칙 엔진이 이를 구독해 조건에 맞는 명령을 다시 발행하는 식으로 동작합니다.
- 커넥티드카·차량 텔레메트리: 위치, 배터리 잔량, 진단 코드(DTC)를 이동통신망을 통해 저대역폭으로 서버에 전송할 때 MQTT가 자주 쓰입니다. 연결이 자주 끊기는 환경 특성상 Persistent Session과 QoS 1 조합이 흔합니다.
- 산업 자동화(IIoT): 공장의 PLC·센서 데이터를 SCADA 시스템과 연동할 때, Sparkplug B 같은 MQTT 기반 페이로드 규격을 얹어 표준화된 상태 보고를 구현하는 사례가 늘고 있습니다.
- 모바일 푸시·채팅: 배터리 소모를 최소화해야 하는 모바일 앱의 백그라운드 알림 채널로 MQTT를 채택하는 경우도 있습니다(대표적으로 Facebook Messenger가 초기에 MQTT를 활용한 사례가 알려져 있습니다).
모범 사례와 트러블슈팅
Topic은 너무 잘게 쪼개도, 너무 뭉뚱그려도 문제가 됩니다. 계층이 지나치게 깊으면 와일드카드 구독의 효율이 떨어지고, 반대로 하나의 Topic에 여러 종류의 데이터를 섞어 넣으면 구독자가 매번 페이로드를 파싱해 분기해야 합니다. {도메인}/{기기ID}/{속성} 같은 일관된 규칙을 팀 차원에서 정해 두는 편이 유지보수에 유리합니다.
| 증상 | 흔한 원인 | 대응 |
|---|---|---|
| 메시지가 간헐적으로 유실됨 | QoS 0 사용 중 네트워크 불안정 | 중요 메시지는 QoS 1로 상향, 재전송 로직 확인 |
| 같은 메시지가 중복 수신됨 | QoS 1의 At-least-once 특성 | 페이로드에 메시지 ID를 넣어 구독자 측에서 멱등 처리 |
| 재접속 후 상태가 비어 있음 | Clean Session으로 접속해 이전 구독·큐 초기화 | 유지가 필요한 클라이언트는 Persistent Session(clean: false) 사용 |
| 기기가 오프라인인데도 online으로 표시됨 | Last Will 미설정 또는 Keep Alive가 너무 김 | LWT 등록, Keep Alive 값을 네트워크 환경에 맞게 단축 |
| 브로커 연결 수가 갑자기 급증·급감 | 재연결 폭주(reconnect storm) | 지수 백오프 재연결 정책 적용, 클라이언트 ID 충돌 여부 점검 |
| 특정 Topic만 메시지가 안 옴 | 와일드카드 위치 오류, ACL(접근 제어) 설정 | Topic 필터 문법 재확인, 브로커 ACL·인증 로그 확인 |
특히 클라이언트 ID 충돌은 자주 놓치는 함정입니다. 동일한 clientId로 두 개의 연결이 시도되면 MQTT 스펙상 브로커는 기존 연결을 강제로 끊고 새 연결을 받아들입니다. 기기를 재배포하거나 스케일아웃할 때 클라이언트 ID를 고정 문자열로 하드코딩해 두면, 여러 인스턴스가 서로의 연결을 끊어버리는 문제가 발생할 수 있으므로 인스턴스별로 고유한 ID(호스트명, UUID 등)를 부여해야 합니다.
참고 자료
같이 보면 좋은 글
- VDA5050 order 수명주기: 노드·엣지 시퀀스, base와 horizon, order update 이어붙이기 — MQTT 위에 JSON 메시지 규칙을 얹은 물류 로봇 규격
- RabbitMQ로 메시지 큐 구축하기
- Kafka 입문
- Socket.IO 실시간 통신
- HTTP/1.1에서 HTTP/3까지