들어가며

지금 운영 중인 서비스에서 AWS IoT Core를 통해 MQTT로 기기를 제어하고 있다. 명령을 publish 하면 기기의 내판과 외판이 동작하고, 기기의 상태가 변경되면 서버가 받아서 반영한다. 외적으로는 잘 돌아가지만 문제는 왜 잘 돌아가는지 깊게 설명할 수 없었다는 것이다.
QoS란 무엇인지, 기기가 오프라인일 때 보낸 명령은 어떻게 되는지, 연결이 끊긴 기기를 서버는 어떻게 알아채는지 잘 설명할 수 없었다. 장애가 나기 전에 이해해두자는 마음으로, MQTT의 내부 동작을 표준 스펙과 AWS IoT Core의 실제 구현을 오가며 정리해봤다.
미리 말해두면, AWS IoT Core는 표준 MQTT 브로커가 아니다. 표준과 다르게 동작하는 지점이 꽤 많고, 그 차이를 제대로 이해하지 못하면 실제 사용되는 기기가 의도하지 않은 채로 동작할 수 있다.
그래서 각 절마다 표준과 AWS IoT Core의 차이점에 대해 정리하였따.
1. 왜 기기 - 서버 통신은 HTTP가 아니라 MQTT인가

본론에 들어가기 전에, 애초에 왜 기기 통신에서 다들 MQTT를 쓰는지부터 짚고 가자. 웹 개발자의 기본기인 HTTP로 시작해보면 이유가 선명해진다.
첫 번째 문제: 서버가 기기를 부를 수 없다.
- HTTP는 요청-응답 모델이다. 항상 클라이언트가 먼저 요청을 해야 응답을 받는 구조이다.
- 그런데 기기 제어의 본질은 반대다. 서버가 기기에게 "이렇게 동작해"라고 말을 걸어야 한다.
- 기기는 보통 공유기 뒤(NAT)나 이동통신망 안에 있기 때문에 서버가 기기의 IP로 접속해 들어갈 방법이 없다.
- HTTP로 풀려면 기기가 폴링(polling)을 해야 하는데, 명령이 없는 99%의 시간에도 요청이 오가고, 즉시 처리가 어렵고 폴링 주기만큼 기다려야한다. 그리고 기기가 늘어갈수록 서버에게도 부하를 주게 된다.
MQTT의 해법은 요청 방향을 뒤집는 것이다. 기기가 먼저 브로커에 TCP 연결을 걸고, 그 연결을 계속 열어둔다. 서버에서 기기로 못 들어가니, 기기에서 서버로 뚫어놓은 통로를 유지하는 것이다. 이제 서버가 보낸 명령을 브로커가 이 열린 통로로 즉시 밀어넣어줄(push) 수 있다. 폴링도, 공인 IP도, 포트포워딩도 필요 없다.
두 번째 문제: HTTP는 무겁다.
- HTTP 요청 하나에는 매번 수백 바이트의 헤더가 따라붙고, 연결을 새로 맺는다면 TCP/TLS 핸드셰이크 비용까지 든다.
- 반면 MQTT는 연결을 한 번 맺어두고 재사용하며, 고정 헤더가 최소 2바이트다. 센서 값 하나 보내는 데 드는 비용이 자릿수부터 다르다.
- 실제로 MQTT는 1999년 IBM 엔지니어들이 위성 회선으로 송유관 센서 데이터를 보내기 위해 만든 프로토콜이다. 대역폭이 비싸고, 회선이 수시로 끊기고, 기기 전력과 네트워크가 불안정한 환경에서 문제를 해결하기 위해 만들어졌다.
세 번째 문제: 기기와 서버를 1:1로 묶으면 확장이 안 된다.
- 기기가 수천 대가 되고, 상태를 받아볼 소비자가 여럿이 되면(제어 서버, 모니터링, 데이터 파이프라인 등) 서로의 주소를 알고 직접 통신하는 구조는 금방 유지보수가 어려워진다.
- MQTT는 pub/sub 모델로 이 결합을 끊는다. 클라이언트끼리는 서로를 모르고, 모든 메시지는 브로커(broker)를 거친다
- AWS IoT Core에서는 메세지 브로커가 그 역할이다. 메시지는 devices/door-01/command 같은 계층형 토픽(topic)으로 라우팅되고, 구독할 때 +(한 레벨), #(하위 전체) 와일드카드를 쓸 수도 있다.
- 발행자는 토픽에 던질 뿐이고, 그걸 누가 몇 명이 받는지는 브로커의 일이다. 기기 한 대의 상태를 서비스 셋이 나눠 받는 구조가 설정 없이 그냥 된다.
정리하면 MQTT가 기기-서버 통신의 표준처럼 쓰이는 이유는:
- NAT 뒤의 기기에게 서버가 실시간으로 push할 수 있고
- 저전력·저대역폭 환경을 버티며
- 기기가 늘어나도 구조가 유지되기 때문(pub/sub 디커플링)이다.
- 연결이 "끊기는 것"을 예외가 아니라 일상으로 전제하고, 그 상황을 다루는 도구들을 QoS, 세션, keep-alive, LWT을 프로토콜 안에 내장되어 있다.
2. QoS - "정확히 한 번"이란 무엇일까

