AEGLOSLAB ROS 2 Jazzy 대전

제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는 다른 층의 다른 메커니즘이다.

아니오 예 예 아니오 publish() 같은 프로세스 + intra-process 활성? ① intra-process rclcpp 계층 · 포인터 전달 rmw로 내려감 같은 호스트 + 공유메모리 전송? ② zero-copy(SHM) DDS 계층 · 공유 메모리 ③ 네트워크
축① intra-process② zero-copy(SHM)
구현 계층rclcppDDS 벤더
범위같은 프로세스같은 호스트(다른 프로세스도)
직렬화없음있거나 최소
활성화use_intra_process_comms(true)벤더 설정(XML 등)
타입 제약없음(일반 C++ 객체)고정 크기 요구가 흔함
loaned message무관밀접(2.6.5)

둘을 함께 쓸 수 있다. 컨테이너 안의 노드끼리는 ①, 컨테이너 밖 다른 프로세스와는 ②로 통신하는 구성이 가능하다.

2.7.3 intra-process의 내부 구조

Publisher::publish(unique_ptr) IntraProcessManager 구독 A의 링버퍼 구독 B의 링버퍼 프로세스 밖 구독자가 있으면 rmw 경로로도 발행 executor가 waitable로 인지 콜백 실행 (포인터 꺼내 전달)

핵심 동작:

  1. 발행자는 IntraProcessManager에게 "이 토픽의 프로세스 내 구독자가 누구인가"를 묻는다.
  2. 각 구독의 링버퍼에 메시지(포인터 또는 복사본)를 넣는다.
  3. 구독 쪽 waitable(2.2.3)이 준비 상태가 되어 executor가 깨어난다.
  4. 콜백이 버퍼에서 꺼내 실행된다.

버퍼 크기는 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_ptr1개UniquePtr0
unique_ptr1개ConstSharedPtr0
unique_ptrN개전부 ConstSharedPtr0(공유)
unique_ptrN개전부 UniquePtrN−1
unique_ptrN개혼합상황에 따라(대개 N−1 이하)
const &임의임의최소 1(라이브러리 복사)
loaned——2.6.5 참조

규칙으로 요약:

  1. unique_ptr로 발행하라. const& 발행은 어떤 설정에서도 복사가 남는다.
  2. UniquePtr 구독자가 여럿이면 복사가 생긴다 — 각자 독점 소유를 요구하므로 라이브러리가 복제한다.
  3. 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_argument if 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가 같은 토픽을 구독한다.

camera 컴포넌트 publish(unique_ptr) intra-process: 포인터 전달 rmw 경로: 직렬화 → 전송 processor 컴포넌트 (같은 컨테이너) 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 벤더의 공유 메모리 전송을 쓴다.

복사 없음 프로세스 1 borrow_loaned_message 공유 메모리 세그먼트 에 직접 씀 프로세스 2 같은 메모리를 읽음

구현체:

방식설명
Fast DDS SHM 전송XML로 공유 메모리 전송 활성화. data-sharing delivery 지원(3.7)
Cyclone DDS + iceoryxiceoryx 기반 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 판단 절차 — 어떤 최적화를 쓸 것인가

예 아니오 예 예 아니오 예 아니오 아니오 통신 비용이 문제로 확인됨 메시지가 크고 빈도가 높은가? 최적화 불필요 다른 병목을 찾으라 같은 프로세스에 둘 수 있는가? 컴포지션 + intra-process + unique_ptr 발행 같은 호스트인가? 타입이 고정 크기 로 가능한가? 압축 · 다운샘플링 · 대역 설계 7.8 공유메모리 zero-copy + loaned message 타입 재설계 검토 또는 G

"같은 프로세스에 둘 수 있는가"에서 걸리는 이유들(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/300.053~0.065 ms약 31 ms
intra-process + const& 발행0/302.9~3.4 ms약 222 ms
intra-process 끔 + unique_ptr0/302.0~2.4 ms약 95 ms
intra-process + TRANSIENT_LOCAL30/303.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개3030복사 0
UniquePtr 1개3030복사 0
ConstSharedPtr 2개6060복사 0 — 둘이 같은 객체를 본다
UniquePtr 2개6030정확히 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배)다.

VOLATILETRANSIENT_LOCAL
구독자에게 복사00(동일)
평균 지연0.06 ms3.8 ms
CPU31 ms142 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 로 발행할 수 있는가? 묶어도 손해일 수 있다 먼저 발행 형태를 고쳐라 TRANSIENT_ LOCAL 이 정말 필요한가? 복사는 0이지만 지연·CPU 비용을 감수 구독자가 여럿이고 수정이 필요한가? N−1 복사 발생 ConstSharedPtr 로 바꿀 수 있나 검토 복사 0 · 최대 이득

이 장의 결론을 한 줄로: "컴포넌트로 묶으면 빠르다"가 아니라 "unique_ptr로 발행하며 묶으면 빠르다"이다.

2.7.10 함정 카탈로그

#함정증상대처
1한쪽만 intra-process 활성경로 미성립양쪽 모두 설정
2const& 발행복사 잔존unique_ptr 발행
3UniquePtr 구독자 여럿N−1 복사ConstSharedPtr로
4ConstSharedPtr를 const_cast로 수정다른 구독자 데이터 오염절대 금지
5밖에 구독자가 있는데 무복사 기대직렬화 잔존토픽 분리(2.7.6)
6echo·RViz2 실행한 채 측정결과 왜곡측정 시 제거
7depth를 작게 두고 "안 빠질 것" 가정프로세스 내 유실depth 설계(1.6.4)
8TRANSIENT_LOCAL이 동작할 것으로 가정늦은 구독자가 못 받음실험으로 확인
9zero-copy를 무한 배열 타입에미지원·fallback고정 크기 타입
10zero-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으로 해도 되는가.