[Design Pattern] 플라이웨이트 패턴

olwooz·2023년 2월 20일

Design Pattern

목록 보기
12/22
모든 데이터를 각각의 객체에 보관하는 대신 여러 객체들의 상태 중 공통된 부분을 공유해 
RAM에 더 많은 객체들을 넣을 수 있게 해주는 구조 패턴

문제

현실적인 입자 시스템을 가진 FPS 게임 가정

  • 총알, 미사일, 파편 등이 맵 전반에 날아다님

→ RAM 부족으로 인해 게임이 멈추는 현상 발생

입자 시스템에 문제 존재

  • 총알, 미사일, 파편 등 각각의 입자가 많은 데이터를 포함한 독립된 객체로 표현되고 있음

해결책

Particle 클래스의 colorsprite(입자를 표현하는 이미지) 필드가 다른 필드에 비해 많은 메모리를 사용하고 있고, 이 필드들은 모든 입자들에 거의 동일한 데이터를 저장하고 있음 (모든 총알은 동일한 color, sprite를 가짐)

입자의 좌표, 벡터, 속도 등 다른 상태들은 각 입자마다 고유한 값을 가짐 → 이 값들은 계속 변화하지만 color와 sprite는 일정함

고유 상태 - 객체의 상수 데이터, 다른 객체들이 읽기만 하고 변경하지 않음

공유 상태 - 고유 상태를 제외한 나머지, 다른 객체들에 의해 외부에서 변경됨

플라이웨이트 패턴

  • 공유 상태를 객체 내부에 저장하지 않고 해당 상태에 의존하는 특정 메서드들에게 전달
  • 객체에는 고유 상태만 남아, 다른 맥락에서 재사용할 수 있도록 함

→ 공유 상태보다 훨씬 변형이 적은 고유 상태만 차이가 나기 때문에 필요한 객체의 수가 줄어듦

Particle 클래스에서 공유 상태를 추출하면 총알, 미사일, 파편 총 3개의 객체만으로 게임의 모든 입자들을 표현할 수 있음

이렇게 고유 상태만 저장하는 객체를 플라이웨이트 라고 함

공유 상태 저장소

공유 상태는 주로 플라이웨이트 패턴을 적용한 객체들을 모은 컨테이너 객체에 저장됨

위 예제에서는 모든 입자들을 저장하는 Game 객체

공유 상태를 해당 클래스로 이동하려면 각 입자의 좌표, 벡터, 속도 등을 저장하는 여러 개의 배열 필드에 추가로 해당 입자를 표현하는 플라이웨이트에 대한 참조를 저장하는 배열까지 필요

동일한 인덱스로 한 입자의 모든 데이터에 접근하기 위해 배열들이 모두 동기화되어 있어야 함

더 좋은 해결책은 별도의 컨텍스트 클래스를 만들어 공유 상태와 플라이웨이트 객체에 대한 참조를 저장하는 것 → 컨테이너 클래스에서 단 하나의 배열만 필요하게 됨

하나의 커다란 플라이웨이트 객체를 몇천 개의 작은 컨텍스트 객체들이 재사용할 수 있게 되면서 메모리 절약

플라이웨이트와 불변성

같은 플라이웨이트 객체는 다른 맥락에서 사용될 수 있기 때문에 상태가 변경되지 않도록 해야 함

플라이웨이트는 생성자 파라미터를 통해 단 한 번만 상태를 초기화해야 하고, setter나 public 필드들을 다른 객체에 노출하면 안 됨

플라이웨이트 팩토리

다양한 플라이웨이트에 더 편리하게 접근하기 위해, 플라이웨이트 객체들의 풀을 관리하는 팩토리 메서드를 생성할 수도 있음

클라이언트가 원하는 플라이웨이트의 고유 상태를 받아, 해당 상태에 부합하는 기존 플라이웨이트 객체를 찾아 반환, 찾지 못한다면 새로운 플라이웨이트를 생성해 풀에 추가

해당 메서드는 플라이웨이트 컨테이너에 위치할 수도 있고, 새로운 팩토리 클래스를 만들 수도 있고, 팩토리 메서드를 정적으로 만들어 실제 플라이웨이트 클래스에 넣을 수도 있음

구조

