9.18. MQTT, 바이트 단위로 살펴보기

이 시점에서 카메라는 공개 인터넷의 실제 서비스와 통신하는 데 필요한 모든 요소를 갖추게 됩니다. TCP 소켓, 이를 감싸는 TLS, 상대를 이름으로 지정하는 DNS, 그리고 연결이 열려 있는 동안 같은 스크립트가 다른 작업을 수행할 수 있게 해주는 asyncio가 그것입니다. MQTT는 이 모든 것을 한데 묶어 실제 배포된 제품이 사용하는 형태로 만들어 내는 첫 번째 와이어 프로토콜입니다.

이 페이지에서는 프로토콜 자체를 다룹니다. 즉 와이어상의 형식, 각 참여자가 맡는 역할, 그리고 그 설계에서의 절충점을 충분히 정직하게 설명하여, 함께 제공되는 mqtt 클라이언트가 미지의 영역으로의 도약이 아니라 이미 알고 있는 것을 명백하게 감싼 것처럼 보이도록 합니다.

9.18.1. Pub/sub 대 request/response

대부분의 카메라 프로젝트가 가장 먼저 사용하는 프로토콜인 HTTP는 request/response 방식입니다. 클라이언트가 특정 서버에 특정 리소스를 요청하면 서버가 응답합니다. 모든 교환은 일대일이며, 양쪽 끝 모두 서로의 주소를 미리 알고 있습니다.

MQTT는 publish/subscribe 방식입니다. 클라이언트는 중간에 있는 제3자인 broker(브로커) 에 연결합니다. publisher(발행자) 는 누가 듣고 있는지 알지도, 신경 쓰지도 않은 채 이름이 붙은 topic(토픽) 으로 메시지를 보냅니다. subscriber(구독자) 는 자신이 원하는 토픽을 브로커에 알리고, 그 이후로 해당 토픽에 발행되는 모든 메시지를 받습니다. 브로커는 팬아웃 역할을 합니다. 즉 yard-cam/motion 에 한 번 발행하면 yard-cam/motion 을 구독하는 모든 기기에 도달하며, 구독자가 0개든 1개든 50개든 상관없습니다.

이러한 모델의 변화로부터 세 가지가 뒤따릅니다:

  • 디커플링(분리). 발행자는 구독자가 존재하는지 알 필요가 없습니다. 구독자는 발행자가 알아채지 못한 채 오갈 수 있습니다. 두 번째 대시보드를 추가하는 것은 새 대시보드 쪽에서 코드 한 줄을 작성하는 일이며, 카메라는 바뀌지 않습니다.

  • 팬아웃. 브로커가 모든 복제를 처리하므로 카메라는 몇 대의 기기가 읽든 상관없이 패킷 하나만 보냅니다. 이것이 바로 MQTT가 만들어진 용도입니다.

  • 비대칭성. 이제 브로커는 필수 인프라가 됩니다. 브로커가 없으면 프로토콜이 동작하지 않습니다. 가정용 프로젝트에서는 보통 무료 공개 브로커(test.mosquitto.org, broker.hivemq.com)이거나 직접 운영하는 소규모 브로커입니다.

브로커의 yard-cam/motion 토픽에 발행하는 카메라 한 대와, 두 개의 브라우저 대시보드 및 한 개의 클라우드 아카이버가 각각 동일한 메시지를 받는 모습.

9.18.2. 토픽

토픽은 슬래시로 구분된 문자열입니다. 관례상 가장 일반적인 것이 왼쪽에, 가장 구체적인 것이 오른쪽에 옵니다:

yard-cam/motion
yard-cam/temperature
workshop-cam/motion
workshop-cam/temperature/sensor-3

두 가지 와일드카드는 구독(subscription) 에서 동작합니다(발행에서는 동작하지 않습니다):

  • + 는 단일 레벨에 매칭됩니다. +/motion 은 모든 카메라의 motion 토픽을 구독하고, yard-cam/+ 는 모든 yard-cam 하위 토픽을 구독합니다.

  • # 는 하나 이상의 후행 레벨에 매칭됩니다. yard-cam/#yard-cam/motion, yard-cam/temperature, yard-cam/temperature/sensor-3, 그리고 yard-cam/ 아래의 다른 모든 것을 구독합니다. 이것은 구독의 끝에 나와야 합니다.

