C++ EBCO와 [[no_unique_address]] | "빈 베이스 최적화" 완벽 가이드

C++ EBCO와 [[no_unique_address]] | "빈 베이스 최적화" 완벽 가이드

이 글의 핵심

빈 클래스가 메모리를 차지하는 문제를 해결하는 EBCO와 C++20 [[no_unique_address]]. std::tuple, std::unique_ptr 구현 비밀, 메모리 레이아웃 최적화, 실전 패턴까지.

들어가며: “빈 클래스인데 왜 1바이트를 차지하나요?"

"std::unique_ptr<T, Deleter>가 왜 8바이트가 아니라 16바이트죠?”

C++에서 빈 클래스(Empty Class—멤버 변수가 없는 클래스)도 최소 1바이트를 차지합니다. 이는 각 객체가 고유한 주소를 가져야 하기 때문입니다. 하지만 이 규칙 때문에 상태 없는 함수 객체(Stateless Functor)나 커스텀 삭제자(Custom Deleter)를 멤버로 가질 때 불필요한 메모리를 낭비하게 됩니다.

EBCO(Empty Base Class Optimization—빈 베이스 클래스 최적화)는 빈 클래스를 베이스로 상속받으면 크기가 0이 되는 컴파일러 최적화입니다. C++20의 [[no_unique_address]] 속성은 멤버 변수에도 이 최적화를 적용할 수 있게 해줍니다.

이 글에서 다루는 것:

  • 빈 클래스 규칙: 왜 1바이트를 차지하는가
  • EBCO 원리: 베이스 클래스로 상속 시 크기 0
  • [[no_unique_address]]: C++20 멤버 변수 최적화
  • 실전 활용: std::tuple, std::unique_ptr, 압축 쌍(compressed pair)
  • 문제 시나리오: 메모리 레이아웃 최적화가 필요한 경우
  • 완전한 예제: 커스텀 스마트 포인터, 압축 쌍 구현
  • 일반적인 에러: EBCO 실패 케이스, ABI 호환성
  • 프로덕션 패턴: 표준 라이브러리 구현 기법

실무에서 겪은 문제

실제 프로젝트에서 이 개념을 적용하며 겪었던 경험을 공유합니다.

문제 상황과 해결

대규모 C++ 프로젝트를 진행하며 이 패턴/기법의 중요성을 체감했습니다. 책에서 배운 이론과 실제 코드는 많이 달랐습니다.

실전 경험:

  • 문제: 처음에는 이 개념을 제대로 이해하지 못해 비효율적인 코드를 작성했습니다
  • 해결: 코드 리뷰와 프로파일링을 통해 문제를 발견하고 개선했습니다
  • 교훈: 이론만으로는 부족하고, 실제로 부딪혀보며 배워야 합니다

이 글이 여러분의 시행착오를 줄여주길 바랍니다.


목차

  1. 문제 시나리오: 빈 클래스가 메모리를 낭비할 때
  2. 빈 클래스 규칙과 EBCO
  3. EBCO 동작 원리
  4. C++20 [[no_unique_address]]
  5. 완전한 실전 예제
  6. 자주 발생하는 에러와 해결법
  7. 표준 라이브러리 구현 사례
  8. 프로덕션 패턴
  9. 정리

1. 문제 시나리오: 빈 클래스가 메모리를 낭비할 때

시나리오 1: “unique_ptr에 커스텀 삭제자를 넣었더니 크기가 2배가 됐어요”

"std::unique_ptr<int>는 8바이트인데, 커스텀 삭제자를 넣으니 16바이트가 됐어요."
"삭제자는 빈 클래스인데 왜 메모리를 차지하나요?"

원인: std::unique_ptr<T, Deleter>는 내부적으로 T* ptrDeleter del을 멤버로 가집니다. Deleter가 빈 클래스여도 최소 1바이트를 차지하고, 정렬(alignment) 때문에 8바이트로 패딩됩니다.

// ❌ 나이브한 구현
template <typename T, typename Deleter = std::default_delete<T>>
struct NaiveUniquePtr {
    T* ptr;          // 8바이트
    Deleter deleter; // 빈 클래스여도 1바이트 + 패딩 → 8바이트
    // 총 16바이트
};

struct EmptyDeleter {
    void operator()(int* p) const { delete p; }
};

static_assert(sizeof(NaiveUniquePtr<int, EmptyDeleter>) == 16);

시나리오 2: “std::tuple에 빈 타입이 섞여 있는데 크기가 커요”

"std::tuple<int, EmptyTag, double>이 24바이트예요."
"EmptyTag는 상태가 없는데 왜 공간을 차지하나요?"

원인: 각 멤버가 고유한 주소를 가져야 하므로, 빈 클래스도 최소 1바이트 + 정렬 패딩이 필요합니다.

struct EmptyTag {};

// ❌ 나이브한 tuple 구현
template <typename... Ts>
struct NaiveTuple;

template <typename T1, typename T2, typename T3>
struct NaiveTuple<T1, T2, T3> {
    T1 first;   // 4바이트 (int)
    T2 second;  // 1바이트 (EmptyTag) + 3바이트 패딩
    T3 third;   // 8바이트 (double)
    // 총 16바이트 (정렬 때문에 24바이트가 될 수도)
};

시나리오 3: “할당자를 멤버로 가진 컨테이너가 너무 커요”

"std::vector<int, MyAllocator>를 수천 개 만드는데 메모리가 부족해요."
"MyAllocator는 상태가 없는 빈 클래스인데 왜 공간을 차지하나요?"

원인: 컨테이너는 할당자를 멤버로 저장합니다. 빈 할당자여도 1바이트 + 패딩이 필요합니다.

시나리오 4: “압축 쌍(compressed pair)을 직접 구현하고 싶어요”

"Boost의 compressed_pair처럼 빈 타입일 때 크기를 줄이고 싶어요."
"std::pair는 항상 두 멤버를 다 저장해서 비효율적이에요."

원인: std::pair는 EBCO를 적용하지 않습니다. 빈 타입이어도 크기가 줄어들지 않습니다.

시나리오 5: “표준 라이브러리 구현을 이해하고 싶어요”

"std::tuple, std::unique_ptr, std::shared_ptr 내부 구현이 궁금해요."
"어떻게 크기를 최소화하는지 알고 싶어요."

