본문 바로가기

C++ 클래스와 언리얼 리플렉션 시스템

UCLASS·UPROPERTY·UFUNCTION 매크로가 UHT를 거쳐 에디터와 Blueprint에 노출되는 과정을 익힙니다.

C++로 클래스를 하나 만들고 멤버 변수를 선언했다고 해서, 언리얼 에디터가 그 변수를 디테일 패널에 보여 주거나 블루프린트에서 쓸 수 있게 되지는 않습니다. 표준 C++은 실행 중에 "이 클래스에는 어떤 변수와 함수가 있는가"를 물어볼 방법을 거의 제공하지 않기 때문입니다. 에디터가 C++ 클래스를 알아보고, 속성을 편집하게 하고, 블루프린트와 연결하려면 엔진이 클래스의 구조를 따로 알고 있어야 합니다.

언리얼 엔진은 이 문제를 리플렉션 시스템(reflection system)으로 풉니다. 이 글에서는 UCLASS(), UPROPERTY(), UFUNCTION() 매크로가 무엇을 등록하는지, 지정자(specifier)에 따라 에디터 편집과 블루프린트 접근이 어떻게 달라지는지 살펴봅니다. 이 개념을 이해해야 이후의 모든 장에서 C++ 클래스를 에디터와 블루프린트에 원하는 만큼만 공개할 수 있습니다.


리플렉션 시스템은 무엇을 하는가

리플렉션 시스템은 실행 중에 클래스의 구조와 동작 정보를 조회하고 활용할 수 있게 해 주는 장치입니다. 언리얼은 전용 매크로를 붙인 클래스, 변수, 함수의 메타데이터(metadata)를 만들어 두고, 런타임과 에디터에서 이 정보를 씁니다. 에디터가 C++로 만든 클래스를 인식하고, 액터 계열의 인스턴스를 월드에 배치하고, 지정자로 허용한 속성을 디테일 패널에서 편집하게 해 주는 것이 모두 이 메타데이터 덕분입니다. 블루프린트에서 C++ 함수를 호출하거나 변수에 접근할 수 있는 것도 같은 이유입니다.

이 메타데이터는 빌드할 때 만들어집니다. 언리얼 빌드 툴(Unreal Build Tool, UBT)이 먼저 언리얼 헤더 툴(Unreal Header Tool, UHT)을 호출하면, UHT가 헤더 파일에서 리플렉션 매크로가 붙은 선언을 읽어 필요한 C++ 코드를 생성합니다. 그다음 C++ 컴파일러가 직접 작성한 코드와 UHT가 생성한 코드를 함께 빌드합니다. 그래서 리플렉션 매크로에 문제가 있으면 C++ 컴파일러보다 먼저 UHT 단계에서 오류가 납니다.


UCLASS: 엔진이 아는 클래스로 선언하기

가장 기본이 되는 매크로는 UCLASS()입니다. C++ 클래스가 언리얼 엔진의 클래스로 인식되려면 클래스 선언 바로 위에 이 매크로를 붙여야 합니다. 다음은 C++ 클래스 마법사로 AActor를 부모로 고르면 만들어지는 헤더와 비슷한 모습입니다.

MyAwesomeActor.h
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MyAwesomeActor.generated.h" // 필수 포함 파일

UCLASS() // 이 클래스가 언리얼 엔진의 클래스임을 선언합니다.
class MYFIRSTCPPPROJECT_API AMyAwesomeActor : public AActor
{
    GENERATED_BODY() // 이 클래스의 본문을 생성하기 위한 매크로입니다.

public:
    // 생성자입니다.
    AMyAwesomeActor();

protected:
    // 게임 시작 시 호출됩니다.
    virtual void BeginPlay() override;

public:
    // 매 프레임 호출됩니다.
    virtual void Tick(float DeltaTime) override;
};

위 코드에서 먼저 볼 것은 #include "MyAwesomeActor.generated.h"입니다. 이 파일은 UHT가 자동으로 만드는 파일로, 리플렉션에 필요한 메타데이터 코드가 들어 있습니다. UCLASS()를 쓰는 클래스의 헤더에서는 이 include가 반드시 마지막 include여야 합니다. .generated.h는 CURRENT_FILE_ID라는 매크로를 이 헤더의 값으로 다시 정의하는데, UCLASS()와 GENERATED_BODY()는 이 값과 줄 번호를 이어 붙인 이름의 매크로로 펼쳐지기 때문입니다. 뒤에 다른 헤더를 include해 이 값이 바뀌면 다른 파일의 코드가 들어갈 수 있으므로, UHT는 .generated.h 뒤에 #include가 있으면 오류를 냅니다.

