컴퓨터공학 300 주제 시리즈의 220번째 글이다. 전체 지도는 여기.

한 줄 요약

MQTT 는 OASIS 가 표준화한 가벼운 발행·구독(publish/subscribe) 메시징 프로토콜로, 기기들이 서로를 모른 채 브로커에 토픽으로 메시지를 보내고 받으며, QoS 0·1·2 단계의 전달 보장, 보존 메시지, 유언(Will) 메시지로 불안정한 네트워크와 작은 기기를 다룬다.

왜 필요한가

집 안 센서 스무 개가 온도를 보내고, 대시보드·알림 서버·기록용 DB 가 그 값을 받는다고 하자. HTTP 로 만들면 이런 문제가 생긴다.

  • 센서마다 받는 쪽 주소를 다 알아야 한다. 받는 쪽이 하나 늘면 모든 센서 펌웨어를 고쳐야 한다.
  • 서버가 기기에 명령을 보내려면 기기가 주기적으로 물어봐야(polling) 한다. 배터리와 대역폭이 낭비된다.
  • 요청마다 HTTP 헤더가 붙어, 몇 바이트짜리 온도 값에 비해 오버헤드가 크다.
  • 연결이 자주 끊기는 환경에서 “보냈는데 받았는지 모르는” 상태를 직접 다뤄야 한다.

MQTT 는 중간에 브로커를 두고 토픽 이름으로 만나게 해서 보내는 쪽과 받는 쪽을 분리한다. 기기는 브로커와 연결 하나만 유지하면 된다. 고정 헤더가 최소 2바이트로 작고, 연결 상태 감시와 재전송 규칙이 프로토콜에 들어 있다.

핵심 개념

