본문으로 건너뛰기
Embedded C++ for Real Systems · 28/41

임베디드 C++ 소유권 모델 — single·shared·borrow 패턴

· Hawk · 5분 읽기

#한 줄 요약

“객체마다 owner는 한 명이고 나머지는 non-owning 참조입니다.” 책임을 명시적으로 할당하면 메모리 관련 버그의 대부분을 막을 수 있습니다.

#어떤 문제를 푸는가

C++의 메모리 안전 문제는 거의 모두 소유권의 모호함에서 출발합니다.

  • Use after free — 소유자가 사라진 뒤에 누가 사용했는지 분명하지 않습니다.
  • Double free — 두 owner가 각자 delete를 호출합니다.
  • Memory leak — owner가 미정이라 delete를 누가 할지 모릅니다.
  • Dangling pointer — owner는 소멸했는데 참조가 살아 있습니다.

해결책은 명시적인 소유권 모델입니다. 각 객체에 정확히 한 owner를 두고 나머지는 non-owning 참조로 다룹니다.

Rust는 이를 컴파일러 차원에서 강제하지만, C++는 프로그래머의 규율에 맡깁니다.

#세 역할 — Owner, Observer, Borrower

C++ 스마트 포인터의 세 가지 소유권 모델을 그림으로 보면 차이가 분명합니다. unique_ptr은 단일 소유자가 자원을 직접 가리키고, shared_ptr은 control block을 매개로 여러 소유자가 공유합니다. weak_ptr은 refcount에 영향을 주지 않고 약하게 참조만 합니다.

unique/shared/weak 소유권 모델

#Owner — 객체 lifetime 관리

class Device {
std::unique_ptr<Driver> driver_; // owner — lifetime 책임
public:
Device() : driver_(std::make_unique<Driver>()) {}
// 자동 delete on destruction
};

unique_ptr이 owner임을 명시합니다. Device가 사라지면 Driver도 함께 사라집니다.

#Observer — 참조만, 관여 없음

class Logger {
public:
void register_sink(Sink* sink) { // observer — non-owning
sink_ = sink;
}
void write(const char* msg) {
if (sink_) sink_->emit(msg);
}
private:
Sink* sink_ = nullptr; // non-owning
};
Sink sink;
Logger logger;
logger.register_sink(&sink); // logger는 sink 안 소유

LoggerSink를 관찰만 하고 lifetime은 외부에서 관리합니다.

Observer는 다음과 같이 표현합니다.

  • T* — nullable, non-owning 의도입니다.
  • T& — non-null, non-owning입니다.
  • std::span<T> — array와 size를 함께 non-owning으로 다룹니다.
  • std::string_view — string을 non-owning으로 가리킵니다.

#Borrower — 일시적 사용

void process(const Data& data) { // borrower
// data를 잠시 사용
// 함수 끝나면 빌림 끝
}

함수 매개변수가 전형적인 borrower이며 함수 scope 동안만 유효합니다.

C++에서 borrowing은 다음과 같이 표현합니다.

  • const T& — read-only 빌림
  • T& — mutable 빌림
  • T* — nullable 빌림
  • std::span<T>, std::string_view

#결정 — 어느 모델

이 함수/객체가 ...
1. 이 객체를 소유해야 하나?
YES → unique_ptr (단일) 또는 shared_ptr (공유, 드물게)
2. 객체 lifetime이 외부에서 관리되나?
YES → 매개변수: const T& 또는 T*
멤버: T*
3. 함수 호출 동안만 필요하나?
YES → 매개변수 (const T&, T*, std::span, std::string_view)

#임베디드 패턴 — Device class

class Display {
public:
Display(Bus& bus) : bus_(bus) {} // borrower — bus는 외부 소유
void clear() { bus_.write(CLEAR_CMD); }
private:
Bus& bus_; // non-owning reference
};
class System {
Bus bus_; // owner
Display display_; // owner (display는 bus borrow)
public:
System() : display_(bus_) {} // bus_ 참조 전달
};

