AEGLOSLAB ROS 2 Jazzy 대전

제2부 실행 모델·클라이언트 라이브러리 내부

2.6 메모리 모델 — allocator·loaned message

2.6.1 이 장에서 배우는 것 / 선수 지식

2.5에서 지터의 원인 중 하나로 "할당·페이지 폴트"를 지목했다. 이 장은 그 지점을 분석한다. 동적 메모리 할당은 실시간 시스템의 주적이다 — malloc은 락을 획득할 수 있고, 힙이 파편화되면 시간이 길어지고, 최악의 경우 커널로 내려가 페이지를 요청한다. 그런데 ROS 2는 기본적으로 곳곳에서 할당한다.

이 장은 ① 어디서 할당이 일어나는지, ② 그것을 어떻게 통제하는지, ③ 미들웨어가 빌려주는 메모리(loaned message)로 할당을 원천적으로 없애는 법을 다룬다.

배우는 것:

  • 할당이 일어나는 지점 전수 — 메시지·큐·직렬화·executor 각각.
  • allocator 주입 — rclcpp의 allocator 지원과 실제 적용 범위.
  • TLSF 같은 실시간 allocator — 무엇을 보장하고 무엇을 못 하나.
  • loaned message — 미들웨어 버퍼에 직접 쓰는 방식과 그 조건.
  • 메시지 메모리 전략 — 구독 측 재사용.
  • 무할당 경로를 실제로 검증하는 법.

선수 지식: 1.2(유한 배열), 1.3(직렬화), 1.4(발행 경로), 2.5(지터). intra-process·zero-copy는 다음 장 2.7, 실시간 전반은 12.3에서 이어받는다.

정직: loaned message와 allocator 지원 범위는 RMW 벤더와 배포판에 크게 의존한다. "지원한다"고 알려진 기능도 조건(타입이 고정 크기여야 하는 등)이 붙으며, 조건 미충족 시 조용히 일반 경로로 되돌아간다(fallback). 아래 서술은 설계 문서·소스 구조 기준이며 이 환경에서 검증하지 않았다(목차 3). 실제 무할당 여부는 반드시 측정으로 확인해야 한다(2.6.7).

검증 상태(추가): 이 장의 서술 가운데 1곳은 이후 회차에서 Jazzy 실물과 대조해 확인했다 — 본문의 (실행 확인) 표기가 그것이다. 표기가 없는 서술은 위 고지가 그대로 적용된다(목차 3).

2.6.2 할당은 어디서 일어나는가

publish(msg) ① 메시지 객체 생성 make_unique · vector 필드 ② intra-process 큐 삽입 ③ 직렬화 버퍼 ④ DDS 히스토리 캐시 ⑤ 전송 버퍼 · 조각화 수신 ⑥ DDS 수신 버퍼 ⑦ 역직렬화 대상 메시지 ⑧ executor 내부 자료구조
#지점통제 가능성
①사용자 메시지 객체높음 — 재사용·풀·loaned
②intra-process 버퍼중간 — 크기 설정(2.7)
③직렬화 버퍼중간 — 벤더·allocator
④DDS 히스토리중간 — QoS depth·벤더 사전 할당
⑤전송 버퍼낮음 — 벤더 설정
⑥수신 버퍼낮음 — 벤더 설정
⑦역직렬화 대상높음 — 메모리 전략·loaned
⑧executor 자료구조중간 — 엔티티 고정 시 안정

핵심 관찰: 사용자가 직접 통제할 수 있는 것은 ①과 ⑦이고, 나머지는 벤더 설정 영역이다. 그래서 "무할당 경로"를 만들려면 두 가지를 모두 해야 한다 — 애플리케이션 코드의 할당 제거 그리고 미들웨어의 사전 할당 설정(3.7·3.8).

할당이 언제 실제로 문제인가:

상황문제인가
초기화 중❌ 아니다. 마음껏 할당해도 된다
저빈도 콜백(1~10 Hz)❌ 대체로 문제없다
고빈도 비실시간(100 Hz)△ 지터에 기여하나 치명적이지 않을 수 있다
실시간 제어 루프✅ 문제다
안전 관련 경로✅ 문제다

견해: "모든 할당을 없애자"는 대개 과잉이다. 비용이 큰 작업이며, 대부분의 ROS 2 노드는 실시간 요구가 없다. 실시간 요구가 명확한 경로에만 집중하고, 나머지는 평범하게 짜라. 무할당화를 시스템 전체에 적용하려다 코드 가독성과 개발 속도를 잃는 것이 흔한 실수다.

