풍력발전단지를 점검하는 드론 관제 시뮬레이터를 개인 프로젝트로 만들었다.

캘리포니아의 실제 풍력단지를 3D로 재현하고, 가상 드론을 띄워 원격 조종하는 웹 GCS(지상관제시스템)다.

처음엔 드론이 예쁘게 나는 걸 만들고 싶었다.

이륙하고, 터빈 사이를 날고, 착륙하는 것.

그런데 만들다 보니 이상했다.

관제 화면은 모든 게 정상일 때 보는 화면이 아니다.

정상이면 사람이 볼 이유가 없다. 관제는 뭔가 잘못됐을 때 보는 화면이다.

그래서 방향을 바꿨다. 잘 나는 드론이 아니라 문제가 생기는 드론을 만들기로 했다.

왜 2D 지도가 아니라 3D였나

처음에 지도 위에 점을 찍는 방식을 생각했다.

그런데 풍력 터빈 점검이라는 작업의 성격상 그걸로는 부족했다.

풍력 터빈은 높이가 100미터를 넘는다. 점검 드론은 그 옆을 아주 가깝게 지나간다.

관제하는 사람이 알아야 하는 건 "드론이 어디 있나"가 아니라 **"드론이 터빈 대비 어느 높이에, 어떤 자세로 있나"**다.

2D 지도에서는 이게 안 보인다. 위에서 내려다보면 드론과 터빈이 겹쳐 있을 뿐이다.

그래서 3D 지형 엔진으로 실제 풍력단지를 띄우고, 회전하는 터빈 블레이드와 드론의 자세를 함께 렌더링했다.

드론 방향은 쿼터니언으로 다뤘다. 오일러 각으로 하면 짐벌락에 걸리는 자세가 실제로 나온다.

공간을 다루는 UI는 공간을 그대로 보여줘야 한다는 게 이때 얻은 감각이었다.

내부 통신에 굳이 계약을 넣은 이유

프론트와 백엔드 사이는 평범하게 REST와 WebSocket으로 붙였다.

그런데 백엔드 게이트웨이와 드론 시뮬레이터 사이는 gRPC와 Protobuf로 만들었다.

혼자 만드는 개인 프로젝트에 이건 과한 선택처럼 보인다.

이유가 있었다.

로봇 제어에서 명령은 정확해야 한다. "고도 50" 이라는 값이 문자열로 갔다가 숫자로 파싱되는 과정에서 틀리면 실제 장비에서는 사고가 난다.

Protobuf는 스키마를 강제한다. 필드 타입이 계약으로 박혀 있어서 런타임에 어긋날 여지가 줄어든다.

그리고 하나 더 — 나중에 시뮬레이터를 다른 언어로 갈아끼울 수 있게 하려고 했다.

실제 로봇 제어 계층은 성능 때문에 C++이나 Go로 가는 경우가 많다.

그때 프론트를 안 건드리고 시뮬레이터만 바꿀 수 있으려면, 그 경계에 언어 중립적인 계약이 있어야 한다.

경계를 먼저 그어두면 나중에 그 뒤를 통째로 갈아끼울 수 있다.

기다리는 것이 아니라 바꾸는 것에 대비한 설계였다.

물리를 넣기 시작하다

여기서부터가 이 프로젝트의 진짜 내용이다.

드론을 애니메이션이 아니라 상태 기계 + 물리로 만들었다.

100ms마다 도는 루프가 상태를 갱신하고, 그걸 10Hz로 WebSocket에 흘려보낸다.

바람. 랜덤한 돌풍이 불고, 그게 드론 위치를 흔든다. 화면에서 드론이 미세하게 떨린다.

배터리. 그냥 시간에 비례해서 닳는 게 아니다. 상승할 때, 빠르게 이동할 때 더 많이 닳는다. 호버링이 제일 적게 쓴다.

이 두 개를 넣고 나니 화면이 갑자기 달라졌다.

