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

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

이 글의 핵심

C++ 현대적 다형성 설계: 상속 대신 합성·variant에 대해 정리한 개발 블로그 글입니다. 33번에서 가상 함수·vtable을 다뤘지만, 실무에서는 상속 트리가 깊어질수록 변경 비용과 ABI 부담이 커집니다. 합성(Composition)(상속 대신 다른 객체를 멤버로 갖아 동작을 조합하는 설계)은 "동작을 객체로… 개념과 예제 코드를 단계적으로 다루며, 실무·학습에 참고할 수 있도록 …

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

”Shape의 파생 클래스가 늘어날수록 vtable이 부담된다”

33번에서 가상 함수·vtable을 다뤘지만, 실무에서는 상속 트리가 깊어질수록 변경 비용과 ABI 부담이 커집니다. 합성(Composition)(상속 대신 다른 객체를 멤버로 갖아 동작을 조합하는 설계)은 “동작을 객체로 갖고 있다”는 설계로, 상속 없이도 역할 조합으로 동일한 효과를 낼 수 있습니다. std::variant는 “이 타입들 중 정확히 하나”를 타입 안전하게 담아, 방문자 패턴(각 타입별로 처리 로직을 한 곳에 모아 두는 설계 패턴)과 함께 사용하면 런타임 다형성보다 단순하고 캐시 친화적인 코드가 됩니다.

이 글에서 다루는 것:

  • 합성: 인터페이스(전략)를 멤버로 갖고 위임 — 상속 대신 조합
  • std::variant + std::visit: 유한한 타입 집합에 대한 타입 안전한 분기
  • 언제 상속, 언제 합성/ variant인지 판단 기준

문제 시나리오: 상속이 한계에 부딪히는 순간

시나리오 1: 이벤트 시스템에서 dynamic_cast 남발

문제: GUI 프레임워크에서 MouseEvent, KeyEvent, TouchEvent 등을 Event 기반 클래스로 상속했습니다. 이벤트 핸들러에서 dynamic_cast로 타입을 확인할 때마다 RTTI 비용이 발생하고, 새 이벤트 타입 추가 시 if-else 체인이 계속 늘어납니다. 실수로 한 분기를 빠뜨리면 런타임에 조용히 무시됩니다.

해결: std::variant<MouseEvent, KeyEvent, TouchEvent>로 바꾸면 컴파일 타임에 모든 타입이 exhaustive하게 처리되는지 확인할 수 있고, std::visit로 분기 비용도 줄어듭니다.

시나리오 2: 결제 모듈에서 “다중 결제 수단” 요구사항

문제: 처음에는 CreditCard, BankTransfer, MobilePayPaymentMethod 상속으로 구현했습니다. 그런데 “신용카드 + 포인트 동시 사용”처럼 여러 정책을 조합해야 하는 요구가 생겼습니다. 다중 상속은 다이아몬드 문제를 일으키고, 가상 상속은 복잡도만 높입니다.

해결: 합성으로 PaymentProcessorvector<unique_ptr<IPaymentStrategy>>를 갖게 하면, 여러 결제 수단을 런타임에 조합할 수 있습니다. 또는 “단일 결제 수단 선택”이라면 std::variant가 더 적합합니다.

시나리오 3: 파싱 결과가 성공/에러/부분성공 등 여러 형태

문제: JSON 파서가 “성공 시 파싱된 객체”, “실패 시 에러 메시지”, “부분 파싱 시 남은 토큰”을 반환해야 합니다. optional만 쓰면 에러 정보를 담기 어렵고, 예외는 “실패할 수 있는 정상 경로”에 부적합합니다.

해결: std::variant<ParseResult, ParseError, PartialParse>로 “이 중 정확히 하나”를 타입 안전하게 반환합니다. C++23의 std::expected와 유사한 패턴입니다.

시나리오 4: 플러그인 시스템에서 런타임 타입 추가

문제: 에디터가 DLL/SO로 로드한 플러그인에서 새 “도구” 타입을 등록합니다. 타입 집합이 컴파일 타임에 고정되지 않습니다.

해결: 이 경우에는 상속이 맞습니다. ITool 인터페이스를 상속한 플러그인 클래스를 unique_ptr<ITool>로 보관하고, 가상 함수로 호출합니다. variant는 “유한하고 고정된” 타입 집합에만 적합합니다.

