제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 할당은 어디서 일어나는가
| # | 지점 | 통제 가능성 |
|---|---|---|
| ① | 사용자 메시지 객체 | 높음 — 재사용·풀·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);
}
이득:
| 이득 | 설명 |
|---|---|
| 할당 없음 | 미들웨어가 미리 할당해 둔 풀에서 나온다 |
| 복사 없음 | 사용자 버퍼 → 미들웨어 버퍼 복사가 사라진다 |
| 공유 메모리와 결합 | 같은 호스트의 다른 프로세스가 그 메모리를 그대로 읽을 수 있다(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~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 | 모든 코드를 무할당화 시도 | 개발 속도·가독성 손실 | 실시간 경로에만 |
| 2 | allocator만 설정하고 안심 | 벤더 내부 할당은 그대로 | 벤더 설정 병행 |
| 3 | loaned 지원 여부 미확인 | 조용히 fallback | can_loan_messages() 로그 |
| 4 | 무한 배열 타입에 loaned 기대 | 미지원 | 고정·유한 타입(1.2.3) |
| 5 | 메시지를 멤버에 오래 저장 | 버퍼 재사용 불가·공유 위험 | 필요한 것만 복사 |
| 6 | 풀 고갈 시 할당으로 fallback | 최악의 순간에 지터 | 고갈 시 폐기·경보 |
| 7 | 측정 코드가 할당 | 측정이 대상 방해 | 사전 할당(2.5.6) |
| 8 | 워밍업 생략 | 초기 수 주기에 큰 지연 | mlockall + dry run |
| 9 | 실시간 경로에 로깅 | 할당·락 | 카운터 방식(1.11.7) |
| 10 | std::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"의 실체가 여기서 확정된다.