System이 bus와 display를 모두 소유하고 Display는 bus를 빌립니다. cyclic ownership이 발생하지 않습니다.

#함수 매개변수 — 결정

사용 의도매개변수 형
객체 소유권 이전T (by value) or unique_ptr<T>
객체 복사T (by value) — 작은 객체
읽기 전용 참조const T&
변경 가능 참조T&
nullable 참조const T* 또는 T*
array 참조std::span<T> (C++20)
string 참조std::string_view (C++17)
새 객체 반환T 반환 (RVO) 또는 unique_ptr<T>
// 소유권 이전
void take_ownership(std::unique_ptr<Resource> r);
// 작은 객체 복사
int compute(int a, int b);
// 큰 객체 read-only 빌림
void print(const HugeData& data);
// 변경 가능 빌림
void modify(Counter& c);
// nullable
void with_optional_logger(Logger* logger = nullptr);
// array
void process(std::span<const uint8_t> data);
// string
bool match(std::string_view pattern, std::string_view text);

#멤버 변수 — 결정

의도멤버 형
객체 ownerT 또는 unique_ptr<T>
Optional ownerunique_ptr<T> (nullable)
Optional 값std::optional<T> (heap 없음)
Non-owning refT* (nullable) 또는 T& (non-null)
다중 owner (드물게)shared_ptr<T>
외부 owner의 weak refweak_ptr<T>
class Service {
Database db_; // owner, value
std::unique_ptr<Logger> logger_; // owner, optional (nullable)
std::optional<Config> config_; // optional value (heap 0)
Bus* bus_; // non-owning, nullable
EventLoop& loop_; // non-owning, non-null (참조 멤버)
};

#임베디드 — RTOS task 간 소유권

multi-task RTOS에서는 객체 공유가 복잡해집니다. 자주 쓰는 패턴은 다음과 같습니다.

#1. Message passing — 소유권 이전

struct Event {
int type;
uint32_t data;
};
QueueHandle_t event_queue;
void producer() {
Event e{1, 42};
xQueueSend(event_queue, &e, portMAX_DELAY); // copy
}
void consumer() {
Event e;
if (xQueueReceive(event_queue, &e, portMAX_DELAY)) {
process(e);
}
}

copy로 소유권을 이전하므로 두 task는 공유 메모리를 갖지 않고 race도 발생하지 않습니다.

#2. Pool + handle

ObjectPool<LargeMessage, 16> pool;
QueueHandle_t handle_queue;
void producer() {
auto* msg = pool.allocate();
msg->fill(...);
xQueueSend(handle_queue, &msg, portMAX_DELAY); // pointer pass
}
void consumer() {
LargeMessage* msg;
if (xQueueReceive(handle_queue, &msg, portMAX_DELAY)) {
process(*msg);
pool.deallocate(msg); // 소유권 명시적 해제
}
}

큰 객체는 copy 비용이 크므로 pool과 pointer로 전달합니다. 소유권은 producer에서 consumer로 명확히 이동합니다.

#3. Shared state with mutex

struct SystemState {
int counter;
float temperature;
};
SystemState g_state; // shared
SemaphoreHandle_t state_mutex;
void update(int c, float t) {
xSemaphoreTake(state_mutex, portMAX_DELAY);
g_state.counter = c;
g_state.temperature = t;
xSemaphoreGive(state_mutex);
}
void read(SystemState& out) {
xSemaphoreTake(state_mutex, portMAX_DELAY);
out = g_state;
xSemaphoreGive(state_mutex);
}

공유 객체에 lock을 걸어 보호합니다. 소유권은 시스템 전체에 속하며 각 task는 non-owning ref만 사용합니다.

#임베디드 — Callback 소유권

callback의 소유권이 모호하면 버그가 자주 발생합니다.