원인: 표준 라이브러리는 EBCO와 [[no_unique_address]]를 적극 활용해 메모리를 최적화합니다.


2. 빈 클래스 규칙과 EBCO

빈 클래스도 1바이트를 차지하는 이유

C++ 표준에서 모든 객체는 고유한 주소를 가져야 합니다. 빈 클래스여도 배열의 각 원소가 서로 다른 주소를 가지려면 최소 1바이트가 필요합니다.

struct Empty {};

int main() {
    Empty e1, e2;
    std::cout << sizeof(Empty) << '\n';  // 1
    std::cout << &e1 << " vs " << &e2 << '\n';  // 서로 다른 주소
    
    Empty arr[10];
    std::cout << &arr[0] << " vs " << &arr[1] << '\n';  // 1바이트씩 떨어짐
}

출력:

1
0x7ffc1234 vs 0x7ffc1235
0x7ffc1240 vs 0x7ffc1241

멤버로 가질 때의 문제

struct Empty {};

struct Container {
    int value;      // 4바이트
    Empty empty;    // 1바이트 + 3바이트 패딩 (정렬 때문)
    // 총 8바이트
};

static_assert(sizeof(Container) == 8);

문제: Empty는 상태가 없는데 4바이트(패딩 포함)를 낭비합니다.

EBCO: 베이스 클래스로 상속 시 크기 0

EBCO(Empty Base Class Optimization)는 빈 베이스 클래스의 크기를 0으로 만드는 컴파일러 최적화입니다.

struct Empty {};

struct Optimized : Empty {
    int value;  // 4바이트
    // Empty는 크기 0 → 총 4바이트
};

static_assert(sizeof(Optimized) == 4);

핵심: 베이스 클래스는 배열의 원소가 아니므로 고유 주소 규칙이 완화됩니다. 컴파일러는 빈 베이스를 파생 클래스의 시작 주소와 같게 배치해 공간을 절약합니다.

EBCO 메모리 레이아웃

멤버로 가질 때 (Container):
┌──────────────────────────────┐
│ value (4바이트) │ Empty (1바이트) │ padding (3바이트) │
└──────────────────────────────┘
총 8바이트

베이스로 상속 시 (Optimized):
┌──────────────────────────────┐
│ Empty (0바이트) │ value (4바이트) │
└──────────────────────────────┘
총 4바이트

EBCO가 적용되는 조건

  1. 베이스 클래스여야 함 (멤버 변수는 안 됨)
  2. 빈 클래스여야 함 (비정적 멤버 변수가 없음)
  3. 가상 함수가 없어야 함 (vtable 포인터가 있으면 빈 클래스가 아님)
  4. 첫 번째 멤버와 타입이 달라야 함 (같은 타입이면 주소가 겹칠 수 있음)

3. EBCO 동작 원리

단일 상속에서의 EBCO

struct EmptyBase {};

struct Derived : EmptyBase {
    int x;
};

int main() {
    Derived d;
    std::cout << "sizeof(Derived): " << sizeof(Derived) << '\n';  // 4
    std::cout << "Address of base: " << static_cast<EmptyBase*>(&d) << '\n';
    std::cout << "Address of derived: " << &d << '\n';
    // 베이스와 파생 클래스의 주소가 같음
}

출력:

sizeof(Derived): 4
Address of base: 0x7ffc1234
Address of derived: 0x7ffc1234

핵심: 빈 베이스 클래스는 파생 클래스의 시작 주소와 동일한 주소를 가지므로 추가 공간이 필요 없습니다.

다중 상속에서의 EBCO

struct Empty1 {};
struct Empty2 {};

struct MultiDerived : Empty1, Empty2 {
    int x;
};

static_assert(sizeof(MultiDerived) == 4);  // 두 빈 베이스 모두 0바이트

주의: 같은 타입을 여러 번 상속하면 모호성(ambiguity) 문제가 발생합니다.

struct Empty {};

// ❌ 컴파일 에러: 같은 베이스를 두 번 상속
struct Bad : Empty, Empty {  // error: duplicate base type
    int x;
};

EBCO 실패 케이스 1: 첫 번째 멤버와 타입이 같을 때

struct Empty {};

struct Container : Empty {
    Empty first;  // ❌ 베이스와 타입이 같음 → EBCO 실패
    int second;
};

static_assert(sizeof(Container) == 8);  // Empty가 1바이트 + 패딩

이유: first와 베이스 Empty같은 주소를 가지면 서로 다른 객체인데 주소가 같아지는 문제가 발생합니다. 컴파일러는 이를 방지하기 위해 EBCO를 적용하지 않습니다.

EBCO 실패 케이스 2: 가상 함수가 있을 때

struct VirtualBase {
    virtual ~VirtualBase() = default;
};

struct Derived : VirtualBase {
    int x;
};

static_assert(sizeof(Derived) == 16);  // vtable 포인터 8바이트 + int 4바이트 + 패딩

이유: 가상 함수가 있으면 vtable 포인터(8바이트)가 필요하므로 빈 클래스가 아닙니다.


4. C++20 [[no_unique_address]]

멤버 변수에도 EBCO 적용하기

C++20 이전에는 베이스 클래스로만 EBCO를 적용할 수 있었습니다. 하지만 [[no_unique_address]] 속성을 사용하면 멤버 변수에도 EBCO를 적용할 수 있습니다.

struct Empty {};

struct WithAttribute {
    [[no_unique_address]] Empty empty;
    int value;
};

static_assert(sizeof(WithAttribute) == 4);  // Empty가 0바이트

베이스 상속 vs [[no_unique_address]]

struct Empty {};

// 방법 1: EBCO (베이스 상속)
struct WithEBCO : Empty {
    int value;
};

// 방법 2: [[no_unique_address]] (C++20)
struct WithAttribute {
    [[no_unique_address]] Empty empty;
    int value;
};

static_assert(sizeof(WithEBCO) == 4);
static_assert(sizeof(WithAttribute) == 4);

장점:

  • 멤버 접근이 명확: obj.empty로 접근 (베이스는 static_cast<Empty&>(obj) 필요)
  • private 상속 불필요: 멤버로 선언하면 됨
  • 다중 상속 복잡도 회피: 같은 타입을 여러 개 가질 수 있음

