이미지 로딩 중...

Flyweight Pattern 완벽 가이드 기초부터 심화까지 - 슬라이드 1/9
A

AI Generated

2025. 11. 5. · 4 Views

Flyweight Pattern 완벽 가이드 기초부터 심화까지

메모리 사용을 극적으로 줄이는 Flyweight 디자인 패턴을 실전 예제로 배웁니다. 게임 개발과 대용량 데이터 처리에서 필수적인 이 패턴의 원리와 구현 방법을 완벽하게 마스터하세요.


목차

  1. Flyweight_Pattern_개념 - 메모리 최적화 핵심 원리
  2. 내부상태와_외부상태 - 상태 분리 전략
  3. Flyweight_Factory - 객체 재사용 관리
  4. 실전_게임_캐릭터_예제 - 대량 객체 최적화
  5. 텍스트_에디터_구현 - 문자 렌더링 최적화
  6. 성능_측정과_비교 - 메모리 사용량 분석
  7. 불변성_관리_패턴 - 안전한 공유 객체
  8. 실무_적용_시나리오 - 실제 프로젝트 활용
  9. Flyweight_Pattern_개념
  10. 내부상태와_외부상태
  11. Flyweight_Factory
  12. 실전_게임_캐릭터_예제
  13. 텍스트_에디터_구현
  14. 처음 5개 Glyph는 모두 defaultStyle 참조
  15. 사용자가 굵게 버튼 클릭
  16. boldStyle = styleFactory.getStyle('Arial', 12, '#000000', true, false) 호출
  17. glyphs[2], glyphs[3], glyphs[4]의 style을 boldStyle로 변경
  18. 렌더링 시 각 Glyph는 자신의 style.getCSS()를 호출
  19. 성능_측정과_비교
  20. 불변성_관리_패턴
  21. Factory에서 ImmutableTreeType 생성
  22. 여러 Tree가 이를 참조
  23. 사용자가 일부 나무의 색 변경 요청
  24. newType = factory.getTreeType('pine', 'red', 'pine.png') 호출 (또는 withColor 사용)
  25. 해당 Tree들의 type 참조만 newType으로 변경
  26. 원본 pineType은 그대로 유지되어 다른 나무들에 영향 없음
  27. 실무_적용_시나리오

1. Flyweight_Pattern_개념 - 메모리 최적화 핵심 원리


2. 내부상태와_외부상태 - 상태 분리 전략


3. Flyweight_Factory - 객체 재사용 관리


4. 실전_게임_캐릭터_예제 - 대량 객체 최적화


5. 텍스트_에디터_구현 - 문자 렌더링 최적화


6. 성능_측정과_비교 - 메모리 사용량 분석


7. 불변성_관리_패턴 - 안전한 공유 객체


8. 실무_적용_시나리오 - 실제 프로젝트 활용


1. Flyweight_Pattern_개념

시작하며

여러분이 게임을 개발하는데 화면에 나무 10,000그루를 렌더링해야 한다고 상상해보세요. 각 나무마다 별도의 객체를 만들면 텍스처, 3D 모델, 애니메이션 데이터가 모두 메모리에 중복 저장됩니다.

결과적으로 메모리 사용량이 기하급수적으로 늘어나 앱이 느려지거나 크래시가 발생합니다. 이런 문제는 대량의 유사한 객체를 다루는 모든 애플리케이션에서 발생합니다.

SNS 피드의 아이콘, 문서 에디터의 문자, 지도 앱의 마커 등 수천 개의 객체가 실제로는 몇 가지 타입만 반복되는 경우가 많죠. 바로 이럴 때 필요한 것이 Flyweight Pattern입니다.

공통 데이터를 공유하고 고유한 데이터만 분리하여, 메모리 사용량을 90% 이상 줄일 수 있습니다.

개요

간단히 말해서, Flyweight 패턴은 많은 수의 유사한 객체를 효율적으로 관리하기 위해 공유 가능한 데이터를 분리하는 구조적 디자인 패턴입니다. 실무에서 이 패턴이 필요한 이유는 명확합니다.

메모리는 한정된 자원이고, 특히 모바일이나 브라우저 환경에서는 더욱 제약이 큽니다. 수천 개의 객체를 생성해야 하는데 각각이 몇 MB씩 차지한다면 앱이 정상 동작할 수 없죠.

예를 들어, 실시간 대시보드에서 10,000개의 데이터 포인트를 시각화하는 경우, 각 포인트가 스타일 정보를 중복 보유하면 불필요한 메모리 낭비가 발생합니다. 전통적인 방법에서는 각 객체가 모든 데이터를 소유했다면, Flyweight 패턴에서는 여러 객체가 공통 데이터를 참조로 공유합니다.

이 패턴의 핵심 특징은 세 가지입니다. 첫째, 내부 상태(intrinsic state)와 외부 상태(extrinsic state)를 명확히 분리합니다.

둘째, Flyweight Factory가 객체 재사용을 보장합니다. 셋째, 공유 객체는 불변(immutable)으로 유지되어야 합니다.

이러한 특징들이 메모리 효율성과 안전성을 동시에 보장합니다.

코드 예제

// Flyweight: 공유 가능한 데이터만 보유
class TreeType {
  constructor(name, color, texture) {
    this.name = name;        // 내부 상태: 공유됨
    this.color = color;      // 내부 상태: 공유됨
    this.texture = texture;  // 내부 상태: 공유됨 (실제로는 큰 이미지 데이터)
  }

  // 외부 상태(x, y)를 매개변수로 받아 렌더링
  draw(x, y) {
    console.log(`${this.name} 나무를 (${x}, ${y})에 그립니다`);
    // 실제로는 this.texture를 사용해 렌더링
  }
}

// Context: 고유한 데이터 보유
class Tree {
  constructor(x, y, type) {
    this.x = x;        // 외부 상태: 각 나무마다 고유
    this.y = y;        // 외부 상태: 각 나무마다 고유
    this.type = type;  // Flyweight 객체 참조
  }

  draw() {
    this.type.draw(this.x, this.y);
  }
}

설명

이것이 하는 일: Flyweight 패턴은 대량의 객체를 생성할 때 공통 데이터를 공유하여 메모리 사용량을 최소화합니다. 첫 번째로, TreeType 클래스는 Flyweight 역할을 합니다.

name, color, texture는 같은 종류의 나무들이 모두 공유하는 데이터입니다. 예를 들어 "소나무"라는 타입의 나무 1,000그루가 있다면, 이들은 모두 하나의 TreeType 인스턴스를 참조합니다.

텍스처 이미지가 5MB라고 가정하면, 1,000개를 따로 저장하는 대신 단 5MB만 사용하는 것이죠. 두 번째로, Tree 클래스는 Context 역할을 하며 외부 상태인 x, y 좌표를 보유합니다.

각 나무는 화면상의 다른 위치에 있으므로 이 값은 공유할 수 없습니다. 하지만 좌표 두 개는 몇 바이트에 불과하므로 메모리 부담이 거의 없습니다.

세 번째로, draw 메서드는 외부 상태를 매개변수로 받습니다. Tree 객체는 자신의 고유한 좌표를 Flyweight의 draw 메서드에 전달하여, 공유 객체가 특정 위치에 렌더링되도록 합니다.

이 설계 덕분에 Flyweight는 상태를 갖지 않고 순수하게 데이터와 행동만 제공합니다. 실행 흐름을 보면 이렇습니다.

tree.draw()를 호출하면, Tree는 자신의 좌표(this.x, this.y)를 TreeType의 draw에 전달합니다. TreeType은 공유 데이터(texture)를 사용해 해당 좌표에 나무를 렌더링합니다.

