Part 2. Actor 시스템: 컴포넌트 구조 · 라이프사이클 · Transform

Semi·2026년 4월 15일

Unreal Engine 정리

목록 보기
2/3
post-thumbnail

공식 문서(Actor Lifecycle UE5.3, Components in Unreal Engine, UActor Components 4.27) 기반 검증 완료 버전


이 문서를 읽기 전에

Part 1에서 UObject 시스템(리플렉션, UClass, CDO, GC)을 이해했다면, 이제 그 위에 올라서는 Actor 시스템을 볼 차례다.

Actor는 언리얼에서 "월드에 존재하는 모든 것"의 기반이다. 하지만 Actor 자체는 빈 껍데기에 가깝다. 실제 기능(시각적 표현, 충돌, 오디오 등)은 전부 컴포넌트(Component) 를 통해 Actor에 붙는다.


1. AActor — 월드에 존재하는 모든 것의 기반

1-1. AActor가 UObject와 다른 점

UObjectAActor가 추가한 것
리플렉션, GC, 직렬화Transform (위치·회전·크기)
CDO 생성, 에디터 연동컴포넌트 부착 (Mesh, Collision, Light 등)
Tick (매 프레임 업데이트)
BeginPlay / EndPlay (생성·소멸 이벤트)
Spawn / Destroy (월드에 동적 생성·제거)

1-2. Actor는 Transform을 직접 갖고 있지 않다

공식 가이드(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는 월드에서 정확한 위치를 가질 수 없다.


2. 컴포넌트 시스템

2-1. 컴포넌트 계층 구조

공식 문서:

"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
                    └─ ...

2-2. 세 계층의 역할 차이

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         (박스 충돌)

2-3. USceneComponent가 다른 SceneComponent의 자식이 되면

SpringArmComp (부모)
    └── CameraComp (자식)

부모가 이동·회전·스케일 변화를 겪으면
자식은 자신의 로컬 오프셋을 유지한 채 함께 따라온다.

→ SpringArmComp가 캐릭터 뒤에서 회전할 때
  CameraComp도 SpringArm 끝에서 함께 회전함
→ 3인칭 카메라가 동작하는 원리

2-4. RootComponent — Actor의 공간적 기준점

공식 문서:

"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);

2-5. ArrowComponent가 에디터 Details에서 보이지 않는 이유

UArrowComponentUPrimitiveComponent를 상속받으므로 Transform은 있다. 에디터에서 조작할 게 없는 이유는 에디터 전용 시각화 컴포넌트이기 때문이다.

공식 문서 코드:

void UCameraComponent::OnRegister()
{
#if WITH_EDITORONLY_DATA
    // DrawFrustum은 에디터 빌드에서만 존재
    DrawFrustum = NewObject<UDrawFrustumComponent>(...);
    DrawFrustum->SetIsVisualizationComponent(true);
#endif
}
ArrowComponent, DrawFrustumComponent 등:
→ 에디터 뷰포트에서 방향·범위를 시각적으로 확인하는 디버그 용도
→ WITH_EDITORONLY_DATA 매크로로 에디터 빌드에서만 포함
→ 실제 게임 빌드에 포함되지 않음
→ 게임 로직에 영향 없음 → 조작할 의미도 없음

2-6. 컴포넌트 생성 방법 — 생성자 vs 런타임

공식 문서:

"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가 호출된다. 차이는 누가 호출하는가다.

CreateDefaultSubobjectNewObject
사용 위치생성자 안에서만생성자 밖 어디서든
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
    );
}

2-7. 컴포넌트 UPROPERTY 선언 — VisibleAnywhere를 쓰는 이유

// ✅ 컴포넌트 권장 패턴
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
UStaticMeshComponent* MeshComp;

// ❌ EditAnywhere를 쓰면 안 되는 이유
UPROPERTY(EditAnywhere)
UStaticMeshComponent* MeshComp;
// EditAnywhere → 에디터에서 MeshComp 포인터 자체를 다른 컴포넌트로 교체 가능
// 컴포넌트는 코드에서 생성하므로 포인터를 바꿔치기할 이유가 없음
// VisibleAnywhere → "볼 수 있지만 교체 불가" 가 올바른 상태

컴포넌트 내부의 에셋(StaticMesh, Material 등)이나 수치를 조정하는 것은 포인터 교체가 아니라 컴포넌트 내부 속성 편집이므로 별개다.


3. Actor 라이프사이클

3-1. Actor가 월드에 등장하는 두 경로

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 메모리 해제

3-2. 각 함수의 역할과 사용 시점

함수경로호출 시점주요 용도
Constructor공통CDO 생성 시 (모듈/에셋 로드 시점)기본값 설정, CreateDefaultSubobject, ConstructorHelpers
PostLoad경로 A직렬화 데이터 로드 완료 후커스텀 버전 관리 및 픽스업 처리. PostActorCreated와 상호 배타적.
PostActorCreated경로 BSpawnActor 직후생성자 유사 초기화. PostLoad와 상호 배타적.
PostInitializeComponents공통모든 컴포넌트 초기화 완료 후컴포넌트 간 의존성 설정. 게임 월드에서만 호출됨
BeginPlay공통게임 시작 또는 스폰 시 한 번타이머/델리게이트 바인딩, 다른 Actor 참조, 게임 로직 시작
Tick공통매 프레임실시간 업데이트
EndPlay공통모든 소멸 상황에서 보장타이머 해제, 델리게이트 해제, 리소스 정리
OnDestroyed공통Destroy() 명시적 호출 시만레거시. 중요한 정리는 EndPlay에
BeginDestroy공통GC 수거 시작 시메모리·멀티스레드 리소스 해제
FinishDestroy공통메모리 해제 직전마지막 내부 데이터 정리