MQTT 표준의 QoS(Quality of Service)는 세 단계로 이뤄진다. 각 단계가 실제로 어떤 패킷을 주고받는지 자세히 살펴보면 메세지 전송의 보장이 무슨 뜻인지 알 수 있다.
QoS 0 - At most once: 던지고 잊는다
Sender ── PUBLISH ──▶ Receiver
패킷 하나로 끝. 응답도, 재전송도 없다. 연결이 끊기는 타이밍에 걸리면 메시지는 사라진다. 유실돼도 다음 값으로 금방 대체되는 데이터(주기적 센서 값 등)에 적합하다.
AWS IoT Core에서는: 놀랍게도 QoS 0 메시지도 중복 전달될 수 있다고 공식 문서에 명시되어 있다. 표준의 "at most once"가 IoT Core에서는 "보통 한 번, 가끔 여러 번"이 되는 셈이다. 게다가 중복 전달 시 DUP 플래그(메시지 발행(PUBLISH) 시 해당 메시지가 이전에 전송된 메시지의 재전송본인지, 아니면 첫 번째 전송 시도인지를 나타내는 1비트 식별자)도 붙지 않는다
QoS 1 - At least once: 도착은 보장, 중복은 감수
Sender ── PUBLISH (packet id 부여) ──▶ Receiver
Sender ◀─ PUBACK ──────────────────── Receiver
발신 측은 메세지(PUBLISH)를 보낸 뒤 응답(PUBACK)을 받을 때까지 메시지를 저장하고, 안 오면 재전송한다. 여기서 본질적인 문제가 있다. 수신 측이 메시지를 잘 받아 처리까지 했는데 응답(PUBACK)이 유실되면, 발신 측은 같은 메시지를 다시 보낸다. 수신 측에는 같은 메시지가 두 번 도착하는 것이다.
기기 제어에서 이 문제는 심각하게 이어질 수 있다. 예를들어 "문 열어" 명령이 두 번 도착하는 건 괜찮을 수 있다. 하지만 "열려있으면 닫고 닫혀있으면 열어"가 두 번 도착하면 문이 열렸다가 다시 닫히면서 고객이 문사이에 끼어 피해를 입을 수 있다.
그래서 결론은 QoS 1 위에서의 제어 명령은 반드시 멱등(idempotent)해야 한다는 것이다. "토글"처럼 누를 때마다 동작이 변화하는 것이 아니라 "문을 열어라"처럼, 같은 명령이 두 번 와도 결과가 같도록 설계하는 것. 이건 선택이 아니라 QoS1을 사용하기 위해 필수로 적용해야하는 개념이다. 명령에 고유 ID를 넣어 기기 쪽에서 중복을 걸러내는 방법도 함께 쓰이고는 한다.
QoS 2 - Exactly once: 중복없이 정확히 한번
표준 MQTT의 QoS 2는 4-way handshake(PUBLISH → PUBREC → PUBREL → PUBCOMP)를 통해 중복까지 제거해 "정확히 한 번"을 제공한다. 대신 메시지 하나에 패킷 4개와 서버, 클라이언트 모두 상태 유지라는 비용이 필요하다.
- 1단계 (PUBLISH): 송신자가 Packet ID와 함께 메시지를 전송합니다.
- 2단계 (PUBREC): 수신자가 메시지를 수신/저장하고, 송신자에게 수신 완료(PUBREC)를 알립니다.
- 3단계 (PUBREL): 송신자가 PUBREC을 확인 후, 수신자에게 메시지를 최종 처리하라는 해제 신호(PUBREL)를 보냅니다.
- 4단계 (PUBCOMP): 수신자가 메시지를 처리하고, 송신자에게 모든 과정이 완료(PUBCOMP)되었음을 알립니다.
AWS IoT Core에서는 QoS 2를 지원하지 않는다. 표준 브로커를 쓰다가 넘어와 QoS 2를 설정하면, PUBREC/PUBREL/PUBCOMP 패킷을 지원하지 않기 때문에 에러 없이 그냥 조용히 실패해버린다.
그러니 IoT Core에서 신뢰성의 상한선은 QoS 1이고, "정확히 한 번"은 프로토콜이 아니라 애플리케이션으로 멱등성을 보장해야한다.
놓치기 쉬운 것: 서버와 클라이언트의 QoS는 별개다
QoS는 발행자→브로커, 브로커→구독자 두 구간에서 따로 적용되고, 실제 적용값은 둘 중 낮은 쪽이다. 발행자가 QoS 1로 보내도 구독자가 QoS 0으로 구독했다면 브로커→구독자 구간은 QoS 0이다. 분명 QoS 1로 보냈는데 유실되는 문제가 생길 수 있다.
또 하나 IoT Core는 메시지 순서를 보장하지 않는다고 문서에 명시되어 있다. 명령의 순서가 중요하다면 메시지에 시퀀스 번호나 타임스탬프를 넣고 수신 측에서 처리해야 한다.
3. 세션 - 오프라인 기기에 보낸 명령은 어디로 가는가

