C++ Mixin 패턴: 템플릿 상속으로 로깅·카운팅 기능 조합하기

이 글의 핵심

Mixin은 가상 함수 없이 컴파일 타임에 기능을 붙일 수 있지만, 여러 Mixin을 겹치면 이름 충돌, 다이아몬드 상속, 생성자 인자 전달 문제가 생기고 에러 메시지의 타입 이름도 길어집니다. 이런 함정을 피하는 방법과 실제로 어떤 상황에서 Mixin이 상속 계층보다 나은지 정리합니다.

Mixin이란?

Mixin은 “기능 하나를 담은 템플릿이 임의의 기반 클래스를 상속해 그 기능을 덧붙이는” 조합 패턴입니다.

Mixin이 해결하는 문제는 “기능 단위로 코드를 재사용하고 싶은데, 그 기능들의 조합이 미리 정해져 있지 않다”는 것입니다. 다중 상속으로 같은 것을 시도하면 class MyWidget : public Printable, public Serializable, public Widget처럼 여러 베이스를 나열해야 하고, 그 베이스들이 서로 관계없는 독립적인 기능이 아니라 같은 대상(Widget)에 적용되어야 하는 경우 어색해집니다. Mixin은 발상을 뒤집습니다 — Printable<Base>처럼 각 기능을 “어떤 타입이든 감싸서 그 기능을 추가하는 템플릿”으로 표현하고, Serializable<Printable<Widget>>처럼 필요한 기능만 순서대로 겹쳐 쌓아 원하는 조합을 그때그때 만들어냅니다. 이 조합은 컴파일 타임에 템플릿 인스턴스화로 완전히 해결되므로, 가상 함수 테이블이나 런타임 위임 없이도 기능을 유연하게 섞을 수 있습니다.

#include <iostream>

// Mixin 클래스들
template<typename Base>
class Printable : public Base {
public:
    void print() const {
        std::cout << "Printing...\n";
    }
};

template<typename Base>
class Serializable : public Base {
public:
    void serialize() const {
        std::cout << "Serializing...\n";
    }
};

// 베이스
class Widget {
public:
    void doWork() {
        std::cout << "Working...\n";
    }
};

// 조합
using MyWidget = Serializable<Printable<Widget>>;

int main() {
    MyWidget w;
    w.doWork();
    w.print();
    w.serialize();
}

기본 패턴

Mixin 템플릿이 따르는 공통 형태는 “타입 매개변수 Base를 받아 그것을 상속하고, 새 기능을 멤버로 추가하는 클래스 템플릿”입니다. template<typename Base> class Mixin : public Base라는 서명 자체가 “나는 어떤 클래스든 확장할 수 있다”는 선언이며, class MyClass : public Mixin<SomeBase>처럼 실제로 인스턴스화되는 시점에야 Base가 무엇인지 결정됩니다. 이 유연성 덕분에 같은 Mixin 하나를 완전히 무관한 여러 베이스 클래스에 재사용할 수 있으며, 이것이 상속 계층을 미리 고정해야 하는 일반적인 클래스 상속과 Mixin의 근본적인 차이입니다.

// Mixin 템플릿
template<typename Base>
class Mixin : public Base {
public:
    void newFeature() { /* ... */ }
};

// 사용
class MyClass : public Mixin<SomeBase> {};

이 형태로 Mixin을 처음 작성할 때 거의 반드시 만나는 컴파일 에러가 있습니다. Mixin 안에서 Base의 멤버를 이름만으로 부르면 찾지 못한다는 에러입니다. 예를 들어 newFeature() 안에서 Base가 가진 doWork()를 그냥 doWork();로 호출하면 GCC는 there are no arguments to 'doWork' that depend on a template parameter, so a declaration of 'doWork' must be available을 냅니다. Base는 템플릿 매개변수에 의존하는 의존 기반 클래스라, 템플릿을 정의하는 시점에는 컴파일러가 그 안을 들여다보지 않기 때문입니다. this->doWork()나 Base::doWork()처럼 의존 이름임을 드러내야 합니다. MSVC의 예전 기본 모드는 이 규칙을 느슨하게 적용해서 Windows에서는 컴파일되던 Mixin이 리눅스 CI에서 깨지는 일이 흔했는데, /permissive-를 켜면 같은 에러를 미리 볼 수 있습니다.