1. 플라이웨이트 패턴은 최적화에 불과
   - 패턴을 적용하기 전에 프로그램이 메모리에 대량의 비슷한 객체가 
     동시에 존재함으로 인해 발생하는 RAM 소모 문제를 겪고 있는지 확인
   - 해당 문제가 다른 방법으로는 해결될 수 없다는 걸 확인
   
2. 플라이웨이트 클래스 - 여러 객체에서 공유될 수 있는 기존 객체의 상태의 한 부분을 포함
                    - 같은 플라이웨이트 객체는 다양한 컨텍스트에서 사용될 수 있음
    
3. 컨텍스트 클래스 - 모든 원본 객체에서 고유한 공유 상태 포함
                - 컨텍스트가 플라이웨이트 객체와 짝지어지면 원본 객체의 전체 상태 표현
    
4. 원본 객체의 행동은 보통 플라이웨이트 클래스에 남음
   - 플라이웨이트의 메서드 호출자는 메서드 파라미터에 공유 상태의 적절한 부분을 전달해줘야 함
   - 행동이 컨텍스트 클래스로 이동한다면 컨텍스트 클래스는 연결된 플라이웨이트를 단순 데이터 객체로 사용
    
5. 클라이언트 - 플라이웨이트의 공유 상태를 계산하거나 저장
   - 클라이언트의 관점에서 플라이웨이트는 컨텍스트 데이터를 
     플라이웨이트 메서드의 파라미터로 전달함으로써 런타임에 설정할 수 있는 템플릿 객체
    
6. 플라이웨이트 팩토리 - 이미 존재하는 플라이웨이트 풀 관리
   - 팩토리가 있으면 클라이언트는 플라이웨이트를 직접 생성하지 않고 
     팩토리에게 원하는 플라이웨이트의 고유 상태의 일부분을 전달해 결과를 반환받음

적용

프로그램이 RAM을 거의 다 사용할 만큼 많은 양의 객체들을 지원해야 하는 경우

- 플라이웨이트 패턴 적용의 이점은 어디서 어떻게 사용되냐에 따라 크게 달라지며, 다음 상황에서 가장 유용함:
    1. 애플리케이션이 많은 양의 비슷한 객체들을 생성하고
    2. 디바이스의 가용 RAM을 소진하며
    3. 해당 객체가 추출되어 여러 객체들끼리 공유될 수 있는 중복 상태를 가지고 있을 때

구현방법

1. 플라이웨이트가 될 클래스의 필드를 고유 상태와 공유 상태로 나눔

2. 고유 상태를 표현하는 필드들을 클래스 내부에 둠
   - 변경될 수 없어야 함
   - 생성자 내부에서만 초기값을 받아야 함
    
3. 공유 상태 필드를 사용하는 메서드들을 찾아, 메서드에 사용된 각 필드마다 새로운 파라미터를 도입해 필드 대신 사용

4. (optional) 팩토리 클래스를 생성해 플라이웨이트 풀 관리 
   - 새 플라이웨이트 생성 전에 기존 플라이웨이트 체크해야 함
   - 팩토리가 존재하면 클라이언트는 팩토리를 통해서만 플라이웨이트를 요청해야 함
    
5. 클라이언트가 플라이웨이트 객체에 메서드를 호출하려면 공유 상태의 값들을 저장하거나 계산해야 함
   - 편의성을 위해 공유 상태와 플라이웨이트를 참조하는 필드를 별도의 컨텍스트 클래스로 이동할 수 있음

장단점

장점

- RAM 절약

단점

- 플라이웨이트 메서드가 호출될 때마다 일부 컨텍스트 데이터가 다시 계산되어야 하면 
  CPU 사이클과 RAM을 교환하는 것일 수도 있음
- 코드가 훨씬 더 복잡해짐

다른 패턴과의 관계

- 컴포지트 트리의 공유된 리프 노드들을 플라이웨이트 패턴으로 구현해 RAM 절약 가능

- 플라이웨이트 패턴 - 작은 객체를 여러 개 만드는 방법, 
  퍼사드 패턴 - 하위 시스템 전체를 대표하는 하나의 객체를 만드는 방법
  
- 객체들의 모든 공통 상태를 하나의 플라이웨이트 객체로 줄이면 
  플라이웨이트 패턴과 싱글턴 패턴이 비슷해지만 두 가지 근본적인 차이점 존재
   1. 싱글턴 인스턴스는 하나만 있어야 하지만,
      플라이웨이트 클래스는 고유 상태가 다른 여러 개의 인스턴스를 가질 수 있음
   2. 싱글턴 객체는 변경 가능하지만 플라이웨이트 객체는 변경 불가

