구조 패턴 - 플라이급(Flyweight)
- 참조: https://refactoring.guru/design-patterns/flyweight
- 플라이급(Flyweight)
GoF의 디자인 패턴에서는 공유를 통해 많은 수의 소립 객체들을 효과적으로 지원한다고 되어있다. 대략적으로 의미는 알것 같기도 한데 뭔가 확실하게 와닿지는 않는다.
개인적으로는 refactoring.guru의 설명이 더 명료하고 와닿는다고 생각한다. refactoring.guru 에서는 "플라이급 패턴은 각 객체에서 데이터를 다루는 대신 객체들의 공통되는 상태를 공유하여 RAM을 확보하는 디자인 패턴이다." 라고 정의하고 있다.
- 시나리오
플레이어가 맵을 돌아다니면서 서로 쏘는 게임을 만든다고 가정하자. 많은 양의 총알, 미사일, 파편들이 온 맵에 흩뿌려질것이다. 이 게임을 열심히 구현해서 친구들에게 설치파일을 공유하였다. 내 컴퓨터에서 테스트할때까지만 해도 별 문제가 없었는데, 친구 들 컴퓨터에서 게임을 실행하자 얼마 지나지 않아서 갑자기 게임이 죽는다.
한참 동안 디버깅을 하다가 원인을 찾아 냈는데, 그 원인은 RAM 부족이였다. RAM이 부족한 이유는 총알, 미사일, 파편 조각들 하나하나를 객체들로 구현하였는데, 객체들이 점점 많아지면서 RAM 의 남은 공간이 부족해졌기 때문이다. 아래 다이어그램을 보면서 생각해보자.
- 문제점 분석과 해결책
RAM cost 를 자세히 살펴보면, sprite와 color가 많은 메모리를 차지하고 있는것을 알 수 있다. 실제로도 프로그래밍을 하다보면 text 보다는 그림이 많은 공간을 차지하는 경우가 많다. 더 최악인건 엄청나게 많은 particle(총알, 미사일, 파편)들이 있지만, color나 sprite는 대부분 중복된다는것이다.
그 외에 coords, vector, speed 와 같은 속성들은 각 particle 객체마다 고유하며, 결국 시간이 지나면서 변한다. 이런 속성들은 particle 이 존재하는한 끊임없이 변하지만, color나 sprite 는 각 particle 마다 상수(변하지 않는다는 의미로 쓰였다.)로 남아있다.
다른 패턴들은 참여자에 대한 용어만 익히면 되지만 플라이급 패턴은 본질적(intrinsic)과 부가적(extrinsic)이라는 용어를 알아야 한다. 위의 예제에서 color나 sprite 같이 각 particle 마다 변하지 않는 속성들을 본질적(intrinsic)이라고 하며, 나머지 객체의 변하는 상태들은 부가적(extrinsic) 이라고 한다.
플라이급 패턴은 객체 안에 부가적인 상태를 저장하지 않는다. 대신 이 부가적인 상태들을 특정 메소드들에게 전달한다. 오직 본질적인 상태들만 객체안에 남아있게 해서, 다른 문맥에서도 이를 사용할 수 있게 한다. 본질적인 상태의 종류의 개수는 객체안에 부가적인 상태들을 포함할 때 더 적기 때문에, 소비하는 용량도 더 작아진다.
- 부가적(Extrinsic)인 상태의 저장 공간
개념도 알겠고 본질적(Intrinsic)인 상태들의 중복을 없앰으로써 저장공간도 줄인다는건 알겠는데, 그렇다면 부가적인 상태들의 데이터도 결국에는 어딘가에 저장되어있지 않을까? 많은 경우에 이런 부가적인 상태들은 패턴을 적용하기 전에 컨테이너 객체로 옮겨 간다.
위에서 언급한 시나리오의 경우를 예로들면 Game 객체가 모든 particle 들을 particles 필드에 저장한다. 아래 다이어그램에서 부가적인 상태들을(coords, vectors, speeds) 컨테이너로 옮기기 위해서는 각 particle의 coord, vector, spped 를 저장하는 배열 필드들을 만들어야 한다. (아래 그림에서 Game1에서 Game2 로 변환.)
또한 particle들이 참조하는 플라이급들에 대한 배열도 필요하다. (Game2 에서 references 참조 필드 추가)
배열 필드가 너무 많으니 조잡한감이 있다. 그래서 플라이급(flyweight) 객체를 참조할 때처럼 부가적인 상태들을(coords, vectors, speeds) 저장할 수 있는 별도의 문맥 클래스를 만들면 깔끔하다. 이렇게 하면 컨테이너 클래스에서 배열 1개만 사용하면 된다.(Game3 클래스에서 MovingParticles)
Game3 의 클래스를 보고 이런 생각을 할 수도 있다. 기껏 메모리를 많이 먹는 객체들을 없앴는데 다시 MovingParticles로 만들면 저장공간을 다시 많이 소비하지 않을까 하는 의문인것이다. 하지만 메모리를 주로 소비하던 필드들은 플라이급 객체로 옮겨진 상태이기 때문에, color와 sprite가 중복으로 복사되어있던 문제점의 초기상태와는 다르다.
- 플라이급과 불변성
하나의 플라이급 객체는 여러곳에서 사용될 수 있기 때문에, 해당 객체가 불변성(변경할 수 없는 객체)임을 보증해야 한다. 그래서 플라이급 객체들은 생성자나 초기화 과정을 거치고 나면 setter나 public 접근제어자 필드를 제공하면 안된다.
- 플라이급 팩토리
플라이급을 더 편리하게 사용하기 위해서 플라이급 객체들의 풀을 관리하는 팩토리 메소드를 사용할수도 있다. 본질적(intrinsic)인 상태를 받아서 이 상태가 존재하면 반환하고 그렇지 않으면 새로운 플라이급 객체를 생성한 후 풀에 추가한다.
- 시나리오
게임에서 숲을 구현한다고 생각해보자. 숲안에는 수많은 나무가 있다. 나무는 소나무, 버드나무, 참나무 등 많은 종류의 나무가 있다. 나무는 각각 자신의 고유한 위치가 있으며, 색, 질감등이 있다.
위 다이어그램은 플라이급을 적용하여 UML로 나타낸것이다. 우선 플라이급을 적용하기 위해 가장 중요한것은 적용할 가치가 있느냐다. Forest 에서 수많은 Tree의 객체가 있을 가능성이 크고, 나무의 종류가 중첩될 가능성이 크므로 적용할 가치가 충분하다.
적용하기로 결정했다면 본질적인 상태와 부가적인 상태를 구별해야 한다. Tree에서 본질적인 상태란 나무의 종류인 TreeType 으로(name, color, texture), 플라이급을 적용하는 클래스이며, 부가적인 상태는 Tree의 위치가 된다.(x,y)
각 클래스간의 관계를 살펴보자.
- TreeFactory와 Forest: Forest 에서 plantTree 로 나무를 생성할 때에는 TreeFactory 를 이용하여 TreeType 을 정하고, 이를 기반으로 Tree 를 생성한다. Forest는 TreeFactory 와 연관(Association)관계이기 때문에 실선화살표로 표시한다.
- Forest와 Tree: Forest는 수많은 Tree의 객체들로 구성되어있다. Forest는 Tree의 집합들을 참조하는 trees 속성을 갖고있기 때문에 Aggregation을 나타내는 다이아몬드 표시가 붙어 있다.
- TreeFactory와 TreeType: TreeFactory는 TreeType의 집합을 참조하는 treeTypes 속성을 갖고 있기 때문에 Aggregation을 나타내는 다이아몬드 표시가 붙어있다.
- TreeType과 Tree: Tree는 부가적인 상태는 x,y 이고, 나무의 종류를 나타내는 name, color, texture에 대해 플라이급을 적용한 TreeType을 단일 속성으로 참조하고 있기 때문에, 연관(Association)인 실선화살표로 표시되었다.
- Java 예제
Tree 클래스부터 만들어 보자. 자신의 위치를 표현하는 부가적인 상태인 x, y와 더불어 본질적인 상태를 참조하는 TreeType을 갖고 있다.
public class Tree {
private int x;
private int y;
private TreeType type;
public Tree(int x, int y, TreeType type) {
super();
this.x = x;
this.y = y;
this.type = type;
}
@Override
public String toString() {
return "Tree [x=" + x + ", y=" + y + ", type=" + type + "]";
}
}
본질적인 상태를 나타내는 TreeType은 아래와 같다. name, color, texture를 속성으로 갖는다. 플라이급 객체인 TreeType 에서 기존에 생성된 TreeType 인지 아닌지를 판단하기 위해서 equals 메소드를 추가하였다.
여기까지만 구현하면 equals로 기존 객체를 검사할 때 List를 검색하면 선형시간인 O(N) 이 걸린다. HashMap을 사용하여 O(1) 으로 접근을 하려면 hashCode도 재정의해야 한다. equals를 재정의했다면 hashCode도 재정의해주는것이 좋다. 이에 대한 자세한 내용은 effective java 의 equals를 정의했다면 hashCode를 재정의하라는 규칙을 참조하자.
public class TreeType {
private String name;
private String color;
private String texture;
public TreeType(String name, String color, String texture) {
super();
this.name = name;
this.color = color;
this.texture = texture;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((color == null) ? 0 : color.hashCode());
result = prime * result + ((name == null) ? 0 : name.hashCode());
result = prime * result + ((texture == null) ? 0 : texture.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
TreeType other = (TreeType) obj;
if (color == null) {
if (other.color != null)
return false;
} else if (!color.equals(other.color))
return false;
if (name == null) {
if (other.name != null)
return false;
} else if (!name.equals(other.name))
return false;
if (texture == null) {
if (other.texture != null)
return false;
} else if (!texture.equals(other.texture))
return false;
return true;
}
@Override
public String toString() {
return "TreeType [name=" + name + ", color=" + color + ", texture=" + texture + "]";
}
}
TreeType의 풀을 관리하는 Factory 메소드를 적용한 TreeFactory는 아래와 같다. TreeFactory 의 객체는 단일이면 충분하기 때문에 싱글톤을 적용하였다. getTreeType 메소드에서는 기존에 저장된 TreeType 이면 그대로 반환하며, 찾지 못하면 TreeType을 추가한다.
public class TreeFactory {
private static TreeFactory instance = null;
private static Map<Integer, TreeType> treeTypes = new HashMap<>();
private TreeFactory() {}
public static TreeFactory getInstance() {
if(instance == null) {
instance = new TreeFactory();
}
return instance;
}
public TreeType getTreeType(String name, String color, String texture) {
TreeType newTreeType = new TreeType(name, color, texture);
if(treeTypes.containsKey(newTreeType.hashCode())) {
TreeType treeType = treeTypes.get(newTreeType.hashCode());
System.out.println("searched treeType in flyweight object: " + treeType);
return treeType;
}
System.out.println("create new treeType: " + newTreeType);
treeTypes.put(newTreeType.hashCode(), newTreeType);
return newTreeType;
}
}
TreeFactory와 Tree를 참조하는 Forest는 아래와 같다.
public class Forest {
private List<Tree> trees;
private TreeFactory treeFactory;
public Forest() {
trees = new ArrayList<>();
treeFactory = TreeFactory.getInstance();
}
public Tree plantTree(int x, int y, String name, String color, String texture) {
TreeType treeType = treeFactory.getTreeType(name, color, texture);
Tree tree = new Tree(x, y, treeType);
trees.add(tree);
return tree;
}
}
테스트 메소드는 아래와 같이 작성하였다. 같은 종류의 name, color, texture에 대한 나무를 생성하는 테스트를 해보면 처음 요청하는 TreeType은 새로 생성하며, 기존에 생성된 TreeType을 참조할 시 새로생성하지 않음을 알 수 있다.
public static void main(String[] args) {
Forest forest = new Forest();
forest.plantTree(0, 0, "소나무", "red", "hard");
forest.plantTree(0, 1, "소나무", "red", "hard");
forest.plantTree(1, 0, "소나무", "brown", "hard");
forest.plantTree(1, 1, "소나무", "brown", "hard");
forest.plantTree(0, 0, "버드나무", "green", "soft");
forest.plantTree(0, 1, "버드나무", "green", "soft");
forest.plantTree(1, 0, "버드나무", "red", "soft");
forest.plantTree(1, 1, "버드나무", "red", "soft");
}
create new treeType: TreeType [name=소나무, color=red, texture=hard]
searched treeType in flyweight object: TreeType [name=소나무, color=red, texture=hard]
create new treeType: TreeType [name=소나무, color=brown, texture=hard]
searched treeType in flyweight object: TreeType [name=소나무, color=brown, texture=hard]
create new treeType: TreeType [name=버드나무, color=green, texture=soft]
searched treeType in flyweight object: TreeType [name=버드나무, color=green, texture=soft]
create new treeType: TreeType [name=버드나무, color=red, texture=soft]
searched treeType in flyweight object: TreeType [name=버드나무, color=red, texture=soft]