포스트

TIL 2026-05-15

TIL 2026-05-15

2026-05-15 Ch3 팀플 HitReact + 히트스톱 통합 (단일 발사 경로 확립)

목차


오늘 한 일 요약

  1. 테스트 환경 셋업BP_TestPlayerCharacter 신규(BP_PlayerCharacter 상속). World Settings의 GameMode Override가 BP_NBC_GameMode로 박혀 있어 Project Settings의 BP_TestGameMode가 무시되던 함정을 World Settings에서 직접 교체. PlayerController BP에 IMC + IA_Move/Look/Jump/Fire 4개 액션을 직접 할당해야 입력이 도는 구조 파악
  2. ZombieCharacter 콜리전 정합UHitReactComponent를 C++ CreateDefaultSubobject로 자동 부착. Mesh를 QueryAndPhysics + ObjectType Pawn + Visibility/Camera/WorldStatic/WorldDynamic/Pawn/ECC_Weapon Block로 코드 일괄 설정. Capsule은 Visibility/Camera/ECC_Weapon Ignore로 라인트레이스가 캡슐을 통과해 본까지 도달하도록 정리. ECC_Weapon은 Combat/CombatTypes.h에서 ECC_GameTraceChannel1로 정의 — 처음에 응답 누락으로 라인트레이스가 캡슐에 막혀 BoneName=None이 떨어지던 이슈 해결
  3. WeaponComponent — BoneName 폴백Hit.BoneName.IsNone()이면 SkeletalMesh->FindClosestBone_K2(Hit.ImpactPoint)로 가장 가까운 본을 찾고, _End 본은 PhysicsAsset 흔들림이 미미하므로 부모로 최대 8단계 거슬러 올라가도록 처리. PhysicsAsset 본 콜리전이 듬성듬성해도 시각적 일관성 보장
  4. WeaponComponent — 히트스톱ApplyHitDamage의 HealthComponent 분기에 ApplyHitStop(HitActor, DamageInstigator) 호출 추가. 좀비 Pawn + 그 AIController + 플레이어까지 CustomTimeDilation = 0.1로 동시 적용 후 World TimerManager로 0.15초 뒤 1.0 복귀. AIController도 같이 멈춰야 BT 의사결정이 진짜로 멎음 — Pawn만 멈추면 BT는 그대로 굴러서 “타격감 없는 추격”이 됨
  5. HitReactComponent — 연사 RESTART — 연사 중 다른 본이 맞으면 이전 본을 SetAllBodiesBelowSimulatePhysics(false) + SetAllBodiesBelowPhysicsBlendWeight(0)로 즉시 정리하고 새 본에서 재시작. 이전에는 bIsReacting 가드 때문에 연사 두 번째 발 이후가 통째로 무시되던 버그 제거
  6. BaseWeapon::Fire — 데드 코드 처리 + 위임 — Player 모듈의 A팀 코드에서 ABaseWeapon::FireBlueprintCallable로 노출돼 BP/AnimNotify에서 호출 시 자체 LineTrace + ApplyDamage가 별도 경로로 도는 이중 경로 발견. 진단 로그로 호출 추적 후 데드 코드 확인 — 본체 통째로 주석 처리 + Owner의 UWeaponComponent를 찾아 TryFire로 위임하는 안전망 1줄만 유지
  7. BTTask_Move 로그 정리 — 매 사이클 MoveToActor 재호출로 UE_LOG가 끝없이 찍히는 잡음을 로그 줄만 주석 처리. 로직은 0줄 수정
  8. 단일 경로 확립 — 좌클릭이든 BP Fire든 AnimNotify든 모든 발사가 UWeaponComponent::TryFire 한 곳으로 모이게 통일 → HitReact·히트스톱이 어느 호출 경로에서도 동일하게 발동

작업 환경

  • 외부 프로젝트D:\Unreal\8th-Team11-CH3-Project
  • 엔진 — UE5
  • 모듈 구성Combat (WeaponComponent / HitReactComponent / CombatTypes), Player (BaseWeapon — A팀), AI (ZombieCharacter / BTTask_Move — B팀)
  • 목적 — 내일 팀 코드리뷰 준비. 핵심은 “왜 이렇게 했는지”가 즉시 보이게 정리


1. 테스트 환경 셋업 — BP_TestPlayerCharacter

A팀 BP_PlayerCharacter를 상속한 BP_TestPlayerCharacter를 만들어 GameMode/PlayerController 빙의가 정상인지 격리 검증. 본 캐릭 BP를 직접 건드리면 A팀 작업과 충돌 위험이 있어 테스트용 상속본을 따로 둠.

World Settings GameMode Override 함정

증상 — Project Settings에서 BP_TestGameMode로 바꿔도 PIE 진입 시 여전히 BP_NBC_GameMode가 떴다.

원인 — 레벨의 World Settings → GameMode Override 슬롯BP_NBC_GameMode로 잡혀 있었다. World Settings의 Override가 Project Settings보다 우선이라 변경이 통째로 무시된 것.

해결 — World Settings에서 직접 BP_TestGameMode로 교체.

교훈 — UE의 GameMode 해석 우선순위는 World Settings Override > Project Settings Default. 레벨 단위 디버깅 환경을 만들 땐 Project Settings 건드리기 전에 World Settings부터 본다.

PlayerController BP에 IMC + IA 직접 할당 구조

A팀이 짠 구조는 EnhancedInput을 PlayerController BP의 디테일 패널에 직접 슬롯으로 노출. C++에서 AddMappingContext 호출 같은 거 없이, BP의 InputMappingContext 슬롯에 IMC를 꽂고 IA_Move/Look/Jump/Fire 액션 각각에 함수 바인딩을 BP 그래프로 연결한 구조.

처음에 입력이 무반응이라 PlayerController.cpp부터 디버깅 시작했는데, 알고 보니 BP 슬롯에 IA가 비어 있어서 그랬다. BP의 4개 IA 슬롯(Move/Look/Jump/Fire) 전부 직접 할당해야 입력이 살아남.

이 구조의 장점은 디자이너가 C++ 건드리지 않고 입력 매핑을 바꿀 수 있다는 것, 단점은 슬롯 누락이 빌드 에러 없이 무반응으로만 드러난다는 것.



2. ZombieCharacter — 콜리전 채널 정합

D:\Unreal\8th-Team11-CH3-Project\Source\...\AI\ZombieCharacter.h/.cpp

HitReactComponent 자동 부착

1
2
3
4
5
6
// ZombieCharacter.h
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Combat")
TObjectPtr<UHitReactComponent> HitReactComp;

// ZombieCharacter.cpp - 생성자
HitReactComp = CreateDefaultSubobject<UHitReactComponent>(TEXT("HitReactComp"));

