블로그 목록
techIoT 플랫폼원격 제어MQTTDevice ShadowSDK

슬라이더 3초에 명령 90개: 원격 제어 경로를 둘로 나눈 이야기

앱에서 조명 밝기를 조절하면 즉시 반응해야 하고, 앱을 꺼둔 새벽 2시에도 예약 실행은 돌아야 합니다. 이 둘은 사실 서로 다른 요구여서 하나의 길로 억지로 묶으면 반드시 한쪽이 망가지는데, 저희가 길을 어떻게 나눴는지 정리했습니다.

OpenIoT

OpenIoT 플랫폼팀

조회 6
슬라이더 3초에 명령 90개: 원격 제어 경로를 둘로 나눈 이야기

슬라이더를 손가락으로 3초 끌면 명령이 90개 나가는데, 이 숫자 하나가 원격 제어의 길을 둘로 갈라놓았습니다. 손가락이 움직이는 지금과 아무도 앱을 켜지 않은 새벽 2시는 사실 같은 경로로 처리할 수 없는 요구였습니다.


슬라이더를 3초 끌면 명령이 90개 나갑니다

원격 제어는 IoT 제품에서 가장 단순해 보이는 기능이라, 앱에서 버튼을 누르면 기기가 켜지고 끝입니다. 그런데 버튼이 아니라 슬라이더가 되는 순간 성격이 완전히 달라집니다.

조명 밝기 슬라이더를 손가락으로 3초 동안 끌었다고 해보면, 슬라이더는 손가락이 움직이는 내내 값을 뱉고 화면 갱신 주기를 감안하면 초당 20~30회입니다.

3초 × 초당 20~30회 = 명령 60~90개

한 번의 조작, 한 번의 손가락 움직임인데 명령은 90개이고, 이게 모든 문제의 출발점입니다.

왼쪽 끝의 손가락 한 번이 오른쪽으로 가면서 수십 갈래로 불어난다는 점을 봐주세요. 사용자에게는 조작 1회지만 서버에게는 호출 90회입니다.

명령 90개를 전부 서버를 거쳐 기기에 보내면 세 가지가 한꺼번에 문제가 됩니다. 비용 쪽에서는 명령 하나마다 서버 함수가 한 번씩 깨어나 조작 한 번에 90번이 되고 사용자가 1만 명이면 계산이 무서워집니다. 지연 쪽에서는 앱에서 서버를 거쳐 기기로 한 정거장을 더 가므로 슬라이더를 놓고 나서 조명이 따라오는데, 사용자는 이걸 "끈적하다"고 표현합니다. 그리고 서버 함수는 동시 실행 개수에 한도가 있어서 저녁 7시에 모두가 조명을 켜면 그 한도에 부딪힙니다.

그러면 반대로 서버를 아예 빼고 앱이 기기에 직접 보내면 어떨까요. 이번에는 서버가 그 명령을 본 적이 없어서 "어젯밤 11시에 누가 보일러를 껐는지"에 답할 수 없고, "매일 오후 6시 점등" 같은 예약은 앱이 꺼져 있어도 실행돼야 하는데 명령을 보낼 주체가 없으며, "도어락 해제는 재인증 후에만" 같은 규칙을 강제할 지점도 사라집니다.

속도를 얻으면 기록과 자동화를 잃고, 기록과 자동화를 얻으면 속도와 비용을 잃습니다. 저희가 참고한 이전 세대 설계서에는 이 문제에 대해 원격 제어 후보안 4개가 결론 없이 나란히 그려져 있었는데, 답을 못 정한 게 아니라 하나만 골라서는 답이 안 되는 문제였기 때문이라고 봅니다.


길을 하나만 내면 속도와 확실성 중 하나를 잃습니다

세 가지를 놓고 비교했습니다.

방식어떤 구조인가장점결정적 한계
① 전부 서버 경유앱 → 서버 → 기기. 모든 명령이 서버를 통과모든 명령이 기록되고 정책을 걸 수 있음. 자동화가 자연스러움비용·지연·동시성 한도. 슬라이더 한 번에 서버 함수 90회
② 전부 앱 직접앱 → 기기. 서버는 관여 안 함가장 빠르고 가장 쌈 (서버 비용 0)기록·자동화·정책이 전부 불가능. 앱이 꺼지면 아무것도 안 됨
③ 이중 경로실시간 조작은 직접, 자동화·기록 필요는 서버 경유양쪽의 장점을 모두 취함개발자가 매번 "어느 길로 보낼지" 판단해야 함

