C/C++ 디자인 패턴 (6) — 어댑터(Adapter)

새로 설계한 코드는 온도를 섭씨 double로 다루는데, 가져다 써야 하는 외부 센서 라이브러리는 화씨 1/10도 정수를 돌려준다. 라이브러리는 고칠 수 없고, 우리 코드 곳곳에 단위 변환을 뿌릴 수도 없다. 어댑터(Adapter)는 기존 클래스의 인터페이스를 클라이언트가 기대하는 인터페이스로 변환해, 호환되지 않는 둘이 함께 동작하게 한다. 이 글에서는 GoF 구조 패턴의 첫 번째인 어댑터를 객체 어댑터, 클래스 어댑터(private 상속), C 콜백에 람다를 끼우는 트램펄린, 표준 라이브러리 속 어댑터, C ops 테이블로 각각 구현해 본다.

이 시리즈의 다른 글

구조

참여자역할예제
Target클라이언트가 사용하는 인터페이스TemperatureSensor
Adaptee인터페이스가 맞지 않는 기존 클래스LegacyThermometer
AdapterTarget을 구현하며 Adaptee로 요청을 변환해 전달ThermometerAdapter
ClientTarget 인터페이스만 앎센서 목록을 순회하는 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)를 다룬다.

참고

답글 남기기