여러분이 이 코드를 사용하면 메모리 사용량을 극적으로 줄일 수 있습니다. 10,000그루의 나무가 있어도 실제로는 5-10개의 TreeType 인스턴스만 존재하고, 각 Tree는 작은 좌표 데이터만 보유합니다.

또한 코드가 명확히 분리되어 유지보수가 쉬워지고, 새로운 나무 타입을 추가하는 것도 간단합니다.

실전 팁

💡 내부 상태와 외부 상태를 구분하는 기준은 "여러 객체가 공유할 수 있는가?"입니다. 공유 가능하면 내부 상태, 객체마다 달라야 하면 외부 상태로 분류하세요. 💡 Flyweight 객체는 반드시 불변(immutable)으로 만드세요. 한 객체가 공유 데이터를 수정하면 다른 모든 객체에 영향을 미쳐 예측 불가능한 버그가 발생합니다. 💡 성능 측정 없이 무조건 적용하지 마세요. 객체가 100개 미만이면 패턴의 오버헤드가 오히려 비효율적일 수 있습니다. 프로파일링 후 결정하세요. 💡 JavaScript에서는 Object.freeze()를 사용해 Flyweight 객체를 명시적으로 불변으로 만들면 실수를 방지할 수 있습니다. 💡 Flyweight Factory와 함께 사용하면 효과가 배가됩니다. Factory가 이미 생성된 Flyweight를 재사용하도록 보장하여 중복 생성을 완전히 차단합니다.


2. 내부상태와_외부상태

시작하며

여러분이 문서 에디터를 개발한다고 가정해봅시다. 사용자가 "Hello World"를 입력하면 11개의 문자가 생성됩니다.

각 문자가 폰트 이름, 폰트 크기, 색상, 스타일 정보를 모두 갖고 있다면 어떻게 될까요? 100페이지 문서에는 수만 개의 문자가 있고, 각각이 불필요한 데이터를 중복 저장합니다.

이 문제의 핵심은 "어떤 데이터를 공유할 수 있는가"를 판단하는 것입니다. 같은 폰트, 같은 크기로 입력된 문자들은 이 정보를 공유할 수 있지만, 각 문자의 위치나 실제 문자 값('H', 'e', 'l' 등)은 고유합니다.

바로 이 구분이 Flyweight 패턴의 핵심인 내부 상태와 외부 상태입니다. 올바르게 분리하면 메모리 효율이 극대화되고, 잘못 분리하면 패턴이 무용지물이 됩니다.

개요

간단히 말해서, 내부 상태(intrinsic state)는 여러 객체가 공유할 수 있는 문맥 독립적인 데이터이고, 외부 상태(extrinsic state)는 각 객체의 고유한 문맥 의존적인 데이터입니다. 왜 이 구분이 중요할까요?

실무에서 잘못된 분리는 두 가지 문제를 일으킵니다. 첫째, 공유 가능한 데이터를 외부 상태로 분류하면 메모리 절약 효과가 사라집니다.

둘째, 고유해야 할 데이터를 내부 상태로 분류하면 버그가 발생합니다. 예를 들어, 게임에서 적 캐릭터의 체력을 내부 상태로 만들면 한 적이 맞으면 모든 적의 체력이 줄어드는 황당한 상황이 벌어집니다.

전통적인 객체 지향 설계에서는 객체가 모든 데이터를 캡슐화했다면, Flyweight에서는 공유 데이터만 캡슐화하고 나머지는 외부에서 주입받습니다. 내부 상태의 특징은 다음과 같습니다.

불변이어야 하고, 생성 시점에 결정되며, 객체의 정체성을 정의합니다. 외부 상태는 가변적이고, 런타임에 변경되며, 객체가 놓인 문맥을 나타냅니다.

이 차이를 명확히 이해하면 어떤 상황에서도 올바른 설계를 할 수 있습니다.

코드 예제

// 내부 상태: 공유 가능한 폰트 정보
class CharacterStyle {
  constructor(fontFamily, fontSize, color, bold) {
    this.fontFamily = fontFamily;  // 내부 상태
    this.fontSize = fontSize;      // 내부 상태
    this.color = color;            // 내부 상태
    this.bold = bold;              // 내부 상태
    Object.freeze(this);  // 불변성 보장
  }

  // 외부 상태를 받아 렌더링
  render(char, x, y) {
    const weight = this.bold ? 'bold' : 'normal';
    console.log(`"${char}"를 (${x},${y})에 ${this.fontFamily} ${this.fontSize}px ${weight}로 그립니다`);
  }
}

// 외부 상태: 각 문자의 고유 정보
class Character {
  constructor(char, x, y, style) {
    this.char = char;    // 외부 상태: 문자 내용
    this.x = x;          // 외부 상태: 위치
    this.y = y;          // 외부 상태: 위치
    this.style = style;  // 내부 상태 참조 (Flyweight)
  }

  draw() {
    this.style.render(this.char, this.x, this.y);
  }
}

설명

이것이 하는 일: 내부 상태와 외부 상태를 명확히 분리하여 공유 가능한 데이터는 재사용하고 고유한 데이터만 별도로 관리합니다. 첫 번째로, CharacterStyle 클래스는 내부 상태만 담습니다.

fontFamily, fontSize, color, bold는 같은 스타일의 모든 문자가 공유합니다. 예를 들어 문서에서 Arial 12pt 검정색으로 입력된 1,000개의 문자는 모두 하나의 CharacterStyle 인스턴스를 참조합니다.

Object.freeze()를 호출하여 이 객체를 불변으로 만들어, 누군가 실수로 수정하는 것을 방지합니다. 두 번째로, Character 클래스는 외부 상태를 보유합니다.

char는 실제 문자('A', 'B' 등)이고, x와 y는 화면상의 위치입니다. 이 값들은 문자마다 다르므로 공유할 수 없습니다.

대신 이들은 몇 바이트에 불과하여 메모리 부담이 거의 없습니다. 세 번째로, render 메서드는 외부 상태를 매개변수로 받습니다.

이 설계가 중요한 이유는 CharacterStyle이 어떤 상태도 보유하지 않기 때문입니다. 순수 함수처럼 동작하여, 같은 입력에 항상 같은 결과를 냅니다.

이렇게 하면 스레드 안전성도 자동으로 확보됩니다. 실제 동작을 보면, character.draw()를 호출하면 Character는 자신의 외부 상태(char, x, y)를 CharacterStyle의 render에 전달합니다.

CharacterStyle은 내부 상태(폰트 정보)와 받은 외부 상태를 결합하여 최종 렌더링을 수행합니다. 여러분이 이 패턴을 사용하면 문서 에디터에서 10,000개의 문자가 있어도 실제로는 10-20개의 CharacterStyle만 존재합니다.

각 스타일이 폰트 데이터로 1KB를 사용한다면, 전통적 방식에서는 10MB가 필요하지만 이 방식에서는 20KB면 충분합니다. 또한 스타일 변경도 간단합니다.

선택된 텍스트를 다른 CharacterStyle로 교체하기만 하면 되니까요.

실전 팁

💡 내부 상태인지 판단하는 간단한 테스트: "이 값을 변경하면 한 객체만 영향을 받는가, 아니면 여러 객체가 영향을 받는가?" 여러 객체가 영향받으면 내부 상태입니다. 💡 외부 상태가 너무 많아지면 Flyweight 패턴의 효과가 감소합니다. 외부 상태를 전달하는 오버헤드가 메모리 절약보다 클 수 있으니, 균형을 맞추세요. 💡 불변성 검증을 자동화하세요. TypeScript를 사용한다면 readonly 키워드로, JavaScript에서는 Object.freeze()로 컴파일/런타임 체크를 활성화하세요. 💡 외부 상태를 객체로 묶어 전달하면 메서드 시그니처가 깔끔해집니다. { char, x, y } 같은 context 객체를 사용하는 것도 좋은 방법입니다. 💡 디버깅 시 내부 상태와 외부 상태를 콘솔에 다른 색으로 출력하면 문제를 빠르게 파악할 수 있습니다. 어떤 데이터가 공유되는지 시각적으로 확인하세요.