[[no_unique_address]] 여러 개 사용

struct Empty1 {};
struct Empty2 {};

struct Multi {
    [[no_unique_address]] Empty1 e1;
    [[no_unique_address]] Empty2 e2;
    int value;
};

static_assert(sizeof(Multi) == 4);  // 두 빈 멤버 모두 0바이트

같은 타입 여러 개

struct Empty {};

struct SameType {
    [[no_unique_address]] Empty e1;
    [[no_unique_address]] Empty e2;
    int value;
};

// 컴파일러마다 다름: GCC/Clang은 8바이트, MSVC는 4바이트
// e1과 e2가 같은 주소를 가질 수 없으므로 하나는 1바이트 차지

주의: 같은 타입을 여러 개 가지면 하나는 1바이트를 차지할 수 있습니다. 표준은 이를 구현 정의(implementation-defined)로 남겨둡니다.


5. 완전한 실전 예제

예제 1: 압축 쌍 (Compressed Pair) 구현

목표: std::pair처럼 두 값을 저장하되, 빈 타입일 때 크기를 줄입니다.

#include <iostream>
#include <type_traits>

// C++20: [[no_unique_address]] 사용
template <typename T1, typename T2>
struct CompressedPair {
    [[no_unique_address]] T1 first;
    [[no_unique_address]] T2 second;

    CompressedPair() = default;
    CompressedPair(const T1& f, const T2& s) : first(f), second(s) {}

    T1& getFirst() { return first; }
    const T1& getFirst() const { return first; }
    T2& getSecond() { return second; }
    const T2& getSecond() const { return second; }
};

// 테스트
struct Empty {};

int main() {
    // 일반 std::pair
    std::pair<int, Empty> p1;
    std::cout << "std::pair<int, Empty>: " << sizeof(p1) << " bytes\n";  // 8

    // 압축 쌍
    CompressedPair<int, Empty> p2;
    std::cout << "CompressedPair<int, Empty>: " << sizeof(p2) << " bytes\n";  // 4

    // 두 빈 타입
    CompressedPair<Empty, Empty> p3;
    std::cout << "CompressedPair<Empty, Empty>: " << sizeof(p3) << " bytes\n";  // 1 (최소)

    // 일반 타입
    CompressedPair<int, double> p4(42, 3.14);
    std::cout << "CompressedPair<int, double>: " << sizeof(p4) << " bytes\n";  // 16
    std::cout << "first: " << p4.getFirst() << ", second: " << p4.getSecond() << '\n';

    return 0;
}

예제 2: EBCO 기반 압축 쌍 (C++17 호환)

C++20 이전에는 베이스 상속으로 EBCO를 구현합니다.

#include <type_traits>
#include <iostream>

// 빈 타입인지 확인
template <typename T>
constexpr bool is_empty_v = std::is_empty_v<T> && !std::is_final_v<T>;

// 빈 타입이면 베이스로 상속, 아니면 멤버로 저장
template <typename T, int Index, bool = is_empty_v<T>>
struct CompressedElement {
    T value;
    
    CompressedElement() = default;
    CompressedElement(const T& v) : value(v) {}
    
    T& get() { return value; }
    const T& get() const { return value; }
};

// 빈 타입 특수화: 베이스로 상속
template <typename T, int Index>
struct CompressedElement<T, Index, true> : T {
    CompressedElement() = default;
    CompressedElement(const T& v) : T(v) {}
    
    T& get() { return *this; }
    const T& get() const { return *this; }
};

// 압축 쌍
template <typename T1, typename T2>
struct CompressedPair : 
    private CompressedElement<T1, 0>,
    private CompressedElement<T2, 1> {
    
    using First = CompressedElement<T1, 0>;
    using Second = CompressedElement<T2, 1>;

    CompressedPair() = default;
    CompressedPair(const T1& f, const T2& s) : First(f), Second(s) {}

    T1& first() { return First::get(); }
    const T1& first() const { return First::get(); }
    T2& second() { return Second::get(); }
    const T2& second() const { return Second::get(); }
};

// 테스트
struct Empty {};

int main() {
    CompressedPair<int, Empty> p1(42, Empty{});
    std::cout << "CompressedPair<int, Empty>: " << sizeof(p1) << " bytes\n";  // 4
    std::cout << "first: " << p1.first() << '\n';

    CompressedPair<Empty, double> p2(Empty{}, 3.14);
    std::cout << "CompressedPair<Empty, double>: " << sizeof(p2) << " bytes\n";  // 8
    std::cout << "second: " << p2.second() << '\n';

    CompressedPair<Empty, Empty> p3;
    std::cout << "CompressedPair<Empty, Empty>: " << sizeof(p3) << " bytes\n";  // 1

    return 0;
}

코드 상세 설명:

  • CompressedElement: 빈 타입이면 베이스로 상속 (EBCO 적용), 아니면 멤버로 저장
  • Index 템플릿 인자: 같은 타입을 두 번 상속할 때 서로 다른 베이스로 만들기 위함
  • is_final_v 체크: final 클래스는 상속할 수 없으므로 멤버로 저장

예제 3: 커스텀 unique_ptr (EBCO 적용)

#include <iostream>
#include <type_traits>

template <typename T, typename Deleter = std::default_delete<T>>
class CompressedUniquePtr : private Deleter {
    T* ptr_;

public:
    CompressedUniquePtr(T* p = nullptr) : Deleter(), ptr_(p) {}
    
    ~CompressedUniquePtr() {
        if (ptr_) {
            Deleter::operator()(ptr_);
        }
    }

    CompressedUniquePtr(const CompressedUniquePtr&) = delete;
    CompressedUniquePtr& operator=(const CompressedUniquePtr&) = delete;

    CompressedUniquePtr(CompressedUniquePtr&& other) noexcept 
        : Deleter(std::move(other.getDeleter())), ptr_(other.release()) {}

    CompressedUniquePtr& operator=(CompressedUniquePtr&& other) noexcept {
        if (this != &other) {
            reset(other.release());
            getDeleter() = std::move(other.getDeleter());
        }
        return *this;
    }

    T* get() const { return ptr_; }
    T* release() { T* p = ptr_; ptr_ = nullptr; return p; }
    void reset(T* p = nullptr) {
        if (ptr_) Deleter::operator()(ptr_);
        ptr_ = p;
    }

