차량을 제어하는 시스템은 단순한 서버 애플리케이션보다 훨씬 복잡하다.
사용자가 스마트폰으로 차량 문을 잠그는 동작 하나에도 모바일 앱, 백엔드 서버, 차량과 통신하는 연결 계층, 차량 내부의 제어기와 게이트웨이, 실제로 문을 잠그는 액추에이터가 관여한다. 여기에 인증과 암호화, 상태 관리, 커넥션과 세션, 메시지 유실과 순서 뒤바뀜, 중복 명령과 재시도, 차량의 물리적 안전 조건까지 고려해야 한다.
차량 제어 시스템에는 클라이언트, 서버, 네트워크, 차량 통신, 임베디드 소프트웨어, 보안 등 서로 다른 영역의 전문성이 필요하다. 실제로 여러 팀의 엔지니어가 각자의 경계를 나눠 책임진다.
나는 42dot의 Connected Service Group 초기 멤버로 합류해 Connect App 제품을 만들었다. 당시 팀원 분들과 함께 차량 원격 제어 시스템을 초기부터 설계하고 구축했으며, 테슬라의 원격 제어 시스템을 직접 벤치마킹하기도 했다.
차량 제어 시스템은 일반적인 웹 서비스와 달리 소프트웨어 명령이 실제 물리 세계의 상태를 바꾼다는 점에서 독특한 설계 문제를 갖는다.
이 글은 차량과 통신하고 원격 명령을 전달하는 서버를 만드는 엔지니어의 관점에서 차량 제어 시스템을 다룬다. 하드웨어 추상화나 차량 내부 시스템은 다루지 않으므로, 본문에서 “차량"이라고 쓴 것은 실제로는 차량 내부의 여러 컴포넌트를 묶어 부르는 말이다. 공개된 테슬라의 차량 원격 제어 시스템과 차량 명령 프로토콜을 하나의 사례로 참고한다.
출발점은 하나의 질문이다.
불확실한 네트워크를 사이에 두고 물리적인 대상을 제어해야 할 때, 소프트웨어 엔지니어는 무엇을 신뢰해야 하고 무엇을 신뢰해서는 안 되는가?
차량 원격 제어에서는 클라우드에서 명령을 보냈다는 사실과 차량이 실제로 그 명령을 실행했다는 사실이 항상 일치하지 않는다. 명령이 네트워크를 통과했는지, 차량이 받았는지, 처리했는지, 상태가 실제로 바뀌었는지, 그 결과를 클라우드가 관찰했는지는 서로 다른 문제다.
신뢰성, 확장성, 탄력성, 장애 내성, 성능, 관측 가능성 같은 일반적인 설계 원칙은 이미 많은 시스템 디자인 자료가 다룬다. 여기서 다루는 것은 차량 원격 제어에서만 발생하는 제어의 불확실성이다. 불확실한 네트워크를 통해 실제 차량이라는 물리적 대상을 제어하는 문제는 단순한 서버 간 통신의 문제로 환원하기 어렵다.
차량의 불안정한 통신 환경
웹 서버는 데이터센터나 클라우드에 있다. 전원이 안정적으로 공급되고, 여러 네트워크 장비를 거쳐 인터넷에 연결되며, 연중무휴 가동(24/7)을 전제한다. 클라이언트와 서버 사이의 연결은 끊어져도 서버 자체가 이동하거나 네트워크 환경이 바뀌지는 않는다.
반면 차량은 이동하는 엣지 디바이스(edge device)다. 차량과 서버 사이의 통신은 차량이 어디에 있는지, 어떤 상태인지, 어떤 네트워크를 사용하고 있는지에 따라 계속 달라진다.
예를 들어 차량이 도로를 주행하고 있다면 하나의 기지국에 계속 연결되어 있는 것이 아니다. 차량이 이동하면서 더 가까운 기지국으로 연결을 변경하는 핸드오버가 발생한다. 핸드오버 과정에서 연결이 순간적으로 끊기고, 패킷이 지연되거나 유실된다.
차량이 지하주차장으로 들어가는 경우에는 LTE나 5G 신호가 약해지거나 완전히 사라질 수 있다. 이 경우 차량은 인터넷에 연결할 수 없기 때문에 서버가 보낸 명령을 즉시 받을 수 없다.
지상에서도 항상 통신이 가능한 것은 아니다. 산이나 터널, 외곽 지역 등에서는 LTE/5G 음영 지역이 있고 이러한 지역을 지나가는 동안에는 서버와 차량 사이의 통신이 일시적으로 끊길 수 있다.
차량은 항상 모든 통신 모듈과 컴퓨터를 켜놓고 있는 것이 아니다. 차량이 장시간 주차되어 있으면 배터리 소모를 줄이기 위해 딥 슬립(Deep Sleep) 상태로 들어갈 수 있다. 이때는 통신 모듈이나 차량의 일부 컴퓨터까지 절전 상태가 될 수 있다. 따라서 서버에서 명령을 보내더라도 차량이 즉시 이를 받을 수 있다고 가정할 수 없다. 차량을 깨우는 과정 자체에도 시간이 필요할 수 있다.
차량의 전원 상태 역시 변수다. 특히 차량의 12V 배터리 상태가 좋지 않거나 전원 관리 정책에 따라 일부 시스템이 비활성화되면 통신이나 명령 처리에 영향을 줄 수 있다. 즉, 차량은 서버처럼 항상 동일한 전원 상태에서 실행되는 컴퓨터가 아니다.
네트워크 연결 방식 자체가 변경될 수도 있다. 차량에 LTE/5G와 Wi-Fi가 모두 제공되는 경우 상황에 따라 연결이 변경될 수 있다. LTE에서 Wi-Fi로, 또는 Wi-Fi에서 다시 셀룰러 네트워크로 전환되는 과정에서 기존 연결이 끊어지고 새로운 연결이 만들어질 수 있다.
따라서 차량은 언제든 연결되어 있지 않을 수 있고, 연결되어 있더라도 통신이 지연되거나 끊길 수 있으며, 차량 자체가 명령을 처리할 수 없는 상태일 수도 있음을 항상 기억해야 한다.
그리고 이 불안정함은 클라우드와 차량 사이의 구간에서 끝나지 않는다. 차량 내부의 구성은 제조사와 차종마다 다르고 외부에 공개되지도 않으므로 아래는 일반적인 차량 전자 아키텍처를 전제한 서술이지만, 대체로 하나의 명령은 액추에이터에 닿기까지 여러 번 서로 다른 구간을 통과한다. 클라우드에서 차량의 통신 유닛까지는 셀룰러 망 위에 얹힌 HTTPS일 것이고, 통신 유닛에서 게이트웨이까지는 차량 내부 이더넷일 수 있으며, 게이트웨이에서 도어 컨트롤러까지는 CAN(Controller Area Network)일 수 있고, 마지막 구간은 전기 신호다. 각 구간은 서로 다른 타임아웃과 서로 다른 실패 모드를 갖는다.
그리고 결정적으로, 하위 구간에서 발생한 결과가 상위 계층까지 온전히 전파된다고 보장할 수 없다. CAN은 애초에 종단 간 명령의 실행 결과를 전달하기 위한 프로토콜이 아니며, 각 계층과 구간은 서로 다른 방식으로 동작한다. 상위에서 “명령을 보냈다"는 사실과 하위에서 “액추에이터가 실제로 움직였다"는 사실 사이에는, 결과를 보장할 수 없는 단절이 있다. 즉, 상위 계층이 확인할 수 있는 것은 명령을 전달했다는 사실이지, 그 명령이 물리 세계의 상태를 실제로 변경했다는 사실이 아니다.
ACK가 보장하지 못하는 것
차량 원격 제어 시스템에서 클라이언트가 차량으로 명령을 보내고 전달받은 HTTP 200 응답은 차량이 명령을 성공적으로 실행했다는 뜻이 아니다. 명령을 실행했을 수도, 하지 못했을 수도 있다. 그저 응답을 받았다는 의미일 뿐이다. 클라이언트가 명령을 전송했지만 차량으로부터 응답을 받지 못했더라도, 차량은 명령을 수신하고 실행했을 수 있다.
예를 들어 차량 제어 서버에서 Door Lock 명령을 차량으로 보낸다. 차량은 명령을 검증하고, ECU(Electronic Control Unit)로 전달하고, 액추에이터를 구동한 뒤, 실제 잠금 상태를 확인한다.
이때 클라우드 명령에 대한 ACK(응답)를 차량이 동기적으로 준다고 가정해보자.
제어 명령에 대한 응답이 현재 차량의 상태가 LOCKED임을 보장할 수 있을까?
차량 제어 서버에서 응답을 모바일 앱으로 전달하는 과정에서 차 안에서 누군가 차 문 잠금을 해제할 수도 있다. 이 경우 앱에서는 LOCKED로 표시되는데
실제 차량의 상태는 UNLOCKED일 것이다.
따라서 차량에서 물리적 변경이 발생한 뒤에 명령에 대한 응답을 동기적으로 준다고 하더라도 차량이 주는 ACK는 차량의 최종적인 상태를 보장하지 못한다.
이 동기 방식은 실무에서 쓸 수 없다. 물리적 작업은 하위 ECU와 액추에이터의 처리 시간에 의존하므로, 모든 명령의 완료를 기다렸다 응답하면 차량 내부의 처리 자원과 연결을 오래 점유한다. 그래서 차량은 물리적 변경을 비동기로 처리한다.
동기든 비동기든, 메시지가 유실될 수 있는 채널에서는 ACK도 유실된다.
잠금 명령이 차량에 도착한다. 차량은 문을 잠그고, 자기 상태를 잠김으로 바꾼 뒤 응답을 보낸다. 만약 응답이 네트워크 어딘가에서 유실되면 서버는 시간이 지나도 아무것도 받지 못한다. 이번에는 반대의 상황이다. 공조 켜기 명령이 차량에 닿기도 전에 사라진다. 차량은 아무것도 받지 못했고, 아무것도 실행할 수 없기 때문에 공조는 여전히 꺼져 있는 상태일 것이다.
두 경우에 차량의 실제 상태는 정반대다. 그런데 서버가 관측한 것은 두 경우 모두 “응답이 오지 않았다” 하나뿐이다. 즉, 서버는 통신 결과만으로 차량에서 실제로 어떤 일이 발생했는지를 확정할 수 없다.
“차량에서 ACK를 한 번 더 보내면 되지 않나?“라고 생각할 수 있다.
차량에서 응답을 한 번 더 보낸다고 하자. 그런데 차량은 자기 응답이 서버에 도착했는지 모른다. 차량 입장에서는 “서버가 못 받았다면 서버는 명령을 다시 보낼 텐데, 그러면 문을 두 번 잠그게 되는 것은 아닐까?” 라고 생각할 수밖에 없다. 그러면 서버가 “네 응답을 받았다"를 알려주면 되지 않을까? 이 경우도 응답은 유실될 수 있고 이러한 과정을 몇 번을 주고받든 마지막으로 보낸 메시지의 도달 여부는 언제나 알 수 없다.
이것이 1975년에 정식화된 Two Generals’ Problem 이다. Two Generals’ Problem은 “메시지가 유실될 수 있는 통신 채널에서 완벽한 합의를 달성할 수 있는가?” 라는 이론적인 문제이며, 그 답은 신뢰할 수 없는 채널 위에서는 합의(consensus)가 불가능하다는 것이다.
이것이 차량 원격 제어와 같은 분산 시스템에서 발생하는 불확실성이며, 차량 원격 제어 시스템 설계의 출발점은 차량의 물리적인 특성을 이해하는 것과, 이 불확실성을 인정하는 것이다.
“TCP를 쓰면 되지 않나?“라고 생각이 든다면 TCP가 보장하지 않는 것이 무엇인지 인지할 필요가 있다. TCP는 바이트 스트림의 순서는 보장하고, 유실 시 재전송을 한다. 하지만 애플리케이션이 그 바이트를 읽었는지, 처리했는지, 처리 결과가 무엇인지는 보장하지 않으며, 애플리케이션 수준의 요청 순서도 보장하지 않는다.
잠금 명령이 차량의 TCP 스택에 도착하는 순간 커널이 자동으로 TCP ACK를 보낸다. 서버는 “전달 완료"를 본다. 그런데 그 시점에 애플리케이션은 아직 그 바이트를 읽지도 않았다. 애플리케이션이 read를 호출하고 명령을 파싱하는 사이에 프로세스가 종료되면, 명령은 도어 컨트롤러까지 가지 못한다. 서버는 TCP ACK를 받았으므로 성공이라고 판단하고, 문은 잠기지 않은 채로 남는다.
여기서 결정적인 것은 ACK를 누가 보내는가이다.
TCP ACK를 받았다는 것은 상대방의 TCP 커널이 데이터를 정상적으로 수신했다는 것을 의미할 뿐이다. 애플리케이션이 해당 데이터를 실제로 처리했는지, 더 나아가 그 처리로 인해 원하는 비즈니스 상태가 만들어졌는지는 보장하지 않는다.
그렇다면 애플리케이션 레벨에서 ACK를 보내면 해결될까? 그렇지 않다. 애플리케이션 ACK를 사용하더라도 같은 문제가 한 계층 위에서 반복된다.
예를 들어 차량이 명령을 정상적으로 수신하고 애플리케이션 ACK를 전송한 직후 장애가 발생했다고 하자. 서버는 ACK를 받았기 때문에 명령이 성공했다고 판단할 수 있지만, 실제로는 차량이 명령을 끝까지 처리하지 못했거나 물리적인 상태가 변경되지 않았을 수 있다. 결국 TCP가 보장하는 것은 transport reliability, 즉 데이터가 전송 계층에서 상대방에게 전달되었다는 사실이다. 하지만 우리가 차량 제어 시스템에서 원하는 것은 business-operation reliability, 즉 “요청한 작업이 실제로 완료되어 원하는 비즈니스 상태가 만들어졌는가"에 대한 보장이다.
ACK는 차량에서 명령이 처리되어 차량의 실제 상태(Actual State)가 변경되었음을 보장하지 못한다.
그렇다면 명령의 성공 여부는 무엇으로 판단해야 할까.
명령과 상태 분리
차량 제어 서버에서 명령을 전달하고 차량 상태가 변경되었을 때 내 명령 때문에 발생한 것이라고 확신할 수 있을까? 차에서 사람이 물리적으로 Door Lock/Unlock 등의 행위를 할 수 있기 때문에 차량의 상태가 변경되었을 때 그 상태 변경이 내 명령 때문에 발생했는지를 보장할 수 없다. 특히 위에서 살펴본 것처럼 ACK 메커니즘으로는 불가능하다.
이 문제를 해결하기 위한 핵심은 명령과 상태를 분리하는 것이다. 명령은 차량의 상태를 변경해 달라는 요청일 뿐이고, 최종 상태는 차량의 상태로 확인해야 한다.
최종 상태를 확인하는 방법은 두 가지다.
첫째는 델타(Delta) 다. 차 문, 공조, 트렁크처럼 차량에서 물리적 변경이 일어나 실제 상태(Actual State)가 바뀌면, 차량이 바뀐 부분만 클라우드로 보낸다. 클라우드는 이 델타로 앱에 보여줄 상태를 갱신한다.
둘째는 스냅샷 조회다. 델타를 처리하는 도중 예외가 발생하면 클라우드가 가진 상태와 차량의 실제 상태가 어긋난다. 이때 클라우드가 차량의 현재 상태 전체를 조회해 맞춘다. 델타의 폴백(Fallback) 이다.
즉 클라우드는 명령을 전달할 뿐이고, 최종 상태는 차량이 보내는 델타로 알며, 어긋나면 스냅샷으로 복구한다.
상태 수렴 패턴
차량은 클라우드에서 보낸 차 문 열기, 충전 퍼센트 설정, 온도 설정과 같은 명령을 어떻게 안전하게 처리할 수 있을까?
차량은 하드웨어 오류, 통신 오류 등 다양한 원인으로 인해 클라우드 요청이 정상적으로 반영되지 않을 수 있다.
예를 들어 Door Open 명령을 차량에 전달했다고 가정하자. 차량은 명령을 처리하는 과정에서 하드웨어 오류로 인해서 처리하지 못했다. 다시 차량이 복구되고 나서 해당 명령을 처리해야 할까? 차량이 복구된 시점에 운전자는 차량과 다른 곳에 있을 수 있다. 이때 차 문이 열린다면 큰 이슈가 될 수 있다.
두 번째 예를 살펴보자. 이번에는 충전 목표를 80%로 설정하는 명령을 차량에 전달했다고 가정하자. 이번에는 차량의 네트워크 연결이 끊어졌다. 네트워크가 복구되고 나서 해당 명령은 처리되어야 할까?
첫 번째 예시와 두 번째 예시를 든 이유는 명령에는 수명이 있다는 것을 말하기 위해서이다. Door Open의 경우에는 일회성 명령이다. 충전 목표를 80%로 설정은 지속되어야 하는 상태를 요구하는 명령이다. 따라서 두 번째 명령의 경우에는 네트워크가 복구되고 나면 차량이 사용자의 의도를 명확히 반영해야 한다.
클라우드가 차량으로 보낸 명령은 사용자의 의도이며 의도는 원하는 상태를 의미하기도 한다. 이것을 Desired State라고 한다. 차량은 하드웨어 오류, 통신 오류 등의 상황에서 차량이 복구되고 나서 사용자의 의도를 처리하기 위해서 의도를 Desired State로 저장한다. 차량의 실제 상태는 Actual State라고 한다.
Desired State를 반영하려면 실제 상태, 전제조건, 정책을 함께 고려해야 한다. Desired와 Actual을 비교해 차이를 좁히는 주체를 Reconciler라고 한다. 차량의 물리적 상태가 바뀌면 차량 내부의 상태 보고 컴포넌트가 Actual State의 변화를 구독해 클라우드로 델타를 보낸다.
지금까지 설명한 이 디자인 패턴을 Desired/Actual State Pattern이라고 하며 IoT에서 자주 사용되는 패턴이다.
추가로 AWS IoT Device Shadow 를 살펴보면 디바이스의 상태를 저장하고 관리하기 위한 방법을 이해하는 데 도움이 된다. Device Shadow는 디바이스의 현재 상태를 저장하고 동기화하는 용도다. desired, reported, delta가 모두 상태를 표현한다. 즉, Shadow는 명령이 아닌 상태를 저장한다. 명령 큐에서는 빨강, 파랑, 초록으로 바꾸라는 세 명령을 모두 전달해야 하지만, 상태 모델에서는 최종 상태인 초록만 전달하면 된다. 중간 상태가 유실되어도 문제가 없다.
눈여겨볼 것은 delta의 의미다. Shadow의 delta는 “이번에 바뀐 것"이 아니라 “현재 desired와 reported 사이에 남아있는 차이"다. 그래서 Shadow는 명령 큐가 아니라 수렴 메커니즘으로 동작한다. 일부 delta가 유실되더라도 다음 delta에 여전히 현재의 차이가 담기므로, 전달 실패가 곧바로 영구적인 상태 손실로 이어지지 않는다.
하지만 이 추상화에도 명확한 경계가 있다. Shadow는 상태 동기화를 위한 추상화다. “경적을 울려라"를 desired 상태로 표현할 수는 있지만, 경적은 지속되는 상태가 아니라 한 번 발생하고 사라지는 이벤트다. 이런 이벤트는 Shadow의 상태 모델과 잘 맞지 않으며, 일반적인 MQTT(Message Queuing Telemetry Transport) 메시지 경로로 전달하는 것이 자연스럽다.
도메인 모델링
모바일 앱에서 제어 명령을 받는 차량 제어 서버는 그 명령을 어떻게 모델링해야 할까.
핵심은 Type 중심의 설계다. 명령을 하나의 거대한 객체로 만들고 차이를 필드와 조건문으로 표현하지 않는다. 명령의 종류 자체를 Type으로 정의하고, Type마다 필요한 데이터와 처리 규칙을 분리한다.
타입을 얼마나 세세하게 나눌 것인지는 설계에 따라 다르지만, 하나의 예를 들자면 DOOR_OPEN, DOOR_LOCK, HORN과 같이 명령을 정의할 수 있다. 이러한 명령을 표현하는 Command라는 상위 타입을 둘 수 있다. Kotlin의 경우에는 sealed interface를 활용할 수 있다.
“상태 수렴 패턴” 섹션에서 다룬 내용처럼 차량도 의도를 반영하기 위해 자체적으로 전제조건, 정책 등을 판단할 것이다.
Type 중심으로 모델링하면 정책을 타입별로 관리할 수 있다. 모바일 앱에 특화된 비즈니스 요건에서 온 정책도, 차량 도메인 자체에서 온 정책도 각 Type에 붙는다. 즉, 명령의 의미와 수명(Lifetime), 재실행 가능성, 실패 후의 행동을 Type 하나로 함께 관리한다.
프로토콜
서버는 차량으로 명령을 보내고, 차량으로부터 물리적 상태나 capability 같은 데이터를 받는다. 따라서 서버와 차량이 데이터를 주고받기 위한 프로토콜이 필요하다.
프로토콜은 세 가지를 만족해야 한다.
- 언어와 플랫폼에 독립적인 직렬화 형식일 것
- 필드를 추가해도 기존 데이터가 깨지지 않을 것
- 전송 크기와 메모리 사용이 효율적일 것
이런 점들을 고려했을 때 Protocol Buffers(Protobuf)가 적절하다. Protobuf는 명시적인 스키마와 타입을 기반으로 메시지를 정의하고, 다양한 언어의 코드를 생성할 수 있으며, 바이너리 직렬화를 통해 JSON보다 작은 메시지를 만들 수 있다. 특히 제한된 네트워크 환경을 거치는 차량 통신에서는 메시지 크기를 줄이는 것 역시 중요한 장점이다.
여기서 테슬라의 프로토콜 사례를 살펴보자.
그 전에 한 가지 짚어둘 것이 있다. Tesla Vehicle Command SDK는 서드파티를 위해 제공되는 것이고 해당 SDK를 사용하는 경우 대략적인 요청 흐름은 Mobile > Vehicle Command SDK > Tesla Server > Vehicle와 같다.
따라서 테슬라 서버에서 차량으로 보내는 프로토콜의 형식을 정확히 알긴 어렵지만 Vehicle Command SDK에서 제공하는 프로토콜을 이해하는 것은 차량 제어 시스템을 설계할 때 좋은 참고 자료가 된다.
RoutableMessage
├── to_destination / from_destination ← 라우팅
├── payload (불투명 바이트열) ← 애플리케이션 명령
├── signature_data ← 인증 계층
│ ├── signer_identity (공개키)
│ └── AES_GCM_Personalized_Signature_Data
│ ├── epoch
│ ├── counter
│ ├── expires_at
│ └── tag
├── signedMessageStatus
├── uuid / request_uuid
└── flags
테슬라 프로토콜 사례를 통해 한 가지 원칙을 도출할 수 있다. “메시지 설계는 페이로드(payload)와 봉투(envelope)를 설계하는 일이며 이 둘은 서로 다른 기술을 사용할 수도 있다” 이다.
페이로드는 애플리케이션 명령을 의미하며 Protobuf를 직렬화해서 바이트로 담는다. 그 외 나머지 필드들이 봉투라고 할 수 있다. 봉투에는 누구에게 보내는지, 누가 보냈는지, 어떻게 검증할 것인지를 담는다.
테슬라 프로토콜의 경우에는 봉투 또한 Protobuf로 설계했다. 하지만 테슬라 서버가 차량으로 전달할 때에는 다른 IDL을 사용할 수도 있다. 예를 들면 ASN.1 이다. ASN.1은 차량과 클라우드처럼 서로 다른 구현체가 오랜 기간 동일한 메시지 구조를 안정적으로 이해하고, 이를 효율적이고 명확한 바이너리 표현으로 교환할 수 있도록 메시지 구조와 인코딩 규칙을 표준화하기 위해 사용될 수 있다.
따라서 페이로드는 차량으로 제어 명령, 데이터 요청과 같은 애플리케이션 요청을 처리하기 위해서 Protobuf를 직렬화해서 바이트로 담고, 메시지가 위변조되지 않았는지, 누구에게 보낼 것인지와 같은 내용을 봉투(Protobuf or ASN.1)로 설계할 수 있다.
이어지는 세 섹션은 Tesla Vehicle Command SDK README의 “command’s flow diagram"과 함께 보면 이해하기 쉽다.
봉투가 담는 세 가지 값
테슬라 프로토콜의 봉투에 있는 필드 중 아래 세 가지는 따로 살펴볼 만하다.
첫째, 도메인은 라우팅 정보처럼 보이지만 실은 보안 경계다. 차량에는 서로 다른 공개키를 갖는 서브시스템이 있고, 각각이 자기 키와 자기 세션을 따로 유지한다. 잠금을 담당하는 컨트롤러와 인포테인먼트가 그렇게 갈린다. 그래서 목적지가 정해지지 않은 명령은 만들어지지도 않는다. 도메인이 없으면 인증 데이터를 구성하는 단계에서 실패하고, 메시지는 네트워크에 나가기도 전에 멈춘다.
둘째, 만료 시간이다. 명령마다 “이 시각이 지나면 실행하지 말라"는 값이 함께 실려 가고, 차량은 명령을 받자마자 그 값부터 확인한다. 늦게 도착한 명령은 인증이 아무리 멀쩡해도 그 자리에서 버려진다.
여기서 눈여겨볼 것은 이 값을 정하는 방식이다. 프로토콜이 “무조건 5초"처럼 상수로 못박아 두지 않는다. 대신 명령을 보내는 쪽이 얼마나 기다릴 생각인지를 그대로 가져다 쓴다. 앱이 10초까지 기다렸다가 포기하기로 했다면 명령의 만료 시각도 10초 뒤가 되고, 별도로 정하지 않았을 때만 기본값이 적용된다. 사소해 보이지만 이 결정이 막는 사고가 있다. 두 시각을 따로 정한다고 해보자. 앱은 5초 뒤에 포기하고 화면에 실패를 띄우는데, 차량은 30초까지 그 명령을 유효하다고 본다. 그러면 사용자가 “안 됐구나” 하고 돌아선 뒤에 차 문이 잠기는 25초짜리 구간이 생긴다. 두 값을 하나에서 뽑아내면 이 구간이 아예 존재할 수 없다. 사용자가 포기하는 순간이 곧 차량이 거부하기 시작하는 순간이기 때문이다.
셋째, 같은 명령이 두 번 쓰이는 것을 막는 값이 하나가 아니라 둘이다. 왜 둘이나 필요한지는 하나만 있을 때 무엇이 뚫리는지 보면 알 수 있다.
먼저 명령마다 순번(counter)을 붙인다고 하자. 차량은 마지막으로 처리한 순번을 기억해 두었다가, 이미 지나간 순번이 다시 오면 거부한다. 누군가 내 잠금 해제 명령을 그대로 복사해 두었다가 나중에 다시 보내도 통하지 않는다. 여기까지는 잘 동작한다. 문제는 차량이 재부팅할 때 생긴다. 그 순번을 어디에 기억해 둘 것이냐가 관건이다. 매번 저장 장치에 쓰면 명령 하나 처리할 때마다 쓰기가 발생하고, 그렇다고 메모리에만 두면 재부팅과 함께 사라진다. 사라지면 순번이 1번부터 다시 시작하고, 공격자가 아침에 복사해 둔 명령이 저녁에 되살아난다.
그래서 값이 하나 더 붙는다. 차량이 켜질 때마다 새로 만드는 세대 식별자(epoch)다. 명령에는 “몇 번째 명령인가"와 “어느 세대에 속하는가"가 함께 실리고, 세대가 다르면 순번이 맞아도 거부된다. 재부팅하면 세대가 통째로 바뀌므로 이전 세대의 명령은 전부 무효가 된다. 저장 장치에 무언가를 쓰지 않고도 재부팅 문제를 해결한 셈이다.
차량은 벽시계를 신뢰할 수 없는 환경이다. 12V 배터리가 방전되었다가 살아나거나, 장기간 오프라인이었거나, NTP 동기화가 안 된 상태일 수 있다. 절대 시각을 썼다면 시계가 틀린 차량은 모든 명령을 거부하거나 모든 명령을 수락하게 된다.
따라서 클라이언트는 시계 동기화 없이 만료를 구현해야 하며 핸드셰이크에서 받은 clock_time으로 기준점을 역산한다. 차량이 알려준 clock_time과 클라이언트의 현재 시간을 가지고 역산하여
“이 도메인의 세대는 내 시계로 14:45:50에 시작됐다"와 같이 timeZero 값을 판단한다. 그런 다음 만료 시각을 now + TTL에서 timeZero를 뺀 값, 즉 세대가 시작된 뒤 몇 초가 지났는지로 환산해 expires_at에 담는다.
도메인마다 시계가 다르므로 오프셋도 도메인마다 따로 추적해야 한다.
이러한 만료 설정 덕분에 오래된 의도의 지연 실행을 방지할 수 있다. 또한 만료 시각은 인증 태그 계산에 포함되므로 중간자가 이 값을 연장할 수 없다.
인증
인증이 답해야 하는 질문은 하나다. 이 명령을 정말 권한 있는 사람이 보냈는지를 가리는 것이다. 그리고 차량 원격 제어에서는 여기에 조건이 하나 붙는다. 그 판단을 차량이 스스로 할 수 있어야 한다. 왜 스스로여야 하는지는 TLS만 썼을 때 무슨 일이 생기는지 보면 알 수 있다. TLS는 구간마다 새로 맺어지는 보호다. 앱에서 서버까지 한 구간이 안전하게 보호되고, 서버에 도착하는 순간 그 보호는 끝난다. 그 뒤로 그 메시지는 그냥 “우리 서버가 만든 메시지"가 된다. 백엔드가 침해당하면 공격자가 만들어 낸 잠금 해제 명령도 차량 입장에서는 똑같이 “우리 서버가 만든 메시지"로 보인다. 그래서 명령의 출처를 구간이 아니라 명령 자체에 붙여야 한다. 이것이 종단 간 인증이다.
방식은 이렇다. 명령을 만드는 쪽과 차량이 각자 자기만 아는 비밀 숫자를 하나씩 갖는다. 그리고 상대의 공개된 숫자(Public Key)와 자기 비밀 숫자를 조합하면, 양쪽이 똑같은 값을 각자 계산해 낼 수 있다. 이 값을 서로 주고받지 않았는데도 둘이 같다는 것이 이 방식의 핵심이다. 오가는 것은 공개해도 되는 값뿐이고, 실제로 쓰는 비밀은 각자 손에서 나가지 않는다.
중요한 것은 이 값을 계산할 수 있는 주체가 딱 둘뿐이라는 점이다. 테슬라 클라우드도 계산할 수 없고, 다른 차량도 계산할 수 없다. 심지어 같은 차량의 다른 서브시스템도 계산할 수 없다. 잠금을 담당하는 컨트롤러와 인포테인먼트가 각자 다른 비밀 숫자를 갖고 있기 때문이다.
그래서 클라우드가 통째로 털려도 공격자가 얻는 것은 명령을 지연시키거나 버릴 수 있는 능력뿐이다. 새 명령을 만들어 내는 능력은 얻지 못한다.
Tesla Vehicle Command SDK는 사전 등록된 공개 키(pre-enrolled public key)와 ECDH(Elliptic Curve Diffie-Hellman) 기반 Shared Secret으로 명령의 인증과 무결성을 보장한다.
키를 갖는다고 아무 명령이나 보낼 수 있는 것은 아니다. 차량은 등록된 각 키에 역할을 붙여 두고, 역할마다 인가할 수 있는 명령을 다르게 정한다. Owner는 다른 사람의 키를 관리하는 것까지 포함해 사실상 전권을 갖고, Driver는 대부분의 명령을 보낼 수 있지만 타인의 키를 관리하거나 PIN을 바꿀 수는 없다. 렌탈처럼 기간이 정해진 접근을 위한 역할도 있고, 데이터만 읽을 수 있는 역할이나 충전 관련 명령만 가능한 역할도 따로 있다.
여기서 클라우드 기반 서비스의 위치가 분명해진다. 서드파티 서버를 운영한다면 그 서버가 자기 비밀 숫자를 갖고 차량에 하나의 키로 등록된다. 즉 그 서버가 프로토콜 입장에서는 “클라이언트"다. 테슬라 클라우드는 그 사이를 지나가는 통로일 뿐 명령을 만드는 키를 갖지 않는다.
실제로 테슬라 차량과 Vehicle Command SDK를 활용하여 차량에 공개키를 등록하기 위해서는 가상키 프로비저닝(VirtualKey Provisioning) 이라는 과정을 거쳐야 한다. 서드파티 서버에서 비대칭키를 생성한 다음, 공개키를 S3, CloudFront로 호스팅하여 Tesla Developers에 엔드포인트를 등록해야 한다. 그리고 실제 테슬라 차량에 가서 카드키를 올려놓고 QR을 활용해 공개키를 차량에 설정할 수 있다.
즉, 신뢰의 시작점은 암호학이 아니라 물리적 존재 증명이다. 원격만으로는 새 키를 등록할 수 없다. 원격 공격자에게 이것이 궁극적인 차단선이 된다.
인증 태그의 계산 재료
인증 태그는 비밀 값과 메시지를 함께 넣어 계산한 짧은 값이다. 같은 비밀 값과 같은 메시지를 넣으면 항상 같은 태그가 나오고, 메시지가 한 글자만 달라져도 태그는 완전히 달라진다. 비밀 값을 모르면 올바른 태그를 만들 수 없다.
그런데 여기서 실질적인 보안 강도를 결정하는 것은 알고리즘이 아니라 “메시지"에 무엇을 넣느냐다. 더 강한 해시 함수를 쓴다고 뚫리던 공격이 막히지는 않는다.
명령 내용만 서명할 때의 구멍
가장 간단한 구현을 살펴보자. 비밀 값과 “LOCK"이라는 문자열만으로 태그를 만든다고 하자. 알고리즘은 최신이고 키도 안전하다. 그런데 이 태그가 붙은 메시지는 다음 다섯 가지 방식으로 악용될 수 있다.
옆집 차로 그대로 주입할 수 있고, 엉뚱한 서브시스템으로 목적지를 바꿔 보낼 수 있고, 그대로 복사해 즉시 다시 보낼 수 있고, 세 시간 뒤에 다시 보낼 수도 있으며, 응답을 암호화하라는 요구를 슬쩍 꺼버릴 수도 있다. 다섯 가지 모두 명령 내용 자체는 진짜다. 공격자가 바꾼 것은 그 명령이 누구에게 가는 것이었는지, 언제까지 유효한 것이었는지, 몇 번째 명령이었는지 같은 주변 정보뿐이다. 편지의 본문은 그대로 두고 수신인과 날짜만 고쳐 쓴 셈이다.
그래서 테슬라는 태그를 계산할 때 명령 내용만 넣지 않는다. 인증 방식, 목적지 서브시스템, 차량 식별자, 세대 식별자, 만료 시각, 순번, 옵션 플래그를 모두 함께 넣고 마지막에 명령 내용을 이어 붙인다. 이 중 하나라도 바뀌면 태그가 깨진다.
항목이 일곱이나 되는 데에는 이유가 있다. 각 항목이 서로 다른 구멍을 막고, 어느 하나도 다른 것으로 대체되지 않는다.
세대 식별자 — 재부팅이라는 구멍
재생 공격을 막으려면 차량이 “이 메시지를 전에 봤는가"를 기억해야 한다. 그런데 차량은 재부팅한다. 재부팅하면 메모리에 있던 기억이 사라지고, 공격자가 재부팅 직전에 복사해 둔 메시지를 재부팅 직후에 흘려 넣으면 그대로 통과한다.
“그럼 저장 장치에 기록하면 되지 않나"라는 생각이 자연스럽게 든다. 프로토콜 문서는 이 선택지를 검토한 흔적을 남기지 않았으므로 아래는 임베디드 환경의 일반적인 제약에서 끌어낸 추정이지만, 명령마다 저장 장치에 쓰는 설계는 여러 방향에서 대가를 치를 수 있다. 저장 장치는 쓰기 횟수에 수명이 있어 마모가 누적될 수 있고, 쓰기 지연이 명령 처리 경로에 얹힐 수 있으며, 쓰는 도중에 전원이 끊기면 저장된 값과 실제 상태가 어긋날 수 있다.
테슬라가 택한 것은 저장하지 않는 쪽이다. 부팅할 때마다 16바이트 난수를 새로 만들고, 그것을 이번 세대의 이름으로 삼는다. 명령에는 “어느 세대에 속하는가"가 함께 실리고, 세대가 다르면 다른 값이 다 맞아도 거부된다. 재부팅하면 세대가 통째로 바뀌므로 이전 세대의 메시지는 전부 무효가 된다. 결과만 놓고 보면 저장 비용을 재동기화 비용으로 바꾼 구조로 읽을 수 있다.
세대 식별자로 난수를 쓴 이유도 짚어볼 만하다. 문서에 설명은 없지만 반대 경우를 상상하면 짐작할 수 있다. 세대 식별자가 단순히 증가하는 부팅 카운터였다면 다음 세대를 예측할 수 있다. 그러면 미래 세대에서 유효할 명령을 미리 만들어 보관하는 것이 가능해진다. 렌터카를 반납한 뒤에도 유효한 잠금 해제 명령 같은 것이 그렇다. 여기서 위험해지는 것이 공격자가 아니라 한때 정당했던 주체라는 점이 흥미롭다. 난수라면 이 가능성 자체가 사라진다.
순번 — 중복은 막지만 순서는 아니다
여기서 가장 많이 오해가 생긴다. 순번은 순서를 보장하지 않는다.
먼저 순번이 무엇을 막는지 보자. 유효한 잠금 명령을 그대로 복사해 백 번 다시 보낸다고 하자. 메시지를 전혀 수정하지 않았으므로 태그는 매번 유효하다. 암호학만으로는 이것을 막을 수 없다. 막는 것은 “이 순번을 전에 썼는가"를 기억하는 상태다.
그런데 순서가 조금이라도 어긋나면 무조건 버릴 수는 없다. 네트워크에서 순서가 뒤바뀌는 일은 흔하기 때문이다. 그래서 차량은 마지막 순번 하나만 기억하는 대신 최근 몇십 개의 순번을 놓고 각각을 이미 썼는지 여부만 기록해 둔다. 이 구간이 통째로 앞으로 밀려나면서 오래된 기록은 잊힌다.
핵심은 이 장치가 답하는 질문이 “이 순번을 전에 썼는가” 하나뿐이라는 점이다. 순서를 바로잡아 주지 않고, 빠진 순번을 기다려 주지도 않는다. 그래서 102번, 100번, 101번 순서로 도착하면 셋 다 도착한 순서 그대로 실행되고, 차량의 최종 상태가 사용자가 마지막에 누른 것과 달라질 수 있다.
순서까지 보장하려면 앞선 순번이 도착할 때까지 뒤에 온 명령을 붙들고 기다려야 하는데, 그렇게 하면 앞 명령이 영영 오지 않을 때 뒤 명령까지 함께 만료된다. 5초 안에 만료되는 명령을 다루면서 그런 대기를 걸 수는 없다. 순서 정합성이 정말 필요하다면 그것은 순번이 아니라 앞에서 본 상태 수렴으로 풀어야 할 문제다.
그리고 도메인마다 정책이 다르다. 프로토콜 문서는 인포테인먼트가 순서가 어긋난 도착을 허용하는 반면, 잠금을 담당하는 컨트롤러는 순번 순서대로 도착할 것을 요구한다고 적고 있다. 하나의 프로토콜 안에서 위험도에 비례해 서로 다른 정책이 적용되는 것이다.
순번에 구멍이 나는 것은 정상이다. 클라이언트는 순번을 먼저 소비하고 그다음에 전송하므로, 전송이 실패한 순번은 영영 쓰이지 않는다. 차량이 요구하는 것은 값이 계속 커지는 것뿐이지 빠짐없이 이어지는 것이 아니다. TCP 시퀀스 번호에 익숙한 사람이 가장 자주 넘어지는 지점이 정확히 여기다. 참고로 클라이언트는 순번이 최댓값에 도달하면 0으로 되돌리지 않고 명령 생성 자체를 거부한다. 되돌리는 순간 재생 방어가 통째로 무너지기 때문이다.
만료 시각 — 공격자가 없어도 필요한 유일한 항목
만료 시각이 없을 때 벌어지는 일은 이렇다. 사용자가 지하 3층에서 잠금 해제를 누른다. 명령이 어딘가에 큐잉된다. 30분 뒤 차량이 지상으로 올라와 온라인이 되고, 그 순간 명령이 전달되어 문이 혼자 열린다.
악의를 가진 사람이 아무도 없는데 사고가 난다. 다른 항목들은 전부 공격자를 전제로 하지만, 이 항목만은 공격자가 없어도 필요하다.
물론 공격 버전도 있다. 명령을 가로채되 차량에 전달하지 않고 붙잡고 있다가 원하는 순간에 발사하는 것이다. 사용자가 차에서 멀어질 때까지 기다렸다가 잠금 해제를 흘려 넣으면 된다. 순번도 세대 식별자도 이것을 막지 못한다. 메시지는 한 번도 재사용되지 않았고 세대도 그대로이기 때문이다. 오직 만료 시각만이 이 공격을 무력화한다.
이 항목의 가장 우아한 부분은 절대 시각을 전혀 쓰지 않는다는 점이다. 차량은 “내 현재 세대가 시작된 뒤 몇 초가 지났다"만 말하고, 클라이언트는 자기 시계로 그 세대의 시작 시점을 역산한 뒤 만료를 상대 시간으로 표현한다. 그래서 차량의 벽시계가 틀려도 만료가 정상 동작한다. 시계 동기화 없이 유통기한을 구현한 셈이다.
옵션 플래그 — 한 줄의 조건문에 두 요구가 들어 있다
플래그에는 “응답을 암호화해서 보내라"는 비트가 있다. 이 비트를 인증 대상에서 빼면 곧바로 구멍이 생긴다. 중간자가 비트를 꺼버리면 차량의 응답이 평문으로 돌아오고, 차량 위치나 도어 잠금 상태가 그대로 노출된다.
그런데 더 위험한 것은 노출이 아니라 응답 위조다. 응답이 인증되지 않으면 “권한 없음"을 “성공"으로 바꿔치기할 수 있다. 사용자는 화면에서 잠금 성공을 확인하고 안심하고 자리를 뜬다. 실제로 문은 열려 있다.
테슬라는 플래그 값이 0보다 클 때만 이 항목을 태그 계산에 포함한다. 언뜻 어중간한 타협처럼 보이지만, 이 한 줄의 조건에 두 개의 요구가 동시에 들어 있다. 플래그가 0이면 넣지 않으므로 플래그 개념이 없던 구형 차량과 같은 바이트열이 되어 하위호환이 지켜진다. 플래그가 켜져 있으면 넣으므로 중간자가 그것을 끄는 순간 해시가 달라져 거부된다.
나머지 셋과 이어 붙이는 방식
나머지 셋 중 인증 방식은 조금 설명이 필요하다. 이 시스템에서 클라이언트와 차량은 하나의 비밀 값을 공유하는데, 그 값으로 만드는 태그가 한 종류가 아니다. 명령에 붙이는 태그, 세션을 여는 응답에 붙이는 태그, 차량이 결과를 돌려줄 때 붙이는 태그, 이렇게 셋이다. 문제는 같은 비밀 값으로 만들다 보니 한 종류의 태그가 다른 종류로도 유효해질 여지가 생긴다는 점이다. 예를 들어 세션을 여는 응답에 붙었던 태그를 공격자가 떼어다 명령에 붙였는데 차량이 그것을 유효하다고 받아들이면 곤란하다.
그래서 태그를 계산할 때 “이건 몇 번 용도의 태그인가“를 맨 앞에 함께 넣는다. 명령용은 명령용 번호를, 응답용은 응답용 번호를 넣는다. 그러면 같은 비밀 값에서 나왔어도 용도가 다르면 계산 결과가 달라지므로, 태그를 다른 자리에 옮겨 붙일 수 없게 된다. 열쇠는 같아도 문마다 다른 홈을 파 두는 것과 비슷하다.
차량 식별자를 넣는 것은 이 명령이 이 차량만을 위한 것임을 못박기 위함이다. 물론 차량마다 비밀 값이 다르므로 옆집 차는 애초에 태그를 검증하지 못하지만, 키 관리가 어딘가에서 실패했을 때의 두 번째 방어선이 된다. 목적지 서브시스템을 넣는 것은 잠금 명령이 인포테인먼트로 리라우팅되는 것을 막기 위함이고, 이 값이 없으면 메시지가 만들어지지도 않는다.
그런데 이 일곱을 한자리에 놓고 보면 진짜 결론은 따로 있다. 이 모든 항목을 하나의 바이트열로 이어 붙이는 방식 자체가 모호하지 않아야 한다. 서로 다른 두 개의 항목 조합이 절대로 같은 바이트열이 되어서는 안 된다는 뜻이며, 테슬라도 이 요구를 명시적으로 적어 두었다.
길이 정보가 없다고 하자. 그러면 어떤 값의 끝과 다음 항목의 시작이 뒤섞여서, 서로 다른 두 의미가 같은 바이트열로 인코딩될 수 있다. 그 순간 하나의 유효한 태그가 두 가지 뜻을 갖게 된다. 항목 번호와 길이와 값을 한 묶음으로 만들어 번호 오름차순으로 이어 붙이고 마지막에 종료 표시를 두는 구조는 이 모호성을 없애기 위한 것이다. JSON을 그냥 직렬화해서 서명하는 흔한 패턴이 위험한 이유가 정확히 이것이다. 키 순서와 공백과 인코딩이 조금만 달라져도 같은 의미가 다른 바이트가 되고, 반대로 다른 의미가 같은 바이트가 될 여지가 생긴다.
응답 방향에는 항목이 둘 더 붙는다. 각각이 응답 위조의 서로 다른 수법을 막는다.
하나는 요청의 태그를 응답의 계산 재료로 넣는 것이다. 명령마다 태그 값이 다르므로, 이렇게 하면 그 응답은 오직 그 명령에 대해서만 유효해진다. 공격자가 어제 받아 둔 잠금 성공 응답을 오늘 보낸 명령의 답으로 재활용할 수 없다.
다른 하나는 오류 코드까지 계산 재료에 넣는 것이다. 오류 코드는 암호화되지 않고 그대로 보이는 자리에 있어서, 이것이 없으면 공격자가 “권한 없음"을 “성공"으로 고쳐 쓰기만 하면 된다. 계산 재료에 넣어 두면 그 값을 건드리는 순간 응답 전체가 검증에 실패한다.
커넥션과 세션
차량 제어 시스템을 설계할 때 가장 중요한 부분 중 하나는 커넥션과 세션 관리다.
먼저 두 단어를 구분해 두자. 커넥션은 지금 이 순간 소켓이 붙어 있다는 물리적 사실이고, 세션은 그 소켓이 몇 번 끊겼다 붙어도 이어지는 논리적 연속성이다. 통화에 비유하면 커넥션은 전화가 이어져 있다는 사실이고, 세션은 우리가 어디까지 이야기했는지에 해당한다. 전화가 끊겨도 대화의 맥락은 남지만, 시스템에서는 이 둘이 자주 한 덩어리로 묶인다. 묶이면 재연결이 “처음부터 다시"가 되고, 분리해 두면 “하던 것을 이어서"가 된다. 이 구분은 설계 요구사항 수준에서 못박아 두는 편이 좋다. 커넥션의 수명주기를 접속, 인증, 활성, 종료 진행, 종료처럼 단계로 명시해 두면 어느 단계에서 끊겼는지에 따라 세션을 살릴지 버릴지를 정할 수 있기 때문이다.
테슬라처럼 서드파티에게 SDK를 제공하는 경우와 그렇지 않은 경우를 살펴보자.
서드파티에게 SDK를 제공하는 경우
Tesla Vehicle Command SDK의 경우 세션 상태에 들어 있는 것은 상대 차량의 식별자, 대상 도메인, 공유 키, 세대 식별자, 순번, 시계 오프셋이 전부다. 소켓도 IP 주소도 BLE 핸들도 들어 있지 않다.
여기서 결정적인 결과가 나온다. 세션을 파일로 저장할 수 있다. 소켓을 저장할 방법은 없지만 숫자와 키는 저장할 수 있기 때문이다. 프로토콜 문서는 계속 실행되지 않는 클라이언트에게 세션 상태를 디스크에 캐시하라고 권고하면서, 로컬 시계와 차량 시계의 차이도 함께 저장하라고 못박는다. 앞 절에서 본 만료 시각이 차량의 자체 시계로 표현되기 때문이다. 그 차이를 잃어버리면 저장해 둔 숫자를 해석할 기준점이 사라진다. SDK의 캐시 항목이 세션 정보와 함께 생성 시각을 갖는 이유가 이것이다.
저장한 세션이 아직 유효한지는 저장한 쪽에서 알 수 없다. 그 사이 차량이 재부팅해서 세대가 바뀌었을 수도 있기 때문이다. 그런데 SDK는 캐시를 불러올 때 유효성을 확인하지 않고 곧바로 사용 가능한 상태로 표시한다. 무모해 보이지만 비용을 따져 보면 그렇지 않다. 캐시가 맞았다면 명령 한 번 왕복으로 끝나고, 틀렸다면 거절 응답에 최신 세션 정보가 실려 오므로 그것으로 갱신해 한 번 더 보내면 된다. 왕복 두 번이다. 그런데 캐시를 아예 쓰지 않으면 핸드셰이크 한 번에 명령 한 번, 이것도 왕복 두 번이다. 틀렸을 때의 비용이 애초에 시도하지 않았을 때의 비용과 같다.
여기에는 일반화할 수 있는 원칙이 있다. 일단 먼저 시도해 보고, 실패하면 처음부터 다시 하는 것과 같은 비용으로 복구할 수 있다면 먼저 시도하는 편이 항상 유리하다. 이를 가능하게 하는 조건은 단순하다. 거절할 때 무엇이 잘못됐는지뿐 아니라, 다시 시도하는 데 필요한 최신 상태까지 함께 알려줘야 한다. 단순히 “안 된다"고만 응답하면 클라이언트는 처음부터 상태를 다시 확인해야 한다. 그러면 먼저 시도한 이점은 사라지고, 오히려 요청 한 번만 더 늘어난다.
세션이 연결에 묶여 있지 않다는 사실은 이동 중인 차량에서 곧바로 제값을 한다. LTE에서 Wi-Fi로 전환되면 IP 주소가 바뀌고 기존 연결이 끊어지고 새 연결이 만들어지지만, 세대 식별자도 순번도 키도 시계 오프셋도 그대로다. 재핸드셰이크가 한 번도 필요하지 않다. 차량은 하루에도 수십 번 네트워크가 바뀌므로, 전환마다 재협상하는 설계는 SDK 입장에서는 애초에 성립하지 않는다. 신원을 연결 경계에 묶는 방식은 그 연결의 양 끝을 직접 소유할 때에나 통하는데, 여기서는 그 양 끝을 소유하지 않는다.
커넥션과 세션을 다루는 서버
여기서는 서드파티에 SDK를 제공하지 않는 환경을 전제로 한다. 즉, SDK 뒤에 감춰진 서버가 차량과의 상시 연결을 유지하고, 클라우드의 차량 제어 요청을 차량까지 전달하는 플랫폼을 기준으로 설명한다. 차량과 테슬라 서버 사이에 상시 연결이 어떻게 유지되는지는 공개된 자료만으로는 알 수 없고, 공개된 것은 인터넷 경로와 BLE(Bluetooth Low Energy) 두 통로뿐이다. 아래는 차량과 상시 연결을 유지하는 플랫폼을 직접 만들 때 반드시 마주치게 되는 문제다.
상시 연결을 직접 소유하는 경우 차량은 이동통신망의 사설 주소 뒤에 있어서 서버가 먼저 접속할 수 없고, 텔레메트리는 차량에서 서버 방향으로 상시 흐르며, 명령의 왕복 지연은 초 단위로 관리해야 한다. 차량이 먼저 접속해서 연결을 유지하는 구조 말고는 사실상 답이 없다.
그러면 그 연결을 무엇으로 만들 것인가가 첫 갈림길이다.
| WebSocket | gRPC | |
|---|---|---|
| 성격 | 양방향 프레임 기반 연결 | HTTP/2 위에서 동작하는 RPC 프레임워크 |
| 메시지 계약 | 의미와 계약을 직접 정의해야 함 | Protobuf 기반의 강한 계약 + 코드 생성 기본 제공 |
| 기본 제공 기능 | 프레임 전달까지 | 스트리밍, 데드라인, 상태 코드 |
| 인프라 호환 | HTTP 기반 인프라와 호환성이 높음 | 장시간 HTTP/2 스트리밍 연결이 프록시나 로드밸런서의 timeout이나 정책으로 종료될 수 있음 |
| 구현 부담 | 프로토콜이 단순해 제한된 환경에서도 구현 용이 | 프로토콜 스택과 런타임의 복잡성이 높음 |
그래서 실무에서는 둘을 동시에 지원하는 선택이 흔하다. 차량 세대마다 통신 모듈과 런타임이 다르고, 나라마다 망의 성향이 다르기 때문이다.
추가로 MQTT를 사용하는 것을 고려할 수 있다. MQTT는 클라이언트와 서버가 직접 메시지를 주고받기보다 브로커를 중심으로 Pub/Sub 방식으로 통신하며, 연결 상태와 QoS(Quality of Service), 세션 관리 등을 프로토콜 차원에서 제공한다. 특히 네트워크가 불안정하거나 대역폭이 제한적인 차량 환경에서 지속적인 연결을 유지하면서 작은 메시지를 주고받는 데 적합하다. 대신 브로커가 통신 경로에 포함되기 때문에 WebSocket이나 gRPC와는 다른 인프라와 장애 모델을 고려해야 한다.
여기서 중요한 설계 원칙은 전송 방식이 바뀌어도 메시지 프로토콜은 변하지 않아야 한다는 것이다. WebSocket을 사용하든 gRPC를 사용하든, 차량이 이해하는 명령과 메시지 구조는 동일해야 한다. 전송 계층은 메시지를 운반할 뿐, 메시지가 무엇을 의미하는지 결정해서는 안 된다. 그래야 네트워크 환경이나 인프라가 바뀌어도 차량 제어 프로토콜을 그대로 유지할 수 있다. 결국 무엇을 보낼 것인가와 어떻게 운반할 것인가는 서로 다른 설계 결정이어야 한다.
Heartbeat
연결이 끊어졌다는 것을 어떻게 알아챌 수 있을까? 연결의 단절에는 두 가지 형태가 있고 둘은 정반대로 동작한다.
먼저 프로세스를 강제 종료하는 경우다. SIGKILL은 프로세스가 직접 처리할 수 없으므로 커널이 소켓을 정리하고 연결 종료를 상대방에 알린다. 상대는 명시적인 연결 종료를 관찰할 수 있기 때문에 비교적 빠르게 연결이 끊어졌다는 사실을 알아차리고 정리할 수 있다. 이 상황에서는 하트비트가 아무 역할도 하지 않는다.
반면 NAT(Network Address Translation) 리바인드, 전원 차단, 끊어진 링크는 상대방에게 아무런 종료 신호를 보내지 않을 수 있다. 상대는 사라졌지만 소켓은 여전히 연결된 것처럼 남아 있고, 결국 일정 시간 동안 아무런 응답이 없다는 사실을 통해서만 연결의 단절을 추론할 수 있다. 이때 필요한 것이 데드라인과 하트비트다.
차량이 터널이나 지하주차장으로 들어가는 상황도 정확히 이쪽에 해당한다. 차량의 연결이 끊어졌다는 사실을 클라우드가 즉시 전달받는 것이 아니라, 일정 시간 동안 아무런 메시지가 도착하지 않는다는 사실을 통해 연결이 끊겼음을 추론해야 한다.
그래서 하트비트(Heartbeat) 가 필요하다. 주기적으로 Ping을 보내고, 읽기 데드라인을 Ping 주기와 Pong 대기 시간의 합으로 잡는다. Pong이 도착하면 데드라인을 미래로 밀고, 오지 않으면 데드라인이 만료되면서 읽기가 실패한다. 몇 번 놓쳤는지 따로 세는 카운터가 필요 없다는 것이 이 방식의 장점이다. 다만 규율이 하나 붙는다. 데이터 수신과 Pong 수신 양쪽 모두가 데드라인을 리셋해야 한다. 그러지 않으면 데이터는 활발히 주고받으면서 Pong만 늦는 멀쩡한 연결이 끊긴다. 읽기 데드라인은 “상대의 프로세스가 살아 있다"와 “상대가 내 메시지를 받을 수 있다"를 구분하지 못한다.
Backpressure
차량처럼 장시간 연결을 유지하면서 대부분의 시간 동안 유휴 상태인 연결이 많다면, 연결마다 유지해야 하는 소켓 버퍼와 TLS 상태, 애플리케이션 상태 때문에 메모리가 중요한 병목이 된다. 반면 연결이 활발하게 통신한다면 TLS 처리와 메시지 처리에 필요한 CPU, 네트워크 대역폭도 함께 병목이 될 수 있다.
그리고 그 서버 안에서는 레지스트리에 압력이 걸린다. 동시 연결이 수만 개에 이르고 등록과 해제가 빈발하면 레지스트리를 지키는 단일 락 자체가 병목이 되는데, 흔한 해법은 이 자료구조를 여러 조각으로 쪼개서 락을 나누는 것이다. 다만 여기서 반드시 구분해야 할 것이 있다. 락을 쪼개는 일은 한 대의 서버 안에서 스레드끼리 덜 부딪히게 만드는 작업이지, 서버를 늘려서 부하를 나누는 작업이 아니다. 이 둘을 섞으면 “쪼갰으니 확장된다"고 착각하게 되는데, 락 경합이 사라져도 방금 말한 연결 수의 상한은 그대로다. 그래서 연결 수가 크지 않은 동안에는 단일 락으로 버티는 편이 낫다. 아직 병목이 되지도 않은 곳을 미리 쪼개면 검증되지 않은 복잡도만 남는다.
연결 수가 정말 커졌을 때의 답은 자료구조를 더 잘게 쪼개는 쪽이 아니라 한 서버가 책임지는 차량의 범위를 좁히는 쪽이고, 그러면 곧바로 다음 문제가 따라온다. 특정 차량이 지금 어느 서버에 붙어 있는지를 찾아낼 방법이 필요해진다. 공유 저장소에 매핑을 두거나, 서버끼리 메시지를 전달하거나, 차량 식별자로 담당 서버를 결정론적으로 정하는 세 가지 방법이 있는데 어느 것을 골라도 대가가 있다. 앞의 것에는 조회 비용과 정합성이, 가운데 것에는 홉의 증가와 장애 전파 경로가, 마지막 것에는 서버 수가 바뀔 때의 대량 재접속이 따라온다.
어느 쪽을 고르든 한 서버는 여전히 수많은 연결을 동시에 붙들고 있어야 한다. 그래서 한 층 아래, 연결마다의 큐를 어떻게 다룰 것인가가 다음 문제가 된다.
큐를 이야기하려면 배압에서 시작해야 한다. 배압(Backpressure) 은 단순히 처리량을 끌어올리기 위한 최적화가 아니다. 생산 속도가 소비 능력을 넘어서는 순간 시스템이 어디까지 받아들이고, 언제 거부하거나 연결을 종료할 것인지를 결정하는 흐름 제어의 규칙에 가깝다. 그리고 그 기준으로 쓸 수 있는 것은 시간과 공간 둘뿐이다. 정해진 시간 안에 보내지 못하면 끊고, 쌓인 양이 정해진 한계를 넘으면 끊는다. 배칭이나 레이트 리미팅은 부하를 고르게 펴 주기는 하지만 느린 소비자를 끊어내지는 못하므로 배압이 아니다. 이 구분을 흐리면 최적화를 잔뜩 갖춰 놓고도 느린 연결 하나에 전체가 무너진다.
큐는 반드시 연결마다 독립이어야 한다. 전역 큐를 공유하면 느린 연결 하나가 전체 처리량을 끌어내린다. 그런데 여기서 더 단순해 보이는 선택지가 하나 있다. 큐를 아예 두지 않고 메시지를 받은 자리에서 곧바로 소켓에 쓰는 것이다. 버퍼가 없으니 쌓일 것도 없다는 논리인데, 이 논리는 소켓에 쓴다는 행위를 오해한 데서 나온다.
소켓 쓰기가 성공했다는 것은 상대가 받았다는 뜻이 아니라 커널의 송신 버퍼에 복사되었다는 뜻이다. 그리고 그 버퍼는 유한하다. 상대가 읽기를 멈추면 상대의 수신 윈도우가 0이 되고, 그 여파로 이쪽 송신 버퍼가 차고, 그다음의 쓰기는 그 자리에서 멈춰 선다. 여기까지는 고장이 아니라 TCP의 정상 동작이다.
문제는 멈춘 다음이다. 쓰기를 기다리는 호출은 저마다 보내려던 메시지를 붙들고 있고, 그 호출을 수행하던 실행 흐름도 함께 블로킹된다. 보낼 메시지가 계속 생기면 붙들린 것의 수도 계속 늘어난다. 버퍼를 두지 않았는데도 메시지는 쌓이고, 다만 큐가 아니라 멈춰 선 호출들 안에 쌓인다.
그리고 이쪽이 더 나쁘다. 큐에 쌓이면 길이를 셀 수 있고 한계를 정할 수 있지만, 멈춘 호출 안에 쌓이면 셀 곳이 없다. 연결은 여전히 연결된 상태로 보이고, 하트비트도 통과한다. 앞에서 본 것처럼 하트비트는 읽기 쪽을 보기 때문이다. 아무 경보도 울리지 않는 채로 사용 메모리만 늘어난다.
버퍼가 없다는 것은 쌓이지 않는다는 뜻이 아니라 셀 수 없다는 뜻이고, 셀 수 없으면 끊을 기준도 없다. 그리고 기준이 없으면 지연은 조용히 메모리로 바뀐다. 연결마다 큐를 두는 진짜 이유는 처리량이 아니라, 쌓인 양을 눈에 보이는 숫자로 만들어서 끊을 기준을 갖기 위해서다.
제한의 단위를 무엇으로 잡을지는 그보다 덜 분명하다. 개수로 잡으면 구현이 단순하고 대부분의 상황에서 잘 동작하지만, 메시지 크기 편차가 크면 “천 개"라는 제한이 실제로는 10킬로바이트일 수도 1기가바이트일 수도 있다. 메모리 사용량을 예측 가능하게 만들려면 바이트로 옮기는 편이 맞다. 다만 그렇게 하면 큐에 넣을 때마다 크기를 재야 하고 정책도 복잡해지므로, 이것은 규칙이라기보다 어느 쪽 불확실성을 감수할지 고르는 문제다.
수신과 송신을 분리하지 않는 것도 같은 종류의 실수다. TCP 송신 버퍼가 차면 쓰기 호출이 블로킹되고, 같은 흐름 안에 있는 읽기가 실행되지 못해서, 한 느린 상대가 빠른 상대의 수신까지 막는다.
큐가 넘쳤을 때의 선택은 버리기 아니면 끊기다. 차량 명령을 다룬다면 끊기 쪽이 낫다. 한번 밀리기 시작한 연결은 회복을 기다리는 것보다 새로 맺는 편이 빠르기 때문이다. 다만 이 정책이 옳으면서 동시에 위험하다는 것을 잊으면 안 된다. 광역 혼잡으로 수천 대의 큐가 동시에 넘치면 수천 개의 절단이 발생하고 재접속 폭풍(Reconnection Storm) 문제가 발생할 수 있다. 배압의 자기 치유는 개별 연결 단위에서만 자기 치유다. 그리고 넘쳤을 때 무엇을 할지도 메시지 종류에 따라 달라질 수 있어야 한다. 텔레메트리는 버려도 되지만 명령은 버리면 안 된다. 이 차이는 정책 계층에서 결정되어야 하고 큐라는 메커니즘에 하드코딩되면 안 된다.
재접속 폭풍 방어
1만 대의 차량이 여러 파드에 나뉘어 연결되어 있다고 하자. 롤링 재시작으로 파드 하나가 종료되면 거기 붙어 있던 차량이 한꺼번에 연결을 잃고 재접속한다. 나머지 파드도 차례로 재시작되므로, 짧은 시간에 재접속 요청이 몰린다.
남은 파드는 기존 연결을 유지하면서 대량의 TLS 핸드셰이크를 함께 처리한다. 재접속 요청이 처리 용량을 넘으면 CPU와 네트워크가 고갈되고, 그 영향은 기존 연결로 번진다. CPU가 부족해지면 기존 연결의 Ping/Pong과 메시지 처리가 밀린다. 데드라인이 만료되면서 멀쩡하던 연결까지 끊기고, 끊긴 연결은 다시 재접속에 합류해 부하를 키운다.
재접속 폭풍이 위험한 이유는 많은 연결이 동시에 재접속해서가 아니다. 재접속을 처리하는 부하가 기존 연결의 안정성을 떨어뜨리고, 그래서 더 많은 연결이 재접속에 합류하는 악순환 때문이다.
그래서 방어는 한 층으로 되지 않는다. 클라이언트 쪽에는 지수 백오프와 Full Jitter를, 서버 쪽에는 초당 받아들일 연결 수의 상한과 동시 연결 수의 상한을, 그리고 연결을 끊을 때는 클라이언트가 재접속 전략을 구분할 수 있도록 사유를 실어 보내야 한다. 가장 흔한 실수는 지터를 빠뜨리는 것이다. 지터 없는 지수 백오프는 모든 클라이언트를 정확히 같은 시각에 동시에 도착시킨다. 1초 뒤에 다 같이 한 번, 2초 뒤에 다 같이 또 한 번, 그다음은 4초 뒤다. 지수 백오프는 폭풍을 없애는 것이 아니라 폭풍의 간격을 벌릴 뿐이다.
지터를 넣더라도 범위가 좁으면 소용이 없다. 1만 대에 0에서 100밀리초 사이의 지터를 주면 여전히 100대 안팎이 같은 밀리초에 도착한다. 필요한 것은 0과 현재 백오프 상한 사이의 균등 난수, 즉 Full Jitter다.
그리고 가장 위험한 실수는 이 모든 것을 클라이언트에게만 맡기는 것이다. 서버는 클라이언트의 선의를 가정하면 안 된다. 구형 펌웨어의 클라이언트가 백오프를 구현하지 않았을 수도 있고, 아예 다른 팀이나 서드파티가 만든 클라이언트일 수도 있다. 서버 측 속도 제한이 없으면 클라이언트 한 종류의 버그가 전체 플랫폼을 무너뜨린다.
사유를 알려주는 일도 생각보다 중요하다. 느린 소비자로 끊긴 것과 토큰이 만료되어 끊긴 것은 클라이언트가 해야 할 행동이 정반대다. 앞의 것은 잠시 기다렸다 다시 붙어야 하고, 뒤의 것은 다시 붙어 봐야 똑같이 거절당한다. 왜 끊겼는지 알려주지 않으면 클라이언트는 어느 경우에도 일단 다시 붙으려 하고, 그것이 폭풍을 키운다.
운영 측면에서는 롤링 재시작 자체에 드레인(Drain) 단계를 넣는 방법이 있다. 새 연결 수락을 먼저 멈추고, 접속 중인 클라이언트에 이전하라는 신호를 보내고, 일정 시간 기다린 뒤에 종료하는 순서다. 동시에 끊기는 대신 시간에 걸쳐 나뉘어 이동한다.
마지막으로 재접속이 성립하려면 지켜야 하는 불변식이 하나 있다. 같은 차량이 두 개의 연결로 동시에 존재해서는 안 된다. 같은 식별자로 다시 붙으면 기존 연결을 끊고 새 연결로 교체해야 한다. 그러지 않으면 메시지가 어느 연결로 나갈지 비결정적이 되고 상태가 어긋난다. 그런데 이 교체에는 함정이 하나 있다. 끊긴 기존 연결도 나름대로 뒷정리를 하면서 자기를 목록에서 지우려 하는데, 그 정리 작업이 교체 직후에 실행되면 방금 등록한 새 연결이 지워진다. 지우기 전에 목록에 있는 것이 정말 자기 자신인지 확인하도록 만들어야 한다.
테슬라 차량 제어 시스템의 철학
Tesla Vehicle Command SDK를 이해할 때 가장 중요한 것은 차량 제어의 신뢰 경계를 어디에 두었는가이다.
일반적인 원격 제어 시스템에서는 클라우드가 사용자를 인증하고, 서버가 권한을 확인한 뒤 차량으로 명령을 전달하는 구조(User > Cloud > Gateway > Vehicle)를 쉽게 생각할 수 있다. 이 구조에서는 차량이 Cloud와 Gateway를 신뢰한다는 전제가 숨어 있다. Cloud가 정상적으로 인증했고 Gateway가 정상적으로 명령을 전달했다면 차량은 그 명령을 받아들이는 식이다.
Tesla Vehicle Command Protocol은 여기서 한 단계 더 나아간다. 테슬라 서버가 명령을 전달할 수 있다는 사실과 차량이 그 명령을 실행할 권한이 있다는 사실을 분리한다.
테슬라의 공식 문서에 따르면 서버 측에서는 OAuth 토큰을 통해 요청 주체를 인증하고, 차량은 별도로 자신의 키체인에 등록된 공개키를 사용해 명령을 인증한다. 즉, 유효한 OAuth 토큰을 가지고 Tesla 서버에 요청하는 것만으로는 충분하지 않으며, 해당 차량에 등록된 공개키에 대응하는 개인키를 가진 주체여야 차량이 명령을 받아들일 수 있다.
이 구조의 핵심에는 Virtual Key가 있다. 애플리케이션은 공개키와 개인키로 구성된 키 쌍을 만들고, 공개키를 사용자의 승인 과정을 거쳐 차량에 등록한다. 개인키는 애플리케이션 측에서 안전하게 보관한다. 차량은 이후 명령에 포함된 공개키가 자신의 키체인에 등록되어 있는지 확인하고, 그 키에 대응하는 개인키를 가진 주체가 생성한 명령인지를 검증한다.
여기서 중요한 철학이 드러난다.
차량 제어 권한을 단순히 클라우드의 인증 결과에 의존하지 않고, 차량이 이해할 수 있는 암호학적 자격 증명으로 표현한다.
이 때문에 테슬라의 서버가 명령을 차량으로 전달하는 통신 경로에 있더라도, 서버가 단순히 “이 명령은 인증된 사용자에게서 왔다"고 주장하는 것만으로 차량을 제어할 수 있는 것은 아니다. 차량은 자신이 신뢰하는 공개키와 명령의 암호학적 인증 정보를 바탕으로 명령을 받아들일지를 독립적으로 판단할 수 있다.
실제 Vehicle Command Protocol에서는 클라이언트와 차량이 ECDH를 사용해 공유 비밀키를 도출한다. 클라이언트의 공개키가 이미 차량에 등록되어 있는 상태에서 세션을 설정하고, 차량이 제공한 세션 정보에는 epoch, 차량 시각, anti-replay counter 등의 정보가 포함된다. 이후 명령은 이 세션 정보와 공유 키를 이용해 인증된다.
명령 자체는 Protobuf로 직렬화된 뒤 RoutableMessage의 payload에 들어가고, 별도의 signature_data에는 명령을 인증하기 위한 정보가 포함된다. 인증 방식에 따라 HMAC-SHA256 또는 AES-GCM이 사용되며, epoch, expiration time, counter, domain, VIN(Vehicle Identification Number) 등의 메타데이터도 인증 대상에 포함된다. 이를 통해 단순히 “누가 보냈는가"뿐만 아니라 어떤 차량을 대상으로, 어떤 세션에서, 어느 시점에, 어떤 순서로 생성된 명령인가까지 검증할 수 있다.
즉, 명령 자체가 권한 있는 주체에 의해 생성되었다는 사실을 검증하는 것이 핵심이다.
차량 제어 시스템이 신뢰해야 할 것
차량 제어 시스템은 성격이 다른 세 요구를 동시에 만족시켜야 한다. 그리고 각 요구는 무엇을 신뢰할 것인지에 대한 답을 하나씩 갖는다.
첫째는 보안이다. 명령이 정말 권한 있는 주체에게서 생성되었는지를 신뢰 경계 안에서 검증할 수 있어야 한다. 여기서 신뢰해야 하는 것은 암호학적 인증 정보다. 네트워크 경로나 중간 서버의 동작을 믿는 것만으로는 부족하고, 서명이나 인증 태그와 같은 암호학적 증거로 명령이 신뢰된 주체에서 생성되었다는 사실을 검증해야 한다.
모든 검증을 반드시 차량 자체에서 해야 하는 것은 아니다. Gateway와 같이 차량 앞단에 신뢰할 수 있는 컴포넌트가 있다면 그곳에서 인증과 인가를 처리할 수 있다. 중요한 것은 검증이 어디에서 이루어지는지가 아니라, 검증되지 않은 명령이 신뢰 경계를 넘어 차량의 제어 경로로 들어가지 않는다는 것이다.
둘째는 명령 전달이다. 유실되고 뒤바뀌고 중복될 수 있는 네트워크를 거쳐, 명령을 어떻게 차량까지 전달하고 실패했을 때 어떻게 처리할 것인가를 다뤄야 한다. 여기서 신뢰해야 하는 것은 명시적으로 정의된 프로토콜 규칙이다. 메시지의 순서가 올바른지, 이전에 사용한 메시지가 다시 사용된 것은 아닌지, 세션이 여전히 유효한지, 명령이 아직 실행 가능한 시간 범위에 있는지는 임의의 추측이 아니라 프로토콜에 정의된 규칙으로 판단한다. Sequence, Epoch, Counter, TTL과 같은 값이 이러한 판단의 명시적인 근거가 된다.
셋째는 상태 관리다. 명령을 보냈다는 사실만으로는 차량의 실제 상태가 바뀌었다고 확신할 수 없고, 응답이 오지 않았다고 해서 명령이 실행되지 않았다고 단정할 수도 없다. 여기서 신뢰해야 하는 것은 차량이 관찰한 실제 상태다. 명령의 성공 여부를 네트워크 응답으로 판단하지 말고, 차량에서 관찰된 상태를 실제 결과에 대한 가장 직접적인 근거로 삼는다. Desired와 Actual이 다르다면 다시 수렴시킨다.
결국 차량 제어 시스템에서 신뢰해야 하는 것은 네트워크가 정상적으로 동작했다는 추측이 아니라, 검증할 수 있는 증거와 명시적으로 정의된 규칙, 그리고 차량이 실제로 관찰한 상태다.
차량 제어 시스템을 설계한다는 것은 결국 무엇을 신뢰할 것인지를 정하는 일이고, 신뢰하지 않기로 한 것의 목록이 곧 아키텍처가 된다.
