개요
로봇 팔을 제어할 때, 전체 프로세스는 단순히 "읽고 → 계산하고 → 이동하고 → 기다리고" 의 순차 반복이 아닙니다. 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는 백그라운드에서 항상 흘러오고 있고, 내 루프는 그냥 최신 값을 가져다 쓰는 것입니다.