-
"Jog가 느린 이유".... 원인은 네 군데!Frontend 2026. 6. 29. 10:43반응형
로보틱스에 대한 도메인 지식이 없는 상태에서 로보틱스 회사에 입사한 후, 프론트엔드 업무를 진행하면서 여러 어려움을 겪고 있다.
그 중에서 결국 시뮬레이터 로봇을 건들여야하는 상황에서의 경험을 공유하고자 한다.

"Jog가 느린 이유".... 원인은 네 군데! 3D 뷰에서 로봇을 jog(수동 조작)할 때 "느리다 / 안 따라온다 / 가끔 튄다"는 리포트가 들어왔다.
"랙"이라는 단일 증상 뒤에는 서로 다른 레이어의 원인 네 개가 있었다 — stale ROS2 노드 프로세스, URDF joint limit API 공백, 상태 publish 주기 부재, 입력 중복 발사. 이 글은 증상 이름에 속지 않고 구간별 계측으로 원인을 격리 → 소스에서 수정 → end-to-end 검증하는 루프를 어떻게 돌렸는지에 대한 기록이다.
증상 이름으로 수정 위치를 정하지 않는다
"jog가 느리다"를 받고 가장 흔히 저지르는 실수는 프론트엔드 입력 핸들러에 throttle/debounce를 거는 것이다.
그건 레이어 추정이지 측정이 아니다.
지연이 백엔드 제어 루프에 있다면 프론트 throttle은 입력만 더 둔하게 만들고 원인은 그대로 남는다.
그래서 입력 → 전송 → 처리 → 상태 반영의 각 구간에 타임스탬프를 박아 시간이 새는 구간부터 격리했다.
// 입력 → API → 3D 반영 각 구간을 performance.now()로 계측 const t0 = performance.now(); emitJog(cmd); socket.once("robot_state", () => { const rtt = performance.now() - t0; if (rtt > 50) console.warn(`[jog] slow path ${rtt.toFixed(1)}ms`, cmd); });# 백엔드는 토픽 발행 주기를 직접 측정 ros2 topic hz /robot_state # 기대 30Hz, 실측은? ros2 topic delay /robot_state # 지연 분포 ros2 node list # 살아있는 노드 — 여기서 첫 단서가 나왔다계측 결과 "느림"은 단일 원인이 아니라 네 개의 독립 원인이 겹친 결과였다.
원인 ① : stale ROS2 노드 프로세스 (증상: 속도가 간헐적으로 어긋남)
ros2 node list에virtual_robot_node가 두 개 떠 있었다.이전 세션에서 띄운 프로세스가
SIGINT로 깔끔히 종료되지 않고 남아, 새 프로세스와 같은 토픽을 동시에 구독하고 있었다.두 노드가 jog 명령을 번갈아 처리하니 속도 값이 가끔 옛 값으로 적용됐다 ( ➡ "가끔 튄다"의 정체 )
ROS2에서 노드가 좀비로 남는 전형적 원인은 spin 루프가 시그널을 안 받는 구조다.
executor를 명시적으로 종료하도록 라이프사이클을 정리했다.
def main(): rclpy.init() node = VirtualRobotNode() executor = SingleThreadedExecutor() executor.add_node(node) try: executor.spin() except KeyboardInterrupt: pass finally: # 좀비 방지: 확실한 회수 executor.shutdown() node.destroy_node() rclpy.shutdown()기동 스크립트에도 이전 인스턴스를 회수하는 가드를 넣어, 재시작 시 토픽을 두 프로세스가 물지 않도록 했다.
표면상 "속도 버그"였지만 실제 레이어는 Process lifecycle이었다.
3. 원인 ② — URDF joint limit API 공백 (증상: limit 근처에서 오동작)
조인트를 한계각 근처로 밀면 동작이 어긋났다.
프론트엔드가 joint min/max limit을 모른 채 하드코딩된 추정 상수로 clamp하고 있었기 때문이다.
진짜 limit은 URDF에 있는데 프론트로 내려오는 통로가 없었다.
프론트가 값을 추측하고 있으면 그건 API 공백 신호다.
추정 상수를 지우고 데이터를 소스(URDF)에서 끌어왔다.
// urdf-loader로 파싱한 로봇에서 실제 limit 추출 → 훅으로 노출 export function useRobotJointLimits(robot: URDFRobot | null) { return useMemo(() => { if (!robot) return {}; return Object.fromEntries( Object.values(robot.joints) .filter((j) => j.jointType !== "fixed" && j.limit) .map((j) => [j.name, { min: j.limit.lower, max: j.limit.upper }]), ); }, [robot]); } // jog 입력을 추정값이 아닌 실제 limit으로 clamp const next = clamp(current + delta, limit.min, limit.max);여기에 이동단위 직접입력(
clamp+ 소수 2자리 고정)과 조인트 step 이동(이동단위 슬라이더)을 붙여 정밀 조작을 지원했다.데이터의 출처가 단일화되니 "프론트와 로봇이 서로 다른 한계를 믿는" 불일치가 사라졌다.
4원인 ③ — 상태 publish 주기 부재 (증상: 3D 뷰가 끊겨 보임)
"3D가 끊긴다"의 원인은 렌더링이 아니라 데이터 공급 주기였다.
ros2 topic hz /robot_state로 재보니 발행이 불규칙·저빈도였다.3D 뷰는 띄엄띄엄 도착한 상태값 사이를 점프하니 끊겨 보였다.
여기서의 함정은 "프론트에 보간(interpolation)을 넣자"였다. 그건 증상 가리기다.
데이터가 안정적으로 30Hz로 오면 보간 없이도 부드럽다. 원인(공급 주기)을 고치면 덧칠이 불필요하다.
# 이벤트 기반(명령 올 때만)에서 고정 주기 타이머 발행으로 전환 class VirtualRobotNode(Node): def __init__(self): super().__init__("virtual_robot_node") self.pub = self.create_publisher(JointState, "/robot_state", 10) self.create_timer(1.0 / 30.0, self._publish_state) # 30Hz 고정 def _publish_state(self): self.pub.publish(self._current_joint_state())프론트는 받은 상태를
requestAnimationFrame루프에서 Three.js 씬에 반영하되, 30Hz 공급 × 60fps 렌더의 미세 간극만 마지막 값 유지로 메웠다. 핵심은 공급 주기를 먼저 고친 것이다.
5. 원인 ④ — 입력 중복 발사 (증상: 지연 누적)
Jog 버튼을 계속 누르는 동안 같은 명령이 반복해서 전송되면서 요청이 쌓였다.
요청이 많이 쌓이다 보니 처리가 늦어졌고, 시간이 지날수록 조작이 더 느리게 느껴졌다.
그래서 직전에 보낸 명령과 같은 명령이면 다시 보내지 않도록 막았다.let last = ""; function emitJog(cmd: JogCommand) { const key = `${cmd.joint}:${cmd.delta}:${cmd.frame}`; if (key === last) return; // 중복 발사 차단 — 큐 적체 해소 last = key; socket.emit("jog", cmd); }이게 "느리다"의 마지막 한 조각이었다. 네 원인이 모두 정리되자 계측 RTT가 안정 범위로 들어왔다.
원인 수정 위에 UX를 얹는다 (순서가 중요)
네 개의 근본 원인이 정리된 뒤에야 UX를 다듬었다.
토대가 흔들리는 상태에서 UI부터 만지면, 개선이 원인 수정 덕인지 UI 덕인지 분간이 안 된다.
- 3D 뷰 동적 줌: 로봇 바운딩박스(BBOX) 기준으로 줌 범위 자동 산출 → 멀티로봇에서도 항상 적절한 거리
- 커서 기준 줌(
zoomToCursor): OrbitControls의 줌 타겟을 마우스 레이캐스트 지점으로 이동 - TCP 절대 이동/정지: 좌표 직접 입력 기반 절대 위치 지정
- jog 설정 persist: 속도·이동단위·모드·좌표계를 store에 영속화 (재접속 시 복원)
디버깅 루프 회고
이 작업을 관통한 방법론:
증상 관측 → 구간별 계측으로 원인 격리 (추정 금지) → 가장 아래 레이어의 원인부터 소스에서 수정 → end-to-end 재계측으로 검증 → 다음 원인 (한 번에 하나씩)두 가지 규칙:
- 한 번에 한 원인. stale 프로세스·API 공백·publish 주기·중복 발사를 동시에 건드리지 않았다. 하나 고치고 재계측하고 다음. 묶어 고치면 무엇이 효과였는지 영영 모른다.
- 증상이 가리키는 레이어에서 멈추지 않는다. "속도 버그"는 프로세스, "끊김"은 publish 주기, "limit 오동작"은 API 공백이었다. 표면 패치(throttle, 보간, clamp 상수)는 전부 더 아래의 원인을 가리는 덧칠이었다.
정리
- 증상 이름으로 수정 위치를 정하지 마라. "랙"이라고 프론트 throttle을 거는 순간 사이드 이펙트만 쌓인다.
performance.now()구간 계측과ros2 topic hz/delay로 시간이 새는 레이어부터 격리한다. - 프론트가 상수를 추측하고 있으면 API 공백이다. clamp 한계값은 소스(URDF)에서 와야 한다
- "끊긴다"는 대개 렌더가 아니라 데이터 공급 주기다. 보간으로 가리기 전에 publish 주기(30Hz 고정 타이머)를 의심하라.
- 근본 원인을 다 고친 뒤 UX를 얹는다. 순서가 반대면 개선의 출처를 분리할 수 없다.
반응형'Frontend' 카테고리의 다른 글
[프론트엔드] Three.js 렌더링 구조와 최적화 (2) 2026.05.15 프론트엔드 폴더 스트럭처 구성 종류 (폴더링 패턴) (0) 2025.09.11 모바일에서 웹 캐러셀 중첩 스크롤 처리 (safari) (3) 2025.07.29 프론트엔드 고화질 이미지 다루는 방법 (1) 2025.06.24 강력새로고침 하면 불러오는데 일반새로고침은 불러오지 못한다? _ service worker (0) 2025.06.04