토픽 문자열은 대소문자를 구분합니다. 사양에 따르면 앞에 붙은 $ 는 발행자가 쓰면 안 되는 브로커 내부 토픽($SYS/...)을 표시합니다.

9.18.3. 패킷 형식

MQTT는 TCP 위에서 동작합니다. 모든 제어 패킷은 1바이트 고정 헤더(fixed header) 로 시작하고, 가변 길이의 Remaining Length 필드, 그다음 패킷 유형별 가변 헤더(variable header), 마지막으로 페이로드(payload) 가 이어집니다. CONNECT, PUBLISH, SUBSCRIBE, PUBACK, DISCONNECT 등 모든 명령에 동일한 외부 형식이 적용되며, 그래서 MQTT 클라이언트를 수백 줄로 작성할 수 있는 것입니다.

MQTT PUBLISH 패킷의 바이트 레이아웃으로, 고정 헤더의 유형 및 플래그 바이트, 가변 길이의 Remaining Length 필드, 토픽 이름, 선택적 패킷 식별자, 그리고 페이로드 바이트를 보여줍니다.

고정 헤더는 1바이트입니다:

  • 비트 7..4는 제어 패킷 유형(control packet type) 입니다. 0x3 은 PUBLISH입니다(따라서 첫 바이트는 보통 0x3? 로 시작합니다). 0x1 은 CONNECT, 0x2 는 CONNACK, 0x8 은 SUBSCRIBE, 0xC 는 PINGREQ, 0xE 는 DISCONNECT 등입니다.

  • 비트 3..0은 패킷 유형별 플래그(flags) 입니다. PUBLISH의 경우 플래그는 DUP 재전송 플래그, QoS 레벨(2비트), 그리고 RETAIN 플래그를 인코딩합니다.

Remaining Length는 자신 이후의 모든 바이트를 세는 1~4바이트 가변 길이 정수입니다. 각 바이트의 최상위 비트는 연속 표시기입니다. 즉 1은 “또 다른 길이 바이트가 뒤따른다”는 뜻이고, 0은 “이것이 마지막이다”는 뜻입니다. 128 미만의 길이는 1바이트에 들어가며, 더 큰 페이로드는 더 많은 바이트를 사용합니다. 인코딩 가능한 최대 길이는 256 MiB입니다.

PUBLISH의 경우 가변 헤더는 토픽 이름(2바이트 길이, 그다음 UTF-8 바이트)이며, 그 뒤에 QoS가 1 또는 2일 때만 존재하는 2바이트 패킷 식별자(packet identifier) 가 옵니다. 나머지 바이트는 페이로드로, 프로토콜은 이를 불투명한 바이트로 취급합니다.

a/bok 를 보내는 최소한의 QoS-0 PUBLISH는 다음과 같습니다:

30 07 00 03 'a' '/' 'b' 'o' 'k'
  • 30 – PUBLISH, 모든 플래그가 0.

  • 07 – 7바이트가 뒤따름.

  • 00 03 – 토픽 길이 3.

  • 'a' '/' 'b' – 토픽.

  • 'o' 'k' – 페이로드.

와이어상 9바이트이며, 메시지는 브로커에서 a/b 를 구독하는 모든 구독자에게 도달합니다.

9.18.4. QoS 레벨

Quality-of-Service(서비스 품질)는 브로커(및 클라이언트)가 전달을 보장하기 위해 얼마나 노력하는지를 제어합니다. 세 가지 레벨은 다음과 같습니다:

QoS 0 – 최대 한 번(at most once). 발사 후 망각(fire and forget) 방식입니다. PUBLISH 패킷이 전송되고 확인되지 않습니다. TCP가 전달하면 브로커가 전달합니다. 전송 도중 연결이 끊기면 메시지는 사라집니다. 대부분의 센서 텔레메트리는 QoS 0이면 충분합니다. 30초마다 보내는 스트림에서 온도 측정값 하나를 놓친다고 해서 문제가 되지 않습니다.