2.6.3 메시지 재사용 — 가장 값싼 개선

loaned message나 allocator에 변경하기 전에, 메시지 객체를 재사용하는 것만으로도 많은 할당이 사라진다.

class Publisher : public rclcpp::Node {
  Publisher() : Node("pub") {
    // 멤버로 한 번만 만들고 재사용
    msg_.data.resize(1024);       // 초기화 때 할당
    pub_ = create_publisher<MyMsg>("topic", 10);
    timer_ = create_wall_timer(10ms, [this]() {
      fill_data(msg_.data);       // 크기가 같으면 재할당 없음
      pub_->publish(msg_);        // const& — 라이브러리가 복사
    });
  }
  MyMsg msg_;
};

주의: const& 발행은 라이브러리 내부 복사가 일어난다(1.4.5). 재사용과 무복사는 서로 상충한다.

목표방법대가
사용자 코드 할당 제거멤버 메시지 재사용 + const& 발행라이브러리 복사 1회
복사 제거unique_ptr 발행매번 새 객체 → 할당
둘 다loaned message(2.6.5) 또는 메시지 풀조건·복잡도

메시지 풀 패턴:

// 미리 만들어 둔 메시지를 재사용한다
class MessagePool {
public:
  explicit MessagePool(size_t n, size_t data_size) {
    for (size_t i = 0; i < n; ++i) {
      auto m = std::make_unique<MyMsg>();
      m->data.resize(data_size);
      free_.push(std::move(m));
    }
  }
  std::unique_ptr<MyMsg> acquire() {            // 실시간 경로: 할당 없음
    if (free_.empty()) return nullptr;          // 고갈 시 nullptr — 할당하지 않는다
    auto m = std::move(free_.front()); free_.pop();
    return m;
  }
  void release(std::unique_ptr<MyMsg> m) { free_.push(std::move(m)); }
private:
  std::queue<std::unique_ptr<MyMsg>> free_;     // (실전에서는 락프리 구조 권장)
};

함정: 풀에서 꺼낸 메시지를 unique_ptr로 발행하면 소유권이 넘어가 돌아오지 않는다. 풀로 되돌리려면 커스텀 deleter를 쓰거나, intra-process 구독자가 처리 후 반환하는 규약이 필요하다. 이 복잡도가 loaned message가 존재하는 이유이기도 하다.

2.6.4 allocator 주입

rclcpp는 표준 C++ allocator를 받아들이는 인터페이스를 제공한다.

using Alloc = MyCustomAllocator<void>;

auto alloc = std::make_shared<Alloc>();

rclcpp::PublisherOptionsWithAllocator<Alloc> pub_options;
pub_options.allocator = alloc;
auto pub = node->create_publisher<MyMsg, Alloc>("topic", 10, pub_options);

rclcpp::SubscriptionOptionsWithAllocator<Alloc> sub_options;
sub_options.allocator = alloc;
auto sub = node->create_subscription<MyMsg, Alloc>(
    "topic", 10, callback, sub_options);

// executor에도
rclcpp::ExecutorOptions exec_options;
exec_options.memory_strategy =
    rclcpp::memory_strategies::allocator_memory_strategy::
        AllocatorMemoryStrategy<Alloc>::make_shared(alloc);
rclcpp::executors::SingleThreadedExecutor exec(exec_options);

실시간 allocator의 대표는 TLSF(Two-Level Segregated Fit) 다 — 상수 시간 $O(1)$ 할당·해제를 보장하도록 설계된 알고리즘이며, 파편화도 제한된다.

$$t_{\text{alloc}} = O(1) \quad \text{(일반 malloc의 최악은 훨씬 나쁠 수 있다)}$$

정직: ROS 2에서 TLSF allocator 패키지의 제공 여부·유지 상태는 배포판에 따라 다르다(확인 필요). 다만 "기본 구성에서 할당이 실제로 몇 번 일어나는가"는 측정했다 — 지속 executor 기준 회당 7~9회이고, rclcpp::spin_some(node) 자유 함수는 143회다(실행 확인, 12.3.12). 커스텀 할당자를 검토하기 전에 그 숫자부터 보라. Jazzy에서 사용하려면 현재 rosdistro와 저장소 상태를 먼저 확인해야 한다. 없다면 직접 구현하거나 다른 실시간 allocator를 쓰게 된다.