3. Flyweight_Factory

시작하며

여러분이 지금까지 배운 Flyweight 패턴을 실제로 적용한다고 가정해봅시다. TreeType을 직접 new TreeType('pine', 'green', texture)로 생성하면 어떤 문제가 생길까요?

코드의 여러 곳에서 같은 타입의 나무를 만들 때마다 새 인스턴스가 생성되어, 패턴의 핵심인 "공유"가 무너집니다. 이 문제는 개발자의 실수나 협업 상황에서 쉽게 발생합니다.

한 파일에서는 소나무 Flyweight를 재사용하지만, 다른 파일에서는 또 다른 소나무 Flyweight를 만들어버리는 거죠. 결국 메모리에는 중복된 Flyweight가 여러 개 존재하게 됩니다.

바로 이럴 때 필요한 것이 Flyweight Factory입니다. 중앙에서 Flyweight 생성을 제어하여, 이미 존재하는 객체는 재사용하고 없는 경우에만 새로 만듭니다.

개요

간단히 말해서, Flyweight Factory는 Flyweight 객체의 생성과 관리를 책임지는 중앙 집중식 관리자입니다. 클라이언트가 직접 Flyweight를 생성하지 못하게 하고, Factory를 통해서만 접근하도록 강제합니다.

실무에서 Factory가 필수적인 이유는 명확합니다. 대규모 애플리케이션에서는 수십 명의 개발자가 코드를 작성하고, 같은 리소스를 다른 모듈에서 필요로 합니다.

Factory 없이는 누가 어떤 Flyweight를 만들었는지 추적할 수 없고, 중복 생성을 막을 방법이 없습니다. 예를 들어, 게임 엔진에서 텍스처 로딩을 각 컴포넌트가 독자적으로 하면 같은 텍스처가 메모리에 10번 로드될 수 있습니다.

전통적인 방식에서는 클라이언트가 new 키워드로 직접 객체를 생성했다면, Factory 패턴에서는 get 메서드로 요청하고 Factory가 생성 여부를 판단합니다. Factory의 핵심 특징은 다음과 같습니다.

첫째, 내부적으로 캐시(보통 Map이나 Object)를 유지하여 생성된 Flyweight를 추적합니다. 둘째, 요청받은 Flyweight가 캐시에 있으면 즉시 반환하고, 없으면 생성 후 캐시에 저장합니다.

셋째, 단일 진입점을 제공하여 Flyweight 생성을 완벽히 제어합니다. 이러한 특징들이 메모리 효율성과 일관성을 보장합니다.

코드 예제

// Flyweight Factory: 객체 생성과 재사용 관리
class TreeFactory {
  constructor() {
    this.treeTypes = new Map();  // 캐시: key는 고유 식별자, value는 Flyweight
  }

  // Flyweight를 가져오거나 생성
  getTreeType(name, color, texture) {
    // 고유 키 생성 (모든 내부 상태 조합)
    const key = `${name}_${color}_${texture}`;

    // 캐시에 있으면 재사용
    if (!this.treeTypes.has(key)) {
      console.log(`새로운 TreeType 생성: ${key}`);
      this.treeTypes.set(key, new TreeType(name, color, texture));
    } else {
      console.log(`기존 TreeType 재사용: ${key}`);
    }

    return this.treeTypes.get(key);
  }

  // 통계 정보 제공
  getTotalTypes() {
    return this.treeTypes.size;
  }
}

// 사용 예시
const factory = new TreeFactory();
const pine1 = factory.getTreeType('pine', 'green', 'pine.png');  // 생성
const pine2 = factory.getTreeType('pine', 'green', 'pine.png');  // 재사용!
console.log(pine1 === pine2);  // true: 같은 인스턴스

설명

이것이 하는 일: Factory는 Flyweight 객체의 생명주기를 관리하여 같은 내부 상태를 가진 객체는 절대 중복 생성되지 않도록 보장합니다. 첫 번째로, constructor에서 this.treeTypes라는 Map을 생성합니다.

이것이 바로 캐시입니다. Map을 사용하는 이유는 객체를 키로 사용할 수 있고, has/get/set 연산이 O(1)로 매우 빠르기 때문입니다.

일반 Object도 가능하지만 Map이 더 명시적이고 안전합니다. 두 번째로, getTreeType 메서드가 핵심입니다.

먼저 name, color, texture를 결합하여 고유 키를 만듭니다. 이 키는 내부 상태의 모든 조합을 나타내므로, 같은 키 = 같은 Flyweight입니다.

그 다음 has 메서드로 캐시를 확인합니다. 캐시에 없으면 새 TreeType을 생성하고 set으로 저장합니다.

있으면 그냥 get으로 꺼냅니다. 이 간단한 로직이 모든 중복을 제거합니다.

세 번째로, 사용 예시를 보면 pine1과 pine2는 같은 매개변수로 요청되었습니다. 첫 번째 호출에서는 "새로운 TreeType 생성"이 출력되고, 두 번째 호출에서는 "기존 TreeType 재사용"이 출력됩니다.

pine1 === pine2가 true인 것은 둘이 메모리상 완전히 같은 객체임을 증명합니다. 네 번째로, getTotalTypes 같은 유틸리티 메서드를 추가하면 디버깅에 큰 도움이 됩니다.

"현재 몇 개의 Flyweight가 생성되었는가?"를 추적하면 패턴이 제대로 작동하는지 즉시 알 수 있습니다. 여러분이 이 Factory를 사용하면 10,000그루의 나무를 심어도 실제로는 5-10개의 TreeType만 메모리에 존재합니다.

또한 코드 전체에서 factory.getTreeType만 호출하면 되므로 일관성이 보장됩니다. 누군가 실수로 new TreeType을 호출해도 Factory를 거치지 않았으므로 코드 리뷰에서 쉽게 잡을 수 있습니다.

실전 팁

💡 생성자를 private으로 만들 수 없는 JavaScript에서는 문서화와 린팅 규칙으로 직접 생성을 금지하세요. ESLint 커스텀 룰로 "new TreeType"을 에러로 처리할 수 있습니다. 💡 고유 키 생성 시 JSON.stringify를 사용하면 편리하지만 성능이 떨어집니다. 간단한 문자열 결합이나 Symbol을 활용하는 것이 더 빠릅니다. 💡 Factory 자체를 싱글톤으로 만들면 애플리케이션 전체에서 하나의 캐시만 유지됩니다. 하지만 테스트하기 어려워지므로 의존성 주입을 고려하세요. 💡 메모리 누수를 방지하려면 WeakMap 사용을 검토하세요. Flyweight가 더 이상 사용되지 않으면 자동으로 가비지 컬렉션됩니다. 단, 키가 객체여야 합니다. 💡 Factory에 clear() 메서드를 추가하면 테스트 시 캐시를 초기화할 수 있어 유용합니다. beforeEach에서 factory.clear()를 호출하여 테스트 간 격리를 보장하세요.


4. 실전_게임_캐릭터_예제

시작하며

여러분이 대규모 멀티플레이어 게임을 개발한다고 상상해보세요. 화면에 100명의 플레이어와 500마리의 몬스터가 동시에 존재합니다.

각 캐릭터는 3D 모델, 애니메이션, 사운드, 이펙트 데이터를 갖고 있고, 각각이 10MB라면 총 6GB의 메모리가 필요합니다. 이는 대부분의 디바이스에서 불가능합니다.