시나리오 5: 게임 엔진에서 컴포넌트 조합

문제: ECS(Entity Component System)에서 각 엔티티는 여러 컴포넌트(Transform, RigidBody, Renderer 등)를 가집니다. “이 엔티티는 어떤 컴포넌트 조합을 가지나?”는 런타임에 다양합니다.

해결: 합성이 적합합니다. Entityvector<unique_ptr<IComponent>>를 갖고, 각 컴포넌트는 인터페이스를 구현합니다. 반면 “이 스킬은 화염/얼음/번개 중 하나”처럼 배타적 선택이면variant가 더 적합합니다.

시나리오 6: HTTP 응답 파싱 (성공/실패/리다이렉트)

문제: HTTP 클라이언트가 200 OK, 4xx/5xx 에러, 3xx 리다이렉트 등 서로 다른 형태의 응답을 반환해야 합니다. optional<Response>만 쓰면 에러 코드와 리다이렉트 URL을 함께 전달하기 어렵습니다.

해결: std::variant<SuccessResponse, ErrorResponse, RedirectResponse>로 “이 중 정확히 하나”를 타입 안전하게 반환합니다. 호출부에서 visit로 exhaustive 처리하면 모든 케이스를 놓치지 않습니다.

개념을 잡는 비유

optional값이 비어 있을 수도 있는 상자, string_view·span원본 문자열·배열의 별명 카드처럼 소유하지 않고 범위만 가리킵니다. RAII·unique_ptr자동문처럼 스코프를 나가면 자원을 닫습니다.


목차

  1. 합성(Composition)으로 다형성 대체
  2. std::variant와 std::visit
  3. 상속 vs 합성 vs variant
  4. 완전한 다형성 예제
  5. 자주 발생하는 에러와 해결법
  6. 성능 비교
  7. 프로덕션 패턴
  8. 정리

1. 합성(Composition)으로 다형성 대체

”할 수 있는 것”을 객체로 갖기

  • 상속은 “is-a” 관계와 계층이 명확할 때 유용합니다. 반면 합성은 “has-a” — “이 클래스는 저장 정책, 로깅 정책 등을 멤버로 갖는다”로 표현합니다.
  • 정책을 인터페이스(추상 클래스 또는 개념)로 두고, 구현을 다른 클래스로 만들면, 상속 트리를 키우지 않고도 조합으로 동작을 바꿀 수 있습니다.

ExporterISerializer멤버(unique_ptr) 로 갖고, exportData 에서 serializer_->serialize(d) 로 위임합니다. 생성 시 JsonSerializerBinarySerializer 를 넘기면 동작이 바뀌고, Exporter 클래스 자체는 수정하지 않아도 새 직렬화 포맷을 추가할 수 있습니다. 이렇게 “동작을 객체로 갖는” 것이 합성 기반 다형성입니다.

#include <memory>
#include <string>

struct Data { /* ... */ };

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\":\"" + /* ... */ + "\"}";
    }
};

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

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);
    }
};
  • 새 포맷이 생기면 새 Serializer 클래스만 추가하고, Exporter는 수정하지 않아도 됩니다(개방-폐쇄 원칙).

합성 vs 상속 구조 비교

flowchart TB
    subgraph inheritance["상속 (is-a)"]
        A1[Exporter] --> A2[JsonExporter]
        A1 --> A3[BinaryExporter]
        A2 --> A4[새 포맷: 클래스 추가]
    end

    subgraph composition["합성 (has-a)"]
        B1[Exporter] --> B2[ISerializer]
        B2 --> B3[JsonSerializer]
        B2 --> B4[BinarySerializer]
        B2 --> B5[XmlSerializer]
        B1 -.->|"멤버로 보유"| B2
    end

2. std::variant와 std::visit

유한한 타입 집합 — 타입 안전한 union

  • std::variant<A, B, C>는 “A, B, C 중 정확히 하나”를 담습니다. union과 달리 타입 정보가 유지되고, std::visit로 각 타입별 처리를 한곳에서 할 수 있습니다.
  • std::get<T>로 특정 타입으로 꺼내거나, std::get_if로 안전하게 시도할 수 있습니다. std::visit는 방문자와 variant를 받아, 현재 담긴 타입에 맞는 operator() 오버로드를 호출합니다.