기능이 없는 빈 기반 클래스를 체인의 맨 안쪽에 두어도 크기 비용이 거의 없는 것은 빈 기반 클래스 최적화(EBO) 덕분입니다. 데이터 멤버가 없는 Mixin은 객체 크기를 늘리지 않으므로 sizeof(Serializable<Printable<Widget>>)는 sizeof(Widget)과 같습니다. 다만 선형 체인이 아니라 여러 빈 클래스를 다중 상속하는 경우, MSVC는 기본적으로 첫 번째 빈 기반에만 EBO를 적용해 크기가 늘어날 수 있고 __declspec(empty_bases)로 바꿔야 합니다.

실전 예시

예시 1: 다중 Mixin

using RichDocument = Versioned<Timestamped<Loggable<Document>>> 한 줄이 이 패턴의 실질적 가치를 보여줍니다 — 로깅, 타임스탬프, 버전 관리라는 세 가지 서로 독립적인 관심사가 Document에 조금씩 더해지는데, 상속 순서(안쪽부터 바깥쪽)를 바꾸면 필요에 따라 얼마든지 다른 조합(Timestamped<Document>만 쓰거나, Loggable<Versioned<Document>>처럼 순서를 바꾸거나)을 즉석에서 만들 수 있습니다. 각 Mixin은 자신이 추가하는 기능 외에는 Document의 존재조차 알 필요가 없으므로(Base라는 이름으로만 참조), 완전히 다른 클래스(예: Image, Video)에도 그대로 재사용할 수 있습니다 — 이것이 상속을 통한 재사용이 아니라 “기능 조각의 합성”이라고 부르는 이유입니다.

#include <iostream>
#include <string>

template<typename Base>
class Loggable : public Base {
public:
    void log(const std::string& msg) const {
        std::cout << "[LOG] " << msg << '\n';
    }
};

template<typename Base>
class Timestamped : public Base {
public:
    void setTimestamp(long ts) {
        timestamp = ts;
    }
    
    long getTimestamp() const {
        return timestamp;
    }
    
private:
    long timestamp = 0;
};

template<typename Base>
class Versioned : public Base {
public:
    void setVersion(int v) {
        version = v;
    }
    
    int getVersion() const {
        return version;
    }
    
private:
    int version = 1;
};

// 베이스
class Document {
public:
    void setContent(const std::string& c) {
        content = c;
    }
    
    std::string getContent() const {
        return content;
    }
    
private:
    std::string content;
};

// 조합
using RichDocument = Versioned<Timestamped<Loggable<Document>>>;

int main() {
    RichDocument doc;
    doc.setContent("Hello");
    doc.log("Document created");
    doc.setTimestamp(1234567890);
    doc.setVersion(2);
    
    std::cout << doc.getContent() << '\n';
    std::cout << "Version: " << doc.getVersion() << '\n';
}

예시 2: CRTP Mixin

이 예시는 앞의 예시와 확연히 다른 패턴입니다 — Mixin<Base>가 아니라 Comparable<Derived>처럼 자기 자신을 템플릿 인자로 넘기는 CRTP(Curiously Recurring Template Pattern) 방식입니다. Number가 operator==와 operator<만 직접 정의하면, Comparable<Number>가 나머지 넷(!=, >, <=, >=)을 그 두 연산의 조합으로 자동 생성해 줍니다 — self()가 static_cast<const Derived&>(*this)로 자기 자신을 Derived 타입으로 되돌려, Comparable 안에서도 Number의 실제 연산자를 호출할 수 있게 합니다. 이는 “타입이 최소한의 연산 두 개만 제공하면 나머지 관련 연산을 파생시켜 준다”는 발상으로, C++20의 <compare>(3중 비교 연산자)가 표준화되기 전까지 비교 연산자 세트를 반복 작성하지 않는 대표적인 방법이었습니다.

#include <iostream>

template<typename Derived>
class Comparable {
public:
    bool operator!=(const Derived& other) const {
        return !(self() == other);
    }
    