①과 ②는 화살표가 한 갈래뿐이고 ③만 두 갈래라는 점이 요점입니다. 한 갈래로는 속도와 확실성 중 하나를 반드시 포기하게 되므로 ③이 답인 건 비교적 빨리 명확해졌고, 대신 ③의 한계인 개발자가 매번 판단해야 한다는 것을 어떻게 없앨지가 진짜 숙제였습니다.

성숙한 플랫폼은 둘 다 주고 선택은 개발자에게 맡깁니다

오픈소스·상용 IoT 플랫폼들의 구조를 함께 봤더니, 성숙한 플랫폼일수록 메시지 브로커에 직접 붙는 경로와 서버 API를 통한 경로를 둘 다 제공하고 있었습니다. ThingsBoard·EMQX·OpenRemote 같은 곳들이 그런데, 대부분 "둘 다 있으니 골라 쓰세요"에서 멈추고 어느 쪽을 언제 쓸지는 그 플랫폼을 쓰는 개발자의 몫으로 남깁니다.

여기서 한 걸음 더 갈 여지를 봤습니다. 길이 두 개인 건 우리 사정이지 앱 개발자가 알아야 할 사정은 아닙니다.

여기에 질문이 하나 더 붙었습니다. 명령을 보낼 때 기기 전원이 꺼져 있거나 인터넷이 끊겨 있으면 그 명령은 어디로 가야 할까요. 서버가 대기 중 명령 목록을 들고 있다가 재전송하도록 직접 구현할 수도 있지만 재접속 감지·중복·순서를 전부 직접 짜야 하고, 클라우드가 제공하는 Device Shadow를 쓰면 오프라인 처리와 여러 앱 간 상태 일치를 대신 관리해 주는 대신 문서 크기 8KB 제한과 요청당 과금이 따라옵니다.

Device Shadow는 클라우드에 있는 기기의 분신 카드로, "이렇게 되면 좋겠다(희망)"와 "지금 실제로 이렇다(보고)" 두 칸을 가집니다.

기기가 꺼져 있어도 희망 칸에는 적을 수 있고 기기가 켜지는 순간 두 칸의 차이를 알아서 맞추므로, 집에 사람이 없어도 넣어둘 수 있는 우편함과 같습니다.


그래서 길을 둘로 내고 SDK 뒤에 숨겼습니다

③ 이중 경로로 가되, 여기에 제어 위임(delegation) 이라고 부르는 원칙을 붙였습니다.

클라우드는 기기를 직접 제어하지 않는다.

클라우드는 정책·인증·기록을 맡고, 실제 조작은 사용자 앱이 기기에게 직접 한다.

실시간 조작에서 서버는 순수한 손해입니다

슬라이더 90개 명령에서 서버가 하는 일이 무엇인지 따져봤더니 아무것도 없었고 받아서 그대로 넘길 뿐이었습니다. 그래서 실시간 조작은 앱이 기기에게 직접 보내기로 했습니다.

앱과 기기 사이에 서버라는 상자가 아예 없다는 것이 이 그림의 전부입니다. 기기가 알린 상태는 같은 장소의 다른 앱에도 동시에 도착합니다.

여기서 앱과 기기가 대화하는 방식이 MQTT인데, 쉽게 말해 채널을 구독하는 단체 대화방입니다. 앱이 명령 채널에 글을 쓰면 기기가 읽고 기기가 상태 채널에 글을 쓰면 그 장소의 모든 앱이 동시에 읽으므로, 정거장이 앱 → 서버 → 브로커 → 기기에서 앱 → 브로커 → 기기로 줄어듭니다. 조작 1회당 서버 비용이 0이 되고 저녁 피크에 부딪힐 수 있는 서버 함수 동시 실행 한도도 이 경로에서는 아예 걸리지 않습니다.

맨 오른쪽 서버 세로선에 아무 화살표도 닿지 않는다는 점을 봐주세요. 명령이 90개든 900개든 서버는 깨어나지 않으므로, 조작이 즉각 반응하면서도 사용자가 늘어도 제어 트래픽 때문에 서버 요금이 올라가지 않습니다. IoT 서비스에서 제어 명령은 가장 빈번한 트래픽이라, 이 항목이 요금표에서 사라지는 것은 규모가 커질수록 커지는 이점입니다.

