자율주행 골프카트 플릿 관제
운전자 없이 코스를 도는 카트들이 하루 운행을 소화하게 만드는 플릿 관제 시스템
- 01 카트 · ACU
- MQTT 02 수신 · 정규화
- 03 플릿 관제 백엔드
- 04 관제 화면 · HMI
- 05 운영자
담당 범위
카트가 스스로 굴러가게 만드는 것은 차량 제어 유닛(ACU)의 일이다. 조향과 제동, 경로 추종, 비전 인식은 담당 경계 밖이고, 이 시스템의 몫은 그 카트들이 하루 운행을 소화하게 만드는 쪽이다. 어떤 지도를 들고 나갈지, 앞차와 간격이 맞는지, 문제가 생기면 무엇을 알릴지.
카트가 올릴 센서 항목과 주기, 형식, 상향/하향 토픽 구조, 명령체계를 제조사·연구기관과 협의해 정의하고 연동 명세를 작성했다. 관제 프론트엔드와 카트 태블릿 HMI는 직접 구현했고, 백엔드는 단계로 분해해 위임한 뒤 완료 기준으로 확인했다.
두 버전의 펌웨어를 동시 수용하는 프로토콜 정규화 계층
펌웨어 v1과 v2.1 두 버전의 카트가 같은 서버에 동시 접속한다. 토픽 구조와 페이로드 형식이 서로 다른데, 현장에 나간 펌웨어는 바꿀 수 없고, 신규 카트를 구버전 규격에 맞추면 새 설계가 과거에 묶인다.
차량 쪽 수정을 요구하는 대신 버전 차이를 서버가 흡수하기로 결정했다. 수신을 단일 지점으로 모아 토픽과 본문으로 규격을 판별하고 하나의 정규 계약으로 변환해, 도메인 로직은 버전을 모르게 했다. 구버전 코덱은 동결하고, 신버전 코덱은 정의 파일에서 생성해 코드 수정 없이 확장되게 했다.
같은 지점에 봉투 검사를 뒀다. 메시지 식별자로 중복을 거르고 카트·스트림별로 순번이 뒤로 가는 것을 막되, 부팅 식별자가 바뀌거나 역행 폭이 크거나 침묵이 길었던 경우는 새 세션으로 받아들인다. 장비의 순번 카운터가 되감기는 것을 실제로 관측한 뒤 넣은 예외다.
버전이 다른 카트가 같은 서버에서 함께 운용된다. 전환 작업은 7단계로 분해해 팀원에게 배정했다.
명령 유실이 성공으로 기록되던 구조를 상태 분리로 재설계
서버가 하행 토픽을 구성하면서 카트의 펌웨어 버전을 가정하고 있었다. 신버전 카트에 구버전 주소로 명령이 발행되어 797건이 유실됐는데, 발행 자체는 성공했으므로 로그에는 정상으로 남았다. 정상 로그만 봐서는 알 수 없는 유실이었고, 수신 거부된 메시지를 따로 쌓던 로그를 분석하는 과정에서 발견했다.
하행 주소를 카트가 상행에서 선언한 규격에 따라 결정하도록 바꿔 서버가 버전을 추측하지 않게 했다. 미지원 명령은 발행 전에 거부하고, 무응답은 6초 시간초과로, 발행 실패는 실패로 각각 분리해 기록했다. 버려진 수신 메시지는 사유별로 집계했다.
응답이 없으면 6초 간격으로 세 번까지 다시 보내고, 그래도 응답이 없을 때만 시간초과로 확정한다. 재발행은 같은 명령 식별자를 유지해 카트가 이미 처리한 명령이면 다시 실행하지 않고 결과만 되돌려주게 했다. 명령 순번은 서버가 단조 증가로 발급해, 뒤늦게 도착한 옛 명령은 카트가 거부한다. 재시도를 붙이려면 같은 명령이 두 번 실행되지 않는 것이 먼저 보장돼야 했다.
보낸 것과 도착한 것이 분리되어 기록된다. 여기서 세운 '조용한 폴백 금지' 원칙을 명령·수신·맵 적용·배포 전반에 같은 방식으로 적용했다.
비상정지를 나머지 명령과 다른 등급으로 분리
명령을 한 종류로 다루면 비상정지도 일반 명령과 같은 관문을 지난다. 오프라인으로 판정된 카트에는 명령이 막히고, 규격을 판별하지 못한 카트에는 발행이 거부된다. 정지가 가장 필요한 상황과 정지가 나가지 않는 상황이 겹친다.
전달 규약을 등급으로 나눴다. 비상정지는 사전 관문을 전부 면제해 오프라인 카트에도 발행하고, 규격을 판별하지 못한 카트에는 알려진 모든 규격으로 중복 발행한다. 나머지 명령은 같은 상황에서 발행하지 않고 사유를 남긴다. 시설 단위 일괄 발행도 비상정지에만 열고 다른 명령은 거부한다.
정지 후 자동 재개는 만들지 않았다. 통신이 복구되거나 장비가 되살아나도 재개는 카트별 명시 명령으로만 이뤄지고, 일괄 재개는 카트가 무시한다.
추측하지 않는다는 원칙은 그대로 두고, 모를 때 기우는 방향만 반대로 뒀다. 일반 명령은 보내지 않는 쪽으로, 비상정지는 전부 보내는 쪽으로 기운다.
관제는 정보 제공, 안전 책임은 차량 — 경계를 규격으로 명시
카트는 자기 앞차가 어디 있는지 모르고, 플릿 전체를 보는 것은 서버뿐이다. 그렇다고 서버가 속도를 직접 제어하면 통신이 끊기는 순간 서버가 위험 요소가 된다.
차간거리를 명령이 아니라 응답을 요구하지 않는 정보 스트림으로 정의하고, 서버 정보만으로 폐루프 제어를 하지 않는 것을 명시 원칙으로 삼았다. 판정은 카트 좌표를 코스 경로선에 투영해 얻은 경로상 진행 위치를 기준으로 앞차를 정하는 방식이고, 두 코스가 같은 물리 도로를 공유하는 구간이 있어 다른 코스의 카트도 경로에 투영해 횡방향 4m 이내면 후보에 포함했다. 마주 오는 카트는 진행 방향 사이 각으로 갈라내고, 순환 코스는 진행 위치를 원형으로 계산한다.
판정 결과는 셋으로 나눴다. 앞차 있음과 없음은 매 주기 명시해 보내고, 판정할 수 없을 때는 아무 값도 보내지 않았다. 모름을 없음으로 말하지 않기 위해서다. 받는 쪽 규약도 함께 정했다. 카트는 마지막으로 받은 값을 계속 붙들고 있어서는 안 되고, 수집 주기의 세 배가 지나면 그 값을 입력에서 내린다. 값이 끊긴 것이 길이 비었다는 뜻은 아니므로, 해제가 곧 정지를 의미하지는 않는다는 것도 규격에 적었다.
차량 제어기는 이 스트림을 주행 보조 입력으로만 쓴다. 서버가 침묵하는 것과 서버가 죽은 것을 카트가 구분할 수 있게 됐다.
연결이 끊긴 카트를 판정에서 걷어내는 두 겹 구조
카트가 통신을 잃어도 서버에는 마지막 값이 남는다. 그 값으로 판정을 이어가면 화면에는 멀쩡히 서 있는 카트가 뜨고, 뒤 카트에는 있지도 않은 앞차가 잡힌다. 연결이 살아 있다는 것과 위치가 최신이라는 것도 서로 다른 사실인데, 한 값으로 묶으면 구분이 사라진다.
상태를 두 축으로 나눴다. 아무 상향에나 갱신되는 값과 위치 보고에서만 갱신되는 값을 따로 뒀다. 오프라인 판정은 브로커 유언과 노화 감시 두 경로를 두고, 15초 이상 소식이 없으면 노화로 판정한다. 간격 판정에서는 10초 이상 소식이 없는 카트를 앞차 후보에서 제외한다.
속도 설정을 내릴 때는 오프라인 여부를 속도보다 먼저 본다. 끊긴 카트의 마지막 속도가 0으로 남아 정차 중으로 읽히면, 실제로는 달리고 있는 카트에 설정이 적용된다. 속도를 알 수 없는 경우도 정차가 아니라 주행 중으로 간주한다.
유령 앞차와 정차 오판이 같이 막혔다. 판정에 쓰는 값마다 언제까지 유효한지가 정해져 있다.
재부팅하면 설정을 잊는 장비를 전제로 한 상태 조정 루프
설정을 한 번 내려보내고 끝내면 카트가 재부팅한 순간부터 서버가 아는 상태와 실제가 어긋난다. 어긋났다는 사실은 아무도 모른다.
운영 상태의 소유권을 서버에 두고, 설정 변경·카트 재접속·코스 변화 세 시점에 재하달하도록 설계했다. 여기에 5분 주기 배경 보정을 더해 세 시점을 모두 놓쳐도 어긋난 시간이 한 주기를 넘지 않게 했다. 재하달에는 안전 게이트를 뒀다 — 속도 배율은 주행 중에는 반영하지 않고 정차 시에만 적용하고, 경로 변경은 홀 경계에서만 적용한다.
카트가 기억하지 못해도 서버가 기준을 쥐고 맞춘다. 상태 변경이 주행 중 거동을 예고 없이 바꾸는 경로를 막았다.
수신을 한 곳으로 묶고 무중단 배포를 포기한 적재 설계
서버를 여러 대로 늘리면 브로커 구독자도 함께 늘어난다. 같은 메시지를 두 대가 받으면 적재가 두 번 되고 알람도 두 번 뜬다. 무중단 배포는 전환 구간에 구버전과 신버전이 함께 떠 있는 것을 전제하므로, 그 구간이 정확히 같은 상황이 된다.
텔레메트리 수신은 플릿 전체에서 한 곳만 하도록 강제했다. 잘못된 조합으로 기동하면 그 시점에 죽게 하고, 배포 형상 자체를 테스트로 고정했다. 배포는 잠깐 끊기는 방식으로 바꿔 전환 구간에 구독자가 둘이 되지 않게 했다. 수평 확장을 위한 공유 구독은 규격에 정의만 해두고 쓰지 않는다. 지금 대수에서 수신을 나눌 이유가 없고, 나누는 순간 상태를 밖으로 빼야 하기 때문이다.
적재는 건별로 넣지 않고 200건 또는 1초 경계에서 묶어 넣는다. 쓰기가 밀리면 상한까지 쌓다가 오래된 것부터 버리고, 버린 양을 로그로 남긴다. 시각은 카트가 찍은 값과 서버가 받은 값을 모두 저장해 궤적과 이력에는 카트 시각을, 온라인 판정과 명령 시간초과에는 서버 시각을 쓴다. 둘을 하나로 접으면 둘 다 잃는다. 카트별 시각 차이는 따로 집계해 조회할 수 있게 했다.
배포할 때 잠깐 끊기는 대신 같은 데이터가 두 번 쌓이지 않는다. 버려지는 것이 생겨도 조용히 사라지지 않는다.
맵 제작·발행 파이프라인과 3층 검증
지도가 잘못되면 카트가 가면 안 되는 곳으로 간다. 원본 좌표는 당초 드론 정사촬영으로 확보하려 했지만 오차가 컸다. 그리고 지도를 편집하는 것은 엔지니어가 아니라 운영자다.
원본 좌표는 카트를 실제 주행시켜 취득하는 방식으로 바꿨다. 차량이 주행하는 좌표계와 지도의 좌표계가 같아져 변환 오차가 사라진다. 그 위에 맵 에디터에서 경로·속도·홀 지정·대기 구역을 제작하고, 검증을 세 층으로 설계했다. 구간이 충돌하는 편집은 편집 단계에서 아예 만들 수 없게 차단하고, 연결성 등은 자동 검사하며, 마지막으로 실제 카트의 회전 반경과 하드웨어 특성을 동일하게 반영한 시뮬레이터로 주행해 확인한다. 도면상 문제없지만 실제로는 돌 수 없는 커브를 여기서 잡는다.
규칙 검사는 지도가 말이 되는지를, 시뮬레이터 주행은 그 지도로 실제로 어떻게 움직이는지를 본다. 시뮬레이터를 개발 도구가 아니라 검증 장비로 뒀다.
감속 명령의 의미 불일치를 규격 확정으로 해결
감속 명령이 양쪽에서 다르게 해석되고 있었다. 관제는 "10km/h에서 20% 감속"을 미리 계산해 절대값 8km/h로 내려보냈는데, 카트는 받은 값에 다시 비율 계산을 적용하고 있었다. 양쪽 다 자기 기준으로는 정상이라 값만 봐서는 오류로 보이지 않았고, 로그를 대조하는 과정에서 충돌을 발견했다.
한쪽 숫자를 고쳐 맞추는 대신 의미를 확정해야 하는 사안으로 판정했다. 감속 명령을 비율값으로 통일해 계산 주체를 카트로 확정하고, 그 정의를 연동 규격에 명시했다.
같은 종류의 충돌이 재발할 경로가 막혔다. 이후 규격을 정할 때 값의 형식보다 값의 의미를 먼저 확정하는 것을 원칙으로 삼았다.
상대 조직이 직접 확인하게 만든 것
장비 연동에서 가장 많이 나오는 말은 보냈는데 왜 반영이 안 되느냐는 것이다. 서버 로그를 요청받아 확인해주는 방식으로는 왕복이 길고, 접속 안내 문서와 실제 발행 주소가 갈라지면 그 사실을 아무도 모른다.
주고받은 MQTT 원문을 수신·발행 양방향으로 남기고 화면에서 조회하게 열었다. 검증을 통과하지 못한 메시지도 사유와 함께 남겨, 왜 버려졌는지를 상대가 직접 본다. 접속 안내는 문서로 쓰지 않고, 실제 발행이 쓰는 것과 같은 코드가 주소를 만들어 내려주게 했다.
안내에 뜨는 주소가 곧 실제 주소라 문서와 구현이 어긋날 수 없다. 구버전 주소를 안내해 하향이 도달하지 않았던 건이 이 구조를 만든 계기다.
현재 상태
실차 3대로 시험장과 실제 골프장 코스를 주행해 제조사·연구기관의 검증 완료 판정을 받았다. 두 시설을 동시에 운용하는 구조로 확장했고, 프로토콜·관제 코어·맵 파이프라인·적재 계층이 가동 중이다. 파일럿 운영을 앞두고 브로커 토픽 권한 분리와 통신 두절 시 차량 측 정지 임계 확정을 남겨두고 있으며, 후자는 차량 제조사와 의제로 올려 협의 중이다.