좀비 BP를 손대지 않아도 C++ 베이스만 상속하면 자동으로 컴포넌트가 붙음. B팀이 만든 다른 좀비 BP들이 있어도 동일.

Mesh 콜리전 코드 일괄 설정

좀비 Mesh가 CharacterMesh 프리셋(= Query Only, No Physics)으로 잡혀 있어 PhysicsAsset 시뮬레이션 자체가 차단된 상태였다. BP에서 일일이 바꾸지 않고 C++에서 일괄 설정.

1
2
3
4
5
6
7
8
9
10
USkeletalMeshComponent* MeshComp = GetMesh();
MeshComp->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
MeshComp->SetCollisionObjectType(ECC_Pawn);
MeshComp->SetCollisionResponseToAllChannels(ECR_Ignore);
MeshComp->SetCollisionResponseToChannel(ECC_Visibility,    ECR_Block);
MeshComp->SetCollisionResponseToChannel(ECC_Camera,        ECR_Block);
MeshComp->SetCollisionResponseToChannel(ECC_WorldStatic,   ECR_Block);
MeshComp->SetCollisionResponseToChannel(ECC_WorldDynamic,  ECR_Block);
MeshComp->SetCollisionResponseToChannel(ECC_Pawn,          ECR_Block);
MeshComp->SetCollisionResponseToChannel(ECC_Weapon,        ECR_Block);   // 핵심

핵심은 ECC_Weapon Block. 이게 빠지면 무기 라인트레이스가 Mesh를 그대로 통과해 본 정보가 안 잡힌다.

Capsule은 ECC_Weapon Ignore

1
2
3
4
UCapsuleComponent* Capsule = GetCapsuleComponent();
Capsule->SetCollisionResponseToChannel(ECC_Visibility, ECR_Ignore);
Capsule->SetCollisionResponseToChannel(ECC_Camera,     ECR_Ignore);
Capsule->SetCollisionResponseToChannel(ECC_Weapon,     ECR_Ignore);

캡슐이 ECC_Weapon에 Block이면 라인트레이스가 캡슐 표면에서 막혀 Hit.BoneName == None이 떨어진다. PhysicsAsset의 본까지 트레이스가 도달해야 HitReact의 본 지정이 의미가 있으므로 캡슐은 ECC_Weapon Ignore가 정답.

ECC_Weapon은 별도 정의 — Combat/CombatTypes.h:

1
#define ECC_Weapon ECC_GameTraceChannel1

Project Settings → Collision의 GameTraceChannel1을 “Weapon” 이름으로 등록한 뒤 C++에서 이 매크로로 참조한다. 처음에 ZombieCharacter 측에서 이 채널 응답을 누락해 라인트레이스가 캡슐에 막히던 게 오늘 디버깅의 시작점이었다.



3. WeaponComponent — 본 폴백 + 히트스톱

본인이 작성한 Combat 모듈의 UWeaponComponent. 핵심 기능 두 가지.

BoneName 폴백 (FindClosestBone_K2)

PhysicsAsset의 본 콜리전이 듬성듬성한 경우 Hit.BoneName이 None으로 떨어진다. 그대로 두면 HitReact가 SKIP되어 시각 반응이 사라짐.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
FName ResolvedBone = Hit.BoneName;

if (ResolvedBone.IsNone())
{
    if (USkeletalMeshComponent* SkelMesh = HitActor->FindComponentByClass<USkeletalMeshComponent>())
    {
        ResolvedBone = SkelMesh->FindClosestBone_K2(Hit.ImpactPoint);

        // _End 본은 PhysicsAsset 흔들림 효과가 미미 → 부모로 거슬러 (최대 8단계)
        int32 Hops = 0;
        while (!ResolvedBone.IsNone() && ResolvedBone.ToString().EndsWith(TEXT("_End")) && Hops < 8)
        {
            ResolvedBone = SkelMesh->GetParentBone(ResolvedBone);
            ++Hops;
        }
    }
}

_End 본은 스켈레톤 말단 — hand_r_End, foot_l_End 같은 것. 콜리전이 거의 없고 PhysicsAsset에 등록도 잘 안 돼 있어 거기에 BlendWeight를 걸어도 흔들림이 시각적으로 안 보인다. 부모(hand_r, foot_l)까지 거슬러 가야 의도한 흔들림이 잡힌다. 8단계 제한은 무한 루프 가드.

히트스톱 — Pawn + AIController + Player 동시 정지

ApplyHitDamage의 HealthComponent 분기 끝에 ApplyHitStop(HitActor, DamageInstigator) 호출.

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
26
27
28
29
30
31
32
33
34
35
36
37
38
void UWeaponComponent::ApplyHitStop(AActor* HitActor, AActor* Instigator)
{
    // 1) 좀비 Pawn 정지
    HitActor->CustomTimeDilation = HitStopTimeScale;

    // 2) 좀비 AIController 정지 — Pawn만 멈추면 BT가 계속 돌아 의사결정이 멎지 않음
    if (APawn* HitPawn = Cast<APawn>(HitActor))
    {
        if (AController* HitController = HitPawn->GetController())
        {
            HitController->CustomTimeDilation = HitStopTimeScale;
        }
    }

    // 3) 플레이어도 정지 → 발사자도 같이 느려져야 충돌감이 살아남
    if (Instigator)
    {
        Instigator->CustomTimeDilation = HitStopTimeScale;
    }

    // 4) Duration 후 1.0으로 복귀
    FTimerHandle Handle;
    TWeakObjectPtr<AActor> WeakHit = HitActor;
    TWeakObjectPtr<AActor> WeakInst = Instigator;

    GetWorld()->GetTimerManager().SetTimer(Handle, [WeakHit, WeakInst]()
    {
        if (WeakHit.IsValid())
        {
            WeakHit->CustomTimeDilation = 1.f;
            if (APawn* P = Cast<APawn>(WeakHit.Get()))
            {
                if (AController* C = P->GetController()) C->CustomTimeDilation = 1.f;
            }
        }
        if (WeakInst.IsValid()) WeakInst->CustomTimeDilation = 1.f;
    }, HitStopDuration, false);
}

AIController도 같이 멈춰야 하는 이유CustomTimeDilation액터 단위로 작동한다. Pawn만 0.1로 두면 Pawn의 Tick·이동·애니메이션은 느려지지만, 별도 액터인 AIController가 매 프레임 돌리는 BehaviorTree 평가는 그대로 풀스피드로 굴러간다. 결과적으로 “몸은 멈췄는데 BT는 다음 노드로 진행”하는 비대칭이 생긴다.

AIController도 0.1로 두면 BT의 Selector/Sequence 평가, MoveTo Task의 Tick, Wait Task의 타이머까지 전부 0.1배로 스케일 → 진짜로 의사결정이 멎는다. 그래서 BTTask 자체는 0줄 수정으로 끝나는 것(아래 6번 항목 참조).