UCLASS()가 붙은 클래스는 언리얼 엔진이 특별하게 다룹니다. 이 예제는 AActor를 상속하므로 액터의 기능과 함께 블루프린트로 파생할 수 있는 성질(Blueprintable)도 물려받습니다. 일반 UObject 계열 클래스를 블루프린트로 파생할 수 있는지는 Blueprintable 지정자와 상속 관계로 정해집니다. 클래스 본문의 첫 부분에 있는 GENERATED_BODY()도 UCLASS()와 함께 꼭 필요한 매크로로, UHT가 만든 내부 코드를 이 자리에 넣어 리플렉션이 동작하게 합니다.

클래스 이름 앞의 A는 접두사 규칙입니다. AMyAwesomeActor처럼 액터 계열 클래스에는 A를 붙이고, 액터가 아닌 UObject 계열 클래스와 컴포넌트에는 U(UStaticMeshComponent), 열거형에는 E(ECollisionChannel)를 붙입니다. 일반 C++에서는 타입 이름을 자유롭게 지을 수 있지만, UHT가 처리하는 타입은 대부분 이 접두사를 지켜야 합니다. 예를 들어 UCLASS() 클래스의 이름이 규칙에 맞지 않으면 UHT가 오류를 내고, 열거형의 E는 UHT가 검사하지는 않지만 코딩 표준이 정한 관례입니다.

MYFIRSTCPPPROJECT_API는 DLL 내보내기와 가져오기에 관련된 매크로로, 이 클래스를 프로젝트 모듈 밖에서도 쓸 수 있게 공개할 때 씁니다. 이 예제는 MyFirstCPPProject 모듈을 기준으로 했으므로, 프로젝트 이름이 다르면 마법사가 만든 실제 모듈의 _API 매크로를 그대로 씁니다. 마지막으로 public AActor는 이 클래스가 월드에 배치하거나 스폰하는 액터의 기본 클래스인 AActor를 상속한다는 뜻입니다. 액터가 가진 컴포넌트나 일반 값 데이터까지 모두 AActor 계열인 것은 아니며, 다른 기본 클래스는 다음 글에서 비교합니다.


UPROPERTY: 멤버 변수를 엔진에 알리기

UPROPERTY()는 지원되는 타입의 멤버 변수를 리플렉션 시스템에 등록합니다. 다만 매크로 하나가 모든 기능을 켜 주는 것은 아닙니다. 에디터에서 편집할 수 있는지, 블루프린트에서 접근할 수 있는지, 저장할 때 직렬화되는지, 가비지 컬렉션(GC)이 참조를 추적하는지는 각각 지정자와 타입, 그 값을 쓰는 경로에 따라 정해집니다. 앞의 헤더에 속성 세 개를 더해 봅니다.

MyAwesomeActor.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MyAwesomeActor.generated.h"

UCLASS()
class MYFIRSTCPPPROJECT_API AMyAwesomeActor : public AActor
{
    GENERATED_BODY()

public:
    AMyAwesomeActor();

protected:
    virtual void BeginPlay() override;

public:
    virtual void Tick(float DeltaTime) override;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "My Properties")
    float MyFloatVariable; // 에디터에서 편집 가능한 float 변수

    UPROPERTY(VisibleAnywhere, Category = "My Properties")
    int MyIntegerVariable; // 에디터에서 볼 수만 있는 int 변수

    UPROPERTY(EditDefaultsOnly, Category = "My Properties")
    FString MyStringVariable; // 블루프린트 기본값에서만 편집 가능한 문자열
};

UPROPERTY() 괄호 안의 키워드가 지정자입니다. 지정자는 변수가 에디터에서 어떻게 보이고 편집되는지, 블루프린트에서 어떻게 접근되는지를 정합니다. 자주 쓰는 지정자는 다음과 같습니다.

