14 분 소요

액터와 서브시스템 아키텍쳐에 대하여 알아보자

김하연 튜터님의 Notion 자료를 바탕으로 강의를 들으며
수정 및 재작성한 블로깅용 글

1. Actor 생성/등록/초기화의 전체 흐름 ❤️‍🔥

1.1 Actor는 어떻게 태어나는가? - 세 가지 탄생 시나리오

시나리오 설명 특징
C++ SpawnActor 코드에서 직접 생성 런타임 동적 생성
Blueprint SpawnActor 블루프린트에서 생성 내부적으로 C++ SpawnActor 호출
Pre-placed Actor 레벨에 미리 배치 .umap에서 역직렬화로 생성
  • 에디터 드래그 시
    SpawnActor가 아닌, Level이 Load될 때 ‘활성화’ 되는 것!
// SpawnActor 기본 사용법
AMyActor* NewActor = GetWorld()->SpawnActor<AMyActor>(
    AMyActor::StaticClass(),
    SpawnLocation,
    SpawnRotation
);

⚠️ Pre-placed Actor는 레벨 로딩 중 생성되므로, 생성자에서 다른 Actor 참조 시 아직 로드 안 됐을 수 있음

  • 에디터에 ‘이미 배치된’ 것을 기반으로 로직을 짜는 방식은
    실제 패키징 된 게임에서 문제를 발생시킬 수 있음
    (이런 버그는 재현도 어려움)

1.2 SpawnActor의 내부 여행 - 한 걸음씩 따라가기

Step 1: StaticClass 준비

AMyActor::StaticClass()
  • 리플렉션 결과물UClass 객체를 가져옴
  • 메모리 크기, 생성자 정보 등 메타데이터 포함

Step 2: AllocateObject - 메모리 할당

UObject* NewObject = StaticAllocateObject(InClass, InOuter, InName, InFlags, ...);
  • InOuter: 나를 소유하는 상위 객체 (Actor는 보통 World나 Level)
  • Outer 체인이 ‘가비지 컬렉션’에서 중요한 역할

Step 3: Constructor 호출

AMyActor::AMyActor()
{
    PrimaryActorTick.bCanEverTick = true;

    // CreateDefaultSubobject는 생성자에서만 호출 가능!
    RootComponent = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
    MeshComp = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
    MeshComp->SetupAttachment(RootComponent);
}

⚠️ CreateDefaultSubobject는 CDO에 컴포넌트를 등록하므로 생성자에서만 호출 가능

이 시점의 컴포넌트는 아직 월드에 등록되지 않은 상태 (“태어나기 전의 태아”)

  • CDO 를 복사해서 인스턴스가 만들어진 것은 지난 시간에 배운 내용
    (UObject 기반의 복사 루틴을 따름)

  • 해당 내용은 CDO에 Component라는 새로운 요소를 추가
    즉, 설계도에 내용을 더해주는 내용임

  • 코드 에만 존재하고, 메모리에 올라온 것은 아님!
    (생성자에선 기본적인 설정만 해야하는것을 권장하는 이유)

Step 4: PostInitProperties - 프로퍼티 초기화 완료

void AMyActor::PostInitProperties()
{
    Super::PostInitProperties();
    // 모든 UPROPERTY가 기본값으로 초기화된 상태
    // 아직 Actor는 월드에 없음 (출생신고 전)
}
  • 메모리에 올라옴
  • 월드 등과 연관은 아직 없음

Step 5: PreRegisterAllComponents - 등록 준비

  • 컴포넌트 목록 정리 단계

Step 6: RegisterComponent - 진짜 중요한 순간!

void UActorComponent::RegisterComponent()
{
    // 월드에 등록
    // Physics Scene에 등록 (충돌 컴포넌트)
    // Render Scene에 등록 (렌더링 컴포넌트)
    // Tick 함수 등록
}

RegisterComponent가 하는 일:

등록 대상 효과
물리 엔진 충돌 검사 가능
렌더링 시스템 화면에 표시
Tick 시스템 매 프레임 업데이트

⚠️ RegisterComponent 없이는 컴포넌트가 유령 상태 - 존재하지만 아무 일도 안 함

  • World 에 일부가 되는 순간임
    • 5.0의 카오스 물리 엔진에 등록
    • 렌더링에 등록하여 ‘그릴 수 있도록 함’
    • Tick 함수가 있다면 등록하여 매 프레임 호출