    bool operator>(const Derived& other) const {
        return other < self();
    }
    
    bool operator<=(const Derived& other) const {
        return !(self() > other);
    }
    
    bool operator>=(const Derived& other) const {
        return !(self() < other);
    }
    
private:
    const Derived& self() const {
        return static_cast<const Derived&>(*this);
    }
};

class Number : public Comparable<Number> {
public:
    Number(int v) : value(v) {}
    
    bool operator==(const Number& other) const {
        return value == other.value;
    }
    
    bool operator<(const Number& other) const {
        return value < other.value;
    }
    
private:
    int value;
};

int main() {
    Number a(10), b(20);
    std::cout << (a != b) << '\n';  // 1
    std::cout << (a <= b) << '\n';  // 1
}

CRTP Mixin이 앞의 형태와 다른 점은 기반 클래스가 파생 클래스를 알고 있다는 것입니다. 앞의 Printable<Base>는 자기가 감싼 안쪽 타입만 알 수 있지만, Comparable<Derived>는 static_cast로 바깥의 Number를 불러 쓸 수 있습니다. 이 캐스트가 안전하려면 Derived가 정말로 Comparable<Derived>를 상속해야 합니다. class Other : public Comparable<Number>처럼 다른 타입을 넣는 복사·붙여넣기 실수를 하면 컴파일은 되고 실행 중에 엉뚱한 메모리를 읽으므로, Comparable의 생성자를 private로 두고 friend Derived;를 선언해 잘못된 상속을 컴파일 에러로 만드는 관용구가 흔히 쓰입니다.

C++20 이후라면 이 예제는 auto operator<=>(const Number&) const = default;와 bool operator==(const Number&) const = default; 두 줄로 대체됩니다. 컴파일러가 <, <=, >, >=를 <=>로, !=를 ==로 다시 써서 처리하므로 Mixin 없이 여섯 연산자가 모두 생깁니다. CRTP 비교 Mixin은 C++17 이하를 지원해야 하는 코드나, 비교 외에 산술 연산자 세트(+에서 += 유도 등)처럼 표준이 대신해 주지 않는 연산을 유도할 때 여전히 쓸모가 있습니다.

예시 3: 기능 선택

앞의 예시들은 조합을 코드 작성 시점에 명시적으로 결정했지만(Versioned<Timestamped<Loggable<Document>>>), 이 예시는 그 조합 자체를 템플릿 매개변수(EnableLogging, EnableCaching)로 제어합니다. std::conditional_t<조건, A, B>가 조건에 따라 컴파일 타임에 A 또는 B 타입을 선택하므로, Service<true, false>와 Service<false, false>는 실제로 서로 다른 베이스 클래스 체인을 가진 완전히 다른 타입으로 인스턴스화됩니다. 이 접근의 실질적 이점은 Service<false, false>처럼 두 기능을 모두 끈 인스턴스에는 로깅·캐싱 관련 코드가 바이너리에 전혀 포함되지 않는다는 것입니다 — 런타임 if로 기능을 켜고 끄는 방식과 달리, 사용하지 않는 기능의 비용을 컴파일 타임에 완전히 제거합니다.

#include <iostream>
#include <type_traits>  // std::conditional_t

// 빈 베이스
class Empty {};

// Mixin들
template<typename Base>
class WithLogging : public Base {
public:
    void log(const char* msg) {
        std::cout << "[LOG] " << msg << '\n';
    }
};

template<typename Base>
class WithCaching : public Base {
public:
    void cache(int key, int value) {
        // 캐싱 로직
    }
};

// 조합 선택
template<bool EnableLogging, bool EnableCaching>
class Service : public 
    std::conditional_t<EnableLogging, WithLogging<
        std::conditional_t<EnableCaching, WithCaching<Empty>, Empty>
    >, 
    std::conditional_t<EnableCaching, WithCaching<Empty>, Empty>
    >
{
public:
    void doWork() {
        if constexpr (EnableLogging) {
            this->log("Working...");
        }
        std::cout << "Work done\n";
    }
};

int main() {
    Service<true, true> s1;   // 로깅 + 캐싱
    s1.doWork();
    
    Service<true, false> s2;  // 로깅만
    s2.doWork();
    
    Service<false, false> s3; // 둘 다 없음
    s3.doWork();
}