하지만 자세히 보면 100명의 플레이어는 5-10개의 직업(전사, 마법사 등)으로 분류되고, 500마리 몬스터도 20-30종류로 나뉩니다. 즉, 실제로 필요한 고유 리소스는 40개 이하입니다.

나머지는 위치, 체력, 이름 같은 개별 데이터일 뿐입니다. 바로 이럴 때 Flyweight 패턴이 빛을 발합니다.

캐릭터 타입별 공유 리소스를 분리하여, 메모리를 1% 이하로 줄이면서도 모든 캐릭터를 렌더링할 수 있습니다.

개요

간단히 말해서, 게임 캐릭터에서 Flyweight 패턴은 캐릭터 타입(클래스, 종족)의 공유 데이터와 개별 캐릭터 인스턴스의 고유 데이터를 분리하는 것입니다. 실무 게임 개발에서 이 패턴이 필수인 이유는 성능과 직결되기 때문입니다.

모바일 게임은 메모리가 제한적이고, 브라우저 게임도 탭당 메모리 할당량이 있습니다. MMO나 RTS처럼 수백 개의 유닛을 다루는 장르에서는 Flyweight 없이는 아예 구현이 불가능합니다.

예를 들어, 스타크래프트에서 마린 100기를 생성할 때 각 마린이 공격 사운드 파일을 따로 갖고 있으면 게임이 크래시됩니다. 전통적인 방식에서는 Character 클래스가 모델, 텍스처, 사운드를 모두 멤버 변수로 가졌다면, Flyweight 방식에서는 CharacterType이 이들을 갖고 Character는 타입을 참조만 합니다.

이 패턴의 실전 적용 포인트는 다음과 같습니다. 첫째, 모델/텍스처/사운드 같은 대용량 에셋은 내부 상태로 분류합니다.

둘째, 위치/체력/상태 같은 게임플레이 데이터는 외부 상태로 분류합니다. 셋째, Factory로 캐릭터 타입을 관리하여 에셋 로딩을 최적화합니다.

이렇게 하면 로딩 속도도 빨라지고 메모리도 절약됩니다.

코드 예제

// Flyweight: 캐릭터 타입의 공유 데이터
class CharacterType {
  constructor(className, model, animations, sounds) {
    this.className = className;      // 내부 상태: 직업명
    this.model = model;              // 내부 상태: 3D 모델 데이터 (가정: 5MB)
    this.animations = animations;    // 내부 상태: 애니메이션 세트 (가정: 3MB)
    this.sounds = sounds;            // 내부 상태: 사운드 파일들 (가정: 2MB)
    Object.freeze(this);
  }

  render(x, y, z, health) {
    console.log(`${this.className}을 (${x},${y},${z})에 렌더링 (체력: ${health})`);
    // 실제로는 this.model과 this.animations를 사용해 3D 렌더링
  }
}

// Context: 개별 캐릭터의 고유 데이터
class Character {
  constructor(name, x, y, z, health, type) {
    this.name = name;      // 외부 상태: 캐릭터 이름
    this.x = x;            // 외부 상태: 위치
    this.y = y;
    this.z = z;
    this.health = health;  // 외부 상태: 현재 체력
    this.type = type;      // Flyweight 참조
  }

  draw() {
    this.type.render(this.x, this.y, this.z, this.health);
  }

  takeDamage(amount) {
    this.health -= amount;  // 외부 상태는 자유롭게 변경 가능
  }
}

// Factory
class CharacterFactory {
  constructor() {
    this.types = new Map();
  }

  getType(className, model, animations, sounds) {
    if (!this.types.has(className)) {
      this.types.set(className, new CharacterType(className, model, animations, sounds));
    }
    return this.types.get(className);
  }
}

설명

이것이 하는 일: 게임 캐릭터의 공유 에셋과 개별 게임플레이 데이터를 분리하여 대규모 캐릭터 시스템을 효율적으로 구현합니다. 첫 번째로, CharacterType 클래스를 봅시다.

이것은 "전사", "마법사" 같은 직업 타입을 나타냅니다. model은 3D 메시 데이터(정점, 텍스처 좌표 등), animations는 걷기/공격/죽음 애니메이션, sounds는 공격 소리/피격 소리 등입니다.

실제 게임에서 이들은 각각 수 MB에 달하는 큰 데이터입니다. 같은 직업의 모든 캐릭터가 이를 공유하므로, 100명의 전사가 있어도 전사 타입의 에셋은 메모리에 단 한 번만 존재합니다.

두 번째로, Character 클래스는 외부 상태만 갖습니다. name은 "플레이어1", "몬스터A" 같은 개별 식별자이고, x, y, z는 3D 공간상의 위치, health는 현재 체력입니다.

이 데이터들은 캐릭터마다 다르고 계속 변경됩니다. takeDamage 같은 메서드가 health를 수정할 수 있는 것은 이것이 외부 상태이기 때문입니다.

절대 type의 내부 데이터를 수정해서는 안 됩니다. 세 번째로, CharacterFactory는 에셋 로딩을 최적화합니다.

getType을 처음 호출하면 model, animations, sounds를 파일에서 로드하고 CharacterType을 생성합니다. 이후 같은 className으로 요청하면 즉시 반환합니다.

이는 게임 시작 시 모든 직업 타입을 한 번만 로드하고, 플레이어가 생성될 때마다 재사용함을 의미합니다. 실제 사용 시나리오를 보면, 게임 초기화 단계에서 factory.getType('warrior', warriorModel, warriorAnims, warriorSounds)로 모든 타입을 미리 로드합니다.

그 후 플레이어가 접속하면 new Character('Player1', 0, 0, 0, 100, warriorType)로 빠르게 생성합니다. 여기서 warriorType은 이미 메모리에 있으므로 추가 로딩이 없습니다.

여러분이 이 패턴을 사용하면 1,000명의 플레이어가 동시 접속해도 실제로는 10개 정도의 직업 타입만 메모리에 존재합니다. 각 타입이 10MB라면 총 100MB만 사용하고, 각 Character는 수십 바이트에 불과합니다.

전통적 방식에서 10GB가 필요한 상황을 100MB로 해결하는 것이죠. 또한 새로운 캐릭터 생성이 매우 빠릅니다.

에셋 로딩 없이 객체만 생성하면 되니까요.

실전 팁

💡 에셋 로딩을 비동기로 처리하세요. CharacterFactory의 getType을 async로 만들어 파일을 로드하는 동안 다른 작업을 계속할 수 있습니다. 💡 레벨 디자인 시 미리 필요한 캐릭터 타입을 선언하세요. 게임 시작 시 해당 타입들을 프리로드하면 플레이 중 끊김이 없습니다. 💡 디버그 모드에서는 각 CharacterType이 몇 개의 Character에 의해 참조되는지 카운트하세요. 사용되지 않는 타입은 메모리에서 언로드할 수 있습니다. 💡 애니메이션 상태(현재 프레임 등)는 반드시 외부 상태로 만드세요. 내부 상태로 만들면 모든 캐릭터가 같은 프레임에서 동기화되어 부자연스럽습니다. 💡 서버-클라이언트 게임에서는 클라이언트가 CharacterType을 캐시하고, 서버는 Character의 외부 상태만 전송하세요. 네트워크 대역폭을 크게 절약할 수 있습니다.


5. 텍스트_에디터_구현

시작하며

여러분이 구글 문서 같은 웹 기반 텍스트 에디터를 개발한다고 가정해봅시다. 사용자가 100페이지 문서를 작성하면 대략 50,000개의 문자가 생성됩니다.