플레이어까지 같이 멈추는 건 타격 충돌의 무게감 연출용. 발사자만 정상 속도면 “총만 빠르고 화면은 멈춤”이 되어 어색하다.

BP 노출 파라미터

1
2
3
4
5
6
7
UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category="Combat|HitStop",
          meta=(ClampMin="0.01", ClampMax="1.0"))
float HitStopTimeScale = 0.1f;

UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category="Combat|HitStop",
          meta=(ClampMin="0.0", ClampMax="0.5"))
float HitStopDuration = 0.15f;

DataAsset이 아닌 컴포넌트 디테일 패널에서 튜닝하도록 노출. 무기 종류별로 강도 차이를 두고 싶을 때(샷건은 0.05/0.2, 라이플은 0.1/0.1 등) BP에서 즉시 조정.



4. HitReactComponent — 연사 RESTART 로직

기존 구조의 버그 — bIsReacting == true면 신규 PlayHitReact 호출을 통째로 SKIP. 첫 발은 정상이지만 연사 두 번째 발 이후가 모두 무시되어 “첫 발만 흔들리고 끝”이 됨.

수정 — bIsReacting이면 이전 본을 즉시 정리하고 새 본에서 재시작.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void UHitReactComponent::PlayHitReact(FName BoneName, float Strength)
{
    if (!CachedMesh)        { UE_LOG(..., TEXT("SKIP: CachedMesh NULL")); return; }
    if (!BlendWeightCurve)  { UE_LOG(..., TEXT("SKIP: BlendWeightCurve NULL")); return; }
    if (BoneName.IsNone())  { UE_LOG(..., TEXT("SKIP: BoneName None")); return; }
    if (ExcludedBones.Contains(BoneName)) { UE_LOG(..., TEXT("SKIP: ExcludedBones")); return; }

    // 연사 중이면 이전 본 즉시 정리
    if (bIsReacting && !CurrentBone.IsNone() && CurrentBone != BoneName)
    {
        CachedMesh->SetAllBodiesBelowSimulatePhysics(CurrentBone, false);
        CachedMesh->SetAllBodiesBelowPhysicsBlendWeight(CurrentBone, 0.f);
    }

    // 새 본으로 재시작
    CurrentBone = BoneName;
    CachedMesh->SetAllBodiesBelowSimulatePhysics(BoneName, true);
    bIsReacting = true;
    Elapsed = 0.f;
    // ... Tick에서 BlendWeightCurve 평가
}

이상 케이스 SKIP 로그는 4종만 남기고(CachedMesh NULL / BlendWeightCurve NULL / BoneName None / ExcludedBones), 정상 동작 로그는 모두 정리했다. 코드리뷰에서 로그가 어수선해 보이지 않게 정돈.

같은 본 연사(CurrentBone == BoneName)는 이전 정리 없이 Elapsed만 0으로 리셋 — 같은 본에 SetAllBodies를 두 번 호출하면 PhysicsAsset이 잠깐 깜빡이는 시각 아티팩트가 생긴다.



5. BaseWeapon::Fire — 데드 코드 처리 + WeaponComponent 위임

Player 모듈의 ABaseWeapon은 A팀이 작성한 코드인데, 코드를 읽다가 Fire()BlueprintCallable로 노출돼 있는 걸 발견.

1
2
3
// 기존 ABaseWeapon.h (A팀)
UFUNCTION(BlueprintCallable, Category="Weapon")
void Fire();

문제 — BP/AnimNotify/Input 등 어디서든 BaseWeapon::Fire를 호출하면 내부에서 자체 LineTrace + UGameplayStatics::ApplyDamage로 별도 경로가 돈다. 이 경로는 UWeaponComponent::TryFire를 거치지 않으니 HitReact·히트스톱이 적용되지 않는다.

오늘 PIE 디버깅에서는 좌클릭 입력이 WeaponComponent로 잘 흘러가는 게 확인됐지만, BP 어딘가에서 BaseWeapon::Fire를 추가 호출하고 있을 가능성 — 즉 이중 발사 — 을 봉인하지 않으면 코드리뷰 후 다른 팀원이 무심코 BP 노드를 연결할 위험이 남는다.

해결 1단계 — 진단 로그로 호출 추적:

1
2
3
4
5
6
void ABaseWeapon::Fire()
{
    UE_LOG(LogTemp, Warning, TEXT("[BaseWeapon::Fire] CALLED by %s"),
           *GetNameSafe(GetInstigatorController()));
    // ... 기존 본체
}

PIE 1회 + 연사 1회를 돌려보고 로그가 안 찍히는 걸 확인 → 데드 코드 판정.

해결 2단계 — 본체 통째로 주석 + WeaponComponent로 위임:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
void ABaseWeapon::Fire()
{
    // [2026-05-15] 데드 코드 봉인. 진단 로그로 호출 0회 확인.
    // 향후 BP/AnimNotify에서 호출되더라도 WeaponComponent 단일 경로로 강제.
    UE_LOG(LogTemp, Warning, TEXT("[BaseWeapon::Fire] redirected to WeaponComponent"));

    AActor* OwnerActor = GetOwner();
    if (!OwnerActor) return;

    UWeaponComponent* WC = OwnerActor->FindComponentByClass<UWeaponComponent>();
    if (!WC) return;

    // Muzzle 소켓이 있으면 그 위치, 없으면 액터 위치 폴백
    FVector StartLoc = GetActorLocation();
    if (USkeletalMeshComponent* WM = FindComponentByClass<USkeletalMeshComponent>())
    {
        if (WM->DoesSocketExist(TEXT("Muzzle")))
        {
            StartLoc = WM->GetSocketLocation(TEXT("Muzzle"));
        }
    }

    // AimRotation은 InstigatorController의 ControlRotation, 없으면 ActorRotation 폴백
    FRotator AimRot = GetActorRotation();
    if (AController* IC = GetInstigatorController())
    {
        AimRot = IC->GetControlRotation();
    }

    WC->TryFire(StartLoc, AimRot);

    /* ===== 기존 본체 전체 주석 (LineTrace + ApplyDamage 직접 호출 경로) =====
    FHitResult Hit;
    FVector Start = ...;
    FVector End = ...;
    GetWorld()->LineTraceSingleByChannel(...);
    UGameplayStatics::ApplyDamage(...);
    ==========================================================================*/
}

이렇게 두면 누가 BaseWeapon::Fire를 호출하든 자동으로 WeaponComponent 경로로 단일화된다. HitReact·히트스톱은 어느 호출 경로에서도 동일하게 발동.

진단 로그(redirected to WeaponComponent)는 1줄만 안전망으로 유지 — 향후 BP에서 호출이 다시 일어나는 게 발견되면 즉시 보임. 정리는 코드리뷰에서 합의 후.



