C++ Adapter 패턴: 객체 어댑터 vs 클래스 어댑터로 레거시·외부 API 감싸기
이 글의 핵심
외부 라이브러리나 오래된 API를 새 인터페이스에 맞추려다 보면 호출부 곳곳에 변환 코드가 흩어지기 쉽습니다. 어댑터가 소유권을 잘못 다뤄 생기는 메모리 누수, 양방향 어댑터가 필요한 경우를 짚고, Decorator·Facade와의 차이와 성능 오버헤드까지 비교해 어떤 방식이 상황에 맞는지 판단할 수 있게 합니다.
Adapter Pattern이란? 왜 필요한가
해외에서 산 전자제품과 콘센트 모양이 맞지 않을 때 어댑터로 모양만 바꿔 꽂듯이, 소프트웨어에서도 호출 규약(Target) 과 실제 구현(Adaptee) 이 다를 때 중간에서 맞춰 주는 역할이 어댑터입니다. 아래에서는 그 불일치가 코드로 어떻게 드러나는지부터 살펴보겠습니다. 구조 패턴 시리즈에서 다른 구조 패턴과의 관계를 정리했으며, JavaScript에서도 API를 맞추는 식의 통합이 자주 나옵니다.
인터페이스가 맞지 않는 미디어 플레이어 예
문제는 다음과 같습니다. 기존 라이브러리가 노출하는 함수 이름·매개변수 형태가, 우리가 이미 설계해 둔 추상 인터페이스와 맞지 않을 때가 있습니다.
// 내 코드가 기대하는 인터페이스
class MediaPlayer {
public:
virtual void play(const std::string& filename) = 0;
};
// 기존 라이브러리 (호환 안 됨)
class VLCPlayer {
public:
void playVLC(const std::string& filename) { /* ... */ }
};
// 어떻게 VLCPlayer를 MediaPlayer로 사용?
해결: Adapter Pattern은 인터페이스를 변환합니다. Adapter가 Target 인터페이스를 구현하며, 내부에서 Adaptee를 호출합니다.
// Adapter
// 타입 정의
class VLCAdapter : public MediaPlayer {
public:
VLCAdapter(std::unique_ptr<VLCPlayer> player)
: vlc(std::move(player)) {}
void play(const std::string& filename) override {
vlc->playVLC(filename); // 인터페이스 변환
}
private:
std::unique_ptr<VLCPlayer> vlc;
};
핵심 개념: Adapter Pattern은 호환되지 않는 인터페이스를 연결하는 다리 역할을 합니다. 레거시 시스템 통합이나 서드파티 라이브러리 사용 시 가장 자주 쓰이는 패턴 중 하나입니다.
어댑터를 두지 않으면 어떻게 될지 생각해 보면 이 패턴의 가치가 분명해집니다. 호출하는 쪽이 if (type == "vlc") vlc.playVLC(f); else mp4.playMP4(f);처럼 구현마다 분기하게 되고, 이런 분기가 코드 곳곳에 퍼집니다. 라이브러리가 새 버전에서 함수 이름을 바꾸거나 다른 라이브러리로 교체하면 그 분기를 전부 찾아 고쳐야 합니다. 어댑터는 “외부 API가 어떻게 생겼는지 아는 코드”를 한 클래스에 가둬, 변경이 생겨도 어댑터 한 곳만 고치면 되게 만듭니다. 그리고 호출부가 MediaPlayer 추상 인터페이스에만 의존하므로 테스트에서는 실제 재생 없이 호출만 기록하는 가짜 구현을 넣을 수 있습니다.
위 코드에서 생성자가 std::unique_ptr<VLCPlayer>를 값으로 받는 것도 의도된 설계입니다. 호출하는 쪽이 std::move로 넘겨야 하므로 “이 객체의 소유권이 어댑터로 넘어간다”는 사실이 시그니처에 드러납니다. 반대로 Adaptee의 수명을 다른 곳(예: 라이브러리가 제공하는 전역 객체, 여러 어댑터가 공유하는 연결)이 관리한다면 소유하지 않는 참조나 포인터로 들고 있는 것이 맞고, 이 경우 어댑터가 Adaptee보다 오래 살지 않도록 수명을 설계해야 합니다.
객체 어댑터: Adaptee를 멤버로 들고 위임하기
객체 어댑터는 상속으로 타입을 합치지 않고, 멤버로 VLCPlayer 같은 구현체를 들고 있는 방식(조합) 입니다. 구현을 바꾸거나 목(mock)으로 바꿀 때 어댑터 한곳만 건드리면 되므로, 실무에서는 이 형태를 가장 많이 권장합니다.
아래 예제에서는 MediaPlayer 하나의 인터페이스로 VLCAdapter와 MP4Adapter를 바꿔 끼울 수 있게 하여, 호출부(player->play)는 동일한 문장을 유지합니다.
#include <iostream>
#include <memory>
#include <string>
class MediaPlayer {
public:
virtual void play(const std::string& filename) = 0;
virtual ~MediaPlayer() = default;
};
class VLCPlayer {
public:
void playVLC(const std::string& filename) {
std::cout << "Playing VLC: " << filename << '\n';
}
};
class MP4Player {
public:
void playMP4(const std::string& filename) {
std::cout << "Playing MP4: " << filename << '\n';
}
};
class VLCAdapter : public MediaPlayer {
public:
VLCAdapter() : vlc(std::make_unique<VLCPlayer>()) {}
void play(const std::string& filename) override {
vlc->playVLC(filename);
}
private:
std::unique_ptr<VLCPlayer> vlc;
};
class MP4Adapter : public MediaPlayer {
public:
MP4Adapter() : mp4(std::make_unique<MP4Player>()) {}
void play(const std::string& filename) override {
mp4->playMP4(filename);
}
private:
std::unique_ptr<MP4Player> mp4;
};
int main() {
std::unique_ptr<MediaPlayer> player;
player = std::make_unique<VLCAdapter>();
player->play("movie.vlc");
player = std::make_unique<MP4Adapter>();
player->play("movie.mp4");
}
MediaPlayer에 가상 소멸자(virtual ~MediaPlayer() = default;)가 있는 것이 중요합니다. std::unique_ptr<MediaPlayer>가 VLCAdapter를 삭제할 때 기반 클래스 포인터로 delete하게 되는데, 소멸자가 가상이 아니면 VLCAdapter의 소멸자가 호출되지 않아 멤버 vlc가 해제되지 않습니다. 정의되지 않은 동작이며 실제로는 누수로 나타납니다. 처음 예제의 MediaPlayer에는 이 소멸자가 빠져 있으니, 인터페이스 클래스를 만들 때는 항상 넣어 두어야 합니다.
이 예제에서 Adaptee를 unique_ptr로 힙에 할당할 필요는 사실 없습니다. VLCPlayer vlc;처럼 값 멤버로 두면 할당이 한 번 줄고 널 검사도 필요 없어집니다. unique_ptr가 필요한 경우는 Adaptee를 외부에서 주입받거나, 헤더에서 Adaptee의 정의를 숨기고 싶을 때(pimpl), Adaptee가 복사·이동 불가능한데 어댑터는 이동 가능해야 할 때입니다.
클래스 어댑터: 다중 상속으로 합치기
클래스 어댑터는 다중 상속을 사용하는 방식입니다. C++에서는 가능하지만, 객체 어댑터보다 유연성이 떨어집니다.
#include <iostream>
#include <string>
class MediaPlayer {
public:
virtual void play(const std::string& filename) = 0;
virtual ~MediaPlayer() = default;
};
class VLCPlayer {
public:
void playVLC(const std::string& filename) {
std::cout << "Playing VLC: " << filename << '\n';
}
};
// 클래스 어댑터 (다중 상속)
class VLCAdapter : public MediaPlayer, private VLCPlayer {
public:
void play(const std::string& filename) override {
playVLC(filename); // 직접 호출
}
};
int main() {
MediaPlayer* player = new VLCAdapter();
player->play("movie.vlc");
delete player;
}
장점: Adaptee 객체를 저장할 필요 없음. 단점: 다중 상속, Adaptee가 final이면 불가.
여기서 VLCPlayer를 private으로 상속한 것이 핵심입니다. public 상속이면 VLCAdapter가 “VLCPlayer이기도 하다”는 관계가 외부에 드러나, 호출부가 adapter.playVLC()를 직접 호출하거나 VLCPlayer*로 변환할 수 있게 됩니다. 어댑터의 목적은 Adaptee의 인터페이스를 숨기는 것이므로 private 상속으로 “구현에만 사용한다”는 의미를 표현합니다. main의 new/delete는 비교를 위한 것이고, 실제로는 std::make_unique를 쓰는 것이 맞습니다.
클래스 어댑터가 객체 어댑터보다 나은 경우는 드물지만 분명히 있습니다. Adaptee의 protected 멤버에 접근해야 하거나, Adaptee가 가상 함수로 콜백을 받는 구조(예: virtual void onData()를 재정의해야 이벤트를 받는 라이브러리 클래스)라면 상속 말고는 방법이 없습니다. 반대로 Adaptee의 하위 클래스 전체를 한 어댑터로 감싸야 한다면(런타임에 어떤 구현이 올지 모름) 객체 어댑터만 가능합니다. 두 기반 클래스에 같은 이름의 멤버 함수가 있으면 이름 충돌로 모호성 에러가 나는 것도 다중 상속 방식의 실무적인 불편입니다.
오래된 C 스타일 API를 현대적 인터페이스로 감싸기
레거시 시스템을 현대적인 코드베이스에 통합할 때 Adapter Pattern이 빛을 발합니다. 기존 코드를 수정하지 않고도 새로운 인터페이스로 사용할 수 있습니다.
#include <iostream>
#include <string>
#include <memory>
// 레거시 API (C 스타일)
class LegacyRectangle {
public:
void draw(int x1, int y1, int x2, int y2) {
std::cout << "Legacy: Rectangle from (" << x1 << "," << y1
<< ") to (" << x2 << "," << y2 << ")\n";
}
};
// 현대적 인터페이스
class Shape {
public:
virtual void draw() = 0;
virtual ~Shape() = default;
};
class Rectangle : public Shape {
public:
Rectangle(int x, int y, int w, int h)
: x_(x), y_(y), width_(w), height_(h) {}
void draw() override {
std::cout << "Modern: Rectangle at (" << x_ << "," << y_
<< ") size " << width_ << "x" << height_ << '\n';
}
private:
int x_, y_, width_, height_;
};
// Adapter
class LegacyRectangleAdapter : public Shape {
public:
LegacyRectangleAdapter(int x, int y, int w, int h)
: x_(x), y_(y), width_(w), height_(h),
legacy(std::make_unique<LegacyRectangle>()) {}
void draw() override {
legacy->draw(x_, y_, x_ + width_, y_ + height_);
}
private:
int x_, y_, width_, height_;
std::unique_ptr<LegacyRectangle> legacy;
};
int main() {
std::unique_ptr<Shape> shape1 = std::make_unique<Rectangle>(10, 20, 100, 50);
shape1->draw();
std::unique_ptr<Shape> shape2 = std::make_unique<LegacyRectangleAdapter>(10, 20, 100, 50);
shape2->draw();
}
이 예제의 요점은 좌표 체계의 변환입니다. 새 인터페이스는 “왼쪽 위 좌표 + 너비·높이”로, 레거시 API는 “두 꼭짓점 좌표”로 사각형을 표현합니다. 어댑터가 x_ + width_, y_ + height_로 변환을 한 곳에서 처리하므로 호출부는 레거시 형식을 몰라도 됩니다. 실제 레거시 통합에서는 이런 변환이 단순한 이름 바꾸기보다 훨씬 까다롭습니다. 끝 좌표를 포함하는지(x2가 마지막 픽셀인지 그 다음인지), 단위가 픽셀인지 포인트인지, 에러를 반환 코드로 알리는지 예외로 던지는지 같은 의미 차이를 어댑터가 모두 흡수해야 합니다. 저는 레거시 API를 감쌀 때 이런 경계 조건을 어댑터 단위 테스트로 먼저 고정해 두는데, 나중에 “한 픽셀씩 어긋난다” 같은 버그가 나왔을 때 어댑터 쪽 문제인지 바로 가릴 수 있기 때문입니다.
주석에 “C 스타일”이라고 되어 있지만 진짜 C API를 감쌀 때는 형태가 조금 다릅니다. void* ctx = legacy_open(); legacy_draw(ctx, ...); legacy_close(ctx);처럼 핸들과 해제 함수가 따로 있는 경우가 많아서, 어댑터가 생성자에서 핸들을 얻고 소멸자에서 해제하는 RAII 역할까지 맡게 됩니다. 이때 어댑터를 복사하면 같은 핸들을 두 번 해제하므로 복사를 금지(= delete)하거나 std::unique_ptr<void, decltype(&legacy_close)>처럼 커스텀 삭제자로 소유권을 관리해야 합니다. C API의 에러 코드를 C++ 예외나 std::expected로 바꾸는 것도 어댑터의 몫입니다.
Adaptee 소유권과 양방향 변환의 함정
raw pointer 멤버로 인한 소유권 불명확
증상: 메모리 누수. 원인: raw pointer 사용.
// ❌ 잘못된 사용
class Adapter {
Adaptee* adaptee; // 누가 delete?
};
// ✅ 올바른 사용
class Adapter {
std::unique_ptr<Adaptee> adaptee;
};
raw pointer 자체가 문제인 것은 아닙니다. 문제는 소유권이 코드에 드러나지 않는 것입니다. Adaptee* 멤버만 봐서는 어댑터가 소멸자에서 지워야 하는지, 다른 누군가가 지우는지 알 수 없고, 양쪽이 모두 지우면 이중 해제, 아무도 안 지우면 누수입니다. 어댑터가 소유한다면 unique_ptr로, 여러 곳이 공유한다면 shared_ptr로, 소유하지 않는다면 참조나 포인터로 두되 그 수명이 어댑터보다 길다는 것을 설계로 보장합니다. 소유하지 않는 포인터를 쓰는 경우 흔한 사고는 어댑터를 콜백이나 스레드에 넘겨 놓고 원본 Adaptee를 먼저 파괴하는 것으로, 이때는 누수가 아니라 해제된 메모리 접근(use-after-free)이 됩니다.
A↔B 양방향 어댑터가 만드는 순환 의존
증상: 순환 의존성. 원인: A를 B로, B를 A로 변환.
// ✅ 해결: 공통 인터페이스
class CommonInterface {
public:
virtual void operation() = 0;
virtual ~CommonInterface() = default;
};
class AdapterA : public CommonInterface { /* ... */ };
class AdapterB : public CommonInterface { /* ... */ };
두 시스템이 서로의 인터페이스를 요구하는 상황(예: 새 모듈은 구 모듈을 호출하고, 구 모듈의 콜백은 새 모듈 형식으로 받아야 함)에서 A→B 어댑터와 B→A 어댑터를 따로 만들면 두 어댑터가 서로의 헤더를 포함하며 순환 의존이 생깁니다. 양쪽이 공통으로 의존하는 추상 인터페이스를 따로 두면 각 어댑터는 그 인터페이스에만 의존하므로 순환이 끊깁니다. 이 구조가 과하다고 느껴진다면, 사실 둘 중 한쪽을 새 인터페이스에 맞게 고치는 것이 가능한지 먼저 검토할 가치가 있습니다. 어댑터는 고칠 수 없는 코드를 위한 도구이지, 고칠 수 있는 코드를 고치지 않기 위한 도구는 아닙니다.
팩토리·템플릿과 결합한 어댑터
팩토리로 어댑터 선택 숨기기
class MediaPlayerFactory {
public:
static std::unique_ptr<MediaPlayer> create(const std::string& type) {
if (type == "vlc") {
return std::make_unique<VLCAdapter>();
} else if (type == "mp4") {
return std::make_unique<MP4Adapter>();
}
return nullptr;
}
};
auto player = MediaPlayerFactory::create("vlc");
player->play("movie.vlc");
팩토리와 결합하면 호출부는 구체 어댑터 클래스의 이름조차 알 필요가 없어집니다. 설정 파일이나 파일 확장자에 따라 어댑터를 고르는 로직이 팩토리 한 곳에 모이기 때문입니다. 이 팩토리는 모르는 타입에 nullptr을 돌려주므로 호출부가 확인 없이 player->play()를 부르면 널 역참조로 크래시가 납니다. 반환값을 반드시 검사하게 하거나, 예외를 던지거나, 아무것도 하지 않는 NullPlayer를 돌려주는 방식 중 하나를 정해 두는 것이 좋습니다.
Adaptee 타입을 템플릿 인자로 받는 어댑터
template<typename Adaptee>
class GenericAdapter : public MediaPlayer {
public:
GenericAdapter() : adaptee(std::make_unique<Adaptee>()) {}
void play(const std::string& filename) override {
adaptee->playSpecific(filename);
}
private:
std::unique_ptr<Adaptee> adaptee;
};
템플릿 어댑터는 모든 Adaptee에 playSpecific이라는 같은 이름의 멤버 함수가 있어야만 동작합니다. 그런데 어댑터가 필요한 이유가 애초에 이름이 달라서이므로, 이 형태가 실제로 쓸모 있는 경우는 “시그니처는 같고 공통 기반 클래스만 없는” 여러 타입을 하나의 가상 인터페이스로 묶을 때입니다. 이름이 제각각이라면 템플릿 인자로 호출 방법 자체를 넘기는 편이 낫습니다. 예를 들어 GenericAdapter<VLCPlayer, &VLCPlayer::playVLC>처럼 멤버 함수 포인터를 템플릿 인자로 받거나, 생성자에서 std::function<void(const std::string&)>이나 람다를 받는 방식입니다. C++20에서는 requires 절로 Adaptee가 갖춰야 할 함수를 명시하면, 조건을 만족하지 않는 타입을 넣었을 때 긴 템플릿 에러 대신 “제약을 만족하지 않는다”는 짧은 메시지를 볼 수 있습니다.
여러 결제 게이트웨이를 하나의 인터페이스로 묶기
#include <cmath>
#include <iostream>
#include <memory>
#include <string>
class PaymentProcessor {
public:
virtual bool processPayment(double amount) = 0;
virtual ~PaymentProcessor() = default;
};
// 레거시 PayPal API
class PayPalAPI {
public:
bool sendPayment(double dollars) {
std::cout << "PayPal: Processing $" << dollars << '\n';
return true;
}
};
// 레거시 Stripe API
class StripeAPI {
public:
bool charge(int cents) {
std::cout << "Stripe: Charging " << cents << " cents\n";
return true;
}
};
// 새로운 Square API
class SquareAPI {
public:
bool makePayment(const std::string& amount) {
std::cout << "Square: Payment of " << amount << '\n';
return true;
}
};
// Adapters
class PayPalAdapter : public PaymentProcessor {
public:
PayPalAdapter() : paypal(std::make_unique<PayPalAPI>()) {}
bool processPayment(double amount) override {
return paypal->sendPayment(amount);
}
private:
std::unique_ptr<PayPalAPI> paypal;
};
class StripeAdapter : public PaymentProcessor {
public:
StripeAdapter() : stripe(std::make_unique<StripeAPI>()) {}
bool processPayment(double amount) override {
int cents = static_cast<int>(std::llround(amount * 100)); // 버림 대신 반올림
return stripe->charge(cents);
}
private:
std::unique_ptr<StripeAPI> stripe;
};
class SquareAdapter : public PaymentProcessor {
public:
SquareAdapter() : square(std::make_unique<SquareAPI>()) {}
bool processPayment(double amount) override {
return square->makePayment("$" + std::to_string(amount));
}
private:
std::unique_ptr<SquareAPI> square;
};
class PaymentService {
public:
PaymentService(std::unique_ptr<PaymentProcessor> processor)
: processor_(std::move(processor)) {}
void checkout(double amount) {
std::cout << "Processing checkout for $" << amount << '\n';
if (processor_->processPayment(amount)) {
std::cout << "Payment successful!\n\n";
} else {
std::cout << "Payment failed!\n\n";
}
}
private:
std::unique_ptr<PaymentProcessor> processor_;
};
int main() {
PaymentService service1(std::make_unique<PayPalAdapter>());
service1.checkout(99.99);
PaymentService service2(std::make_unique<StripeAdapter>());
service2.checkout(49.50);
PaymentService service3(std::make_unique<SquareAdapter>());
service3.checkout(29.99);
}
세 결제 API는 금액을 각각 달러 실수, 센트 정수, 문자열로 받습니다. 어댑터가 없다면 PaymentService가 세 가지 표현을 모두 알아야 하지만, 여기서는 processPayment(double) 하나만 압니다. 이 예제에서 가장 배울 만한 부분은 오히려 단위 변환에 숨은 버그입니다.
StripeAdapter의 원래 코드는 static_cast<int>(amount * 100)이었는데, 부동소수점은 0.29 같은 값을 정확히 표현하지 못해 0.29 * 100이 28.999999999999996이 되고, static_cast<int>는 소수점 이하를 버리므로 29센트가 아니라 28센트가 청구됩니다. 금액 변환에서 매우 자주 나오는 버그라 위 코드에서는 std::llround로 반올림하도록 고쳤습니다. 근본적으로는 금액을 처음부터 double로 다루지 않고 센트 단위 정수(std::int64_t)나 고정소수점 타입으로 들고 다니는 것이 맞습니다. SquareAdapter의 std::to_string(29.99)도 문제가 있습니다. to_string은 %f 형식이라 "29.990000"을 만들고, 받는 쪽이 소수점 두 자리를 기대하면 검증에서 거절될 수 있습니다. C++20의 std::format("{:.2f}", amount)를 쓰는 편이 정확합니다.
결제처럼 외부 시스템과 연결되는 어댑터라면 인터페이스 설계도 다시 볼 만합니다. bool 하나로는 “카드 거절”, “네트워크 오류로 결과 불명”, “중복 결제 차단”을 구분할 수 없습니다. 특히 결과가 불명확한 경우 재시도하면 이중 결제가 될 수 있으므로, 실제 시스템에서는 결과를 열거형이나 오류 코드를 담은 구조체로 돌려주고, 각 결제 API가 제공하는 멱등성 키(idempotency key)를 어댑터가 함께 넘기도록 설계합니다.
Adapter 패턴 구성 요소 요약
| 개념 | 설명 |
|---|---|
| Adapter Pattern | 인터페이스를 변환 |
| 목적 | 호환되지 않는 인터페이스 통합 |
| 구조 | Target, Adapter, Adaptee |
| 장점 | 레거시 통합, OCP 준수, 재사용성 |
| 단점 | 클래스 증가, 간접 참조 |
| 사용 사례 | 레거시 통합, 서드파티 라이브러리, API 변환 |
Adapter Pattern은 호환되지 않는 인터페이스를 통합하는 필수 패턴입니다.
FAQ
Q1: Adapter Pattern은 언제 쓰나요?
A: 레거시 코드 통합, 서드파티 라이브러리 사용, 인터페이스 불일치 해결 시 사용합니다.
Q2: 객체 어댑터 vs 클래스 어댑터?
A: 객체 어댑터는 조합(권장), 클래스 어댑터는 다중 상속(C++ 가능).
Q3: Decorator와 차이는?
A: Adapter는 인터페이스 변환, Decorator는 기능 추가에 집중합니다.
Q4: Facade와 차이는?
A: Adapter는 단일 클래스 변환, Facade는 서브시스템 단순화에 집중합니다.
Q5: 성능 오버헤드는?
A: 가상 함수 호출 1회와 Adaptee 포인터 간접 참조 1회 정도로, 대부분의 경우 무시할 수 있는 수준입니다. 초당 수백만 번 호출되는 핫 루프라면 가상 호출이 인라이닝을 막는 것이 문제가 될 수 있는데, 이때는 템플릿 기반 정적 다형성으로 어댑터를 컴파일 타임에 고정하는 방법을 씁니다. Adaptee를 힙 대신 값 멤버로 두면 할당 비용도 없앨 수 있습니다.
Q6: Adapter Pattern 학습 리소스는?
A:
- “Design Patterns” by Gang of Four
- “Head First Design Patterns” by Freeman & Freeman
- Refactoring Guru: Adapter Pattern Adapter Pattern으로 호환되지 않는 인터페이스를 통합할 수 있습니다. 다음으로 Proxy Pattern을 읽어보면 좋습니다.
같이 보면 좋은 글
- C++ Decorator 패턴
- C++ Facade 패턴
- C++ Bridge 패턴
- C++ Command 패턴
- C++ CRTP: 가상 함수 없이 정적 다형성 구현하기와 C++23 deducing this
- C++ Factory 패턴 비교