제1부 계산 그래프의 코어
1.9 파라미터 — 선언·타입·콜백·이벤트
1.9.1 이 장에서 배우는 것 / 선수 지식
파라미터는 "설정 값을 담는 곳"이지만, ROS 2에서는 그 이상이다 — 노드마다 내장된 작은 키-값 서버이며, 서비스 6개와 이벤트 토픽 하나를 통해 외부에 노출된다. 이 구조를 알면 ros2 param이 왜 노드가 실행 중이어야만 동작하는지, YAML 파일의 들여쓰기가 왜 그렇게 생겼는지, 왜 파라미터로 상태를 관리하면 안 되는지가 한꺼번에 설명된다.
ROS 1의 파라미터 서버(중앙 집중, 노드와 분리)와는 완전히 다른 설계이며, 그 차이가 실무 관행을 바꾼다.
배우는 것:
- 파라미터의 구조 — 노드에 종속된 저장소, 노출되는 서비스,
/parameter_events. - 선언(declare)이 필수인 이유와 미선언 파라미터 허용 옵션의 대가.
- 타입 시스템과 descriptor — 범위·읽기전용·동적 타입.
- 세 종류의 콜백 — 검증(pre)·설정 시(on)·설정 후(post)의 역할 분담.
- YAML 파일 구조 — 네임스페이스·와일드카드·launch에서의 로딩.
- 파라미터 이벤트 구독 — 다른 노드의 설정 변화를 감지하는 법.
- 설계 원칙 — 파라미터로 해야 할 일과 하면 안 되는 일.
선수 지식: 1.1(이름·네임스페이스), 1.7(서비스 — 파라미터는 서비스로 노출된다), 1.6(QoS — /parameter_events의 depth가 큰 이유).
정직: 파라미터 콜백 API는 배포판을 거치며 확장되어 왔다(단일 검증 콜백 → 세 종류). Jazzy에서의 정확한 API 이름·시그니처는
rclcpp헤더가 정본이다. 2026-08-16에 세 콜백(add_pre_set_parameters_callback·add_on_set_parameters_callback·add_post_set_parameters_callback)을 Jazzy 컨테이너에서 컴파일·실행해 호출 순서와 거부 시 거동까지 확인했다(1.9.6.1). 그 밖의 절(선언·Descriptor·YAML·다른 노드의 파라미터)은 여전히 미검증이다(목차 3).
1.9.2 ROS 1과의 결정적 차이
| 축 | ROS 1 | ROS 2 |
|---|---|---|
| 저장 위치 | 중앙 서버 | 각 노드 안 |
| 노드가 종료되면 | 값이 남는다 | 값도 사라진다 |
| 다른 노드의 값 읽기 | 직접 읽기 | 서비스 호출(그 노드가 실행 중이어야) |
| 노드 시작 전 설정 | 가능 | 불가 — 시작 인자·파일로 주입 |
| 전역 공유 값 | 자연스러움 | 불가능(설계상 배제) |
| 타입 | 느슨함 | 선언된 타입 강제 |
"노드가 종료되면 값도 사라진다"가 가장 큰 실무 변화다. ROS 1에서 흔했던 "한 노드가 파라미터 서버에 써 두면 다른 노드가 읽는" 패턴은 ROS 2에서 성립하지 않는다. 노드 간에 값을 공유해야 하면 토픽·서비스로 명시적으로 주고받아야 한다. 이 제약은 의도된 것이다 — 중앙 서버는 단일 장애점이고, 분산 시스템에서 "누가 이 값을 소유하는가"를 흐리게 만든다.
1.9.3 노출되는 인터페이스
노드는 파라미터를 위해 서비스 6개와 토픽 1개를 자동으로 만든다(전부 숨은 이름).
| 서비스 | 역할 |
|---|---|
~/get_parameters | 값 조회 |
~/set_parameters | 값 설정(개별 결과) |
~/set_parameters_atomically | 전부 성공하거나 전부 실패 |
~/list_parameters | 이름 목록 |
~/describe_parameters | descriptor 조회(타입·범위·설명) |
~/get_parameter_types | 타입만 조회 |
| 토픽 | 역할 |
|---|---|
/parameter_events | 전역 토픽. 모든 노드의 파라미터 변경이 여기로 |
ros2 param CLI는 전부 이 서비스들을 호출하는 얇은 껍데기다. 그래서 노드가 spin하지 않으면 ros2 param이 응답 없이 멈춘다(0.7.6의 6번) — 서비스 콜백을 처리할 주체가 없기 때문이다.
~/set_parameters_atomically의 존재가 중요하다. 서로 의존하는 파라미터(예: min_speed와 max_speed)를 하나씩 바꾸면 중간에 모순 상태(min > max)를 거친다. 원자적 설정은 이를 막는다 — 관련 파라미터는 함께 바꾸는 것이 원칙이다.
1.9.4 선언 — 왜 필수인가
class MyNode : public rclcpp::Node
{
public:
MyNode() : Node("my_node")
{
// 기본값과 함께 선언 — 타입이 여기서 확정된다
this->declare_parameter("max_speed", 1.0); // double
this->declare_parameter("frame_id", std::string("base_link"));
this->declare_parameter("enabled", true);
this->declare_parameter("waypoints", std::vector<double>{0.0, 1.0});
// 읽기
max_speed_ = this->get_parameter("max_speed").as_double();
// 또는
this->get_parameter("frame_id", frame_id_);
}
};
선언하지 않은 파라미터를 읽으면 예외가 난다(ParameterNotDeclaredException). 이는 기능이지 불편이 아니다.
| 선언이 주는 것 | 설명 |
|---|---|
| 타입 안전 | 문자열을 double 자리에 넣으면 거부된다 |
| 자기 기술성 | ros2 param list가 실제 사용 가능한 파라미터를 정확히 보여준다 |
| 오타 방어 | max_sped를 읽으면 조용히 기본값이 아니라 예외가 난다 |
| 도구 지원 | rqt_reconfigure가 무엇을 조정할 수 있는지 안다 |
미선언 허용 옵션:
rclcpp::NodeOptions options;
options.allow_undeclared_parameters(true)
.automatically_declare_parameters_from_overrides(true);
MyNode() : Node("my_node", options) { … }
- 장점: ROS 1 스타일 이식이 쉽다. 파일에 있는 값을 자동으로 받아들인다.
- 단점: 위의 네 가지 이점을 전부 잃는다. 오타가 조용히 통과하고, 타입이 파일에 따라 달라지고, 도구가 파라미터를 알 수 없다.
- 견해: 마이그레이션 중간 단계로만 쓰고, 정착되면 명시적 선언으로 전환하는 것이 좋다. 새 코드에 켤 이유는 거의 없다.
1.9.5 Descriptor — 범위·설명·읽기전용
선언 시 메타데이터를 붙이면 도구와 사용자가 그 파라미터를 이해할 수 있다.
rcl_interfaces::msg::ParameterDescriptor desc;
desc.description = "로봇의 최대 선속도";
desc.additional_constraints = "단위: m/s (REP 103)";
rcl_interfaces::msg::FloatingPointRange range;
range.from_value = 0.0;
range.to_value = 2.0;
range.step = 0.01;
desc.floating_point_range.push_back(range);
this->declare_parameter("max_speed", 1.0, desc);
범위(range)는 자동으로 강제된다 — 범위를 벗어난 설정은 거부되므로 검증 코드를 따로 안 써도 된다. 정수형은 IntegerRange를 쓴다.
읽기전용:
desc.read_only = true;
this->declare_parameter("device_path", std::string("/dev/ttyUSB0"), desc);
시작 시 한 번 정해지고 이후 바꿀 수 없다. 하드웨어 경로·포트·버퍼 크기처럼 실행 중 변경이 무의미하거나 위험한 값에 쓴다. 읽기전용은 "이 값은 재시작해야 바뀐다"는 계약을 코드로 표현한 것이며, 운영자가 헛되이 바꾸려다 실패하는 것을 방지한다.
동적 타입:
desc.dynamic_typing = true; // 타입이 바뀔 수 있음 — 신중히
기본적으로 파라미터 타입은 선언 시 고정된다. dynamic_typing은 그 제약을 푼다 — 거의 필요 없고, 대개 설계가 잘못됐다는 신호다.
ros2 param describe /my_node max_speed # descriptor 확인
견해: 범위와 설명을 붙이는 것은 비용 대비 효과가 매우 큰 습관이다.
rqt_reconfigure에 슬라이더가 생기고, 잘못된 값이 원천 차단되고, 6개월 뒤의 자신이 단위를 안다. 특히 단위를additional_constraints나description에 반드시 적으라 — 단위 혼동은 로봇에서 가장 비용이 큰 버그 유형이다(1.2.11).
1.9.6 세 종류의 콜백
파라미터 변경에 반응하는 지점이 셋으로 분리되어 있다. 역할이 다르며, 섞어 쓰면 문제가 생긴다.
① on_set — 검증 (가장 중요)
callback_handle_ = this->add_on_set_parameters_callback(
[this](const std::vector<rclcpp::Parameter> & params) {
rcl_interfaces::msg::SetParametersResult result;
result.successful = true;
for (const auto & p : params) {
if (p.get_name() == "max_speed") {
if (p.as_double() <= 0.0) {
result.successful = false;
result.reason = "max_speed는 양수여야 합니다";
return result; // 거부 — 값은 반영되지 않는다
}
}
}
return result;
});
이 콜백 안에서 값을 실제로 적용하면 안 된다. 아직 반영 전이고, 다른 콜백이 거부하면 롤백되기 때문이다. 여기서는 판단만 한다.
② post_set — 부작용 적용
post_handle_ = this->add_post_set_parameters_callback(
[this](const std::vector<rclcpp::Parameter> & params) {
for (const auto & p : params) {
if (p.get_name() == "publish_rate") {
timer_->cancel();
timer_ = create_wall_timer(
std::chrono::duration<double>(1.0 / p.as_double()),
std::bind(&MyNode::on_timer, this));
}
}
});
값이 확정된 뒤 실제 동작을 바꾼다 — 타이머 재생성, 하드웨어 재설정, 캐시 무효화. post_set 이전에는 이런 작업을 하면 안 된다.
③ pre_set — 연동 조정
들어온 변경을 보고 다른 파라미터를 함께 바꾸거나 값을 조정할 때 쓴다. 예: resolution이 바뀌면 그에 맞춰 map_size도 조정. 흔히 쓰이지는 않으며, 남용하면 "값을 설정했는데 다른 값으로 바뀌어 있는" 혼란을 만든다.
함정:
post_set이 없던 시절의 코드가 아직 많다. 그런 코드는on_set안에서 검증과 적용을 함께 하는데, 거부된 변경의 부작용이 이미 적용되어 상태가 어긋난다. 오래된 예제를 참고할 때 주의할 지점이며,post_set이 있다면 반드시 역할을 나누라.
함정: 콜백 핸들을 멤버 변수로 보관해야 한다. 반환값을 버리면 콜백이 즉시 해제되어 아무 일도 일어나지 않는다. 조용히 실패하는 전형적 실수다.
1.9.6.1 실측 — 순서·롤백 범위
실행 확인(2026-08-16). 위 도해의 주장을 Jazzy 컨테이너에서 직접 실행해 확인했다. 세 콜백을 모두 등록한 노드에
set_parameter를 두 번 호출한 결과다.
pre_set이 a 변경을 보고 b = a*2를 덧붙이도록 해 두고 a=5(수락 조건)와 a=200(거부 조건)을 각각 넣었다.
| 호출 | 관측된 순서 | 최종 상태 |
|---|---|---|
a=5 | pre(1건 수신, b=10 추가) → on(a·b 둘 다 검증) → post(a·b 둘 다 적용 보고) | successful=1, a=5, b=10 |
a=200 | pre(b=400 추가) → on(거부, reason="a>100") → post 호출 없음 | successful=0, a=5, b=10 — 둘 다 이전 값 유지 |
여기서 두 가지가 확인된다.
- 거부되면
post_set은 전혀 불리지 않는다. 그래서 "부작용은post_set에만"이라는 규칙이 실제로 안전망이 된다. - 롤백은 요청 단위이지 파라미터 단위가 아니다.
pre_set이 덧붙인b도 함께 무효가 됐다 —a가 거부됐다고b만 적용되는 일은 없었다. 연동 조정을pre_set에 두면 그 연동까지 한 덩어리로 원자적으로 처리된다는 뜻이며, 이것이pre_set을 쓰는 실질적 이유다.
핸들 타입은 rclcpp::node_interfaces::PreSetParametersCallbackHandle·OnSetParametersCallbackHandle·PostSetParametersCallbackHandle의 SharedPtr다(실행 확인).
캐싱과 원자성: 콜백에서 멤버 변수를 갱신하고 제어 루프는 그 변수를 읽는 형태가 일반적이다. 이때 콜백 스레드와 제어 스레드가 다르면 경쟁이 생긴다(2.9). 스칼라 하나면 std::atomic, 여러 값이 함께 바뀌어야 하면 뮤텍스나 이중 버퍼가 필요하다. 파라미터 변경은 드물므로 락 비용은 대개 무시할 만하지만, 실시간 루프에서 뮤텍스를 획득하는 것은 별개 문제다(12.3).
1.9.7 YAML 파일
# config/my_node.yaml
/my_namespace/my_node:
ros__parameters:
max_speed: 1.5
frame_id: "base_link"
enabled: true
waypoints: [0.0, 1.0, 2.0]
pid:
kp: 2.0
ki: 0.1
kd: 0.05
규칙:
- 최상위 키는 노드의 완전 이름(FQN). 네임스페이스가 있으면 포함해야 한다.
- 그 아래 반드시
ros__parameters:(밑줄 두 개). - 중첩은 점으로 이어진 이름이 된다 —
pid.kp. 코드에서는declare_parameter("pid.kp", 2.0).
와일드카드:
/**: # 모든 깊이의 모든 노드 (실행 확인)
ros__parameters:
use_sim_time: true
/*: # 루트 직속 노드만 — /a/deep 은 안 걸린다 (실행 확인)
ros__parameters:
one_level: 1
/**/camera_*: # 이름 패턴 매칭 (확인 필요: 지원 범위)
ros__parameters:
fps: 30
/**:는 특히 유용하다 — use_sim_time처럼 모든 노드에 공통으로 줘야 하는 값에 쓴다(1.10).
적용:
ros2 run my_pkg my_node --ros-args --params-file config/my_node.yaml
ros2 run my_pkg my_node --ros-args -p max_speed:=2.0 # 개별 지정
# launch
Node(
package='my_pkg', executable='my_node', name='my_node',
namespace='my_namespace',
parameters=[
os.path.join(get_package_share_directory('my_pkg'), 'config', 'my_node.yaml'),
{'override_value': 42}, # 뒤에 오는 것이 우선한다
],
)
우선순위(뒤가 이김): 코드 기본값 < 파일 < 개별 -p < launch의 뒤쪽 항목.
함정: YAML의 노드 이름이 실제 FQN과 다르면 조용히 무시된다. 오류도 경고도 없이 값이 적용되지 않는다. 네임스페이스나 노드 이름을 바꿨는데 설정이 안 바뀌면 여기를 먼저 본다.
ros2 param list /my_namespace/my_node # 실제로 무엇이 선언됐나
ros2 param get /my_namespace/my_node max_speed # 실제 값
함정: YAML은 타입을 추론한다.
1은 정수,1.0은 실수다.declare_parameter("gain", 1.0)(double)로 선언한 파라미터에 파일에서gain: 1을 주면 타입 불일치로 거부된다. 실수형은 항상 소수점을 표기한다.
1.9.8 다른 노드의 파라미터 다루기
// 동기 클라이언트 — 1.7의 데드락 규칙이 그대로 적용된다
auto client = std::make_shared<rclcpp::AsyncParametersClient>(this, "/other_node");
if (client->wait_for_service(std::chrono::seconds(3))) {
auto future = client->get_parameters({"max_speed"});
// 콜백으로 받거나, main 문맥에서만 대기
}
파라미터 이벤트 구독 — 다른 노드의 변화 감지:
param_sub_ = std::make_shared<rclcpp::ParameterEventHandler>(this);
cb_handle_ = param_sub_->add_parameter_callback(
"max_speed",
[this](const rclcpp::Parameter & p) {
RCLCPP_INFO(get_logger(), "다른 노드의 max_speed가 %.2f로 변경됨", p.as_double());
},
"/other_node");
ParameterEventHandler는 /parameter_events 토픽을 구독해 필터링해 준다. 노드 간 설정 동기화가 필요할 때 쓴다.
견해: 이 기능은 편리하지만 의존을 만든다. A 노드가 B 노드의 파라미터를 감시하면, B의 파라미터 이름 변경이 A를 깬다 — 그런데 그 의존은 코드 어디에도 선언되어 있지 않다. 정말 공유해야 하는 값이라면 토픽으로 명시적으로 발행하는 편이 의존을 드러내서 낫다. 파라미터 이벤트 감시는 도구·모니터링 용도로 제한하는 것이 안전하다.
/parameter_events의 QoS: depth가 크게(1000) 설정돼 있다(1.5.5). 변경을 놓치면 상태 불일치가 생기기 때문이다. 다만 이 토픽은 시스템의 모든 노드의 모든 변경이 흐르므로, 노드가 많으면 그 자체로 부하가 된다.
1.9.9 설계 원칙 — 무엇을 파라미터로 할 것인가
파라미터가 맞는 것:
| 종류 | 예 |
|---|---|
| 튜닝 값 | PID 게인, 임계값, 속도 한계 |
| 환경 의존 설정 | 장치 경로, 프레임 이름, 토픽 이름 |
| 동작 모드 스위치 | 디버그 출력, 알고리즘 선택 |
| 캘리브레이션 상수 | 오프셋, 스케일 |
파라미터가 맞지 않는 것:
| 오용 | 왜 문제인가 | 대신 |
|---|---|---|
| 런타임 상태(현재 위치·배터리·오류 코드) | 파라미터는 폴링해야 한다. 이벤트의 부하가 크다. 노드가 종료되면 사라진다 | 토픽(1.4) |
명령(start=true로 시작) | 취소·결과·진행률이 없다 | 서비스·액션(1.7·1.8) |
| 노드 간 공유 데이터 | 소유자가 불명확, 노드 사망 시 소멸 | 토픽·전용 노드 |
| 대용량 데이터(지도·궤적) | 서비스 응답 크기 제한·비효율 | 토픽·파일 |
| 비밀 값(키·비밀번호) | ros2 param get으로 누구나 읽는다 | 파일 권한·시크릿 관리(13.4) |
| 노드 생명주기 상태 | 표준 상태기계가 이미 있다 | 라이프사이클(1.12) |
"파라미터로 상태를 관리하지 말라"가 가장 중요한 원칙이다. 상태는 변하고, 변화는 알려져야 하고, 순서가 중요하다 — 이 셋 모두 토픽이 잘하고 파라미터가 못하는 것이다.
함정: 비밀 값을 파라미터에 두는 것은 보안 사고다. 파라미터는
~/get_parameters서비스로 노출되며, 그래프에 접근할 수 있는 누구나 읽는다. SROS2로 접근을 제한할 수는 있지만(13.2), 애초에 파라미터에 두지 않는 것이 옳다.
1.9.10 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | 콜백 핸들을 보관하지 않음 | 콜백이 안 불림 | 멤버 변수로 저장 |
| 2 | YAML의 노드 이름 불일치 | 값이 조용히 무시됨 | ros2 param get으로 실제 값 → /**: 시험(11.1.14.3) |
| 3 | ros__parameters 밑줄 하나 | 파일 무시 | 밑줄 두 개 |
| 4 | double에 정수 리터럴(1) | 타입 불일치 거부 | 1.0으로 |
| 5 | on_set에서 값 적용 | 거부 시 상태 불일치 | post_set 사용 |
| 6 | 미선언 파라미터 읽기 | 예외로 노드 종료 | 선언 확인 |
| 7 | 관련 파라미터를 개별 설정 | 중간 모순 상태 | set_parameters_atomically |
| 8 | 파라미터로 상태 관리 | 폴링·소멸·순서 문제 | 토픽 |
| 9 | 파라미터로 명령 전달 | 취소·결과 없음 | 서비스·액션 |
| 10 | 비밀 값을 파라미터로 | 그래프 접근자가 읽음 | 시크릿 관리(13.4) |
| 11 | 콜백 갱신 값을 락 없이 공유 | 경쟁 조건 | std::atomic·뮤텍스(2.9) |
| 12 | 노드가 spin 안 하는데 ros2 param | 응답 없음 | executor 확인(0.7.6) |
| 13 | allow_undeclared_parameters 상시 사용 | 오타·타입 문제 재발 | 명시적 선언으로 전환 |
| 14 | 단위 미표기 | deg/rad 혼동 사고 | descriptor에 단위 명시 |
| 15 | param set 성공을 반영으로 오해 | CLI는 successful, 노드는 옛 값으로 계속 실행된다 | 콜백이 없으면 무시된다 — 재시작하거나 add_on_set_parameters_callback(0.7.11.1) |
1.9.11 체크포인트
- ROS 1의 중앙 파라미터 서버와 ROS 2의 노드 종속 파라미터의 차이 다섯 가지를 말할 수 있는가. "노드가 종료되면 값도 사라진다"가 바꾸는 설계 관행은 무엇인가.
- 파라미터가 노출하는 서비스 여섯 개 중
set_parameters_atomically가 필요한 이유를 예로 설명할 수 있는가. - 선언을 필수로 만든 것이 주는 이점 네 가지를 말할 수 있는가.
allow_undeclared_parameters를 켜면 무엇을 잃는가. - descriptor의 범위·읽기전용·설명이 각각 무엇을 해 주는가. 단위는 어디에 적는가.
- 세 콜백의 역할을 구분하고,
on_set에서 값을 적용하면 안 되는 이유를 말할 수 있는가. - 콜백이 아무 일도 안 하는 것처럼 보일 때 가장 먼저 확인할 것은?
- YAML 파일의 세 가지 필수 규칙과, 값이 조용히 무시되는 대표 원인 두 가지를 말할 수 있는가.
/**:와일드카드가 특히 유용한 경우는?- 다른 노드의 파라미터 변화를 감시하는 방법과, 그 방식이 만드는 숨은 의존을 설명할 수 있는가.
- 파라미터로 하면 안 되는 여섯 가지를 들고, 각각의 올바른 대안을 댈 수 있는가.
- 파라미터 콜백에서 갱신한 값을 제어 루프가 읽을 때 주의할 점은?
on_set이 거부하면pre_set이 덧붙인 연동 변경은 어떻게 되는가 — 함께 무효인가, 따로 적용되는가?
다음 장 1.10은 로봇 시스템에서 가장 미묘한 주제 하나 — 시간을 다룬다. 세 종류의 시계, 시뮬레이션 시간, 그리고 "왜 시뮬에서는 되는데 실기에서 안 되나"의 상당 부분이 여기서 설명된다.