    T& operator*() const { return *ptr_; }
    T* operator->() const { return ptr_; }

    Deleter& getDeleter() { return *this; }
    const Deleter& getDeleter() const { return *this; }
};

// 테스트
struct EmptyDeleter {
    void operator()(int* p) const {
        std::cout << "EmptyDeleter called\n";
        delete p;
    }
};

struct StatefulDeleter {
    int log_level = 0;
    void operator()(int* p) const {
        std::cout << "StatefulDeleter (level " << log_level << ") called\n";
        delete p;
    }
};

int main() {
    // 빈 삭제자: 8바이트 (포인터만)
    CompressedUniquePtr<int, EmptyDeleter> p1(new int(42));
    std::cout << "CompressedUniquePtr<int, EmptyDeleter>: " 
              << sizeof(p1) << " bytes\n";  // 8

    // 상태 있는 삭제자: 16바이트 (포인터 + 삭제자)
    CompressedUniquePtr<int, StatefulDeleter> p2(new int(99));
    std::cout << "CompressedUniquePtr<int, StatefulDeleter>: " 
              << sizeof(p2) << " bytes\n";  // 16

    return 0;
}

출력:

CompressedUniquePtr<int, EmptyDeleter>: 8 bytes
CompressedUniquePtr<int, StatefulDeleter>: 16 bytes
EmptyDeleter called
StatefulDeleter (level 0) called

예제 4: 할당자를 가진 벡터 (EBCO 적용)

#include <vector>
#include <memory>
#include <iostream>

// 상태 없는 할당자
template <typename T>
struct EmptyAllocator {
    using value_type = T;
    
    T* allocate(size_t n) { return static_cast<T*>(::operator new(n * sizeof(T))); }
    void deallocate(T* p, size_t) { ::operator delete(p); }
    
    template <typename U>
    struct rebind { using other = EmptyAllocator<U>; };
};

template <typename T, typename Allocator>
class CompressedVector : private Allocator {
    T* data_;
    size_t size_;
    size_t capacity_;

public:
    CompressedVector() : Allocator(), data_(nullptr), size_(0), capacity_(0) {}
    
    ~CompressedVector() {
        if (data_) {
            for (size_t i = 0; i < size_; ++i) {
                data_[i].~T();
            }
            Allocator::deallocate(data_, capacity_);
        }
    }

    void push_back(const T& value) {
        if (size_ == capacity_) {
            size_t new_cap = capacity_ == 0 ? 1 : capacity_ * 2;
            T* new_data = Allocator::allocate(new_cap);
            for (size_t i = 0; i < size_; ++i) {
                new (&new_data[i]) T(std::move(data_[i]));
                data_[i].~T();
            }
            if (data_) Allocator::deallocate(data_, capacity_);
            data_ = new_data;
            capacity_ = new_cap;
        }
        new (&data_[size_]) T(value);
        ++size_;
    }

    size_t size() const { return size_; }
    T& operator { return data_[i]; }
};

int main() {
    CompressedVector<int, EmptyAllocator<int>> vec;
    std::cout << "CompressedVector size: " << sizeof(vec) << " bytes\n";  // 24
    // data_ (8) + size_ (8) + capacity_ (8) + EmptyAllocator (0)

    vec.push_back(1);
    vec.push_back(2);
    vec.push_back(3);
    std::cout << "Elements: " << vec[0] << ", " << vec[1] << ", " << vec[2] << '\n';

    return 0;
}

예제 5: 함수 객체 저장 (EBCO)

#include <iostream>
#include <functional>

// 상태 없는 함수 객체
struct Multiplier {
    int operator()(int x, int y) const { return x * y; }
};

// EBCO 적용 컨테이너
template <typename Func>
struct Calculator : private Func {
    int compute(int a, int b) {
        return Func::operator()(a, b);
    }
};

int main() {
    Calculator<Multiplier> calc;
    std::cout << "Calculator size: " << sizeof(calc) << " bytes\n";  // 1
    std::cout << "Result: " << calc.compute(3, 4) << '\n';  // 12

    return 0;
}

6. 자주 발생하는 에러와 해결법

에러 1: 같은 타입을 두 번 상속

증상: error: duplicate base type

struct Empty {};

// ❌ 컴파일 에러
struct Bad : Empty, Empty {
    int x;
};

해결: 태그 타입(tag type)으로 구분합니다.

template <int N>
struct EmptyTag {};

struct Good : EmptyTag<0>, EmptyTag<1> {
    int x;
};

static_assert(sizeof(Good) == 4);

에러 2: final 클래스 상속 시도

증상: error: cannot derive from 'final' base

struct FinalEmpty final {};

// ❌ 컴파일 에러
struct Bad : FinalEmpty {
    int x;
};

해결: final 클래스는 [[no_unique_address]] 로 멤버로 저장하거나, final을 제거합니다.

struct FinalEmpty final {};

struct Good {
    [[no_unique_address]] FinalEmpty empty;
    int x;
};

static_assert(sizeof(Good) == 4);  // C++20

에러 3: 가상 함수가 있는 베이스

증상: EBCO가 적용되지 않아 크기가 줄지 않음.

struct VirtualBase {
    virtual void foo() {}
};

struct Derived : VirtualBase {
    int x;
};

static_assert(sizeof(Derived) == 16);  // vtable 포인터 때문에

해결: 빈 클래스에는 가상 함수를 넣지 않습니다. 다형성이 필요하면 CRTP(Curiously Recurring Template Pattern)나 std::variant를 고려합니다.

에러 4: 첫 번째 멤버와 베이스 타입이 같음

증상: EBCO가 적용되지 않음.

struct Empty {};

struct Bad : Empty {
    Empty first;  // ❌ 베이스와 타입이 같음
    int second;
};

static_assert(sizeof(Bad) == 8);  // EBCO 실패

해결: 첫 번째 멤버를 다른 타입으로 바꾸거나, [[no_unique_address]] 사용.

struct Good : Empty {
    int first;       // ✅ 타입이 다름
    Empty second;    // 이건 멤버로 1바이트
};

static_assert(sizeof(Good) == 8);

// 또는 C++20
struct Better {
    [[no_unique_address]] Empty e1;
    [[no_unique_address]] Empty e2;  // 같은 타입이지만 구현마다 다름
    int value;
};