QoS 1 – 최소 한 번(at least once). 발행자는 패킷 식별자를 포함하고 PUBACK을 기다립니다. 타임아웃 전에 PUBACK이 도착하지 않으면 발행자는 DUP 플래그를 설정하여 재전송합니다. 브로커는 결국 같은 메시지를 같은 레벨의 구독자에게 두 번 전달할 수 있으므로, 구독자는 중복을 기꺼이 처리할 수 있어야 합니다.

QoS 2 – 정확히 한 번(exactly once). 4단계 핸드셰이크(PUBREC / PUBREL / PUBCOMP)를 통해 재연결을 거치더라도 메시지가 정확히 한 번 도달하도록 보장합니다. 왕복과 브로커 상태 측면에서 비용이 큽니다. 이를 필요로 하는 카메라 앱은 거의 없습니다.

함께 제공되는 mqtt 클라이언트는 QoS 0과 QoS 1을 구현합니다. QoS 2를 요청하면 예외를 발생시킵니다. 센서 측정값을 보고하는 카메라의 경우 거의 항상 QoS 0이 올바른 답입니다.

9.18.5. 보존 메시지(retained message)와 last will

두 가지 기능은 브로커가 토픽에 대해 무엇을 기억하는지를 바꾸기 때문에 알아둘 가치가 있습니다.

RETAIN. PUBLISH에 RETAIN 플래그가 설정되어 있으면 브로커는 그 메시지를 저장해 두었다가 향후 모든 구독자가 구독하는 순간 그들에게 전달합니다. 이것이 MQTT가 “현재 값이 무엇인가?”를 처리하는 방식입니다. 즉 센서가 최신 측정값을 보존(retained)으로 발행하면, 10분 뒤에 구독하는 대시보드도 다음 발행을 기다리지 않고 가장 최근 값을 받게 됩니다. 같은 토픽으로 다시 발행하면 보존된 값을 덮어쓰고, 빈 페이로드를 발행하면 그것을 지웁니다.

Last will. 클라이언트는 연결할 때 브로커에 “유언(last will and testament)”을 줄 수 있습니다. 즉 토픽, 페이로드, QoS, 그리고 retain 플래그입니다. 그 클라이언트가 비정상적으로 연결이 끊기면(TCP RESET, 전원 손실, DISCONNECT 패킷 없는 네트워크 단절), 브로커가 그 클라이언트를 대신해 유언을 발행합니다. 구독자는 이를 카메라가 오프라인이 되었다는 알림으로 받게 됩니다. 카메라 자신은 결코 유언을 보내지 않습니다. 그때쯤이면 카메라가 사라졌으므로 브로커가 보냅니다.

9.18.6. keepalive와 재연결

CONNECT는 초 단위의 keepalive 간격을 담습니다. 클라이언트가 그 시간 동안 침묵하면 브로커는 그것을 죽은 것으로 간주합니다. 이를 방지하기 위해 클라이언트는 주기적으로 PINGREQ(1바이트: 0xC0)를 보내고 PINGRESP(0xD0)를 돌려받습니다. 이는 프로토콜이 담을 수 있는 가장 작고 저렴한 하트비트입니다. 대부분의 카메라 앱은 keepalive를 30초나 60초로 설정합니다.

TCP 연결이 끊기면 양쪽 모두 이를 알아채고 처음부터 다시 연결합니다. 클라이언트가 연결 시 지속 세션(persistent session) 을 사용하지 않았다면 끊기기 전에 만든 구독은 사라집니다. 단순한 카메라 앱에서는 재연결 시 재구독하는 패턴이 더 짧고 그만큼 좋습니다.

이 정도면 MQTT 사양을 읽거나 socket.socket 위에서 클라이언트를 직접 작성하기에 충분합니다. mqtt 에 함께 제공되는 클라이언트는 바로 그 일을 하며, 여기에 애플리케이션 코드를 위한 합리적인 API를 더합니다.