doWork 안의 if constexpr (EnableLogging)가 중요한 역할을 합니다. Service<false, ...>에는 log 멤버가 아예 없으므로 일반 if였다면 this->log(...)가 컴파일 에러가 됩니다. if constexpr는 조건이 거짓인 분기를 인스턴스화하지 않아 존재하지 않는 멤버를 참조해도 괜찮습니다. 다만 이 트릭은 템플릿 안에서만 동작합니다. 템플릿이 아닌 일반 함수에서 if constexpr (false) { undefined_function(); }을 쓰면 버려진 분기도 이름 검사를 받아 에러가 납니다.

std::conditional_t를 중첩한 선언은 기능이 두 개일 때도 이미 읽기 어렵고, 세 개가 넘으면 조합의 수만큼 분기가 늘어납니다. 이런 경우에는 template<bool On, template<class> class M, class B> using If = std::conditional_t<On, M<B>, B>; 같은 도우미 별칭을 만들어 If<EnableLogging, WithLogging, If<EnableCaching, WithCaching, Empty>>처럼 한 기능씩 감싸는 편이 유지보수가 쉽습니다. 기능 선택을 bool 매개변수로 받는 설계는 호출하는 쪽에서 Service<true, false>의 두 bool이 각각 무엇인지 알아보기 어렵다는 단점도 있어, 태그 타입(Service<Logging, NoCache>)을 쓰는 정책 기반 설계로 넘어가는 경우가 많습니다.

예시 4: 체이닝

setValue와 setName이 값을 설정한 뒤 *this(정확히는 auto&로 받은 자기 참조)를 반환하는 것이 메서드 체이닝을 가능하게 하는 전부입니다 — b.setValue(42).setName("Answer").print()처럼 한 표현식 안에서 여러 설정을 이어 쓸 수 있는 이 스타일은 빌더 패턴의 핵심 기법이며, Chainable을 Mixin으로 만들어 두면 이 체이닝 기능 자체를 다른 여러 클래스에도 재사용할 수 있습니다. 다만 반환 타입을 auto&로 둔 것은 Chainable<Base> 자신을 반환한다는 뜻이므로, Chainable을 상속해 새 메서드를 추가한 파생 클래스에서 체이닝을 이어가려면 CRTP를 함께 적용해 정확한 파생 타입을 반환하도록 조정해야 하는 경우가 있다는 점도 함께 알아둘 만합니다.

#include <iostream>
#include <string>

template<typename Base>
class Chainable : public Base {
public:
    auto& setValue(int v) {
        value = v;
        return *this;
    }
    
    auto& setName(const std::string& n) {
        name = n;
        return *this;
    }
    
    void print() const {
        std::cout << name << ": " << value << '\n';
    }
    
private:
    int value = 0;
    std::string name;
};

class Empty {};

using Builder = Chainable<Empty>;

int main() {
    Builder b;
    b.setValue(42)
     .setName("Answer")
     .print();
}

장점

  1. 조합: 기능 자유롭게 조합
  2. 재사용: Mixin 재사용
  3. 컴파일 타임: 오버헤드 없음
  4. 타입 안전: 컴파일 타임 체크

자주 발생하는 문제

문제 1: 다이아몬드

MixinA<Widget>과 MixinB<Widget>을 동시에 다중 상속하면, Widget이 두 경로(MixinA를 거친 경로, MixinB를 거친 경로)로 두 번 상속되어 클래스에 Widget의 서브객체가 두 개 존재하는 고전적인 다이아몬드 문제가 발생합니다 — 가상 상속으로 해결할 수도 있지만, 그러면 모든 Mixin이 가상 상속을 알고 있어야 하고 성능·복잡도 비용도 따라옵니다. Mixin 패턴에서는 애초에 병렬 다중 상속이 아니라 MixinA<MixinB<Widget>>처럼 선형 체인으로 쌓는 방식을 쓰므로, Widget은 체인의 가장 안쪽에 정확히 한 번만 나타나 다이아몬드 자체가 발생할 여지가 없습니다.

