ETC - 디자인 패턴
ETC
디자인 패턴 공부
RPG 게임의 (어트리뷰트) 스탯 시스템을 구현하기 위해 디자인 패턴에 대해 공부해봤다.
데코레이터 패턴
데코레이터 패턴은 객체의 결합을 통해 기능을 동적으로 유연하게 확장할 수 있게 해주는 패턴이다.
객체에 추가할 수 있는 기능의 종류가 많은 경우에 각 추가 기능을 Decorator 클래스로 정의하는 방식이다. 예를 들어, 커피 레시피를 객체화할 때 데코레이터 패턴을 사용하면 좋다.
기본 클래스 Beverage
여기서 구체적인 기본 클래스를 구현한다. 에스프레소, 아메리카노 등등
커피에 추가 옵션을 표현하기 위해 데코레이터 클래스를 생성한다. 우유, 시럽, 샷 등등
예시 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
#include <string>
#include <iostream>
using namespace std;
class Beverage
{
public:
virtual ~Beverage() = default;
virtual double cost() const = 0;
virtual std::string description() const = 0;
};
// 구체적인 기본 클래스
class Espresso : public Beverage
{
public:
double cost() const override
{
return 2.00;
}
std::string description() const override
{
return "Espresso";
}
};
class Americano : public Beverage
{
public:
double cost() const override
{
return 2.50;
}
std::string description() const override
{
return "Americano";
}
};
class AddOnDecorator : public Beverage
{
public:
AddOnDecorator(Beverage* beverage) : beverage(std::move(beverage)) {}
virtual ~AddOnDecorator() { delete beverage; }
protected:
Beverage* beverage;
};
// 구체적인 데코레이터
class Milk : public AddOnDecorator {
public:
Milk(Beverage* beverage) : AddOnDecorator(beverage) {}
double cost() const override
{
return beverage->cost() + 0.50;
}
std::string description() const override
{
return beverage->description() + ", Milk";
}
};
class Syrup : public AddOnDecorator
{
public:
Syrup(Beverage* beverage, const std::string& flavor)
: AddOnDecorator(beverage), flavor(flavor)
{}
double cost() const override
{
return beverage->cost() + 0.30;
}
string description() const override
{
return beverage->description() + ", " + flavor + " Syrup";
}
private:
string flavor;
};
class ExtraShot : public AddOnDecorator
{
public:
ExtraShot(Beverage* beverage) : AddOnDecorator(beverage) {}
double cost() const override
{
return beverage->cost() + 1.00;
}
std::string description() const override
{
return beverage->description() + ", Extra Shot";
}
};
// 메인 함수
int main()
{
// 기본 음료 선택
Beverage* coffee = new Espresso();
std::cout << coffee->description() << ": $" << coffee->cost() << "\n";
// 데코레이터를 통해 옵션 추가
coffee = new Milk(coffee);
coffee = new Syrup(coffee, "Vanilla");
coffee = new ExtraShot(coffee);
std::cout << coffee->description() << ": $" << coffee->cost() << "\n";
return 0;
}
결과
위와 같이 음료의 옵션을 런타임에 조합할 수 있어 유연성이 좋다.
새로운 옵션도 AddOnDecorator를 상속받는 클래스를 정의하면 되기에, 추가하기 쉽다.
단일 책임 원칙(SRP)을 준수하여, 클래스가 하나의 책임만 가지며, 관리와 유지보수가 용이하다.
이 데코레이터 패턴으로 캐릭터 스탯에 장비나 버프로 받는 스탯을 추가하는 방식을 생각했다.
하지만, 데코레이터 패턴의 경우 단방향 연결 리스트처럼 작동하기에, 장비가 바뀌어 데코레이터를 변경하거나 버프의 지속시간이 끝나 데코레이터를 제거해야 할 경우 탐색 효율이 좋지 않다.
또한 특정 데코레이터를 감지하거나 식별하기 위해 별도의 메타데이터나 타입 체크가 필요하기에 적합하지 않다고 생각했다.
데코레이터 패턴은 불변적인 객체 조합을 만들 때 사용하는 것이 적합하다고 생각한다.
컴포지트 패턴 (복합체 패턴)
복합 객체(Composite)와 단일 객체(Leaf)를 동일한 컴포넌트로 취급하여, 클라이언트에게 이 둘을 구분하지 않고 동일한 인터페이스를 사용하도록 하는 패턴이다. 이 패턴은 전체-부분의 관계를 갖는 객체들 사이에 관계를 트리 계층 구조로 정의해야 할 때 유용하다. 윈도우나 리눅스의 파일 시스템 구조가 이런 구조다.
예시 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include <string>
#include <iostream>
using namespace std;
class FileSystem
{
public:
FileSystem(string name) : name(name) {}
virtual ~FileSystem() = default;
virtual void Add(FileSystem* fileSystem) {} // 윈도우의 경우 파일에서 파일을 생성하지 못하기에, 폴더만 Add를 오버라이드
virtual void Display(int depth) = 0;
protected:
void PrintSpace(int depth) const
{
for (int i = 0; i < depth; i++)
{
cout << " ";
}
}
string name;
};
class File : public FileSystem
{
public:
File(string name) : FileSystem(name) {}
void Display(int depth = 0)
{
PrintSpace(depth);
cout << "File: " << name << endl;
}
private:
};
class Folder : public FileSystem
{
public:
Folder(string name) : FileSystem(name) {}
~Folder()
{
for (auto fileSystem : fileSystems)
delete fileSystem;
fileSystems.clear();
}
virtual void Add(FileSystem* fileSystem) { fileSystems.push_back(fileSystem); }
void Display(int depth = 0)
{
PrintSpace(depth);
cout << "Folder: " << name << endl;
for (auto content : fileSystems)
content->Display(depth + 1);
}
private:
vector<FileSystem*> fileSystems;
};
int main()
{
Folder* root = new Folder("Root");
root->Add(new File("File1"));
File* file = new File("File2");
root->Add(file);
Folder* subFolder = new Folder("SubFolder");
subFolder->Add(new File("File3"));
subFolder->Add(new File("File4"));
root->Add(subFolder);
root->Display();
}
결과
이렇게 새로운 개별 객체나 복합 객체를 추가해도 코드를 수정할 필요가 없어 유지보수성이 좋다.
장점
- 개별 객체와 복합 객체를 동일한 방식으로 다룰 수 있어 코드가 좀 더 단순해진다.
- 복합 객체에서 개별 객체를 제거해도 객체 간의 관계가 깨지지 않는다.
- 객체의 구조를 변경하기에 유용하다.
단점
- 복합 객체 내부의 모든 개별 객체를 처리해야 함.
- 객체의 구조가 복잡한 경우에는 효과적, 객체의 구조가 단순하면 설계의 복잡성을 증가시킬 수 있다.
- 개별 객체와 복합 객체가 서로 다른 인터페이스를 사용하거나, 복합 객체 내부 구조가 동적으로 변하는 경우에 사용이 힘듦.
이 패턴 역시 정적 트리 구조에 적합하기에, 버프나 장비, 레벨업 등 스탯의 변경이 빈번하게 발생하기에 적합하지 않다.
그리고 플레이어 스탯 매니저, 장비 스탯 매니저, 버프 스탯 매니저를 직접 변경하는 편이 좋기에 컴포지트 패턴을 사용할 이유가 전혀 없다.
현재로서는, 스탯 간 의존성, 공통 인터페이스를 따르기 좋은 상속 방식이 최선이라고 생각한다.