기록·자동화·안전은 서버가 맡습니다

직접 발행이 정상 경로이지만 서버 경유가 반드시 필요한 상황이 다섯 가지 있습니다. 예약·자동화는 앱이 꺼져 있어도 실행돼야 하고, 외부 연동에서는 앱이 아예 존재하지 않으며, 앱이 오프라인인 상태에서도 명령은 접수돼야 합니다. 그리고 도어락 해제나 밸브 개폐 같은 안전 직결 명령은 반드시 서버를 거치고, 명령 순서가 뒤집히면 안 되는 장비군은 서버 쪽 대기열을 통과시킵니다.

왼쪽 출발점이 앱이 아니라 예약이라는 점과, 명령이 오갈 때마다 기록이 한 줄씩 남는다는 점이 앞 경로와의 차이입니다. 속도를 조금 내주고 확실성을 얻는 길이라, "우리 제품은 예약 기능이 필요한데요"나 "감사 기록이 남아야 하는데요"라는 요구가 구조 변경 없이 이미 들어 있습니다.

개발자는 함수 하나만 호출합니다

앞에서 이중 경로의 결정적 한계로 적은 "개발자가 매번 어느 길로 보낼지 판단해야 함"을 없앤 것이 이 설계의 핵심인데, 길을 고르는 판단을 SDK가 대신합니다.

sdk.devices.command(deviceId, { action: "setBrightness", params: { level: 70 } });

앱 개발자가 쓰는 코드는 이 한 줄이 전부이고, 그 안에서 SDK가 이렇게 판단합니다.

맨 위 상자 하나에서 시작해 아래로 갈라지는 모든 판단이 SDK 안에서 끝난다는 점이 요점입니다. 앱 개발자가 보는 것은 맨 위 한 줄뿐이고 초록이냐 주황이냐는 알 필요가 없습니다. 앱이 백그라운드로 내려가면 실시간 연결은 배터리를 위해 끊는데, 그 상태에서 사용자가 위젯으로 조명을 켜면 같은 함수가 자동으로 서버 경로를 쓰고 앱 코드는 그대로입니다. 덕분에 나중에 저희가 경로 판단 규칙을 개선해도 고객사 앱 코드는 안 고쳐도 됩니다.

기기가 꺼져 있어도 명령이 살아남습니다

Device Shadow를 쓰기로 해서, 기기가 오프라인일 때 들어온 명령은 희망 상태 칸에 기록되고 기기가 접속하는 순간 클라우드가 차이만큼을 기기에 전달합니다.

오프라인 상태에서도 명령이 버려지지 않고 보관함이라는 칸으로 들어간다는 점을 봐주세요. 사용자가 누른 버튼은 사라지지 않고 기기가 켜지는 순간 반영되며, 프로토타입에서는 오프라인 기기의 제어 화면에 "희망 상태에만 기록되고 기기가 접속하면 반영됩니다"라는 안내가 뜨도록 만들었습니다. 이 로직을 고객사가 직접 짤 필요가 없다는 것이 이 선택의 값입니다.


원칙을 밀어붙이니 깨지는 지점들이 나왔습니다

방향은 명확했지만 그대로 밀어붙이면 깨지는 지점들이 있었습니다. 정직하게 적습니다.

앱이 직접 보내면 서버가 그 명령을 못 봅니다

가장 먼저 부딪힌 모순이고 돌아보면 여기서 한참 헤맸는데, 위임의 대가로 감사 기록을 잃을 뻔했습니다. 해법은 방향을 뒤집는 것이었습니다. 명령은 못 봐도 응답은 볼 수 있습니다.

기기가 명령을 처리하고 "이렇게 됐다"고 알릴 때 그 응답에 어떤 명령에 대한 응답인지를 함께 싣게 했고, 서버는 상태 채널을 항상 듣고 있으므로 이 응답을 받아 명령 이력을 역방향으로 기록합니다. 서버가 직접 본 명령은 하나도 없지만 응답만으로 언제·어떤 기기가·어떤 명령을·어떤 결과로 처리했는지가 복원됩니다.