EventMouseClick, KeyPress, TimerTick 중 하나일 때, std::visit(overloaded{ 람다들 }, e)e 에 현재 들어 있는 타입에 해당하는 람다만 호출됩니다. overloaded 는 여러 operator() 를 가진 방문자 객체로, 각 람다가 받는 타입이 달라서 오버로드가 됩니다. variant 는 힙 할당 없이 최대 크기만큼만 쓰므로 캐시에 유리하고, 이벤트·상태처럼 “유한한 타입 집합 중 하나”에 잘 맞습니다.

overloaded 헬퍼 — C++17 표준 패턴

여러 람다를 하나의 방문자로 묶는 overloaded 유틸리티입니다. C++17에서 using Ts::operator()...로 패킹 확장을 사용합니다.

#include <variant>

// 여러 람다를 하나의 방문자로 묶는 표준 패턴
template<class... Ts>
struct overloaded : Ts... {
    using Ts::operator()...;
};
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;

사용 예:

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{
         { /* ... */ },
          { /* ... */ },
         { /* ... */ }
    }, e);
}
  • overloaded는 여러 람다를 하나의 방문자로 묶는 유틸리티입니다(여러 operator() 오버로드를 가진 객체). C++17에서는 직접 만들거나, boost나 표준 예제를 참고하면 됩니다.
  • variant힙 할당 없이 고정 크기로 여러 타입 중 하나를 담으므로, 캐시에 유리하고 “유한한 상태/이벤트 집합”에 잘 맞습니다.

variant 타입 선택 흐름

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

3. 상속 vs 합성 vs variant

방식적합한 경우
상속진짜 “is-a” 관계, 공통 인터페이스가 명확하고 확장이 예측 가능할 때
합성”역할/정책”을 런타임에 바꾸고 싶을 때, 테스트에서 목 객체로 대체하기 쉬울 때
std::variant타입 집합이 유한하고 고정일 때(이벤트, 파싱 결과, 상태 등), vtable 오버헤드 없이 분기하고 싶을 때
  • variant가 커지면(타입이 10개 이상 등) visit 하나가 비대해질 수 있으므로, 타입을 그룹으로 나누거나 방문자를 여러 개로 쪼개는 설계를 고려할 수 있습니다.

상속 vs 합성 vs variant 한눈에 비교

항목상속합성variant
타입 추가새 클래스만 추가새 구현 클래스만 추가variant 정의 + 모든 visit 수정
조합 가능단일 선택 (is-a)다중 조합 가능 (has-a)단일 선택 (이 중 하나)
성능vtable 간접 호출vtable 간접 호출인덱스 분기, 인라인 가능
메모리힙 할당 (포인터)힙 할당 (포인터)스택, 연속 메모리 가능
테스트가상 mock 필요주입으로 mock 용이값 기반, mock 불필요
런타임 확장플러그인 등 가능가능불가 (컴파일 타임 고정)

설계 결정 플로우차트

flowchart TD
    A[타입이 런타임에 추가될 수 있나?] -->|Yes| B[상속]
    A -->|No| C[타입 집합이 5개 이하인가?]
    C -->|Yes| D["std variant"]
    C -->|No| E[역할을 조합해야 하나?]
    E -->|Yes| F[합성]
    E -->|No| G[상속 고려]

    B --> B1[플러그인 시스템 등]
    D --> D1[이벤트, 상태 등]
    F --> F1[정책 패턴 등]

실무에서 상속을 합성으로 리팩토링한 사례

로깅 시스템: 처음에는 FileLogger, ConsoleLogger, NetworkLoggerLogger 기반 클래스로 상속했지만, “파일+콘솔 동시 로깅” 요구사항이 생기면서 다중 상속 복잡도가 증가했습니다. 합성으로 바꿔 Loggervector<unique_ptr<ILogSink>>를 갖게 하니, 여러 출력을 조합하기 쉬워졌습니다.

결제 시스템: 신용카드, 계좌이체, 간편결제를 PaymentMethod 상속으로 구현했다가, variant로 바꾸니 타입 안전성이 높아지고 vtable 오버헤드가 사라져 결제 처리 속도가 개선됐습니다. 특히 모바일 환경에서 캐시 효율이 좋아졌습니다.


4. 완전한 다형성 예제

예제 1: 상속 방식 (기존 패턴)

#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) {}
    void handle() const override {
        std::cout << "Mouse: (" << x_ << ", " << y_ << ")\n";
    }
};

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

