워드프로세서를 만든다고 하자. 화면에 찍히는 글자 하나하나를 객체로 다루고 싶다 — 각 문자가 자기 폰트를 알고, 자기 폭을 재고, 자기 자리를 그린다면 줄바꿈도 커서 이동도 문자에게 물어보면 되니 설계가 깔끔하다. 그런데 A4 한 페이지에 글자가 3천 개, 열 페이지짜리 문서면 3만 개다. Character 객체 3만 개가 각자 폰트 정보와 자간 규칙과 렌더링 로직을 품고 힙에 올라앉는다. 문서 하나 여는 데 메모리가 무너진다.
문제는 이 3만 개가 사실 대부분 똑같다는 것이다. ‘a’라는 글자는 문서 안에서 수백 번 등장하지만, 그 수백 개의 ‘a’가 아는 것 — 글자 모양, 폰트별 글리프, 폭 — 은 전부 동일하다. 다른 것은 오직 어디에 찍히느냐뿐이다. 같은 정보를 수백 벌 복제해 들고 있으니 메모리가 터지는 것이다. 플라이웨이트(Flyweight) 패턴은 이 중복을 정면으로 겨냥한다 — 같은 것은 딱 하나만 만들어 공유하고, 문맥마다 다른 것만 밖에서 그때그때 넘겨준다.
무엇이 문제인가 — 동기
같은 병을 게임으로 옮기면 더 선명하다. 오픈월드에 숲을 그린다. 나무가 백만 그루다. 소박하게 짜면 Tree 객체 백만 개를 만들고, 각 객체가 다음을 들고 있다.
class Tree {
Mesh mesh; // 3D 모델 (수 MB)
Texture bark; // 껍질 텍스처
Texture leaves; // 잎 텍스처
double x, y, z; // 위치
double scale; // 크기
}
mesh와 두 텍스처는 무겁다 — 한 벌에 수 MB다. 그런데 소나무는 소나무끼리 전부 같은 메시와 같은 텍스처를 쓴다. 종류가 열 가지라 해도 실제로 서로 다른 무거운 데이터는 열 벌뿐인데, 백만 그루가 각자 한 벌씩 복제해 들고 있으니 수 MB × 백만 = 수 TB가 필요하다. 정작 그루마다 진짜로 다른 값은 x, y, z, scale — 숫자 몇 개뿐이다.
여기서 세 가지가 동시에 아프다.
첫째, 메모리가 객체 수에 비례해 폭발한다. 무거운 데이터를 객체마다 복제하니, 객체가 늘면 메모리가 선형으로 아니 그 이상으로 치솟는다. 백만 개 규모에서는 아예 실행이 불가능해진다.
둘째, 중복이 명백한데 방치된다. 백만 그루 중 무거운 데이터의 서로 다른 값은 겨우 열 종류다. 99.999%가 복제본이다. 이 복제를 없애지 못하면 메모리는 실제 정보량이 아니라 객체 개수에 인질로 잡힌다.
셋째, 객체 생성 비용도 함께 는다. 텍스처를 로딩하고 메시를 파싱하는 무거운 초기화를 백만 번 반복하면, 메모리만이 아니라 로딩 시간까지 무너진다.
판단 기준: “아주 많은 객체가 필요한데, 그 객체들이 든 상태의 상당 부분이 사실 몇 종류로 겹친다”가 보이면 플라이웨이트를 의심한다. 함정: 객체 수가 수백 개 수준이거나 객체마다 상태가 정말로 제각각이라면 플라이웨이트는 대가만 크다 — 공유할 중복이 없으면 패턴이 살 자리가 없다.
패턴의 구조 — 누가 무엇을 하나
플라이웨이트의 핵심은 구조가 아니라 상태를 두 갈래로 쪼개는 사고다. 배역보다 이 분리를 먼저 잡아야 한다.
본질 상태(intrinsic state). 문맥과 무관하게 객체에 고정된, 그래서 공유 가능한 부분이다. 글자 ‘a’의 글리프, 소나무의 메시와 텍스처. 이 값은 어디에 찍히든 어느 숲에 서 있든 변하지 않으므로, 같은 종류라면 딱 하나의 인스턴스를 여럿이 나눠 쓸 수 있다. 무거운 것은 대개 여기 몰려 있다.
부가 상태(extrinsic state). 문맥마다 달라지는, 그래서 공유할 수 없는 부분이다. 글자의 화면 좌표, 나무의 위치와 크기. 이 값은 객체가 놓인 상황에 따라 매번 다르므로 공유 대상이 아니다 — 플라이웨이트 안에 저장하지 않고, 연산할 때 밖에서 인자로 넘긴다.
Flyweight(플라이웨이트). 본질 상태만 품고, 부가 상태는 메서드 인자로 받는 객체다. TreeType이 메시·텍스처(본질)를 들고, draw(x, y, scale)처럼 위치·크기(부가)를 넘겨받아 그린다. 결정적으로 이 객체는 불변이어야 한다 — 여럿이 공유하는데 한 명이 상태를 바꾸면 다른 모두가 영향을 받으니, 본질 상태는 생성 후 절대 변하지 않아야 안전하다.
FlyweightFactory(공유 풀). 플라이웨이트를 함부로 new 하지 못하게 막고, 팩토리가 **풀(pool)**을 관리한다. “소나무 타입 줘”라고 요청하면, 이미 만든 게 있으면 그것을 돌려주고 없으면 그때 만들어 풀에 넣고 돌려준다. 이 팩토리가 있어야 “같은 본질은 하나만 존재한다”는 보장이 선다. 클라이언트가 직접 생성하게 두면 공유가 깨진다.
Client(클라이언트). 부가 상태는 자기가 들고 있다가, 연산할 때 플라이웨이트에게 넘긴다. 나무 백만 그루는 각자 x, y, z, scale(부가)만 들고, 무거운 본질은 팩토리에서 받은 공유 TreeType을 참조만 한다.
판단 기준: 어떤 필드가 본질이고 어떤 필드가 부가인지 가르는 물음은 “이 값이 객체마다 다른가”다. 다르면 부가(밖으로), 같으면 본질(공유). 함정: 무거운 데이터를 무심코 부가 쪽에 남겨 두면 공유가 안 돼 메모리가 그대로 터진다 — 공유의 이득은 “무거운 것을 본질로 몰아넣는 데” 달렸다.
코드로 — 패턴이 자라나는 과정
나무 객체를 순진하게 백만 개 만드는 데서 출발해, 본질/부가를 가르고, 팩토리로 공유 풀을 세우는 과정을 단계로 따라간다.
public class Tree { private Mesh mesh; // 수 MB — 종류가 같으면 완전히 동일 private Texture bark; // 종류가 같으면 완전히 동일 private Texture leaves; // 종류가 같으면 완전히 동일 private double x, y, z; // 그루마다 다름 private double scale; // 그루마다 다름 public Tree(String species, double x, double y, double z, double scale) { this.mesh = MeshLoader.load(species); // 무거운 로딩을 매번 this.bark = TextureLoader.load(species + "_bark"); this.leaves = TextureLoader.load(species + "_leaves"); this.x = x; this.y = y; this.z = z; this.scale = scale; } public void draw(Canvas canvas) { canvas.render(mesh, bark, leaves, x, y, z, scale); }}List<Tree> forest = new ArrayList<>();for (int i = 0; i < 1_000_000; i++) { forest.add(new Tree("pine", rx(), ry(), rz(), rscale()));}// mesh·bark·leaves가 백만 벌 복제된다. 소나무의 무거운 데이터는// 실제로 한 종류뿐인데 백만 번 로딩되고 백만 번 힙에 올라간다.
무엇이 달라졌나. 원본에서 그루마다 복제되던 무거운 데이터가, 마지막 상태에서는 종류 수만큼(열 벌)만 존재한다. 백만 그루가 든 것은 이제 위치·크기 같은 부가 상태와 공유 TreeType으로의 참조뿐이다. 핵심은 두 가지가 맞물려야 이득이 난다는 점이다 — ① 무거운 것을 본질로 몰아 하나로 만들고(2번 스텝), ② 팩토리가 “같은 본질은 하나뿐”을 강제한다(3번 스텝). 둘 중 하나만 하면 공유는 성립하지 않는다.
판단 기준: 리팩터링 후 “객체는 N개인데 무거운 데이터의 인스턴스는 종류 수 K개(K ≪ N)“가 성립하면 플라이웨이트가 제대로 앉은 것이다. 함정: 팩토리를 두고도 클라이언트가 어딘가에서 new TreeType()을 직접 부르면 풀이 새고 중복이 되살아난다 — 생성 경로를 팩토리 하나로 좁혀야 공유가 보장된다.
결과 — 무엇을 얻고 무엇을 내주나
플라이웨이트가 사는 값은 메모리 절약 하나로 요약된다. 그리고 그 절약의 크기는 두 가지에 달렸다. 공유되는 객체가 얼마나 많은가(N이 클수록 이득), 그리고 본질/부가를 갈랐을 때 본질에 얼마나 무거운 것이 몰리는가(무거운 게 본질이면 이득). 백만 그루가 열 개의 타입을 나눠 쓰는 극단적 비율에서 절약은 압도적이다. 덤으로 무거운 초기화(텍스처 로딩)도 종류당 한 번으로 줄어 로딩 시간까지 짧아진다.
대가는 세 가지다. 첫째, 부가 상태의 계산·전달 비용이 새로 생긴다. 원래 객체 안에 저장돼 있던 값을, 이제 매 연산마다 밖에서 계산해 인자로 넘겨야 한다. 저장을 안 하니 메모리는 아꼈지만, 그 값이 필요할 때마다 다시 구하거나 들고 다녀야 한다 — 메모리를 시간과 맞바꾼 것이다. 부가 상태를 뽑아내는 계산이 비싸면 이 거래가 손해가 될 수도 있다.
둘째, 코드가 복잡해진다. 하나였던 개념(나무)이 본질(TreeType)·부가(Tree의 필드)·팩토리 셋으로 쪼개진다. “나무의 상태가 어디 있지?”라는 물음에 두 곳을 봐야 하고, 본질/부가를 가르는 판단이 코드를 읽는 사람에게 늘 얹힌다. 상태가 한곳에 모여 있던 단순함은 사라진다.
셋째, 공유가 강제하는 불변성이라는 제약이 붙는다. 본질 상태는 여럿이 공유하므로 절대 변경할 수 없다. 만약 나무 종류마다 런타임에 텍스처를 바꾸고 싶어진다면, 공유 때문에 한 그루를 바꾸면 같은 타입의 모든 그루가 함께 바뀐다 — 공유의 이득이 곧 변경의 족쇄가 된다.
운영비용 렌즈로 보면 순이득의 조건이 분명하다. 객체가 대량이고, 무거운 상태가 소수의 종류로 겹치며, 부가 상태를 뽑는 비용이 싸야 한다. 이 셋이 맞을 때만 절약이 복잡성과 계산 비용을 넘어선다. 객체가 수백 개거나, 상태가 그루마다 진짜 다르거나, 부가 상태 계산이 비싸면 플라이웨이트는 대가만 남긴다.
판단 기준: 절약되는 메모리(복제 제거)와 새로 드는 비용(부가 계산·전달·코드 복잡·불변 제약)을 저울에 올려 순이득이 양수일 때만 앉힌다. 규모(N)와 겹침(K/N)의 비율이 극단적일수록 이득이 크다. 함정: “객체가 많으니 무조건 플라이웨이트”는 위험하다 — 겹치는 무거운 상태가 없으면 공유할 게 없어 복잡성만 얻는다.
언제 쓰고, 언제 과용인가
플라이웨이트가 정확히 맞는 자리는 이렇다. 애플리케이션이 엄청나게 많은 객체를 써서 저장 비용이 문제가 되고, 그 객체들의 상태 중 상당 부분을 부가 상태로 밖으로 뽑아낼 수 있으며, 부가 상태를 빼고 나면 서로 겹치는 본질이 소수로 줄어드는 경우다. 문서의 문자, 게임의 지형·초목, GUI의 아이콘, 자바 문자열 상수 풀(String.intern)과 Integer 캐시(-128~127)가 교과서적 사례다 — 모두 “같은 값을 여럿이 공유하면 하나만 있으면 된다”는 발상이다.
과용의 냄새는 반대편에서 난다. 객체 수가 애초에 크지 않은데 “혹시 많아지면”을 이유로 미리 쪼개면, 오지 않을 규모에 대비해 코드 복잡성을 선불로 치르는 것이다. 본질/부가를 억지로 가른 결과 부가 상태가 대부분을 차지해 공유되는 본질이 몇 바이트뿐이라면, 얻는 절약보다 팩토리와 분리가 더 비싸다. 상태가 자주 바뀌어야 하는데 공유 때문에 불변으로 묶으면, 절약하려다 변경을 불가능하게 만든다.
이웃 패턴과의 경계도 그어 둔다. 공유 풀을 관리한다는 점에서 [싱글턴]과 닮았지만 목적이 다르다 — 싱글턴은 “인스턴스가 하나뿐임”을 보장하고, 플라이웨이트는 “같은 본질이면 공유한다”를 보장한다(종류가 열이면 인스턴스도 열이다). 다만 플라이웨이트 팩토리 자체는 흔히 싱글턴으로 둔다 — 풀이 하나여야 공유가 성립하니까. [컴포지트]와는 자주 함께 쓰인다. 문서를 문자·행·열의 트리로 구성할 때(컴포지트), 그 잎에 해당하는 문자 객체를 플라이웨이트로 공유하면 트리의 잎이 수만 개여도 메모리가 터지지 않는다 — 컴포지트가 구조를 만들고, 플라이웨이트가 그 잎의 중복을 없앤다.
판단 기준: 규모(N)가 크고, 상태를 본질/부가로 갈랐을 때 무거운 본질이 소수 종류(K)로 겹치며, 부가 계산이 싼지를 함께 확인하고 셋이 다 맞을 때만 앉힌다. 함정: 상태를 공유하려고 원래 문맥별로 달랐어야 할 값까지 본질로 밀어 넣으면, 한 객체의 변경이 공유하는 모두를 오염시킨다 — “정말 문맥과 무관하게 같은가”를 필드마다 물어야 한다.
다음으로 프록시