어댑터로 소프트웨어 개발을 개선하기

이상문
11 min readMay 11, 2023

어댑터는 한 클래스의 인터페이스를 다른 클래스의 인터페이스와 일치하도록 조정할 수 있는 소프트웨어 패턴이다. 이는 함께 작동하도록 설계되지 않은 컴포넌트를 통합해야 할 때 특히 유용하다.

이 글에서는 어댑터 디자인 패턴의 기본 사항과 개념을 더 잘 이해하는 데 도움이 되는 몇 가지 예제를 다룬다.

기본 어댑터 구현

어댑터는 객체를 감싸고(wrapping) 대상 클래스와 호환되는 새로운 인터페이스를 노출하는 방식으로 작동한다.

class Square {
public:
virtual void draw() const {
cout << "Drawing a square.\n";
}
};

class Circle {
public:
virtual void display() const {
cout << "Displaying a circle.\n";
}
};

class CircleAdapter : public Square {
public:
CircleAdapter(Circle* circle) : circle(circle) {}

virtual void draw() const override {
circle->display();
}

private:
Circle* circle;
};

앞의 예제에는 Square 클래스와 Circle 클래스가 있다. Circle 클래스를 정사각형처럼 사용하고 싶은 경우를 생각해보자. 이를 위해 Square를 확장하고 Circle 객체를 감싸는 CircleAdapter 클래스를 만든다. CircleAdapter 클래스는 정사각형을 그리고 싶을 때 호출되는 draw() 메서드를 구현한다. draw() 메서드 내에서 CircleAdapter는 Circle 객체의 display() 메서드를 호출하여 Circle 인터페이스를 Square 인터페이스로 변환한다.

클래스 어댑터

클래스 어댑터는 다중 상속을 사용하여 한 클래스의 인터페이스를 다른 클래스에 맞게 조정하는 어댑터 패턴의 변형이다.

class Adaptee {
public:
virtual void do_something() const {
cout << "Doing something.\n";
}
};

class Target {
public:
virtual void perform_action() const = 0;
};

class Adapter : public Adaptee, public Target {
public:
virtual void perform_action() const override {
do_something();
}
};

앞의 예제에는 do_something() 메서드가 있는 Adaptee 클래스와 perform_action() 메서드가 있는 Target 인터페이스가 있다. Target 인터페이스와 일치하도록 Adaptee 인터페이스를 조정하려고 한다. 이를 위해 Adaptee와 Target을 모두 확장하는 Adapter 클래스를 만든다. Adapter 클래스는 Adaptee의 do_something() 메서드를 호출하여 perform_action() 메서드를 구현한다.

객체 어댑터

객체 어댑터는 상속 대신 구성(composition)을 사용하는 어댑터 패턴의 또 다른 변형이다.

class Adaptee {
public:
void do_something() const {
cout << "Doing something.\n";
}
};

class Target {
public:
virtual void perform_action() const = 0;
};

class Adapter : public Target {
public:
Adapter(const Adaptee& adaptee) : adaptee(adaptee) {}

virtual void perform_action() const override {
adaptee.do_something();
}

private:
const Adaptee& adaptee;
};

앞의 예제에는 do_something() 메서드가 있는 Adaptee 클래스와 perform_action() 메서드가 있는 Target 인터페이스가 있다. Target 인터페이스와 일치하도록 Adaptee 인터페이스를 조정하기 위해 Adaptee 클래스를 생성한다.

캐싱 어댑터

가끔씩 한 인터페이스를 다른 인터페이스에 적용할 때, 어댑터는 다른 인터페이스를 충족하기 위해 임시 데이터를 생성한다. 이로 인해 새 데이터가 불필요하게 생성되어 비효율적인 코드가 생성될 수 있다. 이를 완화하기 위해 캐싱 어댑터를 사용하여 필요할 때만 새 데이터가 생성되도록 할 수 있다. 하지만 캐시된 객체가 변경되면 오래된 데이터를 정리하여 메모리 누수를 방지해야 한다.

class FahrenheitToCelsiusAdapter
{
public:
FahrenheitToCelsiusAdapter() = default;
double Convert(double temperature) const
{
return (temperature - 32.0) * 5.0 / 9.0;
}
};

이 어댑터는 화씨 온도를 섭씨로 변환한다. 그러나 변환 함수를 호출할 때마다 변환을 처음부터 다시 계산한다. 동일한 온도에 대해 여러 번 변환을 수행해야 하는 경우 이 접근 방식은 비효율적이다. 캐싱 어댑터를 사용하면 중복 계산을 피할 수 있다.

class CachedFahrenheitToCelsiusAdapter
{
public:
CachedFahrenheitToCelsiusAdapter() = default;
double Convert(double temperature) const
{
auto cachedValue = cache.find(temperature);
if (cachedValue != cache.end())
{
std::cout << "Using cached value for " << temperature << " F\n";
return cachedValue->second;
}

std::cout << "Calculating new value for " << temperature << " F\n";
double value = (temperature - 32.0) * 5.0 / 9.0;
cache[temperature] = value;
return value;
}

private:
mutable std::unordered_map<double, double> cache;
};

이 어댑터는 unordered_map을 사용하여 이전에 계산된 값을 캐싱한다. 요청된 온도가 캐시에서 발견되면 어댑터는 캐시된 값을 반환한다. 그렇지 않으면 새 값을 계산하여 캐시에 저장한다. 캐시는 변경 가능하므로 상수 멤버 함수에서도 캐싱이 가능하다.

