겨울 세일!

플라이웨이트 패턴

다음 이름으로도 불립니다: 캐시, Flyweight

의도

플라이웨이트는 각 객체에 모든 데이터를 유지하는 대신 여러 객체들 간에 상태의 공통 부분들을 공유하여 사용할 수 있는 RAM에 더 많은 객체들을 포함할 수 있도록 하는 구조 디자인 패턴입니다.

플라이웨이트 디자인 패턴

문제

당신은 재미 삼아 플레이어들이 지도를 돌아다니며 서로에게 총을 쏘는 간단한 비디오 게임을 만들기로 했습니다. 당신은 폭발들로 인한 방대한 양의 총알들, 미사일들 및 파편들이 지도 전체를 날아다니는 전율 넘치는 경험을 플레이어들에게 선사하기로 했으며, 이를 선사하기 위해 사실적인 입자 시스템을 구현하기로 했습니다.

당신은 게임을 완성한 후 친구에게 게임을 보냈습니다. 당신의 컴퓨터에서는 게임이 완벽하게 실행되었지만, 당신의 친구는 오랫동안 게임을 즐길 수 없었습니다. 왜냐하면 친구의 컴퓨터에서는 시작 후 고작 몇 분 후에 게임이 계속 충돌했기 때문입니다. 당신이 디버그 로그를 자세히 살펴본 결과, 친구의 컴퓨터의 RAM이 당신의 컴퓨터처럼 충분하지 않아 게임이 충돌했음이 분명해졌습니다.

문제의 원인은 당신의 입자 시스템과 관련이 있었습니다. 각 총알, 미사일 또는 파편과 같은 입자는 많은 데이터를 포함하는 별도의 객체로 표시되었습니다. 플레이어 화면의 대학살이 절정에 이르렀을 때 새로 생성된 입자들을 더 이상 나머지 RAM이 감당하지 못해서 프로그램이 충돌했습니다.

플라이웨이트 패턴 문제

해결책

Particle​(입자) 클래스를 자세히 살펴보면 color​(색상) 및 sprite(스프라이트) 필드들이 다른 필드들보다 훨씬 더 많은 메모리를 사용한다는 것을 알 수 있습니다. 더 나쁜 것은 이 두 필드가 모든 입자에 걸쳐 거의 같은 데이터를 저장한다는 것입니다. 예를 들어, 모든 총알은 같은 색상과 스프라이트를 갖습니다.

플라이웨이트 패턴 해결책

좌표, 이동 벡터 및 속도와 같은 입자 상태의 다른 부분들은 각 입자마다 고유하며, 이러한 필드들의 값은 시간이 지남에 따라 변경됩니다. 이 데이터는 입자의 계속 변화하는 콘텍스트를 나타내나, 반면 색상과 스프라이트는 각 입자마다 일정하게 유지됩니다.

객체의 이러한 상수 데이터를 일반적으로 라고 합니다. 이 데이터는 객체 안에서 삽니다. 다른 객체들은 이 데이터를 읽을 수만 있고 변경할 수는 없습니다. 종종 다른 객체들에 의해 '외부에서' 변경되는 객체의 나머지 상태를 라고 합니다.

플라이웨이트 패턴은 객체 내부에 공유한 상태의 저장을 중단하고, 대신 이 상태를 이 상태에 의존하는 특정 메서드들에 전달할 것을 제안합니다. 고유한 상태만 객체 내에 유지되므로 해당 고유한 상태는 콘텍스트가 다른 곳에서 재사용할 수 있습니다. 이러한 객체들은 공유한 상태보다 변형이 훨씬 적은 고유한 상태에서만 달라지므로 훨씬 더 적은 수의 객체만 있으면 됩니다.

플라이웨이트 패턴 해결책

이제 당신의 게임을 다시 살펴봅시다. 입자 클래스에서 공유한 상태를 추출했다고 가정하면 총알, 미사일, 파편의 세 가지 다른 객체만으로도 게임의 모든 입자를 충분히 나타낼 수 있습니다. 지금쯤 짐작하셨겠지만 고유한 상태만 저장하는 객체를 플라이웨이트라고 합니다.

공유한 상태 스토리지

공유한 상태는 어디로 이동할까요? 일부 클래스가 이 상태를 여전히 저장하고 있는 거겠죠? 대부분의 경우 공유한 상태는 패턴을 적용하기 전에 객체들을 집합시키는 컨테이너 객체로 이동됩니다.

