[DesignPattern] Flyweight Pattern

suhan0304·2024년 10월 21일

Design Pattern

목록 보기
15/16
post-thumbnail

Flyweight Pattern

플라이웨이트 패턴은 재사용 가능한 인스턴스를 공유시켜 메모리 사용량을 최소화하는 구조 패턴이다. 간단히 말해서 캐시 개념을 코드로 패턴화 한 것으로 보면 되는데, 자주 변하는 속성과 변하지 않는 속성을 분리하고 변하지 않는 속성을 캐시하여 재사용해 메모리 사용을 줄이는 방식이다. 그래서 동일하거나 유사한 객체들 사이에 가능한 많은 데이터를 서로 공유하여 사용하도록 하여 최적화를 노리는 경량 패턴이라고도 불린다.

Flyweight 단어 의미는 Fly(가벼운) + Weight(무게)를 뜻함으로써, 복싱의 체급에서 유래되었다. 모든 객체를 일일히 인스턴스화 하지 않고 재사용할 수 있는 객체는 재사용함으로써 메모리를 가볍게 만든다는 의미로 쓰인다고 보면 된다.


Structure

  • Flyweight : 경량 객체를 묶는 인터페이스.
  • ConcreteFlyweight : 공유 가능하여 재사용되는 객체 (intrinsic state)
  • UnsahredConcreteFlyweight : 공유 불가능한 객체 (extrinsic state)
  • FlyweightFactory : 경량 객체를 만드는 공장 역할과 캐시 역할을 겸비하는 Flyweight 객체 관리 클래스
    - GetFlyweight() 메서드는 팩토리 메서드 역할을 한다고 보면 된다.
    - 만일 객체가 메모리에 존재하면 그대로 가져와 반환하고, 없다면 새로 생성해 반환한다
  • Client : 클라이언트는 FlyweightFactory를 통해 Flyweight 타입의 객체를 얻어 사용한다.

intrinsic vs extrinsic

플레이웨이트 패턴에서 가장 주의 깊게 보아야할 점이 바로 IntrinsicExtrinsic의 상태를 구분하는 것이다.

intrinsic란 '고유한, 본질적인' 이라는 의미를 가진다. 본질적인 상태란 인스턴스가 어떠한 상황에서도 변하지 않는 정보를 말한다. 그래서 값이 고정되어 있기에 충분히 언제 어디서 공유해도 문제가 없게 된다.
extrinsic이란 '외적인, 비본질적인' 이라는 의미를 가진다. 인스턴스를 두는 장소나 상황에 따라서 변화하는 정보를 말한다. 그래서 값이 언제 어디서 변화할지 모르기 때문에 이를 캐시해서 공유할수 는 없다.

  • intrinsic한 객체 : 장소나 상황에 의존하지 않기 때문에 값이 고정되어 공유할 수 있는 객체
  • extrinsic한 객체 : 장소나 상황에 의존하기 때문에 매번 값이 바뀌어 공유할 수 없는 객체

폭탄 피하기 게임을 생각해보면 일일히 폭탄을 인스턴스화하여 객체를 생성하면 생성할 때마다 메모리를 차지하게 되어 게임이 무거워질 것이다. 떨어지는 폭탄 객체는 어차피 모두 같기 때문에 이를 일일히 생성하는 것은 중복이기 때문이다. 따라서 이 폭탄을 플라이웨이트로 처리함으로써 폭탄 인스턴스는 하나만 만들고 공유하여 이를 가져와 화면에 흩뿌림녀 된다. 폭탄이 다음과 같은 정보를 가지고 있다고 해보자.

  1. 모양
  2. 색깔
  3. 위치

이 때 폭탄 모양이나 색깔 값은 본질적인 폭탄 상태를 나타나기 때문에 캐시하여 여러 곳에 공유할 수 있다. 반면 위치 값은 실시간으로 변화하기 때문에 캐시하여 공유할 수 없다.

