가족 셋이 같은 조명을 켜는 일: 여러 사람이 여러 기기를 함께 쓰는 구조 설계기
남편이 거실 조명을 켜면 아내 휴대폰 화면의 스위치도 같이 켜져야 하는데, 당연해 보이는 이 동작이 실제로는 IoT 플랫폼에서 가장 먼저 무너지는 지점입니다. 기기를 사람에 묶을지 장소에 묶을지를 정하는 데 가장 오래 걸렸고, 결국 나머지 설계가 대부분 그 한 줄에서 따라 나왔습니다.
OpenIoT 플랫폼팀

시리즈의 첫 글은 "기기를 무엇에 묶을 것인가"라는 질문에서 시작합니다. 사용자에 묶느냐 장소에 묶느냐는 처음엔 코드 몇 줄 차이로 보이지만, 한 번 정하고 나면 되돌리는 데 몇 달이 걸리는 결정이었습니다.
남편이 켠 조명이 아내 폰에는 꺼져 있습니다
IoT 제품을 만들 때 가장 흔히 듣는 요구는 "우리 집 기기를 가족이 같이 쓰게 해달라"는 것입니다. 사무실이면 "팀원들이 같이", 매장이면 "직원들이 같이", 공장이면 "관리자 여러 명이 같이"가 되지만 표현만 다르지 전부 같은 요구입니다. 업계에서는 이걸 M:N 통신이라고 부르는데, 여러(M) 사용자가 여러(N) 기기를 함께 쓰는 구조라는 뜻입니다.
이전 세대 시스템들을 조사하면서 실제로 확인한 증상은 네 가지였습니다.
| 증상 | 사용자가 겪는 것 |
|---|---|
| 화면이 안 맞는다 | 남편이 조명을 켰는데 아내 폰에는 여전히 "꺼짐". 앱을 껐다 켜야 갱신됨 |
| 공유가 아예 안 된다 | 기기를 등록한 사람만 제어 가능. 가족은 계정을 공유해야 함 |
| 누가 했는지 모른다 | 계정을 공유하니 "밤 11시에 누가 보일러를 껐는지" 기록이 안 남음 |
| 초대가 번거롭다 | 기기를 한 대씩 각각 공유해야 함. 10대면 10번 |
첫 번째 증상을 그림으로 보면 원인이 분명해집니다.
봐야 할 것은 아내 앱이 대화에 아예 끼어 있지 않다는 점입니다. 서버는 명령을 보낸 사람에게만 결과를 돌려주기 때문에, 같은 기기를 보고 있는 다른 사람은 애초에 고려 대상이 아니었습니다.
계정을 공유하면 누가 껐는지 알 수 없게 됩니다
네 증상 중 세 번째가 특히 아픈데, 계정 공유는 당장은 편해 보이지만 책임 추적이 불가능해지므로 산업용 장비나 도어락처럼 안전이 걸린 제품에서는 애초에 쓸 수 없는 방식이기 때문입니다.
처음 설계가 "1인 1소유"였기 때문입니다
초기 설계도를 열어보면 구조가 이랬습니다. 회원가입을 하면 그 사람의 전용 기기 그룹이 하나 생기고, 자기 그룹에만 접근 권한이 붙습니다. 깔끔하고, 가입하는 순간 "기기 보관함"이 하나 만들어지니 이해하기도 쉽습니다.
문제는 보관함이 사람에게 묶여 있다는 것이었습니다.
점선이 갈 곳이 없다는 것이 이 그림의 요점입니다. 거실 조명을 아내 보관함에도 넣는 순간 보관함은 더 이상 한 사람의 것이 아니게 되므로 개념 자체가 무너집니다. 실제로 이 설계도 한쪽에는 "장소 개념 추가 필요"라는 메모가 남아 있었으니, 만든 사람도 이미 알고 있었던 셈입니다.
기기를 무엇에 묶느냐가 나머지를 전부 결정합니다
핵심 질문은 하나로 좁혀졌고, 후보는 셋이었습니다. 이 결정은 이미 출하된 기기의 통신 방식까지 바꾸기 때문에 한 번 정하면 되돌리기가 매우 어렵습니다.
| 기준 | 어떤 구조인가 | 장점 | 결정적 한계 |
|---|---|---|---|
| ① 사람에 묶기 | 사용자 1명 = 기기 그룹 1개 | 가입만 하면 바로 동작. 단순함 | 공유가 구조적으로 불가능. 별도 기능을 얹어도 계속 어긋남 |
| ② 기기마다 묶기 | 기기마다 개별 권한 | 가장 세밀한 통제 | 기기 10대 = 권한 설정 10번. 공유·회수 운영이 폭발 |
| ③ 장소에 묶기 | "우리 집" 같은 공간으로 묶고, 사람은 그 공간의 멤버 | 공유가 기본 동작. 초대 한 번이면 그 장소 기기 전체 | 공간이라는 개념을 새로 만들어야 함 |
같은 상황을 세 방식으로 그려보면 차이가 숫자로 드러납니다.
연결 개수가 곧 운영 부담입니다. ②는 사람 3명 × 기기 4대 = 연결 12개를 관리해야 하고 기기가 늘 때마다 사람 수만큼 곱해지지만, ③은 사람도 장소에 붙고 기기도 장소에 붙으므로 합으로 끝납니다. 곱셈과 덧셈의 차이입니다.
사내 구현체 세 개를 뜯어봤더니 공교롭게도 셋 다 다른 답을 갖고 있었는데, 한 곳은 ①, 한 곳은 ②, 한 곳은 ③에 가까웠습니다. 같은 회사에서 같은 문제를 세 번 다르게 푼 셈이고, 그중 실제 운영에서 가장 덜 아팠던 것이 ③이었습니다.
성숙한 플랫폼은 이미 같은 답을 갖고 있었습니다
우리끼리만 비교하면 시야가 좁아지므로 오픈소스·상용 IoT 플랫폼 8곳(ThingsBoard · EMQX · Golioth · Mender 등)의 구조도 함께 봤습니다. 성숙한 플랫폼일수록 자산(asset) · 사이트(site) · 프로젝트 같은 공간 또는 조직 단위를 두고 있었고, 이름은 달라도 발상은 하나로 모였습니다. 사람이 아니라 공간에 기기를 묶고, 사람은 그 공간에 초대한다는 것입니다.
혹시 같은 구조를 검토 중이시라면, 앞의 연결 개수 계산부터 먼저 해보시길 권합니다.
그래서 사람이 아니라 장소에 묶었습니다
③으로 가되 위에 한 층을 더 얹어 네 단계로 정리했습니다.
| 계층 | 무엇 | 예 | 성격 |
|---|---|---|---|
| 테넌트 | 고객사. 서로 절대 섞이지 않는 최상위 경계 | A사 / B사 | 격리 경계 |
| Pool | 장소 | 우리 집 · 강남점 · 3공장 | 권한과 통신의 단위 |
| Room | 방 | 거실 · 1층 홀 | 화면상의 분류 |
| Device | 기기 | 조명 · 온습도계 | - |
그림에서 봐야 할 것은 점선과 빈칸입니다. 점선(멤버)이 사람과 장소를 이을 뿐 사람은 기기에 직접 연결되지 않으므로, "본사 관리자"처럼 여러 장소에 동시에 속하는 경우도 자연스럽게 표현됩니다. 그리고 A사와 B사 사이에는 아무 연결도 없는데, 이 단절이 뒤에서 다룰 격리의 출발점입니다.
Pool 위에 테넌트를 얹은 것이 중요했습니다. 우리가 만드는 건 한 회사의 앱이 아니라 여러 고객사가 각자 제품을 올리는 플랫폼이고, 고객사 사이의 격리는 협상 대상이 아니기 때문입니다.
공유가 기능이 아니라 기본 동작이 됩니다
사람에 묶는 구조에서 공유는 나중에 얹는 별도 기능이지만, 공간에 묶으면 초대 한 번이 그 공간의 기기 전체를 여는 일이라 따로 만들 것이 없습니다.
| 사람 중심 | Pool 중심 | |
|---|---|---|
| 가족 추가 | 기기마다 공유 설정 | Pool에 초대 1회 |
| 기기 10대 추가 | 가족 수 × 10번 설정 | 자동으로 전원이 접근 가능 |
| 퇴사자 접근 차단 | 기기마다 회수 | Pool 멤버에서 제외 1회 |
세 번째 줄이 B2B에서 특히 큽니다. 직원이 나갔는데 어느 기기에 권한이 남아 있는지 아무도 모르는 상황이 사라지기 때문입니다.
오른쪽이 안전한 이유는 빠뜨릴 수 있는 단계가 없다는 데 있습니다. 왼쪽은 사람이 목록을 챙겨야 하는데, 사람이 챙기는 일은 언젠가 빠집니다.
같은 장소를 보는 사람에게 상태가 동시에 도착합니다
기기와 앱은 MQTT라는 방식으로 대화하는데 쉽게 말하면 채널을 구독하는 단체 대화방이라고 보면 되고, 채널 이름은 아래와 같이 정했습니다.
pools/{장소ID}/{기기ID}/state ← 기기의 현재 상태가 올라오는 채널
이 채널을 그 장소의 모든 사람이 함께 구독합니다.
화살표 두 개가 동시에 나가는 것이 핵심입니다. 남편이 조명을 켜면 조명이 "켜졌다"고 알리고, 그 알림은 같은 장소를 보고 있는 모든 앱에 동시에 도착합니다. 서버를 한 번 더 거치지 않으므로 체감상 즉시이고, 앞에서 본 첫 번째 증상이 여기서 사라집니다.
로그인에 성공했다는 것이 무엇이든 해도 된다는 뜻은 아닙니다
Pool로 묶는 것만으로는 격리가 완성되지 않습니다. 왜 그런지 설명하려면 권한을 아예 걸지 않으면 어떻게 되는지부터 봐야 합니다.
대화방 서버는 기본적으로 "누가 어느 방에 들어갈 수 있는지"를 모릅니다. 로그인(인증)은 당신이 누구인지를 확인하는 일이고 권한(인가)은 당신이 무엇을 할 수 있는지를 정하는 일인데, 앱을 만들다 보면 "우리 서비스에 로그인한 사람"이면 일단 통과시키기 쉽습니다. 그러면 남의 데이터인지 아닌지를 앱 화면 쪽에서 걸러내는 구조가 되고, 이런 구조는 조용히 무너집니다.
"장소 ID가 난수인데 괜찮지 않나요?"
우리 장소 ID는 추측할 수 없는 긴 난수이므로 남의 장소 ID를 알아낼 방법이 없고, 따라서 안전해 보입니다. 하지만 구독에는 "전부"를 뜻하는 표현이 있습니다.
pools/+/+/state ← "모든 장소의, 모든 기기의 상태를 주세요"
+ 기호 하나면 되므로 추측할 필요가 아예 없습니다. 난수 ID는 찍어서 맞히기는 막지만 그냥 전부 달라고 하는 요청은 못 막고, 권한을 걸지 않으면 이 한 줄이 그대로 통과합니다.
갈림길이 출입증 검증 한 곳뿐이라는 점을 봐주세요. 권한이 없으면 앱이 전체 구독을 시도하는 것만으로 한 고객사 직원이 다른 고객사의 모든 기기 상태를 실시간으로 받게 되고, 기기 인증서가 한 장이라도 유출되면 남의 장소 기기에 제어 명령을 발행할 수 있어 도어락이나 밸브 제품에서는 물리적 사고로 이어집니다.
그런데 정작 가장 자주 일어나는 건 공격이 아닙니다. 앱 코드에 장소 ID를 잘못 넣는 평범한 버그 하나면 배포 즉시 데이터가 새어 나갑니다. 사람이 만드는 코드에는 버그가 들어가므로, 격리가 앱 코드의 품질에 의존하는 한 언젠가 반드시 샙니다.
"그럼 앱에서 걸러내면 되지 않나요?"
안 됩니다. 화면에 안 보여주는 것과 데이터를 안 받는 것은 전혀 다른 일이기 때문입니다.
앱에서 걸러낸다는 건 이미 도착한 남의 데이터를 화면에만 안 그리는 것입니다.
데이터는 이미 그 기기까지 갔으므로, 개발자 도구를 열거나 통신을 들여다보면 그대로 보입니다.
계약서에 "고객사 데이터는 서로 격리됩니다"라고 쓰려면 데이터가 애초에 그쪽으로 가지 않아야 합니다. 그래서 각 기기와 앱에는 "이 채널만 열 수 있다"고 적힌 출입증이 발급되고, 클라우드가 발급한 그 출입증을 구독·발행을 시도할 때마다 브로커가 검증합니다.
| 대상 | 출입증에 적히는 것 |
|---|---|
| 기기 | 자기가 속한 장소의, 자기 자신의 채널: 변수로 표현되므로 기기가 100만 대여도 양식은 한 장 |
| 앱 | 그 사용자가 소속된 장소 목록 |
기기 쪽이 특히 우아합니다. 출입증에 장소 ID를 직접 적지 않고 "접속한 기기의 소속 장소"라는 변수를 쓰기 때문에, 기기가 늘어도 관리할 문서가 늘지 않습니다.
같은 출입증으로 한 요청은 통과하고 한 요청은 거부된다는 점이 요점입니다. 앞의 pools/+/+/state 시도 역시 이 구조에서는 연결 단계에서 거부되므로 앱까지 데이터가 오지 않습니다.
"우리 앱은 남의 기기를 조회하지 않도록 만들었습니다"와 "애초에 접근 권한이 없습니다"는 신뢰 수준이 완전히 다릅니다.
앞은 약속이고 뒤는 구조입니다. 우리는 뒤쪽입니다.
이 차이는 실무에서 바로 드러납니다. B2B 계약 검토와 보안 인증 심사에서 반드시 확인하는 항목이 격리가 애플리케이션 로직이 아니라 시스템 수준에서 강제되는가이기 때문입니다.
결정한 뒤에 나온 문제들이 진짜였습니다
방향은 명확했지만 실제로 동작하게 만들려면 풀어야 할 문제가 남아 있었고, 그중 일부는 지금도 완전히 만족스럽지 않은데 그 부분까지 정직하게 적습니다.
사람 쪽 출입증은 지금도 더 나은 방법이 있을 것 같습니다
기기 쪽 출입증은 "자기가 속한 장소"라는 변수 하나로 우아하게 풀렸지만, 앱 사용자는 여러 장소에 속할 수 있어서 소속 장소 목록을 출입증에 직접 적어야 합니다. 그래서 초대·탈퇴·장소 삭제가 일어날 때마다 출입증을 다시 발급해야 하는데, 이 갱신이 실패하면 사용자가 자기 기기를 못 켭니다. 우리가 만든 구조에서 가장 아픈 실패 지점입니다.
세 가지 장치를 넣어 대응했습니다. 출입증 갱신을 전용 동기화 기능으로 분리해 재시도할 수 있게 했고, 주기적으로 실제 멤버십과 출입증을 대조하는 검증 배치를 돌립니다. 그리고 가장 중요한 것이 차집합 동기화입니다.
두 흐름의 마지막 상자만 비교하면 됩니다. 권한을 "더한다"가 아니라 "지금 있어야 할 상태와 맞춘다"로 생각하는 것이 차이이고, 앞의 방식에서는 퇴사자나 이동한 직원의 권한이 조용히 남습니다. 이 과정에서 "갱신됐겠지"를 가정하지 않는 것이 설계 원칙으로 굳었습니다.
사내 구현체를 뜯어보다 출입증 상한을 발견했습니다
기존 구현 하나는 장소마다 출입증을 한 장씩 만들고 있었는데, 클라우드에는 계정당 출입증 개수 상한이 2,500개로 걸려 있습니다. 즉 장소가 2,500개가 되는 순간 더 이상 만들 수 없고, 지금은 문제없지만 고객사가 늘면 반드시 부딪히는 벽입니다.
아래쪽 흐름에는 끝이 없다는 것이 이 그림의 전부입니다. 기기 그룹 단위로 권한을 걸도록 바꾸면서 장소가 몇 개가 되든 출입증 개수가 늘지 않게 됐는데, 이런 문제는 서비스가 커진 뒤에 발견하면 전면 재작업입니다. 설계 단계에서 사내 구현체를 뜯어본 시간이 여기서 값을 했습니다.
실시간으로 흘릴 것과 아닌 것을 나눴습니다
채널 이름에 고객사 정보를 넣을지도 고민했지만 넣지 않기로 했습니다. 장소 ID가 이미 추측 불가능한 난수인 데다, 실제 격리는 출입증이 담당하므로 채널 이름에 무엇을 적든 출입증이 허술하면 뚫리기 때문입니다. 이름으로 구분하는 것과 권한으로 막는 것은 다른 일이고, 우리는 후자에 격리를 걸었습니다. 고객사 정보는 채널 이름 대신 기기 속성에 심어 데이터 분류와 사용량 집계에 씁니다.
더 큰 문제는 요금이었습니다. 처음엔 기기가 보내는 데이터를 전부 앱에 실시간으로 흘릴 생각이었는데, 온습도계가 10초마다 값을 보내면 하루 8,640건이고 기기 100대면 하루 86만 건이라 계산이 감당되지 않았습니다. 앱이 그걸 전부 받으면 배터리와 데이터 요금이 그대로 사용자 부담이 됩니다.
그래서 채널을 성격별로 넷으로 나누고 누가 받을지를 각각 정했습니다.
| 채널 | 무엇 | 누가 받나 |
|---|---|---|
cmd | 제어 명령 | 해당 기기만 |
state | 현재 상태 | 그 장소의 모든 앱 ← M:N의 핵심 |
tele | 주기 센서값 | 서버만 (앱은 안 받음) |
evt | 이벤트·오류 | 그 장소의 모든 앱 |
굵은 실선(실시간)과 점선(필요할 때 조회)의 구분이 핵심입니다. 앱은 센서값 원본 대신 집계된 값을 필요할 때만 가져오는데, 실시간이 필요한 것과 아닌 것을 나눈 것만으로 앱이 받는 데이터가 크게 줄었습니다.
지금 손에 있는 것을 그대로 적습니다
현재 이 구조는 설계 확정 + 동작하는 화면 프로토타입 단계이고 실기기 연동은 다음 단계입니다. 지금까지 이야기한 것을 한 장으로 모으면 이렇습니다.
①~④가 이 글 전체의 요약입니다. 점장이 켠 결과가 ④에서 두 사람에게 동시에 도착하고, 그 두 사람은 같은 장소의 출입증을 가진 사람들입니다.
문서가 아니라 구현이 따라야 하는 계약으로 채널을 고정했으므로, 이걸 어기면 코드 리뷰에서 반려됩니다.
pools/{장소ID}/{기기ID}/cmd 제어 명령 앱·서버 → 기기
pools/{장소ID}/{기기ID}/state 상태·응답 기기 → 그 장소의 모든 앱
pools/{장소ID}/{기기ID}/tele 주기 센서값 기기 → 서버
pools/{장소ID}/{기기ID}/evt 이벤트·오류 기기 → 그 장소의 모든 앱
여기에 신뢰성 규칙이 함께 붙습니다. 명령마다 고유 ID를 부여해 같은 ID를 다시 받으면 한 번만 실행하고, 재전송은 1초 → 2초 → 4초 → 8초로 최대 5회까지 시도하며, 명령에 발행 시각과 1회용 난수를 넣어 예전 명령을 녹음해 다시 보내는 공격을 차단합니다.
프로토타입은 실제로 클릭되는 화면으로 만들어, 이 구조가 화면으로 성립하는지를 확인했습니다.
| 화면 | 확인한 것 |
|---|---|
| 장소 상세 | 기기 수 · 온라인 수 · 방 · 멤버 수를 한 화면에서. 멤버 초대와 장소별 역할 지정 |
| 기기 제어 패널 | 스위치·슬라이더·선택·실행 4종 컨트롤. 조작하면 실제로 발행될 채널과 메시지가 로그로 출력 |
| 모바일 장소 화면 | 사용자가 여러 장소를 오가는 흐름 |
기기 제어 패널이 특히 유용했습니다. 슬라이더를 움직이면 pools/.../cmd로 어떤 메시지가 나가는지 눈으로 보이기 때문에, 비개발자도 구조를 이해하고 검토에 참여할 수 있었습니다. 이 밖에 사내 구현체 3종 비교, 외부 플랫폼 8종 대조, 관리자·앱 100여 개 화면의 조회 패턴 전수 대조가 검증 산출물로 남았습니다.
정한 것은 한 줄뿐이었습니다
가장 크게 배운 것은 공유가 기능이 아니라 구조라는 점입니다. 공유·초대·권한 회수를 나중에 붙일 기능 목록으로 보면 계속 어긋나지만, 처음에 무엇을 기준으로 기기를 묶을지만 제대로 정하면 그 기능들이 대부분 저절로 따라옵니다. 반대로 이걸 잘못 정하면 나중에 아무리 기능을 추가해도 메워지지 않는데, 통신 채널 이름까지 바뀌므로 이미 출하된 기기가 있으면 사실상 못 고칩니다.
배타적으로 보이던 선택지가 사실은 층위가 달랐던 경험도 남았습니다. "우리 개념이냐 클라우드의 기기 그룹 기능이냐"로 며칠을 고민했는데, Pool은 우리가 정의하는 비즈니스 개념이고 기기 그룹은 그걸 실현하는 기술 수단이라 애초에 경쟁 관계가 아니었습니다. Pool을 만들 때 기기 그룹을 함께 생성하는 것으로 정리하고 나니, 검증된 기존 기능 위에 우리 개념을 그대로 얹을 수 있었습니다. 그 뒤로는 설계에서 A냐 B냐로 오래 막히면 정말 같은 층위의 문제를 푸는 게 맞는지 먼저 확인하는 습관이 생겼습니다.
이 구조가 고객에게 무엇을 주는지로 정리하면 이렇습니다.
| 고객이 얻는 것 | 근거 |
|---|---|
| 공유·초대·권한 회수가 기본 제공 | 별도 개발 없이 Pool 멤버십으로 해결 |
| 여러 사용자 화면이 실시간으로 일치 | 상태 채널을 장소 단위로 공동 구독 |
| "누가 언제 조작했는지" 추적 가능 | 계정 공유가 필요 없으므로 |
| 고객사 간 격리를 시스템이 보장 | 앱 코드 품질과 무관하게 접근 자체가 차단 |
| 규모가 커져도 구조가 안 바뀜 | 출입증 개수가 장소 수에 비례하지 않음 |
마지막 줄이 이 설계에서 가장 신경 쓴 부분입니다. 처음 100대에서 잘 도는 구조는 많지만, 10만 대에서도 같은 구조인 것이 어렵습니다.
돌아보면 정한 것은 한 줄뿐이었습니다. 기기를 사람이 아니라 장소에 묶는다. 공유도, 실시간 동기화도, 규모 확장도 대부분 그 한 줄에서 따라 나왔습니다. 비슷한 고민을 하고 계신 분께 조금이나마 도움이 되었으면 합니다.
읽어주셔서 감사합니다.



