디자인 패턴 2. 스트래티지 패턴(Strategy Pattern)

2020. 2. 16. 21:29Programming/Design Pattern

스트래티지 패턴이 뭔가요?

세상 모든 지식을 알고 있는 위대하신 위키 백과님은 스트래티지 패턴(== 전략 패턴)을 아래와 같이 정의하고 있습니다.

전략 패턴 또는 정책 패턴은 실행 중에 알고리즘을 선택할 수 있게 하는 행위 소프트웨어 디자인 패턴이다. 전략 패턴은 특정한 계열의 알고리즘들을 정의하고 각 알고리즘을 캡슐화하며 이 알고리즘들을 해당 계열 안에서 상호 교체가 가능하게 만든다.

이 뭔 개소리야?

저 글귀를 읽고 '아! 그렇구나, 스트래티지 패턴 공부 끝!' 하고 잠자러 들어갈 수 있는 사람은 애초에 이 포스팅을 읽고 있지 않겠죠. 저 설명에서 캐치하고 가야할 포인트는 두 가지입니다.

  1. 실행 중에
  2. 알고리즘을 선택한다.

이 두 가지가 스트래티지 패턴의 골자입니다. 이제부터 스트래티지 패턴이 무엇이며, 어떻게 쓰일 수 있는지, 게임 만들기를 예로 들어 진행해보겠습니다.

포켓몬은 상성을 가지고 있습니다.

흔히들 스트래티지 패턴 비유에는 로봇이나, 오리 같은, 현실에서 볼 일이 없는 사례를 들고 옵니다만, 저는 그나마 저한테 이해가 쉬울(?) 포켓몬을 들고 와봤습니다.

포켓몬스터는 닌텐도에서 만든 게임 시리즈입니다. 그 중 '포켓몬'은 포켓몬스터의 알파이자 오메가인 존재로, 동물을 모티브로한 생명체이며, 특이하게도 '타입'이라는 것을 가지고 있습니다.

좌측부터, 파이리(불), 꼬부기(물), 이상해씨(풀)

포켓몬 시리즈를 관통하는 전통적인 룰 중에, 이러한 룰이 있습니다. 모든 포켓몬은 타입을 가지고 있으며, 타입간에는 상성이 존재한다. 이게 무슨 소리일까요? 위에 언급된 포켓몬들을 예로 들자면, 파이리(불)는 꼬부기(물)에 약하고, 꼬부기(물)는 이상해씨(풀)에게 약하지만, 또 이상해씨(풀)는 파이리(불)에게 약하다는 것이죠.

이제, 포켓몬 게임의 개발자가 되었다고 생각하고 진행을 해보겠습니다. 현재 게임 내에 등장할 포켓몬은 위의 파이리, 꼬부기, 이상해씨 뿐입니다. 포켓몬들은 아래와 같은 특징이 있습니다.

  • 각 포켓몬은 '타입'을 갖는다.
  • 각 포켓몬은 '공격'을 할 수 있다.
  • 포켓몬이 공격했을 때의 대미지는 내 타입과 상대방 타입의 상성에 따라 결정된다.

이를 토대로 클래스를 구현해 보겠습니다.

일단, Pokemon이라는 부모 클래스를 만들고, 여기에 "이름" "타입" "공격력" "방어력"을 넣고, 공격하는 메소드와 대미지를 계산하는 메소드를 구현했습니다. 이 부분까지는 모든 포켓몬이 공통적으로 가지는 부분이니까요. 그리고 파이리 클래스를 따로 만들어 Pokemon 클래스를 상속받게 한 뒤, 대미지 계산만 오버라이딩하도록 했습니다. 이렇게 할 경우, 상대방 타입이 무엇이냐에 따라 대미지를 다르게 할 수 있으므로 나름 괜찮은 방법 같습니다. (물론 이상해씨, 꼬부기 클래스에서도 약간의 코드 중복이 발생은 하겠지만요)

하지만 한 타입에 포켓몬이 여러개라면 어떨까?

그런데 개발하던 도중, 기획팀이 새로운 그림을 던지고 갑니다. 여러분은 "? 이게 뭥미?"하고 물어봅니다. 그러자 돌아오는 대답은 "진화형이요. 포켓몬은 진화한다고 말씀드렸잖아요?" 아뿔싸. 포켓몬은 진화를 한답니다. 부랴부랴 진화 기능을 추가하기로 했습니다.

갑자기 추가해야할 포켓몬이 6마리가 더 생겨버렸습니다.

추가하는 도중에 문제가 생겼습니다. 첫 째로, 처음에는 파이리, 꼬부기, 이상해씨만 있었으므로 타입마다 포켓몬이 한 마리씩만 있었습니다. 이제는 하나의 속성에 포켓몬이 3마리씩 있게 됩니다. 그럼, 위에서 짰던 대미지 계산 메소드를 같은 타입의 포켓몬 전부한테 복붙해서 넣어야 할까요?

