AEGLOSLAB ROS 2 Jazzy 대전

제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 중앙 파라미터 서버 (roscore 내부) 노드 A 노드 B 노드 A + 자기 파라미터 노드 B + 자기 파라미터
축ROS 1ROS 2
저장 위치중앙 서버각 노드 안
노드가 종료되면값이 남는다값도 사라진다
다른 노드의 값 읽기직접 읽기서비스 호출(그 노드가 실행 중이어야)
노드 시작 전 설정가능불가 — 시작 인자·파일로 주입
전역 공유 값자연스러움불가능(설계상 배제)
타입느슨함선언된 타입 강제

"노드가 종료되면 값도 사라진다"가 가장 큰 실무 변화다. ROS 1에서 흔했던 "한 노드가 파라미터 서버에 써 두면 다른 노드가 읽는" 패턴은 ROS 2에서 성립하지 않는다. 노드 간에 값을 공유해야 하면 토픽·서비스로 명시적으로 주고받아야 한다. 이 제약은 의도된 것이다 — 중앙 서버는 단일 장애점이고, 분산 시스템에서 "누가 이 값을 소유하는가"를 흐리게 만든다.

1.9.3 노출되는 인터페이스

노드는 파라미터를 위해 서비스 6개와 토픽 1개를 자동으로 만든다(전부 숨은 이름).

서비스역할
~/get_parameters값 조회
~/set_parameters값 설정(개별 결과)
~/set_parameters_atomically전부 성공하거나 전부 실패
~/list_parameters이름 목록
~/describe_parametersdescriptor 조회(타입·범위·설명)
~/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 세 종류의 콜백

파라미터 변경에 반응하는 지점이 셋으로 분리되어 있다. 역할이 다르며, 섞어 쓰면 문제가 생긴다.

거부 수락 set_parameters 요청 pre_set 콜백 값을 조정·연동 변경 on_set 콜백 검증 — 수락/거부 변경 없음 reason 반환 값이 실제로 반영됨 post_set 콜백 부작용 적용 /parameter_events 발행

① 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=5pre(1건 수신, b=10 추가) → on(a·b 둘 다 검증) → post(a·b 둘 다 적용 보고)successful=1, a=5, b=10
a=200pre(b=400 추가) → on(거부, reason="a>100") → post 호출 없음successful=0, a=5, b=10 — 둘 다 이전 값 유지

여기서 두 가지가 확인된다.

  1. 거부되면 post_set은 전혀 불리지 않는다. 그래서 "부작용은 post_set에만"이라는 규칙이 실제로 안전망이 된다.
  2. 롤백은 요청 단위이지 파라미터 단위가 아니다. 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콜백 핸들을 보관하지 않음콜백이 안 불림멤버 변수로 저장
2YAML의 노드 이름 불일치값이 조용히 무시됨ros2 param get으로 실제 값 → /**: 시험(11.1.14.3)
3ros__parameters 밑줄 하나파일 무시밑줄 두 개
4double에 정수 리터럴(1)타입 불일치 거부1.0으로
5on_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)
13allow_undeclared_parameters 상시 사용오타·타입 문제 재발명시적 선언으로 전환
14단위 미표기deg/rad 혼동 사고descriptor에 단위 명시
15param 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은 로봇 시스템에서 가장 미묘한 주제 하나 — 시간을 다룬다. 세 종류의 시계, 시뮬레이션 시간, 그리고 "왜 시뮬에서는 되는데 실기에서 안 되나"의 상당 부분이 여기서 설명된다.