
공식 문서(Actor Lifecycle UE5.3, Components in Unreal Engine, UActor Components 4.27) 기반 검증 완료 버전
Part 1에서 UObject 시스템(리플렉션, UClass, CDO, GC)을 이해했다면, 이제 그 위에 올라서는 Actor 시스템을 볼 차례다.
Actor는 언리얼에서 "월드에 존재하는 모든 것"의 기반이다. 하지만 Actor 자체는 빈 껍데기에 가깝다. 실제 기능(시각적 표현, 충돌, 오디오 등)은 전부 컴포넌트(Component) 를 통해 Actor에 붙는다.
| UObject | AActor가 추가한 것 |
|---|---|
| 리플렉션, GC, 직렬화 | Transform (위치·회전·크기) |
| CDO 생성, 에디터 연동 | 컴포넌트 부착 (Mesh, Collision, Light 등) |
| Tick (매 프레임 업데이트) | |
| BeginPlay / EndPlay (생성·소멸 이벤트) | |
| Spawn / Destroy (월드에 동적 생성·제거) |
공식 가이드(Tom Looman):
"Actors don't actually own a translation, rotation, or scale. This is all set and retrieved via the RootComponent."
GetActorLocation(), SetActorLocation() 같은 함수들은 실제로 RootComponent의 Transform을 읽고 쓴다. Actor 자체에 Transform 데이터가 있는 게 아니다. RootComponent가 없는 Actor는 월드에서 정확한 위치를 가질 수 없다.
공식 문서:
"UActorComponent is the base class for all Components."
"Only Scene Components (USceneComponent and its child classes) can attach to one another, due to the requirement for transforms to describe the spatial relationship between the child and parent Components."
UActorComponent ← 모든 컴포넌트의 기반. Transform 없음.
└─ USceneComponent ← Transform + 부착 능력 추가
└─ UPrimitiveComponent ← 렌더링 + 물리 충돌 추가 (중간 계층)
├─ UStaticMeshComponent
├─ USkeletalMeshComponent
├─ UShapeComponent
│ ├─ UCapsuleComponent
│ ├─ USphereComponent
│ └─ UBoxComponent
└─ ...
UActorComponent — Transform 없는 순수 로직 컴포넌트
공식 문서:
"Actor Components do not have a transform, meaning they do not have any physical location or rotation in the world."
Transform 없음 → SceneComponent 계층에 참여하지 않음
SetupAttachment 불가 → USceneComponent에 정의된 함수이므로 호출 불가
SetRootComponent 불가 → 파라미터 타입이 USceneComponent*이므로 컴파일 에러
계층 구조 밖에 독립적으로 존재:
Actor
├── [SceneComponent 계층]
│ RootComponent
│ └── MeshComp
│ └── SpringArmComp
│ └── CameraComp
│
└── [계층 밖]
MovementComp (UCharacterMovementComponent)
InventoryComp (UInventoryComponent)
에디터에서 블루프린트 컴포넌트 목록을 보면 CharacterMovement가 계층 트리 밖에 따로 표시되는 것이 바로 이 구조다.
공식 가이드:
"This is simply because that component is an ActorComponent but NOT a SceneComponent and has no Transform (location, rotation, scale) and does therefore not need to be added to the hierarchy. The component will still be registered with the Actor regardless, as that is separate from the hierarchy meaning functions like MyActor->GetComponentByClass will return any ActorComponents and SceneComponents."
USceneComponent — Transform은 있지만 렌더링·충돌 없음
공식 문서:
"A SceneComponent has a transform and supports attachment, but has no rendering or collision capabilities. Useful as a 'dummy' component in the hierarchy to offset others."
Transform 있음 → 월드에서 위치·회전·크기를 가짐
부착 가능 → SetupAttachment로 다른 SceneComponent의 자식이 될 수 있음
렌더링 없음, 충돌 없음
용도:
→ 직접 사용: 여러 메시를 붙일 때 기준점(루트)으로 사용
→ SpringArm, Camera 등도 직접 사용하는 SceneComponent 파생 클래스
USceneComponent는 직접 생성해서 루트로 사용하는 경우가 많다. 여러 메시를 하나의 Actor에 붙일 때 루트를 순수 USceneComponent로 두면 루트 자체는 렌더링 없이 기준점 역할만 하고, 실제 메시들은 그 아래에 붙는다.
UPrimitiveComponent — 렌더링 + 충돌까지 포함 (중간 계층)
공식 문서:
"Primitive Components (class UPrimitiveComponent) are Scene Components that contain or generate some sort of geometry, generally for rendering or collision purposes."
USceneComponent를 상속 → Transform + 부착 능력 보유
거기에 렌더링과 충돌 추가
UPrimitiveComponent 자체는 직접 생성하지 않는 중간 계층이다.
실제로는 항상 파생 클래스를 사용한다:
→ UStaticMeshComponent (정적 메시)
→ USkeletalMeshComponent (뼈대 있는 메시, 캐릭터)
→ UCapsuleComponent (캡슐 충돌)
→ USphereComponent (구 충돌)
→ UBoxComponent (박스 충돌)
SpringArmComp (부모)
└── CameraComp (자식)
부모가 이동·회전·스케일 변화를 겪으면
자식은 자신의 로컬 오프셋을 유지한 채 함께 따라온다.
→ SpringArmComp가 캐릭터 뒤에서 회전할 때
CameraComp도 SpringArm 끝에서 함께 회전함
→ 3인칭 카메라가 동작하는 원리
공식 문서:
"Actors can designate a single Scene Component as 'root', meaning that the Actor's world location, rotation, and scale are taken from this Component."
Actor
└─ RootComponent (USceneComponent 또는 파생 클래스) ← Actor의 위치 기준
└─ SkeletalMeshComp ← 루트 기준 상대 위치
└─ SpringArmComp
└─ CameraComp
루트가 이동하면 자식 SceneComponent들도 함께 이동한다.
루트로 무엇을 쓸 것인가
// 방법 1: USceneComponent를 루트로 (렌더링 없는 순수 기준점)
// 여러 메시를 붙일 때 구조가 유연함
SceneRoot = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
SetRootComponent(SceneRoot);
MeshA = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshA"));
MeshA->SetupAttachment(SceneRoot);
MeshB = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshB"));
MeshB->SetupAttachment(SceneRoot);
// 방법 2: UStaticMeshComponent를 루트로 (메시가 하나일 때 간결)
MeshComp = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
SetRootComponent(MeshComp);
UArrowComponent는 UPrimitiveComponent를 상속받으므로 Transform은 있다. 에디터에서 조작할 게 없는 이유는 에디터 전용 시각화 컴포넌트이기 때문이다.
공식 문서 코드:
void UCameraComponent::OnRegister()
{
#if WITH_EDITORONLY_DATA
// DrawFrustum은 에디터 빌드에서만 존재
DrawFrustum = NewObject<UDrawFrustumComponent>(...);
DrawFrustum->SetIsVisualizationComponent(true);
#endif
}
ArrowComponent, DrawFrustumComponent 등:
→ 에디터 뷰포트에서 방향·범위를 시각적으로 확인하는 디버그 용도
→ WITH_EDITORONLY_DATA 매크로로 에디터 빌드에서만 포함
→ 실제 게임 빌드에 포함되지 않음
→ 게임 로직에 영향 없음 → 조작할 의미도 없음
공식 문서:
"ActorComponents are automatically registered when their owning Actor is spawned as long as they are created as sub-objects and were added to the Components array in the Actor's default properties. Otherwise they can be registered dynamically via RegisterComponent."
// [생성자] CreateDefaultSubobject — CDO에 컴포넌트 구조 등록
// 생성자 안에서만 유효
AMyActor::AMyActor()
{
RootComponent = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
MeshComp = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
MeshComp->SetupAttachment(RootComponent);
// UActorComponent는 SetupAttachment 없이 그냥 생성
MovementComp = CreateDefaultSubobject<UCharacterMovementComponent>(TEXT("Movement"));
}
// [런타임] NewObject + RegisterComponent — 게임 플레이 중 동적 추가
// PostInitializeComponents, BeginPlay, 다른 함수 어디서든 사용 가능
UStaticMeshComponent* DynamicMesh = NewObject<UStaticMeshComponent>(this);
DynamicMesh->SetupAttachment(RootComponent);
DynamicMesh->RegisterComponent(); // 반드시 직접 호출해야 함
CreateDefaultSubobject vs NewObject
두 방식 모두 결국 RegisterComponent가 호출된다. 차이는 누가 호출하는가다.
| CreateDefaultSubobject | NewObject | |
|---|---|---|
| 사용 위치 | 생성자 안에서만 | 생성자 밖 어디서든 |
| CDO 등록 | ✅ UClass에 구조 기록 | ❌ CDO에 없음 |
| RegisterComponent 호출 | 엔진 초기화 파이프라인이 자동 처리 | 개발자가 직접 호출 필요 |
| 에디터 Components 패널 표시 | ✅ | ❌ |
RegisterComponent가 하는 일
공식 문서:
"Components are registered in a scene with the RegisterComponent function, so that they may be updated each frame. That function calls RegisterComponentWithScene to ensure that the component is present in the Actor's Components array, is associated with the scene, and creates a render proxy and physics state for it."
RegisterComponent():
→ 렌더 프록시 생성 → 화면에 보이기 시작
→ 물리 상태 생성 → 충돌 감지 시작
→ Tick 등록 → 매 프레임 업데이트 시작
→ Actor의 Components 배열에 추가
CreateDefaultSubobject로 등록된 컴포넌트:
→ CDO에 구조가 기록되어 있음
→ 레벨 로드 또는 SpawnActor 후 엔진의 초기화 파이프라인이
Actor의 모든 컴포넌트를 순회하며 RegisterComponent를 자동 호출
→ 개발자가 직접 호출할 필요 없음
NewObject로 만든 컴포넌트:
→ CDO에 없으므로 엔진이 이 컴포넌트의 존재를 모름
→ 개발자가 직접 RegisterComponent() 호출해야 함
SetupAttachment vs AttachToComponent — 언제 무엇을 쓰는가
공식 API 문서:
"Generally intended to be called from its Owning Actor's constructor and should be preferred over AttachToComponent when a component is not registered."
SetupAttachment(Parent, SocketName)
→ 생성자 전용. 컴포넌트가 아직 등록(Register)되지 않은 시점에 사용.
→ "나중에 등록될 때 이 부모에 붙어라"고 예약하는 방식.
→ CDO에 부착 관계가 기록되어 RegisterComponent 시 자동 처리됨.
AttachToComponent(Parent, Rules, SocketName)
→ 런타임 전용. 컴포넌트가 이미 등록(Register)된 이후에 사용.
→ 즉시 부착이 실행됨. bool 반환(성공 여부).
→ NewObject로 만든 컴포넌트를 RegisterComponent 후 부착할 때 사용.
// 생성자 — SetupAttachment 사용
AMyActor::AMyActor()
{
MeshComp = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
MeshComp->SetupAttachment(RootComponent); // ✅ 생성자에서는 SetupAttachment
}
// 런타임 — AttachToComponent 사용
void AMyActor::BeginPlay()
{
Super::BeginPlay();
UStaticMeshComponent* DynamicMesh = NewObject<UStaticMeshComponent>(this);
DynamicMesh->RegisterComponent();
DynamicMesh->AttachToComponent( // ✅ 런타임에서는 AttachToComponent
RootComponent,
FAttachmentTransformRules::KeepRelativeTransform
);
}
// ✅ 컴포넌트 권장 패턴
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
UStaticMeshComponent* MeshComp;
// ❌ EditAnywhere를 쓰면 안 되는 이유
UPROPERTY(EditAnywhere)
UStaticMeshComponent* MeshComp;
// EditAnywhere → 에디터에서 MeshComp 포인터 자체를 다른 컴포넌트로 교체 가능
// 컴포넌트는 코드에서 생성하므로 포인터를 바꿔치기할 이유가 없음
// VisibleAnywhere → "볼 수 있지만 교체 불가" 가 올바른 상태
컴포넌트 내부의 에셋(StaticMesh, Material 등)이나 수치를 조정하는 것은 포인터 교체가 아니라 컴포넌트 내부 속성 편집이므로 별개다.
Actor가 월드에 존재하게 되는 경로는 두 가지다.
경로 A: 레벨 파일(.umap)에서 로드
에디터에서 배치해두고 저장한 Actor가 게임(PIE) 시작 시 디스크에서 읽히는 경우다.
레벨 파일(.umap) 읽기 시작
↓
[UClass + CDO 준비]
C++ 클래스: 엔진 시작 시 이미 DLL 로드와 함께 UClass + CDO 생성 완료
→ 이 단계에서 별도 작업 없음
BP 클래스: 해당 .uasset이 메모리에 없으면 지금 로드 → UClass + CDO 생성
(이미 로드되어 있으면 기존 CDO 재사용)
↓
CDO 복사 → 인스턴스 생성
↓
PostLoad ← 직렬화된 인스턴스별 델타값 적용 (위치, 수정된 프로퍼티 등)
↓
PreInitializeComponents
↓
각 컴포넌트 InitializeComponent
↓
PostInitializeComponents
↓
엔진 초기화 파이프라인이 모든 컴포넌트 순회 → RegisterComponent 자동 호출
↓
BeginPlay
경로 B: SpawnActor()로 동적 생성
공식 문서:
"PostActorCreated is called for spawned Actors after its creation, any constructor implementation behavior should go here. PostActorCreated is mutually exclusive with PostLoad."
SpawnActor<T>() 호출
↓
CDO 복사 → 인스턴스 생성
↓
PostActorCreated ← 생성 직후 (PostLoad와 상호 배타적)
↓
OnConstruction (Blueprint Construction Script)
↓
PreInitializeComponents
↓
각 컴포넌트 InitializeComponent
↓
PostInitializeComponents
↓
엔진 초기화 파이프라인이 모든 컴포넌트 순회 → RegisterComponent 자동 호출
↓
OnActorSpawned (UWorld 브로드캐스트)
↓
BeginPlay
PostActorCreated와 PostLoad가 상호 배타적인 이유
한 Actor가 두 경로를 동시에 탈 수 없기 때문이다. 레벨 로드 경로는 PostLoad를, SpawnActor 경로는 PostActorCreated를 거친다. 이후 PreInitializeComponents부터는 두 경로가 합류해서 동일한 흐름을 탄다.
소멸 흐름 (두 경로 공통)
EndPlay ← 모든 소멸 상황에서 보장 호출
↓
OnDestroyed ← Destroy() 명시적 호출 시만 (레거시)
↓
BeginDestroy ← GC: 메모리·멀티스레드 리소스 해제 시작
↓
IsReadyForFinishDestroy ← GC: 소멸 준비가 됐는지 확인. false 반환 시 다음 GC 사이클로 지연 가능
↓
FinishDestroy ← GC: 메모리 해제 직전 마지막 호출
↓
GC 메모리 해제
| 함수 | 경로 | 호출 시점 | 주요 용도 |
|---|---|---|---|
Constructor | 공통 | CDO 생성 시 (모듈/에셋 로드 시점) | 기본값 설정, CreateDefaultSubobject, ConstructorHelpers |
PostLoad | 경로 A | 직렬화 데이터 로드 완료 후 | 커스텀 버전 관리 및 픽스업 처리. PostActorCreated와 상호 배타적. |
PostActorCreated | 경로 B | SpawnActor 직후 | 생성자 유사 초기화. PostLoad와 상호 배타적. |
PostInitializeComponents | 공통 | 모든 컴포넌트 초기화 완료 후 | 컴포넌트 간 의존성 설정. 게임 월드에서만 호출됨 |
BeginPlay | 공통 | 게임 시작 또는 스폰 시 한 번 | 타이머/델리게이트 바인딩, 다른 Actor 참조, 게임 로직 시작 |
Tick | 공통 | 매 프레임 | 실시간 업데이트 |
EndPlay | 공통 | 모든 소멸 상황에서 보장 | 타이머 해제, 델리게이트 해제, 리소스 정리 |
OnDestroyed | 공통 | Destroy() 명시적 호출 시만 | 레거시. 중요한 정리는 EndPlay에 |
BeginDestroy | 공통 | GC 수거 시작 시 | 메모리·멀티스레드 리소스 해제 |
FinishDestroy | 공통 | 메모리 해제 직전 | 마지막 내부 데이터 정리 |
공식 소스 코드 주석:
// General flow:
// - Actor gets PreInitializeComponents()
// - Actor components get OnComponentCreated
// - Actor components get OnRegister
// - Actor components get InitializeComponent
// - Actor gets PostInitializeComponents() once everything is set up
// - Actor components get RegisterComponentTickFunctions
Constructor
가능:
→ 기본값 설정, CreateDefaultSubobject, ConstructorHelpers::FObjectFinder
불가능:
→ GetWorld() — CDO 생성 시 월드가 없을 수 있음
→ 다른 Actor 참조 — 아직 아무것도 스폰 안 됨
→ AddDynamic — CDO까지 이벤트에 응답하는 문제 발생
PostInitializeComponents
특징:
→ 모든 컴포넌트가 생성·초기화된 상태
→ 게임 월드(PIE 또는 실제 게임)에서만 호출됨 (에디터 월드 아님)
→ BeginPlay보다 이전
용도:
→ 컴포넌트 간 의존성 설정
→ 컴포넌트 상태에 기반한 초기 설정
→ AddDynamic 바인딩 가능 (컴포넌트가 준비됨)
BeginPlay
특징:
→ 월드와 다른 Actor들이 준비된 상태
→ 게임 로직의 진짜 시작점
→ Super::BeginPlay()를 반드시 호출해야 한다
(부모 클래스 체인의 BeginPlay 로직과 Blueprint ReceiveBeginPlay 이벤트 실행을 보장하기 위함)
→ 컴포넌트의 BeginPlay는 Actor의 BeginPlay보다 먼저 호출된다
(공식 API 문서: "Called when the owning Actor begins play or
when the component is created if the Actor has already begun play")
용도:
→ 다른 Actor, GameMode, GameState 참조
→ 타이머·델리게이트 바인딩 (일반적인 위치)
→ AI, 플레이어 컨트롤러 등 다른 시스템과 연동
언리얼에는 두 종류의 월드가 있다.
에디터 월드 (Editor World)
→ 에디터를 켰을 때 뷰포트에 보이는 레이아웃 편집 공간
→ Constructor(CDO 생성)만 실행됨
→ PostInitializeComponents, BeginPlay, Tick 호출 안 됨
게임 월드 (Game World)
→ PIE 실행 버튼 또는 실제 게임 실행 시 만들어지는 공간
→ 실제 게임 로직이 돌아가는 공간
→ PostInitializeComponents, BeginPlay, Tick 전부 호출됨
공식 문서:
"EndPlay is called in several places to guarantee the life of the Actor is coming to an end."
① Destroy() 명시적 호출 → EEndPlayReason::Destroyed
② PIE 종료 버튼 클릭 → EEndPlayReason::EndPlayInEditor
③ 레벨 전환 (OpenLevel, LoadMap) → EEndPlayReason::RemovedFromWorld
④ 스트리밍 레벨 언로드 → EEndPlayReason::RemovedFromWorld
⑤ Actor LifeSpan 만료 → EEndPlayReason::Destroyed
⑥ 배포된 게임 실행파일 종료 → EEndPlayReason::Quit
"애플리케이션 종료"(⑥)는 배포된 게임 실행파일이 완전히 꺼질 때를 의미한다.
PIE 종료(②)와 다른 경우다.
OnDestroyed는 Destroy() 직접 호출 시에만 불린다. 레벨 전환이나 PIE 종료 시에는 호출되지 않는다. 중요한 정리 로직은 반드시 EndPlay에 넣어야 한다.
[에디터 실행 시]
CDO 생성자 실행 (1회) → CDO 로그
[PIE 시작 시]
에디터 월드 Actor를 게임 월드로 복제(Duplicate)
→ Construction Script 재실행 과정에서 생성자가 다시 호출 (2회) → PIE 인스턴스 로그
→ PostInitializeComponents → BeginPlay
정상 동작이다. CDO 생성(1회) + PIE 인스턴스 생성(1회) = 총 2회.
AMyActor::AMyActor()
{
PrimaryActorTick.bCanEverTick = true; // 활성화 (기본값 true)
// PrimaryActorTick.bCanEverTick = false; // Tick 불필요 시 성능을 위해 끄기
}
void AMyActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// DeltaTime: 직전 프레임부터 현재 프레임까지 걸린 시간(초)
}
Tick이 필요 없는 Actor는 반드시 bCanEverTick = false로 설정해야 한다. 타이머나 이벤트로 대체 가능한 로직은 Tick에서 빼는 것이 좋다.
| 속성 | 타입 | 의미 |
|---|---|---|
| Location (위치) | FVector | 월드에서 어느 지점에 있는가 |
| Rotation (회전) | FRotator | 어느 방향을 바라보는가 (도 단위) |
| Scale (크기) | FVector | 원본 대비 배율 (1.0 = 원본) |
FRotator(Pitch, Yaw, Roll)
Pitch → Y축 기준 앞뒤 기울기 (고개를 끄덕이는 방향)
Yaw → Z축 기준 좌우 방향 전환 (나침반 방향) ← 게임에서 가장 자주 사용
Roll → X축 기준 좌우 기울기 (비행기가 날개를 기울이는 방향)
FRotator는 직관적이지만 짐벌 락(Gimbal Lock) 문제가 있다. 세 축 중 두 축이 겹쳐질 때 자유도가 하나 사라지는 현상으로, 모든 방향으로 자유롭게 회전하는 경우에 발생한다.
FQuat(쿼터니언)은 4개의 값 (X, Y, Z, W)으로 이 문제를 수학적으로 해결한다.
공식 API 문서:
"Floating point quaternion that can represent a rotation about an axis in 3-D space. The X, Y, Z, W components also double as the Axis/Angle format."
X, Y, Z → 회전축(Axis) 벡터의 성분
회전이 어느 방향으로 일어나는지를 나타냄
W → 회전량(Angle)과 관련된 스칼라 성분
W = 1 → 회전 없음 (FQuat::Identity)
W = 0 → 180도에 가까운 회전
(X, Y, Z, W)를 직접 보고 회전 방향이나 각도를 직관적으로 파악하기는 어렵다.
이것이 FQuat가 FRotator보다 비직관적인 이유다.)
X, Y, Z, W를 직접 조작하면 안 된다. 수치 오류가 날 수 있기 때문에 반드시 제공된 함수를 통해 다룬다.
// 올바른 생성
FQuat RotQuat = FQuat(FVector::UpVector, FMath::DegreesToRadians(45.f)); // 축 + 각도
FQuat FromRotator = FQuat(FRotator(0.f, 45.f, 0.f)); // FRotator에서 변환
// FQuat가 필요한 핵심 상황 — Slerp (구면 선형 보간)
FQuat Result = FQuat::Slerp(StartQuat, EndQuat, 0.5f);
// FRotator로 보간하면 경로가 부자연스러움
// FQuat Slerp는 항상 최단 경로로 부드럽게 회전
// 상호 변환
FQuat MyQuat = FQuat(FRotator(0.f, 45.f, 0.f)); // FRotator → FQuat
FRotator Back = MyQuat.Rotator(); // FQuat → FRotator
실무 기준:
FRotator 사용 (직관적, 에디터·블루프린트에서도 쉽게 다룸)FQuat 사용월드 좌표계 (World Space)
→ 맵 전체를 기준으로 한 절대적인 좌표
→ SetActorLocation(), GetActorLocation()
→ AddActorWorldOffset(), AddActorWorldRotation()
로컬 좌표계 (Local Space)
→ Actor 자신 또는 부모 컴포넌트를 기준으로 한 상대적인 좌표
→ SetRelativeLocation(), GetRelativeLocation()
→ AddActorLocalRotation()
// 위치
SetActorLocation(FVector(300.f, 0.f, 100.f));
AddActorWorldOffset(FVector(100.f, 0.f, 0.f)); // 월드 기준 이동 추가
SetActorRelativeLocation(FVector(0.f, 0.f, 50.f)); // 부모 기준 상대 위치
// 회전
SetActorRotation(FRotator(0.f, 45.f, 0.f));
AddActorLocalRotation(FRotator(0.f, 90.f, 0.f)); // 로컬 기준 회전 추가
AddActorWorldRotation(FRotator(0.f, 90.f, 0.f)); // 월드 기준 회전 추가
// 크기
SetActorScale3D(FVector(2.f)); // 균일 스케일
SetActorScale3D(FVector(2.f, 1.f, 0.5f)); // 축별 개별 스케일
// 한 번에
FTransform NewTransform(
FRotator(0.f, 45.f, 0.f), // Rotation
FVector(300.f, 200.f, 100.f), // Location
FVector(2.f) // Scale
);
SetActorTransform(NewTransform);
Tick은 매 프레임 호출된다. FPS가 다른 환경에서 Tick 호출 횟수가 달라지기 때문에, DeltaTime 없이는 FPS에 따라 게임 속도가 달라진다.
DeltaTime = 직전 프레임부터 현재 프레임까지 걸린 시간(초)
60FPS → DeltaTime ≈ 0.0167초
120FPS → DeltaTime ≈ 0.0083초
30FPS → DeltaTime ≈ 0.033초
// ❌ 잘못된 코드 — FPS에 따라 속도가 달라짐
AddActorLocalRotation(FRotator(0.f, 1.f, 0.f));
// 60FPS → 60도/초 / 120FPS → 120도/초
// ✅ 올바른 코드 — 어떤 FPS에서도 90도/초
AddActorLocalRotation(FRotator(0.f, 90.f * DeltaTime, 0.f));
// 60FPS → 0.0167 × 90 = 1.5도/프레임, 60회 = 90도/초
// 120FPS → 0.0083 × 90 = 0.75도/프레임, 120회 = 90도/초
Tick에서 처리하는 모든 연속적인 값 변화에 DeltaTime을 곱해야 한다.
DeltaTime과 FPS 이점의 구분: DeltaTime은 "1초 동안의 총 이동량"이 FPS와 무관하게 동일하도록 보장한다. 다만 FPS 자체의 이점(화면 갱신 빈도 증가, 입력 지연 감소)은 DeltaTime이 해결하는 문제와 별개이며 하드웨어 성능 차이에서 온다.
// ❌ float을 0과 직접 비교하면 안 됨
if (RotationSpeed == 0.f) { ... }
// 부동소수점 특성상 0을 정확히 표현하지 못할 수 있음
// ✅ FMath::IsNearlyZero 사용
if (!FMath::IsNearlyZero(RotationSpeed))
{
AddActorLocalRotation(FRotator(0.f, RotationSpeed * DeltaTime, 0.f));
}
UE_LOG(LogTemp, Warning, TEXT("BeginPlay called!"));
UE_LOG(LogTemp, Warning, TEXT("%s BeginPlay"), *GetName());
// *GetName() 에서 * 의 의미:
// GetName()은 FString을 반환
// UE_LOG의 %s는 const TCHAR* 타입을 요구
// FString에는 operator*()가 오버로딩되어 있음
// *MyFString → FString 내부 문자 배열의 포인터(const TCHAR*) 반환
// C++ 포인터 역참조가 아니라 FString의 연산자 오버로딩
| 수준 | 색상 | 용도 |
|---|---|---|
Display | 흰색 | 일반 흐름 확인 |
Warning | 노란색 | 예상치 못한 동작 |
Error | 빨간색 | 즉시 수정 필요 |
C++의 extern 선언/정의 패턴과 동일한 구조다.
// MyActor.h — extern 선언 (존재를 알림, 다른 파일에서도 사용 가능)
DECLARE_LOG_CATEGORY_EXTERN(LogMyGame, Warning, All);
// MyActor.cpp — 실제 정의 (카테고리 객체 생성, 메모리 할당)
DEFINE_LOG_CATEGORY(LogMyGame);
// 사용
UE_LOG(LogMyGame, Warning, TEXT("Custom log"));
EXTERN이 붙은 이유: 헤더를 #include하는 어떤 파일에서든 이 카테고리를 사용할 수 있게 External Linkage를 부여하기 위함이다. 실제 객체는 .cpp에서 딱 한 번만 생성되고, 다른 파일들은 extern으로 그 객체를 참조한다.
Tick에 로그를 넣으면 60FPS 환경에서 1초에 60개씩 출력되어 Output Log가 폭주한다.
// MyActor.h
UCLASS()
class AMyActor : public AActor
{
GENERATED_BODY()
public:
AMyActor();
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
UStaticMeshComponent* MeshComp; // VisibleAnywhere — 포인터 교체 불가
UPROPERTY(EditDefaultsOnly, Category = "Settings")
float RotationSpeed = 90.f; // EditDefaultsOnly — BP 기본값에서만 수정
virtual void PostInitializeComponents() override;
virtual void BeginPlay() override;
virtual void Tick(float DeltaTime) override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
};
// MyActor.cpp
AMyActor::AMyActor()
{
PrimaryActorTick.bCanEverTick = true;
// [Constructor] 컴포넌트 생성, 기본값 설정만
MeshComp = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
SetRootComponent(MeshComp);
// ❌ GetWorld(), AddDynamic() 사용 불가
}
void AMyActor::PostInitializeComponents()
{
Super::PostInitializeComponents();
// [PostInitializeComponents] 컴포넌트 간 의존성 설정
// 게임 월드에서만 호출됨
}
void AMyActor::BeginPlay()
{
Super::BeginPlay();
// [BeginPlay] 게임 로직 시작, 다른 Actor 참조, 바인딩
}
void AMyActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// [Tick] DeltaTime 반드시 사용
if (!FMath::IsNearlyZero(RotationSpeed))
{
AddActorLocalRotation(FRotator(0.f, RotationSpeed * DeltaTime, 0.f));
}
}
void AMyActor::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
// [EndPlay] 정리 코드 — Super 호출 전에 처리
GetWorldTimerManager().ClearAllTimersForObject(this);
Super::EndPlay(EndPlayReason);
}
Actor는 Transform을 직접 갖고 있지 않다. Transform은 RootComponent를 통해 결정된다.
컴포넌트 계층: UActorComponent → USceneComponent → UPrimitiveComponent. UActorComponent는 Transform이 없어서 SceneComponent 계층에 참여하지 않고 독립적으로 Actor에 붙는다. SetupAttachment와 SetRootComponent는 USceneComponent 계열에서만 사용 가능하다.
USceneComponent는 직접 생성해서 루트로 사용한다. 여러 메시를 붙일 때 순수 기준점 역할로 쓰는 것이 올바른 패턴이다. UPrimitiveComponent는 직접 생성하지 않는 중간 계층이고, 항상 파생 클래스(StaticMeshComponent, CapsuleComponent 등)를 사용한다.
CreateDefaultSubobject는 CDO에 컴포넌트 구조를 등록하고, RegisterComponent는 인스턴스를 월드 시스템에 참여시킨다. 두 방식 모두 RegisterComponent가 호출되며 차이는 누가 호출하느냐다. CDO에 등록된 컴포넌트는 엔진의 초기화 파이프라인이 자동으로 RegisterComponent를 호출하고, NewObject로 만든 컴포넌트는 개발자가 직접 호출해야 한다.
라이프사이클 경로는 두 가지다. 레벨 로드 경로는 PostLoad를, SpawnActor 경로는 PostActorCreated를 거치며 이 둘은 상호 배타적이다. PreInitializeComponents 이후는 두 경로 모두 동일한 흐름을 탄다.
PostInitializeComponents는 게임 월드에서만 호출된다. 에디터 월드에서는 생성자(CDO)만 실행된다.
EndPlay는 PIE 종료, 레벨 전환, Destroy() 호출 등 모든 소멸 상황에서 보장된다. 중요한 정리 로직은 반드시 EndPlay에 넣어야 한다.
Tick에서 모든 연속적인 값 변화에는 DeltaTime을 곱해야 한다. FPS가 높으면 게임 속도가 달라지는 버그를 막기 위함이다. 단, 높은 FPS 자체가 주는 이점(화면 갱신 빈도, 입력 반응)은 DeltaTime과 별개다.
FRotator는 직관적이지만 짐벌 락 문제가 있다. 부드러운 보간(Slerp)이 필요한 상황에서는 FQuat를 사용한다.