template<typename Base>
class MixinA : public Base {};

template<typename Base>
class MixinB : public Base {};

// ❌ 다이아몬드
// class MyClass : public MixinA<Widget>, public MixinB<Widget> {};

// ✅ 체인
class MyClass : public MixinA<MixinB<Widget>> {};

문제 2: 생성자

Mixin 체인이 깊어질수록 Base의 생성자 시그니처가 무엇인지(인자가 몇 개인지, 어떤 타입인지) 각 Mixin 레벨에서 알 방법이 없다는 문제가 생깁니다. 만약 Mixin이 자신만의 특정 생성자(인자 없음 등)만 제공한다면, 체인 안쪽의 Base가 인자를 받는 생성자만 가지고 있을 경우 그 인자를 전달할 방법이 막혀버립니다. 가변 인자 템플릿과 완벽 전달(template<typename... Args> Mixin(Args&&... args) : Base(std::forward<Args>(args)...))을 쓰면, Mixin은 자신에게 전달된 인자가 무엇이든 그대로 Base에 그대로 넘겨주기만 하므로 체인의 어느 위치에 끼워 넣어도 그 안쪽 Base의 생성자 시그니처를 그대로 지원하게 됩니다 — 이 패턴이 없으면 Mixin을 추가할 때마다 그 아래 클래스의 생성자를 일일이 다시 감싸 노출해야 합니다.

template<typename Base>
class Mixin : public Base {
public:
    // ✅ 완벽한 전달
    template<typename... Args>
    Mixin(Args&&... args) : Base(std::forward<Args>(args)...) {}
};

이 전달 생성자에는 잘 알려진 함정이 있습니다. 복사 생성자를 가로챈다는 것입니다. Mixin<Widget> a; Mixin<Widget> b = a;에서 a는 const가 아닌 lvalue라, 컴파일러가 만든 복사 생성자 Mixin(const Mixin&)보다 Args = Mixin&로 추론된 템플릿 생성자가 더 정확히 일치합니다. 그 결과 Base(a), 즉 Widget을 Mixin 객체로 생성하려 시도하다가 알아보기 어려운 에러가 나거나, Base에 우연히 맞는 생성자가 있으면 의도와 다른 동작을 합니다. 가장 간단한 해결책은 C++11부터 있는 상속 생성자 using Base::Base; 한 줄입니다. 기반 클래스의 생성자를 그대로 가져오면서 복사·이동 생성자는 가로채지 않습니다. 전달 생성자를 꼭 써야 한다면 requires (!std::is_same_v<std::remove_cvref_t<Args>, Mixin> && ...) 같은 제약으로 자기 자신 타입을 제외해야 합니다.

문제 3: 이름 충돌

MixinA와 MixinB가 우연히 같은 이름의 멤버 함수(func)를 정의하고, 이 둘이 체인의 서로 다른 레벨에 있는 경우(다이아몬드가 아니라 선형 체인이라 해도) MyClass에서 func()을 그냥 호출하면 이름 숨김(name hiding) 규칙에 따라 상속 체인에서 더 가까운 쪽(바깥쪽)의 func만 보이고 안쪽 것은 가려집니다. 에러가 나지 않고 조용히 바깥쪽이 선택된다는 점이 문제입니다. 가려진 안쪽 버전이 필요하다면 MixinB<Empty>::func()처럼 그 클래스를 스코프로 지정해 호출해야 합니다(아래 예제의 MixinA<MixinB<Empty>>::func()는 원래 보이던 A 버전을 명시적으로 적은 것입니다).

더 곤란한 경우는 이름은 같지만 매개변수가 다른 함수입니다. MixinA에 log(int), MixinB에 log(const char*)가 있으면 오버로드처럼 보이지만, 이름 숨김은 시그니처와 무관하게 이름 단위로 일어나서 obj.log("text")는 MixinA::log(int)만 후보로 보고 변환 실패 에러를 냅니다. 바깥 Mixin에 using Base::log;를 넣어 안쪽 오버로드를 끌어오면 두 버전이 함께 후보가 됩니다. Mixin을 설계할 때는 애초에 서로 다른 Mixin이 같은 이름의 공개 멤버를 정의하지 않도록 네이밍 컨벤션을 정해 두는 것이 이런 충돌 자체를 예방하는 실무적인 방법입니다.