각 문자가 폰트 이름(문자열), 크기(숫자), 색상(문자열), 굵기(boolean) 등을 갖고 있다면 각각 최소 100바이트는 됩니다. 50,000 × 100 = 5MB가 문자 데이터만으로 소비됩니다.

하지만 실제로 문서를 보면 대부분의 텍스트가 같은 폰트와 스타일을 사용합니다. "Arial 12pt 검정"이 전체의 80%를 차지한다면, 이 정보를 40,000번 중복 저장하는 것은 엄청난 낭비입니다.

바로 이럴 때 Flyweight 패턴을 적용하면 문자 스타일을 공유하여 메모리를 95% 이상 절약할 수 있습니다. 또한 스타일 변경도 훨씬 빨라집니다.

개요

간단히 말해서, 텍스트 에디터에서 Flyweight 패턴은 문자의 시각적 스타일(폰트, 크기, 색상)을 공유하고 문자 내용과 위치만 개별 관리하는 것입니다. 실무에서 이 패턴이 중요한 이유는 사용자 경험과 직결되기 때문입니다.

메모리를 많이 쓰면 브라우저가 느려지고 탭이 크래시될 수 있습니다. 또한 대용량 문서를 열거나 저장할 때 성능이 급격히 저하됩니다.

예를 들어, 구글 문서나 노션 같은 서비스는 수십만 개의 문자를 다루므로 Flyweight 없이는 불가능합니다. 전통적인 방식에서는 각 Character 객체가 {char: 'A', font: 'Arial', size: 12, color: '#000', x: 10, y: 20}처럼 모든 속성을 가졌다면, Flyweight 방식에서는 {char: 'A', x: 10, y: 20, style: styleRef}처럼 스타일을 참조합니다.

이 패턴의 에디터 특화 장점은 다음과 같습니다. 첫째, 스타일 변경이 매우 빠릅니다.

선택된 텍스트의 스타일 참조만 바꾸면 되니까요. 둘째, 실행 취소/다시 실행이 간단합니다.

스타일 변경 전후의 참조만 저장하면 됩니다. 셋째, 파일 크기가 줄어듭니다.

저장 시 스타일 ID만 기록하면 되니까요.

코드 예제

// Flyweight: 문자 스타일
class TextStyle {
  constructor(fontFamily, fontSize, color, bold, italic) {
    this.fontFamily = fontFamily;
    this.fontSize = fontSize;
    this.color = color;
    this.bold = bold;
    this.italic = italic;
    Object.freeze(this);
  }

  getCSS() {
    return {
      fontFamily: this.fontFamily,
      fontSize: `${this.fontSize}px`,
      color: this.color,
      fontWeight: this.bold ? 'bold' : 'normal',
      fontStyle: this.italic ? 'italic' : 'normal'
    };
  }
}

// Context: 개별 문자
class Glyph {
  constructor(char, row, col, style) {
    this.char = char;
    this.row = row;    // 문서상의 줄 번호
    this.col = col;    // 줄 내의 열 번호
    this.style = style;
  }

  render(ctx) {
    const css = this.style.getCSS();
    // 실제로는 Canvas API나 DOM으로 렌더링
    console.log(`"${this.char}"를 ${this.row}:${this.col}${css.fontFamily} ${css.fontSize}로 렌더링`);
  }
}

// Factory
class StyleFactory {
  constructor() {
    this.styles = new Map();
    // 기본 스타일 미리 생성
    this.defaultStyle = this.getStyle('Arial', 12, '#000000', false, false);
  }

  getStyle(fontFamily, fontSize, color, bold, italic) {
    const key = `${fontFamily}_${fontSize}_${color}_${bold}_${italic}`;
    if (!this.styles.has(key)) {
      this.styles.set(key, new TextStyle(fontFamily, fontSize, color, bold, italic));
    }
    return this.styles.get(key);
  }
}

// 사용 예시
const styleFactory = new StyleFactory();
const style1 = styleFactory.getStyle('Arial', 12, '#000000', false, false);
const glyphs = [
  new Glyph('H', 0, 0, style1),
  new Glyph('e', 0, 1, style1),  // 같은 스타일 재사용
  new Glyph('l', 0, 2, style1),
  new Glyph('l', 0, 3, style1),
  new Glyph('o', 0, 4, style1)
];

설명

이것이 하는 일: 텍스트 에디터의 각 문자를 효율적으로 표현하면서도 다양한 스타일을 지원합니다. 첫 번째로, TextStyle 클래스는 시각적 속성만 담습니다.

fontFamily, fontSize, color, bold, italic은 모두 렌더링에 필요한 정보입니다. getCSS 메서드는 이들을 CSS 객체로 변환하여 DOM이나 Canvas에서 바로 사용할 수 있게 합니다.

Object.freeze로 불변성을 보장하여, 한 문자의 스타일 변경이 다른 문자에 영향을 주지 않습니다. 두 번째로, Glyph 클래스는 문자의 내용과 위치를 나타냅니다.

char는 'A', 'B' 같은 실제 문자이고, row와 col은 문서상의 2D 좌표입니다. 이를 통해 커서 이동, 선택 영역 계산 등이 가능합니다.

style은 TextStyle 참조이므로, 같은 스타일의 10,000개 문자가 모두 하나의 TextStyle을 가리킵니다. 세 번째로, StyleFactory는 중요한 최적화를 합니다.

constructor에서 defaultStyle을 미리 생성하여, 대부분의 문자가 사용하는 기본 스타일을 즉시 제공합니다. 사용자가 "Hello World"를 입력하면 11개의 Glyph가 모두 defaultStyle을 참조하므로, 추가 스타일 생성이 전혀 없습니다.

실제 동작을 시뮬레이션해봅시다. 사용자가 "Hello"를 입력하고 "llo"를 선택하여 굵게 만듭니다.

이때 일어나는 일은:


1. 처음 5개 Glyph는 모두 defaultStyle 참조


2. 사용자가 굵게 버튼 클릭


3. boldStyle = styleFactory.getStyle('Arial', 12, '#000000', true, false) 호출


4. glyphs[2], glyphs[3], glyphs[4]의 style을 boldStyle로 변경


5. 렌더링 시 각 Glyph는 자신의 style.getCSS()를 호출

실전 팁

💡 자주 사용되는 스타일(기본, 제목1, 제목2 등)을 미리 생성하세요. 사용자의 90% 이상의 작업이 이 스타일들로 커버됩니다. 💡 스타일 변경 시 새 Glyph를 만들지 말고 style 참조만 교체하세요. 불변 패턴으로 실행 취소/다시 실행이 간단해집니다. 💡 문서 저장 시 스타일 팔레트를 먼저 저장하고 각 문자는 스타일 ID만 저장하세요. 파일 크기가 극적으로 줄어듭니다. 💡 Canvas 렌더링 시 같은 스타일의 연속된 문자를 배치로 그리세요. Flyweight 덕분에 스타일 그룹핑이 쉽고, 렌더링 성능이 크게 향상됩니다. 💡 서식 복사 기능은 Flyweight와 완벽히 호환됩니다. 소스 문자의 style 참조를 대상 문자들에 복사하기만 하면 됩니다.


6. 성능_측정과_비교

시작하며

여러분이 Flyweight 패턴을 적용했다면 이제 "정말 효과가 있는가?"를 확인할 차례입니다. 개발자들이 자주 하는 실수는 패턴을 적용하고 나서 실제 성능을 측정하지 않는 것입니다.

어쩌면 여러분의 상황에서는 객체 수가 충분히 적어서 패턴의 오버헤드가 오히려 손해일 수도 있습니다. 또한 팀원들이나 이해관계자들에게 "Flyweight를 적용해서 메모리를 90% 줄였습니다"라고 말할 때, 구체적인 수치가 있어야 설득력이 있습니다.

