C++ 현대적 다형성 설계: 상속 대신 합성·variant

들어가며: 상속이 항상 최선은 아니다

가상 함수·vtable 글에서 상속 기반 다형성을 다뤘지만, 실무에서는 상속 트리가 깊어질수록 변경 비용과 ABI 부담이 커집니다. 이 글에서는 두 가지 대안을 봅니다. 합성(composition)은 동작을 담당하는 객체를 멤버로 갖고 위임하는 설계로, 상속 없이 역할을 조합해 같은 효과를 냅니다. std::variant는 “이 타입들 중 정확히 하나”를 타입 안전하게 담는 값 타입으로, std::visit과 함께 쓰면 타입별 처리를 한곳에 모으고 처리 누락을 컴파일 시점에 잡을 수 있습니다.


상속이 한계에 부딪히는 순간

GUI 프레임워크에서 MouseEvent, KeyEvent, TouchEvent를 Event 기반 클래스로 상속했다고 해 봅시다. 핸들러마다 dynamic_cast로 타입을 확인하는 if-else 체인이 생기고, 새 이벤트 타입을 추가할 때 한 곳이라도 분기를 빠뜨리면 그 이벤트는 런타임에 조용히 무시됩니다. 이벤트 종류가 정해져 있다면 std::variant<MouseEvent, KeyEvent, TouchEvent>로 바꿔, 모든 타입을 처리했는지 컴파일러가 확인하게 할 수 있습니다.

결제 모듈을 CreditCard, BankTransfer, MobilePay의 상속으로 만들었는데 “신용카드와 포인트를 함께 사용”처럼 여러 정책을 조합해야 하는 요구가 생기면 상속으로는 표현이 어색해집니다. 이때는 PaymentProcessor가 vector<unique_ptr<IPaymentStrategy>>를 갖는 합성 구조가 맞고, “결제 수단 중 하나만 선택”이라면 variant가 더 단순합니다.

파서가 성공 결과, 에러, 부분 파싱 결과 중 하나를 반환해야 할 때 optional은 에러 정보를 담기 어렵고, 예외는 자주 일어나는 실패 경로에 부적합합니다. std::variant<ParseResult, ParseError, PartialParse>는 “이 중 정확히 하나”를 반환하며, C++23의 std::expected가 같은 생각을 성공/실패 두 경우에 특화한 것입니다.

반대로 에디터가 DLL로 로드한 플러그인이 새 “도구” 타입을 등록하는 구조처럼, 타입 집합이 컴파일 시점에 고정되지 않으면 상속이 맞습니다. ITool 인터페이스를 상속한 클래스를 unique_ptr<ITool>로 보관하고 가상 함수로 호출합니다. 게임 엔티티가 Transform, RigidBody, Renderer 같은 컴포넌트를 런타임에 다양하게 조합하는 경우도 합성이 자연스럽고, “이 스킬은 화염/얼음/번개 중 하나”처럼 배타적 선택이면 variant가 맞습니다.


합성으로 다형성 대체

상속은 “is-a” 관계와 계층이 명확할 때 유용합니다. 합성은 “has-a”, 즉 “이 클래스는 직렬화 정책을 멤버로 갖는다”로 표현합니다. 정책을 인터페이스로 두고 구현을 별도 클래스로 만들면, 상속 트리를 키우지 않고 조합으로 동작을 바꿀 수 있습니다.

#include <memory>
#include <string>

struct Data { std::string payload; };

class ISerializer {
public:
    virtual ~ISerializer() = default;
    virtual std::string serialize(const Data&) const = 0;
};

class JsonSerializer : public ISerializer {
public:
    std::string serialize(const Data& d) const override {
        return "{\"data\":\"" + d.payload + "\"}";  // 실제로는 이스케이프 필요
    }
};

class BinarySerializer : public ISerializer {
public:
    std::string serialize(const Data& d) const override {
        return d.payload;  // 바이너리 직렬화 자리
    }
};

class Exporter {
    std::unique_ptr<ISerializer> serializer_;  // 합성: 동작을 "갖고 있음"
public:
    explicit Exporter(std::unique_ptr<ISerializer> s) : serializer_(std::move(s)) {}
    std::string exportData(const Data& d) const {
        return serializer_->serialize(d);
    }
};