// 잘못된 예 - RegisterComponent 누락
UStaticMeshComponent* NewMesh = NewObject<UStaticMeshComponent>(this);
NewMesh->SetStaticMesh(SomeMesh);
NewMesh->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepRelativeTransform);
// 메시가 안 보임!

// 올바른 예
UStaticMeshComponent* NewMesh = NewObject<UStaticMeshComponent>(this);
NewMesh->SetStaticMesh(SomeMesh);
NewMesh->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepRelativeTransform);
NewMesh->RegisterComponent();  // 필수!

Step 7: PostActorCreated - Actor 생성 완료 알림

void AMyActor::PostActorCreated()
{
    Super::PostActorCreated();
    // Actor 완전히 생성, 컴포넌트도 등록됨
    // 하지만 아직 BeginPlay 전!
}
  • begin play 이전이란 점 유의

Step 8: OnConstruction - 컨스트럭션 스크립트

void AMyActor::OnConstruction(const FTransform& Transform)
{
    Super::OnConstruction(Transform);
    // 절차적 메시 생성 등
}

Constructor vs OnConstruction:

구분 Constructor OnConstruction
호출 시점 메모리 할당 직후 컴포넌트 등록 후
호출 횟수 객체당 1번 에디터 수정 시마다
Transform 접근 NO YES
주 용도 기본 컴포넌트 생성 절차적 생성, 에디터 미리보기
  • 에디터 수정에 유용하므로 알아 두기

1.3 생명주기 흐름 정리

1. StaticClass() - 클래스 메타정보 준비
       ↓
2. AllocateObject - 메모리 할당
       ↓
3. Constructor - 생성자 호출 (CreateDefaultSubobject)
       ↓
4. PostInitProperties - 프로퍼티 초기화 완료
       ↓
5. PreRegisterAllComponents - 등록 준비
       ↓
6. RegisterComponent (각 컴포넌트마다) - 월드에 등록!
       ↓
7. PostActorCreated - 생성 완료 알림
       ↓
8. OnConstruction - 컨스트럭션 스크립트
       ↓
   (아직 BeginPlay 아님!)
  • 에디터에서 레벨을 열었을 때 배치된 Actor들은 여기까지만 실행된 상태

2. BeginPlay의 정확한 호출 조건 🍷

2.1 BeginPlay는 생성 직후에 안 온다!

BeginPlay 호출 조건 두 가지:

  1. Actor가 생성되고 등록 완료
  2. World가 Play 상태
// 엔진 내부 동작 (개념)
void UWorld::BeginPlay()
{
    for (AActor* Actor : AllActors)
    {
        if (!Actor->HasBegunPlay())
            Actor->BeginPlay();
    }
}

레벨에 미리 배치된 Actor 100개 → 게임 시작 시 100개 모두 BeginPlay 호출 (거의 동시에)

  • 에디터에 배치하여도 ‘시작’하지 않으면 호출 안됨
  • Beginplay가 매우 많이 (동시에) 호출되는 상황이기에
    Beginplay에 각종 초기화를 ‘너무’ 몰아두면, 생각치 못한 버그 가 나올 수 있으므로 주의
    • UX 경험 저하 부터, 간헐적 크래시 등
    • Delegate, Lazy Load 등의 대체 방식 고려 가능

2.2 동적 스폰 Actor의 BeginPlay

// 게임 플레이 중 실행하면
AMyActor* NewActor = GetWorld()->SpawnActor<AMyActor>(...);
// SpawnActor 내부에서 BeginPlay까지 호출되고 리턴
// 이 시점에서 NewActor->HasBegunPlay() == true
  • SpawnActor 시, Beginplay 보통 호출됨
    • 다만 SpawnActor를 사용하는 Beginplay 내부에서
      다시 SpawnActor를 사용하면, 순서가 엉망이 될 수 있음
      (현재 SpawnActor -> BeginPlay -> Spawn Actor 시,
      새로 생성되는 녀석의 Beginplay 부분이 끼어들 가능성이 있다?)

2.3 BeginPlay 호출의 내부 조건

