제1부 계산 그래프의 코어
1.7 서비스 — 요청·응답의 실제와 데드락
1.7.1 이 장에서 배우는 것 / 선수 지식
서비스는 개념이 가장 단순하고 실수는 가장 치명적인 통신 패턴이다. "함수 호출처럼 쓰면 된다"는 통념이 여기서는 정확히 반대로 작동한다 — 그렇게 쓰는 순간 노드가 영구히 멈춘다. ROS 2 입문자가 가장 많이 겪는 하드 행(hard hang)이 서비스 동기 호출 데드락이며, 이 장의 절반은 그 하나를 다룬다.
배우는 것:
- 서비스의 실제 구현 — DDS 위에서 요청·응답이 어떻게 만들어지는가(토픽 두 개 + 상관 ID).
- 데드락의 정확한 메커니즘 — 왜 멈추는지 executor 수준에서 설명하고, 세 가지 해법을 비교.
- 비동기 패턴 — future·콜백·타임아웃·재시도의 올바른 형태.
- 서비스가 실패하는 방식들 — 서버 부재·중복·응답 유실·재진입.
- 서비스 introspection — 실행 중인 서비스 호출을 관찰하는 법.
- 서비스 vs 액션 vs 토픽 vs 파라미터 — 판단 기준.
- 서비스 설계 원칙 — 멱등성·타임아웃·에러 표현.
선수 지식: 1.4(토픽 경로·executor의 존재), 1.5(QoS), 1.2(srv 정의). 콜백 그룹과 executor는 2.3·2.4에서 정식으로 다루지만, 이 장에서 그 필요성이 가장 절실하게 드러난다.
정직: 데드락 메커니즘과 해법은 rclcpp의 executor 구조에 근거하며 커뮤니티에서 널리 확인된 동작이다. 다만 executor 구현은 배포판마다 개선되어 왔고, 특정 조합에서 동작이 달라질 수 있다
(확인 필요). 이 환경에서 실행 검증하지 않았다(목차 3). 아래 "안전한 형태"는 어떤 executor에서도 성립하는 형태로 제시한다. 140차(119번째 회차)가ros:jazzy-ros-base·ros:humble-ros-base에서 이 장의 핵심 주장 대부분을 실제로 실행 확인했다 — 데드락 재현, 해법 세 가지,main()의 안전성,wait_for_service()무한 대기, 함정#14. 다만 1.7.3이 "증상"으로 묘사하는 조용한 영구 정지는spin_until_future_complete경로에서는 정확하지 않다는, 책을 고치는 발견도 함께 나왔다 — 실제로는 처리되지 않은 예외로 즉시 크래시한다. 아래 1.7.3의 정정을 보라(목차 3).
1.7.2 서비스는 실제로 무엇인가
ROS 2에는 "서비스 프로토콜"이 따로 없다. DDS 위에 토픽 두 개로 구현된다.
- 요청·응답 각각이 숨은 토픽이다(이름이
_·특수 접두어로 시작해 기본 목록에서 감춰진다 — 1.1.3). - 어느 응답이 어느 요청에 대한 것인지는 요청에 실린 식별자(클라이언트 GUID + 시퀀스 번호)로 맞춘다.
- 서버는 응답을 해당 클라이언트에게만 보낸다.
여기서 나오는 결과들:
| 사실 | 결과 |
|---|---|
| 토픽 기반이다 | QoS가 적용된다. 디스커버리가 필요하다 |
| 상관 ID로 맞춘다 | 응답이 유실되면 클라이언트는 영원히 기다린다 — 프로토콜 차원의 타임아웃이 없다 |
| 서버가 여럿일 수 있다 | ROS 2는 막지 않는다. 요청이 어디로 갈지 불명(1.1.6) |
| 연결 지향이 아니다 | 서버가 종료돼도 클라이언트는 즉시 모른다 |
함정: 서비스에는 내장 타임아웃이 없다. 응답을 못 받으면 그대로 대기한다. 타임아웃은 클라이언트 코드가 반드시 직접 구현해야 한다(1.7.5). 이것을 누락한 노드는 상대가 종료되는 순간 함께 멈춘다.
1.7.3 데드락 — ROS 2에서 가장 비용이 큰 실수
증상: 노드가 완전히 멈춘다. CPU 0%, 로그 없음, 콜백 전부 정지. ros2 node list에는 실행 중이다(AUTOMATIC liveliness라면 "생존"으로도 보고된다 — 1.5.3.6). (→ 140차 정정: 이 "조용한 정지" 서술은 아래 코드(프리 함수 spin_until_future_complete)에는 들어맞지 않는다 — 실제로는 예외로 즉시 크래시한다. 자세한 내용은 코드 블록 바로 뒤의 실측을 보라.)
틀린 코드:
void MyNode::topic_callback(const std_msgs::msg::String::SharedPtr msg)
{
auto request = std::make_shared<std_srvs::srv::Trigger::Request>();
auto future = client_->async_send_request(request);
// ☠️ 여기서 영원히 멈춘다
rclcpp::spin_until_future_complete(this->get_node_base_interface(), future);
}
정정(140차 실행 확인,
ros:jazzy-ros-base·ros:humble-ros-base, rclcpp 28.1.21·16.0.19): 이 코드를 정확히 그대로(타이머/구독 콜백에서, 무한 대기로,rclcpp::spin(node)로 이미 구동 중인 바로 그node에 대해) 실행하면 "CPU 0%·로그 없음"의 조용한 영구 정지가 아니라, 처리되지 않은 예외로 즉시 크래시한다. Humble·Jazzy 양쪽에서 100% 재현(총 5회):terminate called after throwing an instance of 'std::runtime_error' what(): Node '/gap140_deadlock_wrong' has already been added to an executor.원인: 프리 함수
rclcpp::spin_until_future_complete(node, future)는 자신만의 임시 executor를 새로 만들어 그 안에node를 추가하려 시도한다.node는 이미 바깥의rclcpp::spin(node)가 만든(별개) executor에 추가돼 있고, rclcpp는 노드마다 "이미 executor에 추가됨" 플래그를 추적하므로 두 번째 추가 시도는 조용히 무시되지 않고 예외를 던진다. 멤버 함수 형태(이미 실행되는 그 executor 인스턴스 자신에exec.spin_until_future_complete(future)를 호출)로 바꿔도 결과는 같다 — 다른 메시지로 역시 즉시 크래시한다(Humble·Jazzy 양쪽 재현 확인):terminate called after throwing an instance of 'std::runtime_error' what(): spin_until_future_complete() called while already spinning결론: "가장 비용이 큰 실수"라는 진단과 뒤의 해법 세 가지(1.7.4)는 여전히 유효하다. 하지만
spin_until_future_complete를 콜백 안에서 부르는 실수의 실제 증상은 (조용한 정지가 아니라) 명확한 오류 메시지와 함께 프로세스가 종료되는 것이다 — 어떤 의미로는 원인 진단이 더 쉽다. 반면 아래 "같은 함정의 다른 얼굴들" ②(future.wait()/.get()직접 호출)는 실제로 예외 없이 조용히 멈춘다는 것도 이번 회차에 확인했다(1.7.10 함정#14 실측 참고) — 이 장이 "증상"으로 원래 묘사한 동작은 ①이 아니라 ②에 해당한다. 확인하지 못한 것: 바깥 executor가MultiThreadedExecutor이고 멤버 함수 형태를 그 executor 자신에 호출하는 조합(77행이 언급하는 "그룹이MutuallyExclusive면 여전히 멈춘다"에 해당할 조합)은 이번 회차에 실행 검증하지 않았다 — 다음 회차 후보. (목차 3,tools/gap_harness/gap140) (→ 141차 실행 확인: 닫혔다.MultiThreadedExecutor(4스레드)에 클라이언트·타이머를 모두 기본 그룹에 두고, 타이머 콜백에서 이미 스핀 중인 그exec인스턴스 자신에exec.spin_until_future_complete(future)를 호출해도(=문자 그대로 77행이 언급하는 조합) 결과는 조용한 정지가 아니라 똑같이 즉시 크래시했다(spin_until_future_complete() called while already spinning, Humble·Jazzy 양쪽 재현). 즉 executor 타입(Single/Multi)과 무관하게 "이미 스핀 중" 검사는 executor 인스턴스 단위로 걸리며, 멤버 함수 형태의spin_until_future_complete는 어떤 조합으로도 조용히 멈추지 않는다. 77행이 묘사하는 실제 조용한 정지는 (앞서 확인한 대로)spin_until_future_complete계열이 아니라future.wait()/.get()직접 호출(1.7.10 함정#14의 variant 0/2)에서만 재현된다 — 1.7.3의 "증상" 서술은 오직 그 경로에만 정확히 들어맞는다. 목차 3,tools/gap_harness/gap141.)
왜 멈추는가 — executor 수준의 설명:
핵심: 콜백을 실행하는 스레드가 콜백 안에서 대기하면, 자기가 처리해야 할 응답을 자기가 못 처리한다. 단일 스레드 executor에서는 100% 재현되고, 멀티스레드 executor라도 콜백 그룹이 MutuallyExclusive(기본)면 같은 그룹의 콜백은 동시에 못 돌아 여전히 멈춘다.
같은 함정의 다른 얼굴들:
// ① 타이머 콜백 안에서 동기 호출 — 동일하게 멈춤
timer_ = create_wall_timer(1s, [this]() {
auto f = client_->async_send_request(req);
rclcpp::spin_until_future_complete(node, f); // ☠️
});
// ② future.wait() / future.get() 직접 호출 — 더 조용히 멈춤
auto f = client_->async_send_request(req);
f.wait(); // ☠️ executor가 응답을 처리할 기회가 없다
// ③ 서비스 콜백 안에서 다른 서비스 호출 — 동일
void handle(req, res) {
auto f = other_client_->async_send_request(r);
rclcpp::spin_until_future_complete(node, f); // ☠️
}
단, main()에서는 안전하다(실행 확인, 140차 — Humble·Jazzy 양쪽에서 아래 코드 그대로 성공) — 아직 executor 안이 아니기 때문이다.
int main(int argc, char ** argv) {
rclcpp::init(argc, argv);
auto node = std::make_shared<rclcpp::Node>("client_node");
auto client = node->create_client<std_srvs::srv::Trigger>("trigger");
client->wait_for_service(5s);
auto future = client->async_send_request(request);
// ✅ 안전: 이 스레드는 아직 어떤 콜백도 실행 중이 아니다
rclcpp::spin_until_future_complete(node, future);
rclcpp::shutdown();
}
이 차이가 혼란의 근원이다 — 튜토리얼 예제(main에서 호출)는 잘 동작하고, 실제 노드에 옮기는 순간 멈춘다.
1.7.4 데드락 해법 세 가지
① 완전 비동기 — 콜백으로 응답 받기 (권장)
void MyNode::topic_callback(const std_msgs::msg::String::SharedPtr msg)
{
auto request = std::make_shared<std_srvs::srv::Trigger::Request>();
client_->async_send_request(request,
[this](rclcpp::Client<std_srvs::srv::Trigger>::SharedFuture future) {
auto response = future.get(); // 이미 완료된 future — 블로킹 없음
RCLCPP_INFO(get_logger(), "결과: %s", response->message.c_str());
continue_work(response); // 후속 작업을 여기서
});
// 콜백은 즉시 반환 — executor가 계속 실행된다
}
- 장점: 어떤 executor·콜백 그룹에서도 안전. 블로킹 없음.
- 단점: 제어 흐름이 분할된다. 여러 서비스를 순차 호출하면 콜백 중첩(콜백 지옥)이 된다. 상태를 멤버 변수로 들고 다녀야 한다.
- 맞는 경우: 기본 선택. 특히 호출이 하나이거나 결과 처리가 단순할 때.
② 별도 콜백 그룹 + MultiThreadedExecutor
MyNode::MyNode() : Node("my_node")
{
// 클라이언트를 별도의 재진입 그룹에 둔다
client_cb_group_ = create_callback_group(rclcpp::CallbackGroupType::Reentrant);
client_ = create_client<std_srvs::srv::Trigger>(
"trigger", rclcpp::ServicesQoS(), client_cb_group_);
timer_cb_group_ = create_callback_group(
rclcpp::CallbackGroupType::MutuallyExclusive);
timer_ = create_wall_timer(1s, std::bind(&MyNode::on_timer, this),
timer_cb_group_);
}
// main에서 반드시 MultiThreadedExecutor 사용
rclcpp::executors::MultiThreadedExecutor exec;
exec.add_node(node);
exec.spin();
- 장점: 동기적 코드 흐름을 유지할 수 있다. 순차 호출 로직이 읽기 쉽다.
- 단점: 멀티스레드 executor가 필수이고, 스레드 안전을 직접 책임져야 한다(2.9). 그룹 배치를 하나만 틀려도 다시 멈춘다. 스레드 수가 부족하면 여전히 교착 가능.
- 맞는 경우: 여러 서비스를 순차 호출하는 조정(orchestration) 노드.
③ 별도 노드·별도 스레드로 분리
// 서비스 호출 전용 노드를 따로 만들어 별도 executor에서 실행한다
auto client_node = std::make_shared<rclcpp::Node>("my_node_client");
auto client = client_node->create_client<std_srvs::srv::Trigger>("trigger");
std::thread([client_node]() { rclcpp::spin(client_node); }).detach();
// 이제 client_node의 executor는 별개이므로 동기 대기가 안전하다
- 장점: 개념이 단순. 기존 동기 코드를 거의 그대로 옮길 수 있다.
- 단점: 노드가 하나 더 생긴다(그래프가 지저분). 스레드 수명 관리 필요. 두 노드 간 상태 공유에 동기화 필요.
- 맞는 경우: 레거시 코드 이식, 라이브러리 안에서 서비스를 호출해야 하는 경우.
| 축 | ① 비동기 콜백 | ② 콜백 그룹 | ③ 별도 노드 |
|---|---|---|---|
| 안전성 | 가장 높음 | 배치 정확해야 | 높음 |
| 코드 가독성 | 흐름 분절 | 순차 유지 | 순차 유지 |
| executor 요구 | 없음 | MultiThreaded 필수 | 별도 spin |
| 스레드 안전 부담 | 없음 | 있음 | 있음 |
| 그래프 깔끔함 | 좋음 | 좋음 | 노드 추가 |
실행 확인(140차,
ros:jazzy-ros-base·ros:humble-ros-base): 세 해법 모두 실제로 데드락·크래시 없이 응답을 받는다(양쪽 배포판, exit 0). ①은SingleThreadedExecutor+spin_some()폴링으로 재현. ②는 클라이언트를Reentrant그룹으로 분리하고 타이머 콜백에서future.wait_for()로 직접(프리 함수가 아니라std::future자체를) 블로킹하는 형태로 재현 — 책 코드가on_timer()의 본문을 보여주지 않아 가장 자연스러운 해석으로 채워 넣었다. ③은 클라이언트 전용 노드를 별도 스레드에서 실행하는 형태로 재현. (목차 3,tools/gap_harness/gap140)
견해: ①을 기본으로 하고, 순차 호출이 3개 이상 얽히면 ②를 검토한다. ③은 이식·특수 상황용이다. 그리고 호출 사슬이 길어지고 상태가 복잡해지면 그것은 서비스가 아니라 액션(1.8)이나 라이프사이클(1.12)로 표현할 신호다 — 도구를 바꾸는 편이 낫다는 뜻이다.
Python(rclpy)도 동일한 함정이 있다.
# ☠️ 콜백 안에서
rclpy.spin_until_future_complete(node, future)
# ✅ 콜백으로 받기
future = self.client.call_async(request)
future.add_done_callback(self.on_response)
call()(동기 버전)은 rclpy에서 더 위험하다 — 내부적으로 대기하며, 잘못 쓰면 조용히 멈춘다. rclpy에서는 call_async + add_done_callback만 쓴다고 규칙을 세우는 편이 안전하다(2.8).
1.7.5 올바른 클라이언트 — 타임아웃·재시도·서버 대기
데드락을 피했다고 끝이 아니다. 서비스는 여러 방식으로 실패한다.
// 1) 서버가 기동될 때까지 대기 (무한 대기 금지)
if (!client_->wait_for_service(std::chrono::seconds(5))) {
RCLCPP_ERROR(get_logger(), "서비스 서버가 5초 안에 나타나지 않음");
return; // 실패를 명시적으로 처리
}
// 2) 응답 타임아웃 — 프로토콜에 없으므로 직접 만든다
auto future = client_->async_send_request(request,
[this](auto fut) { on_response(fut.get()); });
timeout_timer_ = create_wall_timer(std::chrono::seconds(3), [this]() {
RCLCPP_WARN(get_logger(), "서비스 응답 타임아웃");
// rclcpp 버전에 따라 요청 취소 API가 다르다 (확인 필요)
// client_->remove_pending_request(future);
timeout_timer_->cancel();
handle_timeout();
});
실패 유형과 대처:
| 실패 | 감지 | 대처 |
|---|---|---|
| 서버 없음 | wait_for_service 타임아웃 | 명시적 오류 처리, 재시도 |
| 서버가 응답 중 종료 | 응답이 안 옴 | 클라이언트 타임아웃 필수 |
| 응답 유실 | 응답이 안 옴 | 타임아웃 + 재시도(멱등성 필요) |
| 서버가 느림 | 타임아웃 | 액션으로 전환 검토(1.8) |
| 서버 중복 | 예측 불가 동작 | 이름 관리(1.1.6) |
| QoS 불일치 | 연결 자체 안 됨 | RELIABLE 기본 유지(1.6.7) |
함정:
wait_for_service()를 무한 대기(인자 없이)로 쓰면, 서버가 영영 기동되지 못할 때 노드가 초기화 단계에서 멈춘다(실행 확인, 140차 — 존재하지 않는 서비스에 인자 없이 호출하면 외부timeout으로 강제 종료할 때까지 절대 반환하지 않는다, Humble·Jazzy 양쪽). 게다가 콜백 안에서 호출하면 데드락 조건과 같아진다. 항상 타임아웃 인자를 주고 실패 경로를 쓰라.
재시도와 멱등성: 타임아웃 후 재시도하려면 서비스가 멱등이어야 한다 — 같은 요청을 두 번 처리해도 결과가 같아야 한다. "카운터 증가", "상대 이동"처럼 누적되는 서비스는 재시도가 위험하다. 이런 서비스는 요청에 고유 ID를 넣어 서버가 중복을 걸러내도록 설계한다.
1.7.6 서버 측 — 콜백은 짧아야 한다
void MyNode::handle_service(
const std::shared_ptr<std_srvs::srv::Trigger::Request> request,
std::shared_ptr<std_srvs::srv::Trigger::Response> response)
{
// ✅ 짧고 즉시 완결되는 작업만
response->success = do_quick_thing();
response->message = response->success ? "ok" : "failed: …";
}
서비스 콜백 안에서 하면 안 되는 것:
| 금지 | 이유 | 대안 |
|---|---|---|
| 오래 걸리는 작업(수 초 이상) | executor 점유 → 다른 콜백 정지 | 액션(1.8) |
| 다른 서비스 동기 호출 | 데드락 | 비동기 또는 콜백 그룹 |
| 무한 루프·조건 대기 | 노드 정지 | 상태기계로 분해 |
| 블로킹 I/O(네트워크·디스크) | 지연 전파 | 워커 스레드 + 비동기 응답 |
spin() 호출 | 재진입 오류 | 하지 말 것 |
"오래 걸리면 액션"이 원칙이다. 경계는 대략 1초 — 그보다 오래 걸릴 수 있으면 진행 상황과 취소가 필요해지고, 그것이 액션의 존재 이유다.
정직: rclcpp에는 서비스 응답을 나중에 보내는 지연 응답(deferred response) 형태의 API가 논의·도입되어 왔다. Jazzy에서의 정확한 형태와 가용성은
(확인 필요)이며, 이식성 있는 해법은 여전히 액션으로 전환하는 것이다.
1.7.7 서비스 introspection — 오가는 호출 관찰하기
토픽은 ros2 topic echo로 보는데 서비스는 어떻게 보는가. 서비스 introspection 기능이 이를 제공한다.
ros2 service list -t
ros2 service type /my_service
ros2 service find std_srvs/srv/Trigger
ros2 service call /my_service std_srvs/srv/Trigger "{}"
# 오가는 요청·응답 관측 (introspection이 켜져 있어야 함)
ros2 service echo /my_service
introspection은 기본적으로 꺼져 있다 — 항상 켜 두면 오버헤드가 생기기 때문이다. 활성화 수준은 세 단계(꺼짐 / 메타데이터만 / 내용 포함)로 설계되어 있다.
정정(85차 실측) — 이 책은 앞서 "노드가 파라미터로 켜며 이름은
publish_client_events·publish_service_events계열"이라고 적었으나 그런 파라미터는 없다. 서비스·클라이언트를 가진 C++ 노드의ros2 param list에는use_sim_time·start_type_description_service·qos_overrides.*만 나오고,rclcpp헤더에도 그 이름이 없다. 실제 방식은 엔티티의 메서드다 —Service::configure_introspection()·Client::configure_introspection()(rclpy에도 같은 이름으로 있다). 상태 열거는 rcl의rcl_service_introspection_state_t이고, 이벤트는<서비스이름>/_service_event토픽으로 나간다(rcl 헤더의RCL_SERVICE_INTROSPECTION_TOPIC_POSTFIX). 꺼져 있다는 것은 확인됐다 — 서비스를 만들어도 그 토픽이 생기지 않는다.
견해: 개발·스테이징에서는 켜 두고 운영에서는 끄는 것이 합리적이다. 서비스 호출이 오가는지 확인할 수단이 없으면 "요청이 갔는데 응답이 없다"의 원인이 클라이언트인지 서버인지 네트워크인지 가릴 수 없다. 다만 내용 포함 모드는 대역·CPU를 쓰므로 상시 활성화는 신중해야 한다.
introspection이 없을 때의 대안: 서버 콜백 진입·이탈에 로그를 남기고(1.11), 요청 수·처리 시간을 진단 토픽으로 낸다(6.9). 사실 운영 시스템에서는 이쪽이 더 실용적이다.
1.7.8 서비스 vs 액션 vs 토픽 vs 파라미터
같은 일을 네 가지로 표현할 수 있어 판단이 필요하다.
| 원하는 것 | 적절한 수단 | 이유 |
|---|---|---|
| 즉시 끝나는 명령·질의 | 서비스 | 응답 보장, 단순 |
| 오래 걸리고 취소·진행률 필요 | 액션(1.8) | 상태기계 내장 |
| 지속적 데이터 흐름 | 토픽(1.4) | 다대다·비동기 |
| 설정 값 조회·변경 | 파라미터(1.9) | 표준 도구·이벤트 지원 |
| 상태 전이(구성·활성화) | 라이프사이클(1.12) | 표준 상태기계·관리자 지원 |
흔한 오용:
| 오용 | 문제 | 대신 |
|---|---|---|
| 설정 변경을 커스텀 서비스로 | 표준 도구(ros2 param)를 못 씀 | 파라미터 |
| 긴 작업을 서비스로 | 클라이언트 블로킹·취소 불가 | 액션 |
| 주기 데이터를 서비스 폴링으로 | 비효율·지연 | 토픽 |
| 상태 전이를 커스텀 서비스로 | 라이프사이클 도구·감시 못 씀 | 라이프사이클 노드 |
| 이벤트 통지를 서비스로 | 방향이 반대 | 토픽 |
함정: "파라미터 대신 서비스"는 특히 흔하다.
set_speed서비스를 만들면ros2 param·rqt_reconfigure·파라미터 파일·/parameter_events를 전부 잃는다. 설정 값이면 파라미터를 쓰고, 검증이 필요하면 파라미터 콜백에서 거부하면 된다(1.9).
1.7.9 서비스 설계 원칙
- 응답에 성공 여부와 사유를 담는다.
bool success+string message(std_srvs/srv/Trigger관행 — 1.2.4). 서비스에는 예외 채널이 없다. - 멱등하게 설계한다. 재시도가 안전해야 한다. 불가능하면 요청에 고유 ID를 둔다.
- 1초 규칙. 그보다 오래 걸릴 가능성이 있으면 액션.
- 부작용을 이름에 드러낸다.
get_*·set_*·start_*·reset_*. 읽기와 쓰기를 이름으로 구분하면 운영 사고가 준다(0.7.9). - 서비스 이름은 노드에 종속시킨다. 전역
/reset보다~/reset이 안전하다 — 다중 로봇·다중 인스턴스에서 충돌하지 않는다(1.1.4). - 서버는 언제 종료돼도 되도록. 클라이언트가 타임아웃과 재시도를 갖추면 서버 재시작이 사고가 되지 않는다.
1.7.10 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | 콜백 안에서 spin_until_future_complete | 영구 정지 | 비동기 콜백(1.7.4) |
| 2 | future.wait()·get() 직접 호출 | 영구 정지 | 동일 |
| 3 | 튜토리얼 코드를 노드에 이식 | main에선 되던 것이 멈춤 | 실행 문맥 차이 인지 |
| 4 | wait_for_service() 무한 대기 | 초기화에서 멈춤 | 타임아웃 인자 필수 |
| 5 | 응답 타임아웃 미구현 | 서버 사망 시 영구 대기 | 타이머로 직접 구현 |
| 6 | 비멱등 서비스를 재시도 | 중복 실행 | 요청 ID로 중복 제거 |
| 7 | 긴 작업을 서비스로 | 노드 정지·취소 불가 | 액션 |
| 8 | 서비스 콜백에서 다른 서비스 동기 호출 | 데드락 | 비동기·콜백 그룹 |
| 9 | 설정 변경을 서비스로 | 표준 도구 상실 | 파라미터 |
| 10 | 서비스에 TRANSIENT_LOCAL | 과거 요청 재전달 | 쓰지 말 것(1.6.7) |
| 11 | 서비스 서버 중복 | 요청 행선지 불명 | 이름·네임스페이스 관리 |
| 12 | rclpy에서 call() 동기 사용 | 조용한 정지 | call_async+add_done_callback |
| 13 | 콜백 그룹만 바꾸고 SingleThreaded 유지 | 여전히 데드락 | MultiThreadedExecutor 필수 |
| 14 | 같은 프로세스의 서비스를 콜백에서 동기 호출 | 클라이언트만 분리해도 데드락 | 서버까지 별도 그룹(2.4.12.4) |
13번은 ②를 시도하다 자주 밟는다. 콜백 그룹은 executor가 멀티스레드일 때만 의미가 있다.
14번, 140차 실행 확인. "클라이언트만 분리해도 데드락"의 정확한 의미를 세 배치로 나눠 재현했다(같은 프로세스 안에 서버·클라이언트·타이머, MultiThreadedExecutor 4스레드, Humble·Jazzy 양쪽):
- (a) 전부 기본 그룹 — 클라이언트·타이머·서버가 모두 노드의 기본(
MutuallyExclusive) 그룹: 타임아웃(데드락) 확인. - (b) 클라이언트 핸들만 분리 — 클라이언트를
Reentrant그룹으로 옮기되, 실제로 블로킹하는 타이머 콜백은 기본 그룹에 남겨 서버와 계속 공유: 역시 타임아웃(데드락) — 이 배치가 정확히 14번이 경고하는 구성이다. - (c) 블로킹 콜백까지 분리 — 타이머 콜백 자체를 클라이언트와 같은
Reentrant그룹으로 함께 옮기면(=1.7.4 해법②와 동일한 구성): 성공, 응답 수신.
즉 14번의 "클라이언트만 분리"는 블로킹을 실제로 실행하는 콜백이 아니라 클라이언트 핸들만 별도 그룹에 두는 흔한 실수를 가리킨다 — 그 해석에서는 주장이 정확히 맞는다. 서버까지 분리하지 않아도(서버는 (b)·(c) 모두 기본 그룹에 그대로 있었다) (c)는 성공했다는 점에서, 대처란의 "서버까지 별도 그룹"보다 더 정확한 진단은 "블로킹하는 그 콜백을 클라이언트와 같은 그룹으로"다(목차 3, tools/gap_harness/gap140).
1.7.11 체크포인트
- 서비스가 DDS 위에서 어떻게 구현되는지 설명하고, 거기서 나오는 결과 네 가지를 말할 수 있는가.
- "서비스에는 내장 타임아웃이 없다"의 실무적 함의는 무엇인가.
- 콜백 안에서
spin_until_future_complete를 부르면 왜 멈추는지, executor 스레드 관점에서 설명할 수 있는가. - 같은 코드가
main()에서는 되고 콜백 안에서는 안 되는 이유를 말할 수 있는가. - 데드락 해법 세 가지를 나열하고, 각각의 대가(스레드 안전·가독성·그래프)를 비교할 수 있는가.
- 콜백 그룹을 바꿨는데도 여전히 멈춘다면 무엇을 확인해야 하는가.
- 올바른 서비스 클라이언트가 갖춰야 할 요소 세 가지(서버 대기·응답 타임아웃·실패 경로)를 코드로 쓸 수 있는가.
- 재시도를 안전하게 만들려면 서비스가 어떤 성질을 가져야 하는가. 그것이 불가능하면 어떻게 하는가.
- 서비스 콜백 안에서 하면 안 되는 일 다섯 가지를 들 수 있는가. "1초 규칙"은 무엇인가.
- 설정 값을 커스텀 서비스로 만들면 무엇을 잃는가.
- 서비스 introspection을 운영에서 항상 켜 두지 않는 이유는 무엇이며, 대안은 무엇인가.
다음 장 1.8는 "1초 규칙"의 반대편 — 액션을 다룬다. 오래 걸리고, 취소할 수 있고, 진행 상황을 알려주는 통신이 어떻게 서비스 3개와 토픽 2개 위에 상태기계로 세워지는지 연다.