이 글은 놀개영 http://www.gamedevforever.com 에서 연재 중인 글입니다.
왠지 구조를 바꿔야 할것 같아...
보통 많은 게임들에서 상속 구조를 씁니다. OOP( Object-Oriented Programming ) 디자인을 지향합니다. 하나의 객체를 클래스로 정의하고 상속을 통해 객체를 확장해 나갑니다. 다음은 전형적인 상속 구조를 보여주는 이미지입니다.
끼로님의 이미지를 무단 도용했습니다.
감사합니다. 끼로님.
감사합니다. 끼로님.
플레이어를 예를 들면 게임 오브젝트 클래스를 기반으로 하여, 화면에 그려지는 객체이니 RenderableObject 클래스를 구현하고, 움직일수 있어야 하니 MovableObject 클래스를 구현하여 상속 받습니다. 그다지 문제될게 없어보입니다. 그러나 끼로님 포스트도 보시면 아시겠지만 이 방식이 항상 좋은 것만은 아닙니다. 상속으로 인한 유연성의 부재라든가 여러 이유도 있지만 여기서 이야기 할것은 좀 다른 주제 입니다.
상속을 통한 오브젝트들을 병렬로 처리 할 때 생기는 문제점들을 한번 알아 보겠습니다. 먼저 싱글 스레드로 각각의 오브젝트들을 업데이트 한다고 가정하겠습니다. 사용자 입력을 받고, 그에 맞게 플레이어를 움직이고, 그 상황에 맞게 적 몬스터라든가 환경들이 업데이트 됩니다. 그리고 업데이트 된 최종 결과물을 화면에 그려내는 것 입니다.
여기서 각각의 오브젝트들이 업데이트되는 과정을 좀더 자세히 들여다 보겠습니다. 사용자가 몬스터를 공격한다고 예를 들겠습니다. 플레이어가 공격 버튼을 입력함으로서, 플레이어 캐릭터는 공격 상태에 들어가게 됩니다. 그리고 상대 몬스터의 AI도 업데이트 됩니다. 플레이어의 공격을 막을 것인지, 피할 것인지, 같이 공격할 것인지 판단을 하게 됩니다. 이 때, 몬스터 오브젝트와 플레이어 오브젝트는 상호작용을 하게 됩니다. 서로를 참조 하며, 서로의 데이터를 검사하고, 공격의 성공, 회피의 성공들을 계산하게 됩니다. 그리고 나온 결과에 맞춰 후액션들이 결정 됩니다. 공격이 성공했으면 몬스터의 피격 액션이, 또는 회피에 성공했으면 회피 모션이 나오 듯이 말입니다.
이 모든 동작은 순차적으로 진행되었습니다. 아무런 문제도 없어 보이고, 상당히 논리적입니다. 그러나 이 과정이 병렬 처리로 들어가게 되면 예상치 못한 결과를 초래하게 됩니다. 가장 큰 문제는 오브젝트들이 서로를 직접적으로 참조 하고 있다는 겁니다. 다시 위의 과정을 살펴봐주세요. 플레이어의 캐릭터가 몬스터를 공격하면서 몬스터 객체를 직접 참조하고 있습니다. 몬스터의 방어력, 회피력을 참조 하고, 또 몬스터도 공격하는 플레이어를 참조하면서 회피할지, 맞대응을 할지 판단하게 됩니다.
병렬 처리에 있어 최대 문제점 중 하나가 데이터 의존성 입니다. 각각의 오브젝트들을 병렬로 처리하다 보면 서로 참조 하는 데이터가 어느 순간에 어떻게 바뀔지 알수가 없게 됩니다. 참조 하려는 데이터가 다른 스레드에 의해서 변질 될수 있기 때문입니다.
이런 문제점(?)이 생깁니다.
좀더 유리한 구조로...
그렇다면 위의 문제점을 해결하면서 병렬 처리에 유리한 방법이 어떤게 있을까요? 찾아보다 보면 여러 가지 방법 중에 눈에 띄는 것이 한가지 나옵니다. 바로 구성 요소 기반 객체 ( Component Based Object ) 입니다. 이것 역시 끼로님께서 연재 중이신 바로 그 것 입니다. (끼로님 사... 사... 좋아해요)
왜 구성 요소(이하 컴포넌트) 기반 객체 방식이 병렬 처리에 유리한지 알아보겠습니다. 첫 째로 각 객체들을 직접적으로 참조하지 않습니다. 각 객체들은 컴포넌트로 구성 되어있고, 객체들은 직접적으로 동작하지 않고, 컴포넌트를 통해 모든 동작을 수행하게 됩니다. 컴포넌트들은 메시지를 통해 간접적으로 데이터를 주고 받으며, 동작을 수행하게 됩니다. 이를 통해 데이터 의존성 문제를 어느 정도 우회할 수 있게 됩니다.
이렇게 우회해서 데이터를 전달
두 번째로 객체를 구성하는 각각의 컴포넌트를 하나의 과제(Task)로서 처리할 수 있습니다. 많은 병렬 처리 라이브러리들은 과제 스케쥴링( Task Scheduler )을 지원합니다. 처리해야 할 100개의 과제가 있다고 가정해보겠습니다. 이 과제들은 서로 다른 처리량을 가지고 있으므로, 2개의 스레드에 똑같이 50개씩 분배 했다고 하더라도 동시에 과제 처리를 끝내지는 못합니다. 보통 먼저 과제를 처리 한 스레드가 나머지 스레드를 기다리게 됩니다. 하지만 스케쥴러를 통해 아직 과제를 처리 못한 스레드의 남은 과제를 가져와 보다 효율 적으로 과제를 처리할수 있게 됩니다. 이 스케쥴러를 이용하게 되면 객체 단위로 상태를 업데이트 하는 것보다 기능을 잘게 쪼갠 컴포넌트 방식이 훨씬 유리합니다.
스케쥴링을 통해 효율적으로 과제를 분배
그렇다고 이게 만능은 아니지...
구성 요소 기반 객체를 쓴다고 해서 데이터 의존성에 완전히 벗어나는 것은 아닙니다. 어떻게든 데이터를 필요로 하는 컴포넌트에 데이터는 넘겨주어야 합니다. 그리고 이 데이터가 업데이트 된 올바른 데이터인지 보장도 필요합니다. 이를 위해 많은 방법들이 나와있습니다. 참조 카운트를 이용해 상위 컴포넌트 부터 처리를 한다거나 필요한 데이터를 가지고 있는 컴포넌트가 아직 업데이트 된 상태가 아니면 처리 중인 컴포넌트를 다시 대기열 뒤로 옮겨 나중에 업데이트 시키는 방식 등이 있습니다.
.... 이렇게 끼로님에게 묻어가기 성공
참고
Evolve Your Hierarchy : Refactoring Game Entities With Components
Game Programming Gems 6
게임 오브젝트 설계.. 나도 잘하고 싶다! by 끼로
Multithreaded Game Engine Architectures