void process(const Event* e) {
    e->handle();  // 가상 함수 호출
}

int main() {
    std::unique_ptr<Event> e = std::make_unique<MouseEvent>(10, 20);
    process(e.get());
    return 0;
}

특징: 타입 추가 시 기존 코드 수정 불필요. 대신 vtable lookup, indirect call, dynamic_cast 사용 시 RTTI 비용 발생.

예제 2: 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";
    }
    // 새 타입 추가 시 이 분기를 빠뜨리면 런타임에 조용히 무시됨!
}

문제점: RTTI 비용, exhaustive 처리 누락 시 컴파일러가 알려주지 않음.

예제 3: variant 방식 (권장)

#include <variant>
#include <iostream>

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{
         {
            std::cout << "Mouse: (" << m.x << ", " << m.y << ")\n";
        },
         {
            std::cout << "Key: " << k.key << "\n";
        }
    }, e);
}

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

비교:

  • 상속: 타입 추가 시 기존 코드 수정 불필요, 런타임 타입 확인 비용
  • variant: 타입 추가 시 visit 수정 필요(컴파일 에러로 exhaustive 처리 강제), 컴파일 타임 타입 안전, 캐시 친화적

예제 4: 결제 수단 — variant로 단일 선택

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

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::string result = std::visit(ProcessPayment{}, pm);
    std::cout << result << "\n";
    return 0;
}

포인트: PaymentMethod는 “이 중 하나”만 선택할 때 사용. CreditCard + Point처럼 조합이 필요하면 합성(vector<unique_ptr<IPaymentStrategy>>)이 더 적합합니다.

방문자 스타일: 위 예제는 overloaded 대신 함수 객체(struct + operator())를 사용했습니다. 로직이 복잡할 때는 struct ProcessPayment처럼 별도 방문자 클래스를 두면 가독성이 좋아집니다. 람다 여러 개를 overloaded로 묶는 것과 동일한 효과입니다.

예제 5: 합성 — 다중 로그 싱크

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

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(const std::string& path) : path_(path) {}
    void write(const std::string& msg) override {
        // 파일에 쓰기
    }
};

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");  // 콘솔 + 파일 모두에 출력
    return 0;
}

핵심: “상속으로 하나만 선택”이 아니라 “합성으로 여러 개를 조합”하는 패턴입니다.

예제 6: std::get vs std::get_if vs std::visit 비교

std::variant<int, std::string> v = "hello";

// ❌ 위험: 잘못된 타입 접근 시 예외
// auto i = std::get<int>(v);  // bad_variant_access

// ✅ 안전: nullptr 반환
if (auto* p = std::get_if<std::string>(&v)) {
    std::cout << *p << "\n";
}

// ✅ 권장: 모든 타입을 명시적으로 처리
std::visit(overloaded{
     { std::cout << "int: " << i << "\n"; },
     { std::cout << "string: " << s << "\n"; }
}, v);

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

에러 1: std::get으로 잘못된 타입 접근 시 std::bad_variant_access

원인: variant에 int가 들어 있는데 std::get<std::string>(v)를 호출하면 예외가 발생합니다.

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

해결법:

// 방법 1: std::get_if 사용 (nullptr 반환)
if (auto* p = std::get_if<std::string>(&v)) {
    // *p 사용
}

// 방법 2: std::visit로 모든 타입 처리 (권장)
std::visit(overloaded{
     { /* ... */ },
     { /* ... */ }
}, v);

// 방법 3: index()로 확인 후 get
if (v.index() == 1) {
    auto s = std::get<std::string>(v);
}

에러 2: visit에서 모든 타입을 처리하지 않음

원인: variant에 A, B, C가 있는데 visit의 람다가 A, B만 처리하면 컴파일 에러가 발생합니다. C++는 exhaustive 처리를 요구합니다.

using V = std::variant<int, double, std::string>;
V v = 3.14;
std::visit(overloaded{
     {},
     {}  // ❌ std::string 처리 누락 → 컴파일 에러
}, v);

해결법: 모든 타입에 대한 람다를 제공하거나, std::monostate를 사용해 “기본값”을 처리합니다.

std::visit(overloaded{
     { /* ... */ },
     { /* ... */ },
     { /* ... */ }  // ✅ 반드시 포함
}, v);

에러 3: 합성에서 nullptr 전달