// Bad — capture가 dangling
void setup() {
LocalData data;
register_callback([&data](int e) {
data.process(e); // 함수 끝 후 data 사라짐 — dangling
});
}
// Good — capture by value
void setup() {
LocalData data;
register_callback([data](int e) {
// data는 lambda 내부 owner
});
}
// Better — 명시적 owner
class Handler {
LocalData data_;
public:
void on_event(int e) { data_.process(e); }
};
Handler g_handler; // 명시적 owner
void setup() {
register_callback([](int e) { g_handler.on_event(e); });
}

lambda를 reference로 capture하면 함수 scope가 끝나는 순간 dangling이 됩니다. value capture로 잡거나 외부 owner를 둡니다.

#C++ Core Guidelines

ISO C++ Core Guidelines의 소유권 권장은 다음과 같습니다.

  • unique_ptr<T> — exclusive owner
  • shared_ptr<T> — shared owner (필요할 때만)
  • gsl::owner<T*> — raw pointer지만 owning 의도를 명시
  • T* (gsl::owner 없음) — non-owning
  • T& — non-owning, non-null
#include <gsl/pointers>
void take_owner(gsl::owner<int*> p); // 의도: 소유권 받음
void observe(int* p); // 의도: 관찰만
void use(int& x); // 의도: 빌림

gsl::owner는 static analysis 도구에 힌트를 주며, 임베디드 인증 환경에서 유용합니다.

#자주 보는 함정과 안티패턴

#1. Owner 불명확

class A { B* b; }; // A가 owner? 그냥 reference?

unique_ptr<B>로 명시하거나 주석으로 표시합니다.

#2. Cyclic ownership

struct A { shared_ptr<B> b; };
struct B { shared_ptr<A> a; }; // cycle — 영원히 안 해제

한쪽을 weak_ptr로 두거나 구조를 재설계합니다.

#3. Local owner를 외부에 노출

void setup() {
auto resource = std::make_unique<Resource>();
register_handler([&resource](int e) { /* */ }); // dangling
}

owner를 외부 객체로 옮깁니다.

#4. Reference 멤버의 함정

class A {
B& b_; // reference 멤버
public:
A(B& b) : b_(b) {}
// A를 어떻게 copy/move? b_ 못 재바인딩
};

reference 멤버는 immutable이라 copy/move가 어렵습니다. pointer 멤버를 검토합니다.

#5. Borrowed 객체 저장

void process(const Data& data) {
saved_ = &data; // data는 borrow — caller가 사라지면 dangling
}

저장하려는 의도라면 copy하거나 shared_ptr을 사용합니다.

#6. void* 사용

void register_callback(void* ctx, void (*cb)(void*));

type 정보가 사라집니다. C 인터페이스에서만 쓰고, C++에서는 template이나 std::function을 활용합니다.

#측정 — 소유권 명시의 효과

같은 모듈을 명시적 소유권 버전과 raw pointer 버전으로 비교합니다.

# Raw pointer + new/delete
모듈 크기: 4.2 KB
메모리 leak (1주일 stress): 3-5건/일
use-after-free: 1-2건/주
# unique_ptr 명시적 owner
모듈 크기: 4.3 KB (+100 B)
메모리 leak: 0
use-after-free: 0

100 B 추가만으로 버그가 완전히 사라집니다. 모범 사례로 자리잡은 이유입니다.

#정리

  • 객체마다 owner는 한 명이고 나머지는 non-owning reference로 다룹니다.
  • 도구는 owner로 unique_ptr, non-owning으로 T*/T&, 드물게 shared_ptr을 씁니다.
  • 함수 매개변수는 의도에 맞게 value, const&, &, pointer, span, string_view를 선택합니다.
  • 멤버 변수는 value, unique_ptr, optional, pointer 중에서 고릅니다.
  • RTOS task 간에는 message passing (copy)이나 pool + handle로 전달합니다.
  • Lambda를 reference로 capture하면 dangling 위험이 있으므로 value capture나 외부 owner를 씁니다.

#관련 항목

#다음 글 (Part 4 시작)

