MQTT 연결 유실과 자가 복구 아키텍처 설계
1. 문제 상황: 연결은 되지만, "듣지 못하는" 좀비 키오스크
24시간 운영되는 무인 키오스크는 설치 환경의 불안정한 Wi-Fi, 전력 공급 등의 문제로 인해 중앙 관제 서버와의 MQTT 연결이 중단되는 경우를 고려해야 했습니다. AWS IoT SDK의 자동 재연결 기능 덕분에 네트워크 연결 자체는 복구되었으나, 더 심각한 문제가 발생했습니다.
연결이 복구된 키오스크가 중앙 관제 서버의 원격 명령(원격 재부팅, 강제 키 배출 등)을 전혀 수신하지 못하는 좀비 상태가 되는 치명적인 문제였습니다. 이는 무인 장비의 원격 관리가 불가능해지는, 운영상 심각한 문제였습니다.
2. 원인 분석: 버그가 아닌, 의도된 트레이드오프
'좀비 상태'의 원인을 파악하기 위해
mqttClient.ts의 연결 설정 코드를 분석했습니다. 핵심 원인은
connect 메서드 내부의 Client ID 생성 정책에 있었습니다.// app/module/MQTT/mqttClient.ts // ... const config = iot.AwsIotMqttConnectionConfigBuilder.new_mtls_builder(...) // ... // 문제 원인: 연결 시마다 고유한 UUID를 Client ID로 생성 .with_client_id(`${thingData.thingName}-${crypto.randomUUID()}`) // ... .build(); // ...
A. 초기 설계 의도: Client ID 충돌 원천 방지
매번 ID를 랜덤으로 생성하는 이 설계는 MQTT 시스템에서 가장 큰 문제 중 하나인
'Client ID 충돌'을 방지하기 위한 아키텍처였습니다
만약 모든 키오스크가 'kiosk-001'처럼 고정된 ID를 사용한다고 가정하면
- 불안정한 네트워크에서의 재연결 스톰
- 키오스크('kiosk-001')가 연결됩니다.
- Wi-Fi가 불안정해 잠시 연결이 끊깁니다.
- 키오스크는 연결이 끊겼다고 판단하고, 즉시 동일한 ID ('kiosk-001')로 재연결을 시도합니다.
- 하지만 MQTT 브로커(서버)는 아직 이전 연결의 'keep-alive'가 만료되지 않아, 'kiosk-001'이 이미 연결되어 있다고 인식합니다.
- MQTT 명세에 따라, 브로커는 기존 'kiosk-001' 연결을 강제로 끊고 새 연결을 허용합니다.
- 이 과정이 1초에도 수십 번 반복되며, 키오스크가 스스로의 연결을 계속 끊는 '재연결 스톰'에 빠져 브로커에 막대한 부하를 줄 수 있습니다.
kiosk-002-uuid-123이 연결됩니다.- Wi-Fi가 불안정해 연결이 끊깁니다.
- 키오스크가 재연결을 시도하며 완전히 새로운 ID인
kiosk-002-uuid-456을 생성합니다. - 브로커 입장에서는,
kiosk-002-uuid-123과는 전혀 다른 새 클라이언트가 연결을 요청한 것입니다. 기존 연결을 강제로 끊을 필요 없이, 그냥 새 연결을 받아줍니다. - 이전 연결(
...123)은 아무도 신경 쓰지 않고, 조용히 타임아웃되어 사라집니다.
네트워크가 안정적이라면 5번에서 정상화되겠지만, Wi-Fi가 1초에도 몇 번씩 불안정하게 깜빡이는 환경에서는 1~5번 과정이 1초에 수십 번 반복되게 됩니다.
→ 키오스크가 스스로의 (아직 살아있는) 연결을 계속 끊고 다시 연결하는 '재연결 스톰' 발생
랜덤 UUID (
kiosk-002-random-uuid-123)를 사용하면 이 문제가 해결됩니다.- 운영/배포상의 위험: '유령' 장비의 출현
- 기술자가 현장에서 장비를 교체하거나, 테스트를 위해 디스크 이미지를 복제했을 때,
동일한 고정 ID를 가진 두 대의 장비가 동시에 네트워크에 나타나 서로를 쫓아내는 장애가 발생할 수 있습니다.
때문에, 연결 시마다
crypto.randomUUID()로 새 ID를 생성하여, '모든 연결을 고유하고 일회적인 것으로 취급하도록 하여, 이를 통해 '재연결 스톰'과 'ID 중복' 문제를 회피하고, 어떤 상황에서도 연결이 항상 고유함을 보장하도록 코드를 작성하였습니다B. 트레이드오프: 영구 세션 포기
하지만 이 설계는 MQTT의 '영구 세션' 기능을 의도적으로 포기하는 트레이드오프를 가지게 되었습니다.
'영구 세션'은 MQTT 브로커가 클라이언트의 구독 상태를 기억했다가, 재연결 시 복원해주는 기능입니다.
이 기능이 동작하기 위한 필수 조건은 "반드시 이전에 연결했던 것과 동일한 Client ID로 재연결"하는 것입니다.
C. 문제 발생
'좀비 상태'는 버그가 아니라, 랜덤 ID를 사용하는 아키텍처 하에서 필연적으로 발생하는 문제였습니다.
재연결 시마다 키오스크는 '완전히 새로운 클라이언트'로 인식되었기에, SDK의 자동 재연결 기능만으로는 이전 연결의 구독 상태(어떤 토픽을 들어야 하는지)를 복구할 수 없는 문제가 발생한 것이었습니다.
3. 해결 전략: "남(브로커)"을 믿지 않고, "스스로(클라이언트)" 책임지도록 설계
단순히 고정 ID 방식으로 회귀하여 '영구 세션'에 의존하는 것은 근본적인 해결책이 아니라고 판단했습니다.
24시간 이상의 장기 오프라인(정전) 상황에서는 브로커의 세션이 만료되어 동일한 버그가 재발할 수 있기 때문입니다. 브로커의 불확실한 '기억력'에 의존하는 것은 상용 장비에 부적합했습니다.
이에 "클라이언트가 100% 자신의 상태를 책임진다" 는 더 견고한 원칙을 세우고, SDK의 이벤트를 활용한 '자가 복구' 로직을 직접 구현했습니다.
- 현 아키텍처 유지: 랜덤 ID 정책을 유지하고, 세션을 새로 시작하도록
.with_clean_session(true)옵션을 명시적으로 설정했습니다.
- SDK 이벤트 활용: SDK의 핵심 이벤트인
connection.on('resume', ...)리스너를 활용했습니다. 이 이벤트는 (자동 재연결 성공 시) 연결이 재개될 때 발생합니다.
- '자가 복구' 로직:
resume이벤트 핸들러 내부에서,sessionPresent플래그가false(브로커에 복원할 세션이 없음)인 경우를 감지했습니다. 이는 "나는 새 클라이언트로 취급되었다"는 신호입니다.
- 이때, 직접 구현한
await this.resubscribeAllTopics()함수를 즉시 호출하도록 했습니다. 이 함수는 모든 필수 토픽(원격 명령, 디바이스 상태 등)을 처음 연결처럼 다시 구독(Subscribe)하는 역할을 합니다.
// app/module/MQTT/mqttClient.ts // ... // this.connection = client.new_connection(config); this.connection.on('interrupt', (error) => { log.warn('[MQTT] Connection interrupted:', error); }); this.connection.on('resume', async (returnCode, sessionPresent) => { log.info(`[MQTT] Connection resumed (sessionPresent: ${sessionPresent})`); // 브로커가 내 세션(구독 정보)을 기억하고 있지 않다면 (false) if (!sessionPresent) { try { log.info('[MQTT] Session not present, resubscribing to all topics...'); // 클라이언트가 능동적으로 모든 토픽을 '자가 복구(재구독)' await this.resubscribeAllTopics(); } catch (error) { log.error('[MQTT] Failed to resubscribe after resume:', error); } } }); // ... /** * 모든 토픽 재구독하는 메서드 */ private async resubscribeAllTopics(): Promise<void> { if (!this.connection || !this.cachedThingData) { log.error('[MQTT] Cannot resubscribe: No connection or thingData'); return; } // 기존 구독 로직들을 다시 호출 await this.subscribeShadowTopics(this.cachedThingData.thingName); await this.subscribeForceKeyEject(this.cachedThingData); await this.subscribeFactoryReset(this.cachedThingData); // ... (기타 모든 구독 함수) ... log.info('[MQTT] Successfully resubscribed to all topics'); }
고유 고정 ID'를 사용하며 브로커의 세션 관리에 의존하는 것보다,
아예 매번 새 ID를 발급(랜덤 ID)하여 '재연결 스톰'을 원천 차단하고,
구독 상태는 클라이언트가 직접 책임지고 복구(자가 치유)하는 것이 더 견고하다고 판단하였습니다.
4. 결과
- '자가 복구' 클라이언트 구현: 네트워크가 일시적으로 중단되었다가 복구될 때마다, 키오스크가 능동적으로 모든 구독 상태를 스스로 복구하는 '자가 복구' MQTT 클라이언트를 구현할 수 있었습니다
- 시스템 안정성 확보: '좀비 상태'의 발생 가능성을 원천적으로 제거하여, 상용 무인 장비에 필수적인 원격 제어 신뢰도를 확보할 수 있었습니다
- 교훈: 아키텍처의 트레이드오프 이해 이 경험을 통해 단순히 라이브러리를 사용하는 것을 넘어,
그 아키텍처의 트레이드오프(Client ID 충돌 방지 vs 영구 세션 포기)를 명확히 인지하는 것이 얼마나 중요한지 깨달을 수 있었습니다. 또한, 사용하는 각 방법의 장단점에 따라, 더 나은 구조와 설계를 고민하는 경험을 할 수 있었습니다.