기록을 만드는 화살표가 명령 방향이 아니라 응답 방향에서 나온다는 점이 이 구조의 요점입니다. 다만 정직하게 남는 한계가 있는데, 기기에 도달하지 못한 명령은 응답이 없으므로 기록되지 않습니다. 그래서 완전한 명령-응답 쌍이 반드시 필요한 고객사를 위해 "우리 테넌트는 전부 서버 경유"를 설정으로 켤 수 있게 했지만, 이 부분은 지금도 더 나은 방법이 있을 것 같습니다.

보안 규칙 하나를 지키려다 다른 규칙을 어겼습니다

처음 규약에는 모든 명령에 전자 서명을 필수로 적었습니다. 국내 IoT 보안인증 기준의 제어 정보 보호 항목에 대응하려는 의도였는데, 펌웨어 쪽 설계로 내려가면서 이게 성립하지 않는다는 걸 뒤늦게 발견했습니다.

앱이 서명을 하려면 앱이 개인키를 가져야 하는데, 앱에 개인키를 심는 것은 같은 인증 기준의 다른 항목인 키 하드코딩 금지를 정면으로 위반합니다. 앱은 사용자 기기에 설치되는 파일이고 뜯어보면 나오기 때문입니다. 한 기준을 지키려다 다른 기준을 어기는 구조여서 규칙을 정정했습니다.

경로기밀성·무결성재전송 방어서명
A. 앱 직접전송 구간 암호화 + 발행 주체를 출입증으로 한정시각 창 + 1회용 난수 + 순번 검사없음
B. 서버 경유전송 구간 암호화동일클라우드 키 관리 서비스로 서명, 기기는 공장에서 심어둔 공개키로 검증

그리고 여기에 명령 종류별 정책표를 얹었습니다. 기기 펌웨어가 "이 명령은 서명이 필요한가"를 명령 이름별로 표로 들고 있어서, 도어락 해제·밸브 개폐·의료기기 제어처럼 생명과 안전에 직결되는 명령은 서명 필수로 표시되고 서명 없이 오면 기기가 실행을 거부합니다. 서명은 서버 경유 경로에서만 붙으므로 결과적으로 안전 명령은 반드시 서버를 거치게 강제되고, 앱이 지름길로 보내려 해도 기기가 안 받습니다.

막는 주체가 앱이나 서버가 아니라 맨 아래 기기 자신이라는 점을 봐주세요. 왼쪽에서 명령은 도어락까지 실제로 도착하지만 도어락이 스스로 거부하므로, 이건 앱을 믿는 구조가 아니라 기기가 스스로 거부하는 구조입니다.

인증 기준 대응 논리도 함께 정리했습니다. 해설서는 "타임스탬프 또는 순번을 추가하고 전체를 서명"을 예시로 들 뿐 유일한 방법으로 규정하지 않으므로, 경로 A는 발행 주체가 이미 인증되고 전송 구간도 암호화되며 여기에 재전송 방어를 더하면 기준을 만족한다는 설명이 성립합니다. 다만 이 논리를 인증 신청 명세서에 명시적으로 써야 하고, 안 쓰면 보완 요청을 받습니다.

브라우저와 모바일은 기기처럼 접속할 수 없습니다

기기는 공장에서 심어둔 위조 불가능한 인증서를 들고 접속하지만 앱과 웹에는 이 방식을 쓸 수 없었습니다. 브라우저는 기기가 쓰는 통신 포트에 직접 붙지 못하고, 크로스플랫폼 모바일 환경에는 안정적인 인증서 기반 클라이언트가 사실상 없으며, 무엇보다 사용자 수만큼 인증서를 발급·저장·교체·회수하는 운영 부담이 감당되지 않습니다.

그래서 앱과 웹은 로그인 계정으로 임시 출입증을 받아 웹소켓 443 포트로 접속하는 방식으로 나눴습니다. 그런데 이 선택에는 예상 못 한 실용적 이점이 따라왔는데, 443은 웹사이트가 쓰는 그 포트라 회사 사무실이나 공공 와이파이가 막지 않는다는 점입니다. 기기 전용 포트를 쓰는 앱은 "회사 와이파이에서는 앱이 안 돼요"라는 문의를 받기 쉽지만, 우리 구조에서는 이 문의가 나올 이유 자체가 없습니다.