Part 4-01: Intrusive Containers — 동적 할당 없는 linked list로, 객체 자체가 next pointer를 보유합니다.

Embedded C++ for Real Systems · 29 of 41

  1. 1Embedded C++ for Real Systems — 임베디드 모던 C++ 시리즈 소개
  2. 2임베디드 C++ vs C — 런타임·코드 크기·ABI 관점 비교
  3. 3임베디드 C++ 컴파일러 플래그 분석 — -fno-rtti·-fno-exceptions·-Os
  4. 4임베디드 C++ 런타임 요구사항 — libstdc++·newlib·crt0 분석
  5. 5C++ 코드 크기 분석 — 가상 함수·템플릿·예외 비용 추적
  6. 6C++ ABI 호환성 — Itanium ABI·name mangling·vtable 레이아웃
  7. 7C++ 스타트업 코드 분석 — .init_array·전역 생성자 호출 순서
  8. 8임베디드 C++ 링커 스크립트 — vtable·정적 객체 배치 추적
  9. 9임베디드 C++ 표준 선택 가이드 — C++11/14/17/20/23 트레이드오프
  10. 10임베디드 RAII 기초 — 리소스 안전성과 결정적 소멸 보장
  11. 11임베디드 RAII 실전 패턴 — Lock·Pin·DMA·Power 관리
  12. 12constexpr 기초와 임베디드 적용 — 컴파일 타임 계산 활용
  13. 13constexpr 고급 활용 — 룩업 테이블·CRC·해시 컴파일 타임 생성
  14. 14consteval과 constinit 분석 — C++20 컴파일 타임 강제 메커니즘
  15. 15임베디드 Templates 기초 — 타입 안전과 코드 재사용 분석
  16. 16Template 비용 분석 — 코드 폭증·인스턴스화·디버그 정보 측정
  17. 17CRTP 패턴 분석 — vtable 없는 정적 다형성
  18. 18Type Traits 임베디드 활용 — SFINAE·is_pod·컴파일 타임 검사
  19. 19C++20 Concepts 활용 — 템플릿 제약과 가독성 개선
  20. 20동적 할당 없는 임베디드 C++ — placement new·정적 객체·풀
  21. 21Custom Allocator 기초 — std::allocator 인터페이스 분석
  22. 22Pool Allocator 구현 — Fixed-Size Block과 O(1) 보장
  23. 23std::pmr 임베디드 활용 — Polymorphic Memory Resource 분석
  24. 24No-Exception C++ 설계 — 코드 크기·결정성 트레이드오프
  25. 25임베디드 에러 처리 패턴 — Result·errno·optional 비교
  26. 26std::expected 분석 — C++23 결과 타입과 에러 전파
  27. 27No-RTTI C++ 설계 — dynamic_cast 제거와 정적 타입 분기
  28. 28임베디드 스마트 포인터 선택 — unique·shared·custom 비교
  29. 29임베디드 C++ 소유권 모델 — single·shared·borrow 패턴
  30. 30Intrusive Containers 분석 — 동적 할당 없는 컨테이너 설계
  31. 31ETL 라이브러리 분석 — Embedded Template Library의 STL 대체
  32. 32임베디드 Lock-free 기초 — atomic·memory ordering·CAS
  33. 33Lock-free Container 구현 — SPSC Queue·Ring Buffer
  34. 34Type-safe Flags 패턴 — Enum Class·Strong Typedef·Tag
  35. 35임베디드 State Machine 패턴 — Variant·Visitor·Table-driven 비교
  36. 36Compile-time FSM 구현 — 템플릿으로 상태 전이 검증
  37. 37Singleton 대안 패턴 — Service Locator·Static Init·Phantom
  38. 38MMIO Register 추상화 — 타입 안전한 비트 필드 접근
  39. 39GPIO 추상화 패턴 — Template·Concept으로 보드 독립성
  40. 40Peripheral 추상화 — UART·SPI·I2C 공통 인터페이스 설계
  41. 41임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교