따라서 폭탄 클래스 구조를 플라이웨이트 디자인 패턴으로 표현한다면, 폭탄의 형태나 색깔 같은 고정 정보를 포함하고 있는 객체는 ConcreteFlyweight로 구현되고, 폭탄의 좌표값 같은 변화 정보를 포함하고 있는 객체는 UnsharedConcreteFlyweight로 구분하게 된다. 이러한 폭탄 객체를 FlyweightFactory가 생성하고 캐싱하고 관리를 하는 것이다.


When

  • 어플리케이션에 의해 생성되는 객체의 수가 많아 저장 비용이 높아질 때
  • 생성된 객체가 오래도록 메모리에 상주하며 사용되는 횟수가 많을때
  • 공통적인 인스턴스를 많이 생성하는 로직이 포함된 경우

Then

  • 애플리케이션에서 사용하는 메모리를 줄일 수 있다.
  • 프로그램 속도를 개선 할수 있다.
    - new로 인스턴스화를 하면 데이터가 생성되고 메모리에 적재 되는 미량의 시간이 걸리게 된다.
    - 객체를 공유하면 인스턴스를 가져오기만 하면 되기 때문에 메모리 뿐만 아니라 속도도 향상시킬 수 있게 되는 것이다.

But

  • 코드의 복잡도가 증가한다.

Example

마인크래프트에 숲을 구현하기 위해 지형에 나무 객체들을 심으려고 한다. 나무 객체에 필요한 데이터는 아래와 같다.

  1. 나무 종류
  2. 메시 폴리곤
  3. 나무껍질 텍스쳐
  4. 잎사귀 텍스쳐
  5. 위치 매개변수

나무에도 여러가지 종류가 있으며, 나무의 형태를 구현하는 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 메모리가 사용되게 된다.

Flyweight

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

intrinsic 객체와 extrinsic 객체 쪼개기

마인크래프트 게임 내에서 똑같은 메시와 나무껍질 텍스쳐를 일일히 여러번 메모리에 올릴 이유가 없기 때문에 공유되는 나무 모델 객체를 기존 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 클래스를 만든다.

  • Flyweight Pool : HashMap 컬렉션을 통해 키(key) 와 나무 모델 객체를 저장하는 캐시 저장소 역할
  • getInstance 메서드 : Pool에 가져오고자 하는 객체가 있는지 검사를 하여 있으면 그대로 반환, 없으면 새로 생성
// 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를 이용한 최적화 작업은 다음과 같다.

  1. 먼저 TreeModel 에서 공유되고 있는 나무 모델 객체를 가져온다. (없다면 새로 생성)
  2. 가져온 나무 모델과 좌표값을 이용해 나무 객체를 새로 생성한다.

단순히 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 상태의 데이터를 분간하여 캐싱함으로써 프로그램 최적화를 추구하는 것이 좋은 프로그래머의 자질이라고 할 수 있다.

Garbage Collection

위에서 구현한 TreeModeLFactory에서는 Dictionary Cache를 이용해 TreeModel의 인스터스를 캐싱하여 관리하고 있다. 이와 같이 '인스턴스를 관리'하는 기능을 사용할 때는 반드시 '관리되고 있는 인스턴스는 GC 처리되지 않는다.' 점을 주의해야 한다.

즉, 나무를 모두 렌더링을 완료하여 더이상 나무를 생성할 일이 없다라면, 반드시 TreeModelFactory에 잔존해있는 Flyweight Pool 을 비워줄 필요가 있는 것이다. 그래야 인스턴스에 대한 참조를 잃은 TreeModel 인스턴스들이 GC에 의해 메모리 청소가 되게 된다. 그렇지 않으면 더이상 나무를 생성할 일이 없는데도 TreeModel 데이터가 메모리에 쓸데없이 잔존하게 된다. 메모리 최적화를 위해 플라이웨이트 패턴을 썼는데 마지막 맺음에서 이렇게 허점이 생기면 뒤가 쓰린 법이다.

// 캐시 비우기 메서드
public static void ClearCache()
{
	cache.Clear();
	Debug.Log("-- Flyweight Pool이 비워졌습니다. --");
}

profile
Be Honest, Be Harder, Be Stronger

0개의 댓글