기기가 오프라인일 때 보낸 명령은 어떻게 될까? 답은 세션 설정에 따라 다르다. 클라이언트가 연결할 때 persistent session이면 브로커는 기기가 끊긴 동안에도 구독 목록을 기억하고, QoS 1 메시지를 큐잉해뒀다가 재연결 시 전달한다. clean session이면 끊기는 순간 모든 것이 증발하고, 오프라인 동안의 명령은 그냥 사라진다.
AWS IoT Core에서는:
- persistent session의 기본 만료 시간이 1시간이다(계정 단위 설정, 연장 신청 가능). 기기가 1시간 넘게 오프라인이면 세션과 큐잉된 명령이 함께 사라진다.
- MQTT 5에서는 Session Expiry Interval을 연결별로 지정할 수 있고 최대 7일까지 가능하다. 단, CONNECT에서 지정하지 않으면 기본값이 0이다. 즉 끊기는 순간 세션 종료다.
- 재연결 시 큐잉된 메시지는 초당 최대 10개로 흘려보내진다. 오래 끊겼던 기기에 명령이 산더미처럼 쌓여 있었다면 도착이 순차적으로 지연된다는 뜻이다.
- CONNACK의 sessionPresent 플래그로 세션이 살아 있는지 확인할 수 있다. 0이면 재구독부터 해야 한다.
여기서 질문을 하나 던져보자. 오래된 명령이 뒤늦게 실행되는 게 맞는가? 새벽에 보낸 "문 열어"를 보냈는데 기기 복구 후 오전에 실행된다면 그건 기능이 아니라 장애다.
명령에 유효기간을 넣거나(MQTT 5의 Message Expiry Interval을 쓰면 프로토콜 레벨에서 가능, IoT Core는 최대 7일), 기기가 수신할 때 타임스탬프를 검사해서 특정 시간이 지나면 동작하지 않도록 하는 것이 필요하다.
그리고 세션의 키는 Client ID다. 그리고 두 클라이언트가 같은 Client ID로 접속하면 IoT Core는 기존 연결을 즉시 끊어버린다. 실수로 기기 두 대에 같은 ID를 넣으면 둘이 서로를 밀어내며 무한 재연결하는 루프에 빠져 버린다. 접속이 이상하게 뚝뚝 끊긴다면 Client ID 중복부터 체크해보는 것이 중요하다.
4. Keep-alive - 죽은 기기를 탐지하는 기술