TypeScript 예제

/**
 * The Flyweight stores a common portion of the state (also called intrinsic
 * state) that belongs to multiple real business entities. The Flyweight accepts
 * the rest of the state (extrinsic state, unique for each entity) via its
 * method parameters.
 */
class Flyweight {
    private sharedState: any;

    constructor(sharedState: any) {
        this.sharedState = sharedState;
    }

    public operation(uniqueState): void {
        const s = JSON.stringify(this.sharedState);
        const u = JSON.stringify(uniqueState);
        console.log(`Flyweight: Displaying shared (${s}) and unique (${u}) state.`);
    }
}

/**
 * The Flyweight Factory creates and manages the Flyweight objects. It ensures
 * that flyweights are shared correctly. When the client requests a flyweight,
 * the factory either returns an existing instance or creates a new one, if it
 * doesn't exist yet.
 */
class FlyweightFactory {
    private flyweights: {[key: string]: Flyweight} = <any>{};

    constructor(initialFlyweights: string[][]) {
        for (const state of initialFlyweights) {
            this.flyweights[this.getKey(state)] = new Flyweight(state);
        }
    }

    /**
     * Returns a Flyweight's string hash for a given state.
     */
    private getKey(state: string[]): string {
        return state.join('_');
    }

    /**
     * Returns an existing Flyweight with a given state or creates a new one.
     */
    public getFlyweight(sharedState: string[]): Flyweight {
        const key = this.getKey(sharedState);

        if (!(key in this.flyweights)) {
            console.log('FlyweightFactory: Can\'t find a flyweight, creating new one.');
            this.flyweights[key] = new Flyweight(sharedState);
        } else {
            console.log('FlyweightFactory: Reusing existing flyweight.');
        }

        return this.flyweights[key];
    }

    public listFlyweights(): void {
        const count = Object.keys(this.flyweights).length;
        console.log(`\nFlyweightFactory: I have ${count} flyweights:`);
        for (const key in this.flyweights) {
            console.log(key);
        }
    }
}

/**
 * The client code usually creates a bunch of pre-populated flyweights in the
 * initialization stage of the application.
 */
const factory = new FlyweightFactory([
    ['Chevrolet', 'Camaro2018', 'pink'],
    ['Mercedes Benz', 'C300', 'black'],
    ['Mercedes Benz', 'C500', 'red'],
    ['BMW', 'M5', 'red'],
    ['BMW', 'X6', 'white'],
    // ...
]);
factory.listFlyweights();

// ...

function addCarToPoliceDatabase(
    ff: FlyweightFactory, plates: string, owner: string,
    brand: string, model: string, color: string,
) {
    console.log('\nClient: Adding a car to database.');
    const flyweight = ff.getFlyweight([brand, model, color]);

    // The client code either stores or calculates extrinsic state and passes it
    // to the flyweight's methods.
    flyweight.operation([plates, owner]);
}

addCarToPoliceDatabase(factory, 'CL234IR', 'James Doe', 'BMW', 'M5', 'red');

addCarToPoliceDatabase(factory, 'CL234IR', 'James Doe', 'BMW', 'X1', 'red');

factory.listFlyweights();
// Output.txt

FlyweightFactory: I have 5 flyweights:
Chevrolet_Camaro2018_pink
Mercedes Benz_C300_black
Mercedes Benz_C500_red
BMW_M5_red
BMW_X6_white

Client: Adding a car to database.
FlyweightFactory: Reusing existing flyweight.
Flyweight: Displaying shared (["BMW","M5","red"]) and unique (["CL234IR","James Doe"]) state.

Client: Adding a car to database.
FlyweightFactory: Can't find a flyweight, creating new one.
Flyweight: Displaying shared (["BMW","X1","red"]) and unique (["CL234IR","James Doe"]) state.

FlyweightFactory: I have 6 flyweights:
Chevrolet_Camaro2018_pink
Mercedes Benz_C300_black
Mercedes Benz_C500_red
BMW_M5_red
BMW_X6_white
BMW_X1_red

참고 자료: Refactoring.guru

0개의 댓글