|

코틀린 디자인 패턴 (11) – Flyweight 패턴은 메모리를 어떻게 아낄까

코틀린 Flyweight 패턴과 메모리 공유 구조를 설명하는 대표 이미지
공유 가능한 상태만 Flyweight에 남기고, 나머지 상태는 바깥으로 분리한다

코틀린 Flyweight 패턴은 이름부터 조금 멀게 느껴지지만, 문제 자체는 아주 현실적입니다. 화면에 아이콘이 수천 개 보이거나, 같은 스타일 정보를 수많은 객체가 중복해서 들고 있거나, 문자 렌더링처럼 거의 같은 데이터를 엄청 많이 복제하는 상황이 생길 때 이 패턴이 등장합니다.

이번 글에서는 Flyweight를 단순히 메모리 절약 패턴이라고 외우지 않고, 무엇을 공유하고 무엇을 바깥으로 빼야 하는지를 판단하는 기준으로 정리하겠습니다. 바로 앞 글인 Facade 패턴이 복잡한 진입점을 감추는 데 초점을 맞췄다면, 이번 글은 같은 객체를 반복 생성할 때 생기는 낭비를 줄이는 데 초점을 둡니다.


Flyweight가 필요한 순간은 언제일까

객체지향 코드를 쓰다 보면 작은 객체를 많이 만드는 것이 자연스러울 때가 있습니다. 문제는 작은 객체가 많아지는 것 자체가 아니라, 그 객체들이 사실상 같은 데이터를 각자 복사해서 들고 있을 때입니다.

  • 같은 아이콘 이미지를 수백 개 버튼이 반복해서 참조할 때
  • 텍스트 편집기에서 문자마다 동일한 폰트/스타일 정보를 계속 들고 있을 때
  • 지도 마커나 파티클처럼 모양은 몇 종류 안 되는데 인스턴스 수만 아주 많을 때

이럴 때는 객체 수가 많다는 사실보다, 공유 가능한 상태까지 매번 새로 들고 있다는 점이 더 큰 문제입니다. Flyweight는 바로 이 지점을 겨냥합니다.


핵심은 상태를 둘로 나누는 것

Flyweight를 이해할 때 가장 중요한 개념은 상태를 두 종류로 나누는 것입니다.

  • intrinsic state: 여러 객체가 함께 써도 되는 공유 상태
  • extrinsic state: 객체마다 달라지는 바깥 상태

예를 들어 지도 앱의 마커를 생각해보면, 마커 이미지 파일과 기본 색상은 공유될 수 있습니다. 반면 각 마커의 좌표와 선택 여부는 인스턴스마다 달라집니다. Flyweight 패턴은 공유 가능한 상태만 객체 안에 남기고, 나머지는 바깥에서 전달하게 만듭니다.

Refactoring.Guru 설명도 이 구조를 중심으로 이야기합니다. 같은 총알 객체 수천 개가 각각 색상과 스프라이트를 중복 보관하는 대신, 몇 개의 shared flyweight만 두고 좌표와 속도 같은 값은 바깥 컨텍스트에서 넘기는 식입니다.


Flyweight의 기본 개념은 Refactoring.Guru의 Flyweight 설명SourceMaking 정리에서도 공통적으로 강조됩니다. 공유 가능한 상태는 객체 안에 남기고, 달라지는 상태는 바깥에서 전달한다는 점이 핵심입니다.

패턴 없이 쓰면 어떤 코드가 될까

문제 상황을 더 선명하게 보기 위해, 아이콘이 많이 붙는 알림 항목 예시를 생각해보겠습니다. 각 알림 객체가 제목, 위치, 읽음 여부뿐 아니라 아이콘 경로와 색상, 크기 정보까지 매번 자기 안에 들고 있는 구조입니다.

data class NotificationBadge(
    val title: String,
    val x: Int,
    val y: Int,
    val iconPath: String,
    val tintHex: String,
    val size: Int,
)

val badges = listOf(
    NotificationBadge("메일", 10, 20, "mail.png", "#2563EB", 24),
    NotificationBadge("댓글", 40, 20, "mail.png", "#2563EB", 24),
    NotificationBadge("결제", 70, 20, "mail.png", "#2563EB", 24),
)