고민 끝에 좋은 해법이 생각이 났습니다. 바로 FirePokemon, WaterPokemon, GrassPokemon 클래스를 각각 만든 다음, 타입에 따른 대미지 계산을 여기에 구현하고, 파이리-리자드-리자몽을 FirePokemon을 상속받게 하는 식으로 작업하는 것이죠. 이러면 포켓몬마다 대미지 계산을 따로 넣어줄 필요가 없습니다.

그렇다면 한 포켓몬에 타입이 여러개면 어떨까?

정한 방식대로 FirePokemon 클래스를 만들고 있는데... 느낌이 쎄합니다. 바로 이 놈 때문이죠.

얘는 드래곤 포켓몬과 자주 나오는 경우가 많지만, 이상하게도 드래곤 타입이 아니라 비행 타입입니다.

그림을 보니.. 이 녀석은 뭔가 이상하단 느낌이 듭니다. 무시하고 개발하려고 했지만, 아니나 다를까 기획팀이 와서 한 마디 거듭니다. "XX님! 리자몽은 타입이 2개에요. 불하고 비행! 전에 포켓몬은 타입을 1개 혹은 2개를 가질 수 있다고 말씀드렸죠?"

리자몽 앞에 눈 앞이 캄캄해진 개발자의 상상도.png

이제 난감해졌습니다. 타입을 2개까지 가질 수 있다면, 상속을 통해 대미지 계산을 정의하는 방법은 무의미합니다. 일단 Java에서는 다중 상속이 되지 않기도 하며, 다중 상속이 되는 언어에서도 다중 상속은 기피되니까요. 그렇다면 어떻게 각 포켓몬이 여러 타입을 가지면서, 대미지 계산 코드의 중복이 생기지 않게 할 수 있을까요?

몇날 며칠을 고민하던 여러분은 꿈에서 해법 하나가 떠올라서 바로 착수해봤습니다. 아래와 같은 코드로 말이죠.

해법이 보이시나요? 정답은 바로 타입을 클래스로 구현하는 것에 있었습니다. 맨 처음 요구사항에서부터 이미 답이 나와있던 건데, "상성"은 "타입"에 따라 결정되므로, 타입이 바로 상성에 따른 대미지 계산을 해주는 메소드를 가지고 있어야 했던 것이죠. 이렇게, 타입 클래스는 상대방의 타입에 따라 대미지의 비율을 계산해주고, 포켓몬 클래스는 그 비율에 따라 최종 대미지를 계산하게 되었습니다. 무엇보다, 이 방법을 쓴다면 포켓몬의 타입이 얼마나 많더라도 유연하게 적용시킬 수 있습니다!

간단한 예로, 리자몽은 불/비행 포켓몬이기에, 바위 타입의 공격에 정말 취약합니다. 불이 바위에 약하고, 비행도 바위에 약하기 때문이지요. 위에서 구현한 대로 RockType을 만들어서 적용한다면, 리자몽은 바위 타입의 공격을 받을 때 불타입과 비행타입으로 인해 각각 2배, 2배, 총합 4배의 대미지를 입게 됩니다.

하지만 타입을 바꿀 수 있는 포켓몬이 등장해버리면 어떨까?

그러나 다음 날 기획팀에서 다시 와서, 새로 추가될 포켓몬을 하나 더 던져놓고 갔습니다. 바로 이 녀석을요.

놀랍게도 이 놈은 '창조신' 포켓몬입니다.

"XX님! 포켓몬 하나 더 추가할거에요. 얘 이름은 아르세우스라고 하는데, 가지고 있는 아이템에 따라 타입이 바뀌어요!" 라고 말하며 유유히 사라지는 기획의 뒷통수가 너무나도 야속합니다. 지금까지는 태어날 때부터 처음 타입을 끝까지 유지하는 포켓몬만 만들어 왔는데, 정말 놀랍게도 상황에 따라 타입이 계속 바뀌는 포켓몬까지 등장해버리고 만 것입니다. 생성된 이후에도 타입이 얼마든지 변할 수 있는 포켓몬은 어떻게 구현해야 할까요? 의외로 답은 간단합니다.

기존의 코드는 생성자에서 포켓몬 타입을 받고, 이후로 아무것도 하지 않았다면, 단순히 포켓몬 타입을 새로 넣어주는 메소드를 넣어주면 됩니다. 이렇게 함으로써, 아르세우스 뿐 아니라 모든 포켓몬이 상황에 따라 자신의 타입을 바꿀 수 있게 되었습니다. 즉, 컴파일할 때가 아니라, 실행 시에 대미지 계산의 알고리즘을 바꿀 수 있게 된 것이죠.

헤엄 치는 포켓몬, 하늘을 나는 포켓몬이 출동하면 어떨까?

