TIL 2026-05-18
2026-05-18 좀비 라그돌·히트스톱 후속 + CS 25 꼬리질문 + CS 26 페이지 폴트 신규
목차
- 2026-05-18 좀비 라그돌·히트스톱 후속 + CS 25 꼬리질문 + CS 26 페이지 폴트 신규
오늘 한 일 요약
- Ch3 좀비 라그돌 버그 — Unsafe 본 자식 본 폴백 —
Hit.BoneName이 Hips/Spine 같은 루트성 본으로 떨어지면SetAllBodiesBelow가 캐릭터 전체에 BlendWeight를 걸어 좀비가 그대로 눕는 버그. Mixamo + UE5 표준 9개 본을 unsafe set으로 묶고,FReferenceSkeleton으로 자식 본 6단계까지 내려가ImpactPoint에 가장 가까운 본을 찾도록 폴백 추가. 사용자 확인 완료 - HitReact ExcludedBones 양쪽 표준 확장 — 기존
{ "pelvis" }는 Mixamo 좀비에 그 이름이 없어 무력화 상태였음. Mixamo(Hips·Spine·Spine1·Spine2) + UE5(pelvis·spine_01~03·root) 9개로 확장. 본 폴백의 마지막 방어선 - 히트스톱 — 캡슐 충돌 응답 일시 무시 —
CustomTimeDilation = 0.1로 좀비를 멈춰도 뒤따라오던 다른 좀비가 캡슐 충돌로 멈춘 좀비를 밀어내는 push 현상.ApplyHitStop진입 시ACharacter캡슐의ECC_Pawn응답을Ignore로 변경하고, 복귀 람다에RestoreResp로 원래 응답을 복원 - CS 25 — 캐시 히트/미스 꼬리질문 4건 답변 보강 — 공간지역성·vector의 지역성·캐시 라인 64B 단위·Intel VTune 최적화 흐름.
25_cache_hit_miss.md의 꼬리질문 박스에 4건 채워 넣어 다음 모의면접 대비 마무리 - CS 26 — 페이지 폴트 신규 작성 —
raw/cs-notion/26_page_fault.md신규. 가상 메모리 → 페이지 테이블·present bit → CPU 트랩 → 3종 분류(Minor·Major·Invalid) → demand paging·lazy allocation → TLB miss와의 경계선 → 언리얼 Asset Streaming의 본질까지. 이론까지만, 예상 문제는 비워둠
미해결 — 히트스톱 효과 약함, develop 빌드 에러로 세션 중단. 둘 다 내일(5/19) 우선 처리. 인계 노트(scrum/2026-05-18_히트스톱_인계.md)에 의심 3가지와 패치 코드까지 정리해둠.
작업 환경
- 외부 프로젝트 —
D:\Unreal\8th-Team11-CH3-Project(Ch3 팀플, 발표 2026-05-26) - 수정 파일 — cpp 3개만, 헤더 변경 없음 (모두 단일 파일 내 변경)
Private/Combat/WeaponComponent.cpp— ApplyHitDamage(본 폴백) + ApplyHitStop(캡슐 충돌 무시)Private/Combat/HitReactComponent.cpp— 생성자 ExcludedBones 확장
- CS 자료 —
raw/cs-notion/25_cache_hit_miss.md(꼬리질문 4건 추가) +raw/cs-notion/26_page_fault.md(신규)
1. Ch3 좀비 라그돌 — Unsafe 본 자식 본 폴백
Private/Combat/WeaponComponent.cpp::ApplyHitDamage
버그 증상 — 캐릭터가 눕는다
5/15 코드리뷰 직후 사용자 테스트에서 발견된 후속 버그. 좀비 머리·팔·다리를 쏘면 의도대로 본별 흔들림이 잘 잡히는데, 가끔 좀비가 통째로 옆으로 눕는 현상이 보였다. 발사 위치를 옮기면서 재현해보니 사격 라인이 좀비의 가슴·골반 부근에 떨어졌을 때가 트리거.
원인 — 루트·척추 본에 BlendWeight 걸린 결과
Hit.BoneName이 Hips(Mixamo 루트) 또는 Spine·Spine1·Spine2로 떨어지면, HitReactComponent::PlayHitReact가 SetAllBodiesBelowSimulatePhysics(BoneName, true) + SetAllBodiesBelowPhysicsBlendWeight(BoneName, ...)를 호출.
문제는 이 본들이 전체 스켈레톤의 루트 또는 루트 근방이라는 점. SetAllBodiesBelow는 인자 본을 포함한 그 아래 전체에 BlendWeight를 거니까, Hips에 걸면 전신, Spine에 걸면 상체 전체 + 양팔 + 머리가 시뮬레이션에 들어간다. PhysicsAsset의 중력·관성이 작용해 좀비가 균형을 잃고 그대로 쓰러짐.
5/15에 작성한 _End 폴백(말단 본을 부모로 거슬러)은 본 효과가 너무 작은 쪽을 잡았지만, 반대쪽 — 본 효과가 너무 큰 루트/척추 쪽 폴백은 빠져 있었다는 게 핵심.
수정 — Unsafe set + 자식 본 6단계 탐색
include 추가(헤더 변경 없이 cpp만):
1
2
3
4
5
#include "GameFramework/Character.h"
#include "Components/SkeletalMeshComponent.h"
#include "Components/CapsuleComponent.h"
#include "Engine/SkinnedAsset.h"
#include "ReferenceSkeleton.h"
기존 _End 폴백 루프 뒤에 추가:
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
44
45
46
47
48
49
50
51
52
53
54
55
// 루트성 본은 BlendWeight 시 캐릭터가 통째로 쓰러짐 → 자식 본으로 내려감
static const TSet<FName> UnsafeRootBones = {
// Mixamo 표준
TEXT("Hips"), TEXT("Spine"), TEXT("Spine1"), TEXT("Spine2"),
// UE5 표준
TEXT("pelvis"), TEXT("spine_01"), TEXT("spine_02"), TEXT("spine_03"),
TEXT("root")
};
if (UnsafeRootBones.Contains(ResolvedBone))
{
if (USkeletalMeshComponent* SkelMesh = HitActor->FindComponentByClass<USkeletalMeshComponent>())
{
if (const USkinnedAsset* Asset = SkelMesh->GetSkinnedAsset())
{
const FReferenceSkeleton& RefSkel = Asset->GetRefSkeleton();
FName Current = ResolvedBone;
// 6단계까지 자식 본 중 ImpactPoint 가장 가까운 본 탐색
for (int32 Depth = 0; Depth < 6; ++Depth)
{
FName ClosestChild = NAME_None;
float ClosestDistSq = TNumericLimits<float>::Max();
const int32 ParentIdx = RefSkel.FindBoneIndex(Current);
if (ParentIdx == INDEX_NONE) break;
// 직속 자식만 순회
const int32 NumBones = RefSkel.GetNum();
for (int32 BoneIdx = ParentIdx + 1; BoneIdx < NumBones; ++BoneIdx)
{
if (RefSkel.GetParentIndex(BoneIdx) != ParentIdx) continue;
const FName ChildName = RefSkel.GetBoneName(BoneIdx);
const FVector ChildLoc = SkelMesh->GetBoneLocation(ChildName);
const float DistSq = FVector::DistSquared(ChildLoc, Hit.ImpactPoint);
if (DistSq < ClosestDistSq)
{
ClosestDistSq = DistSq;
ClosestChild = ChildName;
}
}
if (ClosestChild.IsNone()) break;
Current = ClosestChild;
// 더 이상 unsafe가 아니면 정지
if (!UnsafeRootBones.Contains(Current)) break;
}
ResolvedBone = Current;
}
}
}
핵심 포인트 ——
FReferenceSkeleton로 자식 본 순회 —SkelMesh->GetBoneName/Location만으로는 부모-자식 관계를 모름.FReferenceSkeleton::GetParentIndex(BoneIdx)로 트리 구조를 알 수 있다. 직속 자식은 인덱스가 부모보다 큰 본 중GetParentIndex == ParentIdx인 것- ImpactPoint 가장 가까운 자식 선택 — 가슴 정중앙을 쏜 경우 Spine → Spine1 → Spine2 순으로 내려가고, 옆구리를 쏜 경우 Spine → LeftShoulder/RightShoulder로 분기. 사격 방향과 일치하는 본이 자연스럽게 잡힘
- 6단계 제한 — Mixamo 좀비 기준 Hips → Spine → Spine1 → Spine2 → LeftShoulder → LeftArm 정도까지가 의미 있는 깊이. 무한 가드 + 손가락 본까지 내려가는 과한 폴백 차단
- 이미 unsafe가 아니면 즉시 정지 — Spine → Spine1까지 갔는데 Spine1이 unsafe면 계속, 안전한 본이면 거기서 끝
효과 — 사용자 테스트에서 좀비가 눕는 현상 해결 확인 완료. 가슴 사격은 Spine → Spine1 → Spine2 또는 LeftShoulder/RightShoulder로 안전하게 분기.
Mixamo 좀비 본 트리 정리
오늘 디버깅 중에 좀비1·좀비2 스켈레톤을 들여다보면서 정리한 본 트리:
1
2
3
4
5
6
7
Hips ← 루트
├── LeftUpLeg → LeftLeg → LeftFoot → LeftToeBase → LeftToe_End
├── RightUpLeg → RightLeg → RightFoot → RightToeBase → RightToe_End
└── Spine → Spine1 → Spine2
├── LeftShoulder → LeftArm → LeftForeArm → LeftHand → (손가락)
├── RightShoulder → RightArm → RightForeArm → RightHand → (손가락)
└── Neck → Head → HeadTop_End (좀비2: + LeftEye, RightEye)
좀비1·좀비2 모두 동일한 본 구성. 좀비2만 LeftEye/RightEye가 추가됐고, RightHandMiddle 본은 누락. Mixamo 표준 본 이름은 UE5 표준(pelvis·spine_01·upperarm_l 등)과 완전히 다르다 — 이게 ExcludedBones와 Unsafe set을 양쪽 표준으로 다 잡아야 하는 이유.
2. HitReactComponent — ExcludedBones 양쪽 표준 확장
Private/Combat/HitReactComponent.cpp 생성자
기존 설계의 함정 — Mixamo엔 pelvis가 없다
5/15 코드를 짤 때 ExcludedBones는 이렇게 잡혀 있었다:
1
2
// 기존
ExcludedBones = { "pelvis" };
설계 의도는 명확 — pelvis(골반)는 캐릭터 무게중심이라 BlendWeight 걸면 전신 흔들림이라 제외하자. 그런데 실제 좀비 스켈레톤은 Mixamo여서 pelvis라는 이름의 본이 존재하지 않는다. 즉 ExcludedBones가 들어 있긴 했지만 매칭되는 본이 0개라서 무력화 상태였다.
이게 1번 항목의 좀비 라그돌 버그가 발생한 두 번째 경로 — Hips/Spine을 막을 안전장치가 사실상 없었다.
수정 — Mixamo + UE5 9개 본 양쪽 등록
1
2
3
4
5
6
7
8
// 변경
ExcludedBones = {
// Mixamo 표준
TEXT("Hips"), TEXT("Spine"), TEXT("Spine1"), TEXT("Spine2"),
// UE5 표준
TEXT("pelvis"), TEXT("spine_01"), TEXT("spine_02"), TEXT("spine_03"),
TEXT("root")
};
설계 원칙 ——
- 양쪽 표준 모두 등록 — 좀비는 Mixamo, 플레이어는 UE5 표준일 가능성이 있어 한 컴포넌트로 양쪽을 다 잡아야 함. 같은 본이 두 이름 동시 존재하는 일은 없으니 9개 모두 등록해도 안전
- 1번의 Unsafe set과 일치 — 같은 9개 본을 ExcludedBones와 UnsafeRootBones에 동시 등록. 두 가지 동작이 가능 ——
- ExcludedBones 매치 →
PlayHitReact가 진입 즉시 SKIP (안전) - Unsafe set 매치 → 자식 본 6단계 폴백으로 안전한 본 찾기 (보정)
- ExcludedBones 매치 →
- 마지막 방어선이 ExcludedBones — 폴백이 실패해 끝까지 unsafe면 ExcludedBones가 그 본을 통째로 거른다. 좀비가 절대 통째로 눕지 않게 함
ExcludedBones와 Unsafe set은 역할이 다르다. ExcludedBones는 “이 본은 HitReact 안 함”(흔들림 0), Unsafe set은 “이 본은 자식으로 내려가서 HitReact”(흔들림 살리되 안전한 자식 본으로). 둘이 같은 9개 본에 걸려 있으면 폴백이 먼저 시도되고, 끝까지 안전한 본을 못 찾았을 때만 ExcludedBones가 SKIP시키는 흐름.
3. 히트스톱 — 캡슐 충돌 응답 일시 무시
Private/Combat/WeaponComponent.cpp::ApplyHitStop
증상 — 뒤의 좀비가 멈춘 좀비를 밀어낸다
히트스톱 자체는 5/15에 통합했고, 단일 좀비를 쏘면 0.1배 슬로우가 적용되는 게 확인됨. 그런데 여러 좀비가 무리지어 다가오는 상황에서 앞쪽 좀비를 쏘면, 뒤의 좀비들이 Pawn 채널 캡슐 충돌로 멈춘 좀비를 밀어내면서 슬로우 모션이 시각적으로 흐트러지는 현상.
즉 CustomTimeDilation = 0.1로 시간 자체는 슬로우인데, 물리적인 캡슐 충돌은 정상 속도로 계속 일어나는 비대칭이 문제.
원인 — CustomTimeDilation은 캡슐 충돌 응답을 막지 않는다
CustomTimeDilation은 액터의 Tick·이동·애니메이션 시간을 스케일하는 것이지, 콜리전 응답 채널 자체를 비활성화하지 않는다. 즉 멈춘 좀비의 캡슐은 여전히 ECC_Pawn에 Block 응답이라, 뒤의 좀비가 다가와 캡슐이 맞닿으면 물리 엔진은 그대로 Pawn-Pawn 푸시를 계산해 멈춘 좀비를 옆으로 밀어낸다.
이건 CustomTimeDilation 자체의 한계 — 시간 축은 슬로우지만 공간 축의 충돌 응답은 매 프레임 정상으로 평가되는 것.
수정 — 캡슐 ECC_Pawn 응답을 Ignore로 일시 변경 + 복귀 시 원복
ApplyHitStop에서 CustomTimeDilation 설정 직후, 캡슐의 ECC_Pawn 응답을 Ignore로 일시 변경:
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
44
45
46
47
48
49
50
51
52
53
54
55
void UWeaponComponent::ApplyHitStop(AActor* HitActor, AActor* Instigator)
{
if (!HitActor) return;
// 1) 시간 슬로우 (기존)
HitActor->CustomTimeDilation = HitStopTimeScale;
if (APawn* HitPawn = Cast<APawn>(HitActor))
{
if (AController* HitController = HitPawn->GetController())
{
HitController->CustomTimeDilation = HitStopTimeScale;
}
}
if (Instigator) Instigator->CustomTimeDilation = HitStopTimeScale;
// 2) 캡슐 ECC_Pawn 응답을 일시 Ignore (신규)
TWeakObjectPtr<UCapsuleComponent> WeakCapsule;
ECollisionResponse RestoreResp = ECR_Block;
if (ACharacter* HitChar = Cast<ACharacter>(HitActor))
{
if (UCapsuleComponent* Capsule = HitChar->GetCapsuleComponent())
{
WeakCapsule = Capsule;
RestoreResp = Capsule->GetCollisionResponseToChannel(ECC_Pawn);
Capsule->SetCollisionResponseToChannel(ECC_Pawn, ECR_Ignore);
}
}
// 3) Duration 후 복귀 — 시간 + 캡슐 응답 동시 원복
FTimerHandle Handle;
TWeakObjectPtr<AActor> WeakHit = HitActor;
TWeakObjectPtr<AActor> WeakInst = Instigator;
GetWorld()->GetTimerManager().SetTimer(Handle,
[WeakHit, WeakInst, WeakCapsule, RestoreResp]()
{
// 시간 복귀
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;
// 캡슐 응답 복귀 — 원래 응답으로 정확히 복원 (Block이었으면 Block, Overlap이었으면 Overlap)
if (WeakCapsule.IsValid())
{
WeakCapsule->SetCollisionResponseToChannel(ECC_Pawn, RestoreResp);
}
}, HitStopDuration, false);
}
핵심 설계 ——
- 원래 응답을 캡처 + 복원 —
RestoreResp로 캡슐의 원래ECC_Pawn응답(보통 Block, 경우에 따라 Overlap)을 미리 저장해두고, 복귀 람다에서 정확히 그 값으로 되돌린다. 무조건 Block으로 원복하면 원래 Overlap이었던 좀비의 설정이 깨짐 - WeakObjectPtr 캡처 — 람다 실행 시점(0.15초 뒤)에 액터가 이미 파괴됐을 수 있음.
TWeakObjectPtr로 캡처하면IsValid()체크로 안전. 일반 raw 포인터 캡처는 dangling 위험 - 시간과 공간 동시 원복 —
CustomTimeDilation복귀와 캡슐 응답 복귀가 같은 람다 안에서 일어나야 함. 둘 중 하나만 늦으면 또 비대칭
효과 — 멈춘 좀비를 뒤에서 다른 좀비가 밀어내는 push 차단. 슬로우 모션 동안 멈춘 좀비는 그 자리에 멈춰 있고 뒤의 좀비도 캡슐을 통과하지 않고 자기 자리에서 멈춤(슬로우 적용 받음).
4. CS 25 — 캐시 히트/미스 꼬리질문 4건 답변
raw/cs-notion/25_cache_hit_miss.md의 꼬리질문 박스에 오전 모의면접 답변을 4건 채워 넣었다. 자료를 주말에 학습한 뒤 모의면접에서 받은 질문이 정확히 이 네 가지 — 자료 작성 시 예상했던 흐름과 거의 일치.
Q1. 공간지역성(Spatial locality) 추가 설명
핵심 답변 ——
- 공간지역성 — “방금 접근한 주소 근처를 곧 또 접근할 가능성이 높다”는 경험적 원리. 배열을
[0], [1], [2], ...순서로 순회하는 패턴이 대표 사례 - 하드웨어 메커니즘 — 메모리 한 바이트만 요청해도 CPU는 그 바이트가 속한 64바이트 캐시 라인을 통째로 가져온다. 즉 라인 단위 fetch가 공간지역성을 자동으로 활용하게 만드는 구조
- 시간지역성과의 차이 — Temporal은 “같은 주소를 반복 접근”, Spatial은 “인접 주소를 차례로 접근”.
for (i=0; i<N; ++i) sum += v[i];에서 v 원소 접근은 spatial, sum 누적은 temporal
면접에서 강조한 한 줄 — “공간지역성은 프로그래머가 의식하지 않아도 캐시 라인 fetch가 자동으로 살려준다. 단 자료구조가 연속 메모리일 때만 — 연결 리스트는 그 자동 보너스를 못 받는다.”
Q2. vector의 시간지역성·공간지역성
| 컨테이너 | 공간지역성 | 시간지역성 | 한 줄 평 |
|---|---|---|---|
std::vector | 만점 — 원소가 연속 메모리에 일렬로, 한 라인에 16개씩 들어옴 | 알고리즘 의존 — 같은 원소 반복 접근하는지에 따라 다름 | 공간지역성이 강점, prefetcher가 잘 동작 |
std::list | 형편없음 — 노드가 힙에 흩어져 매 노드가 별도 캐시 라인 | 알고리즘 의존 | 캐시 친화성에서 거의 항상 vector에 짐 |
답변 핵심 ——
- vector의 공간지역성이 만점인 이유 —
vector<T>는 내부적으로T*하나가 가리키는 연속 버퍼.&v[0],&v[1],&v[2]의 주소가sizeof(T)씩 정확히 증가. 한 캐시 라인(64B)에int라면 16개,Vec3(12B)면 5개가 같이 fetch됨 - 하드웨어 prefetcher와의 시너지 — 일정한 stride 패턴은 prefetcher가 즉시 감지해 다음 라인까지 자동 끌어옴. vector 순회는 사실상 캐시 미스가 첫 라인 한 번만 발생하고 이후는 모두 hit
- 시간지역성은 알고리즘 책임 — vector 자체가 시간지역성을 보장하지는 않음. 같은 원소를 여러 번 쓰는 알고리즘이라면 자연스럽게 살아남(워킹 셋이 L1에 들어가는 한). 단순 한 번 순회면 시간지역성은 0에 가까움
Q3. 메모리를 담을 때 최소 용량 단위
답변 핵심 — 메모리↔캐시 전송 단위는 바이트가 아니라 캐시 라인 64바이트.
- 프로그래머가
char c = arr[0];처럼 1바이트만 요청해도, CPU와 캐시 컨트롤러는arr[0]이 속한 64바이트 정렬 영역을 통째로 DRAM에서 가져온다 - 산업 표준 — Intel·AMD·대부분 ARM에서 64B. 예외 — Apple M1·M2는 128B, IBM POWER도 128B
- 이 라인 단위가 spatial locality의 토대. 만약 라인이 1바이트였다면 spatial locality 자체가 무의미했을 것
면접 답변 한 줄 — “최소 단위는 64바이트 캐시 라인입니다. 메모리↔캐시 전송은 바이트 단위가 아니라 라인 단위로 일어납니다.”
핵심 함의 ——
alignas(64)로 false sharing 회피 — 서로 다른 스레드가 인접 변수에 쓰면 같은 라인이 무효화되어 성능 폭락. 라인 단위 정렬로 분리- 구조체 패딩과 캐시 라인 —
sizeof(struct) > 64면 한 객체가 두 라인에 걸침. 자주 같이 쓰는 필드는 한 라인 안에 모아야 함 std::hardware_destructive_interference_size— C++17이 제공하는 캐시 라인 크기 상수. 플랫폼별로 정확한 값
Q4. Intel VTune으로 캐시 히트/미스 최적화하는 법
답변 흐름 ——
- VTune이 뭔지 — Intel CPU의 성능 카운터(PMU)를 GUI로 시각화해주는 프로파일러. Windows·Linux·macOS 동작, 개인용 무료. AMD CPU에서는 일부 기능 제한
- Memory Access 분석 — VTune에서
Memory Access또는Microarchitecture Exploration모드 실행. L1/L2/L3 miss rate, LLC(Last Level Cache) miss, DRAM bandwidth가 함수별·라인별로 보임 - Hotspot 식별 — miss rate가 높고 자주 실행되는 함수가 핵심. 한 줄 코드에 캐시 미스가 몰린 부분이 강조됨
- 자료구조·접근 패턴 개선 — 비용 큰 곳부터 ——
- AoS → SoA 변환 — 한 필드만 쓰는 hot loop에서 캐시 라인 활용도 100%
- list → vector 교체 — 연속 메모리로 공간지역성 살림
- 루프 순서 i·j 바꿔서 row-major로 정렬 — 2차원 배열 순회에서 차원 순서 맞추기
- tiling/blocking으로 워킹 셋을 L1에 맞추기
- 자주 쓰는 필드만 hot struct로 분리 (Hot/Cold splitting)
- false sharing 의심 변수에
alignas(64)
- 적용 후 VTune 재실행 — miss rate 감소 확인. 한 사이클마다 측정·개선 반복
다른 도구 짝 ——
| 도구 | 플랫폼 | 비고 |
|---|---|---|
| Intel VTune | Intel CPU | 가장 상세한 캐시 분석 |
| AMD μProf | AMD CPU | VTune의 AMD 버전 |
Linux perf stat | Linux | CI에서 회귀 감지용. cache-misses cache-references 이벤트 |
| Tracy / Unreal Insights | 게임 엔진 | 프레임 단위 시각화. 캐시 분석은 약하지만 hot loop 위치 식별엔 충분 |
면접 답변 — “VTune의 Memory Access 분석으로 라인별 miss rate를 보고, hotspot 함수부터 자료구조 변경(AoS → SoA·vector 교체·alignas)으로 개선합니다. Linux CI에서는 perf stat으로 회귀를 잡습니다.”
5. CS 26 — 페이지 폴트(Page Fault) 신규 작성
raw/cs-notion/26_page_fault.md 신규 작성. 다음 모의면접 주제 — “페이지 폴트(Page Fault) 현상에 대해 설명하고 언제 발생하는지 설명해보세요”. 이론까지만, 예상 문제 슬롯은 비워둠(메모리 정책).
작성 의도와 25번 연결
25번에서 “캐시에 없으면 DRAM에서 가져온다”고 했고 마지막에 TLB miss를 다뤘다. 그런데 DRAM에도 없으면 어떻게 되는가 — 이게 26번의 주제. 캐시 미스는 하드웨어가 해결하지만 페이지 폴트는 OS 커널이 개입한다. 단순한 메모리 접근 한 번이 컨텍스트 스위치급 비용을 일으킬 수 있는 경계선.
1
2
3
4
5
6
01번 메모리 4영역 (Code/Data/Heap/Stack) ← 가상 주소 공간 구조
11번 메모리 레이아웃 (페이지·TLB) ← 가상→물리 주소 변환의 기본
21번 context switching ← 페이지 테이블 교체·TLB flush
25번 캐시 히트 / 미스 (TLB miss) ← 변환 캐시 미스
─────────────────────────────────────────────
26번 페이지 폴트 (★) ← 매핑 자체가 없을 때 OS 개입
핵심 직관 — “페이지 폴트는 버그가 아니라 정상 동작”인 경우가 많다. malloc으로 1GB 잡고 첫 쓰기 때마다 minor fault가 발생하는 건 demand paging의 정상 흐름. 진짜 문제는 major fault(디스크 I/O)와 invalid fault(SIGSEGV). 면접에서는 이 세 종류 구분이 핵심.
페이지 폴트 3종 분류 — 비용 차이가 핵심
| 항목 | Minor | Major | Invalid |
|---|---|---|---|
| 물리 메모리 상태 | 페이지 존재 | 페이지 디스크에 | 매핑 자체 없음 |
| I/O 필요? | 없음 | 있음 (디스크) | 없음 |
| 비용 | µs 단위 | ms 단위 | µs 단위 (그리고 종료) |
| 결과 | 매핑 연결 후 재실행 | 디스크 읽어 적재 후 재실행 | SIGSEGV로 프로세스 종료 |
| 흔한 원인 | 공유 라이브러리·malloc 첫 쓰기·COW | swap-in·mmap·실행파일 첫 호출 | nullptr·댕글링·스택오버플로우 |
비용 스펙트럼 — 캐시 미스부터 페이지 폴트까지
| 사건 | 비용 |
|---|---|
| L1 cache hit | 1 ns |
| DRAM 접근 (캐시 miss) | 60~100 ns |
| TLB miss → page walk | 30 ns |
| Minor page fault | 수 µs (~10,000 cycles) |
| Major page fault (SSD, NVMe) | ~100 µs |
| Major page fault (HDD) | 5~15 ms |
해석 — 60fps 게임 1프레임 예산은 16.6ms. HDD major fault 한 번이면 그 프레임을 통째로 날린다. SSD라도 100µs면 16.6ms의 1/170 — 누적되면 hitch 발생. 그래서 게임 엔진은 레벨 로드 시 페이지 폴트를 의도적으로 미리 일으키고(preload), 게임 플레이 중에는 폴트가 거의 안 나오게 설계.
처리 흐름 — present bit가 페이지 폴트의 if문
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. CPU: MOV RAX, [RBX+8] (가상 주소 0x7ffc1234 접근)
2. MMU: TLB 조회 → miss → 페이지 테이블 walk
3. PTE 확인: present bit = 0
4. CPU: 트랩 발생 (#PF, 인터럽트 벡터 14번), 커널 진입
5. OS Page Fault Handler:
- CR2 레지스터의 폴트 주소가 어느 VMA에 속하나 검색
- VMA가 없으면 → SIGSEGV (invalid)
- VMA 있으면 → PTE 비트로 종류 판별
├─ swap entry → 디스크 읽기 (major)
├─ file-backed → 파일 읽기 (major)
├─ COW 마킹 → 새 프레임 복제 (minor)
├─ lazy alloc → zero page 할당 (minor)
└─ shared → 기존 프레임 매핑만 연결 (minor)
- PTE 갱신 (present=1), TLB invalidate
6. 커널 → 사용자 복귀 → 폴트 명령어 재실행 (instruction restart)
핵심 ——
- present bit이 페이지 폴트의 if문 — PTE의 비트 0번이 0이면 무조건 트랩. OS가 다른 비트(swap entry·COW 표시 등)를 보고 종류를 판별
- 명령어 재실행(precise exception) — 폴트가 발생한 명령어를 부분 실행이 아니라 통째로 다시 실행. 사용자 입장에선 그냥 좀 느린 메모리 접근으로 보임
- 트랩 vs 인터럽트 — 트랩은 명령어 실행으로 동기 발생(페이지 폴트, syscall), 인터럽트는 외부 하드웨어에서 비동기 발생(키보드, 타이머). 페이지 폴트는 트랩이라 “정확히 어느 명령어에서”가 결정됨
TLB miss vs Page Fault — 25번과의 경계선
| 항목 | TLB miss | Page fault |
|---|---|---|
| 발생 조건 | TLB에 변환 결과 없음 | PTE의 present=0 |
| 매핑 존재? | 페이지 테이블에 있음 | 없거나 디스크에 있음 |
| OS 개입 | 없음 (하드웨어 page walk) | 있음 (커널 handler) |
| 비용 | 수십 cycles (~10~100ns) | µs ~ ms |
| 컨텍스트 스위치 | 없음 | 사용자→커널 전환 |
| 명령어 흐름 | 매끄럽게 진행 | 트랩으로 중단 후 재실행 |
면접에서 강조 — “TLB miss는 매핑이 있는데 변환 캐시만 비어있는 것이고, 페이지 폴트는 매핑 자체가 없거나 디스크에 있는 것입니다. 둘은 단계가 다릅니다.”
Huge Page(2MB·1GB) 가 둘 다를 줄여줌 — 한 TLB 엔트리가 더 큰 영역을 커버하니까 TLB miss ↓, 페이지 단위 자체가 커서 페이지 폴트 횟수 ↓.
언리얼 Asset Streaming의 본질
- 레벨 로드 = 의도된 major fault 폭증 — 레벨 처음 로드할 때
umap파일의 에셋 페이지에 대량 major fault가 발생. 로딩 화면의 본질이 이것 - Asset Streaming = 페이지 폴트의 시간 분산 — 플레이어 주변만 메모리에 두고 멀어진 영역은 디스크. 플레이어 이동 방향을 예측해 다음 region을 백그라운드 preload → 도달 시점엔 이미 메모리에 있음 → 폴트 없음
- Memory Pool — 게임 시작 시 큰 블록을 미리 할당하고 모든 페이지에 touch → minor fault를 미리 일으켜둠. 이후 풀에서 잘라 쓸 때는 폴트 없음
- 콘솔 환경(PS5·XSX) — swap이 거의 없음. 메모리 부족은 곧 크래시 → 워킹 셋 정확히 관리. PC보다 메모리 프로파일링이 엄격
오늘 학습한 게임 엔진 메모리 관리 목표 한 줄 — “hot path에서 major fault 0회, minor fault 최소화.”
미해결 — 히트스톱 효과 약함 (내일 5/19 우선 처리)
사용자 보고 — “히트스톱이 저번에도 그렇고 제대로 적용 안 된 것 같다.”
HitStopTimeScale = 0.1, HitStopDuration = 0.15로 헤더 디폴트 값은 정상 수치. 코드 진단 필요.
의심 1 — BP 인스턴스 오버라이드
PlayerCharacter BP 또는 Weapon 관련 BP에서 디폴트 값을 덮어썼을 가능성. BP 디테일 패널 → Weapon | Hit Stop 카테고리에서 실제 인스턴스 값을 확인해야 함.
확인 방법 — BP를 열어 디테일 패널의 Weapon | Hit Stop 카테고리에서 HitStopTimeScale·HitStopDuration 값을 본다. 노란색 화살표(reset to default)가 보이면 오버라이드된 상태.
의심 2 — FireInterval 충돌 (가장 의심)
현재 ApplyHitStop은 매 발사마다 지역 FTimerHandle 로 SetTimer를 호출. 연사 시 ——
1
2
3
4
시간 t=0.00s — 좀비A 피격 → CustomTimeDilation 0.1 + 타이머 SetTimer(0.15s 뒤 1.0 복귀)
시간 t=0.10s — 좀비A 다시 피격 → CustomTimeDilation 0.1 + 새 타이머 SetTimer
시간 t=0.15s — 첫 번째 타이머 만료 → CustomTimeDilation 1.0 복귀 (← 여기서 슬로우 풀림)
시간 t=0.25s — 두 번째 타이머 만료 → CustomTimeDilation 1.0 (이미 1.0이라 효과 없음)
즉 첫 번째 복귀 람다가 새로 정지된 상태를 1.0으로 덮어버리는 레이스. 결과적으로 두 번째 슬로우가 0.05초만 지속되고 끝.
패치 — 액터별 타이머 맵으로 추적
1
2
3
4
// WeaponComponent.h — 멤버 추가
private:
// ApplyHitStop 추적 — 같은 액터 연속 피격 시 이전 복귀 타이머 취소
TMap<TWeakObjectPtr<AActor>, FTimerHandle> HitStopTimers;
1
2
3
4
5
6
7
8
9
10
11
12
13
// ApplyHitStop 진입부에 추가
if (FTimerHandle* Existing = HitStopTimers.Find(HitActor))
{
World->GetTimerManager().ClearTimer(*Existing);
}
// SetTimer 호출을 지역 변수 → 멤버 맵으로
FTimerHandle NewTimer;
World->GetTimerManager().SetTimer(NewTimer, /* lambda */, HitStopDuration, false);
HitStopTimers.Add(HitActor, NewTimer);
// 복귀 람다 마지막에 맵에서 제거
HitStopTimers.Remove(WeakHit);
이렇게 두면 같은 액터에 연속 피격이 들어와도 이전 복귀 타이머가 즉시 취소되어 새 슬로우 기간이 온전히 0.15초 보장. 가장 유력한 원인이라 내일 가장 먼저 패치.
의심 3 — Anim BP가 World Delta 직접 참조
AnimGraph의 Event Update Animation 노드는 인자로 Delta Time을 받는데, 이 값이 자동으로 CustomTimeDilation 스케일을 반영. 그런데 노드 안에서 명시적으로 Get World Delta Seconds 노드를 호출하면 이건 액터 무관 글로벌 delta라 CustomTimeDilation 무시.
확인 — 좀비 AnimBP를 열어 Event Update Animation 본체에 Get World Delta Seconds 노드가 있는지 검색. 있으면 Event Update Animation의 입력 Delta Time으로 교체.
진단 로그 (선택)
ApplyHitStop 진입부에 한 줄:
1
2
UE_LOG(LogTemp, Warning, TEXT("[HitStop] %s scale=%.2f dur=%.2fs"),
*HitActor->GetName(), HitStopTimeScale, HitStopDuration);
PIE에서 사격 후 로그가 찍히는지 확인 — 안 찍히면 ApplyHitStop 자체가 호출 안 됨(다른 경로 문제), 찍히는데 스케일이 다르면 BP 오버라이드(의심 1), 찍히는데 효과 약하면 의심 2 또는 3.
세션 중단 — develop 빌드 에러 (내일 5/19 첫 작업)
오늘 패치 마무리 단계에서 develop 빌드 중 에러 발생. 스크린샷 첨부를 위해 CLI 재시작 필요해 세션 중단. 빌드 에러 로그 확보가 우선이라 패치(의심 2 fix)는 뒤로 미룸.
재시작 후 첫 메시지 흐름 ——
scrum/2026-05-18_히트스톱_인계.md읽기- 빌드 에러 스크린샷 첨부 → 에러 잡기
- 위 의심 2 fix(HitStopTimers 멤버 맵) 패치
- PIE에서 진단 로그로 효과 검증
- 빌드 + 동작 확인 후 커밋
관련 파일 경로 ——
| 파일 | 변경 |
|---|---|
D:\Unreal\8th-Team11-CH3-Project\Source\NBC_Ch3_TeamProject\Private\Combat\WeaponComponent.cpp | ①③ 적용됨, 의심2 패치 대상 |
D:\Unreal\8th-Team11-CH3-Project\Source\NBC_Ch3_TeamProject\Public\Combat\WeaponComponent.h | 의심2 패치 시 멤버 추가 대상 |
D:\Unreal\8th-Team11-CH3-Project\Source\NBC_Ch3_TeamProject\Private\Combat\HitReactComponent.cpp | ② 적용됨 |
오늘 배운 것 정리
- HitReact 안전 본 폴백의 실전 검증 — Mixamo와 UE5 표준은 본 이름이 완전히 다르다 —
pelvis한 개만 ExcludedBones에 넣어둔 게 Mixamo 좀비에선 무력화 상태였던 게 오늘의 핵심 교훈. 양쪽 표준(Hips·Spine·Spine1·Spine2 + pelvis·spine_01~03·root) 9개를 모두 등록해야 함. 처음 설계 때 “스켈레톤 스타일 한 가지만 가정”한 게 함정. ExcludedBones는 “이 본은 HitReact 안 함”, Unsafe set은 “자식 본으로 내려가서 HitReact” — 두 역할이 다르고 같은 9개 본에 둘 다 등록해두면 폴백이 먼저 시도되고 끝까지 안전한 본 못 찾으면 SKIP되는 안전 흐름이 됨 FReferenceSkeleton로 자식 본 트리 탐색 —GetParentIndex가 핵심 — SkeletalMesh의GetBoneName/Location만으로는 부모-자식 관계를 모름.FReferenceSkeleton::GetParentIndex(BoneIdx)로 트리를 알 수 있고, 직속 자식은 인덱스가 부모보다 큰 본 중GetParentIndex == ParentIdx인 것. 자식 본 중ImpactPoint에 가장 가까운 본을 6단계까지 내려가며 찾는 패턴은 다른 본 기반 폴백에도 재사용 가능- CustomTimeDilation의 함정 — 시간과 공간 충돌 응답은 별개 —
CustomTimeDilation은 액터의 Tick·이동·애니메이션 시간을 스케일하지만, 콜리전 응답 채널은 정상 속도로 평가됨. 멈춘 좀비를 뒤의 좀비가 캡슐 충돌로 밀어내는 push는 이 비대칭 때문. 해결은 캡슐의ECC_Pawn응답을 일시 Ignore + 복귀 시 원래 응답을 정확히 복원(Block이었으면 Block, Overlap이었으면 Overlap). 원래 응답을 캡처 안 하고 무조건 Block으로 원복하면 원래 Overlap이던 좀비 설정이 깨짐 - 연사 시 같은 액터에 누적되는 타이머는 멤버 맵으로 추적 — 매 호출마다 지역
FTimerHandle로 SetTimer만 하면 이전 복귀 람다가 새로 정지된 상태를 덮어버리는 레이스. 해결은TMap<TWeakObjectPtr<AActor>, FTimerHandle>멤버로 액터별 타이머 추적 → 새 SetTimer 전에 기존 ClearTimer. 같은 패턴은 모든 “타이머 기반 일시 효과”에 일반화 가능 (스턴·도트·디버프 누적 등) - 공간지역성은 자료구조가 연속 메모리일 때만 자동으로 살아난다 — 캐시 라인 64B fetch가 spatial locality를 자동으로 활용하지만, 자료구조가 연결 리스트면 노드가 힙에 흩어져 매 노드가 별도 캐시 라인 → 자동 보너스 없음.
vector의 강점은 공간지역성, 시간지역성은 알고리즘 책임. 면접에서 “왜 vector가 빠른가” 답할 때 prefetcher와 캐시 라인 16개(int 기준)를 같이 묶어 설명 - 페이지 폴트의 3종 분류 — minor·major·invalid의 비용은 6~7자릿수 차이 — Minor(매핑만 갱신, µs)·Major(디스크 I/O, ms)·Invalid(SIGSEGV). 60fps의 16.6ms 프레임 안에 HDD major fault 한 번 = 프레임 통째 손실. 게임 엔진의 Asset Streaming은 “페이지 폴트를 시간상 분산시켜 게임 플레이 중 폴트 0을 만드는” 설계. 26번이 25번을 자연스럽게 확장 — 25번이 “캐시에 없으면 DRAM”, 26번이 “DRAM에도 없으면 OS 개입”
다음 단계 — 5/19(화) 우선순위
- ⬜ 빌드 에러 잡기 — 스크린샷 첨부 후 즉시 해결. 패치 진행의 전제
- ⬜ 히트스톱 의심 2 패치 — HitStopTimers 멤버 맵 —
WeaponComponent.h에 멤버 추가,ApplyHitStop진입부에 ClearTimer + 멤버 맵 저장, 복귀 람다에서 Remove - ⬜ PIE 진단 로그로 효과 검증 —
[HitStop]로그가 정상적으로 찍히는지, 연사 시에도 0.15초 슬로우가 온전히 유지되는지 확인 - ⬜ 의심 1·3 잔여 검증 — BP 디테일 패널에서 오버라이드 여부, AnimBP에서
Get World Delta Seconds노드 사용 여부 확인 - ⬜ graphify CS 그래프 업데이트 — 25번 꼬리질문 보강 + 26번 신규 일괄 반영.
graphify update .1회 실행 - ⬜ 이월 항목들 — TIL
5월/2026-05-14.md작성, 코드카타100zun/2026-05-15-*.cpp정리, 24번 floating_point 정밀도 파트 보강, 블로그 마이그레이션, NBC_Master Step 2 진입 결정