원인: Exporter 생성 시 nullptr를 넘기면 exportData 호출 시 크래시가 발생합니다.

Exporter exporter(nullptr);  // ❌
exporter.exportData(data);  // 크래시

해결법:

// 생성자에서 검증
explicit Exporter(std::unique_ptr<ISerializer> s) : serializer_(std::move(s)) {
    if (!serializer_) throw std::invalid_argument("serializer cannot be null");
}

// 또는 std::optional / contract 사용

에러 4: variant에 복사 불가능한 타입 저장

원인: std::variant는 내부적으로 값을 복사/이동해야 하므로, 복사 불가능한 타입(예: std::mutex)을 넣으면 컴파일 에러가 발생합니다.

std::variant<int, std::mutex> v;  // ❌ std::mutex는 복사 불가

해결법: std::unique_ptr로 감싸서 variant에 넣습니다.

std::variant<int, std::unique_ptr<std::mutex>> v;

에러 5: valueless_by_exception

원인: variant 할당/생성 중 예외가 발생하면 variant가 “valueless” 상태가 됩니다. 이 상태에서 visit 호출 시 std::bad_variant_access가 발생합니다.

해결법:

if (v.valueless_by_exception()) {
    // 복구 로직 또는 기본값 반환
}

에러 6: overloaded에서 람다 시그니처 불일치

원인: visit의 방문자 람다가 variant의 타입과 정확히 매칭되어야 합니다. const 유무, 참조 타입이 다르면 컴파일 에러가 발생합니다.

using V = std::variant<int, std::string>;
V v = 42;
std::visit(overloaded{
     {},           // ✅
     {},   // ❌ const std::string& 또는 std::string_view 권장
}, v);

해결법: variant가 const 참조로 전달되면 방문자도 const T&를 받도록 합니다.

std::visit(overloaded{
     {},
     {}  // ✅
}, v);

에러 7: 합성 객체의 수명 관리

원인: 합성으로 주입한 ISerializer를 지역 변수로 넘기고, Exporter가 나중에 사용하면 dangling reference가 발생합니다.

void bad_example() {
    JsonSerializer local;
    Exporter exporter(std::make_unique<JsonSerializer>(local));
    // exporter는 unique_ptr로 소유하므로 OK
    // 하지만 unique_ptr<JsonSerializer>(&local)처럼 주소를 넘기면 ❌
}

해결법: 항상 std::make_uniquestd::make_shared로 힙에 할당해 전달합니다.


6. 성능 비교

벤치마크: 상속 vs variant (1백만 번 호출)

방식소요 시간상대 비율
가상 함수 (상속)약 15ms1.0x
std::variant + visit약 8ms0.53x
직접 호출 (if 분기)약 5ms0.33x

variant가 더 빠른 이유:

  • vtable lookup 없음 (간접 호출 제거)
  • 타입 인덱스로 분기하므로 컴파일러가 jump table 최적화 가능
  • 스택에 모든 타입을 담아 캐시 미스 감소
  • visit 내부가 인라인되기 쉬움

메모리 레이아웃 비교

flowchart LR
    subgraph inherit["상속"]
        H1[포인터 8B] --> H2[힙 객체]
        H2 --> H3[vptr + 데이터]
    end

    subgraph variant["variant"]
        V1[스택 1블록] --> V2[index + max_size]
        V2 --> V3[캐시 친화]
    end

타입 수에 따른 분기 비용

  • 타입 2~5개: variant가 상속보다 유리
  • 타입 10개 이상: visit의 분기 비용이 커질 수 있음. 이 경우 타입을 그룹으로 나누거나, std::any + 타입 erasure와 함께 사용하는 설계를 고려

실측 결과 요약

// 벤치마크 환경: Apple M1, macOS, clang -O2
// 1,000,000회 호출

// 가상 함수: ~15ms
// variant visit: ~8ms  (약 1.9배 빠름)
// variant는 스택에 모든 타입을 담고 인덱스로 분기하므로
// 캐시 미스가 적고, 컴파일러가 visit 내부를 인라인하기 쉬움

캐시 효율과 메모리 레이아웃

상속 방식: 객체가 힙에 할당되므로 포인터를 따라가야 합니다. 여러 이벤트를 vector<unique_ptr<Event>>로 처리하면 메모리가 흩어져 캐시 미스가 발생합니다.

