
플라이웨이트 패턴은 재사용 가능한 인스턴스를 공유시켜 메모리 사용량을 최소화하는 구조 패턴이다. 간단히 말해서 캐시 개념을 코드로 패턴화 한 것으로 보면 되는데, 자주 변하는 속성과 변하지 않는 속성을 분리하고 변하지 않는 속성을 캐시하여 재사용해 메모리 사용을 줄이는 방식이다. 그래서 동일하거나 유사한 객체들 사이에 가능한 많은 데이터를 서로 공유하여 사용하도록 하여 최적화를 노리는 경량 패턴이라고도 불린다.
Flyweight 단어 의미는 Fly(가벼운) + Weight(무게)를 뜻함으로써, 복싱의 체급에서 유래되었다. 모든 객체를 일일히 인스턴스화 하지 않고 재사용할 수 있는 객체는 재사용함으로써 메모리를 가볍게 만든다는 의미로 쓰인다고 보면 된다.

플레이웨이트 패턴에서 가장 주의 깊게 보아야할 점이 바로 Intrinsic과 Extrinsic의 상태를 구분하는 것이다.
intrinsic란 '고유한, 본질적인' 이라는 의미를 가진다. 본질적인 상태란 인스턴스가 어떠한 상황에서도 변하지 않는 정보를 말한다. 그래서 값이 고정되어 있기에 충분히 언제 어디서 공유해도 문제가 없게 된다.
extrinsic이란 '외적인, 비본질적인' 이라는 의미를 가진다. 인스턴스를 두는 장소나 상황에 따라서 변화하는 정보를 말한다. 그래서 값이 언제 어디서 변화할지 모르기 때문에 이를 캐시해서 공유할수 는 없다.
폭탄 피하기 게임을 생각해보면 일일히 폭탄을 인스턴스화하여 객체를 생성하면 생성할 때마다 메모리를 차지하게 되어 게임이 무거워질 것이다. 떨어지는 폭탄 객체는 어차피 모두 같기 때문에 이를 일일히 생성하는 것은 중복이기 때문이다. 따라서 이 폭탄을 플라이웨이트로 처리함으로써 폭탄 인스턴스는 하나만 만들고 공유하여 이를 가져와 화면에 흩뿌림녀 된다. 폭탄이 다음과 같은 정보를 가지고 있다고 해보자.
이 때 폭탄 모양이나 색깔 값은 본질적인 폭탄 상태를 나타나기 때문에 캐시하여 여러 곳에 공유할 수 있다. 반면 위치 값은 실시간으로 변화하기 때문에 캐시하여 공유할 수 없다.
따라서 폭탄 클래스 구조를 플라이웨이트 디자인 패턴으로 표현한다면, 폭탄의 형태나 색깔 같은 고정 정보를 포함하고 있는 객체는 ConcreteFlyweight로 구현되고, 폭탄의 좌표값 같은 변화 정보를 포함하고 있는 객체는 UnsharedConcreteFlyweight로 구분하게 된다. 이러한 폭탄 객체를 FlyweightFactory가 생성하고 캐싱하고 관리를 하는 것이다.
마인크래프트에 숲을 구현하기 위해 지형에 나무 객체들을 심으려고 한다. 나무 객체에 필요한 데이터는 아래와 같다.
나무에도 여러가지 종류가 있으며, 나무의 형태를 구현하는 mesh와 texture 그리고 나무가 어느 지형 좌표에 심어질지에 대한 x, y 위치 매개변수가 필요하다.

가장 심플한 방법은 Tree 객체를 new 생성자로 인스턴스화하여 배치하는 것이다. 하지만 이렇게 하면 메모리 부족으로 게임이 터져버릴 것이다. (마인크래프트는 어마어마한 맵 사이즈를 제공하기 때문에 그곳에 존재하는 나무의 개수도 어마어마하다!) 하나의 나무 객체가 포함하고 있는 매시와 텍스쳐의 데이터 크기는 만만치 않고, 이렇게 많은 나무 객체로 이루어진 숲 전체를 화면에 담아내기에는 무리이다.

