3. 들어가기 전에
이 장에서는 유니티 동작의 핵심인 컴포넌트를 설명한다. 또한 게임 오브젝트를 직접 만들면서 컴포넌트가 실제로 어떻게 동작하는지 확인한다.
이 장에서 다루는 내용
- 상속을 이용한 개발 방법
- 컴포넌트 패턴의 장점
- 컴포넌트와 게임 오브젝트의 관계
- MonoBehaviour의 정체
- 메시지 기반 방식의 원리
레트로의 유니티 게임 프로그래밍 에센스 : 네이버 도서
3.1 상속과 재사용
게임 엔진은 이미 완성된 기반 코드를 제공한다. 개발자는 게임 엔진의 코드를 재사용하므로 생산성이 올라간다.
유니티의 컴포넌트 기반 구조를 이해하려면 코드를 재사용하는 전통적 방법인 상속을 알아야 한다. 상속은 이미 만들어진 클래스에 새로운 코드와 기능을 덧붙여 새로운 클래스를 만드는 방법이다.
기초를 제공하는 클래스를 부모 클래스라 부르고 부모 클래스를 상속해서 확장한 클래스를 자식 클래스라 부른다.
클래스란 묘사할 대상과 관련된 코드(변수와 메서드 등)를 묶는 틀이라고 할 수 있다. 예를 들어 플레이어 클래스는 플레이어와 관련된 코드를 가지고, 몬스터 클래스는 몬스터와 관련된 코드를 가진다(변수와 메서드는 4장, 클래스는 5장에서 자세히 설명한다).
3.1.1 상속으로 몬스터 만들기
개발자가 상속으로 게임 속 몬스터를 만드는 예를 생각해본다. 개발자는 오크와 오크 대장을 만들려고 한다. 이들을 효율적으로 구현하기 위한 몬스터 클래스를 먼저 만들고, 이들 세 클래스가 다음과 같은 상속 관계를 가지게 한다.
구현할 몬스터 클래스들
- class Monster
- class Orc : Monster
- class OrcChieftan : Orc
1. 몬스터(Monster) 클래스 구현
Monster 클래스는 몬스터로서 필요한 다음의 필수 기능을 가지고 있다고 하자.
- 인공지능 기능
- 애니메이션 기능
- 공격과 방어 기능
- 물리 기능
- 기타 필수 기능
그런데 Monster 에는 제대로 된 외형이 없다. Monster 클래스의 역할은 게임 속 몬스터로 곧장 사용되는 것이 아니기 때문이다. Monster 클래스는 여러 종류의 몬스터 클래스를 구현할 때 필요한 기초를 제공하는 부모 클래스로 사용된다.
게임에는 오크, 거미, 악마 등 다양한 몬스터가 등장한다. 개발자는 이러한 파생 몬스터를 Monster 클래스를 확장하는 방식으로 만들려 한 것이다.
2. 몬스터를 기반으로 오크(Orc) 클래스 구현
class Orc : Monster
콜론(:)은 오른쪽의 부모 클래스를 상속해 왼쪽의 자식 클래스를 만든다는 의미이다. 즉, Orc : Monster 는 Monster 클래스를 기반으로 Orc 클래스를 만드는 것이다. 몬스터를 상속한 오크는 몬스터의 모든 기능을 가진다. 따라서 몬스터에서 이미 구현한 인공지능, 애니메이션, 공격과 방어, 물리 기능 등을 다시 구현할 필요가 없다.
즉, 이제는 오크 고유 기능을 구현하는 데 집중할 수 있다.
예)
- 초록색 피부
- 오크의 애니메이션
- 오크의 스킬
- 그외 오크의 고유 기능
3. 오크를 기반으로 오크 대장(Orc Chieftan) 클래스 구현
class OrcChieftan : Orc
이는 오크의 모든 기능을 가진 오크 대장을 만들기 때문에 새로 구현할 부분이 많지 않다.
다음과 같은 기능을 새로 추가한다고 하자.
예)
- 대장 모자
- 새로운 무기와 강력한 스킬
- 그외 오크 대장의 고유 기능
개발자는 오크에 대장 모자와 무기, 강력한 대장용 스킬을 추가한다. 이 과정에서 몬스터와 오크에 이미 구현되어 있는 기능은 다시 만들 필요가 없다.
상속을 이용하면 미리 만들어진 코드를 확장하여 새로운 코드를 작성할 수 있다. 이것이 상속의 힘! 그렇지만 상속이 만능은 아니다.
3.1.2 상속의 한계
부모 클래스를 상속해 자식 클래스의 기초 구현을 대신할 수 있다. 하지만 상속에만 의존하면 오히려 코드를 재사용하기 힘들 수 있다.
이번에는 RPG 게임에서 플레이어와 NPC, 몬스터를 만드는 예를 생각해본다. 개발자는 이들을 구현하기 전에 기반이 될 부모 클래스를 먼저 만들기로 한다.
1. 최상위 부모 클래스인 사람(Human) 클래스 구현
- Human 클래스
Human |
렌더 |
물리 |
애니메이션 |
체력 |
Human 클래스는 사람 형태를 가진 클래스의 부모 클래스로 사용한다. 사람 형태를 가진 오브젝트에 필요한 기능을 미리 예상해서 Human 클래스에 추가한다.
2. 플레이어(Player) 클래스 구현
플레이어가 직접 조작하는 캐릭터인 Player 클래스를 만든다. Player 클래스는 Human 클래스를 상속하여 Human 의 모든 기능을 가진다.
Human |
렌더 |
물리 |
애니메이션 |
체력 |
↑
Player |
+ 조작 |
+ 공격 |
+ 직업 |
Human 클래스의 기능 위에 새로 추가한 Player 의 기능은 다음과 같다.
- 조작 기능
- 공격 기능
- 직업 기능
- 그외 필수 기능
지금까지는 문제가 없다.
3. NPC 클래스 구현
마을 NPC 는 한 곳에 머무르며 플레이어와 대화, 거래 등의 상호작용을 한다. 이번에도 Human 클래스를 상속하여 NPC 클래스를 만든다. 그런데 이 과정에서 문제가 생긴다.
Human |
렌더 |
물리 |
애니메이션 |
체력 |
↑
NPC |
- 물리 + 대화 |
- 체력 + 거래 |
NPC 클래스가 상속한 Human 클래스에는 물리와 체력 기능이 있다. 그런데 NPC 는 물리와 체력 기능이 필요 없다. NPC 에 체력이 있으면 누군가 NPC 를 공격해 죽일 수도 있기 때문.. 그리고 NPC 는 한 곳에 고정되어 플레이어와 상호작용하기 때문에 물리 기능이 필요 없다.
결국 NPC 클래스에서 Human 클래스로부터 물려받은 물리와 체력 기능을 제거한다. 또한 제거한 기능과 관련된 다른 기능에 에러가 발생하지 않도록 코드를 정리하는 추가 작업도 해야 한다. 그 후 NPC 고유의 기능을 추가할 수 있다.
즉, 상속으로 인해 오히려 추가 작업이 생겼다.
3.1.3 결론
상속에만 의존하면 오히려 기존 코드를 재사용하기 힘든 경우가 생길 수 있다.
상속에만 의존하여 게임을 개발할 때 생기는 문제점
- 오히려 코드를 재사용하기 힘든 경우
- 기획자가 새로운 오브젝트를 만들려면 프로그래머에게 의존해야 함
이러한 문제를 해결하기 위해 컴포넌트 패턴을 사용해야 한다.
3.2 컴포넌트 패턴: 조립하는 게임 세상
게임 엔진에서 게임 오브젝트는 게임 세상에 존재하는 하나의 물체이다. 유니티는 게임 오브젝트를 컴포넌트 패턴을 사용해 만드낟.
컴포넌트 패턴 혹은 컴포지션(Composition) 패턴이란 미리 만들어진 부품을 조립하여 완성된 오브젝트를 만드는 방식이다. 여기서 미리 만들어진 부품을 컴포넌트라 부르며 컴포넌트는 저마다의 대표 기능을 지닌다.
컴포넌트 패턴에서 게임 오브젝트는 속이 빈 껍데기. 개발자는 빈 게임 오브젝트에 컴포넌트를 조립하여 새로운 기능을 추가할 수 있다.
3.2.1 컴포넌트로 동물 만들기
컴포넌트로 게임 속 동물을 만든다고 가정해본다. 이전에는 여러 필수 기능을 부모 클래스 하나에 몰아넣었다. 이번에는 부품마다 대표 기능을 하나씩 부여하고, 여러 부품을 게임 오브젝트에 조합하는 방식을 사용한다.
1. 컴포넌트를 미리 여러개 만들기
게임에 등장할 모든 동물을 사전에 기획할 수 없다. 게임 콘텐츠는 계속 달라지고 추가되기 때문이다. 즉, 동물이 아니라 동물에 사용할 다양한 종류의 부품을 미리 만들어놓는 것이 더 현실적이다.
컴포넌트 주머니: 폐, 아가미, 탯줄, 뿔, 식사, 지느러미, 잠자기, 다리, 날개, 알 낳기
2. 컴포넌트를 미리 여러개 만들기
이제 컴포넌트들을 추가할 게임 오브젝트를 준비한다. 코뿔소라는 게임 오브젝트를 생성한다. 처음에는 게임 오브젝트의 내부가 비어있다.
* 게임 오브젝트는 빈 껍데기이며, 컴포넌트를 붙일 수 있는 뼈대나 홀더 역할
코뿔소 |
3. 코뿔소 게임 오브젝트 완성하기
이제 원하는 기능을 제공하는 컴포넌트를 찾아 게임 오브젝트에 붙여서 실질적인 기능을 부여한다.
코뿔소 |
폐 |
다리 |
식사 |
뿔 |
숨쉬는 기능을 추가하고 싶다면 폐 컴포넌트를 찾아 코뿔소 게임 오브젝트에 붙이면 된다. 코뿔소를 위한 숨쉬는 기능을 따로 작성할 필요가 없다.
이런식으로 코뿔소에 필요한 모든 컴포넌트를 찾아 추가하면 온전한 기능을 가진 코뿔소 게임 오브젝트가 완성된다.
4. 그 외 게임 오브젝트들 예시
상어 |
아가미 |
지느러미 |
잠자기 |
식사 |
알 낳기 |
독수리 |
폐 |
날개 |
다리 |
잠자기 |
식사 |
알 낳기 |
3.2.2 게임 오브젝트와 컴포넌트의 특징
결론적으로 컴포넌트 방식은 미리 만들어진 컴포넌트를 빈 껍데기인 게임 오브젝트에 조립하는 방식이다. 컴포넌트 방식에는 세 가지 장점이 있다.
컴포넌트 방식의 세 가지 장점
- 유연한 재사용이 가능하다.
- 상속을 이용하면 부모 클래스의 불필요한 기능까지 모두 가져오기 때문에 코드 재사용이 힘든 경우가 있다. 컴포넌트 방식은 원하는 기능을 가진 컴포넌트만 선택적으로 골라 쓸 수 있다.
- 기획자의 프로그래머 의존도가 낮아진다.
- 미리 만들어진 컴포넌트를 조립하여 게임 오브젝트를 만들 수 있기 때문이다.
- 독립성 덕분에 기능 추가와 삭제가 쉽다.
- 코드의 한 부분만 수정하더라도 관련된 여러 부분의 코드가 망가질 수 있기 때문에 두려운 경우가 있다. 하지만 컴포넌트 방식은 어떤 기능을 추가하거나 삭제할 때 다른 기능이 망가지지 않기 때문에 걱정이 줄어든다.
3.2.3 컴포넌트의 독립성
컴포넌트 패턴의 장점은 두 가지 특징에서 파생된다.
- 게임 오브젝트는 단순한 빈 껍데기
- 컴포넌트는 스스로 동작하는 독립적인 부품
- 컴포넌트는 자신과 같은 게임 오브젝트에 추가된 다른 컴포넌트에 관심이 없다. 컴포넌트의 기능은 컴포넌트 내부에 완성되어 있기 때문이다. 컴포넌트는 다른 컴포넌트에 의존하지 않는다. 즉, 게임 오브젝트에 어떤 컴포넌트를 마음대로 조립하거나 빼도 망가지지 않는다.
3.3 유니티 에디터에서의 컴포넌트
1장에서 만든 Hello Unity 프로젝트의 Cube 게임 오브젝트 구성에서 컴포넌트 구조를 다시 확인한다.
1장에서 리지드바디 컴포넌트를 붙였을 때 인스펙터 창을 통해 확인할 수 있었던 Cube 게임 오브젝트의 컴포넌트들은 다음과 같았다.
- 트랜스폼(Transform): 게임 오브젝트의 위치와 크기, 회전을 지정한다.
- 메시 필터(Mesh Filter): 오브젝트의 외각선을 지정한다.
- 메시 렌더러(Mesh Renderer): 메시를 따라 색을 채워 그래픽 외형을 그린다.
- 박스 콜라이더(Box Collider): 다른 물체가 부딪칠 수 있는 물리적인 표면을 만든다.
- 리지드바디(Rigidbody): 게임 오브젝트가 물리 엔진의 통제를 받게 한다.
즉, Cube 게임 오브젝트의 외형과 기능은 본래 Cube 가 지닌 것이 아니라 컴포넌트에 의해 추가되었다는 것을 알 수 있다.
컴포넌트를 추가할 때는 인스펙터 창에서 Add Component 버튼을 클릭한다. 반대로 기존 컴포넌트를 제거할 수도 있다. 특정 컴포넌트를 제거하고 싶다면 컴포넌트를 마우스 오른쪽 버튼으로 클릭하거나 컨텍스트 메뉴(:) 버튼을 클릭한 다음 Remove Component 를 선택한다.
만약 Cube 게임 오브젝트에서 박스 콜라이더 컴포넌트를 제거하면 Cube 게임 오브젝트의 물리적인 표면이 사라진다. 따라서 큐브는 다른 물체와 충돌하지 않고 그대로 뚫고 지나가게 된다.
하지만 박스 콜라이더가 제거되어도 Cube 게임 오브젝트의 다른 컴포넌트들은 정상적으로 동작한다.
3.4 메시지와 브로드캐스팅
컴포넌트 구조에서는 전체 방송을 이용해 컴포넌트의 특정 기능을 간접적으로 실행할 수 있다. 이러한 전체 방송을 브로드캐스팅이라 부른다.
3.4.1 MonoBehaviour는 무엇인가
브로드캐스팅이 가능한 원리를 이해하려면 먼저 모든 컴포넌트의 기반인 MonoBevaviour 를 알아야 한다.
유니티의 모든 컴포넌트는 MonoBehaviour 클래스를 상속한다. MonoBehaviour 클래스는 유니티에서 미리 만들어 제공하는 클래스이며 컴포넌트에 필요한 기본 기능을 제공한다. 즉, MonoBehaviour 를 상속한 클래스는 게임 오브젝트에 컴포넌트로서 추가될 수 있다.
MonoBehaviour 를 상속해서 만든 컴포넌트는 유니티의 제어를 받게 된다. 그러므로 컴포넌트는 유니티의 메시지를 들을 수 있다.
3.4.2 메시지 기반 방식
컴포넌트 패턴에서 컴포넌트들은 서로 관심이 없다. 어떤 컴포넌트는 같은 게임 오브젝트에 추가된 다른 컴포넌트가 일부러 찾아내기 전에는 알 수 없다.
마찬가지로 유니티 엔진 또한 어떤 게임 오브젝트에 어떤 컴포넌트가 추가되었는지 그 모든 명단을 모조리 파악하지 않는다. 따라서 유니티는 컴포넌트의 어떤 기능을 실행시키고 싶을 때 당사자를 직접 찾아가는 방법 대신에 메시지를 날리는 방식을 사용한다.
유니티는 발동시키고 싶은 기능의 이름을 담아 게임 세상에 미시지를 뿌린다. 게임 세상에 있는 모든 오브젝트가 메시지를 받게 된다.
메시지를 받은 오브젝트가 메시지에 명시된 기능을 가지고 있다면 해당 기능을 실행하고, 가지고 있지 않다면 메시지를 무시한다.
메시지 방식의 특징
- 메시지를 보내는 쪽은 누가 받게 될지 신경 쓰지 않는다.
- 메시지를 받는 쪽은 누가 보냈는지 신경 쓰지 않는다.
- 메시지를 받았을 때 메시지에 명시된 기능을 가지고 있다면 실행하고, 관련 없다면 무시한다.
* 이는 누가 메시지를 보냈는지, 누가 받게 될지 신경쓰지 않으므로 컴포넌트의 독립성을 유지할 수 있다. 메시지를 무차별적으로 여러 오브젝트에 동시에 뿌리는 방법을 브로드캐스팅이라고 한다.
3.4.3 브로드캐스팅
유니티가 메시지로 원하는 기능을 동작시키는 방법을 더 자세히 알아본다.
게임 오브젝트 1 |
컴포넌트: Eat(), Play(), Dance() |
컴포넌트: Work(), Walk() |
컴포넌트: Hello(), Run(), Dance() |
게임 오브젝트 2 |
컴포넌트: Eat(), Play(), Dance() |
컴포넌트: Work(), Make(), Dance() |
컴포넌트: Die(), Fishing() |
게임 오브젝트 3 |
컴포넌트: Eat(), Play(), Dance() |
컴포넌트: Die(), Dance() |
위 표에서 Eat(), Play(), Dance() 등을 메서드라고 부르는데, 각 컴포넌트가 가지고 있는 기능이다. 유니티는 컴포넌트들의 Dance() 기능을 일괄 실행하려고 한다. 하지만 일일이 모든 컴포넌트를 찾아가 Dance() 를 수동으로 실행할 필요는 없다. 대신 게임 세상에 Dance 라는 메시지를 브로드캐스팅한다.
그러면 게임 세상에 존재하는 모든 게임 오브젝트와 컴포넌트가 Dance 라는 메시지를 듣게 된다.
메시지를 받은 오브젝트들은 메시지가 어디서 왔는지 따지지 않는다. 그저, 어떤 컴포넌트가 메시지에 표시된 Dance 와 같은 이름의 Dance() 라는 기능을 가지고 있다면 실행한다. 결과적으로 단 한번의 브로드캐스팅으로 Dance() 를 가진 모든 컴포넌트가 Dance() 를 실행하게 한다.
3.4.4 유니티 이벤트 메서드
메시지와 브로드캐스팅은 앞으로 많이 사용할 Start(), Update(), OnTriggerEnter() 등의 유니티 이벤트 메서드가 동작하는 원리이다(메서드는 4장에서 자세히 설명한다).
예를 들어 Start() 메서드는 게임 오브젝트가 처음 활성화될 때 자동으로 한 번 실행된다. 게임 오브젝트가 활성화될 때 유니티가 해당 게임 오브젝트에 Start 라고 적힌 메시지를 브로드캐스팅하기 때문이다. 따라서 Start() 메서드를 수동으로 실행할 필요가 없다.
유니티에는 이런 식으로 이름 철자만 똑같이 구현해두면 메시지에 의해 자동으로 실행되는 메서드들이 있다. 이러한 메서드를 유니티 이벤트 함수 또는 유니티 이벤트 메서드라고 한다.
3.5 마치며
이 장에서는 유니티에서 게임 오브젝트의 핵심이 되는 컴포넌트와 게임 오브젝트 구조를 배웠다.
컴포넌트 구조에서는 미리 만들어진 컴포넌트를 게임 오브젝트에 조립하여 원하는 기능을 구현한다. 그리고 유니티는 미리 만들어진 수많은 컴포넌트를 제공한다. 따라서 유니티를 사용하면 원하는 기능을 바닥부터 직접 만드는 대신 전체 게임 로직에 집중할 수 있다.