6. BTTask_Move — 로그 잡음 제거 (0줄 수정)

B팀이 만든 UBTTask_Move가 매 사이클 MoveToActor를 재호출하면서 UE_LOG를 끝없이 찍어 출력 로그를 어지럽혔다. PIE 30초만 돌려도 콘솔이 가득.

수정 — 로그 줄만 주석 처리. 로직은 0줄 수정.

1
2
3
4
5
6
7
8
9
EBTNodeResult::Type UBTTask_Move::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
    // ... 기존 로직 그대로

    // UE_LOG(LogTemp, Log, TEXT("[BTTask_Move] MoveToActor: %s"), *GetNameSafe(TargetActor));
    AIController->MoveToActor(TargetActor, AcceptanceRadius);

    return EBTNodeResult::InProgress;
}

히트스톱 검증 작업 중에 콘솔 로그가 깨끗해야 다른 로그(BaseWeapon::Fire 진단 등)가 보인다. B팀 코드라 로직은 절대 손대지 않고 로그만 정리한 게 포인트.



트러블슈팅 — 코드리뷰 자주 묻는 부분

증상원인해결
입력 무반응World Settings의 GameMode Override가 BP_NBC_GameMode로 박힘World Settings → BP_TestGameMode
디버그 라인 없음, 사격 무반응BP_Rifle의 WeaponConfig 슬롯이 None → SwitchWeapon이 가드에서 차단 → EquipWeapon 미호출BP_Rifle Class Defaults에 DA_WeaponConfig 할당
좀비 데미지는 들어가는데 HitReact 안 됨Mesh 콜리전이 CharacterMesh 프리셋(Query Only, No Physics)Custom + QueryAndPhysics로 코드 일괄 설정
PhysicsAsset 본 일부만 QueryAndPhysics본 일괄 선택이 안 됨Skeleton Tree 전체 선택 후 일괄 변경
Hit.BoneName == None캡슐 ECC_Weapon Block → 본까지 트레이스 미도달FindClosestBone_K2 폴백 + 캡슐 ECC_Weapon Ignore
HitReact 첫 발만 흔들리고 이후 SKIPbIsReacting 가드이전 본 정리 후 새 본 재시작 (RESTART 로직)
히트스톱 적용은 되는데 좀비가 안 멈춤Pawn만 정지, AIController는 별개 액터라 BT 계속 굴러감AIController에도 CustomTimeDilation 적용
Live Coding 후 변경 미반영UPROPERTY 추가는 정식 빌드 필요에디터 종료 후 IDE 빌드


발사 흐름 최종 정리

1
2
3
4
5
6
7
8
9
10
좌클릭 / BP Fire 호출 / AnimNotify
   ↓
WeaponComponent::TryFire (단일 경로)
   ↓
LineTrace (ECC_Weapon)
   ↓
ApplyHitDamage(Hit, Damage, Instigator)
   ├── HealthComponent->ApplyDamage          (체력 차감)
   ├── HitReactComponent->PlayHitReact       (본 흔들림, RESTART 로직)
   └── ApplyHitStop                          (Pawn + AIController + Player CustomTimeDilation 0.15s)

세 가지 효과(체력·본 흔들림·히트스톱)가 동일한 진입점(ApplyHitDamage)에서 일괄 분기된다. 발사 호출 경로가 늘어나도(샷건·근접무기 추후 추가) 이 진입점만 거치면 일관성 유지.



코드리뷰에서 강조할 설계 결정

  1. 단일 경로 강제BaseWeapon::Fire를 데드 코드 처리 + WeaponComponent 위임으로 통일. BP/AnimNotify에서 누가 호출해도 HitReact·히트스톱이 동일하게 발동. 이중 경로 가능성을 코드 레벨에서 봉인
  2. 본 폴백 (FindClosestBone_K2 + _End 부모 거슬러) — PhysicsAsset 본 콜리전이 듬성듬성한 캐릭터에서도 시각적 일관성. _End 처리가 없으면 말단 본에 BlendWeight를 걸어도 흔들림이 안 보이는 함정 회피
  3. AIController 포함 히트스톱CustomTimeDilation이 액터 단위라 Pawn만 멈추면 BT가 계속 돈다. AIController까지 같이 멈춰야 BT 의사결정이 진짜로 멎고 타격감이 살아남
  4. BT 0줄 수정CustomTimeDilation이 액터 단위라 BTTask의 Tick·타이머가 자동 스케일. B팀 코드에 손 대지 않고 히트스톱 기능을 얹은 점 — 모듈 경계 보존
  5. 콜리전 채널 정합 (ECC_Weapon) — Mesh는 Block / Capsule은 Ignore. 라인트레이스가 캡슐 통과 후 본에 도달하는 흐름을 코드로 일괄 보장 (BP 콜리전 프리셋에 의존하지 않음)
  6. BP 노출 파라미터 최소화 — 히트스톱 강도·지속만 컴포넌트 디테일에서 튜닝. DataAsset까지 안 가는 이유는 무기 종류별 차이가 작아서. 차후 차이가 커지면 DA로 승격


오늘 배운 것 정리

  1. CustomTimeDilation은 액터 단위 — Pawn만 멈추면 BT는 안 멈춘다 — AIController는 별개 액터라서 같이 0.1로 두지 않으면 의사결정이 풀스피드로 진행. 타격감의 핵심은 “몸과 결정이 같이 멈추는 것”
  2. Hit.BoneName == None은 콜리전 채널 누락의 신호 — PhysicsAsset 문제가 아니라 캡슐이 Block이라 본까지 트레이스가 못 가는 경우가 많음. FindClosestBone_K2는 안전망일 뿐, 근본 원인은 채널 응답 정합
  3. PhysicsAsset의 _End 본은 부모로 거슬러야 흔들림이 보인다 — 말단 본은 콜리전 빈약 + 등록 누락이 잦아서 SetAllBodiesBelow의 효과가 거의 없음. 최대 8단계 부모 거슬러 가드
  4. BlueprintCallable 노출 함수는 데드 코드라도 위험 — 호출 가능성이 BP 어디든 열려 있어 향후 다른 사람이 무심코 연결할 수 있음. 데드 확인되면 위임으로 단일화하는 게 안전
  5. World Settings의 GameMode Override는 Project Settings보다 우선 — 레벨 단위 디버깅 환경 만들 땐 Project Settings 먼저 보지 말고 World Settings부터 확인. GameMode 안 바뀌면 첫 의심처
  6. B팀 코드는 로그만 정리하고 로직은 0줄 수정 — 모듈 경계 보존이 코드리뷰에서 가장 빨리 신뢰를 얻는 방식. “내가 책임지는 영역” vs “건드리지 않는 영역”이 디프에서 즉시 보이게


