문서 편집기의 글자 하나하나를 객체로 만들면 편하지만, 글자마다 글꼴 이름·크기·색을 들고 있으면 10만 글자짜리 문서가 수십 MB를 차지한다. 그런데 서식은 대부분 몇 가지뿐이고, 글자마다 다른 것은 위치 정도다. 플라이웨이트(Flyweight)는 객체의 상태를 공유 가능한 내재 상태(intrinsic)와 문맥마다 다른 외재 상태(extrinsic)로 나누고, 내재 상태를 가진 객체를 풀에서 공유해 대량의 작은 객체를 효율적으로 지원한다. 이 글에서는 서식 공유 전후의 실제 힙 사용량, weak_ptr로 자동 해제되는 풀, 문자열 인터닝, C의 인턴 테이블을 다룬다.
이 시리즈의 다른 글
- C/C++ 디자인 패턴 (9) — 데코레이터(Decorator)
- C/C++ 디자인 패턴 (10) — 퍼사드(Facade)
- C/C++ 디자인 패턴 (12) — 프록시(Proxy)
- C/C++ 디자인 패턴 (13) — 책임 연쇄(Chain of Responsibility)
구조
| 참여자 | 역할 | 예제 |
|---|---|---|
| Flyweight | 공유되는 내재 상태를 가진 불변 객체 | Style, Texture |
| FlyweightFactory | 플라이웨이트를 만들고 풀에서 재사용 | StyleFactory, TextureCache |
| Client | 외재 상태를 보관하고 플라이웨이트를 참조 | Glyph(위치 + 서식 포인터) |
공유하지 않을 때와 공유할 때
측정에는 glibc의 mallinfo2()로 얻은 현재 사용 중인 힙 바이트를 쓴다.
// heap.hpp — glibc mallinfo2()로 현재 사용 중인 힙 바이트를 잰다
#pragma once
#include <malloc.h>
// uordblks: brk 영역에서 사용 중, hblkhd: mmap으로 받은 큰 블록
inline std::size_t heap_in_use()
{
struct mallinfo2 mi = mallinfo2();
return mi.uordblks + mi.hblkhd;
}// naive.cpp — 글자마다 서식 전체를 들고 있는 문서
#include <iostream>
#include <string>
#include <vector>
#include "heap.hpp"
struct Glyph {
char ch;
int x, y; // 위치: 글자마다 다르다
std::string font; // 서식: 대부분 같다
int size;
std::string color;
};
int main()
{
const std::size_t base = heap_in_use();
const int N = 100000;
const char *fonts[] = {"Noto Sans KR Regular", "Noto Sans KR Bold", "D2Coding Ligature"};
std::vector<Glyph> doc;
doc.reserve(N);
for (int i = 0; i < N; ++i)
doc.push_back({char('a' + i % 26), i % 80, i / 80, fonts[i % 3], 11, "#1f2328"});
std::cout << "glyphs=" << doc.size() << " sizeof(Glyph)=" << sizeof(Glyph)
<< " heap in use=" << (heap_in_use() - base) / 1024 << " KiB\n";
}$ g++ -std=c++20 -Wall -Wextra -O2 -o naive naive.cpp && ./naive
glyphs=100000 sizeof(Glyph)=88 heap in use=11725 KiB
// flyweight.cpp — 공유 가능한 서식(내재 상태)은 풀에서 하나씩만, 위치(외재 상태)만 글자마다
#include <iostream>
#include <memory>
#include <string>
#include <tuple>
#include <unordered_map>
#include <vector>
#include "heap.hpp"
struct Style { // Flyweight: 불변, 공유됨
std::string font;
int size;
std::string color;
};
class StyleFactory { // FlyweightFactory
public:
const Style *get(const std::string &font, int size, const std::string &color)
{
std::string key = font + '|' + std::to_string(size) + '|' + color;
auto [it, inserted] = pool_.try_emplace(key, nullptr);
if (inserted)
it->second = std::make_unique<const Style>(Style{font, size, color});
return it->second.get();
}
std::size_t count() const { return pool_.size(); }
private:
std::unordered_map<std::string, std::unique_ptr<const Style>> pool_;
};
struct Glyph {
char ch;
int x, y;
const Style *style; // 공유 서식을 가리키기만 한다
};
int main()
{
const std::size_t base = heap_in_use();
const int N = 100000;
const char *fonts[] = {"Noto Sans KR Regular", "Noto Sans KR Bold", "D2Coding Ligature"};
StyleFactory styles;
std::vector<Glyph> doc;
doc.reserve(N);
for (int i = 0; i < N; ++i)
doc.push_back({char('a' + i % 26), i % 80, i / 80, styles.get(fonts[i % 3], 11, "#1f2328")});
std::cout << "glyphs=" << doc.size() << " sizeof(Glyph)=" << sizeof(Glyph)
<< " styles=" << styles.count()
<< " heap in use=" << (heap_in_use() - base) / 1024 << " KiB\n";
std::cout << "glyph[0] and glyph[3] share style: " << std::boolalpha
<< (doc[0].style == doc[3].style) << '\n';
}$ g++ -std=c++20 -Wall -Wextra -O2 -o flyweight flyweight.cpp && ./flyweight
glyphs=100000 sizeof(Glyph)=24 styles=3 heap in use=2348 KiB
glyph[0] and glyph[3] share style: true
글꼴 이름이 SSO(짧은 문자열 최적화) 한도인 15자를 넘어 글자마다 힙 할당이 생기던 것이, 서식 3개만 남으면서 사라졌다. 글자 객체 자체도 88바이트에서 24바이트로 줄었다.
| 상태 | 종류 | 어디에 두나 |
|---|---|---|
| 글꼴, 크기, 색 | 내재(공유 가능, 불변) | 플라이웨이트 Style |
| 문자, x, y | 외재(글자마다 다름) | 클라이언트 Glyph |
쓰지 않으면 풀에서 빠지는 플라이웨이트
위 StyleFactory는 한 번 만든 서식을 끝까지 들고 있다. 텍스처처럼 크고 사용 기간이 들쭉날쭉한 자원은 풀이 weak_ptr만 보관하게 하면, 마지막 사용자가 사라질 때 자동으로 해제된다.
// weak_cache.cpp — 아무도 안 쓰는 플라이웨이트는 자동으로 풀에서 빠지게(weak_ptr)
#include <iostream>
#include <map>
#include <memory>
#include <string>
struct Texture {
std::string path;
explicit Texture(std::string p) : path(std::move(p)) { std::cout << " load " << path << '\n'; }
~Texture() { std::cout << " unload " << path << '\n'; }
};
class TextureCache {
public:
std::shared_ptr<const Texture> get(const std::string &path)
{
if (auto alive = cache_[path].lock()) // 살아 있으면 공유
return alive;
auto t = std::make_shared<const Texture>(path);
cache_[path] = t; // 풀은 소유하지 않는다
return t;
}
std::size_t alive() const
{
std::size_t n = 0;
for (const auto &[k, w] : cache_)
n += !w.expired();
return n;
}
private:
std::map<std::string, std::weak_ptr<const Texture>> cache_;
};
int main()
{
TextureCache cache;
{
auto a = cache.get("grass.png");
auto b = cache.get("grass.png"); // 다시 로드하지 않음
auto c = cache.get("stone.png");
std::cout << "same object: " << std::boolalpha << (a == b)
<< ", use_count=" << a.use_count() << ", alive=" << cache.alive() << '\n';
} // 모든 사용자가 사라짐
std::cout << "after scope alive=" << cache.alive() << '\n';
auto d = cache.get("grass.png"); // 필요해지면 다시 로드
}$ g++ -std=c++20 -Wall -Wextra -O2 -o weak_cache weak_cache.cpp && ./weak_cache
load grass.png
load stone.png
same object: true, use_count=2, alive=2
unload stone.png
unload grass.png
after scope alive=0
load grass.png
unload grass.png
문자열 인터닝
같은 내용의 문자열을 하나만 두고 핸들(std::string_view)을 나눠 주면, 메모리뿐 아니라 비교 비용도 줄어든다. 같은 내용이면 같은 주소이므로 포인터 비교로 충분하다.
// intern.cpp — 문자열 인터닝: 같은 내용은 같은 주소 → 비교가 포인터 비교가 된다
#include <chrono>
#include <iostream>
#include <string>
#include <string_view>
#include <unordered_set>
#include <vector>
class Interner {
public:
std::string_view intern(std::string_view s) { return *pool_.emplace(s).first; }
std::size_t size() const { return pool_.size(); }
private:
std::unordered_set<std::string> pool_; // 노드 기반이라 원소 주소가 안정적
};
int main()
{
Interner in;
std::vector<std::string_view> tags;
// 길이가 같고 앞부분 27바이트가 같은 문자열: 내용 비교는 그만큼 읽어야 다름을 안다
const char *words[] = {"/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor",
"/sys/devices/system/cpu/cpu1/cpufreq/scaling_governor",
"/sys/devices/system/cpu/cpu2/cpufreq/scaling_governor"};
for (int i = 0; i < 300000; ++i)
tags.push_back(in.intern(words[i % 3]));
auto target = in.intern("/sys/devices/system/cpu/cpu2/cpufreq/scaling_governor");
auto t0 = std::chrono::steady_clock::now();
std::size_t by_ptr = 0;
for (auto t : tags)
by_ptr += t.data() == target.data(); // 주소 비교
auto t1 = std::chrono::steady_clock::now();
std::size_t by_str = 0;
for (auto t : tags)
by_str += t == target; // 내용 비교
auto t2 = std::chrono::steady_clock::now();
using us = std::chrono::microseconds;
std::cout << "unique strings=" << in.size() << " handles=" << tags.size() << '\n'
<< "pointer compare: " << by_ptr << " matches, "
<< std::chrono::duration_cast<us>(t1 - t0).count() << " us\n"
<< "content compare: " << by_str << " matches, "
<< std::chrono::duration_cast<us>(t2 - t1).count() << " us\n";
}$ g++ -std=c++20 -Wall -Wextra -O2 -o intern intern.cpp && ./intern
unique strings=3 handles=300000
pointer compare: 100000 matches, 143 us
content compare: 100000 matches, 768 us
결과는 같고 주소 비교가 몇 배 빠르다. 컴파일러의 심볼 테이블, 로그 태그, JSON 키처럼 같은 문자열이 수없이 반복되고 자주 비교되는 곳에서 쓰는 기법이다.
C 구현: 인턴 테이블
/* intern.c — C 인턴 테이블: 같은 문자열은 같은 const char * 하나만 */
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define NBUCKET 64
struct entry { struct entry *next; char str[]; }; /* flexible array member */
static struct entry *buckets[NBUCKET];
static int g_unique;
static unsigned hash(const char *s)
{
unsigned h = 2166136261u; /* FNV-1a */
while (*s)
h = (h ^ (unsigned char)*s++) * 16777619u;
return h;
}
const char *intern(const char *s)
{
unsigned b = hash(s) % NBUCKET;
for (struct entry *e = buckets[b]; e; e = e->next)
if (strcmp(e->str, s) == 0)
return e->str; /* 이미 있으면 공유 */
struct entry *e = malloc(sizeof(*e) + strlen(s) + 1);
if (!e)
abort();
strcpy(e->str, s);
e->next = buckets[b];
buckets[b] = e;
g_unique++;
return e->str;
}
int main(void)
{
char buf[32];
const char *first = NULL, *p = NULL;
for (int i = 0; i < 1000; i++) {
snprintf(buf, sizeof(buf), "eth%d", i % 4); /* 매번 새 버퍼의 내용 */
p = intern(buf);
if (i == 0)
first = p;
}
printf("calls=1000 unique=%d\n", g_unique);
printf("intern(\"eth0\") == first: %d\n", intern("eth0") == first);
for (int b = 0; b < NBUCKET; b++)
while (buckets[b]) {
struct entry *n = buckets[b]->next;
free(buckets[b]);
buckets[b] = n;
}
return 0;
}$ gcc -std=c11 -Wall -Wextra -O2 -fsanitize=address -o intern_c intern.c && ./intern_c
calls=1000 unique=4
intern("eth0") == first: 1
매번 다른 버퍼에 만든 문자열 1000개가 고유 문자열 4개로 모였고, 이후 같은 이름은 같은 포인터로 돌아온다. 호출자는 돌려받은 포인터를 해제하지 않고 테이블이 수명을 관리한다.
주의사항
- 플라이웨이트는 불변이어야 한다: 공유 객체를 한 곳에서 바꾸면 그것을 쓰는 모든 곳이 바뀐다. 예제처럼
const로 반환하고, 서식 변경은 “다른 플라이웨이트로 갈아 끼우기”로 표현한다. - 외재 상태 계산 비용: 공유로 아낀 메모리 대신 매번 외재 상태를 넘기거나 계산해야 한다. 객체 수가 적다면 단순한 구조가 낫다.
- 풀 조회 비용: 예제의
StyleFactory::get()은 호출마다 키 문자열을 만들고 해시한다. 같은 서식을 반복해서 쓸 때는 받은 포인터를 재사용한다. - 스레드 안전성: 풀은 여러 스레드가 공유하는 전역 자료구조가 되기 쉽다. 조회·삽입을 뮤텍스로 보호하거나 스레드별 풀을 둔다. 불변 플라이웨이트 자체는 동기화 없이 읽어도 된다.
- 인터닝 풀의 성장: 사용자 입력처럼 종류가 무한한 문자열을 인터닝하면 풀이 끝없이 커진다. 종류가 제한된 식별자에만 쓰거나,
weak_ptr방식처럼 해제 경로를 둔다.
마무리
플라이웨이트의 핵심은 “무엇을 공유할 수 있는가”를 찾아 상태를 둘로 나누는 데 있다. 나누고 나면 구현은 해시 맵 하나와 포인터 하나로 끝나는 경우가 많다. 다음 글에서는 다른 객체에 대한 접근을 대리하는 프록시(Proxy)를 다룬다.
참고
- Erich Gamma 외, 『Design Patterns: Elements of Reusable Object-Oriented Software』(GoF), Flyweight
- man7.org — mallinfo(3), mallinfo2
- cppreference — std::weak_ptr