bool CanBeginPlay()
{
    if (bHasBegunPlay) return false;         // 중복 호출 방지
    if (IsPendingKill()) return false;       // Actor가 valid해야 함
    if (!GetWorld()->HasBegunPlay()) return false;  // World가 Play 상태
    if (!bActorInitialized) return false;    // 완전히 초기화됨
    return true;
}

💡 BeginPlay는 ‘딱 한 번’만 호출됨 (bHasBegunPlay 플래그)

2.4 왜 BeginPlay에서 초기화해야 하나?

Constructor 대신 BeginPlay에서 초기화해야 하는 이유가 세 가지

이유 1: 다른 Actor 참조

Constructor 시점에는 다른 Actor들이 아직 생성되지 않았을 수 있음.

// ❌ Constructor에서 다른 Actor 찾기
AMyActor::AMyActor()
{
    // TargetActor가 아직 로드 안 됐을 수 있음
    TargetActor = UGameplayStatics::GetActorOfClass(GetWorld(), ATargetActor::StaticClass());
}

// ✅ BeginPlay에서 다른 Actor 찾기
void AMyActor::BeginPlay()
{
    Super::BeginPlay();
    // 이 시점에는 레벨의 모든 Actor가 생성 완료됨
    TargetActor = UGameplayStatics::GetActorOfClass(GetWorld(), ATargetActor::StaticClass());
}

레벨에 배치된 Actor들의 로딩 순서는 보장되지 않음.

에디터에서는 정상 동작하다가 패키지 빌드에서 크래시가 나는 원인

  • 생성자에선 다른 Actor가 메모리에서 로딩 되지 않을 수 있음
    (에디터에선 메모리에 잘 올라와 있으니 될수도 있으나, 패키징 시 위험)

이유 2: 델리게이트 바인딩

// ❌ Constructor에서 델리게이트 바인딩
AMyActor::AMyActor()
{
    // GameMode가 아직 없을 수 있음
    if (AGameMode* GM = UGameplayStatics::GetGameMode(this))
    {
        GM->OnGameStart.AddDynamic(this, &AMyActor::HandleGameStart);
    }
}

// ✅ BeginPlay/EndPlay 짝으로 바인딩
void AMyActor::BeginPlay()
{
    Super::BeginPlay();
    if (AGameMode* GM = Cast<AGameMode>(UGameplayStatics::GetGameMode(this)))
    {
        GM->OnGameStart.AddDynamic(this, &AMyActor::HandleGameStart);
    }
}

void AMyActor::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    if (GameMode)
    {
        GameMode->OnGameStart.RemoveDynamic(this, &AMyActor::HandleGameStart);
    }
    Super::EndPlay(EndPlayReason);
}

BeginPlay/EndPlay 짝으로 바인딩하면 메모리 누수와 댕글링 포인터 문제를 예방할 수 있음.

이유 3: 레벨 재시작 문제

레벨 재시작 시 CDO에서 값을 복사해오는 방식으로 리셋되면
Constructor가 다시 호출되지 않을 수 있음. 반면 BeginPlay는 레벨 시작 시 항상 호출!

// Constructor에서 점수 초기화 - 레벨 재시작 시 리셋 안 될 수 있음
AMyPlayerState::AMyPlayerState()
{
    Score = 0;
}

// BeginPlay에서 점수 초기화 - 레벨 시작마다 확실히 리셋
void AMyPlayerState::BeginPlay()
{
    Super::BeginPlay();
    Score = 0;
}

정리

초기화 위치 용도
Constructor CreateDefaultSubobject, Tick 설정, 기본값
BeginPlay 다른 Actor 참조, 델리게이트 바인딩, 게임 시작 초기화

3. Tick과 RegisterComponent의 관계 ⚡

3.1 Tick이 돌아가려면 뭐가 필요한가?

조건 1: bCanEverTick = true

AMyActor::AMyActor()
{
    PrimaryActorTick.bCanEverTick = true;  // 기본값 false!
}
  • 성능 때문에 보통 꺼두는 편

조건 2: RegisterComponent 완료

  • World에 등록해야 함

조건 3: 활성화 상태

// Actor 레벨
SetActorTickEnabled(true);
SetActorTickEnabled(false);

// Component 레벨
MyComponent->SetComponentTickEnabled(true);
MyComponent->SetComponentTickEnabled(false);

3.2 Tick 등록의 내부 구조