양방향 어댑터

양방향 어댑터는 어댑터가 적용된 인터페이스 간에 양방향 통신을 허용하는 어댑터이다. 즉, 한 인터페이스에 대한 변경 사항이 다른 인터페이스에 자동으로 전파된다.

예를 들어 객체의 속성을 표시하는 GUI 양식이 있고 사용자가 양식을 통해 해당 속성을 수정할 수 있도록 하려고 한다고 가정해 보자. 양방향 어댑터를 사용하면 양식 컨트롤을 개체의 속성에 바인딩하여 양식의 변경 사항이 개체에 자동으로 전파되도록 할 수 있으며, 그 반대의 경우도 마찬가지이다.

template <typename T>
class ITarget {
public:
virtual void Receive(const T& data) = 0;
virtual ~ITarget() = default;
};

template <typename TFrom, typename TTo>
class TwoWayAdapter : public ITarget<TTo>, public ITarget<TFrom> {
public:
TwoWayAdapter(ITarget<TFrom>* source, ITarget<TTo>* target)
: m_source(source), m_target(target)
{
}

void Receive(const TFrom& from) override
{
m_target->Receive(Convert(from));
}

void Receive(const TTo& to) override
{
m_source->Receive(ConvertBack(to));
}

private:
ITarget<TFrom>* m_source;
ITarget<TTo>* m_target;
};

앞의 예제에서 두 개의 대상 객체 중, 하나는 데이터의 소스(m_source)이고 다른 하나는 대상(m_target)이다. 두 대상 간에 양방향 데이터 전송이 가능하도록 TFrom과 TTo 모두에 대한 수신 함수를 재정의한다.

TFrom에 대한 Receive 함수는 TFrom 유형의 데이터를 받아 Convert 함수를 사용하여 TTo로 변환한 다음 m_target 오브젝트의 Receive 함수에 전달한다. 마찬가지로 TTo에 대한 Receive 함수는 TTo 유형의 데이터를 받아 ConvertBack 함수를 사용하여 TFrom으로 변환한 다음 m_source 오브젝트의 Receive 함수에 전달한다.

// Create two targets
ITarget<int>* intTarget = new IntTarget();
ITarget<string>* stringTarget = new StringTarget();

// Create a two-way adapter between the two targets
TwoWayAdapter<int, string>* adapter
= new TwoWayAdapter<int, string>(intTarget, stringTarget);

// Send data from intTarget to stringTarget
intTarget->Receive(123);

// Check that the stringTarget received the data as expected
cout << "String target value: " << stringTarget->GetValue() << endl;

// Send data from stringTarget to intTarget
stringTarget->Receive("456");

// Check that the intTarget received the data as expected
cout << "Int target value: " << intTarget->GetValue() << endl;

실제 사용 예제를 확인하자. 두 대상 간에 양방향 데이터 전송을 허용하는 IntTarget과 StringTarget 사이에 TwoWayAdapter를 생성한다. Receive 함수는 값이 123인 intTarget로 호출하며, 이 값은 문자열로 변환되어 TwoWayAdapter를 통해 stringTarget으로 전달된다. 그런 다음 stringTarget에서 GetValue 함수를 호출하여 예상대로 데이터를 수신했는지 확인한다.

“456”인 stringTarget로 Receive 함수를 호출하고, 이 값은 정수로 변환되어 TwoWayAdapter를 통해 intTarget으로 전달된다. 그런 다음 intTarget에서 GetValue 함수를 호출하여 예상대로 데이터를 수신했는지 확인한다.

실생활에서 어댑터 사용

어댑터는 인터페이스 비호환성, 데이터 변환, 객체 구성과 관련된 다양한 문제를 해결하기 위해 소프트웨어 개발에서 널리 사용된다.

그래픽 사용자 인터페이스는 종종 어댑터를 사용하여 UI 컨트롤을 기본 데이터 모델에 매핑한다. 예를 들어 목록 보기 컨트롤을 개체 모음에 맞게 조정하거나 날짜 선택기 컨트롤을 날짜/시간 데이터 유형에 맞게 조정할 수 있다.

데이터베이스 액세스 계층은 종종 어댑터를 사용하여 데이터베이스별 유형과 애플리케이션별 타입 간에 변환한다. 예를 들어 데이터베이스 드라이버는 데이터베이스의 기본 날짜/시간 유형과 C++ std::chrono::system_clock::time_point 사이를 변환하는 어댑터를 사용할 수 있다.

네트워킹 라이브러리는 종종 어댑터를 사용하여 서로 다른 네트워크 프로토콜 또는 데이터 형식 간에 변환한다. 예를 들어, HTTP 클라이언트 라이브러리는 JSON 데이터와 C++ 데이터 구조 간을 변환하는 어댑터를 사용할 수 있다.

마무리

어댑터는 인터페이스 호환성 문제를 해결하고 소프트웨어 개발을 간소화할 수 있는 강력한 도구이다. 인터페이스 매핑 로직을 별도의 어댑터 클래스에 캡슐화하면 코드를 깔끔하고 유지 관리하기 쉽게 유지할 수 있으며, 인터페이스별 세부 사항으로 메인 코드가 복잡해지는 것을 방지할 수 있다.

--

--

이상문

software developer working mainly in field of streaming, using C++, javascript