Exporter는 생성할 때 받은 직렬화 객체에 일을 위임합니다. 새 포맷이 생기면 ISerializer 구현만 추가하고 Exporter는 수정하지 않습니다(개방-폐쇄 원칙). 테스트에서는 가짜 직렬화 객체를 주입할 수 있습니다.

flowchart TB
    subgraph inheritance["상속 (is-a)"]
        A1[Exporter] --> A2[JsonExporter]
        A1 --> A3[BinaryExporter]
    end
    subgraph composition["합성 (has-a)"]
        B1[Exporter] -.->|멤버로 보유| B2[ISerializer]
        B2 --> B3[JsonSerializer]
        B2 --> B4[BinarySerializer]
        B2 --> B5[XmlSerializer]
    end

std::variant와 std::visit

std::variant<A, B, C>는 A, B, C 중 정확히 하나를 담습니다. union과 달리 현재 어떤 타입이 들어 있는지(index())를 기억하므로 잘못된 타입으로 읽는 일을 막을 수 있습니다. 값은 variant 객체 안에 직접 저장되어 힙 할당이 없고, 크기는 가장 큰 대안 타입과 인덱스를 담을 만큼입니다.

값을 꺼내는 방법은 세 가지입니다. std::get<T>는 타입이 맞지 않으면 std::bad_variant_access 예외를 던지고, std::get_if<T>는 포인터를 받아 맞지 않으면 nullptr를 돌려주며, std::visit은 현재 담긴 타입에 맞는 operator() 오버로드를 호출합니다.

overloaded 헬퍼

여러 람다를 하나의 방문자로 묶는 관용구입니다.

#include <variant>

template<class... Ts>
struct overloaded : Ts... {
    using Ts::operator()...;   // C++17 using 선언 팩 확장
};
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;  // C++17에 필요한 추론 가이드 (C++20부터는 생략 가능)
struct MouseClick { int x, y; };
struct KeyPress { char key; };
struct TimerTick { int id; };
using Event = std::variant<MouseClick, KeyPress, TimerTick>;

void handle(const Event& e) {
    std::visit(overloaded{
        [](const MouseClick& m) { /* m.x, m.y */ },
        [](const KeyPress& k)   { /* k.key */ },
        [](const TimerTick& t)  { /* t.id */ }
    }, e);
}

overloaded는 각 람다를 상속해 그 operator()들을 하나의 오버로드 집합으로 모읍니다. std::visit은 e에 담긴 타입에 맞는 오버로드를 고르고, 맞는 것이 하나도 없는 타입이 있으면 컴파일 에러를 냅니다.

flowchart TD
    subgraph variant["std::variant 내부"]
        V[현재 값] --> I{"index()"}
        I -->|0| T1[MouseClick]
        I -->|1| T2[KeyPress]
        I -->|2| T3[TimerTick]
    end
    subgraph visit["std::visit"]
        O[overloaded 방문자] --> L1[람다 1]
        O --> L2[람다 2]
        O --> L3[람다 3]
        T1 --> L1
        T2 --> L2
        T3 --> L3
    end

상속 vs 합성 vs variant

항목상속합성variant
타입 추가새 클래스만 추가새 구현 클래스만 추가variant 정의와 모든 visit 수정 (누락은 컴파일 에러)
조합단일 선택여러 개 조합 가능단일 선택
호출 방식vtable 간접 호출vtable 간접 호출인덱스로 분기, 인라인되기 쉬움
저장보통 힙 객체 + 포인터보통 힙 객체 + 포인터값으로 저장, 연속 메모리 가능
테스트가짜 파생 클래스가짜 구현 주입이 쉬움값 기반이라 대역이 거의 필요 없음
런타임 확장플러그인 등 가능가능불가 (타입 목록 고정)

새 타입이 자주 추가되면 상속·합성이 편하고, 새 연산(타입별 처리)이 자주 추가되면 variant가 편합니다. variant는 연산 하나를 추가할 때 방문자 하나만 만들면 되지만, 타입을 하나 추가하면 모든 방문자를 고쳐야 하기 때문입니다.

