C++ Strategy 패턴: 다형성 vs 함수 포인터 vs 람다 vs std::function으로 알고리즘 교체하기
이 글의 핵심
Strategy 패턴을 네 가지 방식으로 구현해 보고 각 방식의 유연성과 성능 차이를 비교하며, 압축 알고리즘 교체 예제와 실무에서 생기는 문제를 정리합니다.
Strategy Pattern이란? 왜 필요한가
알고리즘을 하드코딩했을 때의 문제
문제: 정렬 알고리즘을 선택하는 로직이 Context에 하드코딩되면, 새 알고리즘 추가 시 Context를 수정해야 합니다.
// 나쁜 예: 알고리즘 하드코딩
class Sorter {
public:
void sort(std::vector<int>& data, const std::string& algorithm) {
if (algorithm == "bubble") {
// 버블 정렬
} else if (algorithm == "quick") {
// 퀵 정렬
} else if (algorithm == "merge") {
// 병합 정렬
}
// 새 알고리즘 추가 시 여기 수정
}
};
해결: Strategy Pattern은 알고리즘을 캡슐화해 런타임에 교체 가능하게 합니다. 행동 패턴 시리즈·State 패턴과 “알고리즘 교체 vs 상태 전이”를 비교해 읽으면 좋습니다.
// 좋은 예: Strategy Pattern
// 타입 정의
class SortStrategy {
public:
virtual void sort(std::vector<int>& data) = 0;
virtual ~SortStrategy() = default;
};
class BubbleSort : public SortStrategy {
void sort(std::vector<int>& data) override { /* ... */ }
};
class Sorter {
public:
void setStrategy(std::unique_ptr<SortStrategy> s) {
strategy = std::move(s);
}
void sort(std::vector<int>& data) {
strategy->sort(data);
}
private:
std::unique_ptr<SortStrategy> strategy;
};
flowchart TD
context["Context (Sorter)"]
strategy["Strategy (SortStrategy)"]
bubble[BubbleSort]
quick[QuickSort]
merge[MergeSort]
context --> strategy
bubble -.implements.-> strategy
quick -.implements.-> strategy
merge -.implements.-> strategy
“나쁜 예”의 문제는 if/else가 많다는 것 자체가 아니라, 알고리즘을 추가할 때마다 이미 테스트가 끝난 Sorter를 열어서 고쳐야 한다는 점입니다. 그 과정에서 기존 분기를 건드려 회귀 버그를 만들 수 있고, 알고리즘 하나만 단위 테스트하기도 어렵습니다. 문자열로 알고리즘을 고르기 때문에 "Quick"처럼 대소문자만 달라도 아무 분기에도 걸리지 않고 조용히 정렬되지 않은 채 반환되는 버그도 생깁니다.
Strategy는 “무엇을 할지”(정렬한다)와 “어떻게 할지”(버블, 퀵)를 나눕니다. Sorter는 전략의 인터페이스만 알고, 새 알고리즘은 SortStrategy를 구현하는 클래스 하나를 추가하면 끝납니다. 사실 C++ 개발자는 이미 매일 Strategy를 쓰고 있습니다. std::sort(begin, end, comp)의 비교 함수, std::unordered_map의 해시 함수, 컨테이너의 할당자가 모두 “알고리즘의 일부를 호출자가 끼워 넣는” 전략입니다. 차이는 표준 라이브러리는 이것을 템플릿 인자(컴파일 타임)로, 이 글의 대부분 예제는 가상 함수나 std::function(런타임)으로 한다는 점입니다.
가상 함수 기반 기본 구조
최소 Strategy
#include <iostream>
#include <vector>
#include <memory>
#include <algorithm>
class SortStrategy {
public:
virtual void sort(std::vector<int>& data) = 0;
virtual std::string name() const = 0;
virtual ~SortStrategy() = default;
};
class BubbleSort : public SortStrategy {
public:
void sort(std::vector<int>& data) override {
for (size_t i = 0; i < data.size(); ++i) {
for (size_t j = 0; j + i + 1 < data.size(); ++j) { // 빈 벡터에서 size()-1 언더플로 방지
if (data[j] > data[j + 1]) {
std::swap(data[j], data[j + 1]);
}
}
}
}
std::string name() const override { return "BubbleSort"; }
};
class QuickSort : public SortStrategy {
public:
void sort(std::vector<int>& data) override {
std::sort(data.begin(), data.end());
}
std::string name() const override { return "QuickSort"; }
};
class Sorter {
public:
void setStrategy(std::unique_ptr<SortStrategy> s) {
strategy = std::move(s);
}
void sort(std::vector<int>& data) {
if (strategy) {
std::cout << "Using " << strategy->name() << '\n';
strategy->sort(data);
}
}
private:
std::unique_ptr<SortStrategy> strategy;
};
int main() {
Sorter sorter;
std::vector<int> data = {5, 2, 8, 1, 9};
sorter.setStrategy(std::make_unique<BubbleSort>());
sorter.sort(data); // Using BubbleSort
sorter.setStrategy(std::make_unique<QuickSort>());
sorter.sort(data); // Using QuickSort
}
Sorter가 전략을 unique_ptr로 소유한다는 점이 이 구현의 핵심 결정입니다. 전략을 넘기는 순간 호출자는 그 객체를 더 이상 들고 있지 않으므로, 다른 Sorter와 같은 전략 객체를 공유하는 일이 생기지 않습니다. 대신 setStrategy를 호출할 때마다 힙 할당이 한 번 일어나고, 같은 전략을 여러 Context에서 쓰려면 각자 새로 만들어야 합니다. 전략이 상태 없는 순수 알고리즘이라면 static 인스턴스 하나를 두고 참조나 원시 포인터로 넘기는 설계도 흔합니다.
버블 정렬의 안쪽 루프 조건을 j + i + 1 < data.size()로 쓴 데는 이유가 있습니다. 흔히 보는 j < data.size() - i - 1은 size()가 부호 없는 size_t라서, 빈 벡터가 들어오면 0 - 0 - 1이 언더플로해 약 1800경이 되고 루프가 범위 밖 메모리를 읽다가 크래시합니다. 부호 없는 정수끼리의 뺄셈은 C++에서 가장 흔한 경계 버그 중 하나라, 뺄셈 대신 덧셈으로 비교하는 습관이 안전합니다.
두 번째 sort 호출은 이미 정렬된 데이터를 다시 정렬하므로 결과가 같습니다. 예제를 실제로 돌려 보며 전략 차이를 확인하려면 호출 전에 데이터를 다시 섞어야 합니다.
함수 포인터로 전략 넘기기
간단한 알고리즘
#include <iostream>
#include <vector>
#include <algorithm>
using SortFunc = void(*)(std::vector<int>&);
void bubbleSort(std::vector<int>& data) {
for (size_t i = 0; i < data.size(); ++i) {
for (size_t j = 0; j + i + 1 < data.size(); ++j) { // 빈 벡터에서 size()-1 언더플로 방지
if (data[j] > data[j + 1]) {
std::swap(data[j], data[j + 1]);
}
}
}
}
void quickSort(std::vector<int>& data) {
std::sort(data.begin(), data.end());
}
class Sorter {
public:
void setStrategy(SortFunc func) {
strategy = func;
}
void sort(std::vector<int>& data) {
if (strategy) {
strategy(data);
}
}
private:
SortFunc strategy = nullptr;
};
int main() {
Sorter sorter;
std::vector<int> data = {5, 2, 8, 1, 9};
sorter.setStrategy(bubbleSort);
sorter.sort(data);
sorter.setStrategy(quickSort);
sorter.sort(data);
}
함수 포인터 방식은 클래스 계층이 사라져 코드가 훨씬 짧고, 힙 할당도 없으며, 포인터 하나(8바이트)만 저장합니다. C 라이브러리의 qsort 비교 함수처럼 오래된 방식이기도 합니다. 한계는 상태를 가질 수 없다는 것입니다. “내림차순 여부”나 “비교 기준 필드” 같은 설정값을 전략에 담으려면 전역 변수를 쓰거나 void* 사용자 데이터를 추가로 넘겨야 합니다. 캡처가 없는 람다는 함수 포인터로 암묵 변환되므로 setStrategy([](std::vector<int>& d) { ... })처럼 넘길 수 있지만, 캡처가 하나라도 있으면 “no suitable conversion function from lambda to SortFunc” 에러가 납니다. 이 제약을 푸는 것이 다음 절의 std::function입니다.
람다로 전략 넘기기
인라인 알고리즘
#include <iostream>
#include <vector>
#include <functional>
#include <algorithm>
class Sorter {
public:
using Strategy = std::function<void(std::vector<int>&)>;
void setStrategy(Strategy s) {
strategy = s;
}
void sort(std::vector<int>& data) {
if (strategy) {
strategy(data);
}
}
private:
Strategy strategy;
};
int main() {
Sorter sorter;
std::vector<int> data = {5, 2, 8, 1, 9};
// 람다로 Strategy 정의
sorter.setStrategy([](std::vector<int>& data) {
std::sort(data.begin(), data.end());
});
sorter.sort(data);
// 역순 정렬
sorter.setStrategy([](std::vector<int>& data) {
std::sort(data.begin(), data.end(), std::greater<>());
});
sorter.sort(data);
}
람다를 쓰면 전략을 쓰는 곳 바로 옆에 정의할 수 있어, 한 번만 쓰는 작은 알고리즘을 위해 클래스를 만들 필요가 없습니다. 이 방식이 강력해지는 것은 캡처를 쓸 때입니다. bool descending = config.descending;을 캡처한 람다 하나로 오름차순·내림차순을 모두 표현할 수 있고, 가상 함수 방식이라면 생성자 인자를 받는 클래스를 따로 만들어야 할 일입니다.
캡처에는 수명 함정이 따라옵니다. 지역 변수를 참조로 캡처([&])한 람다를 Sorter에 저장하고, 그 지역 변수가 있던 함수가 반환된 뒤에 sort()를 호출하면 댕글링 참조를 읽게 됩니다. 컴파일러는 경고하지 않고, 증상은 가끔 이상한 정렬 결과나 크래시로 나타납니다. 저장해 두었다가 나중에 호출되는 람다라면 값 캡처([=] 또는 [descending])를 기본으로 하세요. 저도 콜백으로 등록한 람다가 this를 캡처해 둔 채 객체가 먼저 파괴되어 크래시가 나는 버그를 여러 번 추적해 봤는데, 원인은 대부분 저장된 람다의 참조 캡처였습니다.
std::function으로 전략 저장하기
유연한 Strategy
#include <iostream>
#include <vector>
#include <functional>
#include <algorithm>
class PaymentStrategy {
public:
using Strategy = std::function<bool(double)>;
void setStrategy(Strategy s) {
strategy = s;
}
bool pay(double amount) {
if (strategy) {
return strategy(amount);
}
return false;
}
private:
Strategy strategy;
};
int main() {
PaymentStrategy payment;
// 신용카드
payment.setStrategy([](double amount) {
std::cout << "Paying $" << amount << " with Credit Card\n";
return true;
});
payment.pay(100.0);
// PayPal
payment.setStrategy([](double amount) {
std::cout << "Paying $" << amount << " with PayPal\n";
return true;
});
payment.pay(50.0);
}
std::function<bool(double)>은 “double을 받아 bool을 반환하는 호출 가능한 모든 것”을 담는 타입 소거 래퍼입니다. 일반 함수, 람다, 함수 객체, std::bind 결과를 모두 같은 타입으로 저장할 수 있어서, 전략을 설정 파일에 따라 고르거나 플러그인에서 받아오는 등 런타임에 조립해야 할 때 가장 유연합니다. 그 대가로 호출이 한 번의 간접 호출을 거쳐 인라인되지 않고, 캡처한 데이터가 내부 버퍼(구현마다 대략 16~32바이트)보다 크면 힙에 할당됩니다. 캡처 없는 람다나 작은 캡처는 대부분 힙 할당 없이 저장됩니다. 비어 있는 std::function을 호출하면 std::bad_function_call 예외가 나므로, 이 예제처럼 if (strategy)로 확인하는 습관이 필요합니다.
결제 예제는 실무라면 조심해야 할 부분이 있습니다. 금액을 double로 다루면 0.1 + 0.2가 0.30000000000000004가 되는 부동소수점 오차가 생기므로, 실제 결제 코드는 최소 화폐 단위(원, 센트)의 정수나 고정소수점 타입을 씁니다. 또 결제 수단은 실패 이유(잔액 부족, 카드 만료, 네트워크 오류)가 호출자에게 중요하므로, bool 대신 결과 타입이나 에러 코드를 반환하는 편이 좋습니다. setStrategy(Strategy s) { strategy = s; }는 인자를 한 번 더 복사하므로 strategy = std::move(s);로 바꾸면 캡처가 큰 람다에서 불필요한 복사를 줄일 수 있습니다.
nullptr 전략과 상태 공유 문제
Strategy nullptr
증상: 크래시. 원인: Strategy가 설정되지 않았습니다.
// ❌ 잘못된 사용: nullptr 검사 없음
void sort(std::vector<int>& data) {
strategy->sort(data); // Crash: nullptr
}
// ✅ 올바른 사용: nullptr 검사
void sort(std::vector<int>& data) {
if (strategy) {
strategy->sort(data);
} else {
throw std::runtime_error("Strategy not set");
}
}
빈 전략을 예외로 거부하는 방식과, 아래 “패턴 1”처럼 생성자에서 기본 전략을 채워 빈 상태 자체를 없애는 방식이 있습니다. 후자가 대부분 더 낫습니다. 예외를 던지는 방식은 여전히 런타임에야 문제를 발견하지만, 기본값을 두면 “전략을 설정하지 않은 Sorter”라는 잘못된 상태가 존재하지 않게 됩니다. 기본 전략이 마땅치 않다면 생성자에서 전략을 필수 인자로 받게 하는 것도 방법입니다.
상태 공유
증상: 예상과 다른 동작. 원인: Strategy가 상태를 가지면 재사용 시 문제가 됩니다.
상태를 가진 전략이 그 자체로 잘못된 것은 아닙니다. 캐시를 들고 있는 전략, 호출 횟수에 따라 동작을 바꾸는 속도 제한 전략처럼 상태가 필요한 경우도 있습니다. 문제는 같은 전략 인스턴스를 여러 Context나 여러 스레드가 공유할 때입니다. 한 Context의 호출이 다른 Context의 결과에 영향을 주고, 멀티스레드라면 ++count가 데이터 레이스가 됩니다. 아래 “올바른 사용”처럼 상태를 없앨 수 있다면 가장 안전하고, 상태가 필요하다면 Context마다 전략을 따로 만들어 소유하게 하거나(unique_ptr 방식이 이를 강제합니다) 상태를 Context 쪽으로 옮겨 전략 호출 시 인자로 넘기는 방식을 씁니다. (예제 이름의 CountingSort는 계수 정렬 알고리즘이 아니라 “호출 횟수를 세는 정렬 전략”이라는 뜻입니다.)
// ❌ 잘못된 사용: 상태 공유
class CountingSort : public SortStrategy {
int count = 0; // 상태
public:
void sort(std::vector<int>& data) override {
++count; // 재사용 시 누적
}
};
// ✅ 올바른 사용: 상태 없는 Strategy
class CountingSort : public SortStrategy {
public:
void sort(std::vector<int>& data) override {
// 상태 없음, 순수 알고리즘
}
};
기본 전략 지정과 Strategy Factory
기본 Strategy
class Sorter {
public:
Sorter() : strategy(std::make_unique<QuickSort>()) {} // 기본값
void setStrategy(std::unique_ptr<SortStrategy> s) {
if (s) {
strategy = std::move(s);
}
}
void sort(std::vector<int>& data) {
strategy->sort(data); // 항상 유효
}
private:
std::unique_ptr<SortStrategy> strategy;
};
Strategy Factory
class StrategyFactory {
public:
static std::unique_ptr<SortStrategy> create(const std::string& type) {
if (type == "bubble") return std::make_unique<BubbleSort>();
if (type == "quick") return std::make_unique<QuickSort>();
return nullptr;
}
};
int main() {
Sorter sorter;
sorter.setStrategy(StrategyFactory::create("quick"));
}
패턴 1과 2를 조합할 때 주의할 점이 있습니다. 팩토리는 모르는 이름에 nullptr을 반환하고, 패턴 1의 setStrategy는 nullptr을 조용히 무시합니다. 설정 파일에 "qiuck"이라고 오타를 내면 에러 없이 기본 전략으로 동작하므로, 설정이 반영되지 않았다는 사실을 한참 뒤에 알게 됩니다. 조용한 대체는 편하지만 설정 오류를 숨기므로, 최소한 경고 로그를 남기거나 팩토리에서 알 수 없는 이름을 예외로 처리하는 편이 좋습니다. 전략 종류가 많아지면 if 나열 대신 std::unordered_map<std::string, std::function<std::unique_ptr<SortStrategy>()>>에 생성 함수를 등록하는 레지스트리 방식을 쓰면, 새 전략을 추가할 때 팩토리 코드를 고치지 않아도 됩니다.
압축 알고리즘 선택 예제
#include <cstdint>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>
class CompressionStrategy {
public:
virtual std::vector<uint8_t> compress(const std::string& data) = 0;
virtual std::string decompress(const std::vector<uint8_t>& data) = 0;
virtual std::string name() const = 0;
virtual ~CompressionStrategy() = default;
};
class ZipCompression : public CompressionStrategy {
public:
std::vector<uint8_t> compress(const std::string& data) override {
std::cout << "[ZIP] Compressing " << data.size() << " bytes\n";
std::vector<uint8_t> result(data.begin(), data.end());
return result;
}
std::string decompress(const std::vector<uint8_t>& data) override {
std::cout << "[ZIP] Decompressing " << data.size() << " bytes\n";
return std::string(data.begin(), data.end());
}
std::string name() const override { return "ZIP"; }
};
class GzipCompression : public CompressionStrategy {
public:
std::vector<uint8_t> compress(const std::string& data) override {
std::cout << "[GZIP] Compressing " << data.size() << " bytes\n";
std::vector<uint8_t> result(data.begin(), data.end());
return result;
}
std::string decompress(const std::vector<uint8_t>& data) override {
std::cout << "[GZIP] Decompressing " << data.size() << " bytes\n";
return std::string(data.begin(), data.end());
}
std::string name() const override { return "GZIP"; }
};
class Compressor {
public:
void setStrategy(std::unique_ptr<CompressionStrategy> s) {
strategy = std::move(s);
}
std::vector<uint8_t> compress(const std::string& data) {
if (!strategy) {
throw std::runtime_error("Compression strategy not set");
}
std::cout << "Using " << strategy->name() << " compression\n";
return strategy->compress(data);
}
std::string decompress(const std::vector<uint8_t>& data) {
if (!strategy) {
throw std::runtime_error("Compression strategy not set");
}
return strategy->decompress(data);
}
private:
std::unique_ptr<CompressionStrategy> strategy;
};
int main() {
Compressor compressor;
std::string data = "Hello, World! This is a test.";
compressor.setStrategy(std::make_unique<ZipCompression>());
auto compressed = compressor.compress(data);
auto decompressed = compressor.decompress(compressed);
std::cout << "Result: " << decompressed << "\n\n";
compressor.setStrategy(std::make_unique<GzipCompression>());
compressed = compressor.compress(data);
decompressed = compressor.decompress(compressed);
std::cout << "Result: " << decompressed << '\n';
}
예제의 compress는 구조를 보여 주기 위해 바이트를 그대로 복사할 뿐이고, 실제로는 zlib이나 zstd 같은 라이브러리를 호출하는 자리입니다. 이 예제에서 눈여겨볼 설계 문제는 압축과 해제가 같은 전략 객체에 묶여 있다는 점입니다. ZIP으로 압축한 데이터를 저장해 두고, 나중에 전략을 GZIP으로 바꾼 Compressor로 해제하면 형식이 맞지 않아 깨진 데이터가 나옵니다. 이 코드에서는 둘 다 복사일 뿐이라 우연히 동작하지만, 실제 압축이라면 해제 단계에서 “invalid header” 같은 오류가 납니다. 그래서 실무 형식들은 압축 결과 앞에 어떤 알고리즘을 썼는지 표시하는 헤더(매직 넘버)를 붙이고, 해제할 때는 현재 설정된 전략이 아니라 헤더를 보고 전략을 고릅니다. 전략을 교체할 수 있다는 것은 곧 “이전 전략으로 만든 데이터”가 남아 있을 수 있다는 뜻이라, 저장되는 결과물을 만드는 전략이라면 이 호환성 문제를 처음부터 설계에 넣어야 합니다.
네 방식의 성능 비교
| 방식 | 장점 | 단점 |
|---|---|---|
| 다형성 | 타입 안전, 확장 가능, 상태 보유 | 가상 호출(인라인 불가), 보통 힙 할당 |
| 함수 포인터 | 가볍고 간단, 할당 없음 | 상태 없음, 인라인 어려움 |
| 람다 (템플릿 인자로 받을 때) | 인라인 가능, 캡처 가능 | 타입이 컴파일 타임에 고정, 런타임 교체 불가 |
| std::function | 유연, 모든 callable | 간접 호출, 큰 캡처는 힙 할당 |
표를 읽을 때 주의할 점은 “람다”의 장점인 인라인이 람다를 어떻게 저장하느냐에 달려 있다는 것입니다. 람다 방식처럼 람다를 std::function에 담으면 std::function과 성능 특성이 같아집니다. 람다가 인라인되는 것은 template<typename F> void sort(std::vector<int>& d, F strategy)처럼 전략의 타입 자체를 템플릿 인자로 받을 때입니다. 이것이 std::sort의 비교 함수가 C의 qsort보다 빠른 이유이며, 전략을 런타임에 바꿀 필요가 없다면 가장 빠른 선택입니다. 다만 전략마다 템플릿이 따로 인스턴스화되어 바이너리가 커지고, 서로 다른 전략을 가진 Context는 서로 다른 타입이 되어 한 컨테이너에 담을 수 없습니다.
실제로 이 차이가 의미 있는 것은 전략이 아주 작고 아주 자주 호출될 때뿐입니다. 정렬 비교 함수처럼 수백만 번 호출되는 한 줄짜리 함수라면 인라인 여부가 체감 성능을 바꾸지만, 압축이나 결제처럼 전략 하나의 실행이 호출 비용보다 수천 배 무거운 경우에는 가상 호출이든 std::function이든 차이가 측정되지 않습니다. 성능보다 설계의 명확성으로 고르고, 병목이 확인된 핫 루프에서만 템플릿으로 바꾸는 순서가 합리적입니다.
Strategy Pattern 요약
| 개념 | 설명 |
|---|---|
| Strategy Pattern | 알고리즘을 캡슐화해 런타임 교체 |
| 목적 | 알고리즘 독립성, 확장성 |
| 구조 | Context, Strategy, ConcreteStrategy |
| 장점 | OCP 준수, 조건문 제거, 테스트 용이 |
| 단점 | 클래스 증가, 간접 참조 |
| 사용 사례 | 정렬, 압축, 결제, 라우팅 |
Strategy Pattern은 알고리즘을 동적으로 교체해야 하는 상황에서 강력한 디자인 패턴입니다.
FAQ
Q1: Strategy Pattern은 언제 쓰나요?
A: 여러 알고리즘 중 선택해야 하며, 런타임에 교체가 필요할 때 사용합니다.
Q2: 다형성 vs 람다?
A: 확장성이 중요하면 다형성, 간단한 알고리즘이면 람다를 사용하세요.
Q3: State Pattern과 차이는?
A: Strategy는 알고리즘 교체, State는 상태 전이에 집중합니다.
Q4: 성능 오버헤드는?
A: 다형성은 가상 호출(인라인 불가), std::function은 간접 호출과 큰 캡처에 대한 힙 할당 오버헤드가 있습니다. 가장 빠른 것은 전략 타입을 템플릿 인자로 받아 인라인되게 하는 방식이며, 대신 런타임 교체가 불가능합니다. 대부분의 전략은 실행 시간이 호출 비용보다 훨씬 커서 차이가 드러나지 않습니다.
Q5: 기본 Strategy는 어떻게 설정하나요?
A: 생성자에서 기본 Strategy를 설정하세요.
Q6: Strategy Pattern 학습 리소스는?
A:
- “Design Patterns” by Gang of Four
- “Head First Design Patterns” by Freeman & Freeman
- Refactoring Guru: Strategy Pattern Strategy Pattern으로 알고리즘을 캡슐화하고 런타임에 교체할 수 있습니다. 다음으로 Command Pattern을 읽어보면 좋습니다.
같이 보면 좋은 글
- C++ 가상 함수 심화 가이드 | vtable·vptr, 가상 상속, 추상 클래스, 가상 소멸자
- C++ Observer 패턴
- C++ CRTP: 가상 함수 없이 정적 다형성 구현하기와 C++23 deducing this
- C++ Factory 패턴 비교
- C++ Visitor Pattern
- 배열과 연결 리스트
- C++ Adapter 패턴