// Tick Manager 개념
class FTickTaskManager
{
    TArray<FTickFunction*> AllTickFunctions;

    void RunTick(float DeltaTime)
    {
        for (FTickFunction* TickFunc : AllTickFunctions)
        {
            if (TickFunc->IsTickEnabled())
                TickFunc->ExecuteTick(DeltaTime);
        }
    }
};

RegisterComponent → Tick Manager에 등록 → Tick 호출됨

  • Tick 매니저를 통해, 등록된 녀석을 싹다 돌려줌

3.3 동적 컴포넌트 추가 패턴

void AMyActor::AddDynamicMeshComponent()
{
    // 1. NewObject로 생성 (CreateDefaultSubobject 아님!)
    UStaticMeshComponent* NewMesh = NewObject<UStaticMeshComponent>(this, TEXT("DynamicMesh"));

    // 2. 설정
    NewMesh->SetStaticMesh(SomeMesh);
    NewMesh->SetRelativeLocation(FVector(100, 0, 0));

    // 3. Attach (Register 전에!)
    NewMesh->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepRelativeTransform);

    // 4. Register 필수!
    NewMesh->RegisterComponent();
}

⚠️ AttachToComponent를 RegisterComponent 전에 해야 함 (Register 시점에 Transform 확정)

  • 런타임에 컴포넌트 추가 시
    • Register를 ‘마지막’에 ‘반드시’ 호출하기
    • Register시 World 좌표가 확정됨
      (부모가 없는 상태로 등록되어서, Relative가 World 기준으로 작동하게 됨)

3.4 컴포넌트 제거 패턴

void AMyActor::RemoveDynamicMeshComponent()
{
    if (DynamicMesh)
    {
        DynamicMesh->UnregisterComponent();  // 1. 월드에서 제거
        DynamicMesh->DestroyComponent();     // 2. 메모리 해제
        DynamicMesh = nullptr;               // 3. 포인터 정리
    }
}
  • 순서 주의하기!
    • World 등록 해제 -> 메모리 해제 -> 포인터 정리

3.5 Tick 순서 제어

AMyActor::AMyActor()
{
    PrimaryActorTick.bCanEverTick = true;

    // Tick 그룹 설정
    PrimaryActorTick.TickGroup = TG_PrePhysics;

    // 특정 Actor 다음에 Tick (의존성 지정하기)
    PrimaryActorTick.AddPrerequisite(OtherActor, OtherActor->PrimaryActorTick);
}
TickGroup 시점 용도
TG_PrePhysics 물리 시뮬레이션 전 캐릭터 이동
TG_DuringPhysics 물리 시뮬레이션 중 -
TG_PostPhysics 물리 시뮬레이션 후 물리 결과 반영
  • AddPrerequisite 가 좀 까다로움
    • 해당 액터가 유효해야 함
    • 순환 의존성 주의하기
    • 어쩔수 없을때만 고려

4. 네트워크 환경에서의 Actor Lifecycle 🧸

4.1 네트워크의 기본: 역할(Role)의 이해

ENetRole Role = GetLocalRole();
Role 설명 예시
ROLE_Authority 진짜 주인 서버의 모든 Actor
ROLE_AutonomousProxy 로컬 플레이어가 조종 내 클라이언트의 내 캐릭터
ROLE_SimulatedProxy 남이 조종 내 클라이언트의 다른 플레이어 캐릭터
ROLE_None 복제 안 됨 -

예시: 플레이어 A, B가 있는 게임

위치 A의 캐릭터 B의 캐릭터
서버 Authority Authority
A 클라이언트 AutonomousProxy SimulatedProxy
B 클라이언트 SimulatedProxy AutonomousProxy

4.2 역할에 따라 BeginPlay가 다르게 동작한다

void AMyCharacter::BeginPlay()
{
    Super::BeginPlay();

    // 서버에서만 (Authority)
    if (HasAuthority())
    {
        InitializeServerLogic();
    }

    // 로컬 플레이어만 (AutonomousProxy)
    if (IsLocallyControlled())
    {
        SetupCamera();
        BindInput();
        CreateHUD();
    }

    // 모든 곳에서
    InitializeVisuals();
}
  • 필요에 따라
    초기화를 다르게

4.3 SpawnActor는 서버에서만!