flowchart TD
    A[타입이 런타임에 추가될 수 있나?] -->|Yes| B[상속 / 인터페이스]
    A -->|No| C[여러 구현을 동시에 조합해야 하나?]
    C -->|Yes| F[합성]
    C -->|No| D[배타적인 유한 선택지인가?]
    D -->|Yes| E["std::variant"]
    D -->|No| G[상속 또는 합성 검토]

로깅을 FileLogger, ConsoleLogger, NetworkLogger의 상속으로 만들면 “파일과 콘솔에 동시에 기록”이라는 흔한 요구를 표현하기 어렵습니다. Logger가 vector<unique_ptr<ILogSink>>를 갖는 합성 구조로 바꾸면 출력 대상을 자유롭게 조합할 수 있고, spdlog 같은 라이브러리도 이 구조(로거와 싱크)를 씁니다.


같은 문제를 상속·dynamic_cast·variant·합성으로 풀어 보기

상속 방식

#include <iostream>
#include <memory>

class Event {
public:
    virtual ~Event() = default;
    virtual void handle() const = 0;
};

class MouseEvent : public Event {
    int x_, y_;
public:
    MouseEvent(int x, int y) : x_(x), y_(y) {}
    int x() const { return x_; }
    int y() const { return y_; }
    void handle() const override {
        std::cout << "Mouse: (" << x_ << ", " << y_ << ")\n";
    }
};

class KeyEvent : public Event {
    char key_;
public:
    explicit KeyEvent(char key) : key_(key) {}
    char key() const { return key_; }
    void handle() const override {
        std::cout << "Key: " << key_ << "\n";
    }
};

int main() {
    std::unique_ptr<Event> e = std::make_unique<MouseEvent>(10, 20);
    e->handle();  // 가상 함수 호출
}

타입을 추가해도 기존 코드를 고칠 필요가 없지만, 호출마다 vtable을 거치고 객체는 보통 힙에 따로 할당됩니다.

dynamic_cast 방식 (비권장)

void handle(const Event* e) {
    if (auto* m = dynamic_cast<const MouseEvent*>(e)) {
        std::cout << "Mouse: (" << m->x() << ", " << m->y() << ")\n";
    } else if (auto* k = dynamic_cast<const KeyEvent*>(e)) {
        std::cout << "Key: " << k->key() << "\n";
    }
    // 새 타입을 추가하고 이 분기를 빠뜨리면 런타임에 조용히 무시됨
}

dynamic_cast는 RTTI로 상속 관계를 검사하는 비용이 들고, 처리 누락을 컴파일러가 알려 주지 않습니다. 가상 함수로 처리를 클래스 안에 넣을 수 없는 경우가 아니라면 피합니다.

variant 방식

#include <iostream>
#include <variant>

struct MouseEvent { int x, y; };
struct KeyEvent { char key; };
using Event = std::variant<MouseEvent, KeyEvent>;

template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

void handle(const Event& e) {
    std::visit(overloaded{
        [](const MouseEvent& m) { std::cout << "Mouse: (" << m.x << ", " << m.y << ")\n"; },
        [](const KeyEvent& k)   { std::cout << "Key: " << k.key << "\n"; }
    }, e);
}

int main() {
    Event e = MouseEvent{10, 20};
    handle(e);
}

이벤트 타입들은 서로 상속 관계가 없는 평범한 구조체이고, std::vector<Event>처럼 값으로 연속 메모리에 담을 수 있습니다. Event에 타입을 추가하면 이 visit이 컴파일 에러를 내므로 처리 누락이 생기지 않습니다.

결제 수단: 함수 객체 방문자

#include <iostream>
#include <string>
#include <variant>

struct CreditCard { std::string number; std::string expiry; };
struct BankTransfer { std::string account; std::string bank_code; };
struct MobilePay { std::string phone; std::string carrier; };
using PaymentMethod = std::variant<CreditCard, BankTransfer, MobilePay>;

struct ProcessPayment {
    std::string operator()(const CreditCard& c) const {
        return "Processing card: " + c.number.substr(0, 4) + "****";
    }
    std::string operator()(const BankTransfer& b) const {
        return "Transfer to " + b.account;
    }
    std::string operator()(const MobilePay& m) const {
        return "Mobile: " + m.phone;
    }
};