"전에는 500MB였는데 지금은 50MB입니다"라는 데이터가 필요하죠. 바로 이럴 때 필요한 것이 체계적인 성능 측정입니다.

전통적 방식과 Flyweight 방식을 동일한 조건에서 비교하여 객관적인 데이터를 얻어야 합니다.

개요

간단히 말해서, 성능 측정은 Flyweight 패턴 적용 전후의 메모리 사용량, 생성 시간, 렌더링 속도를 정량적으로 비교하는 과정입니다. 실무에서 성능 측정이 필수인 이유는 여러 가지입니다.

첫째, 패턴이 실제로 효과가 있는지 검증합니다. 이론과 실제는 다를 수 있으니까요.

둘째, 병목 지점을 발견합니다. 어쩌면 메모리는 개선되었지만 Factory의 캐시 검색이 느려졌을 수도 있습니다.

셋째, 기술 부채를 정당화합니다. "패턴 적용에 3일 걸렸지만 메모리를 80% 줄여서 앱 크래시가 사라졌습니다"라는 보고를 할 수 있죠.

전통적인 방식에서는 console.log나 추측으로 성능을 판단했다면, 현대적 방식에서는 Chrome DevTools, performance.memory API, 벤치마크 라이브러리를 사용합니다. 측정해야 할 핵심 지표는 다음과 같습니다.

첫째, 힙 메모리 사용량 - 전체 객체가 차지하는 메모리. 둘째, 객체 생성 시간 - 10,000개 객체 생성에 걸리는 시간.

셋째, Flyweight 재사용률 - 전체 객체 중 몇 %가 Flyweight를 공유하는가. 이 지표들이 패턴의 효과를 정량적으로 보여줍니다.

코드 예제

// 전통적 방식: 모든 데이터를 각 객체가 소유
class TraditionalTree {
  constructor(x, y, name, color, texture) {
    this.x = x;
    this.y = y;
    this.name = name;          // 중복 저장
    this.color = color;        // 중복 저장
    this.texture = texture;    // 중복 저장 (큰 데이터)
  }
}

// Flyweight 방식
class FlyweightTree {
  constructor(x, y, type) {
    this.x = x;
    this.y = y;
    this.type = type;  // 참조만 저장
  }
}

// 성능 측정 함수
function measurePerformance(count) {
  // 전통적 방식 측정
  const traditionalStart = performance.now();
  const traditionalTrees = [];
  for (let i = 0; i < count; i++) {
    traditionalTrees.push(new TraditionalTree(i, i, 'pine', 'green', 'texture_data_5MB'));
  }
  const traditionalTime = performance.now() - traditionalStart;

  // Flyweight 방식 측정
  const factory = new TreeFactory();
  const pineType = factory.getTreeType('pine', 'green', 'texture_data_5MB');
  const flyweightStart = performance.now();
  const flyweightTrees = [];
  for (let i = 0; i < count; i++) {
    flyweightTrees.push(new FlyweightTree(i, i, pineType));
  }
  const flyweightTime = performance.now() - flyweightStart;

  console.log(`=== ${count}개 객체 생성 ===`);
  console.log(`전통적 방식: ${traditionalTime.toFixed(2)}ms`);
  console.log(`Flyweight 방식: ${flyweightTime.toFixed(2)}ms`);
  console.log(`속도 향상: ${(traditionalTime / flyweightTime).toFixed(2)}배`);

  // 메모리 사용량 (근사치)
  const traditionalMemory = count * 100;  // 각 객체 약 100바이트
  const flyweightMemory = count * 20 + 100;  // 각 객체 20바이트 + Flyweight 100바이트
  console.log(`전통적 메모리: ${(traditionalMemory / 1024).toFixed(2)}KB`);
  console.log(`Flyweight 메모리: ${(flyweightMemory / 1024).toFixed(2)}KB`);
  console.log(`메모리 절감: ${((1 - flyweightMemory / traditionalMemory) * 100).toFixed(2)}%`);
}

measurePerformance(10000);

설명

이것이 하는 일: 동일한 조건에서 전통적 방식과 Flyweight 방식을 비교하여 패턴의 효과를 정량적으로 증명합니다. 첫 번째로, TraditionalTree는 모든 속성을 멤버 변수로 갖습니다.

name, color, texture를 각 인스턴스가 복사하여 저장합니다. 특히 texture는 실제로는 큰 이미지 데이터를 나타내므로, 문자열 'texture_data_5MB'로 시뮬레이션합니다.

10,000개를 만들면 이 데이터가 10,000번 중복됩니다. 두 번째로, FlyweightTree는 x, y와 type 참조만 갖습니다.

type은 TreeType 인스턴스를 가리키는 포인터(JavaScript에서는 참조)이므로, 실제로는 8바이트 정도입니다. 10,000개를 만들어도 TreeType은 단 하나만 존재하고, 각 FlyweightTree는 이를 가리키기만 합니다.

세 번째로, measurePerformance 함수는 공정한 비교를 위해 같은 count로 두 방식을 테스트합니다. performance.now()는 마이크로초 단위로 시간을 측정하여 정확한 비교가 가능합니다.

전통적 방식은 매번 new TraditionalTree를 호출하지만, Flyweight 방식은 한 번만 getTreeType을 호출하고 나머지는 재사용합니다. 네 번째로, 메모리 계산은 근사치입니다.

JavaScript에서 실제 메모리를 정확히 측정하기는 어렵지만, 객체 구조를 기반으로 추정할 수 있습니다. TraditionalTree는 5개 속성 × 20바이트 = 100바이트로 가정하고, FlyweightTree는 3개 속성 × 8바이트 = 24바이트로 가정합니다.

실제로는 V8 엔진의 히든 클래스, 패딩 등이 있지만 비율은 비슷합니다. 실제 실행 결과를 예상해보면: - 전통적 방식: 50ms, 976KB - Flyweight 방식: 10ms, 195KB - 속도 향상: 5배 - 메모리 절감: 80% 여러분이 이 측정을 실행하면 Flyweight의 효과를 명확히 볼 수 있습니다.

또한 이 코드를 CI/CD 파이프라인에 통합하면 성능 회귀를 자동으로 감지할 수 있습니다. 누군가 실수로 Flyweight를 깨뜨리면 테스트가 실패하겠죠.

실전 팁

💡 Chrome DevTools의 Memory 프로파일러를 사용하세요. 힙 스냅샷을 찍어 실제 객체 수와 메모리 사용량을 정확히 확인할 수 있습니다. 💡 벤치마크는 여러 번 실행하여 평균을 내세요. 첫 실행은 JIT 컴파일 때문에 느릴 수 있습니다. 최소 5회 실행 후 평균과 표준편차를 계산하세요. 💡 performance.memory API(Chrome 전용)를 사용하면 실제 힙 사용량을 얻을 수 있습니다. 단, 브라우저마다 다르므로 주의하세요. 💡 대규모 테스트(100,000개 이상)를 실행하여 극단적 상황을 시뮬레이션하세요. 작은 수에서는 차이가 미미해도 대규모에서 극명해집니다. 💡 측정 결과를 그래프로 시각화하세요. 객체 수에 따른 메모리/시간 그래프를 그리면 패턴의 효과가 선형적으로 증가함을 보여줄 수 있습니다.


7. 불변성_관리_패턴

시작하며

여러분이 Flyweight 패턴을 완벽히 구현했다고 생각했는데, 어느 날 버그 리포트가 들어옵니다. "한 나무의 색을 바꿨는데 모든 같은 종류의 나무가 색이 변했어요!" 원인을 추적해보니 누군가 공유 객체의 속성을 직접 수정했습니다.