template<typename Base>
class MixinA : public Base {
public:
    void func() { std::cout << "A\n"; }
};

template<typename Base>
class MixinB : public Base {
public:
    void func() { std::cout << "B\n"; }
};

class MyClass : public MixinA<MixinB<Empty>> {
public:
    void test() {
        MixinA<MixinB<Empty>>::func();  // A
    }
};

문제 4: 타입 복잡도

Mixin 체인이 4~5단계를 넘어가면, 컴파일 에러 메시지에 그 전체 중첩 타입이 그대로 펼쳐져 나오는 경우가 많아 어디서 무엇이 잘못되었는지 읽기가 급격히 힘들어집니다(표현식 템플릿에서 겪는 문제와 동일한 종류입니다). 매번 전체 체인을 손으로 써야 한다는 불편함도 있습니다 — using 별칭으로 그 조합에 이름을 한 번만 붙여 두면, 이후에는 WithFeatures<Base>라는 하나의 의미 있는 이름으로 참조할 수 있고, 조합 자체를 바꾸고 싶을 때도 이 별칭 정의 한 곳만 수정하면 그 별칭을 쓰는 모든 코드에 반영됩니다.

// 복잡한 타입
using MyType = Mixin1<Mixin2<Mixin3<Mixin4<Base>>>>;

// ✅ 별칭
template<typename T>
using WithFeatures = Mixin1<Mixin2<Mixin3<Mixin4<T>>>>;

using MyType = WithFeatures<Base>;

사용 사례

  1. 기능 조합
  2. 선택적 기능
  3. 정책 기반 설계
  4. 빌더 패턴

Mixin이 상속 계층보다 나은 경우는 기능의 조합이 여러 가지로 필요할 때입니다. 로깅, 타임스탬프, 버전 관리 세 기능을 원하는 대로 켜고 끄려면 전통적인 상속으로는 조합마다 클래스를 만들어야 하지만(최대 8개), Mixin으로는 세 개의 템플릿만 있으면 됩니다. 반대로 조합이 한두 가지로 고정되어 있거나, 기능을 런타임에 바꿔야 한다면 Mixin은 맞지 않습니다. 서로 다른 Mixin 조합은 완전히 다른 타입이라 std::vector에 함께 담을 수 없고, 공통 인터페이스로 다루려면 결국 가상 함수가 있는 기반 클래스가 필요해집니다. 기능을 런타임에 붙였다 뗄 수 있어야 한다면 데코레이터 패턴이나 구성(composition)이 더 자연스럽습니다.

FAQ

Q1: Mixin과 데코레이터 패턴은 무엇이 다른가요?

A: 둘 다 기능을 덧붙이지만 Mixin은 컴파일 타임에 타입을 쌓고, 데코레이터는 런타임에 객체를 감쌉니다. Mixin은 가상 호출이 없고 인라인이 잘 되는 대신 조합마다 새 타입이 생기며, 데코레이터는 공통 인터페이스 포인터로 다룰 수 있고 실행 중에 조합을 바꿀 수 있는 대신 간접 호출 비용이 있습니다.

Q2: Mixin 순서를 바꾸면 무엇이 달라지나요?

A: 생성 순서와 이름 숨김이 달라집니다. 안쪽 기반 클래스가 먼저 생성되고 나중에 소멸하므로, 한 Mixin이 다른 Mixin의 상태를 생성자에서 쓴다면 쓰는 쪽이 바깥에 있어야 합니다. 같은 이름의 멤버가 있으면 바깥쪽이 안쪽을 가립니다.

Q3: 헤더에 Mixin을 두면 컴파일 시간이 늘어나나요?

A: 템플릿이라 모든 코드가 헤더에 있어야 하고, 사용하는 조합마다 인스턴스화되므로 조합이 많을수록 컴파일 시간과 바이너리 크기가 늘어납니다. 자주 쓰는 조합이 정해져 있다면 extern template class로 한 번역 단위에서만 인스턴스화하게 할 수 있습니다.


같이 보면 좋은 글