UE 옵저버 패턴과 BlueprintNativeEvent 규칙
Tick 폴링을 델리게이트 push로 뒤집기
아이템이 플레이어 상태를 아는 가장 쉬운 방법은 매 프레임 확인이고, 그게 가장 비싸다. 아이템이 스스로 구독을 거는 구조와 push·pull 선택 기준, _Implementation과 Execute_ 구분.
아이템이 플레이어 상태를 알아야 할 때 가장 쉬운 방법은 Tick에서 매 프레임 확인하는 것이다. 그리고 그게 가장 비싼 방법이기도 하다. 이 글에서는 그 확인을 관찰자 쪽 Tick에서 통지자 쪽 델리게이트로 뒤집는 과정을 이야기하려 한다 — 아이템이 스스로 구독을 걸게 만드는 옵저버 구현, push와 pull 중 무엇을 언제 쓰는지의 기준, BlueprintNativeEvent를 쓸 때 _Implementation과 Execute_를 혼동하지 않는 규칙이 앞이고, 그 과정에서 만난 빌드·연결 문제들이 뒤의 트러블슈팅이다.
기술 구현 1 — 옵저버 패턴, 아이템이 스스로 구독한다
요구사항은 캐릭터가 데미지를 받아 사망할 때 레벨에 배치된 모든 AItemBase 자식을 일괄 Destroy하는 것.
구조는 UMYHealthComponent::OnHealthDead 델리게이트(함수를 등록해두면 이벤트 발생 시 대신 호출해 주는 UE의 콜백 시스템)가 Subject, 각 AItemBase 인스턴스가 Observer다. ItemBase가 BeginPlay에서 플레이어의 HealthComponent를 찾아 자기 자신을 구독시키고, Broadcast가 오면 Destroy()를 호출한다.
1
2
3
4
5
6
7
8
9
10
11
// AItemBase.h
UCLASS()
class AItemBase : public AActor
{
GENERATED_BODY()
public:
virtual void BeginPlay() override;
UFUNCTION()
void OnPlayerDead(AController* DeadInstigator);
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// AItemBase.cpp
void AItemBase::BeginPlay()
{
Super::BeginPlay();
if (APawn* Player = UGameplayStatics::GetPlayerPawn(this, 0))
{
if (auto* Health = Player->FindComponentByClass<UMYHealthComponent>())
{
Health->OnHealthDead.AddDynamic(this, &AItemBase::OnPlayerDead);
}
}
}
void AItemBase::OnPlayerDead(AController* /*DeadInstigator*/)
{
Destroy();
}
UMYHealthComponent 쪽은 OnHealthDead.Broadcast(GetOwnerController()) 만 호출하고 ItemBase에 대해 모름. 의존 방향은 ItemBase → HealthComponent 한 방향.
레벨에 배치된 ItemBase 100개가 있으면 100개가 각자 BeginPlay에서 구독 → Broadcast 1회로 100개가 각자 자기 자신 Destroy. 외부에서 일괄 처리하는 코드가 별도로 필요 없음.
기술 구현 2 — push vs pull, Tick 검사를 델리게이트로 뒤집기
핵심 질문은 이것이다 — “Tick으로 매 프레임 HP 체크해야 하는 거 아닌가? 델리게이트라 상관없나?”
답이 push vs pull 차이의 핵심.
“Tick으로 매 프레임 HP 체크해야 하는 거 아닌가?”
직관적으로는 그래 보인다. “사망 시점을 알려면 매 프레임 HP <= 0인지 봐야 하지 않나” 같은 생각. 하지만 그게 pull 방식이고, 델리게이트는 push 방식이라 검사가 0번이다.
Pull (Tick) vs Push (Delegate) 비교
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Pull (Tick) — 매 프레임 직접 묻기
void AItemBase::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (APawn* Player = UGameplayStatics::GetPlayerPawn(this, 0))
{
if (auto* Health = Player->FindComponentByClass<UMYHealthComponent>())
{
if (Health->GetCurrentHealth() <= 0.f) // 매 프레임 if
{
Destroy();
}
}
}
}
// Push (Delegate) — 등록만, 호출은 발신자가
void AItemBase::BeginPlay()
{
/* ... 한 번 등록 ... */
Health->OnHealthDead.AddDynamic(this, &AItemBase::OnPlayerDead);
}
void AItemBase::OnPlayerDead(AController*) { Destroy(); } // 한 번 호출됨
- Pull (Tick) — 60fps × 게임시간(가령 600초) = 36,000번 if 검사. 36,000번 모두 false. 사망 1번을 잡기 위해 35,999번 헛수고.
- Push (Delegate) —
AddDynamic으로 (객체*, 함수포인터) 한 번 등록만 하고 끝. 사망 시점에 HealthComp가Broadcast를 1번 호출하면 등록된 모든 객체의 함수가 한 번씩 자동 호출. Tick 검사 0번.
비유 — Pull은 “택배 왔어?” 매 1초마다 문 여는 행동, Push는 “오면 벨 누르세요”라고 한 번 적어두는 것. 사망처럼 언제 일어날지 모르고 한 번만 일어나는 이벤트는 누가 봐도 후자가 정답.
내부 동작 — invocation list
AddDynamic이 한 일은 (객체 포인터, 함수 포인터) 쌍을 HealthComp의 invocation list에 추가한 것.
1
2
3
4
5
HealthComp::OnHealthDead.invocation_list
├─ (Item_001*, &AItemBase::OnPlayerDead)
├─ (Item_002*, &AItemBase::OnPlayerDead)
├─ (Character*, &ANBC_MasterCharacter::OnPlayerDead)
└─ ...
Broadcast가 하는 일 — invocation list를 순회하며 target->Function(args) 한 번씩 호출. 그게 끝. 추상적이지만 실제 메모리에는 TArray<FScriptDelegate> 같은 배열이 들어 있고, Broadcast는 for 루프 한 번이다.
언제 Tick / 언제 Delegate
| 상황 | 답 |
|---|---|
| 매 프레임 위치/회전 갱신 | Tick (값이 매 프레임 바뀜) |
| 매 프레임 거리 검사 후 액션 | Tick 또는 Timer (주기성) |
| 이벤트성 1회 변화 (사망·도착·획득) | Delegate |
| 외부 시스템에 변화 알림 | Delegate |
| 키 입력 | InputAction (델리게이트의 일종) |
규칙 — “값이 연속적으로 변하면 Tick, 이산 사건이면 Delegate.”
AddDynamic vs AddRaw — UE 델리게이트의 두 부류
UE 델리게이트는 두 부류.
- Dynamic (UFUNCTION 기반) —
AddDynamic/AddUnique/AddDynamicLambda. UPROPERTY/UFUNCTION 시스템 위에 동작 → 약한 참조처럼 GC 안전. 등록한 객체가 GC로 사라져도 Broadcast 시점에 안전 처리. 직렬화 가능, BP에 노출 가능. 단UFUNCTION()필수. - Raw (
AddRaw/AddSP/AddLambda) — 일반 C++ 함수 포인터 기반. 빠르지만 객체가 사라져도 invocation list에 남아있음 → dangling pointer로 크래시 위험.RemoveAll(this)로 명시적 해제 필요. UObject가 아닌 일반 클래스에서 쓸 때만.
UE 게임플레이 코드에서는 AddDynamic이 기본값. 라이프사이클 안전성이 더 중요하기 때문.
기술 구현 3 — BlueprintNativeEvent 명명 규칙
미완성 코드 4건이 빌드가 안 되던 원인은 BlueprintNativeEvent UFUNCTION을 잘못 override / 호출했기 때문이었다. UHT(Unreal Header Tool — UFUNCTION 매크로를 읽고 보조 코드를 자동 생성하는 툴)가 만들어주는 함수 두 개의 역할을 정확히 알아야 한다.
UHT가 자동 생성하는 두 가지
1
2
3
4
5
6
7
8
9
10
11
// 인터페이스 헤더
UINTERFACE(MinimalAPI, Blueprintable)
class UTestMyInterface : public UInterface { GENERATED_BODY() };
class ITestMyInterface
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, BlueprintNativeEvent)
void OnFireDetected(float Temp, FVector Loc);
};
BlueprintNativeEvent로 마킹된 순간 UHT는 두 함수를 자동으로 만든다.
Execute_OnFireDetected(UObject* Target, float Temp, FVector Loc)— C++에서 호출할 때 쓰는 정적 헬퍼. 내부에서 “BP override가 있으면 BP 함수 / 없으면 C++_Implementation“을 분기 디스패치.OnFireDetected_Implementation(float Temp, FVector Loc)— C++에서 override할 가상 함수. 자식 클래스에서 이 이름으로 override해야 됨.
1
2
3
4
5
6
7
8
9
// 자식 클래스에서 override할 때 — _Implementation 붙임
class AItem_Cloth : public AActor, public ITestMyInterface
{
public:
virtual void OnFireDetected_Implementation(float Temp, FVector Loc) override;
};
// 외부에서 호출할 때 — Execute_ 헬퍼 사용 (직접 OnFireDetected() 호출 금지)
ITestMyInterface::Execute_OnFireDetected(TargetActor, 100.f, FVector::ZeroVector);
4건 빌드 에러 일괄 수정
수정한 5개 파일.
| 파일 | 잘못된 형태 | 고친 형태 |
|---|---|---|
TestMyInterface.h | Excute_OnFireDetected (typo) | OnFireDetected |
Item_Cloth.h | OnFireDetected override | OnFireDetected_Implementation override |
Item_Cloth.cpp | void AItem_Cloth::OnFireDetected(...) | void AItem_Cloth::OnFireDetected_Implementation(...) |
Item_Wood.cpp | void AItem_Wood::OnFireDetected(...) | void AItem_Wood::OnFireDetected_Implementation(...) |
MyTorchlight.cpp | Excute_OnFireDetected(...) 호출 | Execute_OnFireDetected(...) |
TestMyInterface.h의 Excute_ (Excute) typo 1건은 인터페이스 선언 자체에 함수 이름을 잘못 적은 것이라, 그 함수에 의존하던 모든 자식 클래스가 줄줄이 깨졌다. 인터페이스 헤더는 한 글자만 틀려도 폭탄.
왜 이렇게 만들어두었나 — BP override 디스패치
답 — BP에서 override했을 가능성 때문. C++ 클래스에서 OnFireDetected_Implementation을 만들어둬도, 그 클래스를 BP로 상속받아서 BP 그래프에 같은 이름 함수를 새로 짜면, 런타임에는 BP 버전이 우선이어야 한다.
Execute_ 헬퍼가 그 분기를 자동 처리:
1
2
3
4
5
6
7
// 의사코드
ITestMyInterface::Execute_OnFireDetected(Target, Temp, Loc)
{
if (BP override 함수가 존재) Target->ProcessEvent(BP함수, Args); // BP 호출
else static_cast<ITestMyInterface*>(Target)
->OnFireDetected_Implementation(Temp, Loc); // C++ 호출
}
C++에서 직접 OnFireDetected(...) 호출하는 코드가 컴파일 안 되도록 막아둔 것도 이 분기 보호용. 무조건 Execute_ 헬퍼를 거치게 강제.
트러블슈팅
4-1. AActor::Instigator shadowing
1
2
ItemBase.h(29): Error : Function parameter: 'Instigator' cannot be defined in 'OnPlayerDead'
as it is already defined in scope 'AActor' (shadowing is not allowed)
원인은 AActor가 protected 멤버 TObjectPtr<APawn> Instigator를 이미 갖고 있어서다. 자식 클래스의 UFUNCTION 파라미터에 같은 이름을 적으면 UHT가 shadowing(안쪽 스코프의 이름이 바깥 스코프의 같은 이름을 가리는 것)으로 판정해 빌드를 차단한다.
1
2
3
4
5
6
7
// 차단
UFUNCTION()
void OnPlayerDead(AController* Instigator); // ← 부모의 멤버명과 충돌
// 통과
UFUNCTION()
void OnPlayerDead(AController* DeadInstigator);
해결 — 파라미터명 Instigator → DeadInstigator로 rename. 한 글자 prefix만 붙여도 OK.
같은 함정 회피 팁 — UFUNCTION 파라미터에서 다음 이름은 피한다:
| 피할 이름 | 출처 |
|---|---|
Instigator | AActor::Instigator |
Owner | UObject::GetOuter() 별칭 / AActor::Owner |
Role / RemoteRole | AActor::Role (네트워킹) |
RootComponent | AActor::RootComponent |
Tags | AActor::Tags |
또는 In* prefix 컨벤션 (InInstigator, InOwner)을 써서 일관되게 회피 가능. UE 엔진 코드 자체도 이 패턴을 자주 쓴다.
4-2. Figma2UMG 플러그인 미설치 — "Optional": true
NBC_Ch3 develop 브랜치 빌드에서:
1
2
Unable to find plugin 'Figma2UMG' (referenced via NBC_Ch3_TeamProject.uproject).
Error MSB3073 : ... 종료(코드: 6)
상황 — .uproject에 enabled로 등록만 되고 실제 설치는 안 된 상태. E-PR-03(Figma → UMG 변환) 미래 작업용으로 표시만 해둠. 팀원이 빌드하면 일괄 실패.
해결 — UE5.5의 "Optional": true 필드 사용.
1
2
3
4
5
6
7
8
9
{
"Plugins": [
{
"Name": "Figma2UMG",
"Enabled": true,
"Optional": true
}
]
}
Optional: true면 플러그인이 설치돼 있으면 로드, 없으면 조용히 스킵. 빌드 통과. 보유자는 그대로 사용 가능.
다른 옵션 비교:
| 옵션 | 동작 | 단점 |
|---|---|---|
Enabled: false | 끔 | 보유자도 사용 못 함 |
| 항목 자체 제거 | 끔 | 향후 다시 추가할 때 노이즈 |
Optional: true | 있으면 로드, 없으면 스킵 | 없음 (UE5.5+ 한정) |
Optional이 양쪽(보유자·미보유자) 다 빌드 통과시키는 유일한 옵션. 적용 결과 develop에 chore: Figma2UMG 플러그인 Optional 처리 커밋 push.
4-3. BaseWeapon bIsOverheated vs bIsOverHeat typo
1
2
BaseWeapon.cpp(96): Error C2065 : 'bIsOverheated': 선언되지 않은 식별자
BaseWeapon.cpp(124): Error C2065 : 'bIsOverheated': 선언되지 않은 식별자
원인 — 헤더와 cpp의 변수명 불일치.
1
2
3
4
5
6
// BaseWeapon.h (54행)
bool bIsOverHeat; // ← 헤더는 'OverHeat'
// BaseWeapon.cpp (96, 124행)
bIsOverheated = true; // ← cpp는 'Overheated'
bIsOverheated = false;
OverHeat vs Overheated — 사람 눈으로는 같은 의미지만 컴파일러는 다른 식별자로 본다.
해결 — replace_all로 cpp의 bIsOverheated를 헤더 이름 bIsOverHeat로 통일. Reload 과열 진입(96행)과 OnOverHeatEnd 해제(124행) 두 군데 모두 수정. 빌드 통과 후 push.
교훈 — 변수명은 영어 단어 분리(OverHeat vs Overheated)에서 갈리기 쉽다. IDE의 rename refactor를 쓰거나, 헤더에서 변수명을 바꿀 때 cpp도 같이 바꾸는 습관 필요. typo 한 번에 빌드 전체가 막힘.
핵심 요약 — Tick(pull)은 사망 한 번을 잡으려고 수만 번 헛검사를 하지만, 델리게이트(push)는 등록 한 번 + Broadcast 한 번으로 끝난다. 그리고
BlueprintNativeEvent는_Implementation으로 override하고Execute_헬퍼로 호출해야 한다 — 인터페이스 헤더의 typo 한 글자가 자식 클래스 전체 빌드를 깨뜨렸다.