차량, IVI(In-Vehicle Infotainment)처럼 IoT 디바이스를 다루는 시스템에서는 클라우드와 디바이스 간 통신이 시스템 설계의 큰 부분을 차지한다.

클라우드 간 이벤트 전달에는 보통 Kafka를 쓴다. 서비스 사이의 결합도를 낮춰 각각의 독립성을 지키고, 유지보수성과 확장성을 얻기 위한 디커플링(DECOUPLING) 이다.

반면 클라우드에서 디바이스로 이벤트를 전달할 때는 MQTT(Message Queuing Telemetry Transport)를 쓴다.

이 글은 클라우드가 디바이스로 이벤트를 전달하고, 이벤트를 받은 디바이스가 서버에 최신 데이터를 조회하는 상황을 가정한다. 이때 MQTT를 쓰는 이유와, MQTT가 ZERO PAYLOAD 전략과 잘 맞는 이유를 살펴본다.

MQTT

MQTT는 Publisher와 Subscriber 중 하나 이상이 저사양·저대역폭·간헐적 연결 디바이스라는 전제 위에서 설계됐다.

MQTT는 고정 헤더가 2바이트에 불과해 저대역폭 환경에 유리하다.

차량은 터널, 지하주차장, 음영 지역, 셀룰러 핸드오프 때문에 연결이 수시로 끊겼다 다시 붙는다. 연결이 끊긴 사이 서버에서 발생한 이벤트도 재연결 후에는 디바이스에 도착해야 한다.

MQTT는 이러한 제약 조건에서 메시지를 안정적으로 전달하기 위해 만들어진 메시징 프로토콜이다.

Broker가 있어서 Publisher는 Subscriber의 주소나 연결 상태를 알 필요가 없다. 하나의 메시지를 여러 구독자에게 복제해 보내는 Fan-Out, 연결 관리, 마지막 메시지를 보관했다가 새로 구독한 Subscriber에게 바로 전달하는 Retained Message도 Broker가 처리한다.

즉, Broker 덕분에 Publisher는 “이 Topic에 이 메시지를 발행한다"만 신경 쓰면 된다. Broker는 Publisher와 Subscriber 사이의 “전달 책임"을 분리하는 역할을 한다.

끊긴 사이의 메시지를 받는 방법

연결이 끊긴 동안 발행된 메시지를 재연결 후에 받으려면 두 가지 설정이 필요하다.

  • QoS(Quality of Service) — 0은 한 번 보내고 잊는다. 1은 최소 한 번 전달을 보장하는 대신 중복이 생길 수 있다. 2는 정확히 한 번 전달하지만 왕복이 늘어난다. 재연결이 잦은 차량 환경에서는 보통 QoS 1을 쓴다.
  • Persistent Session — 세션을 유지하도록 접속하면(MQTT 5는 Clean Start=0 과 Session Expiry Interval, MQTT 3.1.1은 Clean Session=false) Broker가 구독 정보와 미전달 메시지를 세션에 보관한다. 디바이스가 다시 접속하면 보관해둔 메시지를 이어서 받는다.

다만 세션 보관 기간과 큐 용량에는 한계가 있다. 차량이 장기간 오프라인 상태라면 메시지는 결국 버려진다. MQTT만으로는 “모든 이벤트를 반드시 받는다"를 보장할 수 없다는 뜻이고, 이 한계를 어떻게 다룰 것인지가 ZERO PAYLOAD 전략의 출발점이다.

ZERO PAYLOAD

ZERO PAYLOAD는 메시지의 페이로드에 실제 데이터(e.g {"battery": 82, "door": "locked"})를 담지 않고 어떤 리소스가 변경되었는지를 알려주는 스키마, 식별자 정보(e.g {"schema":"vehicle_state", "vehicleId": ...)만 담아서 Subscriber가 해당 이벤트를 받아 필요한 데이터를 별도로 조회하는 설계 전략을 의미한다.

메시지는 “무엇이 바뀌었다"는 신호일 뿐이고, 실제 값은 언제나 서버에서 가져온다.

ZERO PAYLOAD를 쓰는 이유

  • 오래된 값으로 덮어쓰지 않는다. 재전송이나 순서 역전으로 과거 이벤트가 뒤늦게 도착해도, 디바이스가 조회하는 값은 조회 시점의 최신 상태다. 같은 이벤트를 중복해서 받아도 결과는 같다.
  • 밀린 이벤트가 한 번의 조회로 수렴한다. 같은 리소스에 대한 이벤트가 여러 건 쌓였다면 마지막 한 건만 처리해도 된다.
  • 민감한 데이터가 Broker에 남지 않는다. 특히 Retained Message는 Broker에 계속 보관되므로, 페이로드에 실제 값을 담으면 그 값이 보관 대상이 된다.
  • 스키마를 바꾸기 쉬워진다. 페이로드 구조가 식별자로 고정되므로, 조회 응답의 스키마를 바꿔도 발행 측을 함께 배포할 필요가 없다.

대가도 있다. 이벤트 하나마다 조회가 한 번씩 더 발생하므로 서버 부하와 전달 지연이 늘어난다. 다수의 디바이스가 동시에 재연결하면 조회가 한꺼번에 몰린다(Thundering Herd). 디바이스마다 지터(Jitter)를 준 재시도, 조회 결과 캐싱 같은 완충 장치가 필요하다.

Offline-First

Offline-First의 핵심은 네트워크 연결 여부와 관계없이 클라이언트가 동작할 수 있도록 만드는 것이다.

서버 상태를 항상 실시간으로 전달받는다고 전제하지 않는다. 클라이언트는 로컬에 캐시한 상태로 우선 동작하고, 네트워크가 복구되면 서버와 동기화한다.

클라이언트가 지는 책임은 다음과 같다.

  • 오프라인 동안 로컬 캐시로 UI 표시
  • 재연결 후 동기화 스케줄링
  • 데이터를 언제 Pull할지 결정
  • 서버와 로컬 상태가 충돌했을 때의 해결 정책
  • 필요에 따른 주기적인 Full Sync

MQTT와 ZERO PAYLOAD를 함께 쓰는 구성은 이 책임 분담과 맞물린다. 클라우드에 저장된 DB가 Source of Truth이고, MQTT는 “지금 확인하라"고 알리는 이벤트 채널일 뿐이다. 이벤트를 전달 받은 디바이스는 내부적으로 정의한 동기화 정책에 따라 서버에서 최신 상태를 조회한다.

다음 그림은 클라이언트 앱이 데이터를 변경한 뒤 차량까지 반영되는 흐름이다.

시퀀스 다이어그램

Backend Server는 DB commit과 발행 큐 저장을 한 트랜잭션에서 처리한다(Transactional Outbox). DB에는 반영되었는데 이벤트 발행에 실패해 둘이 어긋나는 상황을 막기 위해서다. 차량이 오프라인이면 이벤트 수신은 재연결 시점까지 밀리고, 재연결 후 이벤트를 받은 차량은 서버에서 최신 스냅샷을 조회해 로컬 DB를 갱신한다.

이 구성은 메시지 전달과 상태 동기화의 책임을 분리한다. 일시적인 메시지 유실은 시스템의 치명적인 실패가 아니라 “다음 동기화에서 해결할 수 있는 상태 불일치"가 된다. 이벤트를 놓치더라도 다음 이벤트나 주기적인 Full Sync에서 상태가 다시 맞춰지기 때문이다.

References