void AMyGameMode::SpawnMonster()
{
    // GameMode는 서버에만 존재
    AMonster* Monster = GetWorld()->SpawnActor<AMonster>(MonsterClass, SpawnLocation);
    // 서버에서 스폰 → 자동으로 모든 클라이언트에 복제
}

⚠️ 클라이언트에서 SpawnActor하면 그 클라이언트에만 존재하는 “가짜” Actor

  • 클라에서 Spawn하면 사실상 ‘클라’에서만 보이고
    서버에선 ‘없는 녀석’

4.4 Replication Graph의 생명주기 관리

기존 복제 시스템:

  • 매 프레임 모든 Actor 순회 → 비효율적 (1000 Actor × 100 플레이어 = 10만 번 판단)

Replication Graph: Actor를 노드로 그룹화

ReplicationGraph
├── GridSpatialization2D (위치 기반)
│   ├── Cell[0,0]: Actor1, Actor2, Actor3
│   ├── Cell[0,1]: Actor4, Actor5
│   └── ...
├── AlwaysRelevant (항상 복제)
│   └── GameState, PlayerStates...
└── PlayerStateFrequencyLimiter
    └── ...

멱등성(Idempotency)

  • 같은 Actor는 항상 같은 방식으로 처리
  • 같은 위치면 항상 같은 Node

4.5 Relevancy: 누구한테 뭘 보내줄까?

bool AActor::IsNetRelevantFor(const AActor* RealViewer, const AActor* ViewTarget,
                              const FVector& SrcLocation) const
{
    // 기본: 거리 기반
    float DistSq = (GetActorLocation() - SrcLocation).SizeSquared();
    return DistSq < NetCullDistanceSquared;
}

// 커스터마이즈 예시
bool AMyActor::IsNetRelevantFor(...) const
{
    // 팀원에게는 거리 상관없이 항상 복제
    if (IsTeammate(RealViewer))
        return true;
    return Super::IsNetRelevantFor(RealViewer, ViewTarget, SrcLocation);
}
  • 연관성
    • 거리에 따라 Replicate 빈도를 줄이는 등의 처리가 가능
    • 대역폭을 아끼면서 UX 경험을 개선 가능

4.6 Dormancy: 잠자는 Actor

변하지 않는 Actor는 휴면 상태로:

AMyTree::AMyTree()
{
    NetDormancy = DORM_Initial;  // 바로 휴면
}

void AMyTree::OnDamaged()
{
    FlushNetDormancy();  // 깨우기
    Health -= 10;        // 이제 변경사항 복제됨
}

4.7 TearOff: 빠잉

서버와의 연결을 끊음 (래그돌 등):

void AMyCharacter::Die()
{
    if (HasAuthority())
    {
        TearOff();  // 더 이상 복제 안 됨
    }
    GetMesh()->SetSimulatePhysics(true);  // 각 클라이언트에서 독립적으로 시뮬
}

5. Subsystem Architecture 😱

5.1 Subsystem이 뭔가요?

문제 상황

  • SaveManager를 Actor로? → 레벨 바뀌면 파괴됨
  • GameInstance에 다 넣으면? → 코드가 뚱뚱해짐

Subsystem: 특정 “범위(Scope)”에 바인딩된 싱글톤 객체

  • 언리얼이 관리해주는 ‘싱글톤’에 가까움
    • 특정 타이밍과 연관되기에 생명주기만 판단하면 잘 이용 가능

5.2 Subsystem의 종류

1. UEngineSubsystem

class UMyEngineSubsystem : public UEngineSubsystem
{
    // 엔진 시작 ~ 엔진 종료
    // 에디터에서도 존재
};
  • 그렇게 쓸일이 많진 않음
    (엔진 급의 전역)

2. UEditorSubsystem

class UMyEditorSubsystem : public UEditorSubsystem
{
    // 에디터 시작 ~ 에디터 종료
    // 패키지 게임에는 없음
};
  • 게임 로직에선 쓰지 말것!

3. UGameInstanceSubsystem

class UMyGameInstanceSubsystem : public UGameInstanceSubsystem
{
    // 게임 시작 ~ 게임 종료
    // 레벨 바뀌어도 살아있음!
};
  • 게임 Instance와 수명이 같기에 범용적으로 사용 가능
    • Save Data, Inventory 등등