에러 5: 정렬 요구사항 무시

증상: 빈 클래스에 alignas가 있으면 EBCO가 제한됨.

struct alignas(16) AlignedEmpty {};

struct Container : AlignedEmpty {
    int x;
};

static_assert(sizeof(Container) == 16);  // 정렬 때문에 16바이트

해결: 빈 베이스에 과도한 정렬 요구사항을 넣지 않습니다.

에러 6: ABI 호환성 문제

증상: 라이브러리를 C++17로 빌드하고 C++20으로 재빌드하면 크기가 달라져 ABI가 깨짐.

struct Empty {};

// C++17: 멤버로 저장 → 8바이트
struct Data {
    Empty e;
    int x;
};

// C++20: [[no_unique_address]] 추가 → 4바이트
struct Data {
    [[no_unique_address]] Empty e;
    int x;
};

해결: ABI 안정성이 중요하면 [[no_unique_address]]를 조심스럽게 도입하거나, 버전별로 별도 네임스페이스를 사용합니다.

에러 7: 배열에서 EBCO 미적용

증상: 빈 클래스 배열은 각 원소가 1바이트씩 차지.

struct Empty {};

struct Container : Empty {
    Empty arr[10];  // ❌ 배열은 EBCO 적용 안 됨 → 10바이트
};

static_assert(sizeof(Container) >= 10);

이유: 배열의 각 원소는 고유한 주소를 가져야 하므로 EBCO가 적용되지 않습니다.


7. 표준 라이브러리 구현 사례

std::unique_ptr 내부 구조

// 표준 라이브러리 유사 구현 (단순화)
template <typename T, typename Deleter>
class unique_ptr : private Deleter {  // EBCO 적용
    T* ptr_;

public:
    unique_ptr(T* p = nullptr) : Deleter(), ptr_(p) {}
    
    ~unique_ptr() {
        if (ptr_) Deleter::operator()(ptr_);
    }

    // 이동 생성자/대입 연산자 생략

    T* get() const { return ptr_; }
    Deleter& get_deleter() { return *this; }
};

// 빈 삭제자: 8바이트 (포인터만)
struct EmptyDeleter {
    void operator()(int* p) const { delete p; }
};

static_assert(sizeof(unique_ptr<int, EmptyDeleter>) == 8);

// 상태 있는 삭제자: 16바이트
struct StatefulDeleter {
    int log_level;
    void operator()(int* p) const { delete p; }
};

static_assert(sizeof(unique_ptr<int, StatefulDeleter>) == 16);

std::tuple 내부 구조 (재귀 상속)

// 표준 라이브러리 유사 구현
template <typename... Ts>
struct Tuple;

template <>
struct Tuple<> {};  // 빈 tuple

template <typename T, typename... Rest>
struct Tuple<T, Rest...> : Tuple<Rest...> {  // 재귀 상속
    T value;

    Tuple() = default;
    Tuple(const T& v, const Rest&... rest) 
        : Tuple<Rest...>(rest...), value(v) {}

    T& get() { return value; }
};

// 테스트
struct Empty {};

int main() {
    Tuple<int, Empty, double> t(42, Empty{}, 3.14);
    std::cout << "Tuple size: " << sizeof(t) << " bytes\n";  // 16
    // int (4) + padding (4) + double (8) + Empty (0, EBCO)

    Tuple<Empty, Empty, int> t2;
    std::cout << "Tuple size: " << sizeof(t2) << " bytes\n";  // 4
    // 두 Empty 모두 EBCO

    return 0;
}

std::shared_ptr 제어 블록

// shared_ptr 제어 블록 (단순화)
template <typename T, typename Deleter, typename Allocator>
struct ControlBlock : private Deleter, private Allocator {  // EBCO
    T* ptr;
    std::atomic<int> ref_count;
    std::atomic<int> weak_count;

    ControlBlock(T* p, Deleter d, Allocator a)
        : Deleter(std::move(d)), Allocator(std::move(a)), 
          ptr(p), ref_count(1), weak_count(1) {}

    void release() {
        if (--ref_count == 0) {
            Deleter::operator()(ptr);
            if (--weak_count == 0) {
                Allocator::deallocate(this, 1);
            }
        }
    }
};

// 빈 삭제자·할당자: 24바이트
// ptr (8) + ref_count (4) + weak_count (4) + padding (8)
// Deleter (0) + Allocator (0)

std::function 내부 (Small Buffer Optimization + EBCO)

// std::function 유사 구현 (단순화)
template <typename Signature>
class Function;

template <typename R, typename... Args>
class Function<R(Args...)> {
    static constexpr size_t BUFFER_SIZE = 16;
    
    alignas(void*) char buffer_[BUFFER_SIZE];
    R (*invoke_)(char*, Args...);
    void (*destroy_)(char*);

public:
    template <typename F>
    Function(F f) {
        // 작은 함수 객체는 buffer_에 직접 저장 (EBCO 활용)
        if constexpr (sizeof(F) <= BUFFER_SIZE && std::is_empty_v<F>) {
            new (buffer_) F(std::move(f));
            invoke_ =  -> R {
                return (*reinterpret_cast<F*>(buf))(std::forward<Args>(args)...);
            };
            destroy_ =  {
                reinterpret_cast<F*>(buf)->~F();
            };
        } else {
            // 큰 함수 객체는 힙 할당
            // (생략)
        }
    }

    R operator()(Args... args) {
        return invoke_(buffer_, std::forward<Args>(args)...);
    }

    ~Function() {
        if (destroy_) destroy_(buffer_);
    }
};

8. 프로덕션 패턴

패턴 1: 압축 쌍으로 메모리 절약

// std::unique_ptr, std::tuple 등에서 사용
template <typename T1, typename T2>
using CompressedPair = /* EBCO 또는 [[no_unique_address]] 구현 */;

// 사용 예: 포인터 + 삭제자
template <typename T, typename Deleter>
class SmartPtr {
    CompressedPair<T*, Deleter> data_;

public:
    T* get() const { return data_.first(); }
    Deleter& get_deleter() { return data_.second(); }
};

패턴 2: 정책 기반 설계 (Policy-Based Design)

