코딩 · 디자인 패턴
예제로 이해하는 플라이웨이트 패턴
핵심 답변
플라이웨이트 패턴은 비슷한 객체들이 공통으로 가진 부분을 공유함으로써 프로그램이 매우 많은 수의 객체를 다룰 수 있게 하는 구조 패턴입니다. 각 객체의 상태는 내재적 상태(공유되고 불변이며 플라이웨이트에 한 번만 저장)와 외재적 상태(사용마다 고유하며 호출자가 전달)로 나뉩니다. 팩토리가 플라이웨이트를 캐시하므로 같은 내재적 상태가 두 번 저장되는 일이 없습니다.
의도와 해결하는 문제
의도: 공유를 통해 세밀한 단위의 객체를 아주 많이 효율적으로 지원합니다. 이 패턴은 Gang of Four(GoF) 책에서 나왔으며 구조 패턴에 속합니다.
나무를 백만 그루 그리는 지도를 떠올려 보세요. 각 나무에는 위치(x, y)와 종류(수종 이름, 색상, 수 킬로바이트 크기의 텍스처 이미지)가 있습니다. 모든 나무 객체가 텍스처 사본을 따로 가지면 나무가 늘수록 메모리 사용량이 늘고 프로그램이 메모리 부족에 빠질 수도 있습니다. 그런데 수종은 몇 가지뿐입니다. 텍스처, 이름, 색상은 모든 참나무가 같습니다.
플라이웨이트 패턴은 반복되는 데이터를 소수의 공유 객체로 옮깁니다. 그러면 나무는 위치와 공유된 나무 종류에 대한 참조만 저장합니다. 수종이 다섯 가지인 나무 백만 그루는 텍스처 사본이 백만 개가 아니라 다섯 개면 됩니다.
내재적 상태와 외재적 상태
핵심 단계는 각 객체의 데이터를 두 종류로 나누는 것입니다.
| 상태 | 의미 | 나무 예제 |
|---|---|---|
| 내재적 | 문맥과 무관하고 많은 객체에서 동일하며, 플라이웨이트 안에 저장되고 절대 바뀌지 않음 | 수종 이름, 색상, 텍스처 |
| 외재적 | 문맥에 따라 다르고 사용마다 달라서 클라이언트가 보관하고 플라이웨이트의 메서드에 전달함 | 위치 x, y |
플라이웨이트는 공유되므로 불변이어야 합니다. 한 클라이언트가 공유된 참나무 종류의 색상을 바꿀 수 있다면 지도에 있는 모든 참나무가 바뀌게 됩니다.
구조와 참여자
| 역할 | 책임 |
|---|---|
| Flyweight | 내재적 상태를 가지고, 외재적 상태를 매개변수로 받는 연산을 제공 |
| 플라이웨이트 팩토리 | 플라이웨이트를 만들고 캐시하며, 같은 내재적 상태를 다시 요청하면 이미 있는 것을 반환 |
| Context | 외재적 상태와 플라이웨이트에 대한 참조를 저장(현실의 객체 하나당 컨텍스트 하나) |
| Client | 팩토리에 플라이웨이트를 요청하고 외재적 상태를 보관하거나 계산 |
TypeScript 구현
팩토리는 내재적 값을 키로 하는 Map을 사용합니다. TreeType이 플라이웨이트, Tree가 컨텍스트, Forest가 클라이언트입니다.
class TreeType {
constructor(
readonly name: string,
readonly color: string,
readonly texture: Uint8Array, // large, shared data
) {}
draw(x: number, y: number): string {
return this.name + " (" + this.color + ") at " + x + "," + y;
}
}
class TreeTypeFactory {
private static cache = new Map<string, TreeType>();
static get(name: string, color: string): TreeType {
const key = name + "|" + color;
let type = this.cache.get(key);
if (!type) {
type = new TreeType(name, color, new Uint8Array(4096));
this.cache.set(key, type);
}
return type;
}
}
class Tree {
constructor(readonly x: number, readonly y: number, readonly type: TreeType) {}
draw(): string {
return this.type.draw(this.x, this.y);
}
}
const forest: Tree[] = [];
for (let i = 0; i < 100_000; i++) {
const [name, color] = i % 2 === 0 ? ["Oak", "green"] : ["Birch", "white"];
forest.push(new Tree(i, i * 2, TreeTypeFactory.get(name, color)));
}
const types = new Set(forest.map((t) => t.type));
console.log(forest.length, "trees,", types.size, "tree types");
console.log(forest[3].draw());
// 100000 trees, 2 tree types
// Birch (white) at 3,6팩토리가 없으면 프로그램은 텍스처 버퍼를 100,000개 할당합니다. 팩토리를 쓰면 두 개뿐입니다. Tree 객체는 여전히 존재하지만 각각 숫자 두 개와 참조 하나만 가집니다.
언제 쓰고 언제 쓰지 말아야 하나
다음 조건이 모두 참일 때 플라이웨이트 패턴을 사용합니다.
- 프로그램이 비슷한 객체를 매우 많이 만든다.
- 메모리 사용량이 추측이 아니라 측정으로 확인된 문제다.
- 각 객체 상태의 대부분을 외재적으로 만들거나 공유할 수 있다.
- 프로그램이 객체의 동일성에 의존하지 않는다(같은 플라이웨이트를 가진 두 컨텍스트가 그것을 공유해도 된다).
객체 수가 적거나, 객체가 공유 데이터를 독립적으로 바꿔야 하거나, 외재적 상태를 넘겨 주는 복잡함이 절약한 메모리보다 코드를 읽기 어렵게 만든다면 피하세요. 더 단순한 해결책이 통하는 경우도 많습니다. 데이터를 병렬 배열이나 타입 배열에 저장하거나, 반복되는 문자열을 Map에 인턴(intern)하는 방법입니다.
절충: 플라이웨이트 패턴은 메모리를 CPU 시간과 맞바꿉니다. 메서드를 호출할 때마다 외재적 상태를 다시 계산하거나 조회해야 할 수 있습니다.
실제 사용 사례
- Java:
Integer.valueOf(int)는 -128부터 127까지의 값에 대해 캐시된Integer객체를 반환하므로 작은 숫자를 박싱할 때마다 새 객체를 할당하지 않습니다. - Java:
String.intern()은 문자열 풀에서 표준 공유 사본을 반환합니다. - 텍스트 편집기와 브라우저는 공유 글꼴 캐시에서 글리프를 렌더링합니다. 화면의 각 문자는 위치를 저장하고, 글리프 모양은 글꼴과 크기마다 한 번만 저장됩니다.
- 게임 엔진은 인스턴스 렌더링을 씁니다. 메시와 머티리얼 하나를 공유하고 복사본마다 위치와 회전 같은 인스턴스별 데이터만 보냅니다.
관련 패턴
| 패턴 | 플라이웨이트 패턴과의 차이 |
|---|---|
| 싱글턴 패턴 | 싱글턴은 클래스의 인스턴스가 하나임을 보장하고, 플라이웨이트는 서로 다른 내재적 상태마다 인스턴스를 하나씩 유지하므로 여러 개일 수 있습니다. |
| 팩토리 메서드 패턴 | 플라이웨이트 팩토리는 캐시가 있는 팩토리이고, 팩토리 메서드는 서브클래스가 만들 클래스를 고르게 하는 것입니다. |
| 컴포지트 패턴 | 컴포지트 트리의 리프 노드는 반복되는 리프가 메모리를 공유하도록 플라이웨이트로 구현되는 경우가 많습니다. |
| 프로토타입 패턴 | 프로토타입은 객체를 복사해 새 객체를 만들고, 플라이웨이트는 하나의 객체를 공유함으로써 복사를 피합니다. |
자주 묻는 질문
내재적 상태와 외재적 상태는 무엇이 다른가요?
내재적 상태는 공유되고 많은 객체에서 동일하므로 플라이웨이트 안에 있으며 바뀌지 않습니다. 외재적 상태는 사용마다 다르므로 클라이언트가 보관하고 플라이웨이트의 메서드에 전달합니다.
플라이웨이트 패턴은 캐싱과 같은가요?
캐시를 쓰지만 목표가 다릅니다. 캐시는 결과를 다시 계산하는 것을 피하고, 플라이웨이트는 하나의 객체를 여러 사용자가 공유하게 해 같은 불변 데이터를 여러 번 저장하는 것을 피합니다.
플라이웨이트는 왜 불변이어야 하나요?
많은 컨텍스트가 같은 플라이웨이트를 참조합니다. 그중 하나가 바꾸면 그것을 공유하는 다른 모든 컨텍스트에 변경이 나타납니다.
JavaScript와 TypeScript에서도 플라이웨이트 패턴이 유용한가요?
네, 파티클, 지도 타일, 격자 셀처럼 수십만 개의 객체를 만들 때 그렇습니다. JavaScript 객체에는 객체마다 오버헤드가 있으므로 반복되는 데이터를 공유하면 메모리와 가비지 컬렉션 작업을 줄일 수 있습니다.