다음 단계 후보

  • ⬜ 카메라 셰이크 (roles.md C-AL) — 근접/원거리 각 1종. 무기 분기와 통합
  • ⬜ 피격 VFX — Niagara SpawnEmitterAtLocation. 05/08 학습한 핸들 보관 패턴 적용(루프 PFX는 Attached 또는 멤버 보관)
  • ⬜ 처형 시스템 — 체력 30% 임계값. HealthComponent의 OnHealthThreshold 델리게이트 신규?
  • BaseWeapon::Fire의 진단 로그 정리 — 현재 안전망 1줄 유지 중. 코드리뷰에서 합의 후 제거 또는 LogVerbosity=VeryVerbose로 강등
  • ⬜ HitReact 본 폴백의 8단계 제한 수치 검증 — 실제 좀비 스켈레톤에서 가장 깊은 _End가 몇 단계인지 측정 후 적정값으로 조정


오후·저녁 추가 학습 — 2026-05-15

오전·낮 작업으로 Ch3 HitReact·히트스톱 통합을 마친 뒤, 오후·저녁에 진행한 다섯 가지 추가 작업을 정리한다. 핵심은 5번 — NBC_Master WeaponBox의 동기 로드를 비동기 로드로 교체한 작업이다. 나머지는 CS 모의면접 후속, 캐시 CS 파일 작성, 백트래킹 2문제, 코드리뷰 발표 가이드 보강 순으로 짧게 정리.

오후·저녁 추가 작업 요약

  1. CS 모의면접 — 실수 자료형 (오전 진행 완료, 후속 메모)24_floating_point.md 흐름대로 답변. 정밀도 파트(가수 비트·ULP·machine epsilon)는 답변이 얕았다는 자평 → 별도 작업으로 큐
  2. CS 25번 — 캐시 히트/미스 신규raw/cs-notion/25_cache_hit_miss.md 작성 (1528줄, 18섹션). 2026-05-18(월) 모의면접 대비, 실제 학습은 주말
  3. 알고리즘 — LeetCode 백트래킹 2문제 — Subsets(78) + Permutations(46). 각 3가지 풀이 비교
  4. Ch3 코드리뷰 발표 준비2026-05-15_코드리뷰_HitReact_히트스톱.md에 §10 PIE 시연 순서 추가 + cpp/h 6개 파일에 발표 가이드용 한 줄 주석 삽입
  5. NBC_Master WeaponBox — 동기 로드 → 비동기 로드 교체 ★LoadSynchronous 한 줄을 FStreamableManager::RequestAsyncLoad + FStreamableDelegate 콜백 패턴으로 전환. TSoftClassPtr<AActor> 개념·IsNull/IsValid/Get 차이·콜백에서의 멤버 변수 컨텍스트 보존이 오늘의 핵심 학습


1. CS 모의면접 — 실수 자료형 후속 메모

raw/cs-notion/24_floating_point.md의 6섹션 흐름(IEEE 754 구조 → 정밀도 → 비교 함정 → 게임 직결 이슈 → 결정성 → 마무리)을 그대로 따라 답변. 부호 1비트 + 지수 8비트 + 가수 23비트(float 기준)와 정규화 표현(1.xxx × 2^E)의 묵시적 leading 1 트릭까지는 매끄럽게 풀렸다.

부족했던 부분 — 정밀도 파트

  • 가수 23비트 → 약 7자리 십진 정밀도라는 결론 자체는 말했지만, 왜 7자리인지 ($2^{23} \approx 8.4 \times 10^6$, $\log_{10}(2^{23}) \approx 6.92$)를 면접관이 한 번 더 물었을 때 즉답이 안 나왔다
  • ULP(Unit in the Last Place) — 인접한 두 float 값 사이의 간격. 값이 커질수록 ULP도 같이 커진다(지수가 커지면 가수 1비트당 표현 간격이 배수로 벌어짐)는 직관을 말로 풀어내지 못함
  • Machine epsilon1.0과 그 다음 표현 가능한 float 사이의 간격. float 기준 약 $1.19 \times 10^{-7}$. 비교 허용 오차의 출발점이지만 절대치 비교에는 부적합하다(큰 값에서는 epsilon이 너무 작다 → 상대 오차 비교가 필요)는 점

이 세 가지가 한 묶음으로 안 나오면 “왜 == 비교가 위험한지”의 근거가 약해진다. 다음 모의면접 전에 별도 보강 필요로 큐.

핵심 학습 — 게임 직결 이슈

IEEE 754의 표현 한계는 게임에서는 두 가지로 직결된다.

  • LWC(Large World Coordinate) — 월드 원점에서 멀어질수록 float 정밀도가 떨어진다 (ULP가 커지니까). UE5가 double 기반 LWC를 도입한 이유. 1km 떨어진 지점의 1mm 단위 위치 차이가 float로는 표현이 안 됨
  • 결정성(Determinism) — 같은 입력에 같은 결과를 보장해야 하는 리플레이·네트워크 동기화에서, 부동소수 연산 순서와 컴파일러·FPU 모드에 따라 결과가 달라질 수 있다. 멀티플레이어 게임이 정수·고정소수 기반 시뮬레이션을 쓰는 이유


2. CS 25번 — 캐시 히트/미스 (신규 작성, 주말 학습 예정)

파일raw/cs-notion/25_cache_hit_miss.md (1528줄, 18섹션)

2026-05-18(월) 모의면접 대비 자료. 오늘은 자료만 작성, 실제 학습은 주말 토·일.

핵심 토픽 정리

섹션핵심
메모리 계층Register(0) / L1(~4 cycle) / L2(~12) / L3(~40) / DRAM(~200~300). cycle 수가 50~70배 점프
3C 미스 분류Compulsory(첫 접근) / Capacity(크기 부족) / Conflict(매핑 충돌). 멀티코어면 +Coherence
캐시 라인64B가 일반. 인접 데이터가 같은 라인에 묶임 → spatial locality 활용
False sharing서로 다른 스레드가 같은 64B 라인 안 다른 변수를 쓰면 라인 무효화로 성능 폭락. alignas(64)로 분리
LocalitySpatial(인접) vs Temporal(반복). 둘 다 캐시 친화 코드의 양대 축
AoS vs SoAArray of Structs는 한 객체 전부, Struct of Arrays는 한 필드만 묶음. DOP(Data-Oriented Programming)는 SoA 선호
MESIModified·Exclusive·Shared·Invalid. 멀티코어 캐시 일관성 프로토콜
TLB·Huge Page가상 주소 → 물리 주소 변환 캐시. 큰 데이터는 2MB·1GB 페이지로 TLB miss 감소
언리얼 Mass EntityECS 기반 SoA 구조. TArray 연속 메모리 vs TMap 해시 산재