4. UWorldSubsystem

class UMyWorldSubsystem : public UWorldSubsystem
{
    // World 생성 ~ World 파괴
    // 레벨별로 다른 인스턴스
};
  • World 와 수명이 같음
    • 보통 GameManager 같은 한 레벨에서
      보조하는 역할의 로직 매니저 등에 고려 가능함
      (ex : WaveManager)
  • 생성 자체는 서버/ 클라 양쪽에서 생성되지만
    별도로 Replicate 같은 동기화는 되지 않음
    (자동 동기화 x)
    • 정말 클라와의 동기화가 필요한 경우는 RPC 함수 등을 고려할 것
    • 게임 로직은 기본적으로 서버에서 동작하므로, 그 부분을 잘 염두해두자

5. ULocalPlayerSubsystem

class UMyLocalPlayerSubsystem : public ULocalPlayerSubsystem
{
    // LocalPlayer 생성 ~ LocalPlayer 제거
    // 분할화면이면 플레이어마다 별도 인스턴스
};
  • Local 에서 사용 가능
    UI용 매니저 or 입력, 설정 등에 활용 가능
    • 확장성을 고려하지 않는다면 GameInstance 쪽에 넣는 것도… 가능은 함

5.3 생명주기 비교표

Subsystem 생성 파괴 용도
EngineSubsystem 엔진 시작 엔진 종료 전역 서비스, Config
EditorSubsystem 에디터 시작 에디터 종료 에디터 도구
GameInstanceSubsystem 게임 시작 게임 종료 세이브, 업적, 매치 데이터
WorldSubsystem World 생성 World 파괴 웨이브, AI 매니저
LocalPlayerSubsystem 플레이어 추가 플레이어 제거 UI, 입력, 설정
  • GameInstance 와 World 서브 시스템을 가장 많이 사용

5.4 Subsystem 만들기

// MyGameInstanceSubsystem.h
UCLASS()
class UMyGameInstanceSubsystem : public UGameInstanceSubsystem
{
    GENERATED_BODY()

public:
    virtual void Initialize(FSubsystemCollectionBase& Collection) override;
    virtual void Deinitialize() override;

    void SaveGame();
    void LoadGame();

private:
    UPROPERTY()
    USaveGame* CurrentSaveGame;
};

// MyGameInstanceSubsystem.cpp
void UMyGameInstanceSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
    Super::Initialize(Collection);
    UE_LOG(LogTemp, Log, TEXT("Save System Initialized!"));
    CurrentSaveGame = nullptr;
}

void UMyGameInstanceSubsystem::Deinitialize()
{
    UE_LOG(LogTemp, Log, TEXT("Save System Shutting Down!"));
    Super::Deinitialize();
}
  • Initialize/Deinitialize 가 Beginplay / Endplay 를 대체하는 편
    초기화/할당 해제 를 이 타이밍에 가능함

5.5 Subsystem 사용하기

// GameInstanceSubsystem
UMySaveSubsystem* SaveSys = GetGameInstance()->GetSubsystem<UMySaveSubsystem>();
SaveSys->SaveGame();

// WorldSubsystem
UMyWaveSubsystem* WaveSys = GetWorld()->GetSubsystem<UMyWaveSubsystem>();
WaveSys->StartNextWave();

// LocalPlayerSubsystem
UMyHUDSubsystem* HUDSys = GetLocalPlayer()->GetSubsystem<UMyHUDSubsystem>();
HUDSys->ShowPauseMenu();
  • 전역적으로 가져다 사용이 가능!

5.6 Subsystem 오용 사례

오용 1: Actor가 해야 할 일을 Subsystem에

// 나쁜 예
class UCharacterMovementSubsystem : public UWorldSubsystem
{
    void MoveCharacter(ACharacter* Char, FVector Direction);
};

// 좋은 예 - Character가 직접 처리
class AMyCharacter : public ACharacter
{
    void MoveCharacter(FVector Direction);
};
  • 객체가 자체적으로 할 수 있는 일이라면
    전역 매니저가 일을 대신할 필요는 없음
    • 전역 매니저를 만들지 않는 가능성도 열어둘 것

오용 2: GameMode에 모든 걸 넣기