tree.type.color = 'red' 같은 코드가 있었던 거죠. 이 문제는 Flyweight 패턴의 가장 큰 함정입니다.

공유 객체는 여러 Context가 참조하므로, 한 곳에서 수정하면 모든 곳에 영향을 미칩니다. 이는 디버그하기 매우 어려운 버그를 만들어냅니다.

바로 이럴 때 필요한 것이 불변성(immutability) 관리입니다. Flyweight 객체를 절대 변경할 수 없게 만들어, 이런 버그를 컴파일/런타임에 차단해야 합니다.

개요

간단히 말해서, 불변성 관리는 Flyweight 객체가 생성 후 절대 변경되지 않도록 보장하는 설계 기법입니다. 변경이 필요하면 새 Flyweight를 생성하여 교체합니다.

실무에서 불변성이 중요한 이유는 버그 예방과 직결되기 때문입니다. 가변 공유 객체는 액션 앳 어 디스턴스(action at a distance) 문제를 일으킵니다.

즉, 코드의 한 부분이 멀리 떨어진 다른 부분에 예측 불가능한 영향을 미칩니다. 이는 디버그가 거의 불가능합니다.

예를 들어, 멀티스레드 환경에서 가변 Flyweight는 레이스 컨디션을 일으켜 간헐적 크래시를 유발합니다. 전통적인 방식에서는 객체의 불변성을 문서나 주석으로만 명시했다면, 현대적 방식에서는 언어 기능(Object.freeze, readonly)이나 라이브러리(Immutable.js)로 강제합니다.

불변성을 보장하는 방법은 여러 가지입니다. 첫째, Object.freeze()로 객체를 동결합니다.

둘째, TypeScript의 readonly 키워드를 사용합니다. 셋째, getter만 제공하고 setter는 제거합니다.

넷째, 방어적 복사를 사용합니다. 이러한 기법들이 실수를 컴파일 타임이나 런타임에 잡아냅니다.

코드 예제

// TypeScript에서의 불변 Flyweight (JavaScript로 변환 가능)
class ImmutableTreeType {
  constructor(name, color, texture) {
    // 내부 속성을 비공개로
    this._name = name;
    this._color = color;
    this._texture = texture;

    // 객체 동결로 런타임 보호
    Object.freeze(this);
  }

  // Getter만 제공 (setter 없음)
  get name() { return this._name; }
  get color() { return this._color; }
  get texture() { return this._texture; }

  // 변경이 필요하면 새 인스턴스 반환
  withColor(newColor) {
    return new ImmutableTreeType(this._name, newColor, this._texture);
  }

  draw(x, y) {
    console.log(`${this._name}(${this._color})를 (${x},${y})에 그립니다`);
  }
}

// 사용 예시
const pineType = new ImmutableTreeType('pine', 'green', 'pine.png');

// 이것은 불가능 (런타임 에러)
try {
  pineType.color = 'red';  // TypeError in strict mode
} catch (e) {
  console.log('오류: Flyweight는 불변입니다');
}

// 올바른 방법: 새 인스턴스 생성
const redPineType = pineType.withColor('red');
console.log(pineType.color);     // 'green' - 원본 유지
console.log(redPineType.color);  // 'red' - 새 객체

설명

이것이 하는 일: Flyweight 객체를 불변으로 만들어 공유로 인한 버그를 원천적으로 차단합니다. 첫 번째로, constructor에서 Object.freeze(this)를 호출합니다.

이는 객체의 모든 속성을 읽기 전용으로 만들고, 새 속성 추가도 막습니다. strict mode에서는 수정 시도 시 TypeError를 던지고, non-strict mode에서는 조용히 무시됩니다.

따라서 항상 strict mode를 사용하세요. 두 번째로, 속성을 _name처럼 밑줄로 시작하여 "내부 속성"임을 표시합니다.

그리고 getter만 제공하여 읽기는 가능하지만 쓰기는 불가능하게 합니다. pineType.name은 작동하지만 pineType.name = 'oak'는 실패합니다.

TypeScript에서는 readonly 키워드로 더 강력하게 강제할 수 있습니다. 세 번째로, withColor 같은 "변경 메서드"는 원본을 수정하지 않고 새 인스턴스를 반환합니다.

이는 함수형 프로그래밍의 핵심 원칙입니다. 예를 들어, 사용자가 나무 색을 변경하고 싶으면 새 TreeType을 생성하고 해당 나무의 type 참조를 교체합니다.

원본 TreeType을 참조하는 다른 나무들은 영향을 받지 않습니다. 네 번째로, 사용 예시를 보면 직접 수정 시도는 에러가 발생합니다.

pineType.color = 'red'는 Object.freeze 때문에 실패합니다. 대신 redPineType = pineType.withColor('red')로 새 객체를 만들면, 두 객체가 독립적으로 존재합니다.

이제 pineType을 참조하는 나무는 초록색이고, redPineType을 참조하는 나무는 빨간색입니다. 실무에서의 워크플로우를 보면:


1. Factory에서 ImmutableTreeType 생성


2. 여러 Tree가 이를 참조


3. 사용자가 일부 나무의 색 변경 요청


4. newType = factory.getTreeType('pine', 'red', 'pine.png') 호출 (또는 withColor 사용)


5. 해당 Tree들의 type 참조만 newType으로 변경


6. 원본 pineType은 그대로 유지되어 다른 나무들에 영향 없음

실전 팁

💡 개발 환경에서는 Object.freeze를 사용하고, 프로덕션에서는 성능을 위해 제거하는 것도 가능합니다. 하지만 버그 위험이 있으니 신중히 판단하세요. 💡 TypeScript를 사용한다면 Readonly<T> 유틸리티 타입으로 모든 속성을 readonly로 만들 수 있습니다. 컴파일 타임에 에러를 잡아줍니다. 💡 Immutable.js 같은 라이브러리를 사용하면 구조적 공유로 메모리 효율적인 불변 객체를 만들 수 있습니다. 특히 중첩된 객체에 유용합니다. 💡 with* 메서드 네이밍 컨벤션을 따르세요. withColor, withSize처럼 명명하면 "새 객체를 반환한다"는 의도가 명확해집니다. 💡 Flyweight Factory도 불변 객체만 반환하도록 문서화하세요. 팀 전체가 "Factory에서 온 객체는 절대 수정하지 않는다"는 규칙을 따라야 합니다.


8. 실무_적용_시나리오

시작하며

여러분이 지금까지 Flyweight 패턴의 모든 이론과 구현을 배웠습니다. 이제 "실제로 언제 사용해야 하는가?"라는 질문에 답할 차례입니다.

패턴을 알아도 적용 시점을 모르면 무용지물이니까요. 실무에서 개발자들이 자주 하는 실수는 패턴을 과도하게 적용하거나, 반대로 필요한 곳에 적용하지 않는 것입니다.

"객체가 많으니까 Flyweight를 써야겠다"고 무조건 적용하면 오히려 코드가 복잡해지고 성능이 떨어질 수 있습니다. 바로 이럴 때 필요한 것이 명확한 적용 기준과 실전 시나리오입니다.

어떤 상황에서 효과적이고, 어떤 상황에서는 불필요한지 판단할 수 있어야 합니다.

개요

간단히 말해서, Flyweight 패턴은 대량의 유사 객체를 다루면서 메모리가 병목인 상황에 적합하고, 객체 수가 적거나 고유 데이터가 많은 상황에는 부적합합니다. 실무에서 패턴 선택이 중요한 이유는 잘못된 패턴 적용이 오히려 생산성을 해치기 때문입니다.

패턴은 복잡성을 추가합니다. Factory, 상태 분리, 불변성 관리 등 추가 코드가 필요하죠.