예시는 작지만, 이런 항목이 수천 개라면 iconPath, tintHex, size 같은 값이 계속 중복됩니다. 이 값이 무겁거나 조합이 적을수록, 중복 저장 비용은 더 선명해집니다.


코틀린으로 Flyweight를 구현하면 이렇게 된다

이제 공유 가능한 부분을 따로 빼보겠습니다. 아이콘 모양과 스타일은 Flyweight 객체가 들고, 좌표와 제목은 외부 상태로 둡니다.

data class BadgeStyle(
    val iconPath: String,
    val tintHex: String,
    val size: Int,
) {
    fun render(title: String, x: Int, y: Int) {
        println("render title=$title at ($x,$y) with $iconPath $tintHex size=$size")
    }
}

class BadgeStyleFactory {
    private val cache = mutableMapOf<String, BadgeStyle>()

    fun get(iconPath: String, tintHex: String, size: Int): BadgeStyle {
        val key = "$iconPath|$tintHex|$size"
        return cache.getOrPut(key) {
            BadgeStyle(iconPath, tintHex, size)
        }
    }
}

data class BadgeContext(
    val title: String,
    val x: Int,
    val y: Int,
    val style: BadgeStyle,
)

val factory = BadgeStyleFactory()
val mailStyle = factory.get("mail.png", "#2563EB", 24)

val badges = listOf(
    BadgeContext("메일", 10, 20, mailStyle),
    BadgeContext("댓글", 40, 20, mailStyle),
    BadgeContext("결제", 70, 20, mailStyle),
)

badges.forEach { badge ->
    badge.style.render(badge.title, badge.x, badge.y)
}

이제 같은 스타일을 공유하는 항목들은 BadgeStyle 하나를 함께 씁니다. 즉 인스턴스 수는 많아도, 무거운 상태는 적은 수의 flyweight로 압축됩니다.


Factory가 왜 같이 등장할까

Flyweight 설명에는 거의 항상 Factory가 같이 따라옵니다. 이유는 단순합니다. 공유하려면 결국 같은 조건의 객체를 재사용하는 저장소가 필요하기 때문입니다.

클라이언트가 매번 BadgeStyle()을 직접 만들면 패턴의 핵심인 공유가 깨집니다. 그래서 보통은 Factory가 key를 기준으로 캐시를 들고 있다가, 이미 있으면 재사용하고 없으면 새로 만듭니다.

  1. 공유 가능한 속성 조합으로 key를 만든다
  2. cache에 있으면 기존 flyweight를 돌려준다
  3. 없으면 새 객체를 만들고 cache에 저장한다

Kotlin에서는 이 구조가 비교적 자연스럽습니다. mutableMapOfgetOrPut()만으로도 패턴의 핵심을 꽤 읽기 좋게 표현할 수 있기 때문입니다.


실전에서는 어떤 예시가 더 잘 맞을까

문자 렌더링과 스타일 공유

텍스트 편집기나 코드 뷰어처럼 문자가 매우 많이 등장하는 구조에서는 문자 모양, 폰트 정보, 색상 같은 상태를 공유하고, 실제 위치나 인덱스는 별도 컨텍스트로 두는 방식이 잘 맞습니다.

아이콘, 배지, 마커

지도 마커나 목록 배지처럼 같은 스타일의 요소가 반복되는 UI도 좋은 예시입니다. 모양과 스타일을 flyweight로 공유하고, 위치와 라벨만 바깥에서 들고 있으면 구조가 단순해집니다.

게임 파티클이나 총알

Flyweight를 설명할 때 자주 나오는 예시입니다. 총알 종류는 몇 개 안 되지만 개별 총알의 좌표, 속도, 방향은 모두 다르기 때문입니다. 이때 스프라이트나 타입 정보는 공유하고, 위치와 속도는 별도 상태로 두는 방식이 잘 맞습니다.


코틀린에서 이 패턴이 더 쉽게 느껴지는 이유

전통적인 패턴 설명에서는 인터페이스와 팩토리 클래스를 장황하게 세우는 경우가 많습니다. 하지만 Kotlin에서는 꼭 그렇게 무겁게 갈 필요가 없습니다.

  • data class로 공유 상태를 간결하게 표현할 수 있다
  • object나 singleton factory로 캐시 저장소를 단순하게 만들 수 있다
  • getOrPut() 같은 표준 라이브러리 함수로 재사용 흐름이 읽기 쉬워진다

