C++ Flyweight 패턴: 공통 상태를 공유해 텍스트·타일·파티클 메모리 줄이기
이 글의 핵심
변하지 않는 공통 상태(intrinsic)는 하나만 두고 공유하며, 위치처럼 인스턴스마다 다른 상태(extrinsic)만 따로 두는 패턴입니다. 글리프·타일·파티클 예제와 함께, 공유 객체를 수정해 모든 사용처가 바뀌는 버그, 캐시 키 설계로 메모리가 계속 느는 문제, 스레드 안전한 팩토리를 다룹니다.
반복되는 상태를 공유해야 하는 이유
공유·캐시와 맞닿은 구조 패턴은 구조 패턴 시리즈에서 Composite·Proxy와 함께 정리합니다.
나무 10,000그루에 텍스처를 복사할 때
문제: 10,000개의 나무를 그릴 때 각 나무마다 텍스처를 복사하면 메모리가 폭발합니다.
// 나쁜 설계: 메모리 낭비
class Tree {
Texture texture_; // 10MB
int x_, y_; // 위치만 다름
};
std::vector<Tree> forest(10000); // 10MB × 10000 = 100GB!
해결: Flyweight 패턴은 공통 상태(텍스처)를 공유하고 개별 상태(위치)만 따로 둡니다.
// 좋은 설계: Flyweight
// 타입 정의
class TreeType { // 공유되는 intrinsic 상태
Texture texture_; // 10MB (1개만)
};
class Tree { // extrinsic 상태
TreeType* type_; // 포인터만 (8 bytes)
int x_, y_; // 위치
};
std::vector<Tree> forest(10000); // 10MB + (16 bytes × 10000) = 10.16MB
flowchart LR
client[Client]
factory[FlyweightFactory]
flyweight["Flyweight<br/>(TreeType)"]
context["Context<br/>(Tree)"]
client --> factory
factory --> flyweight
context --> flyweight
client --> context
이 그림에서 눈여겨볼 부분은 화살표의 방향입니다. Client는 Context(개별 나무)를 직접 만들지만, 실제 무거운 데이터를 쥐고 있는 Flyweight(나무 타입)는 항상 FlyweightFactory를 거쳐서만 얻습니다. 클라이언트가 new TreeType(...)을 직접 호출할 수 있게 열어두면 캐시가 있으나 마나 한 존재가 되어버리기 때문에, Flyweight 패턴을 제대로 구현할 때는 TreeType의 생성자를 private으로 감추거나 최소한 “팩토리를 거치지 않은 생성”을 코드 리뷰에서 걸러내는 규칙이 필요합니다.
intrinsic과 extrinsic을 가르는 기준
이 패턴에서 실제로 버그가 나는 지점은 대개 “이 데이터가 intrinsic이냐 extrinsic이냐”를 잘못 판단했을 때입니다. 판단 기준은 단순합니다.
- intrinsic(내부 상태): 같은 종류의 객체라면 항상 동일한 값. 생성 이후 절대 바뀌지 않아야 합니다. 텍스처, 폰트 데이터, 타일 텍스처 경로가 여기에 속합니다.
- extrinsic(외부 상태): 호출자마다, 또는 호출 시점마다 달라지는 값. 위치, 색상, 회전 각도, 현재 프레임 번호처럼 “이 인스턴스가 지금 어디에서 어떻게 쓰이는가”를 나타냅니다.
문제는 이 둘의 경계가 애매한 필드가 항상 존재한다는 점입니다. 예를 들어 “기본 색상”은 intrinsic처럼 보이지만, 나중에 “이 타일만 강조 표시”라는 요구사항이 추가되면 갑자기 extrinsic이 되어야 합니다. Flyweight로 설계를 굳히기 전에 “이 필드가 요구사항 변경으로 인스턴스별로 달라질 가능성이 있는가”를 반드시 따져봐야 하는 이유입니다. 가변 상태를 intrinsic 쪽에 잘못 끼워 넣으면, 캐시에서 같은 Flyweight를 공유하는 모든 사용처가 서로 눈에 보이지 않는 방식으로 간섭하게 됩니다. A를 그리려고 색을 바꿨는데 B, C, D의 색까지 같이 바뀌는 식입니다 — 이건 컴파일 에러도, 런타임 크래시도 아니라서 발견이 특히 늦어집니다.
실전 경험: 예전에 2D 타일 기반 미니맵을 구현하면서 TileType에 기본 텍스처와 함께 “현재 하이라이트 색상” 필드를 같이 넣은 적이 있습니다. 처음엔 문제가 없었습니다. 모든 잔디 타일이 같은 초록색이었으니까요. 그런데 “선택한 타일만 노란 테두리로 강조”라는 기능을 추가하면서, 저는 습관적으로 tileType->setHighlight(yellow)를 호출했습니다. 결과는 참혹했습니다. 화면에 있는 잔디 타일 수십 개가 전부 동시에 노랗게 변했습니다. 디버거로 몇 시간을 뒤진 끝에야 원인을 찾았는데, TileType은 TileFactory의 캐시에 단 하나만 존재하는 공유 객체였고, 저는 그 공유 객체의 상태를 마치 개별 인스턴스인 것처럼 수정하고 있었던 겁니다. 하이라이트 색상을 Tile(extrinsic 쪽)로 옮기고 나서야 문제가 사라졌습니다. 이 경험 이후로는 Flyweight 후보 클래스에 set으로 시작하는 non-const 메서드가 하나라도 있으면 일단 의심하는 습관이 생겼습니다.
Flyweight와 FlyweightFactory 기본 구조
#include <unordered_map>
#include <string>
#include <memory>
#include <iostream>
// 공유되는 내부 상태 (intrinsic)
struct Glyph {
char ch;
int width, height;
std::string bitmap; // 실제로는 큰 데이터
Glyph(char c, int w, int h) : ch(c), width(w), height(h) {
bitmap = std::string(w * h, '#'); // 가상 비트맵
std::cout << "Creating Glyph '" << ch << "'\n";
}
};
class GlyphFactory {
std::unordered_map<char, std::shared_ptr<Glyph>> cache_;
public:
std::shared_ptr<Glyph> get(char c) {
auto it = cache_.find(c);
if (it != cache_.end()) {
std::cout << "Reusing Glyph '" << c << "'\n";
return it->second;
}
auto g = std::make_shared<Glyph>(c, 8, 16);
cache_[c] = g;
return g;
}
size_t getCacheSize() const { return cache_.size(); }
};
// 외부 상태(extrinsic): 위치 등 — 호출 시 전달
void draw(std::shared_ptr<Glyph> g, int x, int y) {
std::cout << "Draw '" << g->ch << "' at (" << x << "," << y << ")\n";
}
int main() {
GlyphFactory factory;
std::string text = "HELLO WORLD";
int x = 0;
for (char c : text) {
if (c == ' ') { x += 8; continue; }
auto glyph = factory.get(c);
draw(glyph, x, 0);
x += 8;
}
std::cout << "\nTotal unique glyphs: " << factory.getCacheSize() << '\n';
// 출력:
// Creating Glyph 'H'
// Draw 'H' at (0,0)
// Creating Glyph 'E'
// Draw 'E' at (8,0)
// Reusing Glyph 'L'
// Draw 'L' at (16,0)
// Reusing Glyph 'L'
// Draw 'L' at (24,0)
// ...
// Total unique glyphs: 7
return 0;
}
폰트 글리프를 공유하는 텍스트 렌더링
#include <unordered_map>
#include <memory>
#include <iostream>
#include <string>
// Flyweight: 공유되는 폰트 데이터
class Font {
std::string name_;
int size_;
std::string fontData_; // 실제로는 수 MB
public:
Font(std::string name, int size)
: name_(std::move(name)), size_(size) {
fontData_ = std::string(1000000, 'F'); // 1MB 가상 데이터
std::cout << "Loading font: " << name_ << " " << size_ << "pt\n";
}
void render(char c, int x, int y) const {
std::cout << "Render '" << c << "' with " << name_
<< " at (" << x << "," << y << ")\n";
}
};
class FontFactory {
std::unordered_map<std::string, std::shared_ptr<Font>> fonts_;
std::string makeKey(const std::string& name, int size) {
return name + "_" + std::to_string(size);
}
public:
std::shared_ptr<Font> getFont(const std::string& name, int size) {
std::string key = makeKey(name, size);
auto it = fonts_.find(key);
if (it != fonts_.end()) return it->second;
auto font = std::make_shared<Font>(name, size);
fonts_[key] = font;
return font;
}
};
// Context: extrinsic 상태를 가진 문자
class Character {
char ch_;
int x_, y_;
std::shared_ptr<Font> font_; // Flyweight 참조
public:
Character(char ch, int x, int y, std::shared_ptr<Font> font)
: ch_(ch), x_(x), y_(y), font_(std::move(font)) {}
void draw() const {
font_->render(ch_, x_, y_);
}
};
int main() {
FontFactory factory;
auto arial12 = factory.getFont("Arial", 12);
auto arial12_2 = factory.getFont("Arial", 12); // 재사용
auto times14 = factory.getFont("Times", 14);
std::vector<Character> text;
text.emplace_back('H', 0, 0, arial12);
text.emplace_back('i', 10, 0, arial12_2); // 같은 폰트
text.emplace_back('!', 20, 0, times14);
for (const auto& ch : text)
ch.draw();
// 출력:
// Loading font: Arial 12pt
// Loading font: Times 14pt
// Render 'H' with Arial at (0,0)
// Render 'i' with Arial at (10,0)
// Render '!' with Times at (20,0)
return 0;
}
핵심: 같은 폰트는 한 번만 로드되고, 각 문자는 위치만 다르게 가집니다.
게임 타일맵을 Flyweight로 표현하기
#include <unordered_map>
#include <memory>
#include <iostream>
#include <vector>
// Flyweight: 공유되는 타일 타입
class TileType {
std::string name_;
std::string texture_; // 실제로는 큰 텍스처
bool walkable_;
public:
TileType(std::string name, std::string texture, bool walkable)
: name_(std::move(name)), texture_(std::move(texture)), walkable_(walkable) {
std::cout << "Loading tile type: " << name_ << '\n';
}
void render(int x, int y) const {
std::cout << "[" << name_[0] << "]";
}
bool isWalkable() const { return walkable_; }
};
class TileFactory {
std::unordered_map<std::string, std::shared_ptr<TileType>> types_;
public:
std::shared_ptr<TileType> getTileType(const std::string& name) {
auto it = types_.find(name);
if (it != types_.end()) return it->second;
// 타일 타입별 속성 정의
bool walkable = (name != "wall");
auto type = std::make_shared<TileType>(name, name + ".png", walkable);
types_[name] = type;
return type;
}
};
// Context: extrinsic 상태를 가진 타일
class Tile {
int x_, y_;
std::shared_ptr<TileType> type_; // Flyweight 참조
public:
Tile(int x, int y, std::shared_ptr<TileType> type)
: x_(x), y_(y), type_(std::move(type)) {}
void render() const {
type_->render(x_, y_);
}
bool isWalkable() const {
return type_->isWalkable();
}
};
class TileMap {
std::vector<std::vector<Tile>> tiles_;
TileFactory factory_;
public:
TileMap(int width, int height) {
// 간단한 맵 생성
for (int y = 0; y < height; ++y) {
std::vector<Tile> row;
for (int x = 0; x < width; ++x) {
std::string type = (x == 0 || x == width-1 || y == 0 || y == height-1)
? "wall" : "grass";
row.emplace_back(x, y, factory_.getTileType(type));
}
tiles_.push_back(std::move(row));
}
}
void render() const {
for (const auto& row : tiles_) {
for (const auto& tile : row)
tile.render();
std::cout << '\n';
}
}
};
int main() {
TileMap map(10, 5);
map.render();
// 출력:
// Loading tile type: wall
// Loading tile type: grass
// [W][W][W][W][W][W][W][W][W][W]
// [W][G][G][G][G][G][G][G][G][W]
// [W][G][G][G][G][G][G][G][G][W]
// [W][G][G][G][G][G][G][G][G][W]
// [W][W][W][W][W][W][W][W][W][W]
return 0;
}
핵심: 50개 타일이 있어도 타일 타입은 2개(wall, grass)만 로드됩니다.
공유 객체 수정·extrinsic 과다·캐시 누수
공유된 Flyweight를 수정함
// ❌ 나쁜 예: 공유 객체 수정
auto glyph = factory.get('A');
glyph->width = 20; // 모든 'A'가 영향받음!
해결: Flyweight는 불변(immutable)으로 만드세요.
// ✅ 좋은 예: const 메서드만
class Glyph {
const int width_, height_;
public:
int getWidth() const { return width_; } // const만
};
extrinsic 상태가 너무 많아 절약이 안 됨
// ❌ 나쁜 예: extrinsic이 너무 많음
void draw(Glyph* g, int x, int y, int r, int g, int b, float rotation, float scale) {
// 매번 8개 인자 전달
}
해결: extrinsic 상태를 구조체로 묶으세요.
// ✅ 좋은 예
struct RenderContext {
int x, y;
Color color;
float rotation, scale;
};
void draw(Glyph* g, const RenderContext& ctx);
캐시에 남은 Flyweight 누수
// ❌ 나쁜 예: Factory가 계속 커짐
class Factory {
std::unordered_map<std::string, Flyweight*> cache_; // 영원히 유지
};
해결: LRU 캐시나 weak_ptr을 사용하세요.
// ✅ 좋은 예: weak_ptr로 자동 정리
class Factory {
std::unordered_map<std::string, std::weak_ptr<Flyweight>> cache_;
public:
std::shared_ptr<Flyweight> get(const std::string& key) {
auto it = cache_.find(key);
if (it != cache_.end()) {
if (auto sp = it->second.lock())
return sp;
}
auto fw = std::make_shared<Flyweight>(key);
cache_[key] = fw;
return fw;
}
};
weak_ptr 캐시에도 빈틈이 있습니다. 객체는 마지막 사용자가 놓을 때 해제되지만, 맵의 항목(키 문자열과 만료된 weak_ptr)은 그대로 남아 있어서 키의 종류가 계속 늘어나는 입력에서는 여전히 맵이 커집니다. 주기적으로 expired()인 항목을 지우거나, shared_ptr의 커스텀 삭제자에서 자기 키를 맵에서 지우도록 만드는 방법이 쓰입니다(이때는 팩토리가 객체보다 먼저 소멸하지 않도록 수명을 보장해야 합니다). 또 인기 있는 키가 “모든 사용자가 잠깐 놓았다가 다시 요청”되는 패턴이면, 그 사이에 객체가 해제되었다가 다시 로드되기를 반복해 오히려 비용이 커집니다. 이런 경우에는 최근 사용한 N개를 강한 참조로 붙잡아 두는 LRU를 함께 두는 편이 낫습니다.
Object Pool·Composite와 조합하기
Object Pool과 조합
class FlyweightPool {
std::vector<std::unique_ptr<Flyweight>> pool_;
std::unordered_map<std::string, Flyweight*> index_;
public:
Flyweight* get(const std::string& key) {
auto it = index_.find(key);
if (it != index_.end()) return it->second;
pool_.push_back(std::make_unique<Flyweight>(key));
Flyweight* ptr = pool_.back().get();
index_[key] = ptr;
return ptr;
}
};
Composite과 조합
class CompositeFlyweight : public Flyweight {
std::vector<Flyweight*> children_;
public:
void add(Flyweight* fw) { children_.push_back(fw); }
void render(int x, int y) override {
for (auto* child : children_)
child->render(x, y);
}
};
캐시 수명, 동시성, Object Pool과의 차이
캐시는 왜 저절로 비워지지 않는가
shared_ptr 기반 캐시를 쓰면 “안 쓰는 항목은 알아서 정리되겠지”라고 생각하기 쉽지만, 실제로는 정반대입니다. FlyweightFactory가 cache_[key] = fw; 형태로 shared_ptr을 직접 들고 있으면, 팩토리 자체가 살아있는 한 그 항목의 참조 카운트는 절대 0이 되지 않습니다. 즉 프로그램이 종료되거나 팩토리 객체가 파괴될 때까지, 한 번이라도 생성된 Flyweight는 계속 메모리에 남습니다. 이건 버그가 아니라 설계상 당연한 결과입니다 — Flyweight의 존재 이유 자체가 “여러 곳에서 계속 참조할 것”이기 때문에, 캐시가 소유권을 놓아버리면 다른 곳에서 참조하던 포인터가 댕글링될 위험이 생깁니다.
문제는 고유한 키 조합의 수가 계속 늘어나는 입력 패턴일 때입니다. 폰트처럼 조합이 유한한 경우(폰트 이름 × 크기)는 문제가 안 되지만, 만약 캐시 키에 사용자 입력이나 타임스탬프처럼 사실상 무한히 다양한 값이 섞여 들어가면, 캐시는 메모리를 절약하기 위해 도입한 패턴인데도 시간이 지날수록 계속 커지기만 하는 역설적인 상황이 됩니다.
실전 경험: 로그 뷰어 툴에서 반복되는 문자열 라벨(로그 레벨, 모듈 이름 등)을 Flyweight로 캐싱해 문자열 복사 비용을 줄인 적이 있습니다. 그런데 캐시 키를 만들 때 실수로 “모듈 이름 + 요청 ID”를 합쳐서 키로 썼습니다. 요청 ID는 매 요청마다 달라지는 값이었으니, 사실상 캐시 히트율이 0%에 가까웠고 고유 키가 끝없이 쌓였습니다. 몇 시간 동안 로그를 스트리밍하고 나니 메모리 사용량이 꾸준히 우상향하는 그래프를 프로파일러(Valgrind massif)에서 보게 됐습니다. 처음엔 “메모리 누수”라고 생각하고 스마트 포인터 순환 참조부터 의심했는데, 실제로는 순환 참조가 아니라 캐시 키 설계 실수로 인해 “논리적으로는 다 살아있어야 하는” 객체가 계속 쌓이는 것이었습니다. 캐시 키에서 요청 ID를 빼고 모듈 이름만 남기자 캐시 크기가 수십 개 수준에서 안정됐습니다. 이 경험 이후로 캐시 키를 설계할 때는 “이 키의 가능한 값의 개수(cardinality)가 유한하고 작은가”를 항상 먼저 확인합니다. 정말 캐시가 무한정 커질 수밖에 없는 상황이라면 LRU 캐시나 weak_ptr 기반 정리(위 코드 참고)가 필수입니다.
스레드 환경에서의 동시 접근
FlyweightFactory::get()은 겉보기엔 단순한 “찾고, 없으면 만들고, 저장한다”는 로직이지만, 이 세 단계가 원자적이지 않으면 멀티스레드 환경에서 두 가지 문제가 동시에 터집니다.
- 동시 캐시 미스로 인한 중복 생성: 스레드 A와 B가 동시에 같은 키로
get()을 호출하면, 둘 다 캐시에 없다고 판단해 각자 새 Flyweight를 만들고cache_[key] = ...로 덮어씁니다. Flyweight가 원래 텍스처처럼 무거운 리소스를 로드하는 경우, 이 리소스를 두 번 로드하는 낭비가 생기고, 어느 한쪽 결과가 그냥 버려집니다. unordered_map의 데이터 레이스:std::unordered_map은 스레드 안전을 보장하지 않습니다. 한 스레드가 삽입 중(리해싱이 일어날 수도 있음)일 때 다른 스레드가 조회하면 정의되지 않은 동작(UB)입니다. 이건 “가끔 죽는다”가 아니라 “가끔 조용히 잘못된 값을 반환하거나, 가끔 크래시한다”는 뜻이라 재현이 매우 어렵습니다.
해결책은 상황에 따라 다릅니다. 읽기가 압도적으로 많고 새 키가 드물게 추가된다면 std::shared_mutex로 조회는 shared lock, 삽입은 unique lock을 쓰는 방식이 합리적입니다. 반대로 삽입이 빈번하다면 이중 검사 잠금(double-checked locking)보다는 그냥 std::mutex로 get() 전체를 감싸는 편이 코드도 단순하고 버그도 적습니다. “락 없는 자료구조로 최적화하고 싶다”는 유혹이 들 수 있지만, Flyweight 팩토리는 보통 호출 빈도가 그렇게 높지 않은 초기화·로딩 경로에 있는 경우가 많으므로, 먼저 프로파일링으로 실제 병목인지 확인한 뒤에 락 최적화에 시간을 쓰는 것을 권합니다.
Flyweight와 Object Pool, 뭐가 다른가
두 패턴 모두 “객체를 새로 만들지 않고 재사용한다”는 점에서 겉모습이 비슷해 자주 혼동되지만, 목적과 구현의 핵심이 다릅니다.
| 구분 | Flyweight | Object Pool |
|---|---|---|
| 핵심 목적 | 같은 값이면 공유해서 메모리 절감 | 생성 비용이 비싼 객체를 재사용해서 시간 절감 |
| 캐시 키 | 값(내용) 기반 키가 필수 — 키가 같으면 같은 인스턴스를 반환 | 키가 없는 경우가 많음 — “쓰고 반납된 아무 객체”를 재사용 |
| 반환된 인스턴스 | 여러 호출자가 동시에 공유함(불변이어야 안전) | 한 번에 한 사용자만 독점하다가 반납(재사용 전 초기화 필요) |
| 상태 | intrinsic은 불변, extrinsic은 호출마다 전달 | 재사용 시 이전 상태를 리셋해야 함(그렇지 않으면 오염) |
이 차이를 명확히 하는 가장 쉬운 방법은 “이 팩토리가 캐시 키로 무엇을 쓰는가”를 보는 것입니다. Flyweight 팩토리는 값(폰트 이름+크기, 타일 이름 등)을 키로 써서 “이미 이 값에 해당하는 인스턴스가 있으면 그걸 돌려준다”는 조회 로직이 핵심입니다. Object Pool은 대개 키가 없고, “지금 놀고 있는 객체가 있으면 그걸 주고, 없으면 새로 만든다”는 로직이라 반환된 객체를 받는 쪽이 그 객체를 배타적으로 쓰다가 명시적으로 반납합니다. 위의 “Object Pool과 조합” 예제처럼 두 패턴을 함께 쓰는 것도 가능합니다 — Flyweight로 값별 공유를 하면서, 동시에 그 Flyweight 인스턴스 자체의 생성·파괴 비용이 크다면 풀링까지 얹는 식입니다. 다만 두 개념을 한 클래스 안에서 섞어서 구현하면 “이 인스턴스는 지금 공유 중인가, 아니면 누군가 독점하고 있는가”를 코드만 보고 판단하기 어려워지므로, 실제로 필요할 때만 신중하게 조합하는 것을 권합니다.
파티클 시스템 만들기
#include <memory>
#include <vector>
#include <iostream>
#include <string>
#include <unordered_map>
// Flyweight: 공유되는 파티클 타입
class ParticleType {
std::string texture_;
float mass_;
float friction_;
public:
ParticleType(std::string texture, float mass, float friction)
: texture_(std::move(texture)), mass_(mass), friction_(friction) {
std::cout << "Loading particle type: " << texture_ << '\n';
}
void render(float x, float y, float vx, float vy) const {
std::cout << texture_[0] << "(" << x << "," << y << ") ";
}
float getMass() const { return mass_; }
float getFriction() const { return friction_; }
};
class ParticleFactory {
std::unordered_map<std::string, std::shared_ptr<ParticleType>> types_;
public:
std::shared_ptr<ParticleType> getType(const std::string& name) {
auto it = types_.find(name);
if (it != types_.end()) return it->second;
// 파티클 타입별 속성
float mass = (name == "smoke") ? 0.1f : 1.0f;
float friction = (name == "fire") ? 0.05f : 0.1f;
auto type = std::make_shared<ParticleType>(name, mass, friction);
types_[name] = type;
return type;
}
};
// Context: extrinsic 상태를 가진 파티클
class Particle {
float x_, y_;
float vx_, vy_;
std::shared_ptr<ParticleType> type_;
public:
Particle(float x, float y, float vx, float vy, std::shared_ptr<ParticleType> type)
: x_(x), y_(y), vx_(vx), vy_(vy), type_(std::move(type)) {}
void update(float dt) {
x_ += vx_ * dt;
y_ += vy_ * dt;
vx_ *= (1.0f - type_->getFriction());
vy_ *= (1.0f - type_->getFriction());
}
void render() const {
type_->render(x_, y_, vx_, vy_);
}
};
class ParticleSystem {
std::vector<Particle> particles_;
ParticleFactory factory_;
public:
void emit(const std::string& type, float x, float y, float vx, float vy) {
particles_.emplace_back(x, y, vx, vy, factory_.getType(type));
}
void update(float dt) {
for (auto& p : particles_)
p.update(dt);
}
void render() const {
for (const auto& p : particles_)
p.render();
std::cout << '\n';
}
};
int main() {
ParticleSystem system;
// 1000개 파티클 생성 (타입은 3개만)
for (int i = 0; i < 300; ++i)
system.emit("fire", i * 0.1f, 0, 1, 2);
for (int i = 0; i < 400; ++i)
system.emit("smoke", i * 0.1f, 10, 0.5f, 1);
for (int i = 0; i < 300; ++i)
system.emit("spark", i * 0.1f, 20, 2, 3);
std::cout << "\n=== Simulating ===\n";
system.update(0.016f);
system.render();
// 출력:
// Loading particle type: fire
// Loading particle type: smoke
// Loading particle type: spark
//
// === Simulating ===
// F(0.016,0.032) F(0.116,0.032) ... (1000개 파티클, 타입은 3개만 로드)
return 0;
}
이 예제는 구조를 보여 주기 위해 파티클마다 std::shared_ptr<ParticleType>를 들고 있는데, 파티클처럼 수만~수십만 개가 매 프레임 생성·소멸하는 곳에서는 이 선택이 오히려 병목이 됩니다. shared_ptr 하나가 포인터 두 개(16바이트)를 차지해 Particle의 크기가 커지고, 복사·소멸할 때마다 제어 블록의 참조 카운트를 원자적으로 증감하므로 vector 재할당이나 파티클 제거가 느려지며, 여러 스레드가 같은 타입을 동시에 참조하면 그 카운터 하나에 캐시 라인 경합까지 생깁니다. 파티클 타입은 시스템이 살아 있는 동안 절대 사라지지 않으므로, 실무에서는 타입을 std::vector<ParticleType>에 모아 두고 파티클에는 uint8_t 같은 작은 인덱스만 저장하는 방식이 흔합니다. 이러면 Particle이 작아져 한 캐시 라인에 더 많이 들어가고, 타입별로 파티클을 정렬해 두면 같은 텍스처를 쓰는 파티클을 한 번에 그릴 수 있어 렌더링 배치에도 유리합니다. 공유 객체의 수명이 명확히 “시스템 전체”라면 참조 카운팅은 필요 없다는 것이 이 선택의 핵심입니다.
Flyweight 패턴 요약
| 항목 | 설명 |
|---|---|
| 목적 | 공통 상태 공유로 객체 수가 많을 때 메모리 절감 |
| 장점 | 메모리 사용 대폭 감소, 캐시 친화적, 객체 생성 비용 절감 |
| 단점 | extrinsic 전달 오버헤드, 코드 복잡도 증가, 스레드 안전성 고려 필요 |
| 사용 시기 | 텍스트 렌더링, 게임 타일, 파티클, 아이콘 등 반복 객체 다수 |
Flyweight 패턴으로 글리프·타일·파티클처럼 반복되는 데이터를 공유해 메모리를 수십~수백 배 줄일 수 있지만, intrinsic/extrinsic 구분을 잘못하면 공유 객체 간섭 버그가, 캐시 키 설계를 잘못하면 오히려 메모리가 계속 늘어나는 역설이 생길 수 있습니다.