지정자뜻
EditAnywhere클래스 기본값과 레벨에 배치한 인스턴스 모두의 디테일 패널에서 편집할 수 있습니다.
VisibleAnywhere디테일 패널에 보이지만 편집할 수는 없습니다.
EditDefaultsOnly클래스 기본값 같은 아키타입(archetype)에서만 편집할 수 있고, 배치한 개별 인스턴스에서는 편집할 수 없습니다.
BlueprintReadWrite블루프린트에서 읽고(Get) 쓸(Set) 수 있습니다.
BlueprintReadOnly블루프린트에서 읽기(Get)만 할 수 있습니다.
Category = "My Properties"디테일 패널에서 My Properties 분류 아래에 묶어 보여 줍니다.
meta=(DisplayName="My Custom Name")디테일 패널에 표시되는 이름을 바꿉니다.

여기서 알아 둘 점은 에디터 편집을 정하는 Edit, Visible 계열 지정자와 블루프린트 접근을 정하는 BlueprintRead 계열 지정자가 서로 독립적이라는 것입니다. MyIntegerVariable처럼 VisibleAnywhere만 붙이면 디테일 패널에는 보이지만 블루프린트 그래프에서는 접근할 수 없습니다. 또 이 예제의 int는 그대로 써도 되지만, 언리얼은 크기를 밝힌 int32 같은 정수 타입을 관례로 쓰므로 실제 프로젝트에서는 int32를 쓰는 편이 좋습니다.

더 깊이: 에디터에만 있는 메타데이터

meta=(...) 안에 적는 값은 메타데이터 지정자(metadata specifier)입니다. Metadata Specifiers 문서는 메타데이터가 에디터에만 존재하므로 게임 로직이 메타데이터에 접근하도록 만들지 말라고 경고합니다. 예를 들어 DisplayName은 코드에서 생성된 이름 대신 에디터에 보여 줄 이름일 뿐이라서, 패키지한 게임에서 그 이름을 읽어 화면에 띄우는 식으로 쓸 수 없습니다. 게임 안에서도 필요한 표시 이름이라면 FText 속성처럼 실제 데이터로 따로 둡니다.

값의 범위를 정하는 메타데이터도 같은 성질을 가집니다. ClampMin과 ClampMax는 실수와 정수 속성에 입력할 수 있는 최솟값과 최댓값을 정하는데, 이것은 에디터에서 값을 넣을 때의 제한입니다. 그래서 C++ 코드나 블루프린트 그래프가 실행 중에 그 속성에 값을 대입할 때까지 같은 범위로 막아 준다고 기대하면 안 되고, 범위가 게임 규칙이라면 값을 바꾸는 함수에서 직접 검사해야 합니다. 참고로 같은 문서에 따르면 클래스나 함수 같은 선언의 툴팁은 코드 주석에서 자동으로 만들어지며, ToolTip 메타데이터로 덮어쓸 수 있습니다.


UFUNCTION: 함수를 엔진에 알리기

UFUNCTION()은 함수를 리플렉션 시스템에 등록합니다. 등록만으로 블루프린트에서 쓸 수 있게 되는 것은 아니며, 블루프린트 그래프에서 직접 호출하려면 BlueprintCallable이나 BlueprintPure 같은 지정자가 필요합니다.

MyAwesomeActor.h
#pragma once

// ... (생략) ...

UCLASS()
class MYFIRSTCPPPROJECT_API AMyAwesomeActor : public AActor
{
    GENERATED_BODY()

public:
    AMyAwesomeActor();

protected:
    virtual void BeginPlay() override;

public:
    virtual void Tick(float DeltaTime) override;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "My Properties")
    float CurrentHealth;

    UFUNCTION(BlueprintCallable, Category = "My Functions")
    void TakeDamage(float DamageAmount); // 블루프린트에서 호출 가능한 함수

    UFUNCTION(BlueprintPure, Category = "My Functions")
    float GetHealthRatio() const; // 블루프린트에서 호출 가능한 순수(Pure) 함수
};

BlueprintCallable은 함수를 블루프린트에서 호출할 수 있게 합니다. 예를 들어 버튼을 눌렀을 때 C++ 함수를 실행하고 싶다면 이 지정자를 씁니다. BlueprintPure는 실행 핀 없이 값을 돌려주는 노드로 함수를 공개합니다. 개발자는 이런 함수가 눈에 보이는 상태를 바꾸지 않도록 작성해야 하지만, 지정자가 함수의 순수성을 검사해 주지는 않습니다. 또 Pure 함수가 읽는 객체의 상태가 바뀌면 인자가 같아도 결과가 달라질 수 있고, 엔진이 결과를 자동으로 캐시하지도 않습니다. Category는 UPROPERTY와 마찬가지로 블루프린트 에디터에서 함수를 묶어 보여 줄 분류입니다.

