Java나 C#에는 interface 키워드가 있지만 C++에는 없다. 대신 데이터 멤버 없이 순수 가상 함수와 가상 소멸자만 가진 추상 클래스를 인터페이스로 쓴다. 인터페이스를 두면 코드가 구체 클래스 대신 “이런 동작을 할 수 있는 것”에 의존하게 되어, 구현을 바꾸거나 테스트용 가짜 객체를 끼우기 쉬워진다. C++20부터는 상속 없이 컴파일 시점에 요구 조건을 거는 concept도 인터페이스 역할을 한다. 이 글에서는 g++ 13.3으로 다중 인터페이스 구현, 테스트 대역(test double) 주입, NVI 패턴, concept 기반 인터페이스, 인터페이스를 통한 복사 방지를 확인한다.
이 시리즈의 다른 글
- C++ 고급 문법 (21) — 가상 소멸자(Virtual Destructor)
- C++ 고급 문법 (22) — 추상 클래스(Abstract Class)
- C++ 고급 문법 (24) — RTTI(Run-Time Type Information)
- C++ 고급 문법 (25) — RTTI 직접 구현(Implementing RTTI)
순수 추상 클래스로 만드는 인터페이스
#include <cstdio>
#include <string>
#include <vector>
// 인터페이스: 데이터 없이 순수 가상 함수와 가상 소멸자만
struct Readable {
virtual ~Readable() = default;
virtual std::string read() = 0;
};
struct Writable {
virtual ~Writable() = default;
virtual void write(const std::string& s) = 0;
};
// 하나의 클래스가 여러 인터페이스를 구현
class MemoryFile : public Readable, public Writable {
std::vector<std::string> lines_;
std::size_t pos_ = 0;
public:
void write(const std::string& s) override { lines_.push_back(s); }
std::string read() override { return pos_ < lines_.size() ? lines_[pos_++] : "<eof>"; }
};
void produce(Writable& w) { w.write("hello"); w.write("world"); } // 필요한 능력만 요구
void consume(Readable& r) { for (int i = 0; i < 3; ++i) std::printf("%s\n", r.read().c_str()); }
int main()
{
MemoryFile f;
produce(f);
consume(f);
std::printf("sizeof(Readable)=%zu sizeof(MemoryFile)=%zu\n", sizeof(Readable), sizeof(MemoryFile));
}$ g++ -std=c++20 -Wall -o m multi.cpp
$ ./m
hello
world
<eof>
sizeof(Readable)=8 sizeof(MemoryFile)=48
produce는 쓰기만, consume은 읽기만 요구하므로 인터페이스를 작게 나눴다(interface segregation). 데이터가 없는 인터페이스는 다중 상속해도 다이아몬드 문제가 생기지 않고, 인터페이스마다 vptr 하나씩만 붙는다.
의존성 주입과 테스트 대역
#include <cstdio>
#include <string>
#include <vector>
struct Clock { // 외부 의존성을 인터페이스로 추상화
virtual ~Clock() = default;
virtual long now_ms() const = 0;
};
struct Mailer {
virtual ~Mailer() = default;
virtual void send(const std::string& to, const std::string& body) = 0;
};
class SessionGuard { // 테스트 대상: 구체 타입을 모른다
const Clock& clock_;
Mailer& mailer_;
long started_;
public:
SessionGuard(const Clock& c, Mailer& m) : clock_(c), mailer_(m), started_(c.now_ms()) {}
void check(const std::string& user)
{
if (clock_.now_ms() - started_ > 30'000) mailer_.send(user, "session expired");
}
};
// 테스트용 가짜 구현
struct FakeClock : Clock {
long t = 0;
long now_ms() const override { return t; }
};
struct RecordingMailer : Mailer {
std::vector<std::string> sent;
void send(const std::string& to, const std::string& body) override { sent.push_back(to + ": " + body); }
};
int main()
{
FakeClock clock;
RecordingMailer mailer;
SessionGuard g(clock, mailer);
clock.t = 10'000; g.check("kim");
std::printf("after 10s: %zu mails\n", mailer.sent.size());
clock.t = 31'000; g.check("kim");
std::printf("after 31s: %zu mails (%s)\n", mailer.sent.size(), mailer.sent.back().c_str());
}$ g++ -std=c++20 -Wall -o mk mock.cpp
$ ./mk
after 10s: 0 mails
after 31s: 1 mails (kim: session expired)
SessionGuard는 실제 시계나 메일 서버 없이도 테스트할 수 있다. 시간을 원하는 값으로 바꾸고, 보낸 메일은 실제로 보내지 않고 기록만 해서 확인했다.
NVI 패턴
#include <cstdio>
#include <stdexcept>
// NVI(Non-Virtual Interface): 공개 함수는 비가상, 구현 지점은 private 가상
class Storage {
public:
virtual ~Storage() = default;
void put(int key, int value) // 사전·사후 조건을 한 곳에서 강제
{
if (key < 0) throw std::invalid_argument("negative key");
do_put(key, value);
++writes_;
}
int writes() const { return writes_; }
private:
virtual void do_put(int key, int value) = 0; // 파생 클래스는 이것만 구현
int writes_ = 0;
};
class MemStorage : public Storage {
void do_put(int key, int value) override { std::printf(" mem[%d]=%d\n", key, value); }
};
int main()
{
MemStorage m;
Storage& s = m;
s.put(1, 100);
try { s.put(-1, 5); } catch (const std::exception& e) { std::printf(" rejected: %s\n", e.what()); }
std::printf(" writes=%d\n", s.writes());
}$ g++ -std=c++20 -Wall -o nv nvi.cpp
$ ./nv
mem[1]=100
rejected: negative key
writes=1
공개 인터페이스를 비가상 함수로 두면 인자 검증, 통계, 로깅 같은 공통 규칙을 파생 클래스가 우회할 수 없다. 파생 클래스는 private 가상 함수만 재정의한다(private 가상 함수도 재정의할 수 있다).
concept 기반 인터페이스
#include <concepts>
#include <cstdio>
#include <string>
// 컴파일 타임 인터페이스: 상속 없이 "이런 멤버가 있어야 한다"를 요구
template <typename T>
concept Serializer = requires(const T& s, int v) {
{ s.serialize(v) } -> std::convertible_to<std::string>;
{ T::format_name } -> std::convertible_to<const char*>;
};
struct JsonSer {
static constexpr const char* format_name = "json";
std::string serialize(int v) const { return "{\"v\":" + std::to_string(v) + "}"; }
};
struct CsvSer {
static constexpr const char* format_name = "csv";
std::string serialize(int v) const { return "v," + std::to_string(v); }
};
void dump(const Serializer auto& s, int v)
{
std::printf("[%s] %s\n", s.format_name, s.serialize(v).c_str());
}
int main()
{
dump(JsonSer{}, 7);
dump(CsvSer{}, 7);
std::printf("sizeof(JsonSer)=%zu (vptr 없음)\n", sizeof(JsonSer));
}$ g++ -std=c++20 -Wall -o cp concept.cpp
$ ./cp
[json] {"v":7}
[csv] v,7
sizeof(JsonSer)=1 (vptr 없음)
상속도 vptr도 없이 “이 멤버가 있어야 한다”를 요구한다. 요구 조건을 만족하지 않으면 호출 지점에서 어떤 조건이 빠졌는지 알려준다.
#include <concepts>
#include <string>
template <typename T>
concept Serializer = requires(const T& s, int v) {
{ s.serialize(v) } -> std::convertible_to<std::string>;
{ T::format_name } -> std::convertible_to<const char*>;
};
struct XmlSer { // format_name을 빠뜨림
std::string serialize(int v) const { return "<v>" + std::to_string(v) + "</v>"; }
};
void dump(const Serializer auto& s, int v) { (void)s.serialize(v); }
int main() { dump(XmlSer{}, 1); }$ g++ -std=c++20 -Wall -c concept_err.cpp 2>&1 | grep -E 'error|note: the required|format_name' | head -4
concept_err.cpp:16:18: error: no matching function for call to ‘dump(XmlSer, int)’
concept_err.cpp:7:10: note: the required expression ‘T::format_name’ is invalid
7 | { T::format_name } -> std::convertible_to<const char*>;
| 항목 | 가상 함수 인터페이스 | concept |
|---|---|---|
| 검사·바인딩 시점 | 실행 시점 디스패치 | 컴파일 시점 |
| 구현 클래스 | 인터페이스 상속 필요 | 상속 불필요 (조건만 맞으면 됨) |
| 서로 다른 구현을 한 컨테이너에 | 가능 | 불가 (타입마다 다름) |
| 런타임 교체·플러그인 | 가능 | 불가 |
| 오버헤드 | vptr, 간접 호출 | 없음, 대신 타입마다 코드 생성 |
인터페이스를 통한 복사 막기
struct Shape {
virtual ~Shape() = default;
virtual double area() const = 0;
protected: // 인터페이스 자체를 통한 복사·대입을 막는다
Shape() = default;
Shape(const Shape&) = default;
Shape& operator=(const Shape&) = default;
};
struct Circle : Shape {
double r = 1;
double area() const override { return 3.14 * r * r; }
};
int main()
{
Circle a, b;
b.r = 2;
a = b; // 구체 타입끼리는 복사 가능
Shape& ra = a;
Shape& rb = b;
ra = rb; // 인터페이스 참조로 반쪽 대입은 금지
}$ g++ -std=c++20 -Wall -c copy_slice.cpp 2>&1 | grep error
copy_slice.cpp:21:10: error: ‘constexpr Shape& Shape::operator=(const Shape&)’ is protected within this context
인터페이스의 복사 연산을 protected로 두면 구체 타입끼리의 복사는 그대로 되고, 인터페이스 참조를 통한 반쪽 대입(업캐스팅 편 참고)만 컴파일 에러가 된다.
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| 인터페이스에 가상 소멸자 누락 | 인터페이스 포인터로 delete 시 구현 소멸자 누락 | virtual ~I() = default; |
| 인터페이스에 데이터 멤버 추가 | 다중 상속 시 중복·다이아몬드 문제 | 상태는 구현 클래스에 |
| 모든 기능을 담은 큰 인터페이스 | 구현·가짜 객체 작성 부담 | 역할별로 작게 분리 |
| 인터페이스 참조로 대입 | 반쪽 대입 | 복사 연산을 protected 또는 = delete |
| 두 인터페이스에 같은 시그니처 함수 | 재정의 하나가 양쪽을 모두 구현 | 의미가 다르면 이름을 다르게 |
| 공개 가상 함수에 공통 검증 로직 중복 | 파생 클래스가 검증을 빠뜨림 | NVI 패턴 |
마무리
- C++ 인터페이스는 데이터 없이 순수 가상 함수와 가상 소멸자만 가진 추상 클래스다.
- 인터페이스에 의존하게 만들면 구현 교체와 테스트 대역 주입이 쉬워진다.
- 실행 시점 교체가 필요 없으면 C++20 concept으로 상속 없는 컴파일 시점 인터페이스를 만들 수 있다.