Studies/Unreal Engine

Unreal Engine 프로젝트를 시작하기 전에..

ReTeu 2024. 8. 30. 11:15

23년 12월 즈음부터 갑자기 바람이 불어 언리얼엔진 프로젝트를 진행해보고 있다. 얼마 전 첫 결과물을 세상에 공개하기도 했는데, 어제부터 오늘까지 양일간 열리고 있는 Unreal Fest '24 Seoul에 참석해 강연을 듣다가 뒤통수를 제대로 맞은듯한 느낌이 드는 일이 있었다. 내가 미처 고려하지 못했던 부분들이나, '아.. 분명 이걸 깔끔하게 해결할 수 있는 훨씬 효율적인 방법이 있을텐데.. 어떻게 해야할지를 전혀 모르겠네'라는 생각이 들었던 부분들을 정말 깔끔하게 해결한 사례를 알려주는 것이 아닌가. 프로젝트 리빌딩을 반드시 해봐야겠다는 의욕이 샘솟는 한편, 이왕 리빌딩하는 김에 그동안 쌓은 노하우를 바탕으로 언리얼 엔진의 특성에 맞게 체계적으로 구조를 다시 설계해보자는 생각이 들어, 차근차근 정리해보고자 한다.

 

1. GameInstance

게임 인스턴스는 게임이 시작되는 시점부터 terminated되기 까지의 모든 순간에 존재하는 단일 인스턴스로서, 모든 레벨/게임모드에서 공유되는 클래스이다. (정확한 비유가 아닐 수도 있지만) 싱글톤 형태로 존재하는 클래스로 생각하면 될듯하며, 다음과 같은 특성을 가진다.

  1. 게임의 생애주기 내내 모든 경우에 전역적으로 접근 가능하다.
    GameInstance에 담긴 내용은 레벨이 전환되더라도 그대로 유지된다.
    따라서 게임 설정, 통계 데이터 등을 보관하고 관리하는 데에 사용하기 적합하다.
    SaveGame 클래스와 적절히 활용하면, 로컬 환경에서 세이브/로드해야하는 설정이나 통계 데이터를 보관하도록 만들 수 있겠다.
  2. 네트워크를 통해 다른 클라이언트로 Replicate되지 않는다. ( == 클라이언트에서 바로 호출할 수 있다.)
    쉽게 말해, 각 클라이언트 별로 자신만의 GameInstance 객체를 가지고 있으며, 다른 클라이언트로는 그 내용을 공유하지 않는다는 의미이다. 클라이언트와 서버 간에도 공유하지 않고, 서버 역시 서버 만의 독립적인 객체를 갖는다.

UMG로 만든 UI 인스턴스를 GameInstance에서 포인터 변수로 참조하고 있다면, 레벨이 변경되더라도 특정 UI에 대해 언제든지 접근할 수 있게되므로 유용하게 사용할 수 있다. 또한 게임이 실행되는 동안 클라이언트 레벨에서 계속 접근해야하는 값들을 저장해두기에도 용이하다. 세이브데이터에서 로드한 설정값을 GameInstance에 저장하고 업데이트하다가 사용자의 요청이 있거나 게임을 종료하는 시점에 세이브데이터로 덮어쓰도록 만든다면 게임을 실행할 때마다 매번 다시 설정을 조정해야하는 일도 없어지고 개인화된 설정값을 그대로 로드하여 언제든지 접근할 수 있도록 만들 수 있어 용이하다.

 

2. GameMode

마인크래프트때문에 그 이름만은 매우 친숙한 GameMode.. (물론 하는 일은 다르다.)

각 레벨에 대해 어떠한 게임모드를 사용할 것인지 사전에 지정해줄 수 있으며, 눈치챘겠지만 특정 레벨에서 적용되는 게임의 규칙과 플로우(흐름)를 관리한다. 따라서 게임모드는 다음과 같은 특징을 가진다고 볼 수 있겠다.

  1. 레벨의 생애주기 동안 유지된다.
    특정 레벨에서 적용되는 규칙과 게임의 레벨 내 흐름 전반을 관리하면 좋다.
    레벨이 로드될 때 생성되고, 언로드될 때 파괴된다.
  2. GameMode는 서버에서만 관리된다.
    블루프린트 기준으로 `Run On Server` RPC 노드를 통해서만 GameMode에 접근할 수 있으며, 클라이언트에는 GameMode 자체가 존재하지 않는다. (클래스의 존재 목적을 고려해본다면 납득하기 어렵지 않다.)
    이러한 특성을 활용해서, 모든 클라이언트에 대해 일괄적으로 적용해야하는 로직을 수행하기에 좋다. (서버사이드에서 실행되고, 모든 PlayerController에 접근할 수 있으므로)
    물론 스탠드얼론 게임이라면 직접 접근할 수 있다.

 

3. GameState

언리얼에는 GameState와 PlayerState라는 개념(클래스)이 존재한다. 두 이름만 봐도 대강 뭐하는 애들인지 감이 올거다. 생각하고 있는 그것들이 다 맞다.

  1. 서버와 모든 클라이언트에 대해 공통적으로 적용되는 상태를 관리한다.
    게임의 스코어나 타이머와 같이 게임을 플레이중인 모두가 동일한 값을 봐야하는 경우에 사용한다. 모든 값은 당연히 언리얼이 알아서 동기화해준다. 따라서 게임 전연적으로 관리하려는 게임의 진행 상태를 저장하기에 적합하다. 내 경우에는 접속한 플레이어의 PlayerController에 대한 참조를 보관하고 Index를 발급해 클라이언트에게 전달해주는 목적으로 사용했다.
  2. 개별 클라이언트에서 로컬로 관리해야하는 데이터는 후술할 PlayerState에서 사용하는 것이 좋다.

 

