디자인패턴은 프로그래밍 분야에서 활용 가능한 가이드라인을 의미한다.
구체적인 코드를 제공하지 않고, 일반적인 구조와 상호작용을 설명하는 탬플릿이다.
GoF의 디자인 패턴은 소프트웨어 공학에서 가장 많이 사용되는 디자인 패턴이다. 목적에 따라 분류할 시 생성 패턴 5개, 구조 패턴 7개, 행위 패턴 11개, 총 23개의 패턴으로 구성된다.
패턴은 목적에 따른 분류로 세 가지, 범위에 따른 분류로 두 가지로 나눌 수 있다.
1) 생성 패턴
객체의 생성과 관련된 패턴으로, 객체의 인스턴스 과정을 추상화하는 방법이다. 객체의 생성과 참조 과정을 캡슐화하여 객체가 생성되거나 변경되어도 프로그램 구조에 영향을 받지 않도록 하여 프로그램에 유연성을 더해준다.
이름 | 의도 |
---|---|
추상 팩토리(Abstract Factory) | 구체적인 클래스를 지정하지 않고 인터페이스를 통해 서로 연관되는 객체들을 그룹으로 표현함 |
빌더(Builder) | 복합 객체의 생성과 표현을 분리하여 동일한 생성 절차에서도 다른 표현 결과를 만들어낼 수 있음 |
팩토리 메소드(Factory Method) | 객체 생성을 서브클래스로 위임하여 캡슐화함 |
프로토타입(Prototype) | 원본 객체를 복사함으로써 객체를 생성함 |
싱글톤(Singleton) | 어떤 클래스의 인스턴스는 하나임을 보장하고 어디서든 참조할 수 있도록 함 |
2) 구조 패턴
클래스나 객체들을 조합해 더 큰 구조로 만들 수 있게 해주는 패턴이다. 구조 클래스 패턴은 상속을 통해 클래스나 인터페이스를 합성하고, 구조 객체 패턴은 객체를 합성하는 방법을 정의한다.
이름 | 의도 |
---|---|
어댑터(Adapter) | 클래스의 인터페이스를 다른 인터페이스로 변환하여 다른 클래스가 이용할 수 있도록 함 |
브리지(Bridge) | 구현부에서 추상층을 분리하여 각자 독립적으로 확장할 수 있게 함 |
컴포지트(Composite) | 객체들의 관계를 트리 구조로 구성하여 복합 객체와 단일 객체를 구분없이 다룸 |
데코레이터(Decorator) | 주어진 상황 및 용도에 따라 어떤 객체에 다른 객체를 덧붙이는 방식 |
퍼싸드(Facade) | 서브시스템에 있는 인터페이스 집합에 대해 하나의 통합된 인터페이스(Wrapper) 제공 |
플라이웨이트(Flyweight) | 크기가 작은 여러 개의 객체를 매번 생성하지 않고 가능한 한 공유할 수 있도록 하여 메모리를 절약함 |
프록시(Proxy) | 접근이 어려운 객체로의 접근을 제어하기 위해 객체의 Surrogate나 Placeholder를 제공 |
3) 행위 패턴
행위 패턴은 클래스나 객체들이 서로 상호작용하는 방법이나 어떤 태스크, 어떤 알고리즘을 어떤 객체에 할당하는 것이 좋을지를 정의하는 패턴이다.
행위 패턴은 하나의 객체로 수행할 수 없는 작업을 여러 객체로 분배하면서 그들 간의 결합도를 최소화 할 수 있도록 도와준다. 행위 클래스 패턴은 상속을 통해 알고리즘과 제어 흐름을 기술하고, 행위 객체 해턴은 하나의 작업을 수행하기 위해 객체 집합이 어떻게 협력하는지를 기술한다.
이름 | 의도 |
---|---|
책임 연쇄(Chain of Responsibility) | 요청을 받는 객체를 연쇄적으로 묶어 요청을 처리하는 객체를 만날 때까지 객체 Chain을 따라 요청을 전달함 |
커맨드(Command) | 요청을 객체의 형태로 캡슐화하여 재사용하거나 취소할 수 있도록 저장함 |
인터프리터(Interpreter) | 특정 언어의 문법 표현을 정의함 |
반복자(Iterator) | 내부를 노출하지 않고 접근이 잦은 어떤 객체의 원소를 순차적으로 접근할 수 있는 동일한 인터페이스 제공 |
중재자(Mediator) | 한 집합에 속해있는 객체들의 상호작용을 캡슐화하여 새로운 객체로 정의 |
메멘토(Memento) | 객체가 특정 상태로 다시 되돌아올 수 있도록 내부 상태를 실체화 |
옵서버(Observer) | 객체 상태가 변할 때 관련 객체들이 그 변화를 통지받고 자동으로 갱신될 수 있게 함 |
상태(State) | 객체의 상태에 따라 동일한 동작을 다르게 처리해야할 때 사용 |
전략(Strategy) | 동일 계열의 알고리즘군을 정의하고 캡슐화하여 상호교환이 가능하도록 함 |
템플릿 메소드(Template Method) | 상위클래스는 알고리즘의 골격만을 작성하고 구체적인 처리는 서브클래스로 위임함 |
방문자(Visitor) | 객체의 원소에 대해 수행할 연산을 분리하여 별도의 클래스로 구성함 |
디자인 패턴은 어디까지나 참고할 수 있는 양식 같은 것이니 무조건적으로 사용해야 하는 것은 아니다.
- 디자인 패턴을 사용하기 위한 오버 엔지니어링을 하지 않도록 한다.
오버엔지니어링(Overengineering): 현재 필요한 기능보다 과도하게 개발하는 것을 의미- 문제를 해결하기 위해 디자인 패턴을 참고하는 건 좋으나, 디자인 패턴을 사용하기 위해 문제를 만드는 것을 지양한다.
- 디자인 패턴은 범용적으로 쓰기 좋은 것일 뿐이지 그 자체가 게임에서는 적용하기 어려운 경우가 있을 수 있다.
아래 다섯 개의 디자인 패턴은 게임 프로그래밍에서 자주 사용하는 디자인 패턴이다.
싱글톤 패턴
게임에서 유일한 인스턴스를 보장하고, 전역적인 접근이 필요한 경우에 사용되는 패턴이다. 싱글톤 패턴은 클래스의 인스턴스가 하나만 생성되도록 보장하고, 어디서든 동일한 인스턴스에 접근할 수 있게 한다. 예를 들어, 게임 매니저, 자원 관리자 등에서 활용된다.
상태 패턴
게임에서 오브젝트의 동작이 상태에 따라 달라지는 경우 사용되는 패턴이다. 상태 패턴은 각 상태를 클래스로 구현하고, 상태에 따라 다른 동작을 수행하도록 한다. 예를 들어, 캐릭터의 이동 상태, 공격 상태, 대기 상태 등을 분리해서 다루는 예시가 있다.
캐릭터의 상태를 객체로 구현하게 되면, 추후 새로운 상태가 추가 되어도 이 상태를 사용하는 캐릭터 클래스의 코드를 대폭 수정할 필요 없이 유연하게 대처가 가능하다.
오브젝트 풀 패턴
게임에서 반복적으로 생성되고 소멸되는 객체들을 효율적으로 관리하기 위한 패턴이다. 객체 풀 패턴은 미리 생성된 객체들을 풀에 저장해두고, 필요할 때마다 객체를 재사용하여 생성 및 소멸의 오버헤드를 줄인다.
게임 내에서 자주 사용하는 객체에 사용할 수 있는 요소이다. 미리 큰 메모리 공간을 확보해 둔 후에, 객체의 사용이 끝나도 객체를 지우는 것이 아닌, 잠시 비활성화 해 두었다가 재활용하는 방법으로 사용하여 성능상 큰 이득을 보는 것이다. 고전 마리오 게임에서 불꽃을 세 개까지만 발사할 수 있는 것 또한 오브젝트 풀 패턴의 예시이다.
컴포넌트 패턴 ★
게임 오브젝트의 동작과 기능을 구현하는 데 사용되는 패턴이다. 각각의 게임 오브젝트는 여러 개의 컴포넌트로 구성되며, 컴포넌트는 독립적으로 동작하고 상호작용한다. 자동차라는 객체가 있을 경우, 이 객체를 만드는 방법으로 엔진객체, 휠 객체, 미션 객체, 브레이크 객체 등 세분화 된 객체들을 조립하여 하나의 객체를 구성해 나가는 패턴이다.
Unity 엔진에서 만들어내는 객체들이 컴포넌트 패턴의 예시이다. 캐릭터를 하나 만들고, 거기에 필요에 따라 물리가 필요하면 물리 객체, 네트워킹이 필요하면 네트워크 관련 객체등을 붙여나가며 원하는 객체를 만들어 내는 과정을 거친다.
옵저버 패턴
객체 간의 일대다 관계를 구성하여, 한 객체의 상태 변화에 따라 다른 객체들이 자동으로 업데이트되도록 하는 패턴이다. 옵저버 패턴은 주제(Subject)와 옵저버(Observer)라는 두 가지 주요 요소로 구성된다. 주제는 상태를 가지며, 상태가 변경될 때 옵저버에게 알린다. 옵저버는 주제를 구독하고, 주제의 상태가 변경되면 업데이트를 수신하여 필요한 동작을 수행한다.
예시로 플레이어의 정보를 표시하는 UI에서 사용할 수 있다. 변동사항이 있을 때만 변동사항이 있다는 것을 관련된 객체들에게 전달시키는 방식을 채용하면, 객체간의 결합도를 낮추고 유연성 및 확장성을 제공할 수 있다.
디자인 패턴 실전으로, 생성 패턴 두 가지 - 팩토리 패턴과 빌더 패턴을 사용해 보려고 한다.
팩토리 패턴은 비유하자면, 정해진 메뉴를 파는 식당을 연 것과 비슷하다.(이삭 토스트) 객체의 선언과 생성부를 분리하여 캡슐화한 것으로, 자주 사용하는 특정 객체를 인스턴스화하는 과정을 편하게 하기 위해 사용하는 패턴이다.
실습으로 진행했던 내용을 바탕으로 예시를 보여주고자 한다.
실습 내용
팩토리 패턴을 이용하여 WeaponFactory 제작 시도
1. 무기는 이름, 공격력, 공격 범위가 있어야 한다.
2. 팩토리를 통해서 생성해야 할 무기 - 철검, 나무창, 쇠도끼 등
+) 심화과제 - 무기 팩토리에 희귀도를 추가하여 제작할 수 있도록 한다.
* 희귀도에 따른 공격력 보정 : 일반 등급 - 0%, 희귀 등급 - 10% 전설 등급 : 20%
필자는 심화 과제도 같이 진행하였고, 어떻게 코드를 작성할 지 생각해 보았다.
크게는 이렇게 만들면 될 것 같다.
이에 따라 우선 enum과 클래스를 선언했다.
public enum Rarerity { Normal, Rare, Legend }
public class Weapon
{
public string name;
public int attackStats;
public int attackRange;
public Rarerity rarerity;
public Weapon(string name, int attackStats, int attackRange, Rarerity rarerity)
{
this.name = name;
this.attackStats = attackStats;
this.attackRange = attackRange;
this.rarerity = rarerity;
}
}
위와 같이 클래스를 먼저 선언하고, WeaponFactory 클래스를 만들고 해당 클래스에서 무기를 인스턴스화하는 인자를 "이름" 과 "레어도"로 설정했다.
무기는 다음과 같이 스텟을 설정하였다
public class WeaponFactory
{
public Weapon CreateWeapon(string name, Rarerity rarerity)
{
Weapon weapon;
switch(name)
{
case "철검" :weapon = new Weapon("철검", 10, 5, rarerity); break;
case "나무창": weapon = new Weapon("나무창", 6, 8, rarerity); break;
case "쇠도끼": weapon = new Weapon("쇠도끼", 15, 7, rarerity); break;
default: return null;
}
return weapon;
}
}
우선은 무기의 기본 정보를 담은 클래스를 반환하는 함수는 완성되었다. 하지만 레어도에 따른 무기 공격력 보정이 되어 있지 않으니, 이에 대한 고민을 해야 한다.
이걸 위해선 보정을 위한 새로운 변수로, float rate;를 선언해야 한다. 그리고 rarerity에 따른 보정을, Weapon이 생성된 이후에 하면 되겠다고 생각했다.
public class WeaponFactory
{
public float rate; // 무기 공격력 보정
public Weapon CreateWeapon(string name, Rarerity rarerity)
{
Weapon weapon;
switch(name)
{
case "철검" :weapon = new Weapon("철검", 10, 5, rarerity); break;
case "나무창": weapon = new Weapon("나무창", 6, 8, rarerity); break;
case "쇠도끼": weapon = new Weapon("쇠도끼", 15, 7, rarerity); break;
default: return null;
}
switch(rarerity) // 레어도에 따라 공격력 보정
{
case Rarerity.Normal: rate = 1f; break;
case Rarerity.Rare: rate = 1.1f; break;
case Rarerity.Legend: rate = 1.2f; break;
default: rate = 1;break;
}
weapon.attackStats = (int)((float)weapon.attackStats * rate);
return weapon;
}
}
강제 형변환으로 attackStats에 대한 보정을 하고 반환을 하도록 구현했다.
강제 형변환 때문에 나무창의 공격력 보정값이 좀 이상해졌긴 했는데 로직 자체는 잘 작동하는 것으로 보인다.
빌더 패턴은 비유하자면, 식당에서 손님이 요구한 대로 재료를 조합하여 원하는 메뉴를 만들어 주는 것과 비슷하다.(서브웨이) 객체의 생성과 표현을 분리하여 생성 절차에서 여러가지 표현 결과를 만들어 내는 패턴이다.
이 또한 실습 내용으로 진행한 예시를 들고자 한다.
실습 내용
빌더 패턴을 이용하여 AnimalBuilder 제작 시도
1. 동물은 이름, 생산품, 울음소리, 사료 종류를 조합한다.
2. 여러 종류의 동물을 구현할 것 - 양, 소, 닭 등
+) 심화과제 - 희귀종 동물을 만들 수 있도록 하며, 희귀종 동물은 특수 생산품을 생산하도록 구현한다.
이것 또한 심화 과제를 같이 하기로 했다. 코드를 작성하기 위해 아래와 같이 과정을 생각했다.
아까와 같이 enum과 클래스를 선언한다.
public enum Rarerity { Normal, Rare, Legend }
public class Animal
{
public string name;
public string products;
public string sound;
public string feed;
public Rarerity rarerity;
}
그리고 AnimalBuilder 클래스를 선언하고 함수를 만들어준다.
public class AnimalBuilder
{
public string name;
public string products;
public string sound;
public string feed;
public Rarerity rarerity;
public AnimalBuilder()
{
name = "동물";
products = "생산품";
sound = "소리";
feed = "사료";
rarerity = Rarerity.Normal;
}
public Animal Build()
{
Animal animal = new Animal();
animal.name = name;
animal.products = products;
animal.sound = sound;
animal.feed = feed;
animal.rarerity = rarerity;
return animal;
}
public AnimalBuilder SetName(string name)
{
this.name = name;
return this;
}
public AnimalBuilder SetProducts(string products)
{
this.products = products;
return this;
}
public AnimalBuilder SetSound(string sound)
{
this.sound = sound;
return this;
}
public AnimalBuilder SetFeed(string feed)
{
this.feed = feed;
return this;
}
public AnimalBuilder SetRarerity(Rarerity rarerity)
{
this.rarerity = rarerity;
return this;
}
}
우선 위와 같이 작성하면 빌더로 클래스를 생성하는 것 자체는 잘 될 것이다. 다만, 희귀종 동물이 희귀한 생성물을 만드는 과정은 아직 진행하지 않았다.
희귀종 동물의 희귀 생성물, 이걸 구현하라는 말이 조금 모호하기는 한데 나는 이런 경우로 생각했다.
그러면 아까처럼 Switch문으로 조건을 걸고 양털[등급]을 반환하게 하면 될 것이다.
근데 여기서 갑자기 든 의문, 문자열 뒤에 문자열을 추가하려면 어떻게 해야 하지? 단순하게 생각하면 + 연산을 할 수는 있겠지만, 게임 프로그래머에게 문자열 + 연산은 죄악이라는 말 때문에 차마 쓸 수 없었다.
간단하게 문자열을 합칠 수 있는 방법에 대해 구글링을 하는 수밖에 없었다.
방법은 쉽게 찾았다. 사실 알고 있는 방법이기도 했다. 문자열 보간을 활용하면 간단하게 할 수 있었다.
animal.products = $" {products} [레어]"
늘 Console.WriteLine() 안에서만 사용해서 그런가, 이렇게 쓸 수 있을 거라 생각하면서도 선뜻 생각하지 못했던 방법이다.
어쨌든 Switch문을 구현해서 SetProducts 단계에 넣어보았다.
음, 반영이 안 된다. 왜지... 싶어서 디버깅을 해 보는데 가만 보니 SetProduct에 넣으면 Switch문이 그냥 빠져나가는 걸 보았다. 아, 생각해보니 SetProduct 내에는 레어도를 판별할 레어도 정보가 없었다.
그러면 어디다가 Switch문을 넣어야지 레어도를 표기할 수 있지? 코드를 쭉 살펴 보니, Build 단계가 보였다. Build 단계면 아직 클래스가 생성되기 이전일 것이기 때문에 레어도 반영을 할 수 있을 것이다.
public Animal Build()
{
Animal animal = new Animal();
animal.name = name;
animal.products = products;
animal.sound = sound;
animal.feed = feed;
animal.rarerity = rarerity;
switch (rarerity)
{
case Rarerity.Normal: break;
case Rarerity.Rare: animal.products = $" {products} [레어]"; break;
case Rarerity.Legend: animal.products = $" {products} [레전드]"; break;
default: return null;
}
return animal;
}
이렇게 수정하고서 최종적으로 실행을 시켜보았다.
의도한 대로 구현이 된 것을 확인하였다.
참고자료
(디자인패턴)
https://4z7l.github.io/2020/12/25/design_pattern_GoF.html