객체 지향 디자인 패턴을 공부하던 중 "전략 패턴"이라는 친구를 봤습니다. 이 친구는 재밌게도 예시를 들 때 대부분 게임, 그중에서도 무기나 타입의 변경을 예시로 들고 있었습니다. 그렇다는 것은 게임 개발자로서 객체 지향 패턴의 시작으로 이만한 친구가 없다는 것이고 바로 시작했습니다.
자세한 설명을 하기 전에 전략 패턴의 필요성을 깨닫기 위해 예시를 하나 들겠습니다.
우선 배그같은 게임을 만든다 칩시다. 플레이어는 총을 쏘거나 프라이팬을 휘두르거나 화염병을 던질 수 있습니다. 들 수 있는 무기는 M4, M16, kar98, 카타나, 수류탄, 연막탄 등 수많은 무기가 있습니다.
이때 개발자 관점에서 한 번 봐보죠. 각각 무기를 잡는 모션, 사용 시 애니메이션, 사용 기능이 다릅니다. 무기마다 기능이 다른 이 친구들을 어떻게 구현하면 좋을까요?
가장 먼저 생각나는 방법은 if문으로 하나하나 비교하는 겁니다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Shooter : MonoBehaviour
{
void Attack()
{
if(weapon.name == "Kar98") ShopKar98();
else if(weapon.name == "M16") ShopM16();
else if(weapon.name == "M4") ShopM4();
else if(weapon.name == "Katana") SwingKatana();
else if(weapon.name == "frying pan") SwingFryingPan();
else if(weapon.name == "grenade") FireInTheHole();
else if(weapon.name == "smoke shell") ThrowSmoke();
}
}
음..... 뭔지는 모르겠지만 일단 이건 아니라는 느낌이 옵니다.
구체적으로 말하자면 길고 복잡하며 각각의 무기 기능이 강하게 연결되어 있어 어떠한 문제가 생기면 코드 전체를 뜯어고쳐야 될 수도 있고 확장과 수정도 불편할거 같은 코드입니다.
저런 코드는 작성하기도 보기도 싫습니다.( 혹시 저렇게 작성하신 분이 계시다면, 죄송합니다. ) 그렇기에 우리는 다른 접근법을 사용해야 합니다.
접근법을 바꾸기 전에 우선 저의 배그 스토리를 한 번 살펴보도록 하죠.
저는 아이템을 파밍하고 어떤 총을 쓸지 선택합니다. 선택을 했으니 그 총으로 교체합니다.
그렇게 파밍을 마치고 걸어 다니다가 적을 발견합니다. 저는 적을 보자 화들짝 놀라며 총을 사용합니다.
하지만 에임은 흩날리고 결국 상대방의 총에 맞아 죽습니다. 아, 42위군요 아깝습니다.
뭔 소리를 하고 있나 싶지만 위에서 언급한 내용에 전략 패턴의 핵심이 들어가 있습니다. 바로 "교체"입니다.
전략 패턴은 서로 다른 기능을 하지만 공통점이 있는 친구들을 묶습니다. 이 묶는 과정에서 객체 지향의 다형성을 이용하죠.이렇게 하나로 틀로 묶은 후 그 틀 안에서 모튤을 갈아끼우는 방식으로 코드를 작성하는 것이 전략 패턴입니다.
말만 듣고는 정확한 이해가 어려울 겁니다. 코드를 보고 하나하나 알아가도록 하죠.
근데 코드를 보기 전에 쓰잘데기 없는 얘기 하나만 하겠습니다.
지금까지 설명만 들으면 갈아끼우기만 하는데 왜 이름이 "교체 패턴"이 아니라 "전략 패턴" 일까요? 사실 저도 모릅니다. 솔직히 이름에 뭐 대단한 이유가 있을 거 같지도 않습니다. 그럼 이제 여러분의 궁금증이 다른 방향으로 향하겠죠. "진짜 쓰잘데기 없는 얘기하고있네?" 라든가 "내가 귀중한 시간을 낭비하며 뭘 보고 있는 거지?" 라든가요.
하지만 저도 이유 없이 언급한건 아닙니다. 제 개인적인 생각에 이름이 "전략 패턴"인 이유는 아마 교체보다는 전략에 중점을 두어서 그런 것 같습니다.
제가 바로 앞에서 교체가 핵심이라고 말하긴 했지만 그 전에 전제 조건이 하나 있었습니다. "서로 다른 기능을 하는 오브젝트들을 다형성을 이용해서 하나로 묶은 후 교체" 였죠.
서로 다른 기능을 하는 오브젝트들을 하나로 묶는다고는 하지만 무기는 무기여야 합니다. 연막탄과 같이 적에게 직접적인 피해를 주지 않지만 상대방의 시야를 가리는 간접적 공격을 하는 무기도 있습니다. 하지만 붕대같은 회복 아이템이 무기의 범위에 들어가는 건 얘기가 다릅니다.
배그에서 플레이어가 손에 들고 다닐 수 있는 아이템은 형태는 다르지만 결국 적을 공격할 수 있는 "전략"을 제공하는 무기들입니다. 이렇게 "교체"보다는 "전략"에 초점을 둬서 "전략 패턴" 이 아닌가 라는 뇌피셜을 써봤습니다.
개념을 대충 봤으니 코드를 구경하도록 하죠
우선 무기를 정의해줘야 합니다. 무기라는건 저희의 관념 속에 정의되어 있지만 무한한 가능성으로 점철된 Unity 세상에서는 그런 고정관념 따위는 존재하지 않습니다. 그러니 저희가 이 세상의 신으로서 "무기는 이러이러한 것이다~~" 라고 친이 정의해줍시다.
무기는 대미지가 있고(연막탄 같은 건 0일 겁니다) 딜레이 타임이 있습니다. 그리고 제일 중요한 공격 기능이 있습니다.
using UnityEngine;
using System.Collections;
abstract public class Weapons : MonoBehaviour
{
public float attackDelayTime;
public bool isAttackAble;
public int damage;
public abstract void Attack();
}
우선 클래스 선언부를 봐볼까요
abstract public class Weapons : MonoBehaviour
보시면 맨 앞에 abstract라는게 있습니다. 저 친구는 클래스를 추상 클래스로 선언할 때 사용하는 문법입니다.
abstract : 추상적인, 이론적인
추상 : 사물을 어떤 성질·공통성·본질에 착안하여 그것을 추출하여 파악하는 것 (네이버 검색)
한마디로 대충 때려맞춘다는 뜻입니다. 저에 대해 잘 모르는 미국인이 저를 보고 가장 먼저 하는 말은 뭘까요? "Korean?" 입니다. 그럼 저는 "Yes" 라고 대답하겠죠.
저희가 추상 클래스를 사용하는 방법도 마찬가지입니다. 오브젝트에 대해서 잘 모르는 저희가 GetComponent<"Weapons">() != null 이라고 물으면 true or false 라고 대답하겠죠.
저를 한국인이라고 구분하는 것처럼 무기 오브젝트는 Weapons 스크립트로 구분합니다. 그리고 저는 한국인이지만 한국인은 제가 아닙니다. 총은 무기지만 무기가 총은 아니죠.
무슨 소리냐 하면 저는 한국인이라는 성질이 있는거지 그 자체만 있는것은 아닙니다. 이름도 있고 다니는 학교도 있고 밸로그 아이디도 있죠.
그렇기에 추상 클래스는 직접 인스턴스를 통해 생성할 수 없으며 상속을 통해서만 구현 가능합니다. 무기에 포함된 총을 생산할 순 있지만 "무기"라는 추상적인 개념을 직접 생산할 수는 없으니까요.
public abstract void Attack();
아까는 추상 클래스를 만들었고 이거는 추상 함수를 만드는 문법입니다. 내부를 구현할 수 없으며 추상 클래스를 상속받아 덮어쓰기로 구현해야 합니다. 안하면 에러뜹니다. 즉 강제성이 있습니다.
강제성이 있기에 개발자 입장에서는 "한국인이라면 한국어를 할 수 있을거야!" 라는 믿음처럼 "Weapons클래스를 상속받으면 Attact 함수를 무조건 구현했을거야!" 라는 믿음을 가지고 작업할 수 있습니다.
하지만 같은 말이라도 어떤 목소리로 얼마나 빠르게 말하는지는 사람마다 다른 것처럼 함수의 구현도 스크립트마다 다릅니다. 이러한 특징을 이용해서 다른 기능을 하는 무기도 Attack()이라는 하나의 함수로 사용할 수 있습니다.
이렇게 만든 것을 추상 함수를 플레이어가 사용하는 스크립트를 짜 봅시다.
public class Shooter : MonoBehaviour
{
bool SwapWeapon1; // 1번 인베토리 스왑
bool SwapWeapon2; // 2번 인벤토리 스왑
bool AttackDown; // 공격 키
void Update()
{
SwapWeapon();
GetInput();
Attack();
}
void GetInput()
{
SwapWeapon1 = Input.GetButtonDown("ActiveHammer");
SwapWeapon2 = Input.GetButtonDown("ActiveGun1");
SwapWeapon3 = Input.GetButtonDown("ActiveGun2");
AttackDown = Input.GetMouseButton(0);
}
[SerializeField] Weapons weapon_Gun = null; // 총 인벤토리
[SerializeField] Weapons weapon_Melee = null; // 근접무기 인벤토리
// 키입력에 따라 무기를 바꿀 인벤토리의 정보를 반환하는 함수
Weapons GetSwapInventory()
{
if (SwapWeapon1) return weapon_Gun;
else if (SwapWeapon2) return weapon_Melee;
else return null;
}
// 현재 착용 무기
[SerializeField] Weapons currentWeapon = null;
// 현재 착용 무기 변경 및 착용
void SwapWeapon()
{
// 키입력을 받았으며 인벤토리에 무기가 있다면
if( (SwapWeapon1 || SwapWeapon2) && GetSwapInventory() != currentWeapon )
{
PutWeaponOn();
}
}
// 무기 착용
void PutWeaponOn()
{
// 현재 착용중인 무기가 있으면 무기 숨기기
if (currentWeapon != null)
currentWeapon.gameObject.SetActive(false);
// 현재 착용 중인 무기 변경
currentWeapon = GetSwapInventory();
// 착용 무기 보여주기
currentWeapon.gameObject.SetActive(true);
}
// 공격
void Attack()
{
// 공격 키입력을 받았으며 무기를 착용중이면
if(AttackDown && currentWeapon != null && currentWeapon.attackAble)
{
currentWeapon.Attack();
}
}
갑자기 뭔가 긴게 나왔군요. 갑자기 뒤로가기를 누르고 싶고 카톡 메세지를 확인하고 싶어집니다. 참고로 저는 글을 쓰는 시점에서 친구가 메이플 아이템 강화 원트에 성공했다고 비틱질을 시전했습니다.(놀랍게도 실화입니다)
아무튼 여러분은 저 긴 무언가를 보고 나가도 되지만 저는 글을 써야하므로 하나하나 설명하도록 하겠습니다.
먼저 키입력을 받기 위한 bool 변수들을 선언했습니다. 무기를 교체하거나 공격을 하는데 사용합니다.
bool SwapWeapon1; // 1번 인베토리 스왑 키
bool SwapWeapon2; // 2번 인벤토리 스왑 키
bool AttackDown; // 공격 키
그리고 실제로 대입을 해줍니다. Swap으로 시작하는 변수들은 직접 세팅해둔 키입력입니다.
void GetInput()
{
// 숫자 1번 키
SwapWeapon1 = Input.GetButtonDown("ActiveHammer");
// 숫자 2번 키
SwapWeapon2 = Input.GetButtonDown("ActiveGun1");
// 마우스 왼쪽 클릭
AttackDown = Input.GetMouseButton(0);
}
지금까지는 깍두기였고 이제부터 시작입니다.
먼저 무기를 담을 변수들입니다.
[SerializeField] Weapons weapon_Gun = null; // 총 인벤토리
[SerializeField] Weapons weapon_Melee = null; // 근접무기 인벤토리
[SerializeField] Weapons currentWeapon = null; // 현재 착용 무기
모두 Weapons로 선언된 것을 볼 수 있습니다. 이처럼 모든 무기는 Weapons 스크립트를 상속받기 때문에 Weapons라는 틀로 묶어서 사용 및 교체가 가능합니다.
위에 있는 weapon_Gun, weapon_Melee 은 인벤토리에 있는 아이템을 의미합니다
배그에서 1번 인벤토리에는 총이 5번에는 투척무기가 들어가는 것처럼 저는 1번에 총이 2번에는 근접 무기가 들어가기에 저런 이름으로 지었습니다.
currentWeapon은 말 그대로 현재 무기. 즉 현재 플레이어가 착용중인 무기를 의미합니다.
그리고 대망의 무기를 착용하는 부분입니다.
void SwapWeapon() // 무기 변경 및 착용
{
// 키입력을 받았으며 스왑하려는 무기가 현재 착용중인 무기와 다른 무기라면
if( (SwapWeapon1 || SwapWeapon2) && GetSwapInventory() != currentWeapon )
{
// 무기 교체
PutWeaponOn();
}
}
우선 조건을 봐보죠.
( (SwapWeapon1 || SwapWeapon2) && GetSwapInventory() != currentWeapon )
앞에 (SwapWeapon1 || SwapWeapon2) 부분은 그저 키입력을 감지하는 코드입니다.
&& 뒤에 GetSwapInventory() != currentWeapon 입니다. 이 친구는 현재 들고 있는 무기와 같은 무기를 교체하는 것을 방지하는 코드입니다. 즉, M4를 들고 있는데 M4로 교체하는 것을 막는 코드입니다.
좀 더 자세히 알기 위해 GetSwapInventory()에 대해 살펴보죠. 우선 이 친구는 인벤토리의 정보를 리턴하는 함수입니다. 반환한 Weapons는 무기를 교체할 때 사용합니다.
[SerializeField] Weapons weapon_Gun = null; // 총 인벤토리
[SerializeField] Weapons weapon_Melee = null; // 근접무기 인벤토리
// 키입력에 따라 무기를 바꿀 인벤토리의 정보를 반환하는 함수
Weapons GetSwapInventory()
{
if (SwapWeapon1) return weapon_Gun;
else if (SwapWeapon2) return weapon_Melee;
else return null;
}
근데 저것만 보시면 "맨 처음에 있던 무지성 if 사다리랑 똑같은데?" 라고 생각할 수 있습니다. 그래서 뭐가 다른지 설명해드리겠습니다. 일단, 무려 위에 코드는 if 사다리가 100줄을 넘지 않습니다. 뭔 당연한 소리냐고요?
void Attack()
{
if(weapon.name == "Kar98") ShopKar98();
else if(weapon.name == "M16") ShopM16();
else if(weapon.name == "M4") ShopM4();
else if(weapon.name == "Katana") SwingKatana();
else if(weapon.name == "frying pan") SwingFryingPan();
else if(weapon.name == "grenade") FireInTheHole();
else if(weapon.name == "smoke shell") ThrowSmoke();
}
제가 처음에 예시를 든 코드입니다. 보시면 아시겠지만 무기를 추가할때마다 if사다리를 한줄씩 늘려야합니다. 진짜로 배그같이 무기가 많은 게임을 만든다면 몇백줄 가까이 되겠죠.
Weapons GetSwapInventory()
{
if (SwapWeapon1) return weapon_Gun;
else if (SwapWeapon2) return weapon_Melee;
else return null;
}
다시 가져왔습니다. if조건에 들어간 변수의 이름을 보시면 아시겠지만 이 친구는 무기를 확인하는 게 아닙니다. 키입력을 확인하는 겁니다.
함수가 실행되면 몇 번 인벤토리를 스왑하는 키를 눌렀는지 확인하고 해당 인벤토리에 현재 보유중인 무기를 반환합니다. 즉 저 함수는 완성형입니다. 무기를 100개는 고사하고 1000개 넘게 만들어도 저 함수는 3줄입니다. 그런 인생으로 마무리하는 함수입니다.
그리고 무기를 착용하는 함수입니다.
// 무기 착용
void PutWeaponOn()
{
// 현재 착용중인 무기가 있으면 무기 숨기기
if (currentWeapon != null)
currentWeapon.gameObject.SetActive(false);
// 현재 착용 중인 무기 변경
currentWeapon = GetSwapInventory();
// 착용 무기 보여주기
currentWeapon.gameObject.SetActive(true);
}
이 함수는 3개의 단계로 이루어져 있습니다.
1. 현재 들고 있는 무기 비활성화
2. 현재 들고 있는 무기 정보 바꾸기
3. 현재 들고 있는 무기 활성화
한줄로 줄여보자면
"현재 무기를 감추고 변수에 바꿀 무기를 대입후 무기를 보여주기" 입니다.
공격 부분을 확인합시다.
// 공격
void Attack()
{
// 공격 키입력을 받았으며 무기를 착용중며 공격 가능 상태일 때
if(AttackDown && currentWeapon != null && currentWeapon.attackAble)
{
currentWeapon.Attack();
}
}
끝입니다. 글이 끝이라는게 아니라 저 함수가 끝이라는 뜻입니다. 그냥 공격 키를 누르고 무기를 가지고 있고 공격 쿨타임 아니면 Attack()을 실행하면 됩니다.
Attack() 함수는 구현이 강제되고 각각의 무기마다 다르게 구현되기 때문에 저 한 줄로 수백줄의 조건문을 대신할 수 있습니다.
이제 무기를 제작할 때 그냥 Weapons 스크립트만 상속받으면 무기의 사용 및 교체 부분은 자동으로 구현됩니다. 어떻게 작동할 건지만 스크립트에서 구현하면 됩니다.
이러한 부분이 전략 패턴의 강점입니다. 컨텐츠를 추가할때는 특정 스크립트를 상속받고 기능만 구현하면 됩니다.
또한 코드들이 서로 독립적으로 존재하기에 수정, 디버깅을 할 때 "잠깐만 이거 고치면 저거도 고쳐야 되잖아? 그럼 그거도 바꿔야 되는데?" 같은 참사가 일어날 확률이 현저히 줄어듭니다.
참고
여담이지만 저 코드의 조건을 보고 흠칫하신 분들이 계실 겁니다. 아마 저라면 이런 생각을 했을 거 같네요.
" AttackDown 은 공격 키지? 그리고 좌클릭이라고 위에서 설명했고.
근데 배그에서 수류탄은 우클릭으로 조준한 다음에 던지는데? 이건 어떻게 해? "
그 부분에 대한 추측은 뒷부분에서 가능합니다. currentWeapon.attackAble 이죠.
총과 같은 무기에서는 발사와 발사 사이의 간격을 구현할 때 쓰이겠지만 수류탄과 같은 무기에서는 조준이 완료되면 attackAble을 true로 하게 해서 공격 가능 상태도 다르게 구현할 수 있을 겁니다.
그럼 이번에는 사용 부분 말고 무기 내부의 예시를 살짝 봐 봅시다.
망치 스크립트
public class Hammer : Weapons
{
public override void Attack()
{
Debug.Log("망치 퍽퍽");
}
}
총 스크립트
public class Gun : Weapons
{
public override void Attack()
{
Debug.Log("모래반지 빵야빵야");
}
}
함수 안에 있는 내용은 실로 빈약하지만 거시적이고 관대한 관점에서 보신다면 같은 함수가 서로 다른 기능을 한다는 점을 알 수 있을 것입니다.
이처럼 총과 해머 스크립트는 Weapons 스크립트를 상속받으며 Attack 함수를 강제로 구현합니다.
또한 유니티 인스펙터 창에서 각종 변수들을 설정할 수도 있구요
인스펙터 창에서 무기마다 다른 변수 설정 가능
이제 위에서 작성한 코드를 기반으로 구현한 기능의 예시를 봅시다.
무기 교체
해머 공격
총 공격
분명 여러분이 본 건 Debug.Log()밖에 없었는데 갑자기 퀄리티가 올라갔습니다.
지금까지 노잼 텍스트만 보여주다가 갑자기 재밌어보이는 사진을 올린 건 여러분을 놀리는 게 아닙니다.
우선 저 내용은 골드메탈님 퀴터뷰 강의에서 진행하는 내용이고 저는 작년에 따라했습니다.
그리고 저 사진을 올린 이유는 제가 처음 전략 패턴을 보자마자 생각난 것이 바로 저 강좌의 내용이기 때문입니다.
저 강좌에서는 보다시피 망치랑 총이 있는데 교체나 공격을 제가 처음에 언급한 조건문으로 구현합니다.
무기 종류가 3가지 뿐이라 큰 상관은 없지만 공부하는 입장에서는 이미 만들어진 코드를 디자인 패턴을 이용해 리펙토링하는 것이기 때문에 이보다 더 좋을수가 없습니다.
그래서 혹시 관심이 있으신 분은 저 강좌를 시청하시고 거기에 전략 패턴같은 내용을 첨가해 자신만의 코드를 작성해보시는 것도 좋을 거라고 생각해 추천하는 느낌으로 넣어 봤습니다. 참고로 유튜브에 있고 무료입니다.
굳이 저 강의가 아니더라도 자신만의 코드를 작성하시면 많은 도움이 되실 겁니다.
이 재미도 없고 긴 내용을 끝까지 본 여러분에게 감사는 커녕 더 공부하라고 훈수질 하는 것 같아 죄송하긴 하지만 말하고 싶은 충동을 참을 수 없었습니다. 죄송합니다. 거시적이고 관대한 관점으로 봐주실 거라 믿습니다.