class Memory {
public static long size = 0; // 메모리 사용량
public static void Print() {
Debug.Log("총 메모리 사용량 : " + Memory.size + "MB");
}
}
public class DefaultTree
{
long objSize = 100; // 100MB
string type; // 나무 종류
Mesh mesh; // 메쉬
Texture texture; // 나무 껍질 + 잎사귀 텍스쳐
// 위치 변수
public double position_x;
public double position_y;
// public 생성자
public DefaultTree(string type, Mesh mesh, Texture texture, double position_x, double position_y)
{
this.type = type;
this.mesh = mesh;
this.texture = texture;
this.position_x = position_x;
this.position_y = position_y;
// 나무 객체를 생성하였으니 메모리 사용 크기 증가
Memory.size += this.objSize;
}
}
// user
public class Terrain
{
// 지형 타일 크기
public static readonly int CANVAS_SIZE = 10000;
// 나무를 렌더링
public void Render(string type, Mesh mesh, Texture texture, double position_x, double position_y)
{
// Random 객체 생성
System.Random rand = new System.Random();
// 나무를 지형에 생성
DefaultTree tree = new DefaultTree(
type, // 나무 종류
mesh, // mesh
texture, // texture
Random.Range(0, CANVAS_SIZE), // position_x
Random.Range(0, CANVAS_SIZE) // position_y
);
Debug.Log("x:" + tree.position_x + " y:" + tree.position_y + " 위치에 " + type + " 나무 생성 완료");
}
}
public class Flyweight : MonoBehaviour
{
void Start()
{
// 지형 생성
Terrain terrain = new Terrain();
// 지형에 Oak 나무 5 그루 생성
for (int i = 0; i < 5; i++)
{
terrain.Render(
"Oak", // type
new Mesh(), // mesh
new Texture2D(256, 256), // texture
Random.Range(0, Terrain.CANVAS_SIZE), // position_x
Random.Range(0, Terrain.CANVAS_SIZE) // position_y
);
}
// 지형에 Acacia 나무 5 그루 생성
for (int i = 0; i < 5; i++)
{
terrain.Render(
"Acacia", // type
new Mesh(), // mesh
new Texture2D(256, 256), // texture
Random.Range(0, Terrain.CANVAS_SIZE), // position_x
Random.Range(0, Terrain.CANVAS_SIZE) // position_y
);
}
// 지형에 Jungle 나무 5 그루 생성
for (int i = 0; i < 5; i++)
{
terrain.Render(
"Jungle", // type
new Mesh(), // mesh
new Texture2D(256, 256), // texture
Random.Range(0, Terrain.CANVAS_SIZE), // position_x
Random.Range(0, Terrain.CANVAS_SIZE) // position_y
);
}
// 총 메모리 사용률 출력
Memory.Print();
}
}

각 종류별 나무 5개씩 총 1500MB 메모리가 사용되게 된다.
최적화의 핵심은 수백 그루 넘게 나무를 생성해도 대부분의 나무는 비슷하게 보인다는 것이다. 즉, 나무를 생성하는데 사용하는 mesh와 texture를 재사용하여 표현해도 어차피 같은 나무이니 문제가 없다. 따라서 공통으로 사용되는 모델 데이터와 실시간으로 변하는 위치 매개변수를 분리하여 객체를 구성해주면, 지형에서 나무를 구현할때 나무 모델 인스턴스 하나를 공유받고 위치 매개변수만 다르게 설정해주면 메모리 사용량을 절반 이상을 줄일 수 있을 것이다.