int main() {
    PaymentMethod pm = CreditCard{"1234567890123456", "12/28"};
    std::cout << std::visit(ProcessPayment{}, pm) << "\n";
}

로직이 길어지면 람다 대신 이렇게 operator()를 여러 개 가진 구조체로 방문자를 분리하는 편이 읽기 쉽습니다. 모든 오버로드의 반환 타입이 같아야 std::visit의 반환 타입이 정해집니다.

합성: 다중 로그 싱크

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

class ILogSink {
public:
    virtual ~ILogSink() = default;
    virtual void write(const std::string& msg) = 0;
};

class ConsoleSink : public ILogSink {
public:
    void write(const std::string& msg) override { std::cout << "[Console] " << msg << "\n"; }
};

class FileSink : public ILogSink {
    std::string path_;
public:
    explicit FileSink(std::string path) : path_(std::move(path)) {}
    void write(const std::string& msg) override { /* path_에 기록 */ }
};

class Logger {
    std::vector<std::unique_ptr<ILogSink>> sinks_;
public:
    void add_sink(std::unique_ptr<ILogSink> s) { sinks_.push_back(std::move(s)); }
    void log(const std::string& msg) {
        for (auto& s : sinks_) s->write(msg);
    }
};

int main() {
    Logger logger;
    logger.add_sink(std::make_unique<ConsoleSink>());
    logger.add_sink(std::make_unique<FileSink>("/tmp/app.log"));
    logger.log("Hello");  // 두 싱크 모두에 기록
}

자주 만나는 에러

std::get으로 잘못된 타입 접근

std::variant<int, std::string> v = 42;
auto s = std::get<std::string>(v);  // ❌ std::bad_variant_access

if (auto* p = std::get_if<std::string>(&v)) {  // ✅ 맞지 않으면 nullptr
    // *p 사용
}
if (std::holds_alternative<std::string>(v)) {   // ✅ 확인 후 꺼내기
    auto s2 = std::get<std::string>(v);
}

visit에서 일부 타입을 처리하지 않음

using V = std::variant<int, double, std::string>;
V v = 3.14;
std::visit(overloaded{
    [](int) {},
    [](double) {}
    // ❌ std::string을 받을 오버로드가 없어 컴파일 에러
}, v);

모든 타입에 대한 람다를 제공하는 것이 원칙입니다. 정말로 “나머지는 모두 무시”가 의도라면 [](const auto&) {} 같은 포괄 람다를 추가할 수 있지만, 이렇게 하면 나중에 추가한 타입도 조용히 이 람다로 흘러가 컴파일 에러라는 안전장치를 잃습니다. 또 int를 받는 람다는 char나 bool 대안도 암시적 변환으로 받아 버리므로, 대안 타입끼리 변환 가능한 경우에는 오버로드가 의도대로 골라지는지 확인해야 합니다.

복사할 수 없는 타입을 담은 variant

std::variant<int, std::mutex> v;처럼 복사·이동할 수 없는 타입도 variant에 담을 수 있습니다. 다만 그 variant 자체도 복사·이동이 삭제되므로, 컨테이너에 넣거나 함수에서 값으로 반환하려 할 때 컴파일 에러가 납니다. 값처럼 옮겨 다녀야 한다면 std::unique_ptr<std::mutex>처럼 이동 가능한 핸들로 감쌉니다.

valueless_by_exception

이미 값이 있는 variant에 다른 타입의 값을 대입하거나 emplace하는 도중, 새 값의 생성자가 예외를 던지면 기존 값은 이미 파괴되었고 새 값은 만들어지지 않아 variant가 값이 없는 상태가 됩니다. 이 상태에서 std::visit이나 std::get을 호출하면 std::bad_variant_access가 발생합니다.

if (v.valueless_by_exception()) {
    v = 0;  // 정상 값을 다시 대입해 복구
}

방문자 람다의 매개변수 형태

using V = std::variant<int, std::string>;
const V v = std::string("hello");
std::visit(overloaded{
    [](int) {},
    [](std::string& s) {}         // ❌ const variant에서는 const 참조가 넘어오므로 바인딩 불가
}, v);

std::visit(overloaded{
    [](int) {},
    [](const std::string& s) {}   // ✅
}, v);