3-3. Constructor · PostInitializeComponents · BeginPlay — 뭘 어디에 넣는가

공식 소스 코드 주석:

// 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, 플레이어 컨트롤러 등 다른 시스템과 연동

3-4. 에디터 월드 vs 게임 월드

언리얼에는 두 종류의 월드가 있다.

에디터 월드 (Editor World)
→ 에디터를 켰을 때 뷰포트에 보이는 레이아웃 편집 공간
→ Constructor(CDO 생성)만 실행됨
→ PostInitializeComponents, BeginPlay, Tick 호출 안 됨

게임 월드 (Game World)
→ PIE 실행 버튼 또는 실제 게임 실행 시 만들어지는 공간
→ 실제 게임 로직이 돌아가는 공간
→ PostInitializeComponents, BeginPlay, Tick 전부 호출됨

3-5. EndPlay가 호출되는 상황

공식 문서:

"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 종료(②)와 다른 경우다.

OnDestroyedDestroy() 직접 호출 시에만 불린다. 레벨 전환이나 PIE 종료 시에는 호출되지 않는다. 중요한 정리 로직은 반드시 EndPlay에 넣어야 한다.

3-6. PIE에서 생성자 로그가 두 번 찍히는 이유

[에디터 실행 시]
CDO 생성자 실행 (1회) → CDO 로그

[PIE 시작 시]
에디터 월드 Actor를 게임 월드로 복제(Duplicate)
→ Construction Script 재실행 과정에서 생성자가 다시 호출 (2회) → PIE 인스턴스 로그
→ PostInitializeComponents → BeginPlay

정상 동작이다. CDO 생성(1회) + PIE 인스턴스 생성(1회) = 총 2회.

3-7. Tick

AMyActor::AMyActor()
{
    PrimaryActorTick.bCanEverTick = true;   // 활성화 (기본값 true)
    // PrimaryActorTick.bCanEverTick = false; // Tick 불필요 시 성능을 위해 끄기
}

void AMyActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    // DeltaTime: 직전 프레임부터 현재 프레임까지 걸린 시간(초)
}

Tick이 필요 없는 Actor는 반드시 bCanEverTick = false로 설정해야 한다. 타이머나 이벤트로 대체 가능한 로직은 Tick에서 빼는 것이 좋다.


4. Transform

4-1. Transform의 세 요소

속성타입의미
Location (위치)FVector월드에서 어느 지점에 있는가
Rotation (회전)FRotator어느 방향을 바라보는가 (도 단위)
Scale (크기)FVector원본 대비 배율 (1.0 = 원본)

4-2. FRotator — Pitch · Yaw · Roll

FRotator(Pitch, Yaw, Roll)

Pitch  →  Y축 기준 앞뒤 기울기 (고개를 끄덕이는 방향)
Yaw    →  Z축 기준 좌우 방향 전환 (나침반 방향) ← 게임에서 가장 자주 사용
Roll   →  X축 기준 좌우 기울기 (비행기가 날개를 기울이는 방향)

4-3. FQuat — 짐벌 락 문제와 해결

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 사용
  • 블루프린트에 FQuat는 노출되지 않음 (C++ 전용)

4-4. 월드 좌표계 vs 로컬 좌표계

월드 좌표계 (World Space)
→ 맵 전체를 기준으로 한 절대적인 좌표
→ SetActorLocation(), GetActorLocation()
→ AddActorWorldOffset(), AddActorWorldRotation()

로컬 좌표계 (Local Space)
→ Actor 자신 또는 부모 컴포넌트를 기준으로 한 상대적인 좌표
→ SetRelativeLocation(), GetRelativeLocation()
→ AddActorLocalRotation()

4-5. 주요 Transform 함수

// 위치
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);

5. Tick과 DeltaTime

5-1. DeltaTime이 필요한 이유

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이 해결하는 문제와 별개이며 하드웨어 성능 차이에서 온다.

5-2. FMath::IsNearlyZero — float 비교 주의사항

// ❌ float을 0과 직접 비교하면 안 됨
if (RotationSpeed == 0.f) { ... }
// 부동소수점 특성상 0을 정확히 표현하지 못할 수 있음

// ✅ FMath::IsNearlyZero 사용
if (!FMath::IsNearlyZero(RotationSpeed))
{
    AddActorLocalRotation(FRotator(0.f, RotationSpeed * DeltaTime, 0.f));
}

6. UE_LOG — 라이프사이클 확인 도구

6-1. 기본 사용법

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빨간색즉시 수정 필요

6-2. 커스텀 로그 카테고리

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가 폭주한다.


7. 전체 흐름 코드 예시

// 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에 붙는다. SetupAttachmentSetRootComponent는 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를 사용한다.

profile
기반이 되는 강의를 진행하고, Ai와 다시 한 번 탐구하고 정리하여 포스팅합니다. 목적 자체가 저의 학습을 위함이기 때문에, 시리즈 관련 포스팅은 규칙적으로 나오지 않을 수 있습니다. 양해 부탁드립니다. (_ _)

0개의 댓글