새로 설계한 코드는 온도를 섭씨 double로 다루는데, 가져다 써야 하는 외부 센서 라이브러리는 화씨 1/10도 정수를 돌려준다. 라이브러리는 고칠 수 없고, 우리 코드 곳곳에 단위 변환을 뿌릴 수도 없다. 어댑터(Adapter)는 기존 클래스의 인터페이스를 클라이언트가 기대하는 인터페이스로 변환해, 호환되지 않는 둘이 함께 동작하게 한다. 이 글에서는 GoF 구조 패턴의 첫 번째인 어댑터를 객체 어댑터, 클래스 어댑터(private 상속), C 콜백에 람다를 끼우는 트램펄린, 표준 라이브러리 속 어댑터, C ops 테이블로 각각 구현해 본다.
이 시리즈의 다른 글
- C/C++ 디자인 패턴 (4) — 프로토타입(Prototype)
- C/C++ 디자인 패턴 (5) — 싱글톤(Singleton)
- C/C++ 디자인 패턴 (7) — 브리지(Bridge)
- C/C++ 디자인 패턴 (8) — 컴포지트(Composite)
구조
| 참여자 | 역할 | 예제 |
|---|---|---|
| Target | 클라이언트가 사용하는 인터페이스 | TemperatureSensor |
| Adaptee | 인터페이스가 맞지 않는 기존 클래스 | LegacyThermometer |
| Adapter | Target을 구현하며 Adaptee로 요청을 변환해 전달 | ThermometerAdapter |
| Client | Target 인터페이스만 앎 | 센서 목록을 순회하는 main() |
// legacy.hpp — 바꿀 수 없는 외부 라이브러리 (화씨, 정수 1/10도 단위)
#pragma once
class LegacyThermometer {
public:
explicit LegacyThermometer(int raw) : raw_(raw) {}
int readFahrenheitTenths() const { return raw_; } // 986 = 98.6°F
private:
int raw_;
};객체 어댑터
Adaptee 객체를 멤버로 들고 요청을 전달한다. 가장 흔하고 유연한 형태다.
// object_adapter.cpp — 객체 어댑터: 기존 객체를 감싸 새 인터페이스로 노출
#include <iomanip>
#include <iostream>
#include <memory>
#include <vector>
#include "legacy.hpp"
struct TemperatureSensor { // 우리 코드가 기대하는 인터페이스
virtual ~TemperatureSensor() = default;
virtual double celsius() const = 0;
};
struct DigitalSensor : TemperatureSensor {
double c;
explicit DigitalSensor(double v) : c(v) {}
double celsius() const override { return c; }
};
class ThermometerAdapter : public TemperatureSensor {
public:
explicit ThermometerAdapter(const LegacyThermometer &t) : t_(t) {}
double celsius() const override
{
double f = t_.readFahrenheitTenths() / 10.0;
return (f - 32.0) * 5.0 / 9.0; // 단위 변환이 어댑터의 일
}
private:
const LegacyThermometer &t_;
};
int main()
{
LegacyThermometer old(986);
std::vector<std::unique_ptr<TemperatureSensor>> sensors;
sensors.push_back(std::make_unique<DigitalSensor>(36.5));
sensors.push_back(std::make_unique<ThermometerAdapter>(old));
for (const auto &s : sensors)
std::cout << std::fixed << std::setprecision(1) << s->celsius() << " C\n";
}$ g++ -std=c++20 -Wall -Wextra -O2 -o object_adapter object_adapter.cpp && ./object_adapter
36.5 C
37.0 C
클라이언트 루프는 디지털 센서와 레거시 온도계를 구분하지 않는다. 98.6°F가 37.0°C로 변환됐다.
클래스 어댑터: private 상속
C++은 다중 상속이 되므로 Target은 public으로, Adaptee는 private으로 상속할 수 있다. Adaptee의 구현은 재사용하지만 그 인터페이스는 밖으로 새지 않는다.
// class_adapter.cpp — 클래스 어댑터: private 상속으로 구현을 물려받는다
#include <iostream>
#include "legacy.hpp"
struct TemperatureSensor {
virtual ~TemperatureSensor() = default;
virtual double celsius() const = 0;
};
class ThermometerClassAdapter : public TemperatureSensor, private LegacyThermometer {
public:
using LegacyThermometer::LegacyThermometer; // 생성자 상속
double celsius() const override
{ return (readFahrenheitTenths() / 10.0 - 32.0) * 5.0 / 9.0; }
};
int main()
{
ThermometerClassAdapter a(986);
const TemperatureSensor &s = a;
std::cout << s.celsius() << " C, sizeof=" << sizeof(a) << '\n';
#ifdef SHOW_ERROR
a.readFahrenheitTenths(); // private 상속이라 외부에 노출되지 않는다
#endif
}$ g++ -std=c++20 -Wall -Wextra -O2 -o class_adapter class_adapter.cpp && ./class_adapter
37 C, sizeof=16
$ g++ -std=c++20 -DSHOW_ERROR -c class_adapter.cpp 2>&1 | grep error
class_adapter.cpp:23:31: error: ‘int LegacyThermometer::readFahrenheitTenths() const’ is inaccessible within this context
class_adapter.cpp:23:31: error: ‘LegacyThermometer’ is not an accessible base of ‘ThermometerClassAdapter’
| 구분 | 객체 어댑터 | 클래스 어댑터 |
|---|---|---|
| 연결 방식 | 합성(멤버·참조) | private 상속 |
| Adaptee의 서브클래스 | 모두 감쌀 수 있음 | 상속한 그 클래스만 |
| Adaptee 동작 재정의 | 불가 | 가상 함수라면 가능 |
| 객체 수 | 어댑터 + Adaptee 두 개(수명 관리 필요) | 하나 |
C 콜백 API에 람다 잇기
C 라이브러리는 대개 함수 포인터와 void * 사용자 데이터를 받는다. 캡처가 있는 람다는 함수 포인터로 바뀌지 않으므로, 캡처 없는 트램펄린이 void *에서 람다를 되찾아 호출하게 한다. glibc의 qsort_r로 확인했다.
// c_callback.cpp — C 콜백 API(void *arg)에 C++ 람다를 끼워 넣는 어댑터
#include <cstdlib>
#include <functional>
#include <iostream>
#include <string>
#include <vector>
// glibc qsort_r: int (*cmp)(const void *, const void *, void *arg)
template <class T, class Cmp>
void sort_with(std::vector<T> &v, Cmp cmp)
{
auto trampoline = [](const void *a, const void *b, void *arg) -> int {
auto &f = *static_cast<Cmp *>(arg); // arg로 상태를 되찾는다
const T &x = *static_cast<const T *>(a);
const T &y = *static_cast<const T *>(b);
return f(x, y) ? -1 : (f(y, x) ? 1 : 0);
};
qsort_r(v.data(), v.size(), sizeof(T), trampoline, &cmp);
}
int main()
{
std::vector<int> v = {42, 7, 19, 3, 25};
int calls = 0; // 캡처할 상태
sort_with(v, [&calls](int a, int b) { ++calls; return a > b; }); // 내림차순
for (int x : v)
std::cout << x << ' ';
std::cout << "(comparator calls=" << calls << ")\n";
}$ g++ -std=c++20 -Wall -Wextra -O2 -o c_callback c_callback.cpp && ./c_callback
42 25 19 7 3 (comparator calls=12)
람다가 캡처한 calls가 C 라이브러리 내부를 거쳐서도 갱신됐다. 같은 기법으로 libcurl의 CURLOPT_WRITEDATA, pthread의 시작 인자 같은 콜백도 감쌀 수 있다.
표준 라이브러리 속 어댑터
// stl_adapters.cpp — 표준 라이브러리 속 어댑터들
#include <algorithm>
#include <deque>
#include <functional>
#include <iostream>
#include <iterator>
#include <list>
#include <ranges>
#include <stack>
#include <vector>
int main()
{
// 컨테이너 어댑터: vector/deque에 스택 인터페이스(push/pop/top)만 노출
std::stack<int, std::vector<int>> s;
for (int i : {1, 2, 3})
s.push(i);
std::cout << "stack top=" << s.top() << " size=" << s.size() << '\n';
// 반복자 어댑터: push_back을 출력 반복자 인터페이스로
std::list<int> src = {5, 6, 7};
std::vector<int> dst;
std::copy(src.begin(), src.end(), std::back_inserter(dst));
std::cout << "back_inserter -> " << dst.size() << " elements\n";
// 범위 어댑터: 역순 + 변환을 원본 수정 없이
for (int x : dst | std::views::reverse | std::views::transform([](int v) { return v * 10; }))
std::cout << x << ' ';
std::cout << '\n';
// 호출 가능 객체 어댑터: 인자 순서 바꾸기
auto minus = std::minus<int>{};
auto rev = [&](int a, int b) { return minus(b, a); };
std::cout << "minus(10,3)=" << minus(10, 3) << " flipped=" << rev(10, 3) << '\n';
}$ g++ -std=c++20 -Wall -Wextra -O2 -o stl_adapters stl_adapters.cpp && ./stl_adapters
stack top=3 size=3
back_inserter -> 3 elements
70 60 50
minus(10,3)=7 flipped=-7
| 어댑터 | Adaptee | 노출하는 인터페이스 |
|---|---|---|
std::stack, std::queue | 시퀀스 컨테이너 | push/pop/top만 |
std::back_inserter | 컨테이너의 push_back | 출력 반복자 |
std::views::reverse, transform | 범위 | 역순·변환된 범위(지연 평가) |
람다, std::bind_front | 기존 호출 가능 객체 | 인자 순서·개수가 다른 호출 |
C 구현: ops 테이블 어댑터
새 인터페이스(struct sensor)의 함수 포인터 자리에, 레거시 API를 불러 변환하는 함수를 끼운다. priv가 객체 어댑터의 멤버 역할을 한다.
/* adapter.c — C: 기존 함수를 새 ops 테이블 인터페이스에 맞추는 어댑터 */
#include <stdio.h>
/* 바꿀 수 없는 레거시 API: 센서 ID로 화씨 1/10도 정수 반환 */
static int legacy_read_f10(int id) { return id == 1 ? 986 : 720; }
/* 새 인터페이스 */
struct sensor {
const char *name;
double (*celsius)(const struct sensor *s);
void *priv;
};
static double digital_celsius(const struct sensor *s) { return *(const double *)s->priv; }
struct legacy_ctx { int id; };
static double legacy_adapter_celsius(const struct sensor *s)
{
const struct legacy_ctx *ctx = s->priv;
return (legacy_read_f10(ctx->id) / 10.0 - 32.0) * 5.0 / 9.0;
}
int main(void)
{
double v = 36.5;
struct legacy_ctx a = { 1 }, b = { 2 };
struct sensor sensors[] = {
{ "digital", digital_celsius, &v },
{ "legacy#1", legacy_adapter_celsius, &a },
{ "legacy#2", legacy_adapter_celsius, &b },
};
for (size_t i = 0; i < sizeof(sensors) / sizeof(sensors[0]); i++)
printf("%-9s %.1f C\n", sensors[i].name, sensors[i].celsius(&sensors[i]));
return 0;
}$ gcc -std=c11 -Wall -Wextra -O2 -o adapter_c adapter.c && ./adapter_c
digital 36.5 C
legacy#1 37.0 C
legacy#2 22.2 C
비슷한 구조 패턴과 구별
| 패턴 | 목적 | 인터페이스 |
|---|---|---|
| 어댑터 | 이미 있는 것을 맞춰 쓰기 | 다른 인터페이스로 변환 |
| 브리지 | 추상과 구현을 처음부터 분리 설계 | 양쪽을 독립적으로 확장 |
| 데코레이터 | 기능 추가 | 같은 인터페이스 유지 |
| 퍼사드 | 복잡한 하위 시스템을 단순화 | 새로운 단순 인터페이스 |
| 프록시 | 접근 제어(지연, 원격, 권한) | 같은 인터페이스 유지 |
주의사항
- 참조로 감싼 Adaptee의 수명: 예제의
ThermometerAdapter는 참조를 들고 있어 원본이 먼저 사라지면 댕글링이 된다. 소유가 필요하면 값이나std::unique_ptr로 보관한다. - 변환 손실을 숨기지 않는다: 단위·정밀도·오류 코드 변환은 어댑터에서 정보가 사라지기 쉬운 지점이다. 레거시 API의 오류 값(-1 등)을 예외나
std::optional로 옮기는 규칙을 정해 둔다. - 트램펄린과 수명:
void *arg로 넘긴 객체는 C 라이브러리가 콜백을 부를 수 있는 동안 살아 있어야 한다. 비동기 콜백이라면 스택 변수를 넘기면 안 된다. qsort_r는 GNU 확장: BSD/macOS의qsort_r는 이름은 같지만 사용자 데이터와 비교 함수의 인자 순서가 다르다. C11 Annex K의qsort_s는 glibc에 구현돼 있지 않다. 이식성이 필요하면std::sort를 쓴다.- 어댑터가 두꺼워지면 신호다: 변환 로직이 비즈니스 규칙을 담기 시작하면 어댑터가 아니라 별도의 도메인 계층이 필요하다는 뜻이다.
마무리
어댑터는 “고칠 수 없는 쪽”과 “고치고 싶지 않은 쪽” 사이에 얇은 번역 계층을 둔다. C++에서는 객체 어댑터와 람다 트램펄린이 가장 자주 쓰이고, 표준 라이브러리는 이미 컨테이너·반복자·범위 어댑터로 가득하다. 다음 글에서는 추상과 구현을 처음부터 분리하는 브리지(Bridge)를 다룬다.
참고
- Erich Gamma 외, 『Design Patterns: Elements of Reusable Object-Oriented Software』(GoF), Adapter
- man7.org — qsort(3), qsort_r
- cppreference — std::stack
- cppreference — Ranges library