allocator의 한계 — 이것이 중요하다:

한계설명
적용 범위가 제한적rclcpp 계층에만 적용된다. DDS 벤더의 내부 할당은 이 allocator를 쓰지 않는다
메시지 필드 할당메시지 안의 std::vector 등은 별도 allocator 지정이 필요할 수 있다
검증이 어렵다"정말 할당이 없는가"를 확인하려면 별도 측정이 필요(2.6.7)
코드 복잡도템플릿 인자가 코드 전반에 전파된다

따라서 allocator만으로는 무할당 경로가 완성되지 않는다. 벤더 쪽 사전 할당 설정(Fast DDS의 메모리 정책, Cyclone의 설정 등)이 함께 필요하다(3.7·3.8).

2.6.5 loaned message — 미들웨어 버퍼에 직접 쓰기

가장 근본적인 접근이다. 메시지를 만들어 미들웨어에 주는 대신, 미들웨어가 가진 버퍼를 빌려 거기에 직접 쓴다.

// 발행 측
auto loaned = pub_->borrow_loaned_message();
if (loaned.is_valid()) {
  auto & msg = loaned.get();
  msg.linear_acceleration.x = ax;
  msg.linear_acceleration.y = ay;
  msg.linear_acceleration.z = az;
  pub_->publish(std::move(loaned));      // 소유권을 미들웨어에 반환
} else {
  // 지원하지 않는 경우 — 일반 경로로 fallback
  MyMsg msg;
  fill(msg);
  pub_->publish(msg);
}
일반 경로 loaned 경로 사용자 힙에 메시지 생성 직렬화 → 미들웨어 버퍼로 복사 전송 미들웨어 버퍼를 빌림 그 자리에 직접 씀 전송 (복사 없음)

이득:

이득설명
할당 없음미들웨어가 미리 할당해 둔 풀에서 나온다
복사 없음사용자 버퍼 → 미들웨어 버퍼 복사가 사라진다
공유 메모리와 결합같은 호스트의 다른 프로세스가 그 메모리를 그대로 읽을 수 있다(2.7)

조건 — 이것이 까다롭다:

조건설명
RMW 지원벤더가 loan API를 구현해야 한다
고정 크기 타입가변 길이 필드(무한 배열·문자열)가 있으면 대개 불가(확인 필요)
설정공유 메모리 전송 활성화 등이 필요할 수 있다

"고정 크기 타입" 요구가 1.2.3의 유한 배열 논의와 연결된다. 메시지를 처음부터 고정·유한 크기로 설계했다면 이 경로가 열리고, 무한 배열을 하나라도 쓰면 닫힌다. 인터페이스 설계가 성능 옵션을 결정한다는 것이 이 대목의 교훈이다.

함정: loan이 지원되지 않으면 borrow_loaned_message()가 조용히 일반 힙 메시지를 준다(확인 필요: 구현별 동작). 코드는 정상 동작하지만 이득은 없다. "loaned를 썼으니 무할당"이라고 가정하지 말고 측정하라.

실행 확인(2026-08-16): 기본 구성에서는 지원되지 않는다. Jazzy 기본 RMW(rmw_fastrtps_cpp)에 추가 설정 없이 std_msgs/msg/String 발행자를 만들어 물어보니 can_loan_messages()가 false 였다. 즉 아무것도 설정하지 않은 상태에서 borrow_loaned_message()를 쓰면 위 함정에 그대로 걸린다. 위 표의 세 조건(RMW 지원·고정 크기 타입·공유 메모리 설정) 중 무엇이 결정적이었는지는 이 실험으로 가르지 못했다 — String은 가변 길이 필드를 가지므로 타입 조건만으로도 이미 불가다(확인 필요). 고정 크기 타입 + 공유 메모리 설정을 갖춘 조합에서 다시 측정해 보아야 이 경로의 실제 이득을 말할 수 있다.

// 지원 여부 확인
if (pub_->can_loan_messages()) {
  RCLCPP_INFO(get_logger(), "loaned message 지원됨");
} else {
  RCLCPP_WARN(get_logger(), "loaned message 미지원 — 일반 경로 사용");
}

이 확인을 초기화 로그에 남기는 것을 권한다. 벤더나 설정이 바뀌었을 때 즉시 드러난다.

구독 측:

구독에서도 loaned 형태로 받을 수 있으나, 지원 범위와 API가 발행 측보다 제한적일 수 있다(확인 필요). 공유 메모리 경로에서는 수신 측이 그 메모리를 직접 읽으므로 복사 없이 처리가 가능하다 — 대신 읽는 동안 그 버퍼를 점유하고 있으므로 오래 들고 있으면 발행 측 풀이 고갈된다.

2.6.6 메시지 메모리 전략(구독 측)

구독 측에서 매 메시지마다 새 객체를 만드는 대신 재사용할 수 있다.

// 개념: 미리 만든 메시지 풀에서 꺼내 역직렬화 대상으로 쓴다
rclcpp::SubscriptionOptions options;
// 주의: Jazzy 의 SubscriptionOptions 에는 message_memory_strategy 필드가 없다 (88차 실행 확인).
// MessageMemoryStrategyT 는 create_subscription 의 템플릿 인자이고,
// 전략 객체는 별도 인자(msg_mem_strat)로 넘긴다 — 기본은 create_default().

정직: MessageMemoryStrategy의 API와 권장 사용법은 배포판에 따라 변해 왔다(확인 필요). 개념은 명확하다 — 역직렬화 대상 메시지를 풀에서 재사용해 할당을 없앤다. 구체 사용법은 현재 rclcpp 헤더를 확인해야 한다.

대안이자 더 단순한 접근: 구독 콜백에서 받은 메시지를 즉시 처리하고 참조를 남기지 않으면, 라이브러리가 내부적으로 버퍼를 재사용할 여지가 커진다. 반대로 콜백에서 shared_ptr를 멤버에 저장해 오래 보유하면 재사용이 불가능해져 매번 새 할당이 필요하다.

// ❌ 메시지를 오래 보유한다 — 버퍼 재사용 불가
void on_scan(LaserScan::ConstSharedPtr msg) { last_scan_ = msg; }

// ✅ 필요한 것만 복사하고 놓아준다
void on_scan(LaserScan::ConstSharedPtr msg) {
  last_ranges_.assign(msg->ranges.begin(), msg->ranges.end());  // 크기 같으면 재할당 없음
  last_stamp_ = msg->header.stamp;
}

견해: "메시지를 오래 보유하지 않는다"는 규칙은 실시간 여부와 무관하게 좋은 습관이다. intra-process에서는 다른 구독자와 공유하는 객체일 수 있고(1.4.5), 보유하고 있으면 발행 측 큐가 회전하지 못한다.

2.6.7 무할당을 실제로 검증하는 법

설정했다고 믿지 말고 측정하라. 이것이 이 장에서 가장 실무적인 절이다.

① malloc 후킹 — 가장 직접적

// 실시간 구간에서 할당이 일어나면 즉시 알린다
static std::atomic<bool> in_rt_section{false};
static std::atomic<uint64_t> rt_alloc_count{0};

extern "C" void * malloc(size_t size) {
  static auto real_malloc = 
      reinterpret_cast<void*(*)(size_t)>(dlsym(RTLD_NEXT, "malloc"));
  if (in_rt_section.load(std::memory_order_relaxed)) {
    rt_alloc_count.fetch_add(1, std::memory_order_relaxed);
    // 주의: 여기서 로그를 출력하면 그 자체가 할당을 유발할 수 있다
  }
  return real_malloc(size);
}

실시간 테스트 라이브러리들이 이 방식의 도구를 제공한다 — Jazzy에서는 realtime_tools(ros-jazzy-realtime-tools, 88차 실행 확인 · 1.11.7). ros-base에는 없다.

② 페이지 폴트 카운터 — 간접적이지만 쉽다

# 메이저·마이너 페이지 폴트
ps -o min_flt,maj_flt -p $(pgrep -f my_node)
# 실행 중 증가를 관찰
watch -n 1 'ps -o min_flt,maj_flt -p $(pgrep -f my_node)'

정상 상태에서 페이지 폴트가 계속 증가하면 새 메모리를 계속 점유하고 있다는 뜻이다. 워밍업 후에는 증가가 멈춰야 한다.

③ 힙 사용량 추이

# RSS가 시간에 따라 계속 증가하면 누수 또는 지속적 할당
watch -n 5 'ps -o rss,vsz -p $(pgrep -f my_node)'

④ 지연 분포로 간접 확인 — 2.5.6의 백분위수 측정. 할당이 있으면 p99.9와 max가 급증한다. 무할당화 전후의 분포 비교가 가장 실질적인 증거다.