// 빈 정책 클래스를 베이스로 상속
template <typename LockPolicy, typename LogPolicy>
class ThreadSafeContainer : private LockPolicy, private LogPolicy {
    std::vector<int> data_;

public:
    void add(int value) {
        typename LockPolicy::Guard lock(LockPolicy::getMutex());
        LogPolicy::log("Adding value");
        data_.push_back(value);
    }
};

// 빈 정책
struct NoLock {
    struct Guard { Guard(std::mutex&) {} };
    static std::mutex& getMutex() { static std::mutex m; return m; }
};

struct NoLog {
    static void log(const char*) {}
};

// 크기: vector (24바이트) + NoLock (0) + NoLog (0) = 24바이트
ThreadSafeContainer<NoLock, NoLog> container;

패턴 3: 타입 태그 (Type Tag)

// 빈 타입으로 오버로딩 선택
struct InputIteratorTag {};
struct RandomAccessIteratorTag {};

template <typename Iter>
struct IteratorTraits : RandomAccessIteratorTag {
    // EBCO로 태그 크기 0
};

template <typename Iter>
void advanceImpl(Iter& it, int n, InputIteratorTag) {
    // O(n) 구현
    for (int i = 0; i < n; ++i) ++it;
}

template <typename Iter>
void advanceImpl(Iter& it, int n, RandomAccessIteratorTag) {
    // O(1) 구현
    it += n;
}

template <typename Iter>
void advance(Iter& it, int n) {
    advanceImpl(it, n, IteratorTraits<Iter>{});
}

패턴 4: 할당자 전파 (Allocator Propagation)

// 컨테이너가 할당자를 베이스로 상속
template <typename T, typename Allocator = std::allocator<T>>
class Vector : private Allocator {
    T* data_;
    size_t size_;
    size_t capacity_;

public:
    // 할당자 접근
    Allocator& get_allocator() { return *this; }

    // 할당 시 베이스의 allocate 호출
    void reserve(size_t n) {
        T* new_data = Allocator::allocate(n);
        // ...
    }
};

// 빈 할당자: 24바이트 (data_ + size_ + capacity_)

패턴 5: 상태 기반 최적화 선택

// 상태 유무에 따라 구현 선택
template <typename T, typename Deleter>
class SmartPtr {
    using Storage = std::conditional_t<
        std::is_empty_v<Deleter> && !std::is_final_v<Deleter>,
        CompressedPair<T*, Deleter>,  // EBCO 적용
        std::pair<T*, Deleter>         // 일반 저장
    >;
    
    Storage data_;
};

패턴 6: 다중 정책 조합

// 여러 빈 정책을 베이스로 상속
template <typename ErrorPolicy, typename LogPolicy, typename ThreadPolicy>
class Service : private ErrorPolicy, private LogPolicy, private ThreadPolicy {
    std::string data_;

public:
    void process() {
        ThreadPolicy::lock();
        LogPolicy::log("Processing");
        if (ErrorPolicy::shouldThrow()) {
            throw std::runtime_error("Error");
        }
        ThreadPolicy::unlock();
    }
};

// 모든 정책이 빈 클래스면 data_만 차지

패턴 7: 컴파일 타임 플래그

// 빈 타입으로 컴파일 타임 플래그 전달
struct DebugMode {};
struct ReleaseMode {};

template <typename Mode>
class Logger : private Mode {
public:
    void log(const char* msg) {
        if constexpr (std::is_same_v<Mode, DebugMode>) {
            std::cout << "[DEBUG] " << msg << '\n';
        }
        // ReleaseMode면 아무것도 안 함
    }
};

// DebugMode는 크기 0
Logger<DebugMode> logger;

패턴 8: 반복자 어댑터

// 빈 함수 객체를 베이스로 상속
template <typename Iter, typename Func>
class TransformIterator : private Func {
    Iter iter_;

public:
    TransformIterator(Iter it, Func f) : Func(std::move(f)), iter_(it) {}

    auto operator*() const {
        return Func::operator()(*iter_);
    }

    TransformIterator& operator++() {
        ++iter_;
        return *this;
    }
};

// 빈 함수 객체: sizeof(TransformIterator) == sizeof(Iter)

9. 정리

주제요약
빈 클래스 규칙모든 객체는 고유 주소 필요 → 최소 1바이트
EBCO빈 베이스 클래스는 크기 0으로 최적화
[[no_unique_address]]C++20 멤버 변수에도 EBCO 적용
압축 쌍빈 타입일 때 크기 절약 (std::unique_ptr, std::tuple)
문제 시나리오커스텀 삭제자, 할당자, 정책 클래스
일반적 에러같은 타입 중복 상속, final 클래스, 가상 함수
프로덕션표준 라이브러리, 정책 기반 설계, 타입 태그

EBCO와 [[no_unique_address]]는 메모리 레이아웃 최적화의 핵심 기법입니다. 표준 라이브러리 구현을 이해하고, 커스텀 컨테이너·스마트 포인터를 만들 때 필수적입니다.


구현 체크리스트

EBCO와 [[no_unique_address]] 적용 시 확인할 항목:

  • 빈 타입(상태 없는 함수 객체, 할당자, 정책)을 멤버로 가지는지
  • C++20 이상이면 [[no_unique_address]] 사용 고려
  • C++17 이하면 private 베이스 상속으로 EBCO 적용
  • 같은 타입을 여러 번 상속할 때 태그 타입으로 구분
  • final 클래스는 상속할 수 없으므로 [[no_unique_address]] 사용
  • 가상 함수가 있으면 빈 클래스가 아님 (vtable 포인터)
  • ABI 호환성이 중요하면 [[no_unique_address]] 도입 신중히

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 메모리 정렬과 패딩 | alignas·alignof·구조체 크기 최적화
  • C++ 데이터 지향 설계 | AoS vs SoA·캐시 최적화
  • C++ PIMPL 패턴 | 컴파일 의존성 제거와 ABI 안정성
  • C++ CRTP 패턴 | 정적 다형성과 컴파일 타임 인터페이스

이 글에서 다루는 키워드 (관련 검색어)

EBCO, Empty Base Class Optimization, [[no_unique_address]], 빈 베이스 최적화, 압축 쌍, 메모리 레이아웃 등으로 검색하시면 이 글이 도움이 됩니다.

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 표준 라이브러리 구현, 제네릭 라이브러리 개발, 임베디드 시스템(메모리 제약), 고성능 컨테이너 제작 시 사용합니다. 특히 커스텀 할당자, 정책 기반 설계, 타입 태그를 사용할 때 EBCO로 메모리를 절약할 수 있습니다.

