...
Flyweight Pattern
플라이웨이트 패턴(Flyweight Pattern)은 재사용 가능한 객체 인스턴스를 공유시켜 메모리 사용량을 최소화하는 구조 패턴이다. 간단히 말하면 캐시(Cache) 개념을 코드로 패턴화 한것으로 보면 되는데, 자주 변화는 속성(extrinsit)과 변하지 않는 속성(intrinsit)을 분리하고 변하지 않는 속성을 캐시하여 재사용해 메모리 사용을 줄이는 방식이다. 그래서 동일하거나 유사한 객체들 사이에 가능한 많은 데이터를 서로 공유하여 사용하도록 하여 최적화를 노리는 경량 패턴이라고도 불린다.
Flyweight 단어 의미는 Fly(가벼운) + Weight(무게)를 뜻함으로써, 복싱의 체급에서 유래되었다. 이를 자바 프로그래밍에 접목해보면 모든 객체를 일일히 인스턴스화 하지않고 재사용할수 있는 객체는 재사용함으로써 메모리를 가볍게 만든다는 의미로서 쓰인다보 보면 된다.
플라이웨이트 패턴 구조
- Flyweight : 경량 객체를 묶는 인터페이스.
- ConcreteFlyweight : 공유 가능하여 재사용되는 객체 (intrinsic state)
- UnsahredConcreteFlyweight : 공유 불가능한 객체 (extrinsic state)
- FlyweightFactory : 경량 객체를 만드는 공장 역할과 캐시 역할을 겸비하는 Flyweight 객체 관리 클래스
- GetFlyweight() 메서드는 팩토리 메서드 역할을 한다고 보면 된다.
- 만일 객체가 메모리에 존재하면 그대로 가져와 반환하고, 없다면 새로 생성해 반환한다
- Client : 클라이언트는 FlyweightFactory를 통해 Flyweight 타입의 객체를 얻어 사용한다.
intrinsic 와 extrinsic 상태
플라이웨이트 패턴에서 가장 주의 깊게 보아야 할 점이 바로 Intrinsic와 Extrinsic의 상태를 구분하는 것이다.
intrinsic란 '고유한, 본질적인' 이라는 의미를 가진다. 본질적인 상태란 인스턴스가 어떠한 상황에서도 변하지 않는 정보를 말한다. 그래서 값이 고정되어 있기에 충분히 언제 어디서 공유해도 문제가 없게 된다.
extrinsic이란 '외적인, 비본질적인' 이라는 의미를 가진다. 인스턴스를 두는 장소나 상황에 따라서 변화하는 정보를 말한다. 그래서 값이 언제 어디서 변화할지 모르기 때문에 이를 캐시해서 공유할수 는 없다.
- intrinsic한 객체 : 장소나 상황에 의존하지 않기 때문에 값이 고정되어 공유할 수 있는 객체
- extrinsic한 객체 : 장소나 상황에 의존하기 때문에 매번 값이 바뀌어 공유할 수 없는 객체
폭탄 피하기 게임을 예를 들어보자.
무수히 떨어지는 폭탄 들은 모두 하나의 객체일 것인데, 이 폭탄 객체들을 일일히 new 를 통해 인스턴스화하면 객체를 생성 할때마다 메모리를 차지하게 되어 게임이 무거워질 것이다. 떨어지는 폭탄 객체는 어차피 모두 같기 때문에 이를 일일히 생성하는건 중복이기 때문이다. 따라서 이 폭탄을 플라이웨이트로 처리함으로써 폭탄 인스턴스는 하나만 만들고 공유하여 이를 가져와 화면에 흩뿌리면 될 일이다. 그런데 여기서 플라이웨이트 적용 기준이 되는 상태를 구분할 필요가 있다.
우선 떨어지는 폭탄들이 가지고 있는 정보는 다음과 같다고 한다.
- 폭탄 모양
- 폭탄 색깔
- 폭탄 x, y 좌표 위치
여기서 폭탄 모양이나 색깔 값은 본질적인 폭탄 상태를 나타나기 때문에 캐시하여 여러 곳에 공유할 수 있다. 반면 폭탄 객체의 x, y 좌표 값은 실시간으로 변화하기 때문에 이를 캐시하여 공유할 수는 없다.
따라서 폭탄 클래스 구조를 플라이웨이트 디자인 패턴으로 표현한다면, 폭탄의 형태나 색깔 같은 고정 정보를 포함하고 있는 객체는 ConcreteFlyweight로 구현 되고, 폭탄의 좌표값 같은 정보를 변화 정보를 포함하고 있는 객체는 UnsahredConcreteFlyweight로 구현 되게 된다. 그리고 이 폭탄 객체를 FlyweightFactory가 생성하고 캐싱하고 관리를 하는 것이다.
플라이웨이트 패턴 특징
패턴 사용 시기
- 어플리케이션에 의해 생성되는 객체의 수가 많아 저장 비용이 높아질 때
- 생성된 객체가 오래도록 메모리에 상주하며 사용되는 횟수가 많을때
- 공통적인 인스턴스를 많이 생성하는 로직이 포함된 경우
- 임베디드와 같이 메모리를 최소한으로 사용해야하는 경우에 활용
패턴 장점
- 애플리케이션에서 사용하는 메모리를 줄일 수 있다.
- 프로그램 속도를 개선 할수 있다.
- new로 인스턴스화를 하면 데이터가 생성되고 메모리에 적재 되는 미량의 시간이 걸리게 된다.
- 객체를 공유하면 인스턴스를 가져오기만 하면 되기 때문에 메모리 뿐만 아니라 속도도 향상시킬 수 있게 되는 것이다.
패턴 단점
- 아무래도 코드의 복잡도가 증가한다.
예제를 통해 알아보는 Flyweight 패턴
마인크래프트 필드에 나무 심기
아래 사진과 같이 마인크래프트에 숲을 구현하기 위해 지형(Terrain)에 나무 객체들을 심으려고 한다.
이 나무(Tree) 객체에 대해 필요한 데이터는 다음과 같다고 한다.
- 나무 종류
- 메시 폴리곤 (mesh)
- 나무껍질 텍스쳐 (texture)
- 잎사귀 텍스쳐 (texture)
- 위치 매개변수
나무에도 여러가지 종류의 나무가 있으며, 나무의 형태를 구현하는 mesh와 texture 그리고 나무가 어느 지형 좌표에 심어질지에 대한 x, y 위치 매개변수가 필요하다.
클린하지 않은 문제의 코드 ❌
가장 심플한 방법은 Tree 객체를 new 생성자로 인스턴스화 하여 배치하는 것이다. 하지만 이렇게 하면 아마 메모리 부족으로 게임이 터져버릴 것이다.
하나의 나무 객체가 포함하고 있는 메시와 텍스쳐의 데이터의 크기는 만만치 않으며, 이렇게 많은 나무 객체로 이루어진 숲 전체를 하나의 화면에 담아내기에는 무리이기 때문이다.
class Memory {
public static long size = 0; // 메모리 사용량
public static void print() {
System.out.println("총 메모리 사용량 : " + Memory.size + "MB");
}
}
class Tree {
long objSize = 100; // 100MB
String type; // 나무 종류
Object mesh; // 메쉬
Object texture; // 나무 껍질 + 잎사귀 텍스쳐
// 위치 변수
double position_x;
double position_y;
public Tree(String type, Object mesh, Object 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;
}
}
// Client
class Terrain {
// 지형 타일 크기
static final int CANVAS_SIZE = 10000;
// 나무를 렌더릴
public void render(String type, Object mesh, Object texture, double position_x, double position_y) {
// 나무를 지형에 생성
Tree tree = new Tree(
type, // 나무 종류
mesh, // mesh
texture, // texture
Math.random() * CANVAS_SIZE, // position_x
Math.random() * CANVAS_SIZE // position_y
);
System.out.println("x:" + tree.position_x + " y:" + tree.position_y + " 위치에 " + type + " 나무 생성 완료");
}
}
public static void main(String[] args) {
// 지형 생성
Terrain terrain = new Terrain();
// 지형에 Oak 나무 5 그루 생성
for (int i = 0; i < 5; i++) {
terrain.render(
"Oak", // type
new Object(), // mesh
new Object(), // texture
Math.random() * Terrain.CANVAS_SIZE, // position_x
Math.random() * Terrain.CANVAS_SIZE // position_y
);
}
// 지형에 Acacia 나무 5 그루 생성
for (int i = 0; i < 5; i++) {
terrain.render(
"Acacia", // type
new Object(), // mesh
new Object(), // texture
Math.random() * Terrain.CANVAS_SIZE, // position_x
Math.random() * Terrain.CANVAS_SIZE // position_y
);
}
// 지형에 Jungle 나무 5 그루 생성
for (int i = 0; i < 5; i++) {
terrain.render(
"Jungle", // type
new Object(), // mesh
new Object(), // texture
Math.random() * Terrain.CANVAS_SIZE, // position_x
Math.random() * Terrain.CANVAS_SIZE // position_y
);
}
// 총 메모리 사용률 출력
Memory.print();
}
각각 Oak 나무, Acacia 나무, Jungle 나무 5개씩 100MB를 곱하면 총 1500 MB 메모리가 사용되게 된다.
플라이웨이트 패턴을 적용한 코드 ✔️
최적화의 핵심은 나무가 수백 그루 넘게 있는다 해도 대부분 비슷하게 보인다는 것이다. 즉, 나무를 생성하는데 사용된 mesh와 texture를 재사용하여 표현해도 어차피 같은 나무이니 문제가 없는 것이다.
따라서 공통으로 사용되는 모델 데이터와 실시간으로 변하는 위치 매개변수를 분리하여 객체를 구성해주면, 지형에서 나무를 구현할때 나무 모델 인스턴스 하나를 공유받고 위치 매개변수만 다르게 설정해주면 메모리 사용량을 절반 이상을 줄일 수 있을 것이다.
1. intrinsic 객체와 extrinsic 객체 쪼개기
마인크래프트 게임 내에서 똑같은 메시와 나무껍질 텍스쳐를 일일히 여러번 메모리에 올릴 이유가 없기 때문에 공유되는 나무 모델 객체를 기존 Tree 클래스에서 따로 빼준다. 그러면 아래와 같이 TreeModel 클래스는 ConcreteFlyweight가 되고 좌표값을 가지고 있는 기존 Tree 클래스는 UnsahredConcreteFlyweight 가 된다.
// ConcreteFlyweight - 플라이웨이트 객체는 불변성을 가져야한다. 변경되면 모든 것에 영향을 주기 때문이다.
final class TreeModel {
// 메시, 텍스쳐 총 사이즈
long objSize = 90; // 90MB
String type; // 나무 종류
Object mesh; // 메쉬
Object texture; // 나무 껍질 + 잎사귀 텍스쳐
public TreeModel(String type, Object mesh, Object texture) {
this.type = type;
this.mesh = mesh;
this.texture = texture;
// 나무 객체를 생성하여 메모리에 적재했으니 메모리 사용 크기 증가
Memory.size += this.objSize;
}
}
// UnsahredConcreteFlyweight
class Tree {
// 죄표값과 나무 모델 참조 객체 크기를 합친 사이즈
long objSize = 10; // 10MB
// 위치 변수
double position_x;
double position_y;
// 나무 모델
TreeModel model;
public Tree(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;
}
}
이때 Tree 클래스와 TreeModel 간의 관계를 맺어주어야 하는데, 상속을 통해 해주어도 되고, 이 예제에서는 합성(Composition)을 통해 맺어주었다. 그리고 ConcreteFlyweight인 TreeModel 클래스를 final화 시켜 불변 객체로 만들어준다. 나무 모델은 중간에 메시와 텍스쳐가 변경될 일이 없기 때문이다.
2. Flyweight 팩토리 만들기
나무 모델 객체에 플라이웨이트를 적용하였으니 이를 생성하고 관리하는 FlyweightFactory 클래스를 만든다.
플라이웨이트 팩토리의 핵심은 다음과 같다.
- Flyweight Pool : HashMap 컬렉션을 통해 키(key) 와 나무 모델 객체를 저장하는 캐시 저장소 역할
- getInstance 메서드 : Pool에 가져오고자 하는 객체가 있는지 검사를 하여 있으면 그대로 반환, 없으면 새로 생성
// FlyweightFactory
class TreeModelFactory {
// Flyweight Pool - TreeModel 객체들을 Map으로 등록하여 캐싱
private static final Map<String, TreeModel> cache = new HashMap<>(); // static final 이라 Thread-Safe 함
// static factory method
public static TreeModel getInstance(String key) {
// 만약 캐시 되어 있다면
if(cache.containsKey(key)) {
return cache.get(key); // 그대로 가져와 반환
} else {
// 캐시 되어있지 않으면 나무 모델 객체를 새로 생성하고 반환
TreeModel model = new TreeModel(
key,
new Object(),
new Object()
);
System.out.println("-- 나무 모델 객체 새로 생성 완료 --");
// 캐시에 적재
cache.put(key, model);
return model;
}
}
}
3. Client 최적화 하기
기존 Terrain 클래스의 나무를 생성하는 render() 메서드의 내부 로직은 단순히 사용자로부터 매개변수를 받아 그대로 tree 객체를 생성할 뿐이었다. 그러나 기존 Tree 객체를 따로 TreeModel로 나누로, TreeModelFactory 까지 생성하였으니 이제 이들을 이용하는 클라이언트의 코드를 최적화 작업을 진행해 주어야 한다.
Flyweight를 이용한 최적화 작업은 다음과 같다.
- 먼저 TreeModel 에서 공유되고 있는 나무 모델 객체를 가져온다. (없다면 새로 생성)
- 가져온 나무 모델과 좌표값을 이용해 나무 객체를 새로 생성한다.
단순히 2단계로 구성된것 뿐이지만, 공유 객체를 가져와 사용함으로써 쓸데없는 mesh와 texture 낭비를 방지한 것이다.
// Client
class Terrain {
// 지형 타일 크기
static final int CANVAS_SIZE = 10000;
// 나무를 렌더릴
public void render(String type, double position_x, double position_y) {
// 1. 캐시 되어 있는 나무 모델 객체 가져오기
TreeModel model = TreeModelFactory.getInstance(type);
// 2. 재사용한 나무 모델 객체와 변화하는 속성인 좌표값으로 나무 생성
Tree tree = new Tree(model, position_x, position_y);
System.out.println("x:" + tree.position_x + " y:" + tree.position_y + " 위치에 " + type + " 나무 생성 완료");
}
}
public static void main(String[] args) {
// 지형 생성
Terrain terrain = new Terrain();
// 지형에 Oak 나무 5 그루 생성
for (int i = 0; i < 5; i++) {
terrain.render(
"Oak", // type
Math.random() * Terrain.CANVAS_SIZE, // position_x
Math.random() * Terrain.CANVAS_SIZE // position_y
);
}
// 지형에 Acacia 나무 5 그루 생성
for (int i = 0; i < 5; i++) {
terrain.render(
"Acacia", // type
Math.random() * Terrain.CANVAS_SIZE, // position_x
Math.random() * Terrain.CANVAS_SIZE // position_y
);
}
// 지형에 Jungle 나무 5 그루 생성
for (int i = 0; i < 5; i++) {
terrain.render(
"Jungle", // type
Math.random() * Terrain.CANVAS_SIZE, // position_x
Math.random() * Terrain.CANVAS_SIZE // position_y
);
}
// 총 메모리 사용률 출력
Memory.print();
}
패턴 적용 전에는 1500MB 메모리 사용량이 들었지만, 패턴 적용 후에는 메모리 사용량이 확연히 줄어들었음을 확인할 수 있다. 이전에는 통짜로 나무 객체를 생성함에 비해, 나무 모델을 따로 플라이웨이트로 분리함으로써 중복된 mesh, texture 사용을 공유시켜 메모리를 아낀 것이다.
이처럼 공유할수 있는 intrinsic(본질적) 상태의 데이터를 분간하여 캐싱함으로써 프로그램을 최적화를 추구하는것이 훌륭한 프로그래머의 자질이라고 할수 있을 것이다.
Garbage Collection 처리 주의사항 🗑️
위에서 구현한 TreeModelFactory에서는 HashMap을 이용해 TreeModel의 인스턴스를 캐싱하여 관리하고 있다. 이와 같이 '인스턴스를 관리' 하는 기능을 자바 프로그래밍에서 구현하여 사용할 때에는 반드시 '관리되고 있는 인스턴스는 GC(Garbage Collection) 처리되지 않는다' 라는 점을 주의해야 한다.
즉, 나무를 모두 렌더링을 완료하여 더이상 나무를 생성할 일이 없다라면, 반드시 TreeModelFactory에 잔존해있는 Flyweight Pool 을 비워줄 필요가 있는 것이다. 그래야 인스턴스에 대한 참조를 잃은 TreeModel 인스턴스들이 GC에 의해 메모리 청소가 되게 된다. 그렇지 않으면 더이상 나무를 생성할 일이 없는데도 TreeModel 데이터가 메모리에 쓸데없이 잔존하게 된다. 메모리 최적화를 위해 플라이웨이트 패턴을 썼는데 마지막 맺음에서 이렇게 허점이 생기면 뒤가 쓰린 법이다.
실무에서 찾아보는 Flyweight 패턴
Java
String Constant Pool
- String Constant Pool 개념이 바로 Flyweight Pool 개념이다.
- 자바는 String 데이터에 대해 별도로 string constant pool 영역에 적재한다.
- 그래서 같은 문자열 데이터 다시 사용될때 pool을 검사해 있다면 이를 공유한다.
- 만일 pool에 없다면 새로 메모리를 할당하여 pool에 등록한 후 재사용한다.
- 즉, String 클래스는 Flyweight 패턴을 통해 리터럴 문자열 데이터에 대한 캐싱을 하고 있는 것이다.
- 또한 String 클래스는 불변(immutable) 객체 특성을 가지고 있다.
// 문자열 리터럴은 String Pool에 저장되는 Flyweight 패턴을 이용한다.
String str1 = "Hello";
String str2 = "Hello";
// 두 주소값이 같다는 말은 메모리에 하나의 데이터만 적재되고 이를 공유하고 있다는 반증
System.out.println(str1 == str2); // true
// new 연산자를 이용한 방식은 Flyweight 패턴을 적용하지 않는다.
String str3 = new String("Hello");
String str4 = new String("Hello");
// 서로 다른 힙 메모리에 저장된 데이터이니 두 주솟값은 다르다
System.out.println(str3 == str4); // false
Integer의 valueOf()
- 자바의 Interger 클래스의
valueOf()메서드는 정적 팩토리 메서드로서,valueOf()메서드를 통해 값을 할당하면 Flyweight 패턴을 통해 캐시되게 된다. valueOf()메소드가 호출 되었을 때 요청된 Integer 값이 -128 ~ 127 범위의 정수라면 캐싱을 이용하도록 설계 되어있다.- Integer 뿐만 아니라 Double이나 Boolean 같은 Wrapper 클래스들도 마찬가지로 적용된다.
// valueOf 정적 팩토리 메서드로 수를 등록하면 캐시됨
// auto boxing으로 Integer i1 = 100; 으로 선언하여도 똑같음
Integer i1 = Integer.valueOf(100);
Integer i2 = Integer.valueOf(100);
System.out.println(i1 == i2); // true
// 생성자로 수를 등록하면 캐시 안됨
Integer i3 = new Integer(100);
Integer i4 = new Integer(100);
System.out.println(i3 == i4); // false
// -128 ~ 127 는 캐시 됨
Integer i1 = Integer.valueOf(100);
Integer i2 = Integer.valueOf(100);
System.out.println(i1 == i2); // true
// -128 ~ 127 를 넘어선 수는 캐시 안됨
Integer i3 = Integer.valueOf(1000);
Integer i4 = Integer.valueOf(1000);
System.out.println(i3 == i4); // false
# 참고자료
코딩으로 학습하는 GoF의 디자인 패턴 - 백기선
https://refactoring.guru/design-patterns/flyweight
https://www.hanbit.co.kr/channel/series/series_view.html?cms_code=CMS3043948395&hcs_idx=3
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.