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

[2026] 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 호환성
  • 프로덕션 패턴: 표준 라이브러리 구현 기법


목차

  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바이트로 패딩됩니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// ❌ 나이브한 구현
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바이트 + 정렬 패딩이 필요합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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]]를 적극 활용해 메모리를 최적화합니다.

일상 비유로 이해하기: 메모리를 아파트 건물로 생각해보세요. 스택은 엘리베이터 같아서 빠르지만 공간이 제한적입니다. 힙은 창고처럼 넓지만 물건을 찾는 데 시간이 걸립니다. 포인터는 “3층 302호”처럼 주소를 가리키는 메모지라고 보면 됩니다.

2. 빈 클래스 규칙과 EBCO

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

C++ 표준에서 모든 객체는 고유한 주소를 가져야 합니다. 빈 클래스여도 배열의 각 원소가 서로 다른 주소를 가지려면 최소 1바이트가 필요합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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

멤버로 가질 때의 문제

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

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으로 만드는 컴파일러 최적화입니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

// 타입 정의
struct Empty {};
struct Optimized : Empty {
    int value;  // 4바이트
    // Empty는 크기 0 → 총 4바이트
};
static_assert(sizeof(Optimized) == 4);

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

EBCO 메모리 레이아웃

아래 코드는 text를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

멤버로 가질 때 (Container):
┌──────────────────────────────┐
│ value (4바이트) │ Empty (1바이트) │ padding (3바이트) │
└──────────────────────────────┘
총 8바이트
베이스로 상속 시 (Optimized):
┌──────────────────────────────┐
│ Empty (0바이트) │ value (4바이트) │
└──────────────────────────────┘
총 4바이트

EBCO가 적용되는 조건

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

3. EBCO 동작 원리

단일 상속에서의 EBCO

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

struct Empty1 {};
struct Empty2 {};
struct MultiDerived : Empty1, Empty2 {
    int x;
};
static_assert(sizeof(MultiDerived) == 4);  // 두 빈 베이스 모두 0바이트

주의: 같은 타입을 여러 번 상속하면 모호성(ambiguity) 문제가 발생합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

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

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

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

struct Empty {};
struct Container : Empty {
    Empty first;  // ❌ 베이스와 타입이 같음 → EBCO 실패
    int second;
};
static_assert(sizeof(Container) == 8);  // Empty가 1바이트 + 패딩

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

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

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

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를 적용할 수 있습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

struct Empty {};
struct WithAttribute {
    [[no_unique_address]] Empty empty;
    int value;
};
static_assert(sizeof(WithAttribute) == 4);  // Empty가 0바이트

베이스 상속 vs [[no_unique_address]]

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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]] 여러 개 사용

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

struct Empty1 {};
struct Empty2 {};
struct Multi {
    [[no_unique_address]] Empty1 e1;
    [[no_unique_address]] Empty2 e2;
    int value;
};
static_assert(sizeof(Multi) == 4);  // 두 빈 멤버 모두 0바이트

같은 타입 여러 개

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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처럼 두 값을 저장하되, 빈 타입일 때 크기를 줄입니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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를 구현합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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 적용)

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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;
}

출력: 다음은 간단한 text 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

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

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

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

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

해결: 태그 타입(tag type)으로 구분합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

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 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

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

해결: final 클래스는 [[no_unique_address]] 로 멤버로 저장하거나, final을 제거합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

struct FinalEmpty final {};
struct Good {
    [[no_unique_address]] FinalEmpty empty;
    int x;
};
static_assert(sizeof(Good) == 4);  // C++20

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

증상: EBCO가 적용되지 않아 크기가 줄지 않음. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

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가 적용되지 않음. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

struct Empty {};
struct Bad : Empty {
    Empty first;  // ❌ 베이스와 타입이 같음
    int second;
};
static_assert(sizeof(Bad) == 8);  // EBCO 실패

해결: 첫 번째 멤버를 다른 타입으로 바꾸거나, [[no_unique_address]] 사용. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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가 제한됨. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

struct alignas(16) AlignedEmpty {};
struct Container : AlignedEmpty {
    int x;
};
static_assert(sizeof(Container) == 16);  // 정렬 때문에 16바이트

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

에러 6: ABI 호환성 문제

증상: 라이브러리를 C++17로 빌드하고 C++20으로 재빌드하면 크기가 달라져 ABI가 깨짐. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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바이트씩 차지. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

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

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

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

std::unique_ptr 내부 구조

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 표준 라이브러리 유사 구현 (단순화)
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 내부 구조 (재귀 상속)

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 표준 라이브러리 유사 구현
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 제어 블록

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 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)

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 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: 압축 쌍으로 메모리 절약

아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 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)

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 빈 정책 클래스를 베이스로 상속
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)

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 빈 타입으로 오버로딩 선택
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)

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 컨테이너가 할당자를 베이스로 상속
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: 상태 기반 최적화 선택

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 상태 유무에 따라 구현 선택
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: 다중 정책 조합

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 여러 빈 정책을 베이스로 상속
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: 컴파일 타임 플래그

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 빈 타입으로 컴파일 타임 플래그 전달
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: 반복자 어댑터

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 빈 함수 객체를 베이스로 상속
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]] 도입 신중히

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

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


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

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 호환성

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 라이브러리 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)

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

struct Empty {};
// Standard Layout 유지
struct StandardLayout : Empty {
    int x;
    int y;
};
static_assert(std::is_standard_layout_v<StandardLayout>);

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

EBCO와 constexpr

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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);

벤치마크 예제

메모리 사용량 비교

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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;
}

캐시 효율 비교

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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 활용)

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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]]의 정확한 동작을 명시하지 않습니다. 컴파일러마다 구현이 다를 수 있습니다.

이식성 확보

아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// 컴파일 타임 검증
static_assert(sizeof(Test) <= 8, "Size too large");
// 또는 조건부 컴파일
#ifdef _MSC_VER
    // MSVC 전용 최적화
#else
    // GCC/Clang 전용 최적화
#endif

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

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

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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로 할당 성능을 최적화하는 방법을 다룹니다.

관련 글

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3