브로커와 토픽

 온도 센서 ──PUBLISH home/livingroom/temperature "24.5"──▶ ┌────────┐ ──▶ 대시보드 (구독: home/+/temperature)
 문 센서   ──PUBLISH home/door/state "open"──────────────▶ │ 브로커  │ ──▶ 알림 서버 (구독: home/door/#)
 서버      ──PUBLISH home/livingroom/fan/set "on"─────────▶ └────────┘ ──▶ 팬 제어기 (구독: home/+/fan/set)

발행자는 구독자가 누구인지, 몇 명인지 모른다. 모든 클라이언트는 브로커로 나가는 TCP 연결을 맺으므로, 공유기 뒤의 기기라도 포트를 열 필요가 없다. 표준 TCP 포트는 1883(평문)과 8883(TLS)이며, MQTT 5.0 명세는 이 두 포트가 IANA 에 등록되어 있다고 적는다.

토픽은 / 로 구분한 계층 문자열이다. 구독할 때는 와일드카드를 쓸 수 있다.

필터 뜻 예
+ 한 레벨 home/+/temperature → home/kitchen/temperature
# 남은 모든 레벨 (반드시 마지막) home/# → home, home/a/b/c

$ 로 시작하는 토픽(관례적으로 브로커 정보용 $SYS/...)은 맨 앞이 와일드카드인 필터로는 잡히지 않는다. 그래서 # 를 구독해도 $SYS 는 따로 구독해야 한다.

토픽 설계는 데이터베이스 스키마 설계와 비슷하다. 장소/기기/측정값 처럼 넓은 범주에서 좁은 범주로 내려가게 짜면 와일드카드로 묶기 쉽다. 명령 토픽(.../set)과 상태 토픽을 분리해 두면 순환 메시지를 피할 수 있다.

QoS: 전달 보장 단계

명세는 세 단계의 서비스 품질을 정의한다.

QoS 의미 주고받는 패킷 쓰임
0 최대 한 번 (유실 가능) PUBLISH 자주 오는 센서 값
1 최소 한 번 (중복 가능) PUBLISH → PUBACK 대부분의 이벤트, 명령
2 정확히 한 번 PUBLISH → PUBREC → PUBREL → PUBCOMP 중복이 치명적인 경우 (명세의 예: 과금)

QoS 는 발행자→브로커, 브로커→구독자 각 구간에 따로 적용된다. 구독자에게 전달되는 QoS 는 발행 QoS 와 구독 QoS 중 낮은 쪽이다. QoS 1 을 쓸 때는 같은 메시지가 두 번 올 수 있으므로 받는 쪽 처리를 멱등하게 만든다(208번 주제의 멱등성과 같은 이야기다). QoS 가 높을수록 왕복이 늘고 브로커가 들고 있어야 할 상태도 늘어난다.

세션, 보존 메시지, 유언

세션: 클라이언트가 접속할 때 이전 세션을 이어 갈지 정한다. 세션이 유지되면 브로커는 구독 정보와, 클라이언트가 끊겨 있는 동안 쌓인 QoS 1·2 메시지를 보관했다가 재접속 시 전달한다. MQTT 5.0 에서는 Clean Start 플래그와 세션 만료 시간(Session Expiry Interval)으로 이를 조절한다.

보존 메시지(retained): 발행할 때 retain 플래그를 켜면 브로커가 그 토픽의 마지막 메시지 하나를 저장해 두고, 나중에 구독하는 클라이언트에게 즉시 보낸다. “현재 상태”를 나타내는 토픽에 쓴다. 대시보드가 새로 접속하자마자 다음 측정까지 기다리지 않고 현재 값을 볼 수 있다.

유언(Will) 메시지: 접속할 때 “내가 비정상적으로 끊기면 이 토픽에 이 메시지를 발행해 달라”고 등록해 둔다. 기기가 정상 종료(DISCONNECT) 없이 사라지면 브로커가 대신 발행한다. 보존 메시지와 함께 쓰면 .../status 토픽에 online/offline 을 깔끔하게 유지할 수 있다.

Keep Alive: 클라이언트가 정한 간격 동안 보낼 것이 없으면 PINGREQ 를 보낸다. 서버가 Keep Alive 의 1.5배 시간 동안 아무 패킷도 받지 못하면 연결을 끊은 것으로 보고 처리한다. 이것이 유언 메시지의 발동 조건이 된다.

MQTT 5.0 에서 더해진 것

MQTT 5.0 은 3.1.1 에 비해 운영에 필요한 기능을 많이 더했다. 대표적인 것은 다음과 같다.

  • 응답 패킷의 이유 코드(왜 거절됐는지)
  • 사용자 속성(메시지에 붙이는 키-값 메타데이터)
  • 메시지 만료 시간
  • 공유 구독 $share/{그룹}/{필터}: 같은 그룹의 구독자들이 메시지를 나눠 받아 수평 확장
  • 요청·응답 패턴을 위한 응답 토픽과 상관 데이터

보안

MQTT 자체는 전송 암호화를 하지 않는다. TLS(8883)를 쓰고, 사용자 이름·비밀번호나 클라이언트 인증서로 인증하며, 브로커의 ACL 로 “이 클라이언트는 이 토픽만 발행·구독” 같은 권한을 건다. 인증 없이 인터넷에 열린 브로커는 누구나 모든 토픽을 읽고 쓸 수 있다는 뜻이다.

직접 해 보기

브로커 없이 MQTT 의 두 핵심 동작을 파이썬으로 흉내 낸다. 하나는 명세 4.7 절의 토픽 필터 매칭 규칙이고(예시 일부는 명세의 비규범적 예에서 가져왔다), 다른 하나는 보존 메시지와 유언 메시지의 동작이다.

def matches(topic_filter, topic):
    """MQTT 5.0 4.7 절의 와일드카드 규칙을 단순화해 구현."""
    if topic.startswith("$") and topic_filter[:1] in ("+", "#"):
        return False                          # $SYS 등은 맨 앞 와일드카드로 안 잡힌다
    f, t = topic_filter.split("/"), topic.split("/")
    for i, part in enumerate(f):
        if part == "#":
            return True                       # 남은 레벨 전부(0개 포함)
        if i >= len(t):
            return False
        if part != "+" and part != t[i]:
            return False
    return len(f) == len(t)

cases = [("sport/tennis/player1/#", "sport/tennis/player1"),
         ("sport/tennis/player1/#", "sport/tennis/player1/score/wimbledon"),
         ("sport/+", "sport/tennis/player1"),
         ("+/+", "/finance"),
         ("home/+/temperature", "home/livingroom/temperature"),
         ("#", "$SYS/broker/uptime")]
for f, t in cases:
    print(f"{f:24} vs {t:38} -> {matches(f, t)}")

class Broker:
    def __init__(self):
        self.subs, self.retained = [], {}
    def subscribe(self, client, topic_filter):
        self.subs.append((client, topic_filter))
        for topic, payload in self.retained.items():   # 구독 즉시 보존 메시지 전달
            if matches(topic_filter, topic):
                client.deliver(topic, payload, retained=True)
    def publish(self, topic, payload, retain=False):
        if retain:
            self.retained[topic] = payload
        for client, f in self.subs:
            if matches(f, topic):
                client.deliver(topic, payload)
    def disconnect_unexpectedly(self, will):
        self.publish(*will, retain=True)               # Will 메시지를 대신 발행

class Client:
    def __init__(self, name): self.name = name
    def deliver(self, topic, payload, retained=False):
        print(f"  [{self.name}] {topic} = {payload}" + ("  (retained)" if retained else ""))

b = Broker()
print("센서가 상태를 retain 으로 발행, 이후 대시보드 접속")
b.publish("home/livingroom/status", "online", retain=True)
b.publish("home/livingroom/temperature", "24.5")   # retain 아님: 나중 구독자는 못 받음
dash = Client("dashboard")
b.subscribe(dash, "home/+/status")
print("거실 센서 전원 차단 -> 브로커가 Will 발행")
b.disconnect_unexpectedly(("home/livingroom/status", "offline"))

실행 결과:

sport/tennis/player1/#   vs sport/tennis/player1                   -> True
sport/tennis/player1/#   vs sport/tennis/player1/score/wimbledon   -> True
sport/+                  vs sport/tennis/player1                   -> False
+/+                      vs /finance                               -> True
home/+/temperature       vs home/livingroom/temperature            -> True
#                        vs $SYS/broker/uptime                     -> False
센서가 상태를 retain 으로 발행, 이후 대시보드 접속
  [dashboard] home/livingroom/status = online  (retained)
거실 센서 전원 차단 -> 브로커가 Will 발행
  [dashboard] home/livingroom/status = offline

# 는 부모 레벨 자체(sport/tennis/player1)도 잡는다. +/+ 는 빈 문자열도 한 레벨로 보기 때문에 /finance 와 맞는다. 대시보드는 센서보다 늦게 접속했지만 보존 메시지 덕분에 online 을 바로 받았고, retain 이 아닌 온도 값은 받지 못했다. 센서가 갑자기 사라지자 브로커가 유언 메시지 offline 을 대신 발행했다.

실제 브로커로 해 보려면 Eclipse Mosquitto 를 설치하고 터미널 두 개에서 다음을 실행한다.

mosquitto_sub -h localhost -t 'home/#' -v
mosquitto_pub -h localhost -t 'home/livingroom/status' -m online -r

현업에서는

  • IoT 데이터 수집 경로: 기기 → MQTT 브로커 → 브리지/커넥터 → 시계열 DB·스트림 처리(Kafka 등) 구성이 흔하다. MQTT 는 수많은 작은 기기와의 연결을, 뒤쪽 시스템은 대용량 처리와 저장을 맡는다.
  • 홈랩의 스마트홈: 홈 오토메이션 소프트웨어와 센서 게이트웨이를 MQTT 브로커로 묶는 구성은 홈랩에서 자주 보인다. 브로커를 k3s 클러스터에 올릴 때는 세션과 보존 메시지를 잃지 않도록 영속 볼륨을 붙이고, 외부에는 TLS 포트만 열며, 기기별 계정과 토픽 ACL 을 둔다.
  • QoS 선택: 1초마다 오는 온도 값은 QoS 0 이면 충분하다. 하나쯤 빠져도 다음 값이 온다. 문 열림 명령처럼 빠지면 안 되는 메시지는 QoS 1 에 멱등 처리를 붙인다. QoS 2 는 비용이 크므로 꼭 필요할 때만 쓴다.
  • 연결 폭주 대비: 정전 후 복구처럼 수천 대가 동시에 재접속하면 브로커에 부하가 몰린다. 기기 펌웨어에 무작위 지연(jitter)을 둔 재접속 백오프를 넣는다.

확인 문제

  1. 발행·구독 구조가 HTTP 요청·응답 구조에 비해 IoT 에서 유리한 점 두 가지를 들라.
  2. 필터 home/+/temperature 와 home/# 는 각각 home/kitchen/temperature/raw 와 맞는가.
  3. QoS 0, 1, 2 의 보장 수준과, QoS 1 을 쓸 때 받는 쪽이 주의할 점은?
  4. 보존 메시지와 유언 메시지를 함께 쓰면 어떤 기능을 만들 수 있는가.
  5. # 를 구독했는데 $SYS/broker/uptime 메시지를 받지 못하는 이유는?

풀이

  1. 발행자와 구독자가 서로를 몰라도 되어 받는 쪽을 자유롭게 늘릴 수 있다. 기기가 브로커로 나가는 연결 하나만 유지하면 서버의 명령도 그 연결로 받을 수 있어 폴링이 필요 없다. 헤더가 작아 작은 메시지에 효율적이다.
  2. home/+/temperature 는 레벨 수가 달라 맞지 않고, home/# 는 맞는다.
  3. 0 은 최대 한 번(유실 가능), 1 은 최소 한 번(중복 가능), 2 는 정확히 한 번. QoS 1 에서는 같은 메시지가 중복으로 올 수 있으므로 처리를 멱등하게 만든다.
  4. 기기의 온라인·오프라인 상태 표시. 접속 시 상태 토픽에 online 을 보존 발행하고, 유언으로 offline 을 보존 발행하도록 등록하면, 언제 접속한 구독자든 현재 상태를 바로 알 수 있다.
  5. $ 로 시작하는 토픽은 첫 레벨이 와일드카드인 필터와 매칭되지 않도록 명세가 정해 두었기 때문이다. $SYS/# 를 따로 구독해야 한다.

더 읽을거리 (References)