이렇게 또 한번의 위기를 넘는 것 같았는데, 이제 위기의 끝판왕이 오고 말았습니다. 어느 날 회의에서 팀장님이 이런 이야기를 꺼냈습니다.

"포켓몬이 싸우기만 하니까 재미 없다. 물 포켓몬은 맵에서 헤엄도 치고, 비행 포켓몬은 하늘도 날아다녀서 마을을 자유롭게 이동하게 하면 더 멋있지 않겠나?"

이제 "특정 타입의 포켓몬은 특수 동작을 할 수 있다"는 조건까지 추가되고 말았습니다. 이는 단순히 생각해보면, 물 타입에는 swim() 메소드를, 비행 타입에는 fly() 메소드를 추가하면 될 거 같습니다...만! 위의 클래스들을 보면 각 포켓몬은 'PokemonType' 클래스를 멤버로 받고 있으므로, 자식 클래스에만 존재하는 swim()과 fly()를 Pokemon 클래스에서 호출할 수가 없습니다. 어떻게 해야할까요?

그렇다면, 부모 클래스 쪽에서 수정이 가해져야 할 것 같군요. 아래의 코드를 보실까요.

이제 PokemonType에는 swimmable, flyable이라는 boolean이 들어가고, Pokemon 클래스에 fly()와 swim()이 들어가게 되었습니다. 이제 포켓몬들은 fly(), swim()이 호출될 때 각 타입 중에 swimmable이거나, flyable인 타입이 있는지를 판단해서 날 거나 헤엄을 칠 수 있게 되었습니다.

물 포켓몬이 아닌데 헤엄을 치는 포켓몬이 등장하면 어떨까?

발매가 얼마 안 남은 시점에, 팀장님이 싱글벙글하면서 나타났습니다. 느낌이 좋지 않습니다.

"이벤트용 포켓몬을 하나 만들자! 전기 포켓몬인 피카츄 중에, 헤엄도 치고 하늘도 나는 특별한 피카츄를 만들어서 나중에 따로 배포하는거야! 사람들이 이 피카츄 얻으려고 다들 이벤트에 몰려들겠지?"

제발 그만해!!!

전기 타입은 헤엄을 칠 수도 없고, 하늘도 날 수 없습니다. 그런데 이제 하늘을 날면서 헤엄을 칠 수 있는 피카츄는 있어야만 합니다! 그럼 ElectricButCanFlyAndSwimType 이라도 만들어야 할까요? 아뇨, 이제 타입에 특수 행동 여부를 넣는 것은 의미가 없어졌습니다. 즉, 헤엄과 공중날기에 관한 속성을 분리해서 별도의 행위(Behavior)로 정의해야 할 때가 왔다는 뜻입니다.

이제 마지막 코드입니다.

우리는 흔히 class를 "사물"에 빗대어 생각하는 경향이 있습니다. 그래서 FlyBehavior 같은, "동작"같은 개념을 interface화, class 구현화 하는 것이 어색할 수 있습니다. 그러나 위에서 보듯이 행동 또한 인터페이스의 대상이 될 수 있습니다.

이렇게 '하늘을 나는 행위'와 '헤엄을 치는 행위'를 별도의 Behavior 인터페이스로 빼놓고, 포켓몬이 동작을 수행할 때마다 해당 행동 구현체의 메소드를 실행하게 한다면, 지금까지 나온 모든 요구사항을 만족하는 포켓몬을 구현할 수 있습니다. 이제 헤엄을 치는 리자몽 따위의 이상한 포켓몬도 별다른 추가 비용 없이도 마음껏 만들 수 있게 된 것입니다. (하지만 포켓몬 팬들이 용납하지 않을 것입니다.)

예시가 아니라 진짜로 있었던 놈입니다.

마치며

위에서 말했던 대로 스트래티지 패턴은 1. 실행 중에 2. 수행하는 알고리즘을 변경할 수 있다는 점이 특징인 패턴입니다. 이를 포켓몬 게임 개발 과정을 통해 학습해보았습니다. 물론, 실제 닌텐도에서 포켓몬을 이렇게 개발하지는 않을 것 같습니다. (이것보다 제곱은 더 복잡하겠죠) 하지만 저는 의미도 없는 오리 클래스를 구현하는 것보다, 제가 실제로 체험해봤던 사례 중에 스트래티지 패턴이 적용될 만한 것을 찾고 싶었고, 그 결과가 바로 포켓몬이었습니다. 부디 이 포스팅이 여러분으로 하여금 스트래티지 패턴을 잘 이해하는 데 도움이 되었으면 합니다.

여담으로, 스트래티지 패턴은 자바에서(특히 그 중 스프링 프레임워크에서) 자주 사용하는 의존성 주입(Dependency Injection)과도 유사한 점이 많은 패턴입니다. 나중에 기회가 된다면 의존성 주입에 관한 포스팅도 작성해보도록 하겠습니다.