당신의 게임에서 이것은 particle 필드에 모든 입자를 저장하는 주요 Game 객체입니다. 공유한 상태를 이 클래스로 이동하려면 개별 입자의 좌표, 벡터 및 속도를 저장하기 위한 여러 배열 필드들을 생성해야 합니다. 거기서 끝이 아닙니다. 입자를 나타내는 특정 플라이웨이트에 대한 참조를 저장하려면 다른 배열이 필요합니다. 이러한 배열들은 같은 인덱스를 사용하여 입자의 모든 데이터에 액세스할 수 있도록 동기화되어야 합니다.

플라이웨이트 패턴 해결책

이보다 더 훌륭한 해결책은 플라이웨이트 객체에 대한 참조와 함께 공유된 상태를 저장할 별도의 콘텍스트 클래스를 만드는 것입니다. 이 접근 방식을 사용하려면 컨테이너 클래스에 단일 배열만 있으면 됩니다.

잠시만요! 처음에 그랬던 것처럼 이런 콘텍스트 객체들이 많이 있어야 하지 않나요? 맞습니다. 그러나 이제는 이러한 객체들이 이전보다 훨씬 작습니다. 가장 메모리를 많이 사용하는 필드들이 고작 몇 개의 플라이웨이트 객체들로 이동되었습니다. 이제 하나의 커다란 플라이웨이트 객체를 몇천 개의 작은 콘텍스트 객체들이 재사용할 수 있으며, 더 이상 커다란 플라이웨이트 객체의 데이터의 천 개의 복사본을 저장할 필요가 없습니다.

플라이웨이트와 불변성

같은 플라이웨이트 객체가 다른 콘텍스트들에서 사용될 수 있으므로 해당 플라이웨이트 객체의 상태를 수정할 수 없는지 확인해야 합니다. 플라이웨이트는 생성자 매개변수들을 통해 상태를 한 번만 초기화해야 합니다. 또 setter 또는 public 필드들을 다른 객체들에 노출해서는 안 됩니다.

플라이웨이트 팩토리

다양한 플라이웨이트들에 보다 편리하게 액세스하기 위해 기존 플라이웨이트 객체들의 풀을 관리하는 팩토리 메서드를 생성할 수 있습니다. 이 메서드는 클라이언트에서 원하는 플라이웨이트의 고유한 상태를 받아들이고 이 상태와 일치하는 기존 플라이웨이트 객체를 찾고 발견되면 반환합니다. 그렇지 않으면 새 플라이웨이트를 생성하여 풀에 추가합니다.

이 메서드를 배치할 수 있는 몇 가지 옵션이 있습니다. 그중 가장 확실한 장소는 플라이웨이트 컨테이너입니다. 대안으로 당신은 새 팩토리 클래스를 생성할 수 있고, 또 팩토리 메서드를 정적으로 만들고 실제 플라이웨이트 클래스에 넣을 수 있습니다.

구조

플라이웨이트 디자인 패턴 구조플라이웨이트 디자인 패턴 구조
  1. 플라이웨이트 패턴은 단지 최적화에 불과합니다. 이 패턴을 적용하기 전에 프로그램이 동시에 메모리에 유사한 객체들을 대량으로 보유하는 것과 관련된 RAM 소비 문제가 있는지 확인하시고 이 문제가 다른 의미 있는 방법으로 해결될 수 없는지도 확인하세요.

  2. 플라이웨이트 클래스에는 여러 객체들 간에 공유할 수 있는 원래 객체의 상태의 부분이 포함됩니다. 같은 플라이웨이트 객체가 다양한 콘텍스트에서 사용될 수 있습니다. 플라이웨이트 내부에 저장된 상태를 (intrinsic) 상태라고 하며, 플라이웨이트의 메서드에 전달된 상태를 (extrinsic) 상태라고 합니다.

  3. 콘텍스트 클래스는 공유한 상태를 포함하며, 이 상태는 모든 원본 객체들에서 고유합니다. 콘텍스트가 플라이웨이트 객체 중 하나와 쌍을 이루면 원래 객체의 전체 상태를 나타냅니다.

  4. 일반적으로 원래 객체의 행동은 플라이웨이트 클래스에 남아 있습니다. 이 경우 플라이웨이트의 메서드의 호출자는 공유한 상태의 적절한 부분들을 메서드의 매개변수들에 전달해야 합니다. 반면에, 행동은 콘텍스트 클래스로 이동할 수 있으며, 이 클래스는 연결된 플라이웨이트를 단순히 데이터 객체로 사용할 것입니다.

  5. 클라이언트는 플라이웨이트들의 공유된 상태를 저장하거나 계산합니다. 클라이언트의 관점에서 플라이웨이트는 일부 콘텍스트 데이터를 그의 메서드들의 매개변수들에 전달하여 런타임에 설정될 수 있는 템플릿 객체입니다.

  6. 플라이웨이트 팩토리는 기존 플라이웨이트들의 풀을 관리합니다. 이 팩토리로 인해 클라이언트들은 플라이웨이트들을 직접 만들지 않는 대신 원하는 플라이웨이트의 고유한 상태의 일부를 전달하여 공장을 호출합니다. 팩토리는 이전에 생성된 플라이웨이트들을 살펴보고 검색 기준과 일치하는 기존 플라이웨이트를 반환하거나 기준에 맞는 플라이웨이트가 발견되지 않으면 새로 생성합니다.

