제2부 실행 모델·클라이언트 라이브러리 내부
2.7 intra-process 통신과 zero-copy
2.7.1 이 장에서 배우는 것 / 선수 지식
1.3·1.4·1.13·2.6에서 "복사 0", "직렬화 우회", "조건이 맞으면"이라는 표현을 반복해 썼다. 이 장에서 그 조건을 확정한다. 왜 중요한가 — 이 조건들은 조용히 어긋난다. 코드는 그대로 동작하고 오류도 없는데 성능만 원래대로 돌아간다. 그리고 대부분의 사람은 그 사실을 모른 채 "컴포지션했으니 빠르겠지"라고 믿는다.
배우는 것:
- 두 가지 다른 것 — intra-process(프로세스 내)와 zero-copy(공유 메모리)는 별개 메커니즘이다.
- intra-process의 내부 구조 —
IntraProcessManager와 버퍼. - 복사 0의 정확한 조건표 — 발행 형태 × 구독 형태 × 구독자 수.
- 알려진 제약 — 어떤 QoS·기능이 intra-process에서 다르게 동작하는가.
- 공유 메모리 zero-copy — Fast DDS SHM·iceoryx 계열의 구조와 조건.
- 혼합 상황 — 프로세스 안팎 구독자가 섞였을 때.
- 실제 이득 측정법.
- 경계 실측(2.7.12) — intra-process가 덮는 범위(토픽만인가)와, 엔티티별로 켜고 끄는 법.
- "묶으면 빠르다"의 경계(2.7.13) — 지연·CPU·버퍼 주소로 측정한 실제 이득과, 손해가 되는 구성.
선수 지식: 1.4(경로·소유권), 1.13(컴포지션), 2.6(loaned message), 1.3(직렬화).
정직: intra-process 구현은 배포판을 거치며 상당히 개선되어 왔고, 제약 목록도 변해 왔다. 아래의 "지원하지 않음·다르게 동작함" 항목은 설계 문서와 알려진 이슈 기준이며, Jazzy에서 여전히 유효한지는 반드시 확인해야 한다
(확인 필요). 이 환경에서 실행 검증하지 않았다(목차 3). 확실한 것은 하나다 — 측정 없이 가정하지 말 것.검증 상태(추가): 이 장의 서술 가운데 1곳은 이후 회차에서 Jazzy 실물과 대조해 확인했다 — 본문의 (실행 확인) 표기가 그것이다. 표기가 없는 서술은 위 고지가 그대로 적용된다(목차 3).
2.7.2 두 가지를 구분하라
혼동이 잦은 지점이다. intra-process와 zero-copy는 다른 층의 다른 메커니즘이다.
| 축 | ① intra-process | ② zero-copy(SHM) |
|---|---|---|
| 구현 계층 | rclcpp | DDS 벤더 |
| 범위 | 같은 프로세스 | 같은 호스트(다른 프로세스도) |
| 직렬화 | 없음 | 있거나 최소 |
| 활성화 | use_intra_process_comms(true) | 벤더 설정(XML 등) |
| 타입 제약 | 없음(일반 C++ 객체) | 고정 크기 요구가 흔함 |
| loaned message | 무관 | 밀접(2.6.5) |
둘을 함께 쓸 수 있다. 컨테이너 안의 노드끼리는 ①, 컨테이너 밖 다른 프로세스와는 ②로 통신하는 구성이 가능하다.
2.7.3 intra-process의 내부 구조
핵심 동작:
- 발행자는
IntraProcessManager에게 "이 토픽의 프로세스 내 구독자가 누구인가"를 묻는다. - 각 구독의 링버퍼에 메시지(포인터 또는 복사본)를 넣는다.
- 구독 쪽 waitable(2.2.3)이 준비 상태가 되어 executor가 깨어난다.
- 콜백이 버퍼에서 꺼내 실행된다.
버퍼 크기는 QoS depth를 따른다. depth가 작으면 프로세스 안에서도 유실이 생긴다 — "같은 프로세스니까 안 빠지겠지"는 틀렸다(1.4.4의 논리가 그대로 적용된다).
활성화:
// ① NodeOptions로
rclcpp::NodeOptions options;
options.use_intra_process_comms(true);
auto node = std::make_shared<MyNode>(options);
// ② 컴포넌트 — launch에서 (1.13.5)
extra_arguments=[{'use_intra_process_comms': True}]
// ③ 개별 엔티티로 — 실행 확인(Jazzy). 발행자·구독자를 섞을 수도 있다
rclcpp::PublisherOptions pub_opt;
pub_opt.use_intra_process_comm = rclcpp::IntraProcessSetting::Enable;
양쪽 모두 켜져야 한다. 발행 노드만 켜고 구독 노드를 안 켜면 intra-process 경로가 성립하지 않는다.
2.7.4 복사 0의 정확한 조건
이 표가 이 장의 핵심이다.
| 발행 형태 | 프로세스 내 구독자 | 구독 콜백 형태 | 복사 횟수 |
|---|---|---|---|
unique_ptr | 1개 | UniquePtr | 0 |
unique_ptr | 1개 | ConstSharedPtr | 0 |
unique_ptr | N개 | 전부 ConstSharedPtr | 0(공유) |
unique_ptr | N개 | 전부 UniquePtr | N−1 |
unique_ptr | N개 | 혼합 | 상황에 따라(대개 N−1 이하) |
const & | 임의 | 임의 | 최소 1(라이브러리 복사) |
| loaned | — | — | 2.6.5 참조 |
규칙으로 요약:
unique_ptr로 발행하라.const&발행은 어떤 설정에서도 복사가 남는다.UniquePtr구독자가 여럿이면 복사가 생긴다 — 각자 독점 소유를 요구하므로 라이브러리가 복제한다.ConstSharedPtr구독자는 여럿이어도 공유한다 — 대신 수정 금지.
// ✅ 최적: unique_ptr 발행
auto msg = std::make_unique<sensor_msgs::msg::Image>();
fill(*msg);
pub_->publish(std::move(msg));
// ✅ 구독자 여럿이면 ConstSharedPtr로 받기 (읽기 전용)
sub_ = create_subscription<sensor_msgs::msg::Image>(
"image", qos,
[](sensor_msgs::msg::Image::ConstSharedPtr msg) { read_only(*msg); });
// ⚠️ 수정이 필요하면 UniquePtr — 단 구독자가 하나일 때만 복사 0
sub_ = create_subscription<sensor_msgs::msg::Image>(
"image", qos,
[](sensor_msgs::msg::Image::UniquePtr msg) { modify(*msg); });
함정:
ConstSharedPtr로 받은 메시지를const_cast로 수정하는 것은 심각한 버그다. 다른 구독자가 같은 객체를 보고 있을 수 있다. 별도 프로세스에서는 문제가 안 나타나고 컴포지션했을 때만 나타나므로, 배치를 바꾸는 순간 드러나는 최악의 버그 유형이다(1.4.5).
"복사 0"이 정확히 의미하는 것: 메시지 객체 자체의 복사가 없다는 뜻이다. 콜백이 내부에서 데이터를 다른 자료구조로 옮기면 그것은 별개의 복사다. 파이프라인 전체가 무복사가 되려면 각 단계가 in-place로 처리하거나 소유권을 넘겨야 한다.
2.7.5 알려진 제약
intra-process 경로는 rclcpp가 구현한 별도 경로이므로, DDS가 제공하는 기능 일부가 그대로 적용되지 않을 수 있다.
| 항목 | 상태 |
|---|---|
TRANSIENT_LOCAL 내구성 | Jazzy에서 동작 확인 — 늦은 구독자가 과거 메시지를 받고, 전달 경로도 intra-process가 맞다(주소 일치로 확인, 2.7.13.4). 다만 지연·CPU 비용이 크다. 헤더 주석은 여전히 비호환이라 적고 있다 |
| deadline·lifespan·liveliness | 미들웨어 기능이므로 intra-process 경로에서는 다르게 동작할 수 있음(확인 필요) |
| 직렬화 메시지 구독(제네릭) | 직렬화가 없으므로 경로가 다름(1.3.8) |
| QoS 이벤트 | matched는 발생 확인(2.7.5.1). 나머지 이벤트는 미확인(확인 필요) |
| 서비스·액션 | 대상이 아니다 — IntraProcessManager에 add_publisher·add_subscription만 있고 서비스용 옵션 자체가 없다(실행 확인) |
2.7.5.1 실측 — 그래서 TRANSIENT_LOCAL은 되는가
실행 확인(2026-08-16). 위 표가 "반드시 실험으로 확인하라"고 한 것을 실제로 했다.
use_intra_process_comms(true)노드에서TRANSIENT_LOCAL발행자를 만들고, 구독자가 없는 상태에서 한 번 발행한 뒤, 1초 후에 구독자를 추가했다.
[INFO] ① TRANSIENT_LOCAL 발행자 생성됨
[INFO] ② can_loan_messages() = false
[INFO] ③ 구독자 없는 상태에서 1회 발행
[INFO] ④ 늦은 구독자 생성 완료
[WARN] ⑤ 늦은 구독자가 과거 메시지 수신: past-message
[INFO] ⑥ matched 이벤트 발생: current=1
| 표의 항목 | 실측 결과 |
|---|---|
TRANSIENT_LOCAL 내구성 | 동작한다 — 늦게 참여한 구독자가 과거 메시지를 받았고, 경고도 예외도 없었다 |
| QoS 이벤트 | matched는 발생한다 — intra-process 경로에서도 이벤트가 종료되지 않는다 |
| loan 지원 | 이 구성에서 can_loan_messages()가 false(2.6.5의 전제) |
그런데 여기에 정직하게 적어야 할 어긋남이 있다. rclcpp/subscription.hpp의 문서 주석은 지금도 이렇게 말한다 — 원문 확인.
"
\throws std::invalid_argumentif the QoS is uncompatible with intra-process (…qos_profile.durability != RMW_QOS_POLICY_DURABILITY_VOLATILE)"
즉 주석대로라면 TRANSIENT_LOCAL 구독은 intra-process에서 예외를 던져야 한다. 확인해 봤다 — SubscriptionOptions::use_intra_process_comm을 IntraProcessSetting::Enable로 강제해도 예외가 나지 않았다.
| 시도 | 결과 |
|---|---|
NodeDefault + TRANSIENT_LOCAL | 예외 없음 |
Enable 강제 + TRANSIENT_LOCAL | 예외 없음 |
Enable 강제 + VOLATILE | 예외 없음(정상) |
해석은 하나로 좁혀지지 않는다. 구현이 개선되어 지원 범위가 넓어졌는데 주석이 낡은 채 남아 있는 것으로 보이지만, 내가 확인한 것은 "예외가 나지 않고 과거 메시지가 전달된다" 까지다. 그 전달이 intra-process 경로를 통한 것인지, 조용히 DDS 경로로 넘어간 것인지는 이 실험으로 구분하지 못했다 — 이후 회차에 답이 나왔다: intra-process 경로가 맞다(버퍼 주소가 30/30 일치, 2.7.13.4). 다만 VOLATILE보다 지연이 약 60배, CPU가 약 4.6배 든다.
이 어긋남 자체가 위 표의 태도를 정당화한다. 헤더 주석이라는 1차 자료조차 현재 동작과 다를 수 있다 —
TRANSIENT_LOCAL토픽을 컴포지션 경로에 둘 때 "복사가 0이 되었다"고 가정하지 말고, 2.7.8의 방법으로 실제 이득을 재라. 기능이 동작하는 것과 최적화가 적용되는 것은 다른 문제다.
서비스·액션의 적용 범위는 확정했다 — 대상이 아니다(실행 확인, 2.7.12.1).
deadline·lifespan·liveliness가 intra-process 경로에서 어떻게 동작하는지는 여전히 확인하지 못했다 — 이벤트가 발화하는지 측정하려면 위반을 유도하는 실험이 필요하고, 그 결과가 미들웨어 경로와 섞이지 않게 격리하기 어렵다.
실무적 대처:
// 늦게 참여하는 구독자에게 데이터가 필요한 토픽이라면,
// intra-process 여부와 무관하게 동작하도록 설계한다.
// 예: 상태를 서비스로도 조회 가능하게 하거나, 주기적으로 재발행
2.7.6 프로세스 안팎이 섞였을 때
가장 흔한 실제 상황이다. 컨테이너 안에 처리 노드가 있고, 밖에서 RViz2가 같은 토픽을 구독한다.
결과: 두 경로가 모두 동작한다. 직렬화는 밖으로 나가는 몫 때문에 여전히 일어나고, CPU도 그만큼 쓴다.
| 상황 | 직렬화 | 프로세스 내 복사 |
|---|---|---|
| 밖에 구독자 0 | 없음 | 0 |
| 밖에 구독자 1 이상 | 있음 | 0(안쪽은 여전히 최적) |
실무 함의:
ros2 topic echo를 거는 것만으로 직렬화가 다시 발생한다. 디버깅 중 성능이 악화되는 이유가 이것이다(0.7.4의 "관측이 참여다"가 여기서도).- RViz2를 실행해 두고 성능을 측정하면 결과가 왜곡된다.
- 정말 무복사 파이프라인이 필요하면 밖으로 나가는 토픽을 별도로 두는 설계가 낫다 — 내부용 원본 토픽과 외부용 축소·압축 토픽을 분리한다(7.8).
/camera/image_raw ← 컨테이너 내부 전용 (intra-process)
/camera/image_raw/compressed ← 외부 관측용 (압축, 저빈도)
2.7.7 공유 메모리 zero-copy
프로세스 경계를 넘어야 하지만 같은 호스트일 때, DDS 벤더의 공유 메모리 전송을 쓴다.
구현체:
| 방식 | 설명 |
|---|---|
| Fast DDS SHM 전송 | XML로 공유 메모리 전송 활성화. data-sharing delivery 지원(3.7) |
| Cyclone DDS + iceoryx | iceoryx 기반 zero-copy 경로(확인 필요: Jazzy에서의 구성 방법) |
| 벤더별 기타 | 상용 DDS의 자체 방식 |
조건이 까다롭다:
| 조건 | 설명 |
|---|---|
| 같은 호스트 | 당연 |
| 벤더 지원 + 설정 | XML·환경변수 |
| 고정 크기 타입 | 가변 길이 필드가 있으면 대개 불가(2.6.5) |
| loaned message API 사용 | 일반 발행으로는 이득이 줄어든다 |
| 데몬·인프라 | iceoryx 계열은 별도 데몬(RouDi 등)이 필요할 수 있다(확인 필요) |
견해: 공유 메모리 zero-copy는 "필요가 명확할 때" 도입할 기술이다. 조건이 많고 운영 복잡도(데몬·세그먼트 크기 설정·디버깅)가 높아진다. 대안을 먼저 검토하라 — 컴포지션으로 같은 프로세스에 넣을 수 있다면 intra-process가 훨씬 단순하다. 프로세스를 반드시 분리해야 하는 이유(격리·언어·라이선스)가 있을 때 zero-copy가 값을 한다.
2.7.8 실제 이득 측정
"컴포지션했으니 빠르다"를 검증하는 방법.
① 프로세스 밖 트래픽 확인
ros2 topic bw /camera/image_raw
intra-process가 제대로 동작하고 밖에 구독자가 없으면, 이 명령을 실행하는 순간 CLI가 구독자가 되어 트래픽이 나타난다. 실행 전후 CPU를 비교하면 직렬화 비용이 드러난다.
② CPU 비교
# 별도 프로세스 구성
top -p $(pgrep -f camera_node),$(pgrep -f processor_node)
# 컴포지션 구성
top -p $(pgrep -f component_container)
③ 종단 지연 측정 — 가장 확실
// 발행 시각을 헤더에 남기고 (1.10.6)
msg->header.stamp = clock_->now();
// 최종 소비 노드에서
auto latency = clock_->now() - rclcpp::Time(msg->header.stamp, clock_type);
latency_stats_.add(latency.seconds() * 1000.0); // ms
// p50 · p99 · max로 보고 (2.5.6)
④ 트레이싱 — ros2_tracing으로 발행→콜백 구간을 직접 측정. 가장 정확하며 코드 수정이 필요 없다(11.6).
⑤ 버퍼 주소 비교 — 복사 0을 직접 증명한다
// 발행 직전
const void * a = static_cast<const void *>(msg->data.data());
// 콜백에서
if (static_cast<const void *>(m->data.data()) == a) { /* 같은 버퍼 */ }
지연은 부하에 흔들리지만 주소 일치는 흔들리지 않는다 — 무복사인지 아닌지를 이분법으로 답한다. 단, 할당자가 방금 해제한 블록을 재사용하면 거짓 일치가 난다(2.7.13.5). 재사용을 막고 재거나 ③과 함께 보라.
견해: ③의 종단 지연이 실무 의사결정에 가장 유용하다. "직렬화가 몇 번 일어났나"가 아니라 "카메라에서 제어 명령까지 몇 ms인가"가 실제 요구사항이기 때문이다. 최적화의 목표를 이 숫자로 설정하면 잘못된 곳을 최적화하는 일이 줄어든다(12.4).
2.7.9 판단 절차 — 어떤 최적화를 쓸 것인가
"같은 프로세스에 둘 수 있는가"에서 걸리는 이유들(1.13.7): 안전 격리, 언어 차이, 크래시 전파, 재시작 단위, 자원 제어. 이 이유들은 성능보다 우선할 수 있으며, 그 경우 zero-copy나 대역 설계로 간다.
2.7.12 intra-process의 경계 — 실측
2.7.6의 제약 표에 (확인 필요)가 여럿 남아 있었다. 어디까지가 intra-process의 대상인지를 Jazzy에서 컴파일·조회로 확정했다(실행 확인).
2.7.12.1 대상은 토픽뿐이다
rclcpp의 IntraProcessManager가 등록을 받는 엔티티는 둘뿐이다(헤더 조회):
add_publisher add_subscription
service·client는 한 번도 언급되지 않는다. 옵션 쪽도 같다 — use_intra_process_comm 필드는 PublisherOptions와 SubscriptionOptions에만 있고, service.hpp·client.hpp에는 없다.
즉 서비스와 액션은 intra-process 최적화의 대상이 아니다. 같은 프로세스 안에서 서비스를 호출해도 직렬화와 미들웨어 왕복이 그대로 일어난다.
함정: 컴포넌트 컨테이너에 노드를 모으면 서비스도 빨라질 것으로 기대하기 쉽다(1.13). 토픽은 그렇지만 서비스는 아니다. 고빈도 서비스 호출이 병목이라면 컴포지션으로는 해결되지 않는다 — 애초에 서비스가 맞는 통신 방식인지를 되물어야 한다(1.7.1: 고빈도 요청·응답은 서비스의 용도가 아니다).
참고: intra-process 노드에서 서비스를 만드는 것 자체는 문제없다(실행 확인 — 생성 성공). 최적화가 안 될 뿐 동작은 정상이다.
2.7.12.2 엔티티마다 켜고 끌 수 있다
노드 전체를 켜는 것 말고 발행자·구독자 단위로 설정할 수 있다. 실제로 컴파일해 확인했다:
auto o = rclcpp::NodeOptions().use_intra_process_comms(true);
auto n = std::make_shared<rclcpp::Node>("ip", o);
rclcpp::PublisherOptions po;
po.use_intra_process_comm = rclcpp::IntraProcessSetting::Enable;
auto p = n->create_publisher<std_msgs::msg::String>("/t", 10, po);
rclcpp::SubscriptionOptions so;
so.use_intra_process_comm = rclcpp::IntraProcessSetting::Disable; // 이쪽만 끈다
auto sub = n->create_subscription<std_msgs::msg::String>("/t", 10, cb, so);
값은 셋이다:
| 값 | 뜻 |
|---|---|
NodeDefault(기본) | 노드의 use_intra_process_comms 설정을 따른다 |
Enable | 이 엔티티만 켠다 |
Disable | 이 엔티티만 끈다 |
발행자는 intra-process인데 구독자는 아닌 조합도 만들어진다(실행 확인 — 컴파일·생성 모두 성공). 그런 경우 그 쌍은 일반 미들웨어 경로로 통신한다.
견해:
Disable이 실전에서 더 쓸모 있다. 노드 전체를 켜 두고, 문제가 되는 엔티티 하나만 빼는 방식이 진단에 좋다 — 2.7.5의 제약(과거에는TRANSIENT_LOCAL이 걸렸던 것 등)에 걸리는 토픽을 찾았을 때, 노드 전체를 되돌리지 않고 그 토픽만 예외로 둘 수 있다.
함정: 엔티티별 설정은 코드에만 있고 파라미터로는 못 바꾼다. QoS는
qos_overrides로 실행 시 조정할 수 있지만(1.6.6), intra-process 여부는 다시 빌드해야 바뀐다. 그래서 "일단 켜 두고 문제 있으면 끈다"는 전략은 재빌드 비용을 감수한다는 뜻이다.
2.7.13 "묶으면 빨라진다"는 어디까지 참인가 — 실측
"컴포넌트로 묶으면 빠르다"는 ROS 2에서 가장 널리 반복되는 말이다. 조건이 맞으면 참이고, 조건이 어긋나면 프로세스를 나눈 것보다 불리하다. 그 경계를 측정해 봤다.
측정 구성(실행 확인, Jazzy): 한 프로세스 안에서 sensor_msgs/Image 약 6 MB(1920×1080×3)를 30회 발행하고, ① 구독자가 받은 버퍼의 주소가 발행자가 쓴 주소와 같은지, ② 발행→콜백 평균 지연, ③ 30회 동안의 프로세스 CPU 시간을 측정했다. 지연·CPU는 각 구성을 3회 반복해 값이 안정적인 것을 확인했다.
2.7.13.1 결과
| 구성 | 주소 동일 | 평균 지연 | CPU(30회) |
|---|---|---|---|
intra-process + unique_ptr 발행 | 30/30 | 0.053~0.065 ms | 약 31 ms |
intra-process + const& 발행 | 0/30 | 2.9~3.4 ms | 약 222 ms |
intra-process 끔 + unique_ptr | 0/30 | 2.0~2.4 ms | 약 95 ms |
intra-process + TRANSIENT_LOCAL | 30/30 | 3.7~3.9 ms | 약 142 ms |
첫 줄과 셋째 줄이 통념의 근거다 — 조건이 맞으면 지연 약 38배, CPU 약 3배 차이다. 6 MB 메시지에서 0.06 ms와 2.2 ms는 실무적으로 다른 세계다.
2.7.13.2 반증 — const&로 발행하면 나누는 편이 나았다
| 지연 | CPU | |
|---|---|---|
intra-process + const&(같은 프로세스) | 2.9~3.4 ms | 약 222 ms |
| intra-process 끔(프로세스 분리와 같은 경로) | 2.0~2.4 ms | 약 95 ms |
같은 프로세스에 묶고 intra-process까지 켰는데, 안 켠 것보다 느리고 CPU를 2.3배 더 썼다. "묶으면 빨라진다"의 반례가 이렇게 쉽게 나온다.
2.7.4의 표는 const& 발행에 "최소 1회 복사"라고 적었는데, 실측은 그보다 열악하다 — 복사가 남는 정도가 아니라 순손해다. 추정컨대 intra-process 경로의 복사와 미들웨어 경로 준비를 둘 다 치르기 때문으로 보이지만, 내부 경로를 직접 추적하지는 않았다(확인 필요: 정확한 원인).
그래서 이 장의 규칙 1번("
unique_ptr로 발행하라", 2.7.4)이 선택 사항이 아니다. 컴포지션은unique_ptr발행과 한 세트이고, 둘 중 하나만 하면 아무것도 안 한 것보다 나쁠 수 있다.
2.7.13.3 2.7.4의 표를 검증했다 — N−1 규칙이 정확히 맞는다
구독자 수와 콜백 형태를 바꿔 가며 주소를 셌다.
| 구독 구성 | 수신 | 주소 동일 | 해석 |
|---|---|---|---|
ConstSharedPtr 1개 | 30 | 30 | 복사 0 |
UniquePtr 1개 | 30 | 30 | 복사 0 |
ConstSharedPtr 2개 | 60 | 60 | 복사 0 — 둘이 같은 객체를 본다 |
UniquePtr 2개 | 60 | 30 | 정확히 N−1 = 1회 복사 |
표의 네 행이 그대로 재현됐다. 특히 마지막 줄 — UniquePtr 구독자 둘 중 하나만 원본을 받고 나머지는 복사본을 받는다. 2.7.4의 "각자 독점 소유를 요구하므로 라이브러리가 복제한다"가 숫자로 확인된다.
그리고 셋째 줄이 2.7.4의 함정을 실감하게 한다 — ConstSharedPtr 구독자 둘은 문자 그대로 같은 메모리를 본다. 한쪽이 const_cast로 조작하면 다른 쪽이 조용히 오염된다.
2.7.13.4 TRANSIENT_LOCAL의 미해결 질문에 답이 나왔다
2.7.5.1은 "전달이 intra-process 경로인지, 조용히 DDS로 넘어간 것인지 구분하지 못했다"고 적고 (확인 필요)로 남겼다. 주소 비교가 그것을 가른다.
TRANSIENT_LOCAL에서도 주소가 30/30 일치했다 — 구독자는 발행자가 쓴 바로 그 버퍼를 받는다. DDS로 넘어간 것이 아니라 intra-process 경로가 그대로 쓰인다. 경고도 예외도 없다.
그러나 비용이 든다. 같은 무복사인데 지연이 0.06 ms → 3.8 ms(약 60배), CPU가 31 ms → 142 ms(약 4.6배)다.
VOLATILE | TRANSIENT_LOCAL | |
|---|---|---|
| 구독자에게 복사 | 0 | 0(동일) |
| 평균 지연 | 0.06 ms | 3.8 ms |
| CPU | 31 ms | 142 ms |
발행 측이 내구성 캐시를 유지하느라 치르는 비용으로 보이지만, 이 실험으로 원인까지 확정하지는 못했다(확인 필요: 비용의 출처).
실무 함의가 크다. "복사 0인지 확인했는데 왜 느리냐"는 질문의 답이 여기 있다. 복사 0은 성능의 필요조건이지 충분조건이 아니다. 큰 메시지에
TRANSIENT_LOCAL을 습관적으로 붙이고 있다면(1.6.3.3), 그 편의의 대가를 이 숫자로 알고 붙여라.
2.7.13.5 측정 방법 자체가 함정이었다 — 주소 비교의 오답
처음 측정한 결과는 틀렸다. intra-process를 끄고 측정했는데도 주소가 16/30 일치했다. 무복사가 아닌데 무복사로 보인 것이다.
원인은 할당자의 블록 재사용이다. 발행자가 버퍼를 놓아 준 직후 미들웨어가 같은 크기를 요청하면, glibc가 방금 해제한 바로 그 블록을 돌려준다. 주소는 같지만 다른 메모리 사건이다.
// 발행 직후 같은 크기의 미끼 버퍼를 할당해 재사용을 막는다
std::vector<std::unique_ptr<std::vector<uint8_t>>> decoy;
for (int d = 0; d < 4; ++d)
decoy.push_back(std::make_unique<std::vector<uint8_t>>(N, 0xAB));
미끼를 넣자 16/30 → 0/30으로 떨어졌다. 위 표의 모든 주소 수치는 미끼를 넣고 측정한 값이다.
함정: "포인터 주소가 같으니 zero-copy"는 그 자체로 증거가 아니다. 할당자가 우연히 같은 주소를 줄 수 있다. ① 재사용을 막고 재거나, ② 지연·CPU와 함께 보아야 결론이 선다. 미끼 버퍼는 지연 측정을 크게 왜곡하므로(수 MB 할당·초기화가 개입한다) 주소 측정용과 지연 측정용 빌드를 나눠야 한다 — 위 결과도 그렇게 두 번 측정했다.
2.7.13.6 그래서 판단은 이렇게
이 장의 결론을 한 줄로: "컴포넌트로 묶으면 빠르다"가 아니라 "unique_ptr로 발행하며 묶으면 빠르다"이다.
2.7.10 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | 한쪽만 intra-process 활성 | 경로 미성립 | 양쪽 모두 설정 |
| 2 | const& 발행 | 복사 잔존 | unique_ptr 발행 |
| 3 | UniquePtr 구독자 여럿 | N−1 복사 | ConstSharedPtr로 |
| 4 | ConstSharedPtr를 const_cast로 수정 | 다른 구독자 데이터 오염 | 절대 금지 |
| 5 | 밖에 구독자가 있는데 무복사 기대 | 직렬화 잔존 | 토픽 분리(2.7.6) |
| 6 | echo·RViz2 실행한 채 측정 | 결과 왜곡 | 측정 시 제거 |
| 7 | depth를 작게 두고 "안 빠질 것" 가정 | 프로세스 내 유실 | depth 설계(1.6.4) |
| 8 | TRANSIENT_LOCAL이 동작할 것으로 가정 | 늦은 구독자가 못 받음 | 실험으로 확인 |
| 9 | zero-copy를 무한 배열 타입에 | 미지원·fallback | 고정 크기 타입 |
| 10 | zero-copy 도입 후 데몬 미설정 | 동작 안 함 | 벤더 요구사항 확인 |
| 11 | 측정 없이 최적화 | 이득 불명 | 종단 지연 측정 |
| 12 | 콜백 안에서 데이터를 다시 복사 | 무복사가 무의미 | in-place 처리·소유권 이전 |
| 13 | 컴포지션으로 서비스도 빨라질 것으로 기대 | intra-process는 토픽 전용 | 2.7.12.1 |
| 14 | 묶기만 하고 const&로 발행 | 나눈 것보다 느림(CPU 2.3배) | unique_ptr 발행은 세트다(2.7.13.2) |
| 15 | 주소가 같으니 zero-copy로 단정 | 할당자 재사용에 속음 | 재사용을 막고 재라(2.7.13.5) |
| 16 | 큰 메시지에 습관적 TRANSIENT_LOCAL | 복사 0인데 지연 60배 | 비용을 알고 붙여라(2.7.13.4) |
2.7.11 체크포인트
- intra-process와 zero-copy가 어느 계층의 무엇인지 구분해 설명할 수 있는가. 함께 쓸 수 있는가.
IntraProcessManager가 하는 일과, 버퍼 크기가 무엇으로 정해지는지 말할 수 있는가.- 복사 0의 조건표를 발행 형태 × 구독 형태 × 구독자 수로 재구성할 수 있는가.
ConstSharedPtr구독자가 여럿일 때 복사가 0인 이유와, 그 대가로 지켜야 할 규칙은?const_cast로 수정하는 버그가 왜 최악의 유형인가.- 프로세스 밖 구독자가 하나 생기면 무엇이 달라지는가.
ros2 topic echo가 성능에 미치는 영향은? - 무복사 파이프라인을 유지하면서 외부 관측을 허용하는 설계는?
- 공유 메모리 zero-copy의 조건 다섯 가지를 말할 수 있는가. 컴포지션 대신 이것을 택하는 이유는 무엇이어야 하는가.
- 최적화 이득을 측정하는 네 가지 방법과, 실무 의사결정에 가장 유용한 것은?
- intra-process의 제약 항목들을 "확인 필요"로 두는 태도가 왜 정직한가. 실무에서 어떻게 대처하는가.
- 통신 최적화 판단 절차를 그리고, "같은 프로세스에 둘 수 없는" 이유 다섯 가지를 들 수 있는가.
- intra-process 최적화의 대상은 어디까지인가. 서비스도 빨라지는가(2.7.12).
- "컴포넌트로 묶으면 빠르다"가 거짓이 되는 구성을 하나 들 수 있는가(2.7.13.2).
UniquePtr구독자가 2개일 때 복사는 몇 번 일어나는가. 실측은 어떻게 나왔는가(2.7.13.3).TRANSIENT_LOCAL은 intra-process 무복사를 깨는가? 그러면 무엇이 비용이 커지는가(2.7.13.4).- "버퍼 주소가 같다"만으로 zero-copy를 단정하면 안 되는 이유는(2.7.13.5).
다음 장 2.8은 지금까지 C++ 중심으로 다룬 실행 모델을 Python(rclpy) 관점에서 다시 본다. GIL이 무엇을 바꾸고, rclpy executor가 rclcpp와 어떻게 다르며, 어디까지 Python으로 해도 되는가.