유니티 개발자로 살아간지 반년이 넘었다.
이제 슬슬 언리얼을 다시 공부하지 않으면 아예 관련된 내용을 전부 잊을 지경이다.
가능한 범위에서 이론적으로 학습하고, 간단한 프로그램을 제작할 수 있도록 노력해보자.
유니티의 Object와 언리얼의 UObject의 차이
https://docs.unity3d.com/kr/2020.3/Manual/class-Object.html
중요 클래스 - Object - Unity 매뉴얼
UnityEngine 네임스페이스에는 Object라고 불리는 클래스가 있습니다. 이 클래스는 Unity가 에디터에서 참조할 수 있는 모든 오브젝트의 기본 클래스 역할을 합니다. UnityEngine.Object에서 상속되는 클래
docs.unity3d.com
https://dev.epicgames.com/documentation/ko-kr/unreal-engine/objects-in-unreal-engine
나는 처음에 UObject라는 개념을 접했을 때, 유니티의 MonoBehaviour 클래스와 비슷한 역할을 하는 줄 알았다. 그도 그럴 것이, 언리얼도 컴포넌트 구조를 취하니 이를 위한 기본적인 부모 클래스가 UObject겠구나.. 하는 생각을 했기 때문이다.
근데 사실은 굉장히 다르다...
위의 두 글을 보자.
유니티의 Object
기본적으로 유니티는 Object라는 최상단 클래스를 통해 유니티가 에디터에서 참조할 수 있는 모든 클래스를 파생하고 있다. (즉, Object에서 파생된 것이 아니면 에디터에서 참조가 어렵다는 것이다.)
.NET 프레임워크의 System.Object와는 다른, UnityEngine.Object라는 클래스이며, .NET 프레임워크와는 별개로 유니티 엔진이 지원하는 모든 객체의 최상위 클래스라 생각하면 된다.
유니티의 Object 클래스는 기본적으로 특정한 타입의 객체를 찾는 역할, 인스턴스화를 하거나 해당 인스턴스를 파괴하는 등의 역할을 수행할 수 있는 메서드를 제공한다. (Instantiate(), Destroy(), FindObjectOfType()...)
이렇게 Object라는 하나의 최상위 클래스로 관리를 하여 유니티는 에디터에서 강력한 통합 기능을 보여준다.
씬에서 관리되는 모든 객체가 전부 Object 클래스의 하위로 들어가기 때문에, 에디터에서 객체를 참조하거나 관리할 때 Object를 기반으로 관리를 하면 되기 때문이다.
다만 목적이 직렬화를 통한 에디터와의 간편한 통합을 위해서인지, 메모리와 관련해서는 기본적인 .NET Framework의 관리 방식을 따라간다.
언리얼의 UObject
마찬가지로 언리얼의 UObject 또한 언리얼의 게임 플레이에 있어서 필요한 클래스 대부분의 최상단 부모 클래스이다.
(대표적으로 AActor, UActorComponent 등이 있다.)
UObject는 UCLASS()나 UPROPERTY() 매크로를 사용하여 리플렉션과 직렬화가 가능하다.
(리플렉션은 런타임에 객체의 정보인 클래스, 메서드, 속성 등을 확인하고 조작할 수 있게 해주는 기능을 뜻한다.)
UCLASS()
class AMyCharacter : public AActor {
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Stats")
float Health = 100.0f;
};
EditAnywhere → 에디터에서 해당 속성이 노출되는 것을 뜻한다. (유니티의 SerializeField)
BlueprintReadWrite → 블루프린트에서 읽고 쓸 수 있다는 뜻이다.
이런 매크로를 통해 에디터와의 더욱 복잡하고 정교한 통합을 이뤄낼 수 있는 것이 UObject의 특징이다.
다만 확실히 유니티에 비하면 비교적 복잡하고 어려울 수 있다.
언리얼은 고성능의 뛰어난 렌더링 품질을 목표로 해서 그런지 메모리 관리에 특히 신경을 쓰는 느낌이다.
이미 메모리에서 지워진 대상을 참조하는 Dangling Pointer는 C++로 개발을 한번이라도 해본 사람이라면 겪어봤을만한 큰 문제이다.
UObject는 기본적으로 참조 카운팅 방식으로 메모리를 관리한다. UObject 기반 객체는 모두 내부적으로 참조 카운트를 가지게 되고, 무엇이든 이 객체를 참조하면 카운팅이 증가, 참조가 제거되면 카운팅이 감소된다.
참조가 더이상 없게 되면 언리얼 엔진의 GC가 해당 객체를 정리하고 메모리를 확보한다. (그 즉시 삭제되는 것이 아니라 다음 GC까지 대기한다.)
이 방식을 위해서 NewObject나 SpawnActor를 통해서만 객체를 생성할 수 있다. (GC 시스템에 객체가 등록이 되어야 하기 때문)
물론 이렇게 자동으로 처리가 되지만, 원하는 타이밍에 수동으로 메모리를 해제하는 등 직접 메모리 관리도 가능하다.
1. AddToRoot를 사용하면 해당 객체가 GC의 대상에서 삭제된다.
2. AddToRoot를 통해 GC의 대상이 아니었던 객체를 RemoveFromRoot 함수를 통해 다시 GC의 대상으로 복귀시킬 수 있다.
3. MarkPendingKill을 통해 해당 객체를 강제로 삭제하기 위해 GC의 대상이 되게 할 수 있다. (다만 이 객체를 참조하고 있는 포인터가 존재하면 문제가 발생할 수 있다.)
이런 함수들을 통해 조금 더 세밀하게 메모리를 관리하여 더욱 최적화된 코드를 짤 수 있도록 해준다.
유니티와 언리얼의 Object, UObject가 어떤지 매우 가볍게 살펴봤다.
이제 내가 가장 처음에 헷갈렸던, MonoBehaviour와 비교해서는 어떤지 생각해보자...
유니티와 언리얼 모두 컴포넌트 구조이다.
그리고 이런 컴포넌트 구조를 완성시켜주는 역할을 하는 것이 유니티의 MonoBehaviour, 언리얼의 UActorComponent이다.
MonoBehaviour는 GameObject라는 비어있는 유니티의 컨테이너 객체에 부착되는 여러 컴포넌트(클래스/스크립트)를 위해 존재한다. Start와 Update와 같은 기본적인 유니티 라이프사이클 관련 함수도 제공해준다.
UActorComponent는 AActor에 컴포넌트로 부착되어 기능을 확장한다.
마찬가지로 TickComponent나 BeginPlayer 등 컴포넌트의 라이프사이클 관련 함수를 제공해준다.
간단하게 이 둘을 비교하자면...
| 항목 | MonoBehaviour | UActorComponent |
| 유연성 | 높음 (런타임 중 동적으로 추가 및 삭제 가능) |
낮음 (생성 시 정적으로 추가되는 것 위주) |
| 에디터 통합 | 기본 직렬화 (SerializeField) |
고급 설정(메타데이터나 카테고리 분류) |
| 성능 | GC로 인한 지연 가능성 염두해야 함. | 명시적으로 메모리 제어를 해 문제 상황을 예측하고 대응 가능함 |
아주 간단하게 이런 차이가 있다...
그런데 이제 이것과 별개로 또 매우 중요한 내용이 있는데...
조립식의 유니티, 계층적인 언리얼
이제 유니티와 언리얼이 어떤 방향성을 가졌는지, 왜 이렇게 설계되었는지 느끼게 된 대목이다.
나는 위의 소제목처럼, 유니티는 조립식 컴포넌트 구조를 가졌다고 표현하고 싶다.
"상속보다 조합, 조립"
모든 기능을 MonoBehaviour 스크립트로 만들고, GameObject에 부착하여 조립한다.
예를 들어, Rigidbody, BoxCollider2D, 사용자 정의 스크립트 등을 런타임 도중에 동적으로 추가/제거가 가능하다.
이는 건담을 만들 때 무기를 조립했다 뗄 수 있는 것과 비슷하다.
컴포넌트 간 의존성이 과하지 않다면, 전혀 문제 없이 이 조립을 관리할 수 있다.
건담을 만들 때, 대포를 쏘는 자세로 만들고 싶다면 "대포 컴포넌트"와 "오른팔 컴포넌트" 간에 의존성이 생길 수 있다.
또, "오른팔 컴포넌트"는 건담 몸체의 다른 여러 파츠와 의존성이 있을 수 있으므로, 이러한 의존성 관리에 특히 신경을 써야 한다.
만약 "대포 컴포넌트"가 다른 어떤 파츠와도 관계 없이 독립적으로 탈부착이 가능한(의존성이 없는) 상태라면 내가 어떻게 해도 아무런 문제가 발생하지 않을 것이다.
이런 유니티는 같은 기능을 여러 오브젝트에 재사용하기 쉬운 "유연성"을 가지고 있고, 복잡한 상속 계층 없이 필요한 컴포넌트만 취사 선택이 가능한 "단순성" 또한 가지고 있다. (그렇다고 상속을 하지 않는 것이 아니다.)
그리고 이와 반대로, 언리얼은 계층식 상속 구조를 가지고 있다고 생각한다.
"엄격한 규칙에 의한 구조화"
언리얼은 모든 기능이 엄격한 클래스 계층 구조 안에 정의된다.
고정된 프레임워크가 없는 유니티와 달리, 언리얼은 고정된 프레임워크가 존재하고 UObject에서 파생된 다중 상속 구조를 가지고 있기 때문에 이런 엄격한 규칙 안에서의 정밀한 구조화를 보여준다.
// Character 클래스는 이미 Movement, Collision 등의 기능을 내장
ACharacter::ACharacter() {
// 기본 컴포넌트들이 생성자에서 자동 추가됨
MovementComponent = CreateDefaultSubobject<UCharacterMovementComponent>(TEXT("MovementComp"));
}
위 코드를 보면, ACharacter라는 클래스가 보일 것이다.
해당 클래스는 APawn에서 파생되었고, 그 클래스는 AActor에서, 결국 타고타고 올라가면 UObject가 존재한다.
ACharacter는 이미 이동, 충돌 등의 필수 기능을 미리 포함하고 있다.
이런 방식으로 했을 때 모든 개발자가 하나의 프레임워크, 하나의 규칙 아래에서 프로그래밍을 진행하므로 팀이 대규모면 대규모일수록 오히려 능률이 좋아질 것이다.
다만 간단한 기능 변경에도 새로운 서브 클래스를 작성해야 할지도 모른다.
유니티는 기능의 확장을 "컴포넌트의 부착"으로 진행했지만, 언리얼은 "부모 클래스를 상속 후 확장"으로 진행한다.
또한, 컴파일 타임에 구조가 결정되어 유니티에 비하면 비교적으로 유연성도 낮은 편이다.
간단한 예시를 들어보겠다.
C#
// 1. 빈 GameObject 생성
GameObject player = new GameObject("Player");
// 2. 필요한 컴포넌트 추가
player.AddComponent<Rigidbody>(); // 물리
player.AddComponent<CapsuleCollider>();// 충돌체
player.AddComponent<PlayerMovement>(); // 사용자 정의 이동 스크립트
player.AddComponent<PlayerHealth>(); // 체력 관리 스크립트
UE C++
// 1. ACharacter를 상속받는 클래스 정의
UCLASS()
class AMyCharacter : public ACharacter {
GENERATED_BODY()
// 2. 생성자에서 기본 컴포넌트 초기화
AMyCharacter() {
// ACharacter에 이미 포함된 MovementComponent 사용
MovementComponent->MaxWalkSpeed = 600.0f;
}
// 3. 추가 기능을 서브클래싱으로 구현
virtual void Jump() override {
if (CanJump()) {
Super::Jump();
// 커스텀 점프 로직
}
}
};
결론적으로, 위 내용을 보다보면 두 엔진의 방향성이 어떻게 다른지가 와닿을 것이다.
유니티는 "접근성"과 "작업 속도"를 우선시했고, 언리얼은 "안정성"과 "협업"을 우선시 했다.
언리얼 C++ 프로젝트는 아니지만, DirectX12를 통한 C++ 프로젝트와 유니티 C# 프로젝트를 모두 겪어본 경험으로 확실히 그 방향성이 다르다고 느꼈다.
오늘은 이렇게 간단하게 UObject와 유니티의 Object를 비교하고, 언리얼이 어느 방향성을 가지고 개발이 되었는지에 대해 알아보았다.
다음에는 언리얼의 메모리 관리 방식을 좀 더 깊게 파보고 싶은 생각이다.
'언리얼 > 언리얼 C++' 카테고리의 다른 글
| 언리얼과 유니티 GC 비교하기 (0) | 2025.03.09 |
|---|---|
| 언리얼5 C++ 배우기(5) - Game Mode와 UE_LOG에 대해 알아보자 (0) | 2024.07.03 |
| 언리얼5 C++ 배우기(4) - 언리얼 C++ 세팅 및 라이브 코딩 주의점 (0) | 2024.07.03 |
| 언리얼5 C++ 배우기(3) - 클래스와 인스턴스의 차이 (0) | 2024.06.25 |
| 언리얼5 C++ 배우기(2) - Impulse로 큐브 띄우기 (0) | 2024.06.20 |