⊙ 싱글톤 패턴을 이해하고 구현 방법과 활용 방법을 안다.
⊙ 팩토리 메서드 패턴을 이해하고 구현 방법과 활용 방법을 안다.
⊙ 추상 팩토리 패턴을 이해하고 구현 방법과 활용 방법을 안다.
⊙ 빌드 패턴을 이해하고 구현 방법과 활용 방법을 안다.
⊙ 프로토타입 패턴을 이해하고 구현 방법과 활용 방법을 안다.
싱글톤(Singleton) 패턴
시스템 내에서 하나의 인스턴스만 존재하도록 보장하는 패턴이다.
구현 방법
그럼 어떻게 시스템 내에 인스턴스가 딱 하나만 존재한다는 것을 보장할 수 있을까?
그냥 생각나는대로 얘기해보자면, 인스턴스를 하나 만들고, 인스턴스를 또 만드려고 하면 못 만들게 하면 되는 것 아닐까? 못 만들게 하고 대신 전에 만들어 뒀던 걸 가져다 쓰라고 주면 하나 가지고 잘 돌려쓸 수 있을 것 같아 보인다.
class Logger {
private:
static Logger* instance;
Logger() {}
~Logger() {}
Logger(const Logger&) = delete; // 복사 생성자 삭제
Logger& operator=(const Logger&) = delete; // 대입 연산자 삭제
public:
static Logger* GetInstance() {
if (instance == nullptr) {
instance = new Logger();
}
return instance;
}
void Log(const std::string& message) {
std::cout << "Log: " << message << std::endl;
}
};
Logger* Logger::instance = nullptr;
int main() {
Logger::GetInstance()->Log("Application started");
return 0;
}
그걸 C++ 코드로 구현해본 것이다. 나는 Logger라는 클래스로 만든 객체가 딱 하나만 있었으면 좋겠다. 나에게 로그를 찍어줄 Logger는 딱 하나만 있어야 하기 때문이다.
Logger 클래스에는 이제부터 애지중지 하나 유지할 instance를 멤버 변수로 선언한다. 이제 우리 프로젝트에 Logger는 저기 있는 instance가 유일해야할 것이다. 클래스 내의 정적 멤버 변수(클래스 변수)이기 때문에 초기화는 클래스 밖에서 nullptr로 해주도록 한다.
그리고 개발자가 마음대로 생성자를 이용해서 Logger를 생성할 수없도록 기본 생성자를 private 영역으로 이동시킨다. 복사 생성자와 대입 연산자에 대한 선언이 있었다면 모두 지워주는 것이 좋다.
이제 기본 생성자를 대신해 개발자가 클래스 외부에서 Logger를 생성할 정적 메서드를 만들어준다. GetInstance는 정적 메서드이며 Logger를 반환한다. 이 함수는 instance가 존재하지 않는다면, 즉 nullptr일 경우 instance를 새로 new하여 만들어준다. 하지만 그게 아닐 경우는 새로 생성하지 않고 기존에 instance를 리턴한다.
- 클래스 내에 정적 멤버 변수 instance를 선언한다.
- 생성자는 private 영역에 두도록 하고 복사 생성자, 대입 연산자는 삭제한다.
- 생성자를 대신하여 instance를 생성할 정적 메소드를 선언한다.
- 정적 메소드에서는 instance가 존재하지 않을 때만 생성자를 이용해 인스턴스를 생성하도록 한다.
- 이미 instance가 존재한다면 그 instance를 반환하게 한다.
마지막으로 어떻게 사용하는지 보도록 하자. Logger를 생성하기 위해 기본 생성자를 대신하여 GetInstance를 사용한다. 처음 만드는 것이라면 Logger를 new로 생성하여 리턴할 것이고 이미 다른 곳에서 생성한 적이 있다면 기존 instance를 리턴할 것이다. 이렇게 받은 클래스의 메소드 Log도 잘 사용되는 것을 확인할 수 있다.
활용 방법
하나의 인스턴스만 존재하도록 보장해야 하는 경우는 어떤 것들이 있을까? 데이터베이스 연결을 관리, 게임 매니저 등은 하나의 인스턴스가 보장되어야만 하는 것들이다. 특히 게임에선 게임 매니저, 오디오 매니저, 입력 매니저 등 여러개 있어서는 안되는 것들을 싱글톤(Singleton)으로 구현한다. 실제로 Unity에서 매니저를 만들 때 많이 사용해보았다.
class GameManager {
private:
static GameManager* instance; // 싱글톤 인스턴스
GameManager() {} // 생성자를 private으로 막음
public:
static GameManager* GetInstance() {
if (instance == nullptr) {
instance = new GameManager();
}
return instance;
}
void PrintState() {
std::cout << "Game is running!" << std::endl;
}
};
GameManager* GameManager::instance = nullptr;
// 사용 예시
int main() {
GameManager::GetInstance()->PrintState();
return 0;
}
이런식으로 GameManager 객체 생성을 개발자가 마음대로 생성하지 못하도록 생성자를 private로 하고 클래스 내에 GameManager 하나를 static으로 가지고 관리한다. GameManager를 생성하려면 GetInstance 함수를 사용해야 하는데 이 때 생성된 GameManager 객체가 없다면 새로 생성하고 이미 있다면 이미 있는 객체를 반환하도록 한다. 이로써 GameManager 인스턴스는 단 하나만 생성되도록 할 수 있다.
장점과 단점
싱글톤 패턴은 객체를 생성하고 소멸하는데 사용되는 메모리 낭비 문제를 방지할 수 있다는 장점이 있다.
하지만 단점도 여러개 존재한다.
- 의존성이 높아진다.
: instance를 생성하여 사용하는 모두가 연결되어 있기 때문에 클래스 사이에 의존성이 높아진다는 문제점이 있다.(=높은 결합) 그래서 싱글톤의 instance가 변경되면 해당 인스턴스를 참조하는 모든 클래스들을 수정해야 하는 문제가 발생한다. - private 생성자 때문에 상속이 어렵다.
: 싱글톤 패턴은 private로 만들었기 때문에 상속을 통한 자식 클래스를 만들 수 없다는 문제점이 있다. - 테스트하기 어렵다.
: 싱글톤 패턴의 인스턴스는 자원을 공유하고 있다는 특징이 있다. 이는 서로 독립적이어야하는 단위 테스트를 하는데 문제가 된다.
당장에 Unity로 여러 프로젝트를 진행하면서 GameManager는 싱글톤 패턴으로 만들어왔다. 하지만 가장 문제라고 느껴지는 것이 테스트 부분이다. GameManager의 기능을 사용하는 모든 부분들이 GameManager와 연결되어 있어서 독립적인 테스트가 불가능했다.
이런 이유로 메모리 낭비 문제를 방지하겠다는 이유만으로 싱글톤 패턴을 사용하는 것은 좋지 않을 듯하다.
팩토리 메서드(Factory Method) 패턴
객체 생성을 서브 클래스에 위임하는 패턴이다. 즉, 상위 클래스에서는 인터페이스만 정의하고 실제 생성은 서브 클래스가 담당한다.
구현 방법
무슨 말인지 이해하기 위해서는 예제를 보는 것이 더 좋을 것 같다. 아래 코드를 보면 Animal이라는 상위 클래스가 있다. virtual 함수만 있는 것으로 보아 인터페이스다. 그리고 그 자식으로 Dog와 Cat이 있다. 각각 Speak라는 함수가 override 하여 구현되어 있다.
그리고 그 아래로 AnimalFactory라는 클래스가 선언되어 있다. 여기부터가 시작이다. AnimalFactory도 보면 모두 가상 함수로 인터페이스라는 것을 알 수 있다. 진짜 구현을 하고 있는 부분을 찾아보면 DogFactory와 CatFactory를 찾을 수 있다. 둘은 모두 AnimalFactory를 상속 받고 있다.
#include <iostream>
#include <memory>
using namespace std;
// --- 공통 인터페이스 ---
class Animal {
public:
virtual void Speak() const = 0; // 순수 가상 함수
virtual ~Animal() = default;
};
// --- Animal의 실제 구현체 ---
class Dog : public Animal {
public:
void Speak() const override {
cout << "Woof! Woof!" << endl;
}
};
class Cat : public Animal {
public:
void Speak() const override {
cout << "Meow! Meow!" << endl;
}
};
// --- 객체 생성에 대한 인터페이스 ---
class AnimalFactory {
public:
virtual unique_ptr<Animal> CreateAnimal() const = 0; // Factory Method
virtual ~AnimalFactory() = default;
};
// --- 실제로 객체 생성의 책임을 지는 클래스 ---
class DogFactory : public AnimalFactory {
public:
unique_ptr<Animal> CreateAnimal() const override {
return make_unique<Dog>();
}
// 강아지에게 날개를 붙여주는 함수
unique_ptr<Dog> MakeWings(unique_ptr<Dog> dog) {
cout<<"dog wings added"<<endl;
return dog;
}
};
class CatFactory : public AnimalFactory {
public:
unique_ptr<Animal> CreateAnimal() const override {
return make_unique<Cat>();
}
};
// --- Client Code ---
int main() {
unique_ptr<AnimalFactory> factory;
// Create a Dog
factory = make_unique<DogFactory>();
unique_ptr<Animal> dog = factory->CreateAnimal();
dog->Speak(); // "Woof! Woof!"
unique_ptr<Dog> windDog=factory.makeWings(dog); // "dog wings added"
// Create a Cat
factory = make_unique<CatFactory>();
unique_ptr<Animal> cat = factory->CreateAnimal();
cat->Speak(); // "Meow! Meow!"
return 0;
}
구조를 전체적으로 보았으니 자세히 보도록 하자. 그래서 위에 언급된 클래스 중에 생성의 책임을 지고 있는 클래스는 무엇이었는가? 바로 DogFactory와 CatFactory다. 설명에서 처럼 상위 클래스는 인터페이스만 제공하고 있을 뿐 다른건 하지 않았다. 이 두 클래스는 모두 상위 클래스의 인터페이스를 상속 받은 자식 클래스들이다. 그리고 그 자식들이 상위 클래스에 있는 CreateAnimal 함수를 override 하여 구현하고 있는 것을 볼 수 있다. 구현 내용은 Dog를 생성하여 반환하고 Cat을 생성해서 반환하는 것이다.
그럼 이 코드를 어떻게 쓸 수 있는지 보자. main을 보면 먼저 factory를 정의한다. 그리고 Dog를 만들기 위해서는 DogFactory의 CreateAnimal을 이용하고 Cat을 만들기 위해서 CatFactory의 CreatAnimal을 사용하는 것을 볼 수 있다. 그리고 만든 인스턴스로 각각 speak()함수도 잘 호출되는 것을 볼 수 있다.
얘기하지 않고 넘어간 부분이 있는데 DogFactory로 다시 돌아가 보도록 하자. makeWings라는 함수가 있다. 이 함수는 Cat에는 없는 함수이다. 이처럼 팩토리에 기능을 더 추가하여 구현할 수도 있다.
- 공통 인터페이스(Animal)와 각각 그 인터페이스를 부모로 한 실제 구현체(Dog, Cat)들이 있다.
- 객체 생성에 대한 인터페이스(AnimalFactory)가 있다.
- 실제로 객체의 생성을 책임질 서브 클래스(DogFactory, CatFactory)를 구현체별로 생성한다.
- 각각의 팩토리에서 원하는 기능을 더 추가할 수도 있다.
실제 코드에서는 Facotry라고 표현되어 있지 않은 경우도 있다. Manager, Creator 다양한 이름으로 보일 수 있으니 구분에 유의한다.
활용 방법
어떤 구조로 어떻게 작동하는지는 이해했다. 그런데 이게 왜 좋은 것일까? 어디에 사용해야 이 구조의 혜택을 볼 수 있는 것일까? 팩토리 메소드 패턴은 다양한 종류의 객체를 동적으로 생성해야할 때 사용한다. 다양한 버튼을 생성하는 GUI 라이브러리, 게임에서는 무기 시스템, 적 스폰 시스템을 예시로 들 수 있겠다. 아래는 적 스폰 시스템의 예시다.
#include <iostream>
#include <memory>
using namespace std;
// --- Abstract Product ---
class Enemy {
public:
virtual void Attack() const = 0; // 순수 가상 함수
virtual ~Enemy() = default;
};
// --- Concrete Products ---
class Goblin : public Enemy {
public:
void Attack() const override {
cout << "Goblin attacks with a club!" << endl;
}
};
class Orc : public Enemy {
public:
void Attack() const override {
cout << "Orc attacks with a sword!" << endl;
}
};
// --- Creator (Factory) ---
class EnemyFactory {
public:
virtual unique_ptr<Enemy> CreateEnemy() const = 0; // Factory Method
virtual ~EnemyFactory() = default;
};
// --- Concrete Creators ---
class GoblinFactory : public EnemyFactory {
public:
unique_ptr<Enemy> CreateEnemy() const override {
return make_unique<Goblin>();
}
};
class OrcFactory : public EnemyFactory {
public:
unique_ptr<Enemy> CreateEnemy() const override {
return make_unique<Orc>();
}
};
// --- Client Code ---
int main() {
unique_ptr<EnemyFactory> factory;
// Create a Goblin
factory = make_unique<GoblinFactory>();
unique_ptr<Enemy> goblin = factory->CreateEnemy();
goblin->Attack(); // "Goblin attacks with a club!"
// Create an Orc
factory = make_unique<OrcFactory>();
unique_ptr<Enemy> orc = factory->CreateEnemy();
orc->Attack(); // "Orc attacks with a sword!"
return 0;
}
위에서 보던 Animal을 한 번 보고나니 별로 다를게 없다는 것을 한눈에 알 수 있다. Enemy라는 공통 인터페이스가 주어지고 그 아래로 Orc, Goblin 등의 실제 구현체 클래스들이 있다. 그리고 아주 중요한 팩토리들이 등장한다. EnemyFactory는 객체 생성의 인터페이스가 있다. 그리고 실제 객체를 생성하는 GoblinFactory, OrcFactory가 있는 것을 볼 수 있다. 마찬가지로 각각 CreateEnemy에서 적을 생성하고 있다.
장점과 단점
팩토리 메서드는 확장성이 좋다는 장점이 있다. 위에 예시 코드에서 새로운 동물 클래스를 추가해도 기존 코드를 수정할 필요는 없다. 만약 새를 추가한다고 하면 Animal을 상속받는 Bird 클래스와 AnimalFactory를 상속 받는 BirdFactory만 만들어주면 된다. 기존 코드에는 수정할 것이 없다.
또한 객체 생성 코드 분리한다는 점에도 장점이 있다. 객체 생성 로직을 클라이언트 코드에서 분리하여 유지 보수가 용이하다는 장점도 있다.
하지만 클래스 수가 증가한다는 단점과 구현 복잡성이 증가한다는 단점도 있다. 또한 객체 생성 시 다양한 매개변수를 필요로 한다면 팩토리 메서드 패턴 만으로는 구현하기 어렵다. 그걸 해결하기 위해 빌더 패턴과 결합하여 사용하면 코드가 더 복잡해지기도 한다.
추상 팩토리(Abstract Factory) 패턴
관련 객체 그룹을 생성하기 위한 인터페이스를 제공한다.
이름도 비슷한데 팩토리 메소드랑 비슷한게 아닌가 할 수도 있지만 "객체 그룹"이라는 것에서 차이가 있다. 비슷하게 팩토리를 만들긴 하지만 팩토리 메서드가 단일 객체를 생성하기 위한 인터페이스를 제공하고 있다면 추상 팩토리는 제품군 전체를 생성하기 위한 인터페이스를 제공하고 있다. Dog를 하나 생산하는 팩토리 메서드와 아래의 예가 어떻게 다른지 비교해보자.
구현 방법
아래 예제는 다크 모드/라이트 모드에 따라 UI 위젯을 생성하는 내용이다. UI 종류에는 각각 Button ,CheckBox가 있다. 각 UI 종류마다 인터페이스가 있고 그 아래 자식으로 Dark 버전의 UI와 Light 버전의 UI가 있다. Button은 DarkButton, LightButton이 있고 CheckBox는 DarkCheckBox, LightCheckBox가 있다. 여기가 구현체들에 대한 이야기고 이 다음부터는 팩토리가 있다.
추상 팩토리인 UIFactory가 있고 그 안에는 가상 함수만 있으므로 인터페이스이다. 그 아래로는 실제 생성을 책임지고 있는 팩토리들이 있다. DarkUIFactory와 LightUIFactory는 각각 다크 버전의 UI와 라이트 버전의 UI를 생성하는데 안에를 잘 보면 생성하는 것이 한 종류가 아닌 것을 볼 수 있다.
#include <iostream>
#include <memory>
using namespace std;
// --- Abstract Product A ---
class Button {
public:
virtual void Render() const = 0;
virtual ~Button() = default;
};
// --- Concrete Product A ---
class DarkButton : public Button {
public:
void Render() const override {
cout << "Rendering Dark Button" << endl;
}
};
class LightButton : public Button {
public:
void Render() const override {
cout << "Rendering Light Button" << endl;
}
};
// --- Abstract Product B ---
class Checkbox {
public:
virtual void Render() const = 0;
virtual ~Checkbox() = default;
};
// --- Concrete Product B ---
class DarkCheckbox : public Checkbox {
public:
void Render() const override {
cout << "Rendering Dark Checkbox" << endl;
}
};
class LightCheckbox : public Checkbox {
public:
void Render() const override {
cout << "Rendering Light Checkbox" << endl;
}
};
// --- Abstract Factory ---
class UIFactory {
public:
virtual unique_ptr<Button> CreateButton() const = 0;
virtual unique_ptr<Checkbox> CreateCheckbox() const = 0;
virtual ~UIFactory() = default;
};
// --- Concrete Factory 1 ---
class DarkUIFactory : public UIFactory {
public:
unique_ptr<Button> CreateButton() const override {
return make_unique<DarkButton>();
}
unique_ptr<Checkbox> CreateCheckbox() const override {
return make_unique<DarkCheckbox>();
}
};
// --- Concrete Factory 2 ---
class LightUIFactory : public UIFactory {
public:
unique_ptr<Button> CreateButton() const override {
return make_unique<LightButton>();
}
unique_ptr<Checkbox> CreateCheckbox() const override {
return make_unique<LightCheckbox>();
}
};
// --- Client Code ---
int main() {
unique_ptr<UIFactory> factory;
// Create Dark Mode UI
factory = make_unique<DarkUIFactory>();
auto darkButton = factory->CreateButton();
auto darkCheckbox = factory->CreateCheckbox();
darkButton->Render(); // "Rendering Dark Button"
darkCheckbox->Render(); // "Rendering Dark Checkbox"
// Create Light Mode UI
factory = make_unique<LightUIFactory>();
auto lightButton = factory->CreateButton();
auto lightCheckbox = factory->CreateCheckbox();
lightButton->Render(); // "Rendering Light Button"
lightCheckbox->Render(); // "Rendering Light Checkbox"
return 0;
}
여기서 실제 생성에 책임을 지고 있는 것은 DarkUIFactory와 LIghtUIFactory이다. 이 둘은 모두 인터페이스인 UIFactory의 상속을 받고 있다. UIFactory에는 CreateButton과 CreateCheckBox가 가상 함수로 선언되어 있다.
관련 객체 그룹을 생성한다는 점에서 팩토리 메소다와 다르다고 하였는데 그건 DarkUIFactory와 LightUIFactory를 보면 알 수 있다. 내부를 살펴보면 전에는 Dog만 생성하던 팩토리 메소드와 다르게 DarkButton도 생성하고 있고 DarkCheckBox도 생성할 수 있다. 즉 관련된 객체"들"을 생성할 수 있는 팩토리인 것이다.
main을 확인해보면 다크 모드의 UI를 만들기 위해 DarkUIFactory를 생성하는 것을 볼 수 있다. 그리고 버튼을 생성할 때는 CreateButton을, CheckBox를 생성할 때는 CreateCheckBox를 사용하고 있다. 그리고 각각 Render함수를 이용한다.
- 추상 팩토리는 관련된 객체를 생성하기 위한 인터페이스를 제공한다.
- 구체 팩토리는 추상 팩토리를 구현하여 관련 객체"들"을 생성한다.
두 개만 있어서 와닿지 않을 수 있지만 CheckBox, Button 말고도 Dropdown, Scrollbar등 다양한 객체들이 한 팩토리에서 생성된다고 생각해보면 앞서 본 팩토리 메서드와 뭐가 다른지 알 수 있을 것이다.
활용 방법
추상 팩토리는 서로 관련된 여러 객체(제품군)을 생성해야 할 때, 객체 간의 일관성을 보장하기 위해 사용한다. OS에 따라 다른 UI 구성 요소 생성, 모드에 따른 UI 생성 등이 예제로 많이 등장한다. 또한 객체 생성 로직이 변경될 가능성이 높은 경우 추상 팩토리를 이용하여 생성 로직을 캡슐화 하면 변경에 따른 영향을 최소화할 수 있다. 예를 들어 게임 개발 중 새로운 테마나 모드를 추가해야할 때가 있겠다.
아래 예제는 게임에서 숲 테마와 우주 테마 두 테마에 따라 다른 월드 구성 요소를 생성하기 위한 코드이다.
#include <iostream>
#include <memory>
using namespace std;
// --- Abstract Product A ---
class Background {
public:
virtual void Render() const = 0;
virtual ~Background() = default;
};
// --- Concrete Product A1 ---
class ForestBackground : public Background {
public:
void Render() const override {
cout << "Rendering a dense forest" << endl;
}
};
// --- Concrete Product A2 ---
class SpaceBackground : public Background {
public:
void Render() const override {
cout << "Rendering the vast expanse of space" << endl;
}
};
// --- Abstract Product B ---
class Character {
public:
virtual void Render() const = 0;
virtual ~Character() = default;
};
// --- Concrete Product B1 ---
class KnightCharacter : public Character {
public:
void Render() const override {
cout << "Rendering a brave knight" << endl;
}
};
// --- Concrete Product B2 ---
class AstronautCharacter : public Character {
public:
void Render() const override {
cout << "Rendering a space astronaut" << endl;
}
};
// --- Abstract Factory ---
class GameWorldFactory {
public:
virtual unique_ptr<Background> CreateBackground() const = 0;
virtual unique_ptr<Character> CreateCharacter() const = 0;
virtual ~GameWorldFactory() = default;
};
// --- Concrete Factory 1: Fantasy Theme ---
class FantasyWorldFactory : public GameWorldFactory {
public:
unique_ptr<Background> CreateBackground() const override {
return make_unique<ForestBackground>();
}
unique_ptr<Character> CreateCharacter() const override {
return make_unique<KnightCharacter>();
}
};
// --- Concrete Factory 2: Sci-Fi Theme ---
class SciFiWorldFactory : public GameWorldFactory {
public:
unique_ptr<Background> CreateBackground() const override {
return make_unique<SpaceBackground>();
}
unique_ptr<Character> CreateCharacter() const override {
return make_unique<AstronautCharacter>();
}
};
// --- Client Code ---
int main() {
unique_ptr<GameWorldFactory> factory;
// Fantasy Theme
factory = make_unique<FantasyWorldFactory>();
auto fantasyBackground = factory->CreateBackground();
auto fantasyCharacter = factory->CreateCharacter();
fantasyBackground->Render(); // "Rendering a dense forest"
fantasyCharacter->Render(); // "Rendering a brave knight"
// Sci-Fi Theme
factory = make_unique<SciFiWorldFactory>();
auto sciFiBackground = factory->CreateBackground();
auto sciFiCharacter = factory->CreateCharacter();
sciFiBackground->Render(); // "Rendering the vast expanse of space"
sciFiCharacter->Render(); // "Rendering a space astronaut"
return 0;
}
GameWorldFactory가 추상 팩토리가 되고 그 자식으로 FantasyWorldFactory, SciFiWorldFactory 팩토리가 있다.각 팩토리는 배경과 캐릭터를 테마에 맞게 생성하고 있는 것을 확인할 수 있다.
반대로 단일 객체만 생성해야 할 경우는 팩토리 메서드 패턴이 더욱 적합하며 복잡성이 필요없는 경우, 즉 제품군의 개수가 적고 확장 가능성이 낮다면 오히려 복잡해질 수 있다.
장점과 단점
클라이언트 코드와 객체 생성 로직이 분리되어 클라이언트 코드를 변경하지 않고도 수정을 할 수 있다는 장점과 확장성이 좋다는 것은 당연한 것이다. 새로운 제품군을 추가할 때 기존 코드를 따로 건드리지 않아도 따로 팩토리만 추가하면 된다. Dark와 Light 테마 말고도 Neon 테마를 추가한다고 하면 Factory 하나만 추가해주면 된다. 거기다가 객체 생성의 일관성 유지라는 장점도 있다. 추상 팩토리는 관련 객체를 함께 생성하므로, 생성되는 객체 간의 일관성을 보장할 수 있다.
단점은 아무래도 클래스 수가 증가하는 것과 복잡성이 증가하는 것일 것이다. 팩토리 이름 달린 친구들의 특징인지 이 단점은 항상 따라다닌다. 거기다가 새로운 제품을 제품군에 추가할 때 번잡스러운 문제가 있다. CreateButton과 CreateCheckBox만 제공하였었는데 CreateSlider를 추가하려고 하면 모든 팩토리를 돌아다니며 메소를 구혀해야 한다. 또한 런타임에서 제품군을 변경하려면 팩토리를 관리하거나 교체하는 로직을 추가해주어야 한다.
빌더(Builder) 패턴
복잡한 객체를 단계별로 생성하고, 생성 과정의 세부 사항을 숨기기 위해 설계된 패턴이다.
이 패턴을 통해 클라이언트는 생성 과정의 세부 사항에 관여하지 않고, 동일한 절차를 통해 서로 다른 종류의 객체를 생성할 수 있다.
구현 방법
전체적으로 무엇이 있는지 살펴보자. 보아하니 결론적으로는 피자를 만들 생각인 것 같다. 최종적으로 생성될 객체는 Pizza이다. 그리고 이 피자 안에는 dough, sauce, topping 데이터가 포함되어 있다. 그리고 각각의 데이터를 채워넣는 Set 함수들도 있다.
그 아래로는 드디어 Builder가 등장한다. 먼저 PizzaBuilder는 인터페이스다. 보면 dough, sauce, topping 등을 올리는 Build~ 함수들이 있다. 그리고 그 아래는 이 추상 빌더를 구현한 클래스 HawaiianPizzaBuilder가 있다. 이 클래스에 진짜 구현이 들어있다. 대충 보니 dough, sauce, topping을 Pizza의 Set 함수들을 이용해 결정하는 것 같다.
마지막으로 이렇게 열심히 만들어 놓은빌더를 사용해 객체를 생성하는 클래스는 Cook이다. SetBuilder를 이용해 위에 만들어 놓은 빌더를 받아와 장착하고 MakePizza를 하는 것이다.
// 최종적으로 생성될 객체
class Pizza {
std::string dough;
std::string sauce;
std::string topping;
public:
void SetDough(const std::string& d) { dough = d; }
void SetSauce(const std::string& s) { sauce = s; }
void SetTopping(const std::string& t) { topping = t; }
void ShowPizza() {
std::cout << "Pizza with " << dough << ", " << sauce << ", " << topping << std::endl;
}
};
// 객체 생성의 각 단계를 정의하는 인터페이스
class PizzaBuilder {
public:
virtual ~PizzaBuilder() {}
virtual void BuildDough() = 0;
virtual void BuildSauce() = 0;
virtual void BuildTopping() = 0;
virtual Pizza* GetPizza() = 0;
};
// 추상 빌더를 구현한 클래스
class HawaiianPizzaBuilder : public PizzaBuilder {
Pizza* pizza;
public:
HawaiianPizzaBuilder() { pizza = new Pizza(); }
~HawaiianPizzaBuilder() { delete pizza; }
void BuildDough() override { pizza->SetDough("Cross"); }
void BuildSauce() override { pizza->SetSauce("Mild"); }
void BuildTopping() override { pizza->SetTopping("Ham and Pineapple"); }
Pizza* GetPizza() override { return pizza; }
};
// 빌더를 사용하여 객체를 생성하는 클래스
class Cook {
PizzaBuilder* builder;
public:
void SetBuilder(PizzaBuilder* b) { builder = b; }
void MakePizza() {
builder->BuildDough();
builder->BuildSauce();
builder->BuildTopping();
}
};
int main() {
Cook cook;
HawaiianPizzaBuilder hawaiianPizzaBuilder;
cook.SetBuilder(&hawaiianPizzaBuilder);
cook.MakePizza();
Pizza* pizza = hawaiianPizzaBuilder.GetPizza();
pizza->ShowPizza();
delete pizza;
return 0;
}
최종적으로 생성해야할 Pizza는 생성하기 위해서는 많은 것을 결정해야 한다. 도우가 뭔지, 소스가 뭔지, 토핑이 뭔지. 이 모든 것들을 단계적으로 결정하고 만들어 주는 것이 Build 클래스이다. 먼저 Build 클래스는 인터페이스를 만든다. 그래야 하와이안 피자 말고도 다른 피자들 빌더를 만들 테니까. 그렇게 만들어진 인터페이스가 PizzaBuilder이다. 그리고 실제로 구현한 클래스가 HawaiianPizzaBuilder 클래스다. 이 클래스 안을 살펴보면 차례차례 단계별로 도우를 놓고, 소스를 바르고, 토핑을 올리는 것을 볼 수 있다.
마지막에 갑자기 등장한 Cook은 빌더를 사용하여 객체를 생성하는 클래스이다. 내부에 PizzaBuilder* builder를 하나 가지고 있으며 이 안에 빌더는 SetBuilder로 장착할 수 있다. SetBuilder로 장착된 PizzaBuilder는 MakePizza가 호출되면 차례대로 도우, 소스, 토핑을 올린다.
main을 살펴보자. 나는 이걸 보면서 밀키트가 생각이 나긴 했다. Cook을 생성하고 HawaiianPizzaBuilder도 하나 생성했다. 나는 이 하와이안피자 빌더가 밀키트 같다고 생각했다. 어떤 재료가 들어갈지 이미 들어가 있는 만들기 키트 느낌? 그걸 cook한테 주니 그걸 가지고 cook은 요리하는 것이다. SetBuilder로 cook에게 이 밀키트를 넘겨준다. 그리고 MakePizza를 하면 피자를 만든다. 만들어진 피자가 보고 싶다면 빌더 안에 있는 함수 중 GetPizza를 이용해 피자를 받아 들고 Pizza의 멤버 함수였던 ShowPizza를 통해 출력한다.
- 최종적으로 생성될 객체(Pizza)가 있다.
- 객체 생성의 각 단계를 정의하는 인터페이스(PizzaBuilder)를 제공한다.
- 추상 빌더를 구현한 클래스(HawaiianPizzaBuilder)가 있다.
- 빌더를 사용해 객체를 생성하는 클래스(Cook)가 있으며 클래스 안에는 빌더를 멤버 변수로 가진다.
이해하기가 어렵다가 밀키트라고 생각하니 좀 이해하기 쉬워졌다.
활용 방법
빌더 팩턴의 예시로는 HTML 문서 생성기가 있다. 잘 와닿지가 않으니 게임에서 활용 방법을 생각해보자면 캐릭터 생성 화면이 있겠다. 플레이어가 선택한 머리 스타일, 옷, 색상 등을 조합하여 캐릭터를 생성할 때 활용할 수 있다.
#include <iostream>
#include <string>
using namespace std;
// --- Product: Character ---
class Character {
string hairStyle;
string outfit;
string skinColor;
public:
void SetHairStyle(const string& hair) { hairStyle = hair; }
void SetOutfit(const string& outfitStyle) { outfit = outfitStyle; }
void SetSkinColor(const string& color) { skinColor = color; }
void ShowCharacter() const {
cout << "Character Details:\n"
<< " Hair Style: " << hairStyle << "\n"
<< " Outfit: " << outfit << "\n"
<< " Skin Color: " << skinColor << "\n";
}
};
// --- Abstract Builder ---
class CharacterBuilder {
public:
virtual ~CharacterBuilder() = default;
virtual void BuildHairStyle() = 0;
virtual void BuildOutfit() = 0;
virtual void BuildSkinColor() = 0;
virtual Character* GetCharacter() = 0;
};
// --- Concrete Builder: Fantasy Character ---
class FantasyCharacterBuilder : public CharacterBuilder {
Character* character;
public:
FantasyCharacterBuilder() { character = new Character(); }
~FantasyCharacterBuilder() { delete character; }
void BuildHairStyle() override { character->SetHairStyle("Long and Curly"); }
void BuildOutfit() override { character->SetOutfit("Armor with Cape"); }
void BuildSkinColor() override { character->SetSkinColor("Light"); }
Character* GetCharacter() override { return character; }
};
// --- Concrete Builder: Sci-Fi Character ---
class SciFiCharacterBuilder : public CharacterBuilder {
Character* character;
public:
SciFiCharacterBuilder() { character = new Character(); }
~SciFiCharacterBuilder() { delete character; }
void BuildHairStyle() override { character->SetHairStyle("Short and Spiky"); }
void BuildOutfit() override { character->SetOutfit("Space Suit"); }
void BuildSkinColor() override { character->SetSkinColor("Blue"); }
Character* GetCharacter() override { return character; }
};
// --- Director: Character Customization Screen ---
class CharacterCustomization {
CharacterBuilder* builder;
public:
void SetBuilder(CharacterBuilder* b) { builder = b; }
void BuildCharacter() {
builder->BuildHairStyle();
builder->BuildOutfit();
builder->BuildSkinColor();
}
};
// --- Client Code ---
int main() {
CharacterCustomization customization;
// Fantasy Character
FantasyCharacterBuilder fantasyBuilder;
customization.SetBuilder(&fantasyBuilder);
customization.BuildCharacter();
Character* fantasyCharacter = fantasyBuilder.GetCharacter();
fantasyCharacter->ShowCharacter();
delete fantasyCharacter;
cout << endl;
// Sci-Fi Character
SciFiCharacterBuilder sciFiBuilder;
customization.SetBuilder(&sciFiBuilder);
customization.BuildCharacter();
Character* sciFiCharacter = sciFiBuilder.GetCharacter();
sciFiCharacter->ShowCharacter();
delete sciFiCharacter;
return 0;
}
장점과 단점
이 패턴의 가장 큰 목적 두 가지가 장점이라고 할 수 있겠다. 첫째로 객체 생성 절차의 캡슐화가 가능하다. 클라이언트는 객체 생성 과정을 알 필요 없이 완성된 객체를 얻을 수 있다. 두 번재로 복잡한 객체 생성에 적합하다. 빌더 패턴은 서로 다른 구성을 가진 복잡한 객체를 쉽게 생성할 수 있다. 그래서 피자를 예로 든 것이다. 도우가 얇은, 불고기 소스가 들어간 불고기 피자도 있고 도우가 두껍고 토마토 소스가 들어간 하와이안 피자도 있으니 말이다. 마지막으로 이 패턴도 역시 유연성과 확장성이 좋다. 새로운 빌더, 예를 들면 SpicyPizzaBuilder를 추가하면 기존 코드를 변경하지 않고 새로운 피자를 만들 수 있다.
하지만 이 역시 클래스가 증가한다는 단점이 있으며 단순한 객체에는 사용을 권장하지 않는다. 생성 과정이 복잡하지 ㅇ낳은 경우 빌더 패턴을 사용하면 불필요한 복잡성을 초래할 수 있기 때문이다.
프로토타입(Prototype) 패턴
기존 객체를 복사하여 새로운 객체를 생성하는 방식이다.
왜 복사라는 방법을 선택한 것일까? 이 패턴은 객체 생성 비용이 높거나 객체를 생성하느 과정이 복잡한 경우에 유용하다. 이 패턴을 이용하면 새로운 객체를 생성하는 대신 기존 객체를 복사(얕은 복사 또는 깊은 복사)하여 빠르게 새로운 객체를 얻을 수 있다.
구현 방법
아래 코드엔 프로토타입 인터페이스 Shape와 이 인터페이스를 구현하고 있는 Circle, Square가 보인다. 그리고 Shape에 Draw말고도 Clone이라는 함수도 보이는데 Shape* 타입을 리턴하는 모양이다. Circle과 Squre를 보면 각각 Clone과 Draw를 구현하고 있다. Clone은 대충보니 자기 자신을 복제하여 새로운 Circle 객체를 생성하고 반환하는 것 같아보인다.
class Shape {
public:
virtual Shape* Clone() = 0;
virtual void Draw() = 0;
};
class Circle : public Shape {
public:
Circle* Clone() override {
return new Circle(*this);
}
void Draw() override {
std::cout << "Drawing a Circle" << std::endl;
}
};
class Square : public Shape {
public:
Square* Clone() override {
return new Square(*this);
}
void Draw() override {
std::cout << "Drawing a Square" << std::endl;
}
};
int main() {
Shape* circle = new Circle();
Shape* anotherCircle = circle->Clone();
circle->Draw();
anotherCircle->Draw();
delete circle;
delete anotherCircle;
return 0;
}
각 실구현체인 Circle과 Square에서 유심히 봐야할 부분은 Clone이다. Clone은 자신과 똑같은 복제 객체를 하나 만들어 return 하고 있다. 아마 복사 생성자가 쓰이고 있을 것이다.
main을 보면 circle을 하나 만들고 anotherCircle에 circle이 자신을 복제한 복제품을 대입하고 있다. 그리고 둘다 멤버 함수인 Draw를 잘 수행하고 있는 것을 볼 수 있다. 참고로 이 둘은 오나전히 독립적으로 동작하며 원본과 복제본은 모두 동일한 특성을 가진다.
활용 방법
프로토타입 패턴의 예시로는 데이터 베이스객체, GUI 시스템 드잉 있다. 이것도 잘 와닿지 않으니 게임에서 생각해보자. 게임에서는 적 클론 생성에서 활용할 수 있겠다. 기존 적 객체를 복제하여 동일한 특성을 가진 적들을 초기화하지 않고 복제하여 생성하는 것이다.
#include <iostream>
#include <string>
#include <unordered_map>
using namespace std;
// --- Prototype Interface ---
class Enemy {
public:
virtual Enemy* Clone() const = 0; // 복제 메서드
virtual void Render() const = 0; // 적 출력 메서드
virtual ~Enemy() = default;
};
// --- Concrete Prototype: Enemy Plane ---
class EnemyPlane : public Enemy {
string color;
int health;
public:
EnemyPlane(const string& c, int h) : color(c), health(h) {}
EnemyPlane(const EnemyPlane& other) : color(other.color), health(other.health) {}
Enemy* Clone() const override {
return new EnemyPlane(*this); // 깊은 복사 생성자를 사용
}
void Render() const override {
cout << "Enemy Plane [Color: " << color << ", Health: " << health << "]" << endl;
}
};
// --- Concrete Prototype: Enemy Tank ---
class EnemyTank : public Enemy {
string armorType;
int damage;
public:
EnemyTank(const string& a, int d) : armorType(a), damage(d) {}
EnemyTank(const EnemyTank& other) : armorType(other.armorType), damage(other.damage) {}
Enemy* Clone() const override {
return new EnemyTank(*this); // 깊은 복사 생성자를 사용
}
void Render() const override {
cout << "Enemy Tank [Armor: " << armorType << ", Damage: " << damage << "]" << endl;
}
};
// --- Prototype Registry (Factory) ---
class EnemyPrototypeRegistry {
unordered_map<string, Enemy*> prototypes;
public:
void RegisterPrototype(const string& key, Enemy* prototype) {
prototypes[key] = prototype;
}
Enemy* CreateEnemy(const string& key) const {
if (prototypes.find(key) != prototypes.end()) {
return prototypes.at(key)->Clone(); // 프로토타입을 복제하여 새로운 객체 반환
}
return nullptr;
}
~EnemyPrototypeRegistry() {
for (auto& pair : prototypes) {
delete pair.second; // 등록된 모든 프로토타입 삭제
}
}
};
int main() {
// 1. Prototype Registry 생성 및 초기화
EnemyPrototypeRegistry registry;
// 2. 적 캐릭터 프로토타입 등록
registry.RegisterPrototype("Plane", new EnemyPlane("Red", 100));
registry.RegisterPrototype("Tank", new EnemyTank("Heavy", 200));
// 3. 복제된 적 생성
Enemy* enemy1 = registry.CreateEnemy("Plane");
Enemy* enemy2 = registry.CreateEnemy("Tank");
Enemy* enemy3 = registry.CreateEnemy("Plane"); // 동일 프로토타입으로 다른 객체 생성
// 4. 생성된 적 출력
enemy1->Render(); // Output: Enemy Plane [Color: Red, Health: 100]
enemy2->Render(); // Output: Enemy Tank [Armor: Heavy, Damage: 200]
enemy3->Render(); // Output: Enemy Plane [Color: Red, Health: 100]
// 5. 메모리 해제
delete enemy1;
delete enemy2;
delete enemy3;
return 0;
}
장점과 단점
복잡한 객체 생성 로직을 회피할 수 있다는 장점이 있다. 객체를 복사여 새로 생성하기 때문에, 초기화 과정이 복잡한 객체의 생성 비용을 절감할 수 있다. 그리고 런타임에서 기존 객체를 기반으로 새로운 객체를 쉽게 생성할 수 있다.
하지만 단점도 존재한다. 지금은 Circle이나 Square에 문제될 멤버 변수가 없었다. 하지만 멤버 중 참조형 멤버를 포함한 변수가 있다면 깊은 복사와 얕은 복사 구현에서 실수가 발생할 수도 있다. 그리고 각 클래스에서 Clone 메서드를 개별적으로 구현해야하므로 코드가 증가할 수 있다.
배운 내용 정리
- 싱글톤 패턴은 시스템 내에서 하나의 인스턴스만 존재하도록 보장하는 패턴이다. (GameManager)
- 팩토리 메소드 패턴은 객체 생성을 서브 클래스에 위임하는 패턴이다. (다양한 적 스폰)
- 추상 팩토리 패턴은 관련 객체 그룹을 생성하기 위한 인터페이스를 제공한다. (다양한 테마, UI 구현)
- 빌더 패턴은 복잡한 객체를 단계별로 생성하고, 생성 과정의 세부 사항을 숨기기 위해 설계된 패턴이다. (캐릭터 커스터마이징)
- 프로토타입 패턴은 기존 객체를 복사하여 새로운 객체를 생성하는 방식이다. (적 복사 생성)
'C++ > 게임 개발자를 위한 C++ 문법' 카테고리의 다른 글
템플릿(Template) (0) | 2025.01.07 |
---|---|
범위 기반 for 문(ranged-based for statement) (0) | 2025.01.07 |
스마트 포인터(Smart Pointer) (0) | 2025.01.03 |
선형 탐색과 이진 탐색 (0) | 2025.01.02 |
얕은 복사(Shallow Copy) VS 깊은 복사(Deep Copy) (0) | 2024.12.30 |