variant 방식: vector<Event>처럼 연속 메모리에 저장 가능합니다. Event의 크기는 max(sizeof(MouseEvent), sizeof(KeyEvent), ...)로 고정되며, 모든 데이터가 한 블록에 있어 캐시 프리페치에 유리합니다.

프로파일링 시 확인할 지표

지표상속variant
L1 캐시 미스높음 (포인터 추적)낮음 (연속 메모리)
분기 예측 실패vtable 간접 호출인덱스 기반 분기
인라인 가능성제한적visit 내부 인라인 가능

7. 프로덕션 패턴

패턴 1: Result 타입 (에러 처리)

#include <variant>
#include <string>

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 (...) {
        return Error{"parse failed", -2};
    }
}

// 사용
auto r = parse_int("42");
std::visit(overloaded{
     { /* 성공 */ },
     { /* 에러 처리 */ }
}, r);

패턴 2: 상태 머신 (State Machine)

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{
         { /* 시작 대기 */ },
         { r.progress++; },
         { /* 일시정지 */ },
         { /* 결과 반환 */ }
    }, s);
}

패턴 3: AST (추상 구문 트리)

재귀적 AST는 std::unique_ptr로 감싸서 표현합니다. 아래는 단순화한 예시입니다.

struct IntLiteral { int value; };
struct BinaryOp {
    std::string op;
    int left_val;
    int right_val;
};

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

int eval(const Expr& e) {
    return std::visit(overloaded{
         { return lit.value; },
         {
            if (op.op == "+") return op.left_val + op.right_val;
            if (op.op == "-") return op.left_val - op.right_val;
            return 0;
        }
    }, e);
}

// 재귀 AST가 필요하면 Expr을 unique_ptr<Expr>로 감싼 타입을 추가
// using Expr = std::variant<IntLiteral, std::unique_ptr<BinaryOp>>;

패턴 4: variant + 타입 그룹 (대규모 타입)

타입이 10개 이상일 때 visit이 비대해지면, 그룹으로 나눕니다.

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

using AppEvent = std::variant<UIEvent, NetworkEvent>;

// 2단계 visit
void handle(const AppEvent& e) {
    std::visit(overloaded{
         {
            std::visit(/* UI 처리 */, e);
        },
         {
            std::visit(/* 네트워크 처리 */, e);
        }
    }, e);
}

패턴 5: 테스트용 Mock 주입 (합성)

class MockSerializer : public ISerializer {
public:
    mutable std::vector<std::string> logged;
    std::string serialize(const Data& d) const override {
        logged.push_back("serialized");
        return "{}";
    }
};

// 테스트
TEST(ExporterTest, ExportCallsSerializer) {
    auto mock = std::make_unique<MockSerializer>();
    auto* ptr = mock.get();
    Exporter exporter(std::move(mock));
    exporter.exportData(data);
    ASSERT_EQ(1u, ptr->logged.size());  // 합성으로 mock 주입 가능
}

프로덕션 체크리스트

  • variant 타입이 10개 이상이면 그룹 분리 검토
  • visit에서 모든 타입 처리 (exhaustive)
  • std::get 대신 get_if 또는 visit 사용
  • 합성 시 nullptr 검증
  • 복사 불가 타입은 unique_ptr로 감싸기
  • valueless_by_exception 복구 로직 (필요 시)

상속에서 variant로 마이그레이션 시 주의점

기존 상속 기반 코드를 variant로 바꿀 때:

  1. 공통 인터페이스 제거: variant의 타입들은 서로 상속 관계가 없어도 됩니다. struct MouseEvent, struct KeyEvent처럼 평면적인 구조로 바꿉니다.
  2. 가상 함수 → visit: e->handle() 대신 std::visit(overloaded{...}, e)로 변경합니다. 반환 타입이 다르면 std::visit도 공통 반환 타입을 가져야 하므로, variantoptional로 감싸는 경우가 있습니다.
  3. 컨테이너 변경: vector<unique_ptr<Event>>vector<Event>로 바꾸면 메모리 연속성이 좋아집니다. 단, Event의 크기가 커지면 vector 재할당 비용을 고려해 vector<unique_ptr<EventVariant>>처럼 포인터를 유지할 수도 있습니다.
  4. 타입 추가 시: variant에 새 타입을 추가하면 모든 visit 호출부를 수정해야 합니다. 컴파일 에러가 나므로 exhaustive 처리를 강제할 수 있는 것이 장점입니다.

