← Posts

GoF · Structural · Flyweight

Flyweight

같은 객체를 수백만 개 만들어 메모리가 터질 때. 공유 가능한 부분을 뽑아 하나만 만들어 나눠 쓰는 구조 패턴.

목차
  1. 같은 것을 수백만 개 만든다
  2. 플라이웨이트 = 공유해서 줄인다
  3. 내부 상태와 외부 상태를 가른다
  4. 공유 객체를 팩토리로 재사용한다
  5. 외부 상태는 밖에서 넘긴다
  6. 메모리가 왜 줄어드나
  7. 게임의 나무로 다시 보기
  8. 불변이어야 공유된다
  9. 실무: Integer 캐시와 String 풀
  10. 정리

같은 것을 수백만 개 만든다

텍스트 에디터를 만든다고 하자. 화면의 글자 하나하나를 객체로 다루면 편하다. 각 글자가 자기 폰트와 모양을 알고, 자기 위치를 안다.

java
class Character {
    private char symbol;      // 'A'
    private String font;      // "본고딕"
    private Glyph glyph;      // 'A'의 벡터 외곽선 (무겁다)
    private int x, y;         // 화면 위치
    private String color;
}

깔끔해 보인다. 하지만 문서 하나에 글자가 수백만 개다. 그리고 그 글자들 대부분은 같은 폰트, 같은 모양을 쓴다. ‘A’가 문서에 10만 번 나오면, ‘A’의 폰트와 글리프가 10만 번 중복 저장된다. 글리프 하나가 몇 KB만 돼도 이게 곱해지면 메모리가 통째로 날아간다.

플라이웨이트 = 공유해서 줄인다

플라이웨이트 패턴이 이걸 푼다.

같은 상태를 가진 객체가 수없이 많을 때, 공유 가능한 부분을 뽑아 하나만 만들어 나눠 쓴다. 그래서 메모리를 아낀다.

‘A’의 폰트와 글리프는 문서 어디에 나오든 똑같다. 그렇다면 그걸 담은 객체는 딱 하나만 만들고, 모든 ‘A’가 그 하나를 가리키게 하면 된다. flyweight는 “가벼운 것”이라는 뜻이다. 무거운 부분을 밖으로 빼서 객체 하나하나를 가볍게 만든다.

내부 상태와 외부 상태를 가른다

핵심은 객체의 상태를 두 갈래로 나누는 것이다.

  • 내부 상태(intrinsic) - 어디서 쓰이든 항상 같은 값. 공유 가능하고, 변하지 않는다. 여기선 문자의 모양(‘A’라는 글리프)과 폰트다.
  • 외부 상태(extrinsic) - 쓸 때마다 다른 값. 공유할 수 없다. 여기선 그 글자가 놓인 위치(x, y)와 색이다.

같은 ‘A’라도 문서의 첫 줄에 있는 ‘A’와 마지막 줄에 있는 ‘A’는 위치가 다르다. 위치는 공유할 수 없다. 하지만 모양은 같다. 모양은 공유할 수 있다. 공유할 수 있는 것만 객체에 담고, 나머지는 밖으로 뺀다.

java
public class Glyph {              // 내부 상태만 담는다 (공유 대상)
    private final char symbol;    // 'A'
    private final String font;    // "본고딕"
    private final Shape outline;  // 무거운 벡터 외곽선

    public Glyph(char symbol, String font, Shape outline) {
        this.symbol = symbol;
        this.font = font;
        this.outline = outline;
    }

    // 위치(x, y)는 필드에 없다. 그릴 때 밖에서 받는다.
    public void draw(int x, int y) {
        // outline을 (x, y)에 렌더링
    }
}

Glyph에는 위치가 없다. 위치는 매번 다르니 담을 수 없다. 담긴 건 어디서든 같은 모양뿐이다.

공유 객체를 팩토리로 재사용한다

같은 ‘A’를 두 번 만들면 공유가 깨진다. 그래서 누가 만들지를 통제해야 한다. 팩토리가 이미 만든 것을 캐시에 두고, 있으면 그걸 돌려준다.

java
public class GlyphFactory {
    private final Map<Character, Glyph> cache = new HashMap<>();

    public Glyph getGlyph(char symbol) {
        // 이미 만든 'A'가 있으면 그걸 그대로 준다
        return cache.computeIfAbsent(symbol,
            c -> new Glyph(c, "본고딕", loadOutline(c)));
    }
}

getGlyph('A')를 몇 번을 불러도 돌아오는 Glyph같은 인스턴스 하나다. 클라이언트가 직접 new Glyph()를 하지 못하게 막고, 반드시 팩토리를 거치게 하는 게 요령이다.

diagramdiagram

‘A’가 10만 번 나와도, 무거운 Glyph 객체는 화면에 뜨는 문자 종류 수만큼(수십~수백 개)만 존재한다.

외부 상태는 밖에서 넘긴다

그럼 위치는 어디로 갔나. 위치는 쓰는 쪽이 들고 있다가, 그릴 때 넘긴다.

java
GlyphFactory factory = new GlyphFactory();