이 복잡성이 얻는 이익보다 크면 안 쓰는 게 낫습니다. 예를 들어, 화면에 나무가 10그루만 있는 게임에서 Flyweight를 쓰면 코드만 복잡해지고 메모리는 몇 KB밖에 안 줍니다.

전통적인 개발에서는 "일단 만들고 나중에 최적화"했다면, 현대적 개발에서는 "처음부터 올바른 패턴을 선택"합니다. 리팩토링 비용이 크니까요.

Flyweight가 효과적인 시나리오는 다음과 같습니다. 첫째, 객체 수가 수천 개 이상입니다.

둘째, 객체들이 많은 공통 데이터를 공유합니다. 셋째, 공통 데이터가 크고 고유 데이터가 작습니다.

넷째, 메모리가 제한적인 환경(모바일, 브라우저)입니다. 이 조건들을 충족하면 Flyweight가 큰 효과를 발휘합니다.

코드 예제

// 체크리스트: Flyweight 적용 여부 판단
class FlyweightApplicabilityChecker {
  static shouldApply(scenario) {
    const {
      objectCount,        // 예상 객체 수
      sharedDataSize,     // 공유 가능한 데이터 크기 (KB)
      uniqueDataSize,     // 고유 데이터 크기 (KB)
      memoryBudget        // 메모리 예산 (MB)
    } = scenario;

    // 기준 1: 객체가 충분히 많은가?
    const hasEnoughObjects = objectCount >= 1000;

    // 기준 2: 공유 데이터가 고유 데이터보다 훨씬 큰가?
    const hasSignificantSharedData = sharedDataSize > uniqueDataSize * 10;

    // 기준 3: 메모리 절약이 의미있는가?
    const traditionalMemory = objectCount * (sharedDataSize + uniqueDataSize);
    const flyweightMemory = sharedDataSize + (objectCount * uniqueDataSize);
    const savings = (traditionalMemory - flyweightMemory) / 1024;  // MB
    const hasMeaningSavings = savings > memoryBudget * 0.2;  // 20% 이상 절약

    console.log(`=== Flyweight 적용성 분석 ===`);
    console.log(`객체 수: ${objectCount}개 (기준: 1000개 이상) ${hasEnoughObjects ? '✓' : '✗'}`);
    console.log(`공유/고유 비율: ${(sharedDataSize / uniqueDataSize).toFixed(1)} (기준: 10배 이상) ${hasSignificantSharedData ? '✓' : '✗'}`);
    console.log(`예상 절약: ${savings.toFixed(2)}MB (${memoryBudget}MB의 ${(savings / memoryBudget * 100).toFixed(1)}%) ${hasMeaningSavings ? '✓' : '✗'}`);

    const shouldApply = hasEnoughObjects && hasSignificantSharedData && hasMeaningSavings;
    console.log(`결론: Flyweight ${shouldApply ? '적용 권장' : '불필요'}`);

    return shouldApply;
  }
}

// 실전 시나리오 예시
const scenarios = [
  {
    name: '게임 - 나무 10,000그루',
    objectCount: 10000,
    sharedDataSize: 5000,    // 텍스처 5MB
    uniqueDataSize: 0.016,   // 좌표 16바이트
    memoryBudget: 512
  },
  {
    name: '대시보드 - 아이콘 50개',
    objectCount: 50,
    sharedDataSize: 10,      // SVG 10KB
    uniqueDataSize: 0.1,     // 위치/색 100바이트
    memoryBudget: 256
  },
  {
    name: '문서 에디터 - 문자 50,000개',
    objectCount: 50000,
    sharedDataSize: 1,       // 폰트 정보 1KB
    uniqueDataSize: 0.01,    // 문자/좌표 10바이트
    memoryBudget: 128
  }
];

scenarios.forEach(scenario => {
  console.log(`\n${scenario.name}`);
  FlyweightApplicabilityChecker.shouldApply(scenario);
});

설명

이것이 하는 일: 정량적 데이터를 기반으로 Flyweight 패턴이 해당 시나리오에 적합한지 객관적으로 판단합니다. 첫 번째로, shouldApply 메서드는 네 가지 입력을 받습니다.

objectCount는 생성할 객체의 수, sharedDataSize는 Flyweight에 들어갈 공유 데이터의 크기, uniqueDataSize는 각 Context의 고유 데이터 크기, memoryBudget은 전체 메모리 예산입니다. 이 값들은 설계 단계에서 추정할 수 있습니다.

두 번째로, 세 가지 기준을 평가합니다. hasEnoughObjects는 객체가 1,000개 이상인지 확인합니다.

경험적으로 1,000개 미만에서는 패턴의 오버헤드가 이익을 상쇄합니다. hasSignificantSharedData는 공유 데이터가 고유 데이터보다 10배 이상 큰지 확인합니다.

비율이 낮으면 공유해도 절약이 미미합니다. hasMeaningSavings는 절약량이 메모리 예산의 20% 이상인지 확인합니다.

5%만 절약되면 복잡성 대비 효과가 작습니다. 세 번째로, 메모리 계산 로직을 봅시다.

traditionalMemory는 모든 객체가 전체 데이터를 갖는 경우로, objectCount × (sharedDataSize + uniqueDataSize)입니다. flyweightMemory는 Flyweight 하나 + 각 Context의 고유 데이터로, sharedDataSize + (objectCount × uniqueDataSize)입니다.

savings는 둘의 차이입니다. 네 번째로, 실전 시나리오 예시를 분석해봅시다.

게임 나무 시나리오는 10,000개 객체, 5MB 공유 데이터, 16바이트 고유 데이터입니다. 비율이 312,500배이므로 Flyweight가 매우 효과적입니다.

반면 대시보드 아이콘 시나리오는 50개뿐이므로 첫 번째 기준을 통과하지 못해 불필요합니다. 문서 에디터는 50,000개이고 비율도 100배라 적용 권장입니다.

실제 출력을 예상하면: - 게임: "✓✓✓ 적용 권장" - 48.8MB 절약 (9.5%) - 대시보드: "✗✓✗ 불필요" - 0.48MB 절약 (0.2%) - 문서 에디터: "✓✓✓ 적용 권장" - 48.8MB 절약 (38%) 여러분이 이 체크리스트를 사용하면 감이 아닌 데이터로 결정할 수 있습니다. 또한 팀원이나 매니저를 설득할 때 객관적 근거를 제시할 수 있습니다.

"이 패턴을 적용하면 메모리를 40MB 절약하여 앱 크래시를 방지할 수 있습니다"라고 말이죠.

실전 팁

💡 프로토타입을 먼저 만들어 실제 데이터를 측정하세요. 추정은 틀릴 수 있으므로, 작은 규모로 구현하여 메모리를 프로파일링한 후 결정하세요. 💡 적용 여부는 이진 선택이 아닙니다. 일부 컴포넌트에만 적용하는 것도 가능합니다. 예를 들어, UI 아이콘은 전통적 방식, 게임 엔티티는 Flyweight로 혼합할 수 있습니다. 💡 팀의 숙련도를 고려하세요. Flyweight는 중급 이상의 패턴입니다. 주니어 개발자가 많다면 충분한 교육 없이 적용하지 마세요. 💡 비즈니스 요구사항을 우선하세요. 패턴이 완벽해도 일정이 촉박하면 일단 간단한 방식으로 구현하고 나중에 리팩토링할 수 있습니다. 💡 대안을 검토하세요. Object Pooling, Lazy Loading, Virtual Scrolling 등 다른 최적화 기법이 더 적합할 수 있습니다. Flyweight는 만능이 아닙니다.


#JavaScript#Flyweight#DesignPattern#MemoryOptimization#Performance

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.