TCP의 불편한 진실: 상대가 죽어도 아무 일도 일어나지 않는다. 패킷을 보내보기 전까지는 연결이 죽었는지 알 수 없다(half-open connection). 마지막으로 보낸 신호가 정상이고 그 뒤에 신호를 보내지 않는다면 기기가 네트워크 문제가 있어도 계속 서버에서는 정상으로 보일 것이다.
그래서 MQTT의 해법이 keep-alive다. 클라이언트는 CONNECT에서 keep-alive 간격(초)을 선언하고, 그 간격 안에 보낼 패킷이 없으면 PINGREQ를 보낸다. 브로커는 PINGRESP로 답한다. 브로커는 keep-alive를 설정한 시간의 1.5배 동안 클라이언트로부터 아무 패킷이 없으면 연결이 죽었다고 판단하고 정리한다. IoT Core도 동일하게 1.5배 규칙을 쓴다.
값 선택은 트레이드오프다. 짧으면 죽음 감지가 빠르지만 트래픽과 전기를 많이 먹고, 길면 반대다. 그리고 이 "죽었다고 판단하는 순간"이 중요한 이유가 다음 절에 나온다. 바로 이때 LWT가 발동한다.
5. 기기의 생사 알림 - LWT, 그리고 retained의 함정

LWT - 클라이언트의 유언장
클라이언트는 CONNECT 시점에 브로커에게 LWT(Last Will and Testament)를 맡길 수 있다. 내가 비정상적으로 죽으면 이 토픽에 이 메시지를 대신 발행한다는 뜻이다. 브로커는 네트워크 오류로 연결이 끊기거나, keep-alive 1.5배 시간 내에 패킷이 없으면 LWT를 발행한다. 정상적으로 DISCONNECT를 보내고 떠나면 발행하지 않는다. 즉 LWT는 정확히 "예고 없는 죽음"의 감지하는 기술이다. IoT Core도 LWT를 지원한다.
retained message - 마지막 값의 스냅샷
일반 메시지는 발행 순간 구독 중인 클라이언트에게만 전달된다. 1초 늦게 구독한 클라이언트는 그 메시지를 영영 모른다. retained flag를 켜고 발행하면 브로커가 그 메시지를 토픽당 1개 저장해두고, 새 구독자에게 즉시 전달할 수 있다. "이 토픽의 마지막 값"이다.
- 연결 시 LWT 등록: devices/{id}/status에 offline, retained
- 연결 직후 발행: 같은 토픽에 online, retained
- 누가 언제 구독하든 현재 상태를 즉시 알 수 있다. 기기가 소리 없이 죽으면 브로커가 offline을 대신 발행한다.
AWS IoT Core에서는: retained message를 지원하지만(2021년부터) 치명적인 예외가 있다 - 와일드카드(+, #) 구독으로는 retained 메시지를 받을 수 없다.
토픽을 정확히 지정해 구독해야만 구독 시점에 retained 값이 온다. devices/+/status로 전체 기기 상태를 한 번에 받아오는 표준식 패턴이 IoT Core에서는 동작하지 않는다는 뜻이다. (이후의 실시간 발행분은 와일드카드로도 받는다. 안 오는 건 "구독 시점의 저장된 값"이다.)
그래서 AWS가 미는 Device Shadow
이 지점에서 IoT Core의 답은 사실 retained가 아니라 Device Shadow다. Shadow는 기기별로 브로커 옆에 붙는 JSON 상태 문서로, desired(원하는 상태)와 reported(기기가 보고한 상태)를 나눠 저장한다.

동작 흐름:
- 서버가 desired: {door: "open"}을 Shadow에 쓴다.
- 기기가 온라인이 되면 Shadow가 delta(desired와 reported의 차이)를 기기에 알려준다
- 기기가 실행 후 reported: {door: "open"}을 보고하면 delta가 사라진다
이 구조의 장점은 "명령을 보낸다"에서 "원하는 상태를 선언한다"로 관점을 바꿔주는 것이다. 명령의 중복도, 유실도, 오프라인 큐잉도 상태 수렴이라는 모델 안에서 자연스럽게 해소된다. 앞에서 말한 멱등 설계를 AWS가 인프라 레벨로 만들어둔 셈이다.
그래서 우리 인프라를 확인해봤다. 그냥 기기별 커스텀 토픽에 QoS 0으로 publish하고 있었다. Shadow가 해주는 일들을 우리는 직접 하고 있던 셈이다. 오프라인 기기 문제는 못 풀었고(명령이 그냥 사라진다), 중복 방지는 DB 락으로, 실행 확인은 폴링으로 때우고 있다. 이 사실을 Shadow를 공부하고 나서야 알았다.
그나마 다행인 건 명령 페이로드가 전부 멱등성 있게 설계되었다는 점이다. Shadow의 desired가 요구하는 게 정확히 이 형태라, 옮기기로 하면 페이로드 설계는 다시 할 필요가 없다. 토픽 구조와 기기 쪽 구독 코드를 바꾸는 일이 새롭게 추가되었다.
6. 그 외 IoT Core에서 알아두면 좋은 제약 (짧게)
- 메시지 하나의 최대 크기는 128KB다. 펌웨어나 이미지처럼 큰 데이터를 보내려면 나눠서 보내는 수밖에 없다.
- MQTT 5도 쓸 수 있다. reason code, shared subscription, topic alias 같은 기능이 들어온다. 다만 MQTT 3에서 만든 persistent session은 MQTT 5로 이어지지 않으니 버전을 올릴 때 주의해야 한다.
- 와일드카드 동작에 애매한 구석이 하나 있는데, sensor/#를 구독하면 sensor/temperature는 받지만 sensor 토픽으로 오는 메시지는 못 받는다.
- $로 시작하는 토픽은 AWS가 예약해서 쓴다. Shadow, lifecycle 이벤트 같은 것들이 $aws/... 토픽으로 온다.
마치며
- IoT Core에서 신뢰성의 상한은 QoS 1이고, "정확히 한 번"은 어플리케이션으로 멱득성을 설계해야된다.
- 제어 명령은 중복된 명령에도 멱득성을 보장하도록 "토글"이 아니라 "이렇게 동작해라"로 구현해야 한다.
- 오프라인 기기에 보낸 명령의 운명은 세션 설정이 결정한다. clean session 여부, 세션 만료 1시간, Client ID 전략 등 셋 다 개발자가 직접 결정해야 한다.
- 기기 상태 추적은 LWT+retained 조합이 자주 사용되던 방식이지만, AWS IoT Core는 와일드카드 구독에 retained Message를 주지 않는다. 서버가 재시작하면 전체 기기의 마지막 상태를 받아올 방법이 없다는 뜻이다. 대신 상태를 문서로 저장해두고 아무 때나 API로 조회할 수 있는 Device Shadow를 제공해준다.,
글을 쓰기 전의 나는 "MQTT로 기기를 제어한다" 수준까지 이해하고 있었지만 지금은 MQTT 뒤에 숨어 있던 기술이 나오게 된 배경 그리고 문제를 해결하기 위해 나온 기술들(QoS, 세션, Keep Alive) 을 하나씩 설명할 수 있게 됐다. 그 과정속에 우리가 가진 기술부채까지 알게 되면서 기기의 상태와 서버의 상태가 불일치하는 이유까지 파악할 수 있었다.