의사코드

이 예시에서 플라이웨이트 패턴은 캔버스에 수백만 개의 나무 객체들을 렌더링할 때 메모리 사용량을 줄이는 데 도움을 줍니다.

플라이웨이트 패턴 예시

이 패턴은 주요 Tree​(나무) 클래스에서 반복되는 고유한 상태를 추출하여 Tree­Type​(나무 종류) 플라이웨이트 클래스로 이동합니다.

같은 데이터를 여러 객체에 저장하는 대신 이제 몇 개의 플라이웨이트 객체들에 보관되고 콘텍스트 역할을 하는 적절한 Tree 객체들에 연결됩니다. 클라이언트 코드는 플라이웨이트 팩토리를 사용하여 새 Tree 객체들을 생성합니다. 이 팩토리는 올바른 객체를 검색하고 필요한 경우 재사용하는 작업의 복잡성을 캡슐화합니다.

// 플라이웨이트 클래스는 트리의 상태 일부를 포함합니다. 이러한 필드는 각 특정
// 트리에 대해 고유한 값들을 저장합니다. 예를 들어 여기에서는 트리 좌표들을 찾을
// 수 없을 것입니다. 그러나 많은 트리들이 공유하는 질감들과 색상들은 찾을 수 있을
// 것입니다. 이 데이터는 일반적으로 크기 때문에 각 트리 개체에 보관하면 많은
// 메모리를 낭비하게 됩니다. 대신 질감, 색상 및 기타 반복되는 데이터를 많은 개별
// 트리 객체들이 참조할 수 있는 별도의 객체로 추출할 수 있습니다.
class TreeType is
    field name
    field color
    field texture
    constructor TreeType(name, color, texture) { ... }
    method draw(canvas, x, y) is
        // 1. 주어진 유형, 색상 및 질감의 비트맵을 만드세요.
        // 2. 비트맵을 캔버스의 X 및 Y 좌표에 그리세요.

// 플라이웨이트 팩토리는 기존 플라이웨이트를 재사용할지 아니면 새로운 객체를
// 생성할지를 결정합니다.
class TreeFactory is
    static field treeTypes: collection of tree types
    static method getTreeType(name, color, texture) is
        type = treeTypes.find(name, color, texture)
        if (type == null)
            type = new TreeType(name, color, texture)
            treeTypes.add(type)
        return type

// 콘텍스트 객체는 트리 상태의 공유된 부분을 포함합니다. 이러한 부분들은 두 개의
// 정수로 된 좌표와 하나의 참조 필드만 참조하여 크기가 작기 때문에 하나의 앱이
// 이런 부분을 수십억 개씩 만들 수 있습니다.
class Tree is
    field x,y
    field type: TreeType
    constructor Tree(x, y, type) { ... }
    method draw(canvas) is
        type.draw(canvas, this.x, this.y)

// Tree 및 Forest 클래스들은 플라이웨이트의 클라이언트들이며 Tree 클래스를 더
// 이상 개발할 계획이 없으면 이 둘을 병합할 수 있습니다.
class Forest is
    field trees: collection of Trees

    method plantTree(x, y, name, color, texture) is
        type = TreeFactory.getTreeType(name, color, texture)
        tree = new Tree(x, y, type)
        trees.add(tree)

    method draw(canvas) is
        foreach (tree in trees) do
            tree.draw(canvas)