// 나쁜 예 - 3000줄짜리 괴물
class AMyGameMode : public AGameModeBase
{
    void SaveGame();
    void LoadGame();
    void UnlockAchievement();
    void FindMatch();
    // ... 100개 더
};

// 좋은 예 - 분리
class AMyGameMode : public AGameModeBase
{
    // GameMode 본연의 역할만
    virtual void PostLogin(APlayerController* NewPlayer) override;
};

class USaveGameSubsystem : public UGameInstanceSubsystem { ... };
class UAchievementSubsystem : public UGameInstanceSubsystem { ... };
class UMatchmakingSubsystem : public UGameInstanceSubsystem { ... };
  • GameMode에 모든 걸 넣으면 GodClass..
  • 다른 서브 시스템과 같이 사용하여
    책임을 나누기

오용 3: Subsystem에서 Tick 남용

// 나쁜 예
class UMyWorldSubsystem : public UWorldSubsystem, public FTickableGameObject
{
    virtual void Tick(float DeltaTime) override
    {
        for (AActor* Actor : AllActors)
            ProcessActor(Actor);  // 매 프레임 무거운 로직
    }
};

💡 Subsystem은 “요청이 왔을 때” 일하는 게 맞음. 매 프레임 로직은 Actor에.

  • 매 프레임 로직이 있다면
    Subsystem이 아니라 다른 방식을 고려해보자
    • ex) 매프레임마다 서버로 정보 보내기 등의 로직이라면 가능할수도 있긴 함

6. 실제 게임 구조 설계 예시 🥴

6.1 각 시스템의 역할 분담

Actor의 역할

AMyCharacter : 이동, 공격, 데미지 처리, 애니메이션
AWeapon : 발사, 재장전, 데미지 계산
AMonster : AI 행동, 어그로, 스킬 사용
APickupItem : 상호작용, 획득 효과
ADoor : 열기/닫기, 잠금 상태

GameMode의 역할

AMyGameMode
├── 플레이어 스폰 위치 결정
├── 게임 시작/종료 조건
├── 승패 판정
└──  배정
  • 매칭과 승패 규칙 등 본래의 역할만 해주기!
    • 여러가지 넣기 좋기에 크기가 커지기 쉬우니 주의하자

GameState의 역할

AMyGameState
├── 현재 라운드
├── 남은 시간
├──  팀의 점수
└── 매치 단계 (대기중/진행중/종료)
  • 데이터 위주로 넣어야 함!
    • 전광판 느낌을 잊지 말자

GameInstanceSubsystem의 역할

USaveGameSubsystem
├── 세이브 파일 관리
├── 자동 저장
└── 로드

UPlayerProgressSubsystem
├── 언락된 아이템
├── 플레이어 레벨
└── 업적 상태

UMatchmakingSubsystem
├── 서버 검색
├── 로비 관리
└── 매치 참가/생성

WorldSubsystem의 역할

UWaveSpawnSubsystem
├── 현재 웨이브 번호
├── 스폰 스케줄
└── 몬스터  관리

UEnvironmentSubsystem
├── 날씨 시스템
├── / 주기
└── 환경 이벤트

UAIManagerSubsystem
├── AI 풀링
├── 네비게이션 쿼리 캐싱
└── 전역 AI 상태

LocalPlayerSubsystem의 역할

UHUDSubsystem
├── UI 위젯 관리
├── 알림 시스템
└── 미니맵

UInputSubsystem
├──  바인딩
├── 입력 모드 전환
└── 게임패드/키보드 전환

USettingsSubsystem
├── 그래픽 설정
├── 오디오 설정
└── 조작 설정

6.2 실제 흐름 예시: 몬스터 웨이브 시스템

[게임 시작]
    │
    ▼
UWaveSpawnSubsystem::Initialize()
    │ - 웨이브 데이터 로드
    │ - 스폰 포인트 캐싱
    │
    ▼
[웨이브 시작 트리거]
    │
    ▼
UWaveSpawnSubsystem::StartWave(WaveIndex)
    │
    ├─▶ 스폰 스케줄 생성
    │
    └─▶ 타이머로 SpawnMonster() 호출
            │
            ▼
        GetWorld()->SpawnActor<AMonster>(...)
            │
            ├─▶ [서버에서]
            │       AMonster::Constructor
            │           ↓
            │       RegisterComponent
            │           ↓
            │       AMonster::BeginPlay
            │           ↓
            │       [Replication] → 클라이언트로 복제
            │
            └─▶ [각 클라이언트에서]
                    AMonster 생성 (복제)
                        ↓
                    AMonster::BeginPlay