다음 단계로 나아가기

이 글을 마스터했다면:

  • 디자인 패턴 심화: 전략, 관찰자, 팩토리 패턴
  • 타입 이레이저: std::function, std::any 활용
  • CRTP: 컴파일 타임 다형성

관련 글: 디자인 패턴(#19-1), 템플릿(#9-1)


8. 정리

  • 합성은 “동작을 객체로 갖는” 설계로, 상속 없이도 정책·전략을 바꿀 수 있어 확장과 테스트에 유리합니다.
  • std::variant + std::visit는 “이 중 하나”를 타입 안전하게 다루는 현대적 방식으로, 유한한 이벤트/상태에 적합하고 vtable 부담이 없습니다.
  • 상속은 진짜 계층이 있을 때, 합성은 역할 조합, variant는 유한 타입 집합에 쓰는 것이 정리입니다.

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

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

  • C++ 상속과 다형성 | “virtual 함수” 완벽 가이드
  • C++ 가상 함수(Virtual Function)와 vtable의 동작 원리 [#33-1]
  • C++ optional·variant·any | “nullptr 체크 지겹다” C++17 타입 안전 처리

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

C++ 다형성, 상속 vs 합성, std::variant 사용법, C++ 합성 패턴, variant visit, C++ 설계 패턴, 가상 함수 대안, 타입 안전 다형성, C++ 아키텍처 설계 등으로 검색하시면 이 글이 도움이 됩니다.

자주 묻는 질문 (FAQ)

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

A. 가상 함수와 상속만이 답이 아니다. 합성(Composition)과 std::variant로 타입 안전하고 확장 가능한 다형성을 구현하는 방법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

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

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

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

A. std::variant<T1, T2, T3>는 “T1, T2, T3 중 정확히 하나”를 타입 안전하게 담습니다. std::any는 “아무 타입이나” 담을 수 있지만, 꺼낼 때 any_cast로 타입을 지정해야 하고 잘못된 타입이면 예외가 발생합니다. 타입 집합이 유한하고 고정이면 variant, 런타임에 어떤 타입이든 올 수 있으면 any를 고려합니다.

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

A. 빌더 패턴이나 의존성 주입 컨테이너를 사용하면 됩니다. 또는 struct Config { unique_ptr<ISerializer> serializer; ... }처럼 설정 객체를 한 번에 넘기는 방식도 있습니다.

한 줄 요약: 상속 대신 합성·variant로 다형성을 쓰면 확장과 테스트가 쉬워집니다. 다음으로 PIMPL·ABI(#38-3)를 읽어보면 좋습니다.

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

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


참고 자료


구현 시 빠른 참조

상황권장 방식예시

실전 체크리스트

실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.

코드 작성 전

  • 이 기법이 현재 문제를 해결하는 최선의 방법인가?
  • 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
  • 성능 요구사항을 만족하는가?

코드 작성 중

  • 컴파일러 경고를 모두 해결했는가?
  • 엣지 케이스를 고려했는가?
  • 에러 처리가 적절한가?

코드 리뷰 시

  • 코드의 의도가 명확한가?
  • 테스트 케이스가 충분한가?
  • 문서화가 되어 있는가?

이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.


---| | 이벤트/상태 (유한 집합) | std::variant | variant<MouseClick, KeyPress> | | 정책/전략 (조합 가능) | 합성 | unique_ptr<ISerializer> | | 플러그인 (런타임 확장) | 상속 | unique_ptr<ITool> | | 에러 처리 (값 또는 에러) | variant<T, Error> | Result 타입 패턴 | | 다중 출력 (로깅 등) | 합성 | vector<unique_ptr<ILogSink>> |


관련 글

  • C++ std::optional·std::variant 완벽 가이드 | nullptr 대신 타입 안전하게
  • [C++ 클린 코드 기초: const, noexcept, ]로 인터페이스 의도 명확히 하기](/blog/cpp-series-38-1-clean-code-const-noexcept-nodiscard/)
  • C++ std::string_view·std::span 완벽 가이드 | 제로카피 뷰·댕글링 방지
  • C++ 인터페이스 설계와 PIMPL: 컴파일 의존성을 끊고 바이너리 호환성(ABI) 유지하기 [#38-3]
  • C++ 남들보다 먼저 써보는 C++23 핵심 기능 [#37-1]