학습 포인트 한 줄

  • 히트율 95~99%가 일반 기준선. 1번의 miss가 DRAM까지 가면 200~300 cycle 페널티. 5%의 miss가 전체 시간의 절반 이상을 먹는 구조

graphify CS 그래프도 업데이트했다 — 594 nodes·604 edges·112 communities. 이전보다 캐시·메모리 클러스터가 한 덩어리로 명확하게 잡힘.



3. 알고리즘 — LeetCode 백트래킹 2문제

오늘은 백트래킹을 묶어서 풀었다. 두 문제 모두 “선택지 트리를 어떻게 표현할 것인가”의 차이가 핵심이다.

3-1. LeetCode 78 — Subsets (Medium)

파일CodeingTest/CodingTest/100zun/2026-05-15-1.cpp

3가지 풀이를 다 짜봤다.

풀이 1 — 백트래킹 (include/exclude)

1
2
3
4
5
6
7
8
9
void backtrack(int start, vector<int>& nums, vector<int>& path,
               vector<vector<int>>& result) {
    result.push_back(path);  // 매 진입 시점이 곧 하나의 부분집합
    for (int i = start; i < nums.size(); ++i) {
        path.push_back(nums[i]);
        backtrack(i + 1, nums, path, result);
        path.pop_back();
    }
}

핵심 — 별도 leaf 조건이 없다. 매 함수 진입 시점의 path 자체가 유효한 부분집합. start 인덱스를 단조 증가시켜 중복 방지.

풀이 2 — 비트마스크

1
2
3
4
5
6
for (int mask = 0; mask < (1 << n); ++mask) {
    vector<int> subset;
    for (int i = 0; i < n; ++i)
        if (mask & (1 << i)) subset.push_back(nums[i]);
    result.push_back(subset);
}

2^n을 순회하면서 비트가 켜진 인덱스만 모음. n ≤ 32일 때 깔끔.

풀이 3 — Cascading (iterative)

빈 집합에서 시작해 원소를 하나씩 추가하며 기존 집합 전부에 그 원소를 덧붙인 새 집합을 만들어 결과를 두 배씩 키움. 재귀 없이 직관적.

복잡도 — 시간 $O(N \cdot 2^N)$ (부분집합 $2^N$개 × 복사 $O(N)$), 공간 재귀 스택 $O(N)$.

3-2. LeetCode 46 — Permutations (Medium)

파일CodeingTest/CodingTest/100zun/2026-05-15-2.cpp

이쪽도 3풀이.

풀이 1 — used 플래그 백트래킹

1
2
3
4
5
6
7
8
9
10
void backtrack(vector<int>& nums, vector<bool>& used,
               vector<int>& path, vector<vector<int>>& result) {
    if (path.size() == nums.size()) { result.push_back(path); return; }
    for (int i = 0; i < nums.size(); ++i) {
        if (used[i]) continue;
        used[i] = true; path.push_back(nums[i]);
        backtrack(nums, used, path, result);
        path.pop_back(); used[i] = false;
    }
}

각 자리에 “아직 안 쓴 원소” 중 하나를 시도. 가독성 최상.

풀이 2 — 스왑 in-place 백트래킹

1
2
3
4
5
6
7
8
void backtrack(int first, vector<int>& nums, vector<vector<int>>& result) {
    if (first == nums.size()) { result.push_back(nums); return; }
    for (int i = first; i < nums.size(); ++i) {
        swap(nums[first], nums[i]);
        backtrack(first + 1, nums, result);
        swap(nums[first], nums[i]);
    }
}

used 배열도 path 벡터도 없이 원본 배열만 스왑. 추가 메모리 0. 대신 호출 직전 상태를 호출 직후에 정확히 복구해야 해서 실수 위험.

풀이 3 — STL next_permutation

1
2
sort(nums.begin(), nums.end());
do { result.push_back(nums); } while (next_permutation(nums.begin(), nums.end()));

정렬된 상태에서 사전순으로 다음 순열을 만들어 순회. 코드가 압도적으로 짧지만 백트래킹 이해엔 도움 안 됨.

복잡도 — 시간 $O(N \cdot N!)$, 공간 재귀 $O(N)$.

두 문제를 묶어서 본 학습 포인트

  • 선택지 트리의 표현이 핵심이다
    • 부분집합 — “이번에 쓸 다음 원소는?” → start 인덱스로 단조 증가
    • 순열 — “이 자리에 쓸 원소는?” → used 플래그로 안 쓴 것 표시, 또는 스왑으로 앞쪽 영역에 고정
  • 메모리 트레이드오프가 분명하다
    • 부분집합 비트마스크 — $2^n$ 자체가 한계 (n ≤ 32 정도)
    • 순열 스왑 풀이 — 추가 메모리 0이지만 호출 전후 상태 복구 부담
  • leaf 조건의 위치도 패턴이 다르다
    • 부분집합 — 매 진입 시점 push (leaf 조건 없음)
    • 순열 — path.size() == n일 때만 push (명시적 leaf)


4. Ch3 코드리뷰 발표 준비

내일 팀 코드리뷰 발표 직전 준비 작업.

파일 1 — 발표 가이드 보강

scrum/Ch3-TeamProject/2026-05-15_코드리뷰_HitReact_히트스톱.md§10 PIE 시연 순서 섹션을 새로 추가했다.

구성:

  • 시연 환경 셋업 — World Settings GameMode Override 교체, BP_TestPlayerCharacter 빙의 확인, PIE 진입
  • 데모 ① 단일 사격 — 좌클릭 1발 → 좀비 본 흔들림 + 히트스톱 0.15초
  • 데모 ② 본 단위 흔들림 — 머리·팔·다리 각각 사격해 본별 PhysicsAsset 반응 확인
  • 데모 ③ BoneName 폴백 — Capsule을 임시로 Block 처리한 비교 영상(또는 로그)
  • 데모 ④ 연사 RESTART — 빠른 연속 클릭으로 이전 본 정리 + 새 본 흔들림 전환
  • 데모 ⑤ 히트스톱 — AIController까지 멈춰 BT 정지하는 모습 (좀비가 추격을 잠깐 끊고 다시 시작)
  • 데모 ⑥ Fire 단일 경로 위임 — BaseWeapon::Fire 호출 시 WeaponComponent로 redirect되는 로그 확인

각 데모마다 액션 / 기대 결과 / 보여줄 코드 위치 / 말할 포인트를 네 항목으로 분리. 컴포넌트 책임 다이어그램(ASCII)과 5분 시간 배분표, 실패 대비표(콜리전 채널이 안 먹을 때 무엇부터 확인)도 같이 정리.

파일 2 — 발표 가이드용 한 줄 주석 삽입

발표 중 코드를 열면서 설명할 위치 6곳에 한 줄 주석으로 마커를 박았다.

