포스트

UE 옵저버 패턴과 BlueprintNativeEvent 규칙

Tick 폴링을 델리게이트 push로 뒤집기

아이템이 플레이어 상태를 아는 가장 쉬운 방법은 매 프레임 확인이고, 그게 가장 비싸다. 아이템이 스스로 구독을 거는 구조와 push·pull 선택 기준, _Implementation과 Execute_ 구분.

UE 옵저버 패턴과 BlueprintNativeEvent 규칙

아이템이 플레이어 상태를 알아야 할 때 가장 쉬운 방법은 Tick에서 매 프레임 확인하는 것이다. 그리고 그게 가장 비싼 방법이기도 하다. 이 글에서는 그 확인을 관찰자 쪽 Tick에서 통지자 쪽 델리게이트로 뒤집는 과정을 이야기하려 한다 — 아이템이 스스로 구독을 걸게 만드는 옵저버 구현, push와 pull 중 무엇을 언제 쓰는지의 기준, BlueprintNativeEvent를 쓸 때 _ImplementationExecute_를 혼동하지 않는 규칙이 앞이고, 그 과정에서 만난 빌드·연결 문제들이 뒤의 트러블슈팅이다.

기술 구현 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는 두 함수를 자동으로 만든다.

  1. Execute_OnFireDetected(UObject* Target, float Temp, FVector Loc) — C++에서 호출할 때 쓰는 정적 헬퍼. 내부에서 “BP override가 있으면 BP 함수 / 없으면 C++ _Implementation“을 분기 디스패치.
  2. 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.hExcute_OnFireDetected (typo)OnFireDetected
Item_Cloth.hOnFireDetected overrideOnFireDetected_Implementation override
Item_Cloth.cppvoid AItem_Cloth::OnFireDetected(...)void AItem_Cloth::OnFireDetected_Implementation(...)
Item_Wood.cppvoid AItem_Wood::OnFireDetected(...)void AItem_Wood::OnFireDetected_Implementation(...)
MyTorchlight.cppExcute_OnFireDetected(...) 호출Execute_OnFireDetected(...)

TestMyInterface.hExcute_ (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);

해결 — 파라미터명 InstigatorDeadInstigator로 rename. 한 글자 prefix만 붙여도 OK.

같은 함정 회피 팁 — UFUNCTION 파라미터에서 다음 이름은 피한다:

피할 이름출처
InstigatorAActor::Instigator
OwnerUObject::GetOuter() 별칭 / AActor::Owner
Role / RemoteRoleAActor::Role (네트워킹)
RootComponentAActor::RootComponent
TagsAActor::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 한 글자가 자식 클래스 전체 빌드를 깨뜨렸다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.