4. PlayerState

그렇다. 얘도 이름 그대로 생겨먹었다.

  1. 개별 클라이언트에서 관리하며, 자동으로 서버와 동기화(Replicate)된다.
    때문에, 로컬 환경에서 기억하고 관리해야하는 상태를 보관하기에 좋다.

이렇게 되면 GameInstance가 있는데 왜 PlayerState가 존재하고 사용해야하는지에 대해 의문이 생길 수 있다.

GameInstance는 게임당 하나의 객체만 생성되지만, PlayerState는 플레이어의 개수만큼 생성된디는 점을 인지하자. '현재 몇 라운드인지', '남은 시간은 몇 초인지'와 같이 게임 자체에서 공통적으로 적용될 값은 GameInstance에서, '몇 번 플레이어인지', '이 플레이어가 소속된 팀은 어디인지/킬 수가 몇인지/소지하고 있는 56탄은 몇 개인지'와 같이 특정 Player에 대한 값은 PlayerState에서 보관하도록 만드는 것이 훨씬 효율적이다.

5. PlayerController

이름이 다 비슷비슷하게 생겨서 처음 접할 때에는 많이 헷갈릴 수 있다. PlayerController (PC)는 클라이언트마다 하나씩 존재하며, 서버와도 동기화된다. (다만, 서버와 클라이언트에서 바라보는 객체는 다를 수 있다. 자세한 설명은 아래에서..)

 

PC에 대해 이해하려면 우선 언리얼 엔진에서 객체를 다루는 기본적인 방식에 대해 알아야할 필요가 있다.

Unreal Engine에 존재하는 (거의) 모든 클래스는 UObject를 부모 클래스로 가진다. Unity를 다뤄본 경험이 있다면 'monobehaviour'에 정확히 대응하는 개념이다. # (UObject를 상속받지 않고 None으로 만들고 내부적으로 사용할 수도 있긴 하다. 에디터에서 인식을 못하게되어서 그렇지)

레벨에 배치할 수 있는 객체의 기본 단위는 Actor라고 부르며, 액터 중에서도 특별한 기능이 추가된 오브젝트들이 존재하는데, 대표적으로 아래와 같은 클래스를 가장 많이 사용한다. (전부 다 액터를 상속받아 구현된 클래스라는 말이다.)

  • Actor: 액터는 레벨에 배치될 수 있는 오브젝트의 기본 단위이다.
  • Pawn: 폰은 액터 중에서도 플레이어(PC) 또는 AI가 빙의할 수 있는 기능이 포함된 오브젝트이다. 여기에서부터 InputAction 등을 통해 사용자의 입력값을 핸들링할 수 있다.
  • Character: 캐릭터는 폰 중에서도 플레이어 또는 AI가 직접 조작하고 움직일 수 있는 기능이 포함된 오브젝트이다.
    (사실 Pawn에서도 움직이게 만들 수 있고 정의만 놓고 보면 간단하여 별 차이가 없어보이지만, 생각보다 훨씬 다양하고 복잡한 로직들이 여럿 추가되어 있다. #예를_들면_이런거)

더 다양한 클래스에 대해 찍먹만 해보고 싶다면 언리얼 에디터에서  'Tools > New C++ Class' 메뉴를 통해 아래와 같이 각 클래스에 대한 한줄 소개를 확인할 수 있으니 참고하자.

 

앞서 서버와 클라이언트에서 참조하는 PC가 상이할 수 있다고 말했는데, 이는 치팅과 연관되어 있다. 플레이어가 조작하는 대상에 대한 대부분의 로직을 PC에서 수행한다는 특성으로 인해 클라이언트에서 PC의 동작으로 조작한다면 너무나도 손쉽게 치팅할 수 있게된다. 이러한 보안 상의 이유로, 언리얼엔진에서는 하나의 플레이어에 대하여 클라이언트 사이드에서 관리하는 PC와 서버 사이드에서 관리하는 PC가 분리되어 존재한다.

  • 클라이언트 : Client-Side PC만을 참조할 수 있다. (때문에 PC를 조작하더라도 다른 클라이언트로 replicate되지 않음.)
  • 서버 : Server-Side PC와 Cliet-Side PC 모두를 참조하고 수정할 수 있다. 만약 서버를 통해 replicate되어야 하는 로직이 존재한다면 RPC를 통해 서버쪽의 PC를 조작하도록 호출해야한다. (RPC를 이용해 서버로 넘긴 다음, 서버 사이드에서 다시 RPC를 호출해서 브로드캐스트(언리얼에서는 multicast)해야한다.)

서론이 길었다. 그래서 PlayerController에서는 어떠한 로직을 다루는 것이 현명할까? 플레이어의 입력을 받아 처리하고 서버와의 통신을 관리하는 비즈니스 로직을 담고 있기에 적절하다. 서버와 클라이언트 모두에 존재하기 때문에 둘 간의 네트워크 트래픽을 처리하는 부분에 사용하기 좋다. 다만 위에어 언급한 것처럼, 서버 사이드에서 수행하는 로직은 클라이언트로 replicate되지만, 클라이언트에서만 처리한 로직은 서버를 통해 replicate되지 않으므로 자기 자신에서만 작동하는 것처럼 착각할 수 있으니 집중해서 작성하는 것이 좋겠다. 한 번 꼬이면 찾아내기 쉽지 않을테니...