마인크래프트 게임 내에서 똑같은 메시와 나무껍질 텍스쳐를 일일히 여러번 메모리에 올릴 이유가 없기 때문에 공유되는 나무 모델 객체를 기존 Tree 클래스에서 따로 빼준다. 그러면 아래와 같이 TreeModel 클래스는 ConcreteFlyweight가 되고 좌표값을 가지고 있는 기존 Tree 클래스는 UnsahredConcreteFlyweight 가 된다.
// ConcreteFlyweight - 플라이웨이트 객체는 불변성을 가져야 한다. 변경되면 모든 것에 영향을 준다.
public sealed class TreeModel
{
// 메시, 텍스쳐 총 사이즈 (메모리 사용 크기)
private readonly long objSize = 90; // 90MB
// 불변 필드
private readonly string type; // 나무 종류
private readonly Mesh mesh; // 메쉬
private readonly Texture2D texture; // 나무 껍질 + 잎사귀 텍스쳐
// 생성자
public TreeModel(string type, Mesh mesh, Texture2D texture)
{
this.type = type;
this.mesh = mesh;
this.texture = texture;
// 나무 객체를 생성하여 메모리에 적재했으니 메모리 사용 크기 증가
Memory.size += this.objSize;
}
}
// UnsahredConcreteFlyweight
public class DefaultTree
{
// 좌표값과 나무 모델 참조 객체 크기를 합친 사이즈
private readonly long objSize = 10; // 10MB
// 위치 변수
public readonly double position_x;
public readonly double position_y;
// 나무 모델
public readonly TreeModel model;
// 생성자
public DefaultTree(TreeModel model, double position_x, double position_y)
{
this.model = model;
this.position_x = position_x;
this.position_y = position_y;
// 나무 객체를 생성하였으니 메모리 사용 크기 증가
Memory.size += this.objSize;
}
// 나무의 위치 정보와 모델을 출력하기 위한 메서드
public void Render()
{
Debug.Log("x: " + position_x + " y: " + position_y + " 위치에 " + model.GetType() + " 나무 생성 완료");
}
}
이때 DefaultTree 클래스와 TreeModel 간의 관계를 맺어주어야 하는데, 상속을 통해 해주어도 되고 예제에서는 합성을 통해 맺어주었다. 그리고 ConcreteFlyweight인 TreeModel 클래스를 sealed화 시켜서 불변 객체로 만들어준다. 나무 모델은 중간에 메시와 텍스쳐가 변경될 일이 없기 때문이다.
이제 나무 모델 객체에 플라이웨이트를 적용했으니 이를 생성하고 관리하는 FlyweightFactory 클래스를 만든다.
// FlyweightFactory
public static class TreeModelFactory
{
// Flyweight Pool - TreeModel 객체들을 Dictionary로 등록하여 캐싱
private static readonly Dictionary<string, TreeModel> cache = new Dictionary<string, TreeModel>(); // Thread-Safe
// static factory method
public static TreeModel GetInstance(string key)
{
// 캐시되어 있다면 반환
if (cache.ContainsKey(key))
{
return cache[key];
}
else
{
// 캐시되어 있지 않으면 나무 모델 객체를 새로 생성
TreeModel model = new TreeModel(
key,
new Mesh(), // Unity에서 사용할 실제 Mesh
new Texture2D(1, 1) // Unity에서 사용할 실제 Texture
);
Debug.Log("-- 나무 모델 객체 새로 생성 완료 --");
// 캐시에 적재
cache[key] = model;
return model;
}
}
}
기존 Terrain 클래스의 나무를 생성하는 render() 메서드의 내부 로직은 단순히 사용자로부터 매개변수를 받아 그대로 tree 객체를 생성할 뿐이었다. 그러나 기존 Tree 객체를 따로 TreeModel로 나누고, TreeModelFactory까지 생성하였으니 이들을 이용하도록 하자. Flyweight를 이용한 최적화 작업은 다음과 같다.
단순히 2단계로 구성된것 뿐이지만, 공유 객체를 가져와 사용함으로써 쓸데없는 mesh와 texture 낭비를 방지한 것이다.
// user
public class Terrain : MonoBehaviour
{
// 지형 타일 크기
public const int CANVAS_SIZE = 10000;
// 나무를 렌더링하는 메서드
public void Render(string type, float positionX, float positionY)
{
// 1. 캐시되어 있는 나무 모델 객체 가져오기
TreeModel model = TreeModelFactory.GetInstance(type);
// 2. 재사용한 나무 모델 객체와 변화하는 속성인 좌표값으로 나무 생성
DefaultTree tree = new DefaultTree(model, positionX, positionY);
Debug.Log($"x: {tree.position_x} y: {tree.position_y} 위치에 {type} 나무 생성 완료");
}
}
public class Flyweight : MonoBehaviour
{
void Start()
{
// 지형 생성
Terrain terrain = new Terrain();
// 지형에 Oak 나무 5 그루 생성
for (int i = 0; i < 5; i++)
{
terrain.Render(
"Oak", // type
Random.Range(0, Terrain.CANVAS_SIZE), // position_x
Random.Range(0, Terrain.CANVAS_SIZE) // position_y
);
}
// 지형에 Acacia 나무 5 그루 생성
for (int i = 0; i < 5; i++)
{
terrain.Render(
"Acacia", // type
Random.Range(0, Terrain.CANVAS_SIZE), // position_x
Random.Range(0, Terrain.CANVAS_SIZE) // position_y
);
}
// 지형에 Jungle 나무 5 그루 생성
for (int i = 0; i < 5; i++)
{
terrain.Render(
"Jungle", // type
Random.Range(0, Terrain.CANVAS_SIZE), // position_x
Random.Range(0, Terrain.CANVAS_SIZE) // position_y
);
}
// 총 메모리 사용률 출력
Memory.Print();
}
}

패턴 적용 전에는 1500MB 메모리 사용량이 들었지만, 패턴 적용 후에는 메모리 사용량이 420MB로 확연히 줄어들었음을 확인할 수 있다. 이전에는 나무 객체를 하나씩 생성함에 비해, 나무 모델을 따로 플라이웨이트로 분리함으로써 중복된 mesh, texture 사용을 공유시켜 메모리를 아낀 것이다. 이처럼 공유할 수 있는 intrinsic 상태의 데이터를 분간하여 캐싱함으로써 프로그램 최적화를 추구하는 것이 좋은 프로그래머의 자질이라고 할 수 있다.
위에서 구현한 TreeModeLFactory에서는 Dictionary Cache를 이용해 TreeModel의 인스터스를 캐싱하여 관리하고 있다. 이와 같이 '인스턴스를 관리'하는 기능을 사용할 때는 반드시 '관리되고 있는 인스턴스는 GC 처리되지 않는다.' 점을 주의해야 한다.
즉, 나무를 모두 렌더링을 완료하여 더이상 나무를 생성할 일이 없다라면, 반드시 TreeModelFactory에 잔존해있는 Flyweight Pool 을 비워줄 필요가 있는 것이다. 그래야 인스턴스에 대한 참조를 잃은 TreeModel 인스턴스들이 GC에 의해 메모리 청소가 되게 된다. 그렇지 않으면 더이상 나무를 생성할 일이 없는데도 TreeModel 데이터가 메모리에 쓸데없이 잔존하게 된다. 메모리 최적화를 위해 플라이웨이트 패턴을 썼는데 마지막 맺음에서 이렇게 허점이 생기면 뒤가 쓰린 법이다.
// 캐시 비우기 메서드
public static void ClearCache()
{
cache.Clear();
Debug.Log("-- Flyweight Pool이 비워졌습니다. --");
}