// 같은 'A' 글리프를 서로 다른 위치에 그린다
Glyph a = factory.getGlyph('A');
a.draw(10, 20);    // 위치는 draw의 인자로 넘어간다
a.draw(30, 20);    // 같은 객체, 다른 위치
a.draw(50, 20);

에디터는 각 글자의 위치를 담은 가벼운 배열만 들고 있으면 된다. 무거운 모양은 팩토리가 공유하는 하나를 빌려 쓰고, 매번 다른 위치만 인자로 얹어 준다. 무거운 건 공유, 가벼운 건 전달 - 이 분업이 플라이웨이트의 전부다.

메모리가 왜 줄어드나

숫자로 보면 명확하다. 글리프 하나가 4KB, 문서에 글자가 100만 개, 문자 종류는 100가지라고 하자.

  • 공유 전: 100만 개 각각이 글리프를 품는다 → 4KB × 100만 = 약 4GB.
  • 공유 후: 글리프는 종류 수만큼만 → 4KB × 100 = 400KB. 나머지 100만 개는 위치(몇 바이트)와 공유 객체를 가리키는 참조(8바이트)만 든다.

무거운 부분이 100만 벌에서 100벌로 줄었다. 곱해지던 것이 더는 곱해지지 않는다. 이게 플라이웨이트가 노리는 지점이다.

게임의 나무로 다시 보기

에디터가 아니라 게임이라도 똑같다. 숲에 나무가 100만 그루 있다. 나무마다 텍스처·색·높이 같은 무거운 데이터가 있는데, 나무는 사실 몇 종류뿐이다(소나무·참나무·단풍나무…).

java
// 내부 상태: 나무 종류마다 하나만 (공유)
public class TreeType {
    private final String name;
    private final Texture texture;   // 무겁다
    private final Color color;

    public void draw(int x, int y) { /* 위치는 밖에서 */ }
}

// 외부 상태: 그루마다 위치만
public class Tree {
    private final int x, y;
    private final TreeType type;      // 공유되는 종류를 가리킴

    public void draw() { type.draw(x, y); }
}

TreeType은 종류 수만큼(몇 개)만 만들어 공유하고, Tree는 위치와 참조만 든다. 100만 그루라도 무거운 텍스처는 몇 벌뿐이다.

불변이어야 공유된다

공유에는 엄격한 전제가 하나 있다. 공유되는 내부 상태 객체는 **반드시 불변(immutable)**이어야 한다.

이유는 자명하다. 하나를 여럿이 나눠 쓰는데 누군가 그걸 바꾸면, 그 변경이 공유하는 모두에게 번진다. ‘A’ 글리프의 폰트를 한 곳에서 바꾸면 문서의 모든 ‘A’가 함께 바뀐다. 그래서 Glyph의 필드를 전부 final로 두고, 상태를 바꾸는 메서드를 두지 않는다. 위치처럼 바뀌는 값은 애초에 내부 상태가 아니라 외부 상태로 뺐으니 문제가 안 된다. 바뀌는 것은 밖으로, 안 바뀌는 것만 공유 - 이 구분이 지켜져야 공유가 안전하다.

실무: Integer 캐시와 String 풀

플라이웨이트는 자바 표준 라이브러리 안에 이미 들어 있다.

Integer.valueOf(int)-128 ~ 127 범위의 값을 미리 만들어 캐시해 두고 재사용한다. 이 범위 안에서는 Integer.valueOf(100) == Integer.valueOf(100)이 참이다 - 같은 객체를 돌려주기 때문이다. 자주 쓰이는 작은 정수를 매번 새로 만들지 않는 것, 이게 플라이웨이트다. Boolean, Character, 작은 Long도 같은 캐시를 쓴다.

String 상수 풀도 마찬가지다. 소스에 리터럴로 쓴 같은 문자열은 풀에서 하나로 공유된다. 불변이기에 공유가 가능하고(String은 불변이다), 그래서 같은 리터럴은 같은 객체를 가리킨다.

주의할 점도 있다. 플라이웨이트는 같은 것이 진짜로 대량일 때만 값어치가 있다. 종류가 다양하고 중복이 적으면 캐시 유지 비용만 늘고 이득이 없다. 그리고 외부 상태를 계산해 넘기는 부담이 생기니, 메모리를 아끼는 대신 코드가 조금 복잡해지는 걸 감수해야 한다.

정리

플라이웨이트란공유 가능한 부분을 뽑아 하나만 만들어 나눠 쓴다
문제같은 상태의 객체가 수백만 개라 중복 저장으로 메모리가 터진다
핵심상태를 내부(공유·불변)와 외부(매번 다름)로 가른다
고침내부 상태는 팩토리가 캐시해 재사용, 외부 상태는 쓸 때 밖에서 넘긴다
전제공유되는 내부 상태 객체는 반드시 불변이어야 한다
실무Integer.valueOf 캐시(-128~127), String 상수 풀

같은 것을 수없이 만들 때, 안 바뀌는 부분을 하나로 공유하고 바뀌는 부분만 밖에서 넘기는 것 - 그게 플라이웨이트다.

관련 글