파일위치주석 의미
Combat/WeaponComponent.cppTryFire 진입부모든 발사의 단일 진입점 마커
Combat/WeaponComponent.cppApplyHitDamage①체력 → ②HitReact → ③히트스톱 디스패치 순서
Combat/WeaponComponent.cppApplyHitStop3중 적용(Pawn·AIController·Instigator) + WeakLambda 안전성
Combat/HitReactComponent.cppPlayHitReact가드·연사 RESTART·임펄스 예약
Combat/WeaponComponent.hTryFire 선언BP 호출 가능 단일 진입 API
Combat/HitReactComponent.h클래스 선언부컴포넌트 책임 한 줄 요약

학습 포인트

코드 리뷰는 “코드를 보면서” 진행되므로 시연 흐름과 코드 위치를 1:1로 매핑해두는 게 핵심. 발표자가 IDE에서 파일을 빨리 못 찾으면 흐름이 끊긴다. 한 줄 주석은 동료 검색용 마커이자, 다른 사람이 6개월 뒤 코드를 다시 볼 때 “여기가 발사 단일 경로구나”를 즉시 알아볼 수 있는 자가 문서화이기도 함.



5. NBC_Master WeaponBox — 동기 로드 → 비동기 로드 교체 ★ (오늘 핵심 학습)

파일D:/Unreal/NBC_Master/Source/NBC_Master/WeaponBox.{h,cpp} 참고 패턴TestActorTT.cppLoadWithSoftPtr + MyOnLoadCompleted

오늘의 핵심 학습이라 분량 비중을 높여서 정리한다. 코드 한 줄(LoadSynchronous) 바꾸는 작업처럼 보이지만, 그 한 줄을 비동기로 바꾸려면 콜백 패턴 / 멤버 변수 컨텍스트 / 이미 로드된 경우 분기 / Soft 포인터의 세 상태까지 한 묶음으로 이해해야 한다.

5-1. 변경 전 — 동기 로드

1
2
3
4
5
6
7
8
// AWeaponBox::SpawnSelectedWeapon (변경 전)
UClass* LoadedClass = SelectedWeapon->WeaponClass.LoadSynchronous();
if (LoadedClass)
{
    FActorSpawnParameters Params;
    Params.Owner = this;
    GetWorld()->SpawnActor<AActor>(LoadedClass, GetActorLocation(), GetActorRotation(), Params);
}
  • WeaponClassTSoftClassPtr<AActor> 타입 (디스크 경로만 보관, 메모리에는 아직 없을 수 있음)
  • LoadSynchronous()현재 스레드에서 즉시 디스크에서 읽어와 메모리에 올리고 UClass* 반환
  • 단순하고 직선적이지만 디스크 I/O 동안 게임 스레드 블록 → 프레임 끊김. 무기 종류·블루프린트가 무거우면 체감 hitch 발생

5-2. 변경 후 — 비동기 로드

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 헤더 (WeaponBox.h)
UPROPERTY(VisibleAnywhere)
TSoftClassPtr<AActor> PendingWeaponClass;   // 콜백에서 .Get()용 보관

UPROPERTY(VisibleAnywhere)
FName PendingWeaponName;                     // 디버깅·로그용

void OnWeaponClassLoaded();                  // 콜백 함수

// 구현 (WeaponBox.cpp)
void AWeaponBox::SpawnSelectedWeapon()
{
    if (!SelectedWeapon) return;

    PendingWeaponClass = SelectedWeapon->WeaponClass;  // 멤버에 컨텍스트 보관
    PendingWeaponName  = SelectedWeapon->WeaponName;

    if (PendingWeaponClass.IsValid())
    {
        // 이미 메모리에 있으면 비동기 요청 없이 즉시 콜백 (동일 경로 재사용)
        OnWeaponClassLoaded();
    }
    else
    {
        FStreamableManager& Streamable = UAssetManager::GetStreamableManager();
        Streamable.RequestAsyncLoad(
            PendingWeaponClass.ToSoftObjectPath(),
            FStreamableDelegate::CreateUObject(this, &AWeaponBox::OnWeaponClassLoaded)
        );
    }
}

void AWeaponBox::OnWeaponClassLoaded()
{
    UClass* LoadedClass = PendingWeaponClass.Get();
    if (!LoadedClass) return;

    FActorSpawnParameters Params;
    Params.Owner = this;
    GetWorld()->SpawnActor<AActor>(LoadedClass, GetActorLocation(), GetActorRotation(), Params);

    UE_LOG(LogTemp, Log, TEXT("[WeaponBox] Spawned %s"), *PendingWeaponName.ToString());
}

기존 동기 코드는 삭제하지 않고 주석으로 보존(요구사항). 비교 학습용으로 두 경로를 같이 두는 형태.

5-3. TSoftClassPtr<AActor> 개념

TSoftClassPtr는 클래스를 경로 문자열로 들고 있는 포인터다. TSubclassOf와의 차이가 중요하다.

타입보관 형태메모리 로드사용 시점
TSubclassOf<AActor>UClass* 직접 참조이 변수를 가진 객체가 로드되면 클래스도 같이 로드(하드 참조)항상 즉시 사용 가능
TSoftClassPtr<AActor>FSoftObjectPath(경로 문자열)소유 객체 로드와 무관. 명시적으로 로드해야 메모리에 올라옴로드 후 Get() 또는 LoadSynchronous()

TSoftClassPtr를 쓰는 이유 — 무기 50종을 데이터 에셋에 다 등록해도 WeaponBox 액터 자체가 무기 50종을 전부 메모리에 끌고 들어오지 않는다. 필요한 무기만 그때그때 로드. 이게 비동기 로드와 결합되면 메모리 풋프린트 최소화 + 프레임 끊김 없음 두 마리를 잡는다.

5-4. IsNull / IsValid / Get — Soft 포인터의 세 상태

비동기 로드를 다룰 때 가장 헷갈리는 부분. 셋이 명확히 다르다.

메서드무엇을 확인반환
IsNull()경로 자체가 비어있나 (디자이너가 슬롯을 안 채웠나)bool
IsValid()메모리에 이미 로드되어 있나bool
Get()이미 로드되어 있으면 raw 포인터, 아니면 nullptrUClass*

핵심 — !IsNull()이라고 해서 Get()이 nullptr이 아닌 게 보장되지 않는다. 경로는 있어도 메모리엔 아직 없을 수 있음. 그래서:

  • IsNull() 체크 → “디자이너 슬롯 채웠나” 검증
  • IsValid() 체크 → “비동기 로드를 또 요청할 필요 없나” 분기
  • Get() 사용 → 로드 완료가 보장되는 콜백 안에서만

LoadSynchronous()는 이 세 상태에서 자동으로 로드까지 해주고 raw 포인터를 돌려준다. 그래서 동기 코드는 단순하다. 비동기는 이 세 상태를 코드로 직접 다뤄야 한다.

