ROS 로봇 팔 전체 제어 프로세스 — 비동기 제어 루프의 이해

개요

로봇 팔을 제어할 때, 전체 프로세스는 단순히 "읽고 → 계산하고 → 이동하고 → 기다리고" 의 순차 반복이 아닙니다. ROS 기반 시스템에서는 비동기 메시지 패싱을 통해 상태 읽기와 명령 발행이 독립적으로 동작합니다.

이 포스트에서는 /joint_states 구독부터 IK 계산, 컨트롤러 명령 발행까지의 전체 제어 루프를 단계별로 살펴보고, 동기식과 비동기식의 차이를 명확히 정리합니다.


전체 제어 루프

30Hz로 반복되는 제어 루프는 다음 6단계로 구성됩니다.

단계동작주체
/joint_states 구독 (콜백으로 계속 갱신)내 노드
목표 Pose 계산 (원 위의 다음 점 등)내 노드
/compute_ik 호출 (시드 = ①의 관절각)내 노드
결과 관절각을 컨트롤러에 발행내 노드
모터 구동 → 로봇 이동컨트롤러
/joint_states 자동 갱신 → ①로 돌아감컨트롤러
flowchart TD
    A["① /joint_states 구독<br/>(콜백으로 최신 관절값 저장)"] --> B["② 목표 Pose 계산"]
    B --> C["③ /compute_ik 호출<br/>(시드 = 최신 관절각)"]
    C --> D["④ 결과 관절각 발행"]
    D --> E["⑤ 컨트롤러가 모터 구동"]
    E --> F["⑥ 컨트롤러가 /joint_states 갱신"]
    F --> A
 
    style A fill:#4CAF50,color:#fff
    style D fill:#2196F3,color:#fff
    style E fill:#FF9800,color:#fff

핵심: ①과 ④~⑥은 비동기로 동시에 돌아갑니다. 제어 루프는 최신 상태를 "가져다 쓸 뿐"이고, 상태 갱신은 백그라운드에서 자동으로 이루어집니다.


동기식 vs 비동기식

처음 접하면 아래처럼 생각하기 쉽습니다.

❌ 순차적 (동기식)
읽기 → IK → 이동 → 갱신 완료 대기 → 읽기 → IK → ...

이 방식은 매 루프마다 갱신을 기다려야 하므로 제어 주기가 느려지고, 실시간성이 떨어집니다.

sequenceDiagram
    participant CB as 콜백 흐름
    participant CL as 제어 루프
    participant CT as 컨트롤러
 
    loop 항상 반복
        CT->>CB: /joint_states 발행
        CB->>CB: self._current_joints 갱신
    end
 
    loop 30Hz 반복
        CL->>CL: 목표 Pose 계산
        CL->>CL: IK 호출 (시드 = 최신 관절값)
        CL->>CT: 관절각 명령 발행
        CT->>CT: 모터 구동
    end

두 흐름이 독립적으로 동작합니다:

흐름역할주기
콜백 흐름/joint_states 수신 → self._current_joints 갱신토픽 발행 주기 (보통 50~1000Hz)
제어 루프목표 계산 → IK → 발행30Hz (사용자 설정)

제어 루프는 콜백이 저장해둔 변수를 읽기만 합니다. 갱신을 "기다리는" 것이 아니라, 항상 그 시점의 최신 값을 사용합니다.


코드로 보는 제어 루프

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
 
