C++로 Blueprint 클래스 확장
핵심 로직은 C++에 두고 표현과 조정은 Blueprint로 확장하는 부모·자식 클래스 협업 방식을 실습합니다.
C++로 만든 액터의 환영 문구 하나를 바꾸려고 할 때마다 코드를 고치고 다시 빌드해야 한다면, 기획자는 프로그래머를 기다려야 하고 프로그래머는 사소한 수정에 시간을 씁니다. 반대로 모든 로직을 블루프린트로만 만들면, 성능이 중요한 부분이나 여러 클래스가 공유하는 규칙을 정리하기 어려워집니다. 언리얼은 이 두 방식을 섞어 쓰도록 설계되어 있어서, C++로 뼈대를 만들고 그 클래스를 부모로 하는 블루프린트에서 값과 연출을 조정할 수 있습니다.
AActor와 UObject의 차이점에서 액터의 성질을 익혔으니, 이 글에서는 C++ 액터 클래스를 직접 만들고 그것을 블루프린트로 확장합니다. 메시를 보여 주고 환영 문구를 출력하는 작은 액터를 만들어, C++ 생성자의 초기값과 블루프린트 기본값, 배치한 인스턴스의 값이 각각 어디서 정해지는지 확인합니다.
C++과 블루프린트가 나눠 맡는 일
C++과 블루프린트는 잘하는 일이 다릅니다. C++은 복잡한 연산, 물리 계산, AI 로직처럼 성능이 중요한 부분에 유리하고, 엔진 깊숙한 곳까지 접근해 고칠 수 있으며, 메모리 같은 세부 사항을 세밀하게 제어할 수 있습니다. 또 큰 프로젝트에서 코드의 구조와 규칙을 일관되게 지키게 하기에도 좋습니다.
블루프린트는 시각적인 스크립팅이라 아이디어를 빠르게 만들어 시험하는 프로토타이핑(prototyping)에 강합니다. 프로그래밍을 하지 않는 아티스트나 기획자도 게임 로직에 참여할 수 있고, 간단한 로직 변경이나 데이터 수정은 빌드 없이 바로 반영할 수 있습니다.
그래서 흔히 쓰는 작업 방식은 일을 나누는 것입니다. 성능에 민감한 부분, 엔진의 핵심 경로, 복잡한 로직의 기반은 C++로 만들고, 자주 바뀌는 게임플레이의 세부 사항이나 기획자가 조정할 값은 블루프린트에서 다룹니다. C++ 클래스를 블루프린트가 상속하게 하면 이 두 가지를 한 클래스 계층 안에서 함께 쓸 수 있습니다.
C++ 액터 클래스 만들기
간단한 메시를 보여 주고 환영 문구를 출력하는 액터를 만들어 보겠습니다. 언리얼 에디터 상단 메뉴에서 Tools > New C++ Class...(도구 메뉴의 새 C++ 클래스)를 누르면 C++ 클래스 마법사가 열립니다. 부모 클래스 선택(Choose Parent Class) 화면에서는 3D 월드에 배치할 객체를 만들 것이므로 Actor를 고르고 다음(Next)을 누릅니다.
이름에는 접두사 없이 MyAwesomeActor를 입력하고, 저장 경로를 확인한 뒤 클래스 생성(Create Class)을 누릅니다. 그러면 MyAwesomeActor.h와 MyAwesomeActor.cpp 두 파일이 생기고, C++ 클래스 이름은 마법사가 A 접두사를 붙인 AMyAwesomeActor가 됩니다. IDE가 자동으로 열리지 않으면 프로젝트에서 두 파일을 직접 엽니다.
아래 예제는 MyFirstCPPProject 모듈에 만든 새 클래스를 기준으로 합니다. 프로젝트 이름이 다르면 헤더의 MYFIRSTCPPPROJECT_API는 마법사가 만든 실제 모듈의 API 매크로를 그대로 둡니다. 2장 첫 글 C++ 클래스와 언리얼 리플렉션 시스템에서 선언만 살펴본 코드를 이 클래스에 덧붙이지 말고, 이 글의 헤더와 cpp 정의를 한 쌍으로 씁니다.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Components/StaticMeshComponent.h" // 추가할 컴포넌트를 위해 미리 포함
#include "MyAwesomeActor.generated.h" // 항상 마지막
UCLASS()
class MYFIRSTCPPPROJECT_API AMyAwesomeActor : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
AMyAwesomeActor();
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
// StaticMeshComponent를 위한 포인터 선언
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
UStaticMeshComponent* MyStaticMesh;
// 블루프린트에서 변경 가능하도록 노출할 속성
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Custom Properties")
FString WelcomeMessage;
// 블루프린트에서 호출할 수 있는 함수
UFUNCTION(BlueprintCallable, Category = "Custom Functions")
void PrintWelcomeMessage();
};헤더에는 세 가지를 더했습니다. MyStaticMesh는 액터에 눈에 보이는 형태를 주기 위한 UStaticMeshComponent 포인터이고, WelcomeMessage는 블루프린트에서 편집할 수 있도록 UPROPERTY(EditAnywhere, BlueprintReadWrite)로 선언한 문자열이며, PrintWelcomeMessage는 블루프린트에서 호출할 수 있도록 UFUNCTION(BlueprintCallable)로 선언한 함수입니다. 이제 cpp 파일에서 생성자와 함수를 정의합니다.
// MyAwesomeActor.cpp
#include "MyAwesomeActor.h"
#include "Components/StaticMeshComponent.h" // StaticMeshComponent를 위해 다시 포함 (보통 헤더에서 미리 함)
#include "Engine/Engine.h" // GEngine->AddOnScreenDebugMessage를 위해 포함
// Sets default values
AMyAwesomeActor::AMyAwesomeActor()
{
// Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = true;
// UStaticMeshComponent 생성 및 Root Component로 설정
MyStaticMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MyMesh"));
RootComponent = MyStaticMesh;
// 변수 기본값 설정
WelcomeMessage = TEXT("Hello from C++!");
}
// Called when the game starts or when spawned
void AMyAwesomeActor::BeginPlay()
{
Super::BeginPlay();
// BeginPlay 시 메시지 출력
PrintWelcomeMessage();
}
// Called every frame
void AMyAwesomeActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
void AMyAwesomeActor::PrintWelcomeMessage()
{
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Green, WelcomeMessage);
}
UE_LOG(LogTemp, Warning, TEXT("%s"), *WelcomeMessage);
}생성자에서는 CreateDefaultSubobject로 UStaticMeshComponent를 만들고 RootComponent로 정했습니다. 이 컴포넌트가 루트가 되므로 액터의 위치와 회전도 이 컴포넌트의 트랜스폼을 따릅니다. 같은 생성자에서 WelcomeMessage의 초기값을 Hello from C++!로 정했습니다.
PrintWelcomeMessage는 화면에 디버그 메시지를 띄우고 로그에도 같은 문구를 남깁니다. 화면 메시지를 띄우는 GEngine 전역 객체를 쓰기 위해 Engine/Engine.h를 포함했고, GEngine이 없는 상황을 대비해 if (GEngine)으로 먼저 확인합니다. UE_LOG의 %s 자리에는 FString에 *를 붙여 얻은 문자열 포인터를 넘깁니다. BeginPlay에서 이 함수를 한 번 부르므로, 액터가 게임을 시작할 때 문구가 한 번 출력됩니다.
MyStaticMesh에 붙인 VisibleAnywhere와 BlueprintReadOnly는 컴포넌트를 가리키는 포인터 자체에 대한 권한입니다. 그래서 에디터나 블루프린트에서 이 포인터를 다른 컴포넌트로 바꿔 넣을 수는 없지만, 포인터가 가리키는 컴포넌트 안의 메시나 머티리얼 설정까지 읽기 전용이 되는 것은 아닙니다.
빌드하고 다시 열기
코드를 고친 뒤에는 반드시 빌드해야 합니다. 이 실습처럼 클래스의 구조를 처음 만들 때는 에디터를 닫고 IDE에서 프로젝트의 Development Editor 타깃을 빌드한 뒤 에디터를 다시 여는 편이 안전합니다. 실행 중에 코드를 고칠 때는 라이브 코딩을 쓸 수도 있지만, 언리얼 에디터와 주요 패널 이해에서 본 것처럼 생성자에서 정한 기본값이 기존 인스턴스에 모두 반영된다고 가정하면 안 됩니다.
더 깊이: 라이브 코딩은 실행 중인 에디터를 어떻게 고칠까
라이브 코딩은 Live++라는 도구를 엔진에 통합한 기능으로, 에디터가 실행되는 동안 C++ 코드를 다시 빌드해 실행 중인 바이너리에 패치합니다. 변수 값이나 기존 함수의 몸체를 조금 고치는 변경은 이 패치만으로 반영됩니다. 반면 새 함수나 새 변수 묶음을 더하거나 구조를 크게 고치는 변경은 이미 메모리에 있는 객체와 새 코드가 서로 맞지 않게 됩니다. 공식 문서는 이런 변경을 그대로 패치하면 예측할 수 없게 동작하고 대개 크래시로 이어진다고 설명합니다.
이 문제를 줄이려고 라이브 코딩에는 객체 재인스턴싱(Object Reinstancing)이 있으며, 기본으로 켜져 있습니다. 재인스턴싱은 UCLASS, USTRUCT, UENUM 같은 매크로로 선언한 타입의 기존 인스턴스를 애플리케이션과 에디터 전체에서 새 인스턴스로 바꿔, 구조 변경이 적용되게 합니다. 그래서 재인스턴싱된 객체를 가리키는 포인터를 코드가 따로 쥐고 있었다면, ReloadReinstancingCompleteDelegate나 ReloadCompleteDelegate에서 그 포인터를 고치거나 캐시를 비워야 합니다. 에디터를 닫고 빌드하면 메모리에 남은 인스턴스가 없으므로 이 교체 과정 자체를 거치지 않습니다.
라이브 코딩은 언리얼 에디터와 PIE, 그리고 에디터에 연결된 데스크톱 패키지 빌드에서만 쓸 수 있고, 콘솔과 모바일에서는 쓸 수 없습니다. 라이브 코딩을 끄면 에디터는 예전 방식인 핫 리로드(Hot Reload)를 씁니다. 설정 방법과 그 밖의 제한은 Live Coding 문서에서 확인할 수 있습니다.
C++ 클래스를 부모로 블루프린트 만들기
빌드를 마쳤으면 이 C++ 클래스를 부모로 하는 블루프린트 클래스를 만듭니다. 콘텐츠 브라우저에서 원하는 폴더(예: Content/Blueprints)로 이동해 빈 공간을 마우스 오른쪽 버튼으로 누르고 Blueprint Class를 고릅니다.
부모 클래스를 고르는 창에서는 모든 클래스(All Classes) 항목에서 MyAwesomeActor를 검색해, 방금 만든 C++ 클래스(타입 이름 AMyAwesomeActor)를 선택하고 선택(Select)을 누릅니다. 여기서 일반 Actor를 고르면 C++에서 만든 속성과 함수가 없는 블루프린트가 만들어지므로 꼭 확인합니다. 새 블루프린트의 이름은 BP_MyAwesomeActor로 정합니다. 블루프린트 클래스에는 BP_ 접두사를 붙이는 것이 흔한 관례입니다.
블루프린트에서 C++ 속성과 함수 확인하기
BP_MyAwesomeActor를 더블클릭해 블루프린트 에디터를 엽니다. 왼쪽 위의 컴포넌트(Components) 패널에 C++에서 만든 MyStaticMesh 컴포넌트가 이미 들어 있고, 이것을 선택하면 디테일 패널에서 메시나 머티리얼을 지정할 수 있습니다. 그다음 툴바의 클래스 기본값(Class Defaults)을 누르면 액터의 디테일 패널이 나오는데, Custom Properties 분류 아래에 C++에서 선언한 WelcomeMessage가 보입니다. 이 값을 Hello from Blueprint!로 바꿔 봅니다.
이벤트 그래프(Event Graph)에서 마우스 오른쪽 버튼을 눌러 Print Welcome Message를 검색하면, C++ 함수가 노드로 나타나 블루프린트에서도 직접 호출할 수 있습니다. 다만 C++의 BeginPlay가 이미 이 함수를 한 번 부르고 있으므로, 블루프린트의 Event BeginPlay에도 연결하면 호출이 하나 더 생겨 메시지가 두 번 나옵니다.
레벨에 배치하고 시험하기
콘텐츠 브라우저에서 BP_MyAwesomeActor를 뷰포트로 끌어다 놓아 레벨에 배치합니다. 화면에 모양을 보고 싶다면 배치한 인스턴스를 선택하고 디테일 패널에서 MyStaticMesh 컴포넌트를 고른 뒤, Static Mesh 항목에 시작 콘텐츠의 Shape_Cube 같은 기본 메시를 지정합니다. 이 과정은 선택 사항입니다. 메시를 지정하지 않아도 로그 실습은 할 수 있지만, 컴포넌트를 만든 것만으로 화면에 3D 형상이 나타나지는 않습니다.
에디터 상단의 Play 버튼을 눌러 실행한 뒤, 화면 메시지와 Output Log에서 시험한 인스턴스의 WelcomeMessage를 확인합니다. 값이 예상과 다르다면 그 값이 어디서 정해졌는지를 나눠 봐야 합니다.
UNREAL C++ · CH2-3
WelcomeMessage는 어느 객체의 값인가
C++ 생성자의 초기값, 블루프린트 클래스 기본값, 배치한 인스턴스의 편집값을 구분해 Play에서 확인합니다.
| 설정 위치 | 이 예제의 값 | 확인할 대상 |
|---|---|---|
| C++ 생성자 | Hello from C++! | 새 객체의 기본값을 초기화하는 생성자 코드 |
| 블루프린트 기본값 | 직접 바꿨다면 Hello from Blueprint! | BP_MyAwesomeActor의 Class Defaults |
| 배치한 인스턴스 | 별도로 편집했다면 그 인스턴스의 값 | 월드 아웃라이너에서 선택한 액터의 Details |
- C++ 생성자
- 이 예제의 값: Hello from C++!확인할 대상: 새 객체의 기본값을 초기화하는 생성자 코드
- 블루프린트 기본값
- 이 예제의 값: 직접 바꿨다면 Hello from Blueprint!확인할 대상: BP_MyAwesomeActor의 Class Defaults
- 배치한 인스턴스
- 이 예제의 값: 별도로 편집했다면 그 인스턴스의 값확인할 대상: 월드 아웃라이너에서 선택한 액터의 Details
부모 기본값을 바꿔도 이미 덮어쓴 인스턴스 값이 자동으로 같아지지는 않습니다. BeginPlay에서 출력할 실제 인스턴스를 먼저 선택합니다.
위 그림은 WelcomeMessage가 정해지는 세 위치를 보여 줍니다. C++ 부모 클래스의 생성자는 Hello from C++!로 초기값을 정하고, 그것을 상속한 블루프린트 자식 클래스는 기본값을 Hello from Blueprint!로 바꿉니다. 그림의 점선은 블루프린트가 C++ 부모를 상속하는 관계입니다. 레벨에 배치한 인스턴스 A와 B는 블루프린트 기본값을 기준으로 만들어지는데, A만 따로 편집해 Hello from A!를 가지고 B는 따로 편집하지 않아 기본값을 그대로 씁니다.
그림의 표는 각 값을 어디서 확인하는지 정리합니다. C++ 생성자의 값은 새 객체의 기본값을 초기화하는 생성자 코드에서, 블루프린트 기본값은 BP_MyAwesomeActor의 클래스 기본값에서, 배치한 인스턴스의 값은 아웃라이너에서 선택한 액터의 디테일 패널에서 봅니다. 그림의 화살표는 클래스 상속과 초기값의 기준을 나타낼 뿐, 함수가 실행되는 순서나 A의 값이 B로 전달되는 관계가 아닙니다. 그림 아래의 설명처럼 부모의 기본값을 바꿔도 이미 덮어쓴 인스턴스의 값은 자동으로 같아지지 않으므로, BeginPlay에서 출력할 실제 인스턴스를 먼저 고릅니다.
그림의 예를 실제로 따라가 봅시다. BP_MyAwesomeActor의 클래스 기본값을 Hello from Blueprint!로 바꿔 컴파일하고 저장한 뒤, 새 인스턴스 A와 B를 배치합니다. A에서만 WelcomeMessage를 Hello from A!로 편집하고 B는 그대로 둡니다. 블루프린트에서 함수를 더 부르지 않고 C++의 BeginPlay만 실행하면, A는 자신의 값인 Hello from A!를, B는 상속한 기본값인 Hello from Blueprint!를 출력합니다. 두 인스턴스가 같은 C++ 함수를 부르지만, 함수가 읽는 객체가 서로 다르기 때문입니다.
메시가 없어도 로그로 결과를 확인할 수 있습니다. 화면에 메시지가 보이지 않을 때는 함수가 아예 호출되지 않은 것인지, 화면 디버그 메시지 표시가 꺼져 있는 것인지를 Output Log로 구별합니다.
정리
- 성능이 중요한 부분과 공통 구조는 C++에, 자주 바뀌는 값과 연출은 블루프린트에 두고, C++ 클래스를 블루프린트가 상속하게 해 둘을 함께 씁니다.
- C++ 클래스는
Tools > New C++ Class...로 만들고, 블루프린트는Blueprint Class의 모든 클래스 목록에서 그 C++ 클래스를 부모로 골라 만듭니다. - 생성자에서
CreateDefaultSubobject로 컴포넌트를 만들고 루트로 정하며,UPROPERTY와UFUNCTION의 지정자로 블루프린트에 공개할 범위를 정합니다. - 컴포넌트 포인터의
VisibleAnywhere는 포인터를 바꾸지 못하게 할 뿐, 컴포넌트 안의 설정까지 잠그지는 않습니다. - 값은 C++ 생성자, 블루프린트 기본값, 배치한 인스턴스의 세 곳에서 정해질 수 있으며, 같은 함수라도 어느 인스턴스가 호출하느냐에 따라 다른 값을 읽습니다.
연습 문제
1. 호출이 두 경로라면
앞의 인스턴스 A와 B에서, 블루프린트의 Event BeginPlay에도 Print Welcome Message 노드를 한 번씩 연결했습니다. 다른 호출이나 다시 스폰하는 일은 없고, C++ BeginPlay의 기존 호출도 그대로 있습니다. 로그는 모두 몇 번 기록되고, 각 인스턴스에서 어떤 문구가 기록될까요? 로그를 인스턴스마다 한 번으로 줄이려면 무엇을 확인해야 할까요?
해설 보기
각 인스턴스에서 C++ 호출이 한 번, 블루프린트에서 추가한 호출이 한 번 있으므로 모두 2 × 2 = 4번 기록됩니다. A는 두 번 모두 Hello from A!를, B는 두 번 모두 Hello from Blueprint!를 기록합니다.
블루프린트에 같은 함수 노드를 추가했다고 기존 C++ 호출이 대체되는 것은 아닙니다. 한 번만 출력하려면 추가한 블루프린트 호출 경로를 지워 C++ BeginPlay의 호출만 남깁니다.
2. 기본값을 다시 바꾸면
A와 B를 배치한 뒤 BP_MyAwesomeActor의 클래스 기본값을 Hello again!으로 바꾸고 컴파일했습니다. 다시 Play하면 A와 B는 각각 어떤 문구를 출력할까요?
해설 보기
A는 여전히 Hello from A!를 출력합니다. A는 인스턴스에서 값을 따로 덮어썼으므로, 부모의 기본값이 바뀌어도 그 편집값이 유지됩니다.
B는 따로 편집하지 않고 기본값을 따르고 있었으므로 새 기본값인 Hello again!을 출력합니다. 기본값을 바꾸는 일과 배치한 인스턴스의 값을 바꾸는 일은 대상이 다르다는 점을 기억해 둡니다.
C++ 클래스를 블루프린트에 공개하는 방법은 Epic의 Coding in Unreal Engine: Blueprint vs. C++ 문서에서 더 볼 수 있습니다.
다음 글 게임 루프와 주요 클래스 구조에서는 BeginPlay와 Tick이 게임 루프의 어느 시점에 불리는지, 그리고 게임 모드와 컨트롤러 같은 주요 클래스가 각각 무엇을 맡는지 살펴봅니다.