견해: ④가 실무에서 가장 유용하다. "할당이 몇 번 있었나"보다 "지연 분포가 개선됐나"가 실제로 중요한 질문이며, 측정도 쉽다. 무할당화 작업의 성과는 max·p99.9로 보고하라.

2.6.8 무할당 경로 구축 순서

실시간 요구 식별 1. 메시지를 고정·유한 크기로 1.2.3 2. 초기화에서 모든 자원 확보 3. 콜백에서 할당 제거 재사용 · 풀 4. 로깅 제거 1.11.7 5. mlockall + 워밍업 12.3 6. loaned message 검토 7. 벤더 사전 할당 설정 3.7 · 3.8 8. 측정 · 검증 2.6.7

순서가 중요하다. 1~5는 애플리케이션 코드만으로 가능하고 효과가 크다. 6~7은 벤더 의존이며 조건이 까다롭다. 1~5를 먼저 하고 측정한 뒤, 부족하면 6~7로 간다. 반대로 하면 복잡한 설정을 먼저 조정하고도 코드 안의 std::string 하나가 모든 노력을 무효화하는 상황이 된다.

5번의 워밍업이 자주 누락된다:

void warm_up() {
  mlockall(MCL_CURRENT | MCL_FUTURE);        // 스왑 방지
  // 스택 미리 터치
  volatile char stack[64 * 1024];
  memset(const_cast<char*>(stack), 0, sizeof(stack));
  // 실제 경로를 여러 번 실행해 캐시·힙을 데운다
  for (int i = 0; i < 1000; ++i) control_step_dry_run();
}

2.6.9 함정 카탈로그

#함정증상대처
1모든 코드를 무할당화 시도개발 속도·가독성 손실실시간 경로에만
2allocator만 설정하고 안심벤더 내부 할당은 그대로벤더 설정 병행
3loaned 지원 여부 미확인조용히 fallbackcan_loan_messages() 로그
4무한 배열 타입에 loaned 기대미지원고정·유한 타입(1.2.3)
5메시지를 멤버에 오래 저장버퍼 재사용 불가·공유 위험필요한 것만 복사
6풀 고갈 시 할당으로 fallback최악의 순간에 지터고갈 시 폐기·경보
7측정 코드가 할당측정이 대상 방해사전 할당(2.5.6)
8워밍업 생략초기 수 주기에 큰 지연mlockall + dry run
9실시간 경로에 로깅할당·락카운터 방식(1.11.7)
10std::string 사용숨은 할당고정 버퍼·정수 ID
11설정만 하고 검증 안 함효과 불명지연 분포 비교
12순서 없이 6·7부터복잡도만 증가2.6.8의 순서

2.6.10 체크포인트

  • 발행·수신 경로에서 할당이 일어나는 여덟 지점을 말하고, 그중 사용자가 통제 가능한 것이 무엇인지 구분할 수 있는가.
  • 할당이 실제로 문제가 되는 상황과 그렇지 않은 상황을 구분할 수 있는가.
  • 메시지 재사용과 무복사 발행이 상충하는 이유는? 둘 다 얻으려면 무엇이 필요한가.
  • rclcpp의 allocator 주입이 적용되지 않는 영역은 어디인가. 그래서 무엇을 함께 해야 하는가.
  • TLSF 같은 실시간 allocator가 보장하는 것을 수식으로 표현할 수 있는가.
  • loaned message의 이득 세 가지와 조건 세 가지를 말할 수 있는가.
  • "고정 크기 타입" 요구가 인터페이스 설계와 어떻게 연결되는가(1.2.3).
  • loan이 지원되지 않을 때 무슨 일이 일어나는가. 어떻게 확인하는가.
  • 구독 콜백에서 메시지를 오래 보유하면 안 되는 이유를 두 가지 이상 들 수 있는가.
  • 무할당 여부를 검증하는 네 가지 방법과, 실무에서 가장 유용한 것은 무엇인가.
  • 무할당 경로 구축의 여덟 단계를 순서대로 말하고, 왜 그 순서인지 설명할 수 있는가.
  • 워밍업이 하는 일 세 가지는 무엇인가.

다음 장 2.7은 이 장의 loaned message와 맞닿은 주제 — intra-process 통신과 zero-copy를 정확한 조건과 함께 다룬다. 1.3·1.4·1.13에서 반복 언급했던 "복사 0"의 실체가 여기서 확정된다.