5-5. UAssetManager::GetStreamableManager()FStreamableDelegate

  • UAssetManager — 엔진 전역 싱글톤. 에셋 라이프사이클 관리
  • FStreamableManagerUAssetManager 안의 스트리밍 매니저. 비동기 로드 요청 큐 관리
  • RequestAsyncLoadFSoftObjectPath(또는 배열)를 받아 백그라운드 스레드에서 로드, 완료되면 델리게이트 호출
  • FStreamableDelegate::CreateUObject — UObject 멤버 함수 포인터를 델리게이트로 바인딩. 객체가 GC되면 자동으로 안전 처리
1
FStreamableDelegate::CreateUObject(this, &AWeaponBox::OnWeaponClassLoaded)

이 한 줄이 곧 “이 액터의 OnWeaponClassLoaded를 콜백으로 등록”이다. 람다나 std::function으로 직접 짤 필요가 없다 — 엔진이 다 만들어둔 인프라를 가져다 쓰는 것.

5-6. 콜백 패턴에서의 멤버 변수 컨텍스트

비동기의 가장 큰 함정 — 콜백 시점에 지역 변수는 이미 소멸한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
void AWeaponBox::SpawnSelectedWeapon()
{
    UWeaponData* LocalSelected = SelectedWeapon;  // 지역 변수!
    Streamable.RequestAsyncLoad(
        LocalSelected->WeaponClass.ToSoftObjectPath(),
        FStreamableDelegate::CreateLambda([LocalSelected]() {
            // 콜백 시점에 LocalSelected가 살아있다는 보장 없음
            // (이 예시는 캡처라 살지만, 그 안의 SelectedWeapon이 GC됐을 수 있음)
        })
    );
    // 여기서 SpawnSelectedWeapon 종료
    // 콜백은 N프레임 뒤 호출됨
}

그래서 PendingWeaponClass·PendingWeaponName멤버 변수로 보관한다. 함수가 종료돼도 액터가 살아 있는 한 멤버는 유지. 콜백에서 this->PendingWeaponClass.Get()으로 안전하게 재참조.

CreateUObject(this, ...)this가 GC되면 콜백을 알아서 호출 안 함. 람다보다 더 안전한 이유.

5-7. 이미 로드된 케이스 분기 — 동일 경로 재사용

1
2
3
4
5
6
7
8
if (PendingWeaponClass.IsValid())
{
    OnWeaponClassLoaded();  // 직접 호출
}
else
{
    Streamable.RequestAsyncLoad(...);
}

IsValid() == true면 이미 메모리에 있다. 비동기 요청을 다시 보내면 즉시 콜백되긴 하지만 불필요한 매니저 호출이다. 직접 콜백을 호출하면 로드 경로 코드 = 캐시 히트 경로 코드가 한 함수로 통일된다 (Spawn 로직이 한 군데).

이 패턴이 깔끔한 이유 — 호출자 입장에서 SpawnSelectedWeapon()은 동기처럼 보이든 비동기로 처리되든 상관없다. 결국 콜백 한 곳에서 스폰이 일어난다는 단일 책임이 보장됨.

5-8. 동기 vs 비동기 — 실무 트레이드오프

항목동기 (LoadSynchronous)비동기 (RequestAsyncLoad)
코드 분기1줄, 직선함수 분리(요청·콜백) + 멤버 변수
게임 스레드 블록디스크 I/O 시간만큼 멈춤안 멈춤
첫 호출 hitch있음(특히 콜드 캐시)없음
로드 완료 보장호출 직후 보장콜백 안에서만 보장
디버깅 난이도낮음콜백 호출 순서가 비결정적이라 약간 까다로움
적합한 곳게임 시작·로딩 화면·확실히 가벼운 에셋게임플레이 중 동적 스폰·콘텐츠 무거운 에셋

WeaponBox는 게임플레이 중 플레이어가 다가갈 때 스폰되므로 비동기가 맞다. 캐릭터 셀렉트 화면 초기 로드처럼 “어차피 로딩 중이다”라면 동기가 더 단순해서 나을 수도 있다.

5-9. 학습 포인트 (오늘의 핵심)

  1. TSoftClassPtr vs TSubclassOf 구분 — Soft는 경로만, Hard는 즉시 참조. 메모리 풋프린트 차이가 큼
  2. IsNull / IsValid / Get 세 상태를 명확히 — 경로 존재 ≠ 메모리 로드 ≠ 즉시 사용 가능
  3. 비동기 = 콜백 패턴이지만 엔진이 제공하는 인프라(StreamableManager·FStreamableDelegate)를 쓰면 직접 람다·std::function·스레드 안전을 신경 쓸 필요가 없다
  4. 콜백에서의 컨텍스트는 멤버 변수로 — 지역 변수는 함수 종료와 함께 소멸. 비동기에서 살아남으려면 객체에 묶어야 함
  5. 이미 로드된 경우 직접 콜백 호출로 코드 경로를 통일 — 호출자가 동기·비동기를 신경 쓸 필요가 없게 단일 책임을 콜백으로 모음
  6. CreateUObject가 람다보다 안전 — 대상 객체 GC 시 자동으로 콜백 무시. WeakLambda보다도 의도가 명확

가장 큰 인사이트 — “비동기 = 람다나 콜백을 직접 새로 짜기”가 아니다. 엔진이 제공하는 StreamableManager·Delegate 인프라를 조립하면 된다. 새로 발명할 필요가 없다는 게 핵심.



오후·저녁 종합 학습 정리

  1. 부동소수 정밀도 깊이 부족 — 가수·ULP·machine epsilon 한 묶음으로 정리 필요 (다음 모의면접 전 보강)
  2. 캐시 히트율 95~99%가 기준선 — 5%의 miss가 DRAM 200~300 cycle을 먹어 전체 시간의 절반을 차지하는 구조 (25번 파일 작성 완료, 학습은 주말)
  3. 백트래킹의 핵심은 “선택지 트리의 표현 방식” — 부분집합은 단조 증가 인덱스, 순열은 자리별로 안 쓴 원소. used 플래그 vs 스왑 인플레이스의 메모리 트레이드오프
  4. 코드리뷰는 코드를 보면서 진행되므로 시연 흐름과 코드 위치를 1:1로 매핑 — 한 줄 주석 마커는 동료를 위한 검색 키이자 자가 문서화
  5. TSoftClassPtr + FStreamableManager::RequestAsyncLoad + FStreamableDelegate::CreateUObject 콜백 패턴 — 비동기 로드는 엔진 인프라 조립이지 람다 직접 짜기가 아니다. 멤버 변수로 컨텍스트 보관 + IsValid() 분기로 캐시 히트 경로 통일
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.