20. Flyweight — 치즈는 치즈다, 수만 번 써도 같은 객체

작성일:2026.05.21|수정일:2026.07.07|조회수:0

20. Flyweight — 치즈는 치즈다, 수만 번 써도 같은 객체

에이든 피자의 인기가 하늘을 찌르고 있다. 시범 운영 20일차, 이제 하루 주문량이 10만 건을 넘나들기 시작했다. 장사가 잘되는 건 기쁜 일이지만, 서버 모니터링 화면을 보던 내 얼굴은 점점 어두워졌다. 메모리 사용량이 예사롭지 않았기 때문이다.

문제의 원인은 의외로 '토핑'에 있었다. 피자 하나에는 평균 5~6개의 토핑이 올라간다. 우리는 지금까지 피자를 만들 때마다 토핑 객체를 새로 생성했다.

TS
class Topping {  constructor(    public name: string,    public calories: number,    public allergens: string[],    public imageUrl: string  ) {}}// 주문이 들어올 때마다...const cheese = new Topping("모짜렐라", 200, ["유제품"], "https://...");

피자 10만 개에 각각 '모짜렐라' 토핑 객체가 붙어 있다고 생각해보자. 10만 개의 똑같은 이름, 10만 개의 똑같은 칼로리 정보, 그리고 10만 개의 똑같은 이미지 URL 문자열이 메모리를 차지하고 있는 셈이다. 모짜렐라는 어디서나 모짜렐라인데, 왜 우리는 이 똑같은 데이터를 10만 번이나 복제해서 들고 있어야 하는 걸까.

무엇이 불편한가

지금의 가장 큰 문제는 동일한 불변 데이터가 중복해서 생성되고 있다는 점이다. 토핑의 이름, 영양 성분, 알레르기 정보 등은 어떤 피자에 올라가든 변하지 않는 정적인 정보다. 이런 데이터를 객체마다 개별적으로 소유하게 하면, 주문량이 늘어날수록 메모리는 비효율적으로 낭비된다.

특히 이런 데이터는 '불변'인 경우가 많다. 모짜렐라 치즈의 칼로리가 피자마다 다를 리 없지 않은가. 그런데도 우리는 매번 new 키워드를 남발하며 힙 메모리를 채우고 있었다. 이건 마치 도서관에 똑같은 책 10만 권을 비치해두고, 손님이 올 때마다 새 책을 한 권씩 주는 것과 같다. 한 권만 비치해두고 모두가 같이 읽게 하면 될 일인데 말이다.

우리는 공유할 수 있는 데이터와 공유할 수 없는 데이터를 분리해야 했다. "모짜렐라는 이런 녀석이다"라는 정보는 하나만 만들어 공유하고, "이 피자에는 모짜렐라가 20g 들어간다"는 정보만 피자가 가지는 구조가 필요했다.

Flyweight 패턴

Flyweight 패턴은 공유를 통해 많은 수의 소립 객체(fine-grained objects)를 효과적으로 지원하는 패턴이다. GoF의 원래 의도는 다음과 같다.

객체를 가볍게 만들어 메모리 사용량을 줄인다. 이를 위해 객체의 상태를 공유 가능한 '내재적 상태'와 공유 불가능한 '외재적 상태'로 나눈다.

여기서 핵심 개념 두 가지를 짚고 넘어가야 한다.

  1. 내재적 상태(Intrinsic State): 객체 내부에 저장되어야 하며, 객체의 컨텍스트(문맥)에 의존하지 않는 정적인 정보. 토핑의 이름, 칼로리 등이 여기에 해당한다. 이들은 공유 가능하다.
  2. 외재적 상태(Extrinsic State): 객체가 사용되는 컨텍스트에 따라 달라지는 가변 정보. 토핑의 위치나 수량 등이 여기에 해당한다. 이들은 공유할 수 없으며, 외부에서 전달받아야 한다.

에이든 피자에 대입해보면, ToppingFlyweight 클래스는 내재적 상태만 담고, PizzaTopping 클래스는 이 공유 객체와 외재적 상태(수량)를 결합해 피자에 붙여주는 역할을 한다. 그리고 이 공유 객체들을 관리해줄 Flyweight Factory가 필요하다.

