[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #26] 플라이웨이트(Flyweight) 패턴: 값 기반 공유와 Ranges로 효율적 메모리 관리하기

이전 글에서는 퍼사드(Facade) 패턴을 모던 C++ 관점에서 재해석하며, 상속 없이 람다와 함수 합성으로 복잡한 서브시스템을 단순한 인터페이스로 감싸고, std::expected, std::format, coroutine, Ranges 등을 활용해 비동기 처리나 조건부 처리, 로깅, 에러 처리 등 다양한 요구사항에 쉽게 대응할 수 있음을 확인했습니다. 이번에는 구조적 패턴 중 플라이웨이트(Flyweight) 패턴을 다룹니다.

플라이웨이트 패턴은 많은 수의 유사한 객체를 효율적으로 관리하기 위해 객체들이 공유할 수 있는 상태를 중앙에서 관리하는 패턴입니다. 예를 들어, 텍스트 처리기에서 각 문자를 개별 객체로 표현하면 메모리 낭비가 심하므로, 공유 가능한 폰트 정보나 형태 정보를 플라이웨이트 객체로 한 번만 유지하고, 문자들은 최소한의 상태만 갖습니다. 전통적 구현은 전역 풀이나 정적 맵, 상속 기반 구조로 공유 로직을 관리했지만, 이는 코드 복잡성과 유지보수성 문제를 야기합니다.

C++20 이상에서는 std::variant, std::visit, std::expected, Concepts, 람다, coroutine, Ranges, std::format 등을 활용해 상속 없이도 값 기반 공유를 구현하고, 조건부 처리나 비동기 로딩, 로깅, 에러 처리 등 다양한 요구사항에 유연하게 대응할 수 있습니다.

패턴 소개: 플라이웨이트(Flyweight)

의도:

  • 대량의 유사 객체가 공유 가능한 상태를 중앙에서 관리해 메모리 사용량 감소.
  • 예: 텍스트 문서 내 수많은 문자를 개별 객체로 표현하기보다 폰트, 스타일 정보를 공유하는 플라이웨이트로 관리.

전통적 구현 문제점:

  • 전역 풀이나 정적 맵, 상속 기반 구조로 공유 로직 구현.
  • 새로운 공유 상태나 조건부 공유 로직 추가 시 코드 복잡 증가.
  • 비동기 로딩이나 에러 처리, 로깅 시 대응 어려움.

기존 C++ 스타일 구현 (전통적 방식)

예를 들어, 아이콘(Icon) 데이터를 공유하는 플라이웨이트 구현:

#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로 전역 맵 관리, 상속은 없지만 여전히 정적 접근
  • 에러 처리나 조건부 처리, 로깅, 비동기 로딩 등 구현 시 코드 복잡 증가

모던 C++20 이상 개선: std::expected, Concepts, 람다

1. Concepts로 Flyweight Factory 요구사항 정의

Flyweight Factory: (const std::string&) -> std::expected<std::shared_ptr<IconData>,std::string> 형태로 아이콘 반환:

template<typename F>
concept FlyweightFactoryConcept = requires(F& f, const std::string& p) {
    { f(p) } -> std::convertible_to<std::expected<std::shared_ptr<IconData>,std::string>>;
};

2. 람다로 공유 로직 구현

#include <map>
auto iconFactory = [cache=std::map<std::string,std::weak_ptr<IconData>>() ](const std::string& path) mutable
    -> std::expected<std::shared_ptr<IconData>,std::string> {

    if(path.empty()) return std::unexpected("Empty path");
    if(auto it=cache.find(path); it!=cache.end()) {
        if(auto sp=it->second.lock()) return sp;
    }

    auto data = std::make_shared<IconData>(IconData{path});
    cache[path]=data;
    return data;
};

static_assert(FlyweightFactoryConcept<decltype(iconFactory)>);

비교:

  • 전통적: IconFactory 클래스 필요
  • C++20: 람다로 공유 로직 구현, 상속 없음, std::expected로 에러 처리

3. Icon rendering 함수

auto renderIcon = [](std::shared_ptr<IconData> data, int x,int y)->std::expected<void,std::string> {
    std::cout << "Rendering " << data->imagePath << " at ("<<x<<","<<y<<")\n";
    return {};
};

// 사용 예:
auto iconRes = iconFactory("icon.png");
if(!iconRes) std::cerr << "Error: " << iconRes.error() << "\n";
else renderIcon(*iconRes,10,10);

비교:

  • 전통적: IconFlyweight 클래스 필요
  • C++20: std::shared_ptr<IconData> 바로 사용하거나 람다로 감싸고 필요 시 장식 가능

조건부 공유, 로깅, 비동기 처리

조건부 공유(예: 특정 경로 필터링):

auto conditionalFactory = [&](auto baseFactory) {
    return [=](const std::string& path)->std::expected<std::shared_ptr<IconData>,std::string> {
        if(!path.ends_with(".png")) return std::unexpected("Only .png supported");
        return baseFactory(path);
    };
};

auto pngFactory = conditionalFactory(iconFactory);

로깅 추가(std::format):

auto loggingFactory = [&](auto baseFactory) {
    return [=](const std::string& path)->std::expected<std::shared_ptr<IconData>,std::string> {
        std::cout << std::format("[LOG] Fetching icon: {}\n", path);
        auto res = baseFactory(path);
        if(!res) std::cout << std::format("[LOG] Error: {}\n", res.error());
        else std::cout << "[LOG] Success.\n";
        return res;
    };
};

auto loggedPngFactory = loggingFactory(pngFactory);

비동기 처리: coroutine으로 Factory를 비동기 I/O 기반 로딩 구현 가능:

// co_iconFactory(...) 비동기 로딩, co_await 네트워크나 디스크 I/O

Ranges로 다수 요청 파이프라인 처리

여러 아이콘 요청을 Ranges로 순회:

#include <vector>
#include <ranges>

std::vector<std::string> icons = {"icon.png","image.png",""};
for (auto res : icons | std::views::transform(loggedPngFactory)) {
    if(!res) std::cerr << "Error: " << res.error() << "\n";
    else renderIcon(*res, 0,0);
}

비교:

  • 전통적: 조건부, 비동기, 로깅 등 기능 추가 시 코드 복잡 증가
  • C++20: 람다 합성으로 쉽게 기능 추가, coroutine으로 비동기, Ranges로 파이프라인 처리, std::expected로 에러 처리

전통적 구현 vs C++11/14/17 vs C++20 이상 비교

전통적(C++98/03):

  • Flyweight Factory 클래스 필요, 전역 맵 관리
  • 새로운 조건부 로직, 비동기 로딩, 로깅 등 추가 시 코드 수정 및 클래스 증가
  • 에러 처리나 파이프라인 처리 어려움

C++11/14/17:

  • 람다, std::function으로 일부 개선 가능
  • Concepts, coroutine, std::expected, Ranges 미지원 → 타입 안정한 인터페이스 제약, 비동기/에러 처리 선언적 표현 한계

C++20 이상(모던 C++):

  • Concepts로 Flyweight Factory 요구사항 정의, 상속 없이 타입 안전 인터페이스
  • 람다 합성과 std::expected로 에러 처리 명확화
  • coroutine으로 비동기 로딩, std::format으로 로깅, Ranges로 다수 아이콘 요청 파이프라인 처리
  • 클래스 증가 없이 확장 가능, 유지보수성과 확장성, 타입 안전성 대폭 향상

결론

플라이웨이트(Flyweight) 패턴은 대량의 유사 객체를 효율적으로 관리하기 위한 패턴이지만, 전통적 구현은 전역 풀이나 정적 맵, 상속 기반 구조로 인해 유지보수성과 확장성이 제한되었습니다. C++20 이상에서는 Concepts, 람다, std::expected, coroutine, Ranges, std::format 등을 활용해 상속 없이도 값 기반 공유 로직을 깨끗하고 타입 안전하게 구현할 수 있으며, 조건부 처리, 비동기 로딩, 로깅, 에러 처리 등 고급 요구사항에도 쉽게 대응 가능합니다. 전통적 구현 대비 유지보수성과 확장성이 크게 개선됩니다.

반응형