예제의 TakeDamage라는 이름에는 주의할 점이 있습니다. AActor에는 이미 TakeDamage라는 가상 함수가 있어서, 같은 이름으로 매개변수가 다른 함수를 선언하면 이름 숨김(name hiding) 때문에 부모의 함수가 가려집니다. 이 예제는 지정자를 보여 주기 위한 선언이므로, 실제 프로젝트에서는 ApplyHealthDamage처럼 겹치지 않는 이름을 쓰는 편이 안전합니다. 엔진의 TakeDamage를 재정의하는 방법은 6장의 HUD 예제에서 다룹니다.

UFUNCTION()에는 네트워크 게임에서 원격 프로시저 호출(RPC)의 실행 위치를 정하는 Server, Client, NetMulticast 같은 지정자도 있습니다. 실제 원격 호출이 이루어지려면 액터의 소유권과 복제 같은 네트워크 조건도 갖춰져야 하며, 자세한 내용은 RPC와 서버-클라이언트 통신에서 다룹니다.


편집 권한과 그래프 접근을 따로 읽기

UNREAL C++ · CH2-1

에디터 편집과 그래프 접근은 별도 권한

본문의 세 UPROPERTY 선언을 비교합니다. Edit·Visible 지정자와 BlueprintRead·Write 지정자를 따로 읽습니다.

예제 속성의 편집 권한과 블루프린트 접근
예제 멤버에디터의 값 편집블루프린트 그래프
실수 값MyFloatVariable: 기본값과 배치한 인스턴스에서 편집BlueprintReadWrite: 읽기·쓰기
정수 값MyIntegerVariable: VisibleAnywhere로 표시만 가능읽기·쓰기 지정자 없음
문자열 값MyStringVariable: EditDefaultsOnly로 기본값에서 편집읽기·쓰기 지정자 없음
실수 값
에디터의 값 편집: MyFloatVariable: 기본값과 배치한 인스턴스에서 편집
블루프린트 그래프: BlueprintReadWrite: 읽기·쓰기
정수 값
에디터의 값 편집: MyIntegerVariable: VisibleAnywhere로 표시만 가능
블루프린트 그래프: 읽기·쓰기 지정자 없음
문자열 값
에디터의 값 편집: MyStringVariable: EditDefaultsOnly로 기본값에서 편집
블루프린트 그래프: 읽기·쓰기 지정자 없음

Category는 표시 분류입니다. 각 선언의 권한은 실제로 붙인 지정자에서 결정됩니다.

위 그림은 앞에서 선언한 세 속성을 에디터 편집과 블루프린트 그래프 접근의 두 열로 나눠 비교합니다. 실수 값 MyFloatVariable은 EditAnywhere라서 기본값과 배치한 인스턴스 모두에서 편집할 수 있고, BlueprintReadWrite라서 그래프에서 읽고 쓸 수 있습니다. 정수 값 MyIntegerVariable은 VisibleAnywhere라서 디테일 패널에 표시만 되고, 읽기와 쓰기 지정자가 없으므로 그래프에서는 접근할 수 없습니다. 문자열 값 MyStringVariable은 EditDefaultsOnly라서 기본값에서만 편집할 수 있습니다. 그림 아래의 설명처럼 Category는 표시 분류일 뿐이고, 각 선언의 권한은 실제로 붙인 지정자로 정해집니다.

두 종류의 지정자를 함께 쓰면 권한을 더 세밀하게 나눌 수 있습니다. 다음은 편집 권한과 객체 참조를 따로 선언한 멤버의 예로, 리플렉션에 참여하는 클래스 안에 둡니다.

권한과 참조를 분리한 멤버 선언
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Combat")
float MaxHealth = 100.0f;

UPROPERTY()
TObjectPtr<UObject> ActiveEffect;

MaxHealth는 클래스 기본값(Class Defaults) 같은 기본값 편집 위치에서 100을 150으로 바꿀 수 있지만, 레벨에 배치한 인스턴스에서 직접 편집하는 권한은 주지 않았습니다. 또 BlueprintReadOnly이므로 그래프에서는 값을 읽을 수 있지만, 이 속성에 직접 값을 넣는 Set 노드는 만들 수 없습니다. EditDefaultsOnly를 썼다고 블루프린트 읽기 권한까지 생기는 것은 아니며, 두 지정자가 각자의 일을 합니다.