class ArmController(Node):
    def __init__(self):
        super().__init__('arm_controller')
        self._current_joints = {}
 
        # ① 콜백 등록: /joint_states가 올 때마다 자동 실행
        self.create_subscription(
            JointState, '/joint_states', self._joint_state_cb, 10
        )
 
        # 30Hz 타이머로 제어 루프 실행
        self.create_timer(1.0 / 30, self._control_loop)
 
    def _joint_state_cb(self, msg):
        """콜백: /joint_states가 올 때마다 자동 실행 (별도 흐름)"""
        for name, pos in zip(msg.name, msg.position):
            self._current_joints[name] = pos  # 최신 값 저장
 
    def _control_loop(self):
        """제어 루프: 30Hz로 반복"""
        target = self._next_target_pose()                    # ② 목표 계산
        ik_result = self._compute_ik(target, self._current_joints)  # ③ IK (최신 시드 사용)
        self._publish_command(ik_result)                     # ④ 발행
        # ⑤⑥ 컨트롤러가 모터 구동 + joint_states 갱신 → 콜백이 알아서 받음

: create_subscription의 QoS depth(여기서 10)는 콜백이 처리하기 전에 쌓일 수 있는 메시지 수입니다. 제어용으로는 최신 값만 필요하므로 qos_profile_sensor_data를 사용하면 항상 최신 메시지만 받을 수 있습니다.


역할 분리

로봇 제어에서 내 노드컨트롤러의 역할은 명확히 분리됩니다.

내 노드가 하는 것컨트롤러가 하는 것
목표 Pose 계산모터 구동 (하드웨어 제어)
/compute_ik 호출/joint_states 발행
관절각 명령 발행TF 갱신 (robot_state_publisher)
flowchart LR
    subgraph 내노드["내 노드 (Planner)"]
        A[목표 Pose 계산] --> B[/compute_ik 호출/]
        B --> C[관절각 발행]
    end
 
    subgraph 컨트롤러["컨트롤러 (Hardware Interface)"]
        D[모터 구동] --> E[/joint_states 발행/]
        E --> F[TF 갱신]
    end
 
    C -->|"명령 토픽"| D
    E -->|"상태 토픽"| A
 
    style 내노드 fill:#E3F2FD,stroke:#1565C0
    style 컨트롤러 fill:#FFF3E0,stroke:#E65100

이 구조의 핵심은 관심사의 분리(Separation of Concerns) 입니다:

  • 내 노드: "어디로 갈지" 결정 (고수준 계획)
  • 컨트롤러: "어떻게 갈지" 실행 (저수준 제어)

비동기 제어의 장점과 주의점

  • 실시간성 확보: 상태 갱신을 기다리지 않으므로 일정한 제어 주기(30Hz) 유지 가능
  • 모듈화: 상태 수집과 명령 생성이 분리되어 각각 독립적으로 테스트/수정 가능
  • 확장성: 센서 추가, 제어 알고리즘 변경이 서로에게 영향을 주지 않음

  • 데이터 레이스: 멀티스레드 콜백 사용 시 self._current_joints 접근에 락(Lock) 이 필요할 수 있음
  • 초기화 문제: 제어 루프가 시작될 때 아직 /joint_states가 한 번도 안 왔을 수 있음 → 초기값 체크 필수
  • 시드 품질: 오래된 관절값을 시드로 쓰면 IK가 엉뚱한 해를 줄 수 있음 → 타임스탬프 확인 권장
def _control_loop(self):
    # 초기값 체크: joint_states가 아직 안 왔으면 스킵
    if not self._current_joints:
        self.get_logger().warn('Waiting for /joint_states...')
        return
 
    target = self._next_target_pose()
    ik_result = self._compute_ik(target, self._current_joints)
    self._publish_command(ik_result)

정리

개념설명
제어 루프목표 계산 → IK → 발행을 30Hz로 반복
상태 갱신컨트롤러가 자동으로 /joint_states 발행 → 콜백이 수신
비동기 구조상태 수집과 명령 발행이 독립적으로 동작
역할 분리내 노드 = 계획(Planner), 컨트롤러 = 실행(Executor)

핵심 한 줄 요약: "state 갱신을 기다린다"는 개념이 아니라, state는 백그라운드에서 항상 흘러오고 있고, 내 루프는 그냥 최신 값을 가져다 쓰는 것입니다.