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)도 함께 참고하십시오.
같이 보면 좋은 글
- C++ 상속과 다형성
- C++ 가상 함수(Virtual Function)와 vtable의 동작 원리 [#33-1]
- C++17 optional·variant·any: nullptr 체크 대신 타입으로 표현하기
- const·noexcept·[[nodiscard]]로 C++ 인터페이스 의도 명확히 하기
- string_view와 span
- C++ 인터페이스 설계와 PIMPL: 컴파일 의존성을 끊고 바이너리 호환성(ABI) 유지하기 [#38-3]
- C++ 미리 사용해 보는 C++23 핵심 기능 [#37-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) 유지하기