[](std::string s)처럼 값으로 받는 것은 컴파일되지만 매번 복사가 일어나므로, 큰 타입은 const T&로 받는 것이 좋습니다. variant를 수정하는 방문자라면 non-const variant를 넘기고 T&로 받습니다.

합성에서 nullptr 전달

Exporter exporter(nullptr);   // ❌ exportData에서 nullptr 역참조

explicit Exporter(std::unique_ptr<ISerializer> s) : serializer_(std::move(s)) {
    if (!serializer_) throw std::invalid_argument("serializer cannot be null");
}

생성자에서 검사하거나, null이 될 수 없는 참조형 래퍼(gsl::not_null 등)로 의도를 드러냅니다. 소유권을 넘기지 않고 ISerializer&나 원시 포인터로 받아 멤버에 저장하는 설계라면, 넘겨준 객체가 Exporter보다 먼저 사라지지 않는지도 함께 관리해야 합니다.


성능과 메모리 레이아웃

가상 함수 호출과 std::visit의 성능 차이는 호출하는 함수가 하는 일의 양에 크게 좌우됩니다. 함수 본문이 가벼우면 variant 쪽이 빠른 경우가 많은데, 이유는 두 가지입니다. variant는 모든 대안을 객체 안에 담으므로 vector<Event>처럼 연속 메모리에 둘 수 있어 캐시 미스가 적고, 컴파일러가 모든 대안 타입을 알고 있어 visit의 분기를 인라인하기 쉽습니다. 상속은 vector<unique_ptr<Event>>의 객체가 힙 곳곳에 흩어지고 호출마다 vtable을 거칩니다. 반대로 함수 본문이 무거우면 분기 방식의 차이는 거의 보이지 않으므로, 성능이 선택 기준이라면 실제 처리 코드로 측정해 보는 것이 가장 확실합니다.

flowchart LR
    subgraph inherit[상속]
        H1["포인터 (8B)"] --> H2[힙 객체]
        H2 --> H3[vptr + 데이터]
    end
    subgraph var[variant]
        V1[값 하나] --> V2["가장 큰 대안 크기 + 인덱스"]
    end

variant의 크기는 가장 큰 대안에 맞춰지므로, 대안 중 하나만 유독 크면 모든 원소가 그 크기를 차지합니다. 이런 경우 큰 대안만 unique_ptr로 감싸 크기를 맞추는 방법이 있습니다.


variant로 자주 쓰는 패턴

Result 타입

#include <string>
#include <variant>

struct Error {
    std::string message;
    int code;
};

template <typename T>
using Result = std::variant<T, Error>;

Result<int> parse_int(const std::string& s) {
    if (s.empty()) return Error{"empty string", -1};
    try {
        return std::stoi(s);
    } catch (const std::exception&) {
        return Error{"parse failed", -2};
    }
}

auto r = parse_int("42");
std::visit(overloaded{
    [](int value) { /* 성공 */ },
    [](const Error& err) { /* 에러 처리 */ }
}, r);

C++23을 쓸 수 있다면 같은 목적의 std::expected<int, Error>가 더 명확한 API(has_value(), value(), error())를 제공합니다.

상태 머신

struct Idle {};
struct Running { int progress; };
struct Paused {};
struct Finished { int result; };
using State = std::variant<Idle, Running, Paused, Finished>;

void update(State& s) {
    std::visit(overloaded{
        [](Idle&) { /* 시작 대기 */ },
        [](Running& r) { r.progress++; },
        [](Paused&) { /* 일시정지 */ },
        [](Finished&) { /* 결과 보관 */ }
    }, s);
}

상태마다 필요한 데이터만 갖게 되어, “Running일 때만 유효한 progress” 같은 규칙을 타입으로 표현할 수 있습니다. 상태 전이는 방문자가 새 상태를 반환하게 하고 s = std::visit(...)로 대입하는 방식으로 만들 수 있습니다.

단순한 수식 트리

#include <memory>
#include <variant>

struct IntLiteral;
struct BinaryOp;
using Expr = std::variant<IntLiteral, std::unique_ptr<BinaryOp>>;

struct IntLiteral { int value; };
struct BinaryOp {
    char op;
    Expr left;
    Expr right;
};

