나만의 작은 도서관

[TIL][C++] 251110 MMO 서버 개발 133일차: [언리얼] SpawnActor에 따른 BeginPlay() 호출 시점, BP의 BeginPlay가 C++ BeginPlay보다 먼저 호출되는 이유 본문

Today I Learn

[TIL][C++] 251110 MMO 서버 개발 133일차: [언리얼] SpawnActor에 따른 BeginPlay() 호출 시점, BP의 BeginPlay가 C++ BeginPlay보다 먼저 호출되는 이유

pledge24 2025. 11. 11. 01:37
주의사항: 해당 글은 일기와 같은 기록용으로, 다듬지 않은 날것 그대로인 글입니다. 

[언리얼] SpawnActor에 따른 BeginPlay() 호출 시점

  • 이전에도 한 번 다룬 적이 있는데, 새롭게 알게 되거나 정리된 내용이 많이 생겨서 다시 다루려고 한다.
  • SpawnActor의 경우, 시점에 따라 BeginPlay()가 호출되는 타이밍이 다르다. 거두절미하고 결과만 이야기하자면 아래와 같다.

 

BeginPlay() 호출 시점

  • 게임 플레이 전(Ex. LoadMap()을 호출한 경우)
    • SpawnActor() 함수의 반환 시점에 BeginPlay()가 호출되지 않고, 레벨이 시작될 때 AActor::BeginPlay()가 호출된다.
  • 게임 플레이 중(Ex. 레벨이 열려있는 상황)
    • SpawnActor() 함수가 스폰한 액터는 즉시 BeginPlay()까지 호출한다. (이에 대해서 이야기가 좀 갈린다. 어디선 SpawnActor를 호출한 프레임의 끝 또는 다음 프레임 시작 부분에 BeginPlay()가 스폰한 액터들이 한 번에 호출된다고도 한다. 그러면서도 런타임엔 그 즉시 실행되는 것처럼 보인다나 뭐라나… 암튼 애매한 부분이 많아서 그냥 즉시 호출한다고 생각하는 게 편하다)

 

 

다시 다루는 직후 초기화

  • 이 이야기를 다시 꺼내게 된 사유는 SpawnActor() 함수 호출 직후에 내부 데이터를 초기화하는 경우, 의도된 순서대로 작동하지 않았기 때문이다. 저번에도 이 방식이 잘못되었다는 것을 알았지만 이번에 한 번 더하면서 확실해졌다. 상황은 아래와 같다.
OutMonster = GetWorld()->SpawnActor<AMonster>(MonsterBPClass, Location, Rotation);
if(!OutMonster)
	OutMonster->SetDefaultMonsterData(MonsterData);
  • SpawnActor다음에 반환된 액터를 초기화하는 코드이다. 결론적으로 이 코드가 잘못된 것은 대부분의 경우에서 AActor::BeginPlay()가 초기화 함수(여기선 SetDefault 어쩌고…)보다 먼저 호출된다는 것이다. 이는 비단 C++ 뿐만 아니라 블루프린트에서도 똑같이 적용된다. 즉, 결론은 아래와 같다.

 

게임 플레이 중 SpawnActor로 스폰한 액터의 BeginPlay는 다음 코드 라인(C++), 연결된 다음 노드(BP)보다 먼저 호출된다

 

 

 


[언리얼] BP의 BeginPlay가 C++ BeginPlay보다 먼저 호출되는 이유

  • SpawnActor호출 시 BeginPlay가 언제 호출되는지 알아보려고 Log를 찍어보던 도중, 이상한 부분이 발견되었다.
LogBlueprintUserMessages: [BP_Beginner_RangedMinion_C_0] 5
LogTemp: ACreature::BeginPlay TestNum: 6
LogTemp: AMonster::BeginPlay TestNum: 7
LogTemp: AMonster::SetDefaultMonsterData TestNum: 8
  • 해당 로그는 몬스터 스폰 시 각 BeginPlay마다 찍히는 것으로, 각 UE_LOG마다 ++TestNum를 넣어 출력마다 1씩 TestNum이 증가하도록 만든 것이다. 이에 따라 BeginPlay 호출 순서가 아래와 같다는 것을 알 수 있다.
1. BP::BeginPlay() 호출
2. ACreature::BeginPlay() 호출
3. AMonster::BeginPlay() 호출
4. Monster->SetDefaultMonsterData() 호출 // SpawnActor 다음 코드
  • SetDefaultMonsterData에 대해서는 위에서 다뤘으니 위치가 이상하지 않다는 것을 알게 되었는데, BP의 BeginPlay가 가장 먼저 호출되는 것은 이해가 가지 않았다. 최하위 클래스인 BP의 BeginPlay가 가장 먼저 호출되었으니 말이다. ‘이러면 안 되는 게 아닌가?‘ 싶었다.

 

 

결론: 이게 정상적인 출력이었다.

  • 나는 당연히 BP의 BeginPlay가 C++의 BeginPlay를 오버라이드한다고 생각했다. 하지만 그렇지 않았다. AActor에 가보면 이 둘은 서로 다른 거였다.
// AActor.h의 일부
protected:
	/** Event when play begins for this actor. */
	UFUNCTION(BlueprintImplementableEvent, meta=(DisplayName = "BeginPlay"))
	ENGINE_API void ReceiveBeginPlay();

	/** Overridable native event for when play begins for this actor. */
	ENGINE_API virtual void BeginPlay();
  • 위 코드는 AActor.h의 일부이다. ReceiveBeginPlay가 뭔가 싶겠지만 이게 바로 BP를 만들면 Event Graph에 기본으로 있는 세 이벤트 중 하나인 BeginPlay 이벤트이다. 그리고 우리가 C++에서 BeginPlay를 정의할 때 사용하는 함수는 그 아래에 있는 함수이다. 즉, 정리하자면 아래와 같다.
    • ReceiveBeginPlay(): BP의 BeginPlay 이벤트
    • BeginPlay(): C++의 BeginPlay 함수
  • 결국 BP에서 사용하는 BeginPlay와 C++에서 사용하는 BeginPlay는 서로 다른 것이다! 그리고 이 BP의 BeginPlay(ReceiveBeginPlay 함수)는 AActor::BeginPlay 안에서 아래와 같이 마지막 부분에서 호출된다.
void AActor::BeginPlay()
{
	// 많고 많은 코드들이 지나고...

	ReceiveBeginPlay();

	ActorHasBegunPlay = EActorBeginPlayState::HasBegunPlay;
}
  • 이렇게 AActor의 BeginPlay에서 BP의 BeginPlay 이벤트를 호출하는 구조 때문에 BP가 최하위 클래스임에도 불구하고 AActor의 하위 C++ 클래스들보다 먼저 호출되는 것이었다.

 

 

왜 이렇게 만들었을까?

  • 사실 잘 모르겠다. 언리얼 엔진의 설계 결정이라는데, 정확한 이유를 알고 있는 사람이 잘 없는듯하다. 그래도 추측해 보자면, 언리얼이 블루프린트 사용자에게 우선권을 주고 싶었기 때문이 아닐까 싶다.