이전 글에서는 비지터(Visitor) 패턴을 모던 C++ 관점에서 재해석하며, std::variant와 std::visit를 통해 이중 디스패치 없이도 다양한 요소 타입별 로직을 처리하는 법을 살펴보았습니다. 이제는 구조적(Structural) 패턴 중 하나인 어댑터(Adapter) 패턴을 다룹니다.
어댑터 패턴은 기존 클래스나 함수 인터페이스를 클라이언트가 원하는 다른 인터페이스로 변환하는 패턴입니다. 전통적으로는 어댑터 클래스를 상속 기반으로 정의하고, 기존 인터페이스를 변환하는 메서드를 구현했지만, 이 방식은 클래스 증가, 상속 기반 구조로 인한 유지보수 어려움을 야기합니다.
C++20 이상에서는 Concepts로 원하는 인터페이스 요구사항을 명시하고, 람다나 함수 합성으로 변환 로직을 구현하면 상속 없이도 인터페이스 변환이 가능합니다. std::expected로 에러 처리, std::format으로 로깅, coroutine으로 비동기 변환, Ranges로 조건부 변환 파이프라인 등을 구성해 더 유연하고 확장성 높은 어댑터를 만들 수 있습니다.
패턴 소개: 어댑터(Adapter)
의도:
- 기존 인터페이스를 클라이언트가 요구하는 다른 인터페이스로 변환하는 중개자를 제공.
- 예: 레거시 클래스가 제공하는 함수와 클라이언트가 원하는 함수 프로토타입이 다를 때, 어댑터를 통해 호출 가능하도록 함.
- 전통적 구현에서 어댑터 클래스는 상속으로 기존 인터페이스를 감싸고 변환 메서드 구현.
전통적 구현 문제점:
- 어댑터 클래스를 별도로 정의, 상속 기반으로 인터페이스 변환
- 새로운 변환 요구사항 추가 시 클래스 증가
- 비동기 처리나 에러 처리, 로깅 등 고급 요구사항 적용 어려움
기존 C++ 스타일 구현 (전통적 방식)
예를 들어, 레거시 AudioPlayer가 playFile(std::string filename)를 통해 오디오 재생하는데, 클라이언트는 (const char* file) 시그니처를 원하는 상황을 가정.
#include <iostream>
#include <string>
struct LegacyAudioPlayer {
void playFile(std::string filename) {
std::cout << "Playing " << filename << "\n";
}
};
struct ClientInterface {
virtual ~ClientInterface()=default;
virtual void play(const char* file)=0;
};
// 어댑터 클래스
struct AudioAdapter : ClientInterface {
LegacyAudioPlayer& player;
AudioAdapter(LegacyAudioPlayer& p):player(p){}
void play(const char* file) override {
player.playFile(std::string(file));
}
};
int main() {
LegacyAudioPlayer legacy;
AudioAdapter adapter(legacy);
adapter.play("song.mp3"); // Playing song.mp3
}
문제점:
- 어댑터 클래스 정의 필요, 상속 기반 구조
- 다른 인터페이스 변환 추가 시 클래스 증가
- 에러 처리나 로깅, 비동기 변환 등 어렵
모던 C++20 이상의 개선: Concepts, 람다로 인터페이스 변환
1. Concepts로 원하는 인터페이스 제약
클라이언트가 원하는 인터페이스: play(const char* file) 호출 시 std::expected<void,std::string> 반환한다고 가정:
template<typename C>
concept ClientPlayable = requires(C& c, const char* file) {
{ c.play(file) } -> std::convertible_to<std::expected<void,std::string>>;
};
2. 기존 인터페이스와 변환 람다 정의
레거시 AudioPlayer:
struct LegacyAudioPlayer2 {
void playFile(std::string filename) {
std::cout << "Playing " << filename << "\n";
}
};
// 레거시 함수
auto legacyPlay = [](LegacyAudioPlayer2& player, std::string filename) -> std::expected<void,std::string> {
if(filename.empty()) return std::unexpected("Filename empty");
player.playFile(filename);
return {};
};
3. 어댑터: 람다 합성으로 인터페이스 변환
어댑터 없이도 람다로 (const char* file)를 받는 함수를 만들어 내부에서 std::string으로 변환 후 legacyPlay 호출:
auto makeAdapter = [](auto& legacyPlayer) {
return [&](const char* file) -> std::expected<void,std::string> {
if(!file) return std::unexpected("Null file pointer");
return legacyPlay(legacyPlayer, std::string(file));
};
};
LegacyAudioPlayer2 legacy;
auto adapted = makeAdapter(legacy);
static_assert(ClientPlayable<decltype(adapted)>);
비교:
- 전통적: AudioAdapter 클래스 필요
- C++20: 람다로 인터페이스 변환, 상속 없음, Concepts로 타입 안전성 보장
사용 예
int main() {
LegacyAudioPlayer2 legacy;
auto adapted = makeAdapter(legacy);
auto res = adapted("song.mp3");
if(!res) std::cerr << "Error: " << res.error() << "\n";
else std::cout << "Play success\n";
}
출력:
Playing song.mp3
Play success
로깅, 비동기 처리, 조건부 변환
로깅 추가
std::format으로 로깅 가능:
auto loggingAdapter = [&](auto baseAdapter) {
return [=](const char* file) -> std::expected<void,std::string> {
std::cout << std::format("[LOG] Attempting to play: {}\n", file ? file : "(null)");
auto res = baseAdapter(file);
if(!res) std::cout << std::format("[LOG] Error: {}\n", res.error());
else std::cout << "[LOG] Play success.\n";
return res;
};
};
auto loggedAdapter = loggingAdapter(adapted);
loggedAdapter("song.mp3");
비교:
- 전통적: 로깅 추가 시 Decorator 클래스 필요
- C++20: 람다 장식으로 로깅, 상속 없이 기능 확장
조건부 변환
특정 조건에서 변환 거부:
auto conditionalAdapter = [&](auto baseAdapter) {
return [=](const char* file) -> std::expected<void,std::string> {
if(file && std::string(file).ends_with(".wav")) {
return std::unexpected("WAV files not supported");
}
return baseAdapter(file);
};
};
auto condAdapter = conditionalAdapter(adapted);
condAdapter("music.wav"); // Error: WAV files not supported
비교:
- 전통적: 조건부 로직 추가 시 어댑터 클래스 수정 필요
- C++20: 람다로 쉽게 조건부 처리 추가
비동기 처리: coroutine
비동기 변환 필요 시 coroutine 사용 가능:
// co_adapter(...) 가정: co_await 비동기 I/O, 완료 후 std::expected 반환
// C++20 coroutine으로 비동기 변환 로직 간단히 구현
비교:
- 전통적: 비동기 처리 시 Future/Promise, 스레드 관리 필요
- C++20: coroutine으로 비동기 변환 자연스럽게 처리
Ranges로 복수 인터페이스 변환
여러 개의 legacy 인터페이스를 변환해야 한다면, Ranges로 변환 함수를 파이프라인화 가능:
#include <vector>
#include <ranges>
std::vector<const char*> files = {"song1.mp3", "song2.mp3", ""};
for (auto res : files | std::views::transform(adapted)) {
if(!res) std::cerr << "Error: " << res.error() << "\n";
else std::cout << "Play success\n";
}
비교:
- 전통적: 다수 파일 처리 시 명령형 코드 증가
- C++20: Ranges로 파이프라인 형태 처리, 더 선언적이고 유지보수성 향상
전통적 구현 vs C++11/14/17 vs C++20 이상 비교
전통적(C++98/03):
- Adapter 클래스 상속 필요, 기존 인터페이스를 감싸 새 인터페이스로 변환
- 새로운 변환 요구사항 추가 시 클래스 증가
- 비동기 처리, 에러 처리, 로깅, 조건부 변환 구현 복잡
C++11/14/17:
- 람다, std::function 활용 가능하나 Concepts, std::expected, coroutine, Ranges 미지원
- 에러 처리나 타입 안전한 인터페이스 제약, 비동기 변환 등에 한계
C++20 이상(모던 C++):
- Concepts로 원하는 인터페이스 제약 정의, 상속 없이 타입 안전한 변환
- 람다 합성으로 인터페이스 변환, std::expected로 에러 처리 명확, std::format으로 로깅 용이
- coroutine으로 비동기 변환, Ranges로 복수 대상 파이프라인 처리
- 클래스 증가 없이 확장 가능, 유지보수성, 확장성, 타입 안전성 향상
결론
어댑터(Adapter) 패턴은 기존 인터페이스를 원하는 다른 인터페이스로 변환하는 핵심 패턴입니다. 전통적 구현은 상속 기반 어댑터 클래스를 필요로 하고, 새로운 요구사항 추가 시 클래스 증가와 유지보수 어려움을 초래합니다.
C++20 이상에서는 Concepts, 람다, std::function, std::expected, std::format, coroutine, Ranges 등을 활용해 상속 없이도 인터페이스 변환을 간결하게 구현할 수 있습니다. 이를 통해 에러 처리, 비동기 지원, 로깅, 조건부 변환, 파이프라인 처리 등 다양한 요구사항에도 쉽게 대응 가능하며, 전통적 구현 대비 훨씬 적은 코드와 더 나은 유지보수성, 확장성을 확보할 수 있습니다.