기기 시계가 틀어지면 정상 명령이 전부 거부됩니다

재전송 공격을 막기 위해 기기는 명령에 적힌 발행 시각이 ±30초 창 밖이면 폐기하는데, 문제는 뒤집었을 때 보였습니다. 기기 시계가 1분 틀어져 있으면 정상 명령이 전부 거부됩니다.

사용자가 겪는 증상은 앱도 정상이고 인터넷도 정상이고 기기 램프도 켜져 있는데 버튼만 누르면 아무 일도 안 일어나는 것이라, 원인을 짐작하기 가장 어려운 형태의 고장입니다. 그래서 기기가 부팅 시 표준 시각 서버와 시계를 맞추게 하고, 맞추기에 실패하면 "나 시계가 안 맞는다"를 이벤트 채널로 알리게 했으며, 시각이 안 맞는 동안의 기록에는 "시각 미동기" 꼬리표가 붙습니다. 증상이 나타나기 전에 운영자가 먼저 알게 되는 구조입니다.

마지막으로, 경로를 나누든 안 나누든 반드시 필요한 신뢰성 규칙들을 규약으로 고정했습니다.

항목규칙무엇을 막나
중복 방지명령마다 고유 ID. 같은 ID를 다시 받으면 실행하지 않고 마지막 상태만 다시 알림네트워크 문제로 명령이 두 번 도착해도 창문이 두 번 열리지 않음
재전송실패 시 1초 → 2초 → 4초 → 8초, 최대 5회일시적 끊김에서 사용자 개입 없이 복구
재전송 공격 방어발행 시각(±30초) + 1회용 난수 + 순번 역행 검사남이 녹음한 예전 명령을 다시 보내는 공격
전달 보장 등급명령·이벤트는 도착 보장, 주기 센서값은 보장 없음중요한 것만 비용을 씀

중복 방지에서 한 번만 실행하고 마지막 상태를 다시 알린다는 부분이 실무적으로 중요합니다. 그냥 무시해버리면 앱은 응답을 못 받고 10초 뒤 실패로 처리하지만, 상태를 다시 보내주면 앱 화면은 정상적으로 확정됩니다.


지금 손에 있는 것을 그대로 적습니다

현재 이 구조는 설계 확정 + 동작하는 화면 프로토타입 단계이고 실기기 연동과 성능 측정은 아직 하지 않았습니다. 지금까지 이야기한 것을 한 장으로 모으면 이렇습니다.

가는 길은 ①과 ② 두 갈래인데 돌아오는 길은 하나로 합쳐진다는 점이 이 글 전체의 요약입니다. 어느 길로 보냈든 결과는 같은 상태 채널로 돌아오고, 그 하나의 흐름이 화면 갱신과 감사 기록을 동시에 만듭니다.

펌웨어와 서버가 함께 따라야 하는 계약으로 명령 형식을 고정했습니다.

{ "cmdId": "고유 ID",      // 중복 실행 방지
  "action": "setPower",     // 무엇을 할지
  "params": { "on": true }, // 값
  "ts": "발행 시각",         // 재전송 공격 방어
  "nonce": "1회용 난수",     // 재전송 공격 방어
  "sig": "서명",            // 서버 경유 경로에만
  "origin": "app | server" } // 어느 길로 왔는지

origin 필드가 있어서 기기와 서버 모두 이 명령이 어느 경로로 왔는지를 알 수 있는데, 기기가 "안전 명령인데 앱에서 직접 왔네" 하고 거부할 수 있는 근거이기도 합니다. 응답 메시지에는 명령 ID와 순번이 함께 담기고, 앞서 말한 역방향 감사 기록이 이 필드 위에서 성립합니다.

프로토타입은 실제로 클릭되는 화면으로 만들어 설계를 검증했습니다.

화면확인한 것
기기 제어 패널스위치·슬라이더·선택·실행 4종 컨트롤. 조작하면 발행될 채널과 메시지가 로그로 출력. 서명이 필요한 명령에는 자물쇠 아이콘이 붙고, 오프라인이면 "희망 상태에만 기록됨" 안내로 바뀜
기기 상세 화면속성 · 희망/보고 상태 나란히 보기 · 제어 · 데이터 · 공유 · 로그 6개 탭. 로그 탭에서 명령·상태·센서값·이벤트가 채널별로 어떻게 쌓이는지 확인
모바일 기기 화면제어·데이터·정보 3개 탭. "연결이 살아 있으면 직접 발행, 없으면 서버 경유: 앱 코드는 함수 하나" 규칙이 명시되고, 오프라인 기기는 제어 UI가 실제로 비활성화됨

