개인 프로젝트로 시작한 것이 회사 과제가 되었다.
드론 시뮬레이터를 먼저 만들었고, 거기에 6축 로봇 팔과 AGV, 컨베이어를 붙여 멀티 플랫폼 디지털 트윈으로 키웠다.
관절 슬라이더를 움직이면 3D 로봇이 따라 움직이는 화면.
만들면서 꽤 뿌듯했다.
그런데 어느 순간 이상하다는 생각이 들었다.
이 화면으로 로봇 스무 대를 어떻게 다루지?
처음 만든 것은 조종석이었다
처음에 만든 건 명확히 조종석이었다.
6축 관절마다 슬라이더가 있고, IK 솔버를 붙여서 엔드 이펙터 좌표를 찍으면 관절각이 역산되어 따라온다.
Three.js로 로봇 팔을 그리고 블룸과 비네트까지 얹었다.
AGV는 웨이포인트를 찍으면 그 경로를 따라가고, 컨베이어는 속도와 방향을 조절하면 벨트가 돈다.
백엔드에는 33ms마다 도는 틱 루프를 만들어 물리 상태를 갱신하고, WebSocket으로 텔레메트리를 흘려보냈다.
기술적으로는 재미있었다.
문제는 이게 한 대를 다루는 인터페이스라는 것이었다.
한 대는 조종할 수 있다. 스무 대는 조종할 수 없다
로봇이 한 대일 때는 슬라이더가 답이다.
두 대여도 탭을 나누면 된다.
그런데 스무 대가 되는 순간, 조종이라는 개념 자체가 성립하지 않는다.
사람은 동시에 스무 대를 조종할 수 없다.
더 결정적인 건 따로 있었다.
실제로 현장에 로봇을 운영하는 회사는 이미 자율주행을 갖고 있다.
배차도, 경로 계획도, 장애물 회피도 그쪽이 훨씬 잘한다.
내가 만든 조종석은 그들이 이미 더 잘하는 일을 어설프게 흉내내는 화면이었다.
그래서 방향을 바꿨다.
로봇을 조종 대상이 아니라 감독 대상으로 다시 정의했다.
우리 화면이 할 일은 관절각을 보내는 게 아니라,
이벤트를 감지하고 → 상황을 사람에게 보여주고 → "저기로 가서 확인해줘"라고 요청하고 → 그 과정을 추적하는 것이다.
조종(Teleoperation)에서 감독 제어(Supervisory Control)로.
기능을 더 넣는 결정이 아니라 버리는 결정이었는데, 지금까지 한 결정 중에 제일 잘한 것 같다.
문제 1. 연동할 상대가 아직 없다
방향은 정해졌는데 현실적인 문제가 있었다.
로봇 데이터를 줄 협력사의 API가 아직 확정되지 않았다.
심지어 협의 중에 그쪽 로봇 두 대가 오프라인이 되어 실검증 자체가 막히기도 했다.
전시 일정은 정해져 있는데 데이터가 없는 상황.
여기서 FleetSource라는 인터페이스를 하나 두기로 했다.
interface FleetSource {
id: string;
start(handlers): void;
stop(): void;
}
// handlers: onFleet(robots) · onAlert(alert) · onMission(mission)
이 인터페이스만 지키면 데이터가 어디서 오든 화면은 신경 쓰지 않는다.
구현체는 세 개를 뒀다.
SelfSimSource— 내가 만든 백엔드 시뮬레이터에서 받아온다DispectorMockSource— 시나리오 대본대로 움직이는 목업DispectorSource— 실연동 자리 (아직 비어 있음)
환경변수로 어느 걸 쓸지 고른다.
결과적으로 협력사 로봇이 꺼져 있어도 전체 시연이 끊김 없이 돌아간다.
나중에 실연동이 확정되면 세 번째 구현체만 채우면 되고, 화면 코드는 한 줄도 바뀌지 않는다.
예전의 나였다면 아마 "API 나오면 그때 붙이죠"라고 하고 기다렸을 것 같다.
기다리는 대신 경계를 먼저 그어두면 기다릴 필요가 없다는 걸 배웠다.
문제 2. 같은 상태인데 이름이 두 개였다
이건 좀 창피한 이야기다.
로봇 상태가 두 군데서 들어왔다.
하나는 실시간 텔레메트리에 실려 오는 문자열("moving", "idle" 같은).
다른 하나는 시설 트리에 정적으로 박혀 있는 enum.
같은 "이동 중"인데 형식이 달랐다.
처음엔 필요한 곳마다 분기를 넣었다.
지도 마커 색을 정하는 곳에서 한 번, 상태 배지에서 한 번, 요약 카운트에서 한 번.
그러다 상태를 하나 추가할 일이 생겼는데, 고쳐야 할 자리를 찾느라 한참을 헤맸다.
빠뜨린 곳도 있었다. 마커는 색이 바뀌는데 요약 카운트에는 안 잡히는 식이었다.
그래서 operationalState라는 모듈을 만들어 상태 정의를 한 곳으로 모았다.
IDLE · DISPATCHED · MOVING · ARRIVED · INSPECTING · RETURNING · CHARGING · ERROR · OFFLINE
이 9개가 정규 상태이고, 각 상태마다 한글·영문 라벨, 색, 심각도를 함께 정의했다.
그리고 규칙을 하나 정했다.
변환은 어댑터 경계에서 딱 한 번만 한다. 그 아래에서는 raw 문자열을 절대 분기하지 않는다.
fromLiveState()와 fromRefStatus()가 두 출처를 각각 정규 상태로 바꾸고, 그 뒤로는 모두 이 값만 본다.
마커 색도, 배지도, 요약 카운트도 전부 여기서 파생된다.
지금은 상태를 추가할 때 파일 하나만 열면 된다.
교훈은 단순했다.
같은 개념이 두 가지 형식으로 존재하면, 그 둘을 합치는 지점을 반드시 하나로 정해야 한다.
정하지 않으면 형식 변환이 코드 전체에 흩어지고, 흩어진 변환은 반드시 어딘가에서 어긋난다.
문제 3. 영상은 두 종류다
라이브 영상월을 만들 때 처음엔 하나의 파이프라인으로 생각했다.
카메라 영상을 받아서, 화면에 뿌리고, 거기서 이상 상황을 감지하고.
그런데 이 둘은 요구사항이 완전히 다르다.
시청용은 사람이 보는 것이다. 지연이 조금 있어도 되고, 화질이 중요하고, 여러 개를 동시에 띄워야 한다.
분석용은 기계가 보는 것이다. 프레임 단위 정확도가 필요하고, GPU가 필요하고, 화면에 띄울 필요가 없다.
이걸 한 파이프라인에 넣으면 양쪽 다 망가진다.
그래서 아예 갈라놨다.
시청용은 백엔드에서 HTTP Range로 파일을 서빙하거나, WebRTC로 스트리밍한다.
(ffmpeg으로 VP8 RTP 인코딩 → werift 트랙 → 브라우저 video 태그)
분석용은 우리 백엔드가 아예 하지 않는다.
분석 서버나 엣지 장비가 처리하고, 우리는 그 결과인 "이벤트 JSON"만 받는다.
책임 경계를 그으니 백엔드가 훨씬 단순해졌다.
"우리가 안 하는 일"을 명시적으로 정하는 것도 아키텍처라는 걸 이때 알았다.
음성은 마지막에 얹었다
관제 운영자를 보조하는 음성 에이전트를 얹었다.
"렉스"라고 부르면 깨어나서, 현장 브리핑을 하고 CCTV를 전환하고 로봇 출동을 요청한다.
Web Speech API로 음성을 받고, 백엔드의 LLM 게이트웨이를 SSE로 스트리밍해서 답을 만든다.
API 키는 백엔드에만 두고 프론트는 게이트웨이만 부른다.
여기서 신경 쓴 건 성능이 아니라 안전장치였다.
로봇을 실제로 움직이는 명령은 한 번 더 확인을 받게 했다.
음성 인식은 틀릴 수 있고, LLM도 틀릴 수 있다.
두 개가 겹친 상태에서 로봇이 바로 움직이면 안 된다고 생각했다.
배운 것
정리해보면 세 가지다.
1. 기능을 빼는 결정이 더 어렵고 더 중요하다.
조종석을 버리는 결정은 만든 걸 지우는 일이라 아까웠다.
그런데 그 결정 하나로 제품의 정체성이 명확해졌다.
무엇을 만들지보다 무엇을 만들지 않을지가 방향을 정한다.
2. 경계를 먼저 그으면 기다리지 않아도 된다.
외부 의존이 확정되기를 기다리는 대신 인터페이스를 먼저 정의하면 개발이 멈추지 않는다.
이건 협업하는 상대가 사람이든 회사든 똑같이 적용되는 것 같다.
3. 같은 개념은 한 곳에서만 정의해야 한다.
두 출처의 상태를 합치는 지점을 정하지 않았을 때, 버그는 코드가 아니라 설계에서 나왔다.
한 파일로 모으고 나서야 안심하고 상태를 추가할 수 있게 됐다.
프론트엔드 개발자로 일하면서, 화면을 잘 만드는 일에서 시작해 무엇을 보여줄지 정하는 일로 넘어오고 있다는 느낌이 든다.
요구 명세를 쓰고, 인터랙션 모델을 정하고, 외부 API 매핑을 협의 자료로 만드는 일까지 하게 되었다.
아직 갈 길이 멀지만, 이 방향이 재밌다.