int eval(const Expr& e) {
    return std::visit(overloaded{
        [](const IntLiteral& lit) { return lit.value; },
        [](const std::unique_ptr<BinaryOp>& b) {
            int l = eval(b->left), r = eval(b->right);
            return b->op == '+' ? l + r : l - r;
        }
    }, e);
}

variant는 불완전한 타입을 직접 담을 수 없으므로, 자기 자신을 포함하는 재귀 구조는 unique_ptr로 한 단계 감쌉니다.

타입이 많을 때 그룹으로 나누기

대안 타입이 많아지면 하나의 방문자가 비대해져 읽기 어려워집니다. 관련 있는 타입끼리 묶어 2단계로 방문하면 각 방문자가 작아집니다.

using UIEvent = std::variant<MouseClick, KeyPress, TouchEvent>;
using NetworkEvent = std::variant<Connected, Disconnected, DataReceived>;
using AppEvent = std::variant<UIEvent, NetworkEvent>;

void handle(const AppEvent& e) {
    std::visit(overloaded{
        [](const UIEvent& ui) { std::visit(UiHandler{}, ui); },
        [](const NetworkEvent& net) { std::visit(NetHandler{}, net); }
    }, e);
}

합성 구조의 테스트

class FakeSerializer : public ISerializer {
public:
    mutable int calls = 0;
    std::string serialize(const Data&) const override {
        ++calls;
        return "{}";
    }
};

TEST(ExporterTest, ExportCallsSerializer) {
    auto fake = std::make_unique<FakeSerializer>();
    auto* ptr = fake.get();
    Exporter exporter(std::move(fake));
    exporter.exportData(Data{"x"});
    EXPECT_EQ(1, ptr->calls);
}

상속에서 variant로 옮길 때

기존 상속 코드를 variant로 바꿀 때는 먼저 공통 기반 클래스를 없애고 각 타입을 평범한 구조체로 만듭니다. e->handle() 같은 가상 함수 호출은 std::visit(handler, e)로 바꾸며, 모든 오버로드의 반환 타입을 맞춰야 합니다. 컨테이너는 vector<unique_ptr<Event>>에서 vector<Event>로 바꿔 메모리 연속성을 얻을 수 있지만, 대안 중 하나가 매우 크면 모든 원소가 그 크기가 된다는 점을 고려합니다. 이후 variant에 타입을 추가하면 모든 visit 호출부가 컴파일 에러로 드러나므로, 빠짐없이 고칠 수 있습니다.

더 나아가 볼 주제로는 전략·관찰자 패턴, std::function과 std::any를 이용한 타입 소거, CRTP를 이용한 컴파일 타임 다형성이 있습니다. 디자인 패턴(#19-1)과 템플릿(#9-1)도 함께 참고하십시오.


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. variant와 std::any의 차이는?

A. std::variant<T1, T2, T3>는 “T1, T2, T3 중 정확히 하나”를 담고, 가능한 타입 목록이 타입에 드러나 있어 std::visit으로 빠짐없이 처리할 수 있습니다. std::any는 복사 가능한 어떤 타입이든 담을 수 있지만, 꺼낼 때 any_cast로 정확한 타입을 지정해야 하고 틀리면 예외가 발생하며, 큰 값은 힙에 저장됩니다. 타입 집합이 유한하고 고정이면 variant, 정말로 임의의 타입이 올 수 있는 경우에만 any를 고려합니다.

Q. 합성으로 바꿨더니 생성자가 너무 많은 인자를 받아요.

A. 관련 있는 의존성을 struct Config { std::unique_ptr<ISerializer> serializer; ... } 같은 설정 객체로 묶어 한 번에 넘기거나, 빌더 패턴으로 단계적으로 조립할 수 있습니다. 인자가 계속 늘어난다면 그 클래스가 너무 많은 책임을 지고 있다는 신호일 수 있으므로, 역할을 나누는 것도 검토합니다.

다음으로 PIMPL·ABI(#38-3)를 읽어 보면 좋습니다.

이전 글: C++ 아키텍처 #38-1: 클린 코드 기초

다음 글: [C++ 아키텍처 #38-3] 인터페이스 설계와 PIMPL: 컴파일 의존성을 끊고 바이너리 호환성(ABI) 유지하기


참고 자료