ActiveEffect처럼 지정자 없이 UPROPERTY()만 붙인 TObjectPtr<UObject>는 에디터와 블루프린트에는 공개되지 않지만, 가비지 컬렉터가 이 참조를 추적하게 합니다. 이런 강한 참조는 이 멤버를 가진 객체 자체가 GC에서 도달 가능한 상태일 때 의미가 있습니다. 객체를 만들고 수명을 관리하는 규칙은 다음 글에서 자세히 다룹니다.

이 글의 헤더들은 선언을 설명하기 위한 예시이며, 생성자와 함수 정의까지 갖춘 완성된 프로그램은 아닙니다. 직접 작성하다가 오류가 나면 단계를 나눠 확인합니다. 먼저 UHT가 읽는 선언과 마지막 generated.h include가 맞는지, 다음으로 C++ 컴파일과 링크 오류가 있는지, 마지막으로 에디터가 그 클래스를 불러왔는지를 차례로 봅니다.


정리

  • 리플렉션 시스템은 매크로를 붙인 클래스, 변수, 함수의 메타데이터를 만들어 에디터 노출, 속성 편집, 블루프린트 연동을 가능하게 합니다.
  • 빌드할 때 UBT가 UHT를 먼저 호출해 리플렉션 코드를 생성하고, 그다음 C++ 컴파일러가 빌드합니다. generated.h는 헤더의 마지막 include여야 합니다.
  • UCLASS()와 GENERATED_BODY()가 클래스를 엔진에 등록하며, UHT가 처리하는 타입은 A, U, E 같은 접두사 규칙을 따릅니다.
  • UPROPERTY()의 Edit, Visible 지정자는 에디터 편집을, BlueprintRead 지정자는 그래프 접근을 따로 정합니다. Category는 권한을 바꾸지 않습니다.
  • UFUNCTION()은 BlueprintCallable이나 BlueprintPure를 붙여야 블루프린트에서 호출할 수 있고, Pure 함수의 순수성은 개발자가 지켜야 합니다.
  • 지정자 없는 UPROPERTY()도 객체 참조를 GC가 추적하게 하는 의미가 있습니다.

연습 문제

1. 편집과 그래프 접근

다른 실수형 멤버에 EditAnywhere, BlueprintReadOnly를 함께 지정했습니다. 배치한 인스턴스의 디테일 패널에서 값을 바꾸는 것과 블루프린트 그래프에서 그 멤버에 직접 값을 넣는 것 가운데 어느 쪽이 허용될까요? Category 이름만 바꾸면 이 답도 바뀔까요?

해설 보기

인스턴스의 디테일 패널에서 편집하는 것은 EditAnywhere가 허용합니다. 블루프린트는 BlueprintReadOnly에 따라 Get으로 읽을 수는 있지만 Set으로 직접 값을 넣을 수는 없습니다.

Category는 표시 분류일 뿐이므로 바꿔도 권한은 그대로입니다. 또 이 판단은 블루프린트가 속성에 직접 접근하는 경우에 관한 것이므로, C++ 함수가 내부에서 값을 바꾸는 것까지 막지는 않습니다.

2. 보이지만 쓸 수 없는 변수

블루프린트 그래프에서 MyIntegerVariable의 Get 노드를 찾으려고 했는데 나타나지 않습니다. 디테일 패널에는 값이 잘 보입니다. 왜 그럴까요? 그래프에서 읽을 수 있게 하려면 무엇을 바꿔야 할까요?

해설 보기

MyIntegerVariable에는 VisibleAnywhere만 붙어 있어서 에디터의 디테일 패널에 표시하는 권한만 있습니다. 블루프린트 그래프의 접근은 BlueprintReadOnly나 BlueprintReadWrite 같은 별도 지정자가 정하므로, 이 지정자가 없으면 Get 노드도 만들어지지 않습니다.

그래프에서 읽기만 하려면 BlueprintReadOnly를 더합니다. 이때 블루프린트에 노출하는 정수는 int 대신 int32로 선언하는 것이 언리얼의 관례이므로 타입도 함께 바꾸는 편이 좋습니다.

선언이 처리되는 빌드 단계는 Epic의 Unreal Header Tool 문서에서, 지정자별 의미는 UProperties 문서에서 확인할 수 있습니다.

다음 글 AActor와 UObject의 차이점에서는 월드에 놓이는 액터와 그렇지 않은 UObject를 비교하고, 각각을 만들고 수명을 관리하는 방법을 살펴봅니다.