즉 Kotlin은 Flyweight의 필요 자체를 없애주지는 않지만, 구현을 덜 장황하게 만들어줍니다. 이 점은 예전에 다룬 Prototype 패턴과 data class copy 글과도 결이 조금 다릅니다. Prototype이 기존 객체를 복제해 빠르게 새 버전을 만드는 쪽이라면, Flyweight는 애초에 같은 무거운 상태를 계속 새로 만들지 않도록 막는 쪽에 더 가깝습니다.


언제 쓰면 좋고, 언제 과할까

Flyweight는 이름만 멋있다고 아무 데나 붙일 패턴은 아닙니다. 다음 조건이 보여야 실전성이 있습니다.

  • 인스턴스 수가 아주 많다
  • 그중 공유 가능한 무거운 상태가 분명히 있다
  • 클라이언트가 외부 상태를 따로 들고 있어도 구조가 감당 가능하다
  • 메모리나 객체 생성 비용이 실제 병목에 가깝다

반대로 객체 수가 많지 않거나, 상태 대부분이 인스턴스마다 다르거나, 외부 상태를 분리하면 코드가 지나치게 복잡해진다면 오히려 과합니다. 이 패턴은 항상 좋은 설계가 아니라, 공유 이득이 복잡도 증가를 이길 때만 가치가 있는 설계입니다.


남용하면 어떤 문제가 생길까

Flyweight는 메모리를 줄이는 대신 상태를 둘로 나누게 만듭니다. 그래서 무턱대고 적용하면 오히려 코드가 더 읽기 어려워질 수 있습니다.

  1. 어떤 값이 공유 상태인지 파악하기 어려워진다
  2. 외부 상태를 계속 메서드 인자로 넘겨야 해서 호출부가 장황해진다
  3. Factory key 설계가 어설프면 재사용이 깨지거나 버그가 난다
  4. 메모리 절감은 미미한데 구조만 복잡해질 수 있다

즉 Flyweight의 핵심은 무조건 공유가 아니라, 정말 공유해도 안전한 상태만 공유하는 것입니다.


비슷한 패턴과 헷갈리지 않는 기준

Prototype과의 차이

Prototype은 기존 객체를 복제해서 새 객체를 빠르게 만드는 데 초점이 있습니다. 반면 Flyweight는 같은 무거운 상태를 재사용해서 객체당 비용을 줄이는 데 초점이 있습니다.

Decorator와의 차이

Decorator는 기존 객체에 기능을 덧붙이는 구조입니다. Flyweight는 기능 확장이 아니라 상태 공유가 핵심입니다. 그래서 Decorator는 책임을 조합하는 문제를 풀고, Flyweight는 메모리와 중복 저장 문제를 풉니다. 이 비교는 Decorator 패턴 글과 함께 보면 더 선명합니다.


Flyweight 패턴 실무 판단 기준

  1. 객체 수가 정말 많은가
  2. 중복되는 무거운 상태가 분명한가
  3. 공유 상태와 외부 상태를 안정적으로 나눌 수 있는가
  4. Factory와 캐시를 두는 복잡도를 감수할 가치가 있는가
  5. 코드를 단순화하는 것이 아니라도, 메모리 절감 이익이 충분한가

이 다섯 질문에 대체로 yes라고 답할 수 있다면 Flyweight를 검토할 가치가 있습니다. 그렇지 않다면 단순한 data class나 일반 캐싱만으로도 충분할 수 있습니다.


마무리

코틀린 디자인 패턴 시리즈 11편의 핵심은 이것입니다. Flyweight 패턴은 객체 수가 많다는 사실 자체를 해결하는 것이 아니라, 공유 가능한 상태까지 매번 중복 보관하는 구조를 줄이는 패턴입니다.

그래서 중요한 것은 패턴 이름보다, 지금 객체가 들고 있는 상태 중 무엇이 정말 인스턴스마다 달라야 하는지 따져보는 일입니다. 그 경계를 잘 나누면 Flyweight는 꽤 실용적인 설계가 됩니다.

함께보면 좋은 글