적용

플라이웨이트 패턴은 당신의 프로그램이 많은 수의 객체들을 지원해야 해서 사용할 수 있는 RAM을 거의 다 사용했을 때만 사용하세요.

이 패턴 적용의 혜택은 패턴을 사용하는 방법과 위치에 따라 크게 달라지며, 다음과 같은 경우에 가장 유용합니다.

  • 앱이 수많은 유사 객체들을 생성해야 할 때
  • 이것이 대상 장치에서 사용할 수 있는 모든 RAM을 소모할 때
  • 이 객체들에 여러 중복 상태들이 포함되어 있으며, 이 상태들이 추출된 후 객체 간에 공유될 수 있을 때

구현방법

  1. 플라이웨이트가 될 클래스의 필드들을 두 부분으로 나누세요.

    • 고유한 상태: 많은 객체에 걸쳐 복제된 변경되지 않는 데이터를 포함하는 필드들
    • 공유한 상태: 각 객체에 고유한 콘텍스트 데이터를 포함하는 필드들
  2. 클래스의 고유한 상태를 나타내는 필드들은 그대로 두되 변경될 수 없도록 하세요. 이 필드들은 생성자 내부에서만 초깃값들을 가져와야 합니다.

  3. 공유한 상태의 필드들을 사용하는 메서드들을 살펴보세요. 메서드에 사용된 각 필드에 대해 새 매개변수를 도입하고 필드 대신 사용하세요.

  4. 옵션으로, 플라이웨이트들의 풀을 관리하기 위한 팩토리 클래스를 생성하세요. 이 클래스는 새 플라이웨이트를 만들기 전에 기존 플라이웨이트의 존재 여부를 확인해야 합니다. 팩토리가 설치되면 고객은 팩토리를 통해서만 플라이웨이트를 요청해야 합니다. 그들은 팩토리에 플라이웨이트의 고유한 상태를 전달하여 원하는 플라이웨이트를 설명해야 합니다.

  5. 클라이언트는 플라이웨이트 객체들의 메서드들을 호출할 수 있도록 공유한 상태의 값들​(콘텍스트)​을 저장하거나 계산해야 합니다. 편의상, 플라이웨이트를 참조하는 필드와 공유한 상태는 별도의 콘텍스트 클래스로 이동할 수 있습니다.

장단점

  • 당신의 프로그램에 유사한 객체들이 많다고 가정하면 많은 RAM을 절약할 수 있습니다.
  • 누군가가 플라이웨이트 메서드를 호출할 때마다 콘텍스트 데이터의 일부를 다시 계산해야 한다면 당신은 CPU 주기 대신 RAM을 절약하고 있는 것일지도 모릅니다.
  • 코드가 복잡해지므로 새로운 팀원들은 왜 개체​(entity)​의 상태가 그런 식으로 분리되었는지 항상 궁금해할 것입니다.

다른 패턴과의 관계

  • RAM을 절약하기 위하여 복합체 패턴 트리의 공유된 잎 노드들을 플라이웨이트들로 구현할 수 있습니다.

  • 플라이웨이트는 작은 객체들을 많이 만드는 방법을 보여 주는 반면 퍼사드 패턴은 전체 하위 시스템을 나타내는 단일 객체를 만드는 방법을 보여 줍니다.

  • 만약 객체들의 공유된 상태들을 단 하나의 플라이웨이트 객체로 줄일 수 있다면 플라이웨이트싱글턴과 유사해질 수 있습니다. 그러나 이 패턴들에는 두 가지 근본적인 차이점이 있습니다:

    1. 싱글턴은 인스턴스가 하나만 있어야 합니다. 반면에 클래스는 여러 고유한 상태를 가진 여러 인스턴스를 포함할 수 있습니다.
    2. 객체는 변할 수 있습니다 (mutable). 플라이웨이트 객체들은 변할 수 없습니다 (immutable).

코드 예시

C#으로 작성된 플라이웨이트 C++로 작성된 플라이웨이트 Go로 작성된 플라이웨이트 자바로 작성된 플라이웨이트 PHP로 작성된 플라이웨이트 파이썬으로 작성된 플라이웨이트 루비로 작성된 플라이웨이트 러스트로 작성된 플라이웨이트 스위프트로 작성된 플라이웨이트 타입스크립트로 작성된 플라이웨이트