지난 글에서는 퍼사드(Facade) 패턴을 모던 C++ 관점에서 재해석하며, 모듈(Modules), Concepts, std::expected, std::format, std::variant 등을 활용해 복잡한 서브시스템을 단순화하는 전략을 살펴보았습니다. 이번 글에서는 또 하나의 구조적 패턴인 플라이웨이트(Flyweight) 패턴에 주목합니다.
플라이웨이트 패턴은 다수의 유사한 객체를 효율적으로 다루기 위해, 공유 가능한 상태를 메모리 절약적으로 관리하는 방법을 제시합니다. 전통적으로는 "풀(Pool)을 두고 재활용"하거나 "공유된 객체를 전역 맵으로 관리"하는 방식으로 구현했으나, 모던 C++에서는 std::unordered_map, std::shared_ptr, std::string_view, Concepts, std::expected, 심지어는 Ranges와 std::variant 등을 활용해 더 안전하고 유연한 공유 전략을 세울 수 있습니다.
패턴 소개: 플라이웨이트(Flyweight)
의도:
- 객체가 많을 때, 공통된 속성을 공유해 메모리 사용량을 줄이고, 성능을 개선.
- 자주 반복되는 동일한 상태(예: 문서 내 글자, GUI 아이콘, 문자열)들을 "플라이웨이트"로 관리하여 중복 생성 방지.
- 예: 텍스트 편집기에서 문자를 표시할 때, 각 문자 객체를 매번 생성하지 않고, 공유 가능한 폰트나 스타일 정보를 flyweight 객체로 관리.
전통적 구현 문제점:
- 전역 pool이나 static 맵으로 flyweight 관리, 쓰레드 안전성이나 라이프사이클 관리 복잡.
- 문자열 같은 자원 공유 시 사본 관리 어려움.
- 타입 안전성과 문서화 미비.
기존 C++ 스타일 구현 (C++11/14/17)
예를 들어, 그래픽 아이콘 표시를 가정해봅시다. 많은 아이콘이 동일한 이미지를 공유하되, 위치나 상태만 다를 수 있습니다.
#include <unordered_map>
#include <memory>
#include <string>
#include <iostream>
struct IconData {
std::string imagePath;
// 실제 이미지 데이터 가정
};
struct IconFlyweight {
std::shared_ptr<IconData> data;
IconFlyweight(std::shared_ptr<IconData> d) : data(std::move(d)) {}
void render(int x, int y) const {
std::cout << "Rendering " << data->imagePath << " at (" << x << "," << y << ")\n";
}
};
struct IconFactory {
std::unordered_map<std::string, std::weak_ptr<IconData>> cache;
IconFlyweight getIcon(const std::string& path) {
if (auto it = cache.find(path); it != cache.end()) {
if (auto sp = it->second.lock()) {
return IconFlyweight(sp);
}
}
auto data = std::make_shared<IconData>(IconData{path});
cache[path] = data;
return IconFlyweight(data);
}
};
int main() {
IconFactory factory;
auto icon1 = factory.getIcon("icon.png");
auto icon2 = factory.getIcon("icon.png");
icon1.render(10,10);
icon2.render(20,20);
}
고찰:
- IconFactory가 std::unordered_map으로 아이콘 데이터 공유.
- std::weak_ptr로 캐시 관리, std::shared_ptr로 참조 카운팅.
- 단순히 메모리 공유를 통해 flyweight 구현.
모던 C++20 이상의 개선: Concepts, std::expected, std::string_view, Ranges
1. Concepts로 Flyweight Factory 요구사항 정의
Flyweight Factory는 "키(key)로부터 공유 가능한 객체를 반환"해야 한다는 공통 요구사항을 Concepts로 정의할 수 있습니다.
template<typename F, typename Key>
concept FlyweightFactoryConcept = requires(F& f, const Key& k) {
{ f.getFlyweight(k) } -> std::convertible_to<std::shared_ptr<IconData>>;
};
고찰:
- 상속 없이도 Flyweight Factory 조건을 명시, 컴파일 타임 제약 체크.
2. std::string_view로 키 관리 최적화
키가 문자열일 경우, std::string_view를 사용하면 키 조회 시 불필요한 복사 최소화.
#include <string_view>
struct ModernIconFactory {
std::unordered_map<std::string, std::weak_ptr<IconData>> cache;
std::shared_ptr<IconData> getFlyweight(std::string_view path) {
auto it = cache.find(std::string(path));
if(it != cache.end()) {
if(auto sp = it->second.lock()) {
return sp;
}
}
auto data = std::make_shared<IconData>(IconData{std::string(path)});
cache[std::string(path)] = data;
return data;
}
};
static_assert(FlyweightFactoryConcept<ModernIconFactory, std::string_view>);
고찰:
- std::string_view로 성능 미세 최적화, 필요 시 std::format으로 디버그 정보 출력 가능.
3. std::expected로 로딩 실패 처리
만약 이미지 파일 로딩 과정에서 실패할 수 있다면 std::expected로 처리할 수 있습니다.
struct SafeIconFactory {
std::unordered_map<std::string, std::weak_ptr<IconData>> cache;
std::expected<std::shared_ptr<IconData>,std::string> getFlyweight(std::string_view path) {
if (path.empty()) {
return std::unexpected("Empty path");
}
std::string key(path);
if (auto it = cache.find(key); it != cache.end()) {
if (auto sp = it->second.lock()) {
return sp;
}
}
// 가상의 로딩 로직 (실패 가능)
bool loadSuccess = true;
if(!loadSuccess) {
return std::unexpected(std::string("Failed to load: ") + key);
}
auto data = std::make_shared<IconData>(IconData{key});
cache[key] = data;
return data;
}
};
고찰:
- 로딩 실패 시 std::expected로 에러 반환, 클라이언트는 명시적으로 검사 가능.
4. Ranges와 파이프라인으로 Flyweight 조합
Flyweight 패턴은 종종 다수의 키에 대해 flyweight를 순차적으로 얻는 상황 발생. 이 때 Ranges를 통해 파이프라인화 가능.
#include <vector>
#include <ranges>
#include <algorithm>
SafeIconFactory safeFactory;
std::vector<std::string_view> icons = {"icon.png", "icon2.png", ""}; // 빈 string은 실패 예
auto results = icons | std::views::transform([&](auto sv) { return safeFactory.getFlyweight(sv); });
for (auto& res : results) {
if(!res) {
std::cerr << "Error: " << res.error() << "\n";
} else {
std::cout << "Loaded: " << (*res)->imagePath << "\n";
}
}
고찰:
- Ranges로 flyweight 획득 과정 파이프라인화.
- 에러 처리와 성공 처리 로직을 람다에서 직관적으로 표현 가능.
5. std::format을 통한 디버깅 개선
Flyweight 캐시 상태를 std::format으로 정리해 출력할 수 있습니다.
#include <format>
void printCacheState(const SafeIconFactory& f) {
std::cout << std::format("Cache size: {}\n", f.cache.size());
}
고찰:
- 유지보수성과 디버깅 편의성 향상.
비교 및 분석
- 전통적 구현(C++11 전후):
- 전역이나 static 맵으로 flyweight 관리, 단순히 메모리 공유에 초점.
- 에러 처리 시 단순 bool, nullptr 등 사용.
- 타입 안전성, 문서화, 동적 상황 처리 제한.
- 모던 C++(C++20 이상):
- Concepts로 Flyweight Factory 제약 명시.
- std::string_view로 성능 최적화, std::expected로 명확한 에러 처리.
- std::format으로 디버그 출력 정돈.
- Ranges를 통해 flyweight 획득 프로세스를 파이프라인화, 다양한 상황에 맞게 동적으로 처리 가능.
- 상속 사용 최소화, 템플릿 및 표준 라이브러리 기능 적극 활용해 코드 간결화와 유지보수성 향상.
결국 모던 C++에서는 플라이웨이트 패턴도 상속과 전역 변수에 의존하지 않고, Concepts와 표준 라이브러리 기능을 통해 더 타입 안전하고 유지보수하기 쉬운 구조로 재구성할 수 있습니다. 에러 처리나 동적 상황 처리 면에서도 유연성이 증가합니다.
마무리
플라이웨이트 패턴은 많은 수의 유사 객체를 효율적으로 관리하기 위한 패턴이며, 모던 C++에서는 단순히 공유 포인터와 맵을 넘어, Concepts, std::expected, Ranges, std::string_view 등의 기능으로 더 정교하고 타입 안전하며 유지보수성이 높은 구현이 가능합니다. 이렇게 하면 대규모 시스템에서 자원 공유와 메모리 최적화가 필요할 때 더 깨끗하고 직관적인 코드를 작성할 수 있습니다.
다음 글에서는 구조적 패턴 중 남은 패턴인 프록시(Proxy) 패턴을 다루거나, 행동 패턴(Behavioral Patterns)으로 넘어가며 Strategy, Observer, State 등의 패턴을 모던 C++로 재해석하는 과정을 살펴보겠습니다.