Unity vs Unreal: 내가 힘들게 다시 배운 5가지
Source: Dev.to
Unity에서 수년을 보낸 뒤 처음으로 Unreal을 열었을 때, 나는 재생 버튼이 어디에 있는지 찾느라 온전한 10분을 보냈다. 눈에 안 보여서가 아니라, 눌렀을 때 기대한 대로 동작할지 믿지 못했기 때문이다. 스크립트를 GameObject에 끌어다 놓고, 재생을 눌러서 물체가 흔들리는… 이런 근육 기억이 순식간에 무용지물이 되었다. 뷰포트가 비슷해 보여서 일시적인 친숙함에 빠졌지만, 5초 뒤면 “save”가 무슨 뜻인지 편집기와 싸우게 되었다.
반대 방향도 마찬가지다. Unreal 개발자가 Unity에 뛰어들면 첫 주 내내 핫 리로드 시 엔진이 레퍼런스를 먹어버리는 이유나, 인스펙터가 완전히 합리적인 struct를 보여주지 않는 이유를 물어본다.
두 엔진 모두에서 작업을 배포해 본 지금, 솔직히 말하면 문법 장벽(C# vs C++)은 쉬운 편이다. 실제로 여러분을 느리게 하는 것은, 주말을 날려버리기 전까지는 아무도 경고하지 않는 수십 개의 작은 사고 모델 불일치다. 엔진이 같은 문제를 해결한다고 보여도, 해결책의 형태가 동일하다고 가정한다. 그렇지 않다.
아래는 우리 스튜디오가 머리를 실제로 다시 연결해야 했던 다섯 가지이다. 엔진 퀴즈가 아니라, 일찍 잡지 못하면 며칠을 잡아먹는 내용이다.
1. 가비지 컬렉션 모델 차이
Unity는 C#과 .NET GC를 제공한다. Unreal은 C++와 명시적으로 알려준 객체만을 추적하는 커스텀 마크‑앤‑스윕 GC를 제공한다. 비슷하게 들리지만 전혀 다르다.
- Unity에서는 클래스를 작성하고
new하면 GC가 마음대로 언제든지 정리한다. 흔히 겪는 함정은 성능(업데이트에서 할당 → GC 스파이크)인데, 정확성 모델은 직관적이다. 모든 것이 객체이며, 모두 추적된다. - Unreal에서는 GC가
UObject서브클래스만 추적하고, 오직UPROPERTY()로 표시된 레퍼런스만 본다.UObject*를 매크로 없이 원시 포인터로 저장하면 GC는 그 객체를 신경 쓰지 않으며, 다음 스윕에서 포인터는 댕글링 레퍼런스가 된다. 객체가 눈앞에서 사라진다.
UCLASS()
class AEnemy : public AActor {
GENERATED_BODY()
// 안전: GC가 이 레퍼런스를 알고 있다
UPROPERTY()
UWeapon* EquippedWeapon;
// 시간 폭탄: GC가 이 객체를 자유롭게 삭제한다
UWeapon* SecretWeapon;
};
재배선: Unity에서는 GC가 성능 이슈다. Unreal에서는 GC가 정확성 이슈다. 프레임 시간 때문에가 아니라 객체 수명 때문에 싸워야 한다. 이 점을 이해하고 나니 “왜 내 포인터가 null인가” 라는 버그가 절반 정도 사라졌다.
반대편: Unity 개발자가 Unreal에 들어오면 GC가 무섭다는 이유로 reflexively TWeakObjectPtr 을 잡아먹는다. 대부분의 경우 UPROPERTY() 로 표시된 포인터만 있으면 충분하다—그것이 객체를 살아 있게 하고 추적한다. TWeakObjectPtr 은 실제로는 “파괴를 방지하지 않고 레퍼런스만 유지하고 싶을 때” 쓰는 것이 맞다(예: 먼저 사라질 수 있는 타깃 액터를 바라보는 UI 패널).
2. 라이프사이클 차이
표면적으로 MonoBehaviour.Update()와 AActor::Tick()은 같은 개념처럼 보인다. 프레임마다 호출되는 콜백이다. 하지만 실제 라이프사이클과 스코프 규칙이 달라서 습관을 그대로 옮길 수 없다.
- Unity의
MonoBehaviour는 비교적 대화형 라이프사이클을 가진다(Awake, OnEnable, Start, Update, LateUpdate, OnDisable, OnDestroy). 대부분 에디터에서도 신뢰할 수 있게 호출된다.Start를 가벼운 초기화에 활용하고 잊어버려도 된다. - Unreal은 액터 수명을
PostInitializeComponents,BeginPlay,Tick,EndPlay로 나누고, 컴포넌트는 자체OnRegister/InitializeComponent흐름을 가진다.BeginPlay는 게임이 실제로 시작될 때만 호출되므로, Unity 전환자가 처음에 생성자에서 초기화를 시도하고 “아직 아무것도 연결되지 않았다”는 혼란을 겪는다.
AEnemy::AEnemy() {
// CDO(클래스 기본 객체) 초기화 시와 인스턴스마다 호출된다.
// 이 시점엔 월드가 유효하지 않다 — 게임플레이 상태도, 다른 액터도 없다.
// 기본값만 설정하고, 게임플레이 설정은 BeginPlay에 둔다.
PrimaryActorTick.bCanEverTick = true;
}
void AEnemy::BeginPlay() {
Super::BeginPlay();
// 이제 월드가 존재한다. 여기서 게임플레이 설정을 한다.
}
반대로, Unreal 개발자가 Unity에 들어오면 단계화된 라이프사이클을 기대하지만 Unity는 평면적인 흐름과 미묘하게 다른 규칙을 가진다. Awake 는 일찍 실행되지만, 씬의 다른 객체들이 모두 연결되었는지는 보장되지 않는다—형제 MonoBehaviour에 접근하려면 Start 에 두어야 한다. Start 자체는 객체를 Instantiate 할 때 바로 호출되지 않고, 객체가 활성화된 첫 프레임에 호출된다. OnEnable/OnDisable 은 객체가 생성될 때가 아니라 토글될 때마다 호출되므로 풀링에는 좋지만, 한 번만 실행되는 초기화 훅으로 착각하면 함정에 빠진다. 또한 “생성자에 실제 초기화를 넣는다”는 C++ 직관은 완전히 버려야 한다. Unity는 씬이 없을 때도 직렬화를 위해 MonoBehaviour 를 생성할 수 있기 때문에, 생성자 안의 게임플레이 코드는 무시되거나 도메인 리로드 시 에디터가 크래시될 수 있다. 게다가 Update 는 가변 프레임 레이트로 실행되고, 물리와 연관된 로직은 FixedUpdate 에 넣어야 한다—이 구분은 고프레임 레이트에서 캐릭터가 진동하기 시작할 때 비로소 깨닫게 된다.
재배선: Unity에서는 라이프사이클이 평면적이고 대부분이 단일 타임라인에서 발생한다. Unreal에서는 단계화된 라이프사이클(생성자 → 기본값, BeginPlay → 런타임, Tick → 옵트인)이다. 이를 서로 다른 도구로 인식하고 사용하라.
3. 직렬화와 속성
Unity는 [SerializeField], [Serializable], [SerializeReference] 로 인스펙터와 YAML 직렬화를 제어한다. 가볍게 느껴지지만(공개 필드는 기본 직렬화, private 필드는 하나의 어트리뷰트만 필요) 규칙이 은밀히 함정을 만든다:
- 다형성 레퍼런스는
[SerializeReference]가 필요하고, - 커스텀 클래스는 인스펙터에 나타나게 하려면
[Serializable]가 필요하며, - IL2CPP 는 리플렉션을 통해서만 도달하는 타입을 자동으로 스트립한다—
link.xml이나[Preserve]로 보존해야 한다.
Unreal의 UPROPERTY(), UFUNCTION(), UCLASS() 매크로는 같은 아이디어처럼 보이지만, 엔진 전체에 걸쳐 필수적인 구조 기둥이다. 이들은 직렬화, 에디터, 블루프린트 노출, 네트워크 복제, GC 추적(#1), 프로퍼티 시스템을 구동하는 리플렉션 시스템에 공급한다. UPROPERTY 가 없는 필드는 이 모든 시스템에 보이지 않으며, UFUNCTION 이 없는 함수는 블루프린트나 RPC에서 호출될 수 없고, UCLASS 가 없는 클래스는 엔진에 존재하지 않는다.
UCLASS(Blueprintable)
class AEnemy : public AActor {
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Combat")
float Health = 100.f;
UFUNCTION(BlueprintCallable, Category="Combat")
void TakeHit(float Damage);
};
각 메타 지정자(EditAnywhere, BlueprintReadWrite, Category, Replicated 등)는 서로 다른 서브시스템에 대한 필수 지시사항이다. 다른 어떤 페이지보다도 Unreal 문서의 프로퍼티 지정자 섹션을 많이 읽게 될 것이다.
재배선: Unity에서는 어트리뷰트가 설탕(sugar)이다. Unreal에서는 매크로가 API다. 에디터에 보이지 않거나 복제되지 않거나 블루프린트에 나타나지 않거나 GC에 의해 예기치 않게 사라지는 경우, 90%는 지정자를 빼먹었기 때문이다. 매크로를 먼저, 필드를 나중에 쓰는 습관을 들여라.
4. 에디터 확장 방식
두 엔진 모두 에디터를 확장할 수 있다. 커스텀 인스펙터, 툴 윈도우, 에셋 프로세서를 만들 수 있다. 하지만 구현 방식이 너무 달라서 툴링 지식이 거의 이식되지 않는다.
- Unity 에디터 스크립팅은 주로
Editor/폴더 안에 C# 파일을 두고,