Q. 선행으로 읽으면 좋은 글은?

A. C++ 메모리 정렬과 패딩, C++ 상속과 다형성, C++ 템플릿 기초를 먼저 읽으면 이해에 도움이 됩니다.

Q. 더 깊이 공부하려면?

A. cppreference - Empty base optimization, cppreference - [[no_unique_address]], Boost의 compressed_pair 구현, LLVM libc++ 소스 코드를 참고하세요.

한 줄 요약: 빈 클래스를 베이스로 상속하거나 [[no_unique_address]]를 쓰면 메모리를 절약할 수 있습니다. 다음으로 커스텀 알로케이터·pmr를 읽어보면 좋습니다.

다음 글: [고성능 C++ #39-2] 현대적 메모리 관리: 커스텀 알로케이터(Memory Pool) 제작과 std::pmr 활용

이전 글: [고성능 C++ #39-1] 캐시 효율적인 코드: 데이터 지향 설계 가이드


추가 학습 자료

온라인 리소스

표준 라이브러리 소스 코드

  • LLVM libc++ <memory> - unique_ptr 구현
  • GCC libstdc++ <tuple> - tuple 구현
  • MSVC STL - compressed_pair 구현

관련 제안서


심화 주제

EBCO와 ABI 호환성

// 라이브러리 v1.0 (C++17)
struct Data {
    Empty e;
    int x;
};
// sizeof(Data) == 8

// 라이브러리 v2.0 (C++20, [[no_unique_address]] 추가)
struct Data {
    [[no_unique_address]] Empty e;
    int x;
};
// sizeof(Data) == 4 → ABI 깨짐!

해결:

  • 버전별로 별도 네임스페이스 사용
  • ABI 안정성이 중요하면 [[no_unique_address]] 도입 신중히
  • PIMPL 패턴으로 내부 구현 숨기기

EBCO와 표준 레이아웃 (Standard Layout)

struct Empty {};

// Standard Layout 유지
struct StandardLayout : Empty {
    int x;
    int y;
};

static_assert(std::is_standard_layout_v<StandardLayout>);

주의: 빈 베이스가 있어도 첫 번째 멤버와 타입이 다르면 Standard Layout을 유지합니다.

EBCO와 constexpr

struct Empty {
    constexpr void foo() const {}
};

struct Container : Empty {
    int x;
    
    constexpr Container(int v) : x(v) {}
    
    constexpr int compute() const {
        Empty::foo();
        return x * 2;
    }
};

constexpr Container c(21);
static_assert(c.compute() == 42);
static_assert(sizeof(c) == 4);

벤치마크 예제

메모리 사용량 비교

#include <iostream>
#include <memory>

struct EmptyDeleter {
    void operator()(int* p) const { delete p; }
};

struct StatefulDeleter {
    int log_level = 0;
    void operator()(int* p) const { delete p; }
};

int main() {
    std::cout << "std::unique_ptr<int>: " 
              << sizeof(std::unique_ptr<int>) << " bytes\n";  // 8
    
    std::cout << "std::unique_ptr<int, EmptyDeleter>: " 
              << sizeof(std::unique_ptr<int, EmptyDeleter>) << " bytes\n";  // 8 (EBCO)
    
    std::cout << "std::unique_ptr<int, StatefulDeleter>: " 
              << sizeof(std::unique_ptr<int, StatefulDeleter>) << " bytes\n";  // 16

    // 배열 크기 비교
    constexpr size_t N = 1000000;
    std::cout << "\n1백만 개 배열 메모리:\n";
    std::cout << "EmptyDeleter: " << N * 8 / 1024 / 1024 << " MB\n";  // 7.6 MB
    std::cout << "StatefulDeleter: " << N * 16 / 1024 / 1024 << " MB\n";  // 15.2 MB

    return 0;
}

캐시 효율 비교

#include <vector>
#include <chrono>
#include <iostream>

struct Empty {};

// EBCO 적용
struct Optimized : Empty {
    int data[15];  // 60바이트 + Empty (0) = 60바이트
};

// EBCO 미적용
struct Unoptimized {
    Empty e;
    int data[15];  // Empty (1 + 3 패딩) + 60바이트 = 64바이트
};

template <typename T>
void benchmark(const char* name) {
    constexpr size_t N = 10000000;
    std::vector<T> vec(N);
    
    auto start = std::chrono::high_resolution_clock::now();
    long long sum = 0;
    for (const auto& item : vec) {
        sum += item.data[0];
    }
    auto end = std::chrono::high_resolution_clock::now();
    
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    std::cout << name << ": " << ms << "ms (sum: " << sum << ")\n";
}

int main() {
    std::cout << "Optimized size: " << sizeof(Optimized) << " bytes\n";
    std::cout << "Unoptimized size: " << sizeof(Unoptimized) << " bytes\n\n";
    
    benchmark<Optimized>("Optimized (EBCO)");
    benchmark<Unoptimized>("Unoptimized");
    
    return 0;
}

예상 결과: Optimized가 캐시 라인당 더 많은 객체를 담아 캐시 효율이 좋습니다.


고급 활용: std::tuple 재귀 구현

완전한 tuple 구현 (EBCO 활용)

#include <iostream>
#include <utility>

// 재귀 종료
template <typename... Ts>
struct TupleImpl;

template <>
struct TupleImpl<> {};

// 재귀 케이스: 첫 번째 타입을 베이스로 상속
template <typename T, typename... Rest>
struct TupleImpl<T, Rest...> : TupleImpl<Rest...> {
    T value;

    TupleImpl() = default;
    
    template <typename U, typename... Args>
    TupleImpl(U&& v, Args&&... args)
        : TupleImpl<Rest...>(std::forward<Args>(args)...),
          value(std::forward<U>(v)) {}

    T& get() { return value; }
    const T& get() const { return value; }
    
    TupleImpl<Rest...>& getTail() { return *this; }
    const TupleImpl<Rest...>& getTail() const { return *this; }
};

// 편의 래퍼
template <typename... Ts>
class Tuple : public TupleImpl<Ts...> {
public:
    using TupleImpl<Ts...>::TupleImpl;
};

// get<N> 헬퍼
template <size_t N, typename T, typename... Rest>
struct TupleGetter {
    using Type = typename TupleGetter<N - 1, Rest...>::Type;
    
    static Type& get(TupleImpl<T, Rest...>& t) {
        return TupleGetter<N - 1, Rest...>::get(t.getTail());
    }
};

template <typename T, typename... Rest>
struct TupleGetter<0, T, Rest...> {
    using Type = T;
    
    static Type& get(TupleImpl<T, Rest...>& t) {
        return t.get();
    }
};

template <size_t N, typename... Ts>
auto& get(Tuple<Ts...>& t) {
    return TupleGetter<N, Ts...>::get(t);
}

// 테스트
struct Empty {};

int main() {
    Tuple<int, Empty, double> t(42, Empty{}, 3.14);
    
    std::cout << "Tuple size: " << sizeof(t) << " bytes\n";  // 16
    std::cout << "get<0>: " << get<0>(t) << '\n';  // 42
    std::cout << "get<2>: " << get<2>(t) << '\n';  // 3.14

    // 빈 타입만
    Tuple<Empty, Empty, Empty> t2;
    std::cout << "Tuple<Empty, Empty, Empty> size: " << sizeof(t2) << " bytes\n";  // 1

    return 0;
}

코드 상세 설명:

  • 재귀 상속: TupleImpl<int, Empty, double>TupleImpl<Empty, double>를 상속
  • EBCO 적용: 빈 타입인 Empty는 베이스 클래스로 들어가 크기 0
  • get: 재귀적으로 N번째 베이스를 찾아 값을 반환

컴파일러별 차이

GCC/Clang vs MSVC

struct Empty {};

struct Test {
    [[no_unique_address]] Empty e1;
    [[no_unique_address]] Empty e2;
    int x;
};

// GCC/Clang: sizeof(Test) == 8 (e1과 e2가 같은 주소)
// MSVC: sizeof(Test) == 4 (e2가 1바이트 차지)

이유: 표준은 [[no_unique_address]]의 정확한 동작을 명시하지 않습니다. 컴파일러마다 구현이 다를 수 있습니다.

이식성 확보

// 컴파일 타임 검증
static_assert(sizeof(Test) <= 8, "Size too large");

// 또는 조건부 컴파일
#ifdef _MSC_VER
    // MSVC 전용 최적화
#else
    // GCC/Clang 전용 최적화
#endif

실전 사례: 표준 라이브러리 스타일 구현

압축 쌍 최종 버전 (프로덕션 수준)

#include <type_traits>
#include <utility>

// 빈 타입이고 final이 아닌지 확인
template <typename T>
constexpr bool can_use_ebco_v = std::is_empty_v<T> && !std::is_final_v<T>;

// 압축 원소: 빈 타입이면 베이스로, 아니면 멤버로
template <typename T, int Index, bool UseEBCO = can_use_ebco_v<T>>
struct CompressedElement {
    T value_;
    
    constexpr CompressedElement() = default;
    
    template <typename U>
    constexpr explicit CompressedElement(U&& v) 
        : value_(std::forward<U>(v)) {}
    
    constexpr T& get() noexcept { return value_; }
    constexpr const T& get() const noexcept { return value_; }
};

// EBCO 특수화
template <typename T, int Index>
struct CompressedElement<T, Index, true> : private T {
    constexpr CompressedElement() = default;
    
    template <typename U>
    constexpr explicit CompressedElement(U&& v) 
        : T(std::forward<U>(v)) {}
    
    constexpr T& get() noexcept { return *this; }
    constexpr const T& get() const noexcept { return *this; }
};

// 압축 쌍
template <typename T1, typename T2>
class CompressedPair : 
    private CompressedElement<T1, 0>,
    private CompressedElement<T2, 1> {
    
    using Base1 = CompressedElement<T1, 0>;
    using Base2 = CompressedElement<T2, 1>;

public:
    constexpr CompressedPair() = default;
    
    template <typename U1, typename U2>
    constexpr CompressedPair(U1&& f, U2&& s)
        : Base1(std::forward<U1>(f)), Base2(std::forward<U2>(s)) {}

    constexpr T1& first() noexcept { return Base1::get(); }
    constexpr const T1& first() const noexcept { return Base1::get(); }
    constexpr T2& second() noexcept { return Base2::get(); }
    constexpr const T2& second() const noexcept { return Base2::get(); }
};

// 테스트
struct Empty {};
struct Stateful { int x; };

int main() {
    // 모두 빈 타입
    CompressedPair<Empty, Empty> p1;
    std::cout << "CompressedPair<Empty, Empty>: " << sizeof(p1) << " bytes\n";  // 1

    // 하나만 빈 타입
    CompressedPair<int, Empty> p2(42, Empty{});
    std::cout << "CompressedPair<int, Empty>: " << sizeof(p2) << " bytes\n";  // 4
    std::cout << "first: " << p2.first() << '\n';

    // 둘 다 상태 있음
    CompressedPair<int, double> p3(42, 3.14);
    std::cout << "CompressedPair<int, double>: " << sizeof(p3) << " bytes\n";  // 16

    // constexpr 지원
    constexpr CompressedPair<int, Empty> p4(99, Empty{});
    static_assert(p4.first() == 99);

    return 0;
}

마치며

EBCO[[no_unique_address]] 는 C++ 메모리 최적화의 숨은 영웅입니다. 표준 라이브러리가 어떻게 효율적으로 구현되는지 이해하고, 직접 제네릭 라이브러리를 만들 때 이 기법을 활용하면 메모리 사용량을 크게 줄일 수 있습니다.

특히 임베디드 시스템, 게임 엔진, 고성능 서버처럼 메모리가 중요한 환경에서 이 최적화는 필수적입니다. 수백만 개의 객체를 다룰 때 객체당 4~8바이트 절약수십 MB의 메모리 절약으로 이어집니다.

다음 글에서는 커스텀 알로케이터와 std::pmr로 할당 성능을 최적화하는 방법을 다룹니다.


관련 글

  • C++ 시리즈 전체 보기
  • C++ Adapter Pattern 완벽 가이드 | 인터페이스 변환과 호환성
  • C++ ADL |
  • C++ Aggregate Initialization |