기기 제어 패널이 특히 값을 했습니다. 슬라이더를 움직일 때마다 명령 메시지가 한 줄씩 로그에 쌓이는 게 눈으로 보여서 "3초에 90개"가 추상적인 계산이 아니라 화면 위의 현상이 되고, 덕분에 비개발자도 왜 경로를 나눴는지 이해하고 검토에 참여할 수 있었습니다. 같은 화면에서 제어 UX 규칙도 확정했는데, 보낸 즉시 화면을 먼저 바꾸고 기기 응답이 오면 실제 값으로 확정하며 10초 안에 응답이 없으면 원래 값으로 되돌립니다.

검증 산출물로는 기존 사내 구현 조사, 외부 플랫폼 대조, 인증 기준 항목 1:1 매핑, 화면-기능 전수 대조가 남았습니다. 앞서 말한 서명 모순은 세 번째 과정에서 발견한 것입니다.


"하나만 골라야 한다"는 전제부터 의심해야 했습니다

이전 설계서에 후보 4개가 결론 없이 나란히 있던 이유를 나중에 이해했는데, 넷 중 하나를 고르는 문제가 아니었기 때문입니다. 실시간 조작과 예약 자동화는 요구가 정반대여서 하나는 속도이고 하나는 확실성이라, 한 경로로 둘 다 만족시키려 하면 반드시 어느 쪽인가가 나빠집니다. 경로를 나누고 나눈 사실을 SDK가 숨기는 것이 답이었습니다.

보안 규칙끼리도 충돌할 수 있다는 것을 배웠습니다. "모든 명령에 서명"은 그 자체로는 완벽하게 옳은 규칙이지만 구현으로 내려가면 앱에 개인키를 심어야 한다는 결론이 나오고 그건 다른 항목 위반인데, 보안 요구사항을 목록으로만 보면 이 충돌이 안 보이고 실제 실행 주체까지 내려가야 보입니다. 이걸 코드 한 줄 쓰기 전에 발견한 게 이번 설계에서 가장 다행이었던 지점입니다.

제약을 피하다 얻은 것도 있습니다. 앱이 기기용 인증서를 못 쓴다는 건 처음엔 순수한 제약이었고 우회하려고 웹소켓 443 경로를 택했는데, 그 덕에 회사나 공공 와이파이에서 막히지 않는다는 이점이 따라왔습니다. 의도한 건 아니지만 제약을 우회할 때 가장 표준적인 길을 고르면 이런 부수적 이점이 붙는 경우가 있다는 건 기록해둘 만합니다.

고객이 얻는 것근거
조작이 즉각 반응실시간 명령이 서버를 거치지 않음
제어 트래픽이 서버 요금을 밀어올리지 않음조작 1회당 서버 비용 0. 사용자가 늘어도 구조 동일
앱을 꺼도 예약·자동화가 동작서버 경유 경로가 처음부터 설계에 포함
기기가 꺼져 있어도 명령이 살아남음희망 상태 보관함에 기록 후 재접속 시 반영
"누가 언제 조작했는지" 기록응답을 통한 역방향 감사 기록 + 필요 시 서버 경유 강제 설정
안전 명령은 앱이 뚫려도 막힘기기가 명령별 정책표로 스스로 거부
앱 개발이 단순경로 판단은 SDK가. 개발자는 함수 하나

마지막 줄이 이 설계에서 가장 신경 쓴 부분입니다. 좋은 구조를 만드는 것과 그 구조를 쓰는 사람이 복잡함을 느끼지 않게 하는 것은 다른 일이라, 앞의 여섯 줄은 우리가 감당할 복잡함이고 고객사 개발자가 보는 건 마지막 줄 하나여야 합니다.

비슷한 고민을 하고 계신 분께 조금이나마 도움이 되었으면 합니다.

읽어주셔서 감사합니다.


IoT 서비스,
지금 바로 시작하세요

디바이스 연결부터 대시보드까지 올인원

무료 플랜으로 부담 없이 체험