6.3 흔한 설계 실수와 해결책

실수 1: 순환 의존성

// 나쁜 예: A가 B를 알고, B도 A를 알음
class AMonster
{
    UWaveSubsystem* WaveSystem;
};

class UWaveSubsystem
{
    TArray<AMonster*> Monsters;
};

// 좋은 예: 델리게이트로 느슨한 결합
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnMonsterDied, AMonster*, Monster);

class AMonster
{
public:
    FOnMonsterDied OnDied;
    void Die() { OnDied.Broadcast(this); }
};

class UWaveSubsystem
{
    void OnMonsterSpawned(AMonster* Monster)
    {
        Monster->OnDied.AddDynamic(this, &UWaveSubsystem::HandleMonsterDied);
    }

    void HandleMonsterDied(AMonster* Monster)
    {
        AliveCount--;
        CheckWaveComplete();
    }
};
  • 하나 건들면 다 건드려야 하는 방식은…
    유지보수가 나쁨

  • 서로를 알아야 하는지 반드시 확인하고
    아니라면 Delegate 등의 사용 방식을 고려하자

실수 2: 잘못된 생명주기 선택

// 잘못된 예: 세이브 시스템을 WorldSubsystem으로
class USaveSubsystem : public UWorldSubsystem
{
    // 레벨 바뀌면 사라짐! 세이브 데이터도 손실!
};

// 좋은 예: GameInstanceSubsystem 사용
class USaveSubsystem : public UGameInstanceSubsystem
{
    // 게임 내내 유지됨
};

6.4 WorldSubsystem으로 Actor 스포너 만들기

Step 1: Subsystem 클래스 생성

// SpawnManagerSubsystem.h
UCLASS()
class USpawnManagerSubsystem : public UWorldSubsystem
{
    GENERATED_BODY()

public:
    virtual void Initialize(FSubsystemCollectionBase& Collection) override;
    virtual void Deinitialize() override;

    void SpawnTestActor();

private:
    UPROPERTY()
    TSubclassOf<AActor> TestActorClass;
};

// SpawnManagerSubsystem.cpp
void USpawnManagerSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
    Super::Initialize(Collection);
    UE_LOG(LogTemp, Warning, TEXT("=== SpawnManagerSubsystem Initialize ==="));
}

void USpawnManagerSubsystem::Deinitialize()
{
    UE_LOG(LogTemp, Warning, TEXT("=== SpawnManagerSubsystem Deinitialize ==="));
    Super::Deinitialize();
}

void USpawnManagerSubsystem::SpawnTestActor()
{
    UWorld* World = GetWorld();
    if (World)
    {
        FVector Location(0, 0, 100);
        FRotator Rotation(0, 0, 0);

        AActor* SpawnedActor = World->SpawnActor<AStaticMeshActor>(Location, Rotation);

        UE_LOG(LogTemp, Warning, TEXT("Actor Spawned! HasBegunPlay: %s"),
            SpawnedActor->HasActorBegunPlay() ? TEXT("True") : TEXT("False"));
    }
}

Step 2: 테스트 Actor에 로그 추가

ATestActor::ATestActor()
{
    UE_LOG(LogTemp, Warning, TEXT("[TestActor] Constructor"));

    RootComponent = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
    MeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
    MeshComponent->SetupAttachment(RootComponent);
}

void ATestActor::PostInitProperties()
{
    Super::PostInitProperties();
    UE_LOG(LogTemp, Warning, TEXT("[TestActor] PostInitProperties"));
}

void ATestActor::PostActorCreated()
{
    Super::PostActorCreated();
    UE_LOG(LogTemp, Warning, TEXT("[TestActor] PostActorCreated"));
}

void ATestActor::BeginPlay()
{
    Super::BeginPlay();
    UE_LOG(LogTemp, Warning, TEXT("[TestActor] BeginPlay"));
}

Step 3: 결과 확인

[TestActor] Constructor
[TestActor] PostInitProperties
[TestActor] PostActorCreated
[TestActor] BeginPlay
Actor Spawned! HasBegunPlay: True

댓글남기기