배터리 게이지가 그냥 내려가는 숫자가 아니라 의사결정에 쓰이는 정보가 됐다.

"지금 저기까지 갔다가 돌아올 수 있나?"를 사람이 계산하기 시작한다.

바람이 세지면 배터리가 빨리 닳으니까, 바람 경고가 뜨면 임무를 앞당길지 판단해야 한다.

정보가 판단에 쓰일 때만 UI가 의미를 갖는다는 걸 이때 처음 체감했다.

failsafe — 스스로 돌아오게 만들기

그리고 가장 중요한 걸 넣었다.

배터리가 임계치 밑으로 떨어지거나 통신이 끊기면, 드론이 스스로 복귀(RTH)를 시작한다.

사람의 명령을 기다리지 않는다.

이걸 구현하면서 상태 기계가 이렇게 생겼다.

LANDED → ARMED → TAKING_OFF → FLYING ⇄ HOVERING
                                   ↓
                           MISSION / INTERCEPT
                                   ↓
                     RETURNING → LANDING → LANDED

RETURNING은 사용자가 부를 수도 있지만, 시스템이 스스로 진입할 수도 있는 상태다.

여기서 UI 설계의 어려운 문제가 나왔다.

드론이 스스로 돌아가기 시작했을 때, 화면은 이걸 어떻게 보여줘야 하나?

조종하던 사람 입장에서는 갑자기 내 명령을 무시하고 기체가 움직이는 상황이다.

그래서 왜 그렇게 됐는지를 함께 표시하기로 했다. "배터리 임계 — 자동 복귀 중" 같은 식으로.

시스템이 사람 대신 결정을 내렸으면, 그 이유를 반드시 말해줘야 한다.

이유 없이 알아서 잘 하는 시스템은 신뢰를 못 얻는다.

위젯이 줄어들면 내용도 줄어들어야 한다

관제 화면은 위젯을 자유롭게 크기 조절할 수 있게 만들었다. 제어판, 텔레메트리, 영상.

그런데 위젯을 작게 줄이면 내용이 다 깨졌다.

글자가 넘치고, 로그가 잘리고, 숫자가 두 줄로 접혔다.

CSS 미디어 쿼리로는 안 됐다. 화면 크기가 아니라 위젯 크기에 반응해야 하니까.

ResizeObserver로 위젯 자체의 크기를 관찰해서, 작아지면 로그를 숨기고 폰트를 줄이고 라벨을 축약하게 했다.

반응형이 뷰포트만의 문제가 아니라는 걸 이때 알았다.

컨테이너 안에서도 반응형이 필요하다.

이 프로젝트가 남긴 것

이 시뮬레이터는 나중에 회사 과제로 이어졌다.

여기서 만든 것들 — 텔레메트리 스트리밍, 상태 기계, 실패 시나리오, 3D 공간 표현 — 이 그대로 멀티로봇 관제 플랫폼의 기반이 됐다.

개인 프로젝트로 시작한 게 업무가 된 셈인데, 돌이켜보면 그게 가능했던 이유는 결과물이 아니라 감각이 남았기 때문인 것 같다.

기술 스택은 바뀌었다. Cesium은 Mapbox와 Three.js로, gRPC는 REST로.

그런데 "관제 화면은 이상 상황을 위해 존재한다"는 감각은 그대로 갔다.

정리하면 이렇다.

  • 정상 동작만 만들면 절반짜리다. 실패를 시뮬레이션해야 관제 UI가 설계된다
  • 숫자는 판단에 쓰일 때만 정보다. 배터리 게이지는 물리를 넣고 나서야 정보가 됐다
  • 시스템이 사람 대신 결정했으면 이유를 말해야 한다. 자동 복귀에 사유 표시를 붙인 이유
  • 반응형은 뷰포트만의 문제가 아니다. 컨테이너 크기에 반응해야 할 때가 있다

떨어지는 드론을 만드는 게 나는 드론을 만드는 것보다 훨씬 재밌었다.