C/C++ 디자인 패턴 (11) — 플라이웨이트(Flyweight)

문서 편집기의 글자 하나하나를 객체로 만들면 편하지만, 글자마다 글꼴 이름·크기·색을 들고 있으면 10만 글자짜리 문서가 수십 MB를 차지한다. 그런데 서식은 대부분 몇 가지뿐이고, 글자마다 다른 것은 위치 정도다. 플라이웨이트(Flyweight)는 객체의 상태를 공유 가능한 내재 상태(intrinsic)와 문맥마다 다른 외재 상태(extrinsic)로 나누고, 내재 상태를 가진 객체를 풀에서 공유해 대량의 작은 객체를 효율적으로 지원한다. 이 글에서는 서식 공유 전후의 실제 힙 사용량, weak_ptr로 자동 해제되는 풀, 문자열 인터닝, C의 인턴 테이블을 다룬다.

이 시리즈의 다른 글

구조

참여자역할예제
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)를 다룬다.

참고

답글 남기기