Flyweight 패턴은 Cache로도 알려져 있습니다.
Cache가 더욱 친숙합니다.
1. 목적
객체들의 모든 데이터를 유지하는 대신에 여러 객체들 간에 공통된 상태 부분을 공유함으로써 사용 가능한 RAM 공간에 더 많은 객체를 넣을 수 있게 하는 패턴입니다.
알면서도 모르겠습니다.
2. 문제 상황
플레이어들이 맵을 돌아다니며 싸우는 게임을 만든다고 가정합니다.
방대한 양의 총알, 미사일, 파편들이 맵 전체를 날아다니는 경험을 주기 위해 현실적인 입자 시스템(Particle System)을 만들고자 합니다.
게임을 완성시키고, 테스트를 위해 친구에게 게임을 전달했습니다.
하지만 여러분의 컴퓨터에서 잘 실행되는 게임이 친구의 컴퓨터에서는 몇 분 플레이한 후 계속 충돌하게 됩니다.
원인을 알아보니 친구의 컴퓨터는 여러분의 컴퓨터보다 RAM 용량이 충분하지 못하다는 사실을 알게 됩니다.
문제 상황을 더욱 깊게 살펴보니, 실제 문제는 입자 시스템과 관련이 있었습니다.
각 입자는 많은 데이터를 포함하는 별도의 객체로 사용되었습니다.
이에 새로 생성된 입자 객체들로 인해 RAM이 데이터 용량을 감당하지 못한 것입니다.
대략 코드로 보면 다음과 같습니다.
fireAt(target: Unit)
의 내부 코드가 조금 이상하지만,
설명에 정확하게 나오지 않는 부분의 코드는 의미상 최대한 비슷하게 임의로 작성되었습니다.
import UIKit
class Particle { // ≈ 21KB
var coords: (x: Float, y: Float) = (0, 0) // 8B
var vector: (dx: Double, dy: Double) = (0.0, 0.0) // 16B
var speed: Float = 0.0 // 4B
var color: String = "" // 1KB
var sprite: UIImage = UIImage() // 20KB
init() { }
func draw(canvas: Canvas) {
// Draw
}
func move() {
// Move
}
}
class Game {
static var particles: [Particle] = []
static func addParticle(p: Particle) {
particles.append(p)
}
static func draw(canvas: Canvas) {
for particle in particles {
particle.draw(canvas: canvas)
}
}
}
class Unit {
var coords: (x: Double, y: Double) = (0, 0)
var weaponPower: Float = 150.0
func fireAt(target: Unit) {
var p = Particle()
p.coords = (Float(coords.x), Float(coords.y))
p.vector = (target.coords.x, target.coords.y)
p.speed = weaponPower
p.color = "red"
p.sprite = UIImage(named: "bullet.jpeg") ?? UIImage()
Game.addParticle(p: p)
}
}
3. 해결책
어떻게 용량을 줄일 수 있을까요?
먼저 용량을 많이 사용하는 color
와 sprite
를 살펴봅시다.
메모리도 많이 사용하지만 거의 모든 입자가 같은 데이터를 사용하고 있습니다.
모든 총알이 같은 color
와 sprite
값을 갖는 것이죠.
위 사진과 같이 Unique State(mutable)한 객체인 MovingParticle
과
Repeating State(immutable)한 객체인 Particle
로 나눕니다.
class Particle {
var color: String // 1KB
var sprite: UIImage // 20KB
init(color: String, sprite: UIImage) {
self.color = color
self.sprite = sprite
}
func draw(
coords: (Float, Float),
canvas: Canvas
) {
// Draw
}
func move(
coords: (Float, Float),
vector: (Double, Double),
speed: Float
) {
// Move
}
}
class MovingParticle {
var particle: Particle
var coords: (x: Float, y: Float) = (0, 0) // 8B
var vector: (dx: Double, dy: Double) = (0.0, 0.0) // 16B
var speed: Float = 0.0 // 4B
init(p: Particle) {
self.particle = p
}
func draw(canvas: Canvas) {
particle.draw(
coords: self.coords,
canvas: canvas
)
}
func move() {
particle.move(
coords: self.coords,
vector: self.vector,
speed: self.speed
)
}
}
color
와 sprite
를 반복적으로 생성하지 않아도 되도록 합니다.
그리고 이러한 상수 데이터를 일반적인 Intrinsic State(내재적 상태)라고 하며,
보통 객체 내부에 존재하며 다른 객체는 읽을 수만 있고 변경할 수 없습니다.
반대로 다른 객체에 의해 외부에서 변경되는 객체 생태를 Extrinsic State(외재적 상태)라고 합니다.
Flyweight 패턴은 객체 내부에 외부 상태를 저장하는 것을 멈추고, 이 상태를 상태에 의존하는 특정 메서드에 전달합니다.
고유한 상태만 객체 내에 유지되므로 다른 곳에서 재사용될 수 있습니다.
결과적으로 변형이 상대적으로 적은 내부 상태만 다르기 때문에 객체가 덜 필요하게 됩니다.
이제 Game
과 Unit
클래스도 어떻게 변했는데 살펴봅시다.
class Game {
static var mps: [MovingParticle] = []
// 사진에서는 배열로 표시했지만 Enum을 Key로하는 Dictionary가 더욱 적합해보임
static private var particles: [Particle] = []
static func addParticle(
coords: (Float, Float),
vector: (Double, Double),
speed: Float,
color: String,
sprite: UIImage
) {
// Append MovingParticle & Particle
}
static func draw(canvas: Canvas) {
for movingParticle in mps {
movingParticle.draw(canvas: canvas)
}
}
}
class Unit {
var coords: (x: Double, y: Double) = (0, 0)
var weaponPower: Float = 150.0
func fireAt(target: Unit) {
Game.addParticle(
coords: (Float(coords.x), Float(coords.y)),
vector: (target.coords.x, target.coords.y),
speed: weaponPower,
color: "red",
sprite: UIImage(named: "bullet.jpeg") ?? UIImage()
)
}
}
이제는 1,000,000개의 총알이 생성되어도 훨씬 적인 RAM 메모리를 사용하게 됩니다.
설명대로라면 Particle
에는 총알, 미사일, 파편에 대한 단 세 가지 객체면 충분합니다.
이렇듯 내부 상태만 저장하는 객체(Particle
)를 Flyweight라고 합니다.
Extrinsic State Storage(외부 상태 저장)
외부 상태, 즉 MovingParticle
은 어떻게 되었나요?
Game
의 프로퍼티가 되어 사실상 모든 Particle
이 저장됩니다.
설명에서는 mps
와 particles
배열을 통해 동일한 인덱스를 사용하여 Flyweight를 참조한다고 합니다.
그럼 결국 같은 객체가 계속 쌓이는 것이 아닌가 싶을 수 있지만, 여기서 Flyweight Factory를 사용합니다.
기존에 사용된 내부 상태의 Flyweight가 존재한다면 그대로 반환하고, 없다면 새 Flyweight를 생성하여 풀에 추가합니다.
아래 구조를 살펴보며 다시 이해해 봅시다.
또한 코드를 더욱 간결하게 표현하기 위해 아래와 같이 Particle
의 종류를 담은 ParticleType
을 enum으로 선언하여 작성했으니 참고하시기 바랍니다.
enum ParticleType {
case bullet
case missile
case shrapnel
var color: String {
switch self {
case .bullet:
return "red"
case .missile:
return "silver"
case .shrapnel:
return "black"
}
}
var sprite: UIImage {
switch self {
case .bullet:
return UIImage(named: "bullet.jpeg") ?? UIImage()
case .missile:
return UIImage(named: "missile.jpeg") ?? UIImage()
case .shrapnel:
return UIImage(named: "shrapnel.jpeg") ?? UIImage()
}
}
}
4. 구조
문제 상황에서 Flyweight는 Particle
클래스입니다.
Flyweight의 요소와 매칭시켜 봅시다.
// Flyweight
class Particle {
var color: String // repeatingState
var sprite: UIImage // repeatingState
init(type: ParticleType) {
self.color = type.color
self.sprite = type.sprite
}
// opration
func draw(
coords: (Float, Float), // uniqueState
canvas: Canvas
) {
// Draw
}
// opration
func move(
coords: (Float, Float), // uniqueState
vector: (Double, Double), // uniqueState
speed: Float // uniqueState
) {
// Move
}
}
이때 color
와 sprite
는 Intrinsic State라고 하며, move()
와 draw()
에 전달된 coords
, vector
, speed
등은 Extrinsic State라고 합니다.
다음으로 Flyweight Factory를 정의합니다.
// FlyweightFactory
class ParticleFactory {
// - cache: Particle[]
private var cache: [ParticleType: Particle] = [:]
// + getFlyweight(repeatingState)
func getParticle(type: ParticleType) -> Particle {
// if(cache[repeaingState] == null {
if cache[type] == nil {
// cache[repeaingState] = new Flyweight(repeaingState)
cache[type] = Particle(type: type)
}
// return cache[repeaingState]
return cache[type]!
}
}
다음으로 Context는 uniqueState를 포함하는 MovingParticle
입니다.
// 사전에 생성한 FlyweightFactory
let particleFactory = ParticleFactory()
// Context
class MovingParticle {
var particle: Particle
var coords: (x: Float, y: Float) // 8B
var vector: (dx: Double, dy: Double) // 16B
var speed: Float // 4B
// Context(repeatingState, uniqueState)
init(
particleType: ParticleType, // repeatingState
coords: (Float, Float), // uniqueState
vector: (Double, Double), // uniqueState
speed: Float // uniqueState
) {
// this.uniqueState = uniqueState
self.coords = coords
self.vector = vector
self.speed = speed
// this.flyweight = factory.getFlyweight(repeatingState)
self.particle = particleFactory.getParticle(type: .bullet)
}
// + operation()
func draw(canvas: Canvas) {
// flyweight.operation(uniqueState)
particle.draw(
coords: self.coords, // uniqueState
canvas: canvas
)
}
// + operation()
func move() {
// flyweight.operation(uniqueState)
particle.move(
coords: self.coords, // uniqueState
vector: self.vector, // uniqueState
speed: self.speed // uniqueState
)
}
}
마지막으로 여기서는 크게 중요하지 않지만
Client에 해당하는 Game
와 Unit
의 코드가 어떻게 변했는지 간단히 살펴봅시다.
class Game {
static var mps: [MovingParticle] = []
static private var particles: [Particle] = []
static func addParticle(
coords: (Float, Float),
vector: (Double, Double),
speed: Float,
particleType: ParticleType
) {
let movingParticle = MovingParticle(
particleType: .bullet,
coords: coords,
vector: vector,
speed: speed
)
mps.append(movingParticle)
particles.append(movingParticle.particle)
}
static func draw(canvas: Canvas) {
for movingParticle in mps {
movingParticle.draw(canvas: canvas)
}
}
}
class Unit {
var coords: (x: Double, y: Double) = (0, 0)
var weaponPower: Float = 150.0
func fireAt(target: Unit) {
Game.addParticle(
coords: (Float(coords.x), Float(coords.y)),
vector: (target.coords.x, target.coords.y),
speed: weaponPower,
particleType: .bullet
)
}
}
5. 장단점
상태가 같은 객체들이 많은 경우 Flyweight 패턴을 통해 메모리를 절약할 수 있습니다.
하지만 Flyweight의 메서드를 호출할 때마다 Context의 데이터를 다시 계산하는 경우, CPU 성능을 포기하고 RAM을 절약하는 꼴이 됩니다.
예를 들어 Particle
의 color
에 의존되어 MovingParticle
의 어떠한 데이터가 계산되어야 하는 경우입니다.
또한 코드가 복잡해지기 때문에 팀원들이 상태가 분리된 원인에 대해 쉽게 파악하기 어렵습니다.
6. 실제 사용 사례
실제로 지도 라이브러리의 마커(Marker, Pin) 등이 이러한 방식으로 작동합니다.
네이버 지도 SDK를 살펴봅시다.
오버레이 공통 · NAVER Map iOS SDK
오버레이 공통 오버레이는 지리적 정보를 시각적으로 나타내는 요소로, 개발자가 지도 위에 자유롭게 배치할 수 있습니다. 네이버 지도 SDK는 마커, 정보 창, 셰이프 등 다양한 유형의 오버레이
navermaps.github.io
오버레이 이미지에 대한 메모리 관리에 대해서 유의사항을 설명합니다.
아래 marker1
과 marker2
처럼 하나의 인스턴스를 통해 이미지를 적용할 것을 권장합니다.
// Good: marker1, 2가 같은 비트맵을 공유
let image = NMFOverlayImage(name: "marker_icon")
marker1.iconImage = image
marker2.iconImage = image
// Bad: marker3, 4가 비트맵을 중복해서 사용
marker3.iconImage = NMFOverlayImage(name: "marker_icon")
marker4.iconImage = NMFOverlayImage(name: "marker_icon")
또한 UIImage
객체로부터 NMFOverlayImage
객체를 만들었다면, NMFOverlayImage
가 나타내는 UIImage
가 동일할 경우 인스턴스가 다르더라도 동일한 비트맵을 공유하게 되어, 메모리를 효율적으로 사용할 수 있도록 합니다.
// Good: marker1, 2가 같은 비트맵을 공유
let image = NMFOverlayImage(image: bitmap)
marker1.iconImage = image
marker2.iconImage = image
// OK: marker3, 4가 다른 NMFOverlayImage 객체를 사용하지만 참조하는 리소스가 같으므로 비트맵도 공유
marker3.iconImage = NMFOverlayImage(image: bitmap)
marker4.iconImage = NMFOverlayImage(image: bitmap)
내부적인 Caching, 즉 Flyweight과 관련된 로직을 사용하고 있을 것으로 추정됩니다.
Conclusion
메모리 절약은 항상 중요합니다.
Flyweight 패턴을 사용하면 같은 객체가 반복해서 생성되는 것을 막고, 상태를 공유하여 메모리를 효율적으로 사용할 수 있게 됩니다.
예시에서처럼 게임 개발에서는 필수적인 요소일지도 모릅니다.
Reference
Flyweight
/ Design Patterns / Structural Patterns Flyweight Also known as: Cache Intent Flyweight is a structural design pattern that lets you fit more objects into the available amount of RAM by sharing common parts of state between multiple objects instead of keep
refactoring.guru
'Design Pattern > GoF - Structural Patterns' 카테고리의 다른 글
[GoF Design Patterns] Proxy (0) | 2024.12.07 |
---|---|
[GoF Design Patterns] Facade (0) | 2024.11.27 |
[GoF Design Patterns] Decorator (3) | 2024.11.13 |
[GoF Design Patterns] Composite (0) | 2024.11.10 |
[GoF Design Patterns] Bridge (4) | 2024.10.28 |