치즈 한 조각도 아껴 쓰는 법

이제 에이든 피자의 토핑 시스템을 가볍게(Flyweight) 만들어보자. 먼저 공유할 내재적 상태를 담은 클래스다.

TS
class ToppingFlyweight {  constructor(    readonly name: string,    readonly calories: number,    readonly allergens: readonly string[],    readonly imageUrl: string,  ) {    Object.freeze(this); // Flyweight는 반드시 불변이어야 한다  }}

다음은 이들을 관리하고 재사용하게 해줄 팩토리다. 이미 만든 게 있다면 그걸 주고, 없으면 새로 만들어 풀(Pool)에 저장한다.

TS
class ToppingFlyweightFactory {  private static pool = new Map<string, ToppingFlyweight>();  static getTopping(name: string): ToppingFlyweight {    const cached = this.pool.get(name);    if (cached) {      return cached;    }    if (!this.pool.has(name)) {      // 실제로는 DB 등에서 상세 정보를 가져오겠지만, 여기서는 예시로 생성      this.pool.set(name, new ToppingFlyweight(name, 200, ["유제품"], "..."));    }    const created = this.pool.get(name);    if (!created) {      throw new Error(`토핑 Flyweight 생성 실패: ${name}`);    }    return created;  }}

마지막으로 피자에서는 이 공유 객체를 참조하고, 자신만의 데이터(수량)만 따로 관리한다.

TS
class PizzaTopping {  constructor(    private flyweight: ToppingFlyweight, // 공유 객체 참조    private quantity: number             // 이 피자만의 외재적 상태  ) {}  print() {    console.log(`${this.flyweight.name} x ${this.quantity}`);  }}

이렇게 하면 피자 10만 개가 생겨도 '모짜렐라' Flyweight 객체는 단 하나만 존재한다. 피자들은 그저 이 객체를 가리키는 참조만 가진다. 메모리 사용량은 크게 줄어들고, 가비지 컬렉터가 치워야 할 일감도 줄어든다.

TypeScript 포인트

Flyweight 패턴을 TypeScript로 구현할 때 가장 주의해야 할 점은 **불변성(Immutability)**이다. 여러 곳에서 공유하는 객체인데 누군가 슬쩍 내부 값을 바꾼다면, 그 토핑을 공유하는 모든 피자의 정보가 한꺼번에 바뀌는 대참사가 일어난다.

그래서 Flyweight 클래스의 필드는 반드시 readonly로 선언하고, 생성자 마지막에 Object.freeze(this)를 호출해 런타임에서도 수정을 막는 것이 안전하다. 배열 타입인 allergens 역시 readonly string[]으로 선언하여 원소가 추가되거나 삭제되는 것을 방지해야 한다.

또한 팩토리에서 사용하는 Map 풀이 너무 커져서 오히려 메모리를 압박하는 경우가 생길 수도 있다. 이럴 때는 JavaScript의 WeakRef를 활용해, 아무도 해당 토핑을 참조하지 않을 때 가비지 컬렉터가 수거해갈 수 있도록 설계하는 심화 기법도 고민해볼 법하다. 하지만 에이든 피자의 토핑 종류는 100가지를 넘지 않으니, 단순한 Map으로도 충분하다.

트레이드오프

Flyweight 패턴은 메모리가 부족하거나 객체 생성 비용이 너무 클 때 마법 같은 해결책이 된다. 특히 텍스트 에디터의 글자 하나하나를 객체로 만들거나, 게임 엔진에서 수천 개의 나무를 렌더링할 때 필수적으로 쓰인다. 에이든 피자처럼 대규모 주문 처리가 필요한 시스템에서도 성능 최적화의 핵심 역할을 한다.

하지만 설계가 복잡해진다는 단점이 있다. 내재적 상태와 외재적 상태를 논리적으로 완벽하게 분리해야 하며, 이 과정에서 코드가 직관성을 잃을 수도 있다. 또한 상태를 분리함에 따라 메서드 호출 시 외부에서 상태를 계속 전달해줘야 하므로, 런타임 계산 비용이 약간 늘어날 수 있다.

하지만 수만 명의 손님이 동시에 피자를 주문할 때 서버가 메모리 부족으로 뻗어버리는 상황을 생각한다면, 이 정도의 설계 고민은 감수할 가치가 있다. "조기 최적화는 만악의 근원"이라는 말도 있지만, 지금 에이든 피자의 상황은 조기가 아니라 이미 '적기'에 와 있기 때문이다.

전체 코드

TS
/** * Flyweight: 공유되는 불변 데이터 (내재적 상태) */class ToppingFlyweight {  constructor(    readonly name: string,    readonly calories: number,    readonly allergens: readonly string[],    readonly imageUrl: string,  ) {    Object.freeze(this);    Object.freeze(this.allergens);  }  printInfo(quantity: number): void {    console.log(`- ${this.name} (x${quantity}): ${this.calories * quantity}kcal`);  }}/** * Flyweight Factory: 공유 객체 풀 관리 */class ToppingFlyweightFactory {  private static pool = new Map<string, ToppingFlyweight>();  static getTopping(name: string): ToppingFlyweight {    let flyweight = this.pool.get(name);    if (!flyweight) {      console.log(`[Factory] ${name} 토핑 객체 신규 생성`);      flyweight = this.createMockTopping(name);      this.pool.set(name, flyweight);    }    return flyweight;  }  private static createMockTopping(name: string): ToppingFlyweight {    const mockData: Record<string, { cal: number; allergens: string[] }> = {      "모짜렐라": { cal: 200, allergens: ["유제품"] },      "페퍼로니": { cal: 150, allergens: ["돼지고기"] },      "베이컨": { cal: 180, allergens: ["돼지고기"] },      "양파": { cal: 10, allergens: [] },    };    const data = mockData[name] || { cal: 50, allergens: [] };    return new ToppingFlyweight(      name,      data.cal,      data.allergens,      `https://cdn.ayden-pizza.com/toppings/${name}.png`    );  }  static getPoolSize(): number {    return this.pool.size;  }}/** * Context: 외재적 상태(수량)를 포함하는 클라이언트 객체 */class PizzaTopping {  constructor(    private readonly flyweight: ToppingFlyweight,    private readonly quantity: number,  ) {}  print(): void {    this.flyweight.printInfo(this.quantity);  }}class Pizza {  private toppings: PizzaTopping[] = [];  constructor(public name: string) {}  addTopping(name: string, quantity: number): void {    const flyweight = ToppingFlyweightFactory.getTopping(name);    this.toppings.push(new PizzaTopping(flyweight, quantity));  }  showDetails(): void {    console.log(`=== [${this.name}] 토핑 정보 ===`);    this.toppings.forEach(t => t.print());  }}/** * 시뮬레이션 */function runFlyweightDemo() {  const pizzas: Pizza[] = [];  const pizzaCount = 1000;  console.log(`${pizzaCount}개의 피자 주문 생성 중...`);  for (let i = 0; i < pizzaCount; i++) {    const pizza = new Pizza(`피자 #${i + 1}`);    pizza.addTopping("모짜렐라", 2);    pizza.addTopping("페퍼로니", 1);    pizza.addTopping("양파", 1);    pizzas.push(pizza);  }  console.log("--- 생성 완료 ---");  console.log(`총 생성된 피자 수: ${pizzas.length}`);  console.log(`총 사용된 토핑 인스턴스(PizzaTopping): ${pizzas.length * 3}`);  console.log(`실제 메모리에 존재하는 Flyweight 객체 수: ${ToppingFlyweightFactory.getPoolSize()}`);  if (pizzas[0]) {    pizzas[0].showDetails();  }}runFlyweightDemo();

플라이웨이트 패턴 덕분에 에이든 피자는 수만 건의 주문 앞에서도 평온한 메모리 상태를 유지하게 되었다. 성능 고민을 덜고 나니 이제 손님의 '변덕'이 눈에 들어오기 시작한다. "아 치즈 빼주세요", "아니다 그냥 다시 넣어주세요", "올리브 추가했던 거 취소할게요". 주문을 수정하다 말고 계속 이전으로 되돌려달라는 손님들. 이들의 변덕에 대응하기 위해, 우리는 객체의 과거 상태를 기억하고 언제든 되돌릴 수 있는 방법이 필요해졌다.

댓글

댓글을 불러오는 중...