왜 피격 인디케이터를 만들었는지?

게임에서 플레이어가 공격받았을 때 “어디서 맞았는지” 알려주는 피격 인디케이터는 단순한 UI 장식이 아니다.
이 작은 시각적 신호 하나가 플레이어의 생존을 좌우한다.
공격 방향을 즉각 인지해야 반격하거나 회피할 수 있기 때문이다.
결국 피격 인디케이터는 플레이어 경험(Player Experience)의 질을 결정짓는 핵심 피드백 시스템이다.
이 글에서는 언리얼 엔진 C++ 환경에서 피격 인디케이터를 구현하며 마주한 5가지 문제와 해결 과정을 공유한다.
단순한 기능 설명이 아니라, 어떤 함정에 빠졌고 어떻게 빠져나왔는지를 기록한 개발 회고록이다.
우리 시스템의 기본 구조는 간단했다.
UUserWidget 내부에 상하좌우 각 방향별로 그래디언트 텍스처를 적용한 Image 위젯을 배치하고,
피격 방향에 따라 해당 이미지의 투명도를 조절하는 방식이다.
하지만 이 단순한 구조 위에서 예상치 못한 문제들이 연속으로 터져 나왔다.
1. 방향 없는 도트 데미지는 왜 표시되지 않았는가

UI 텍스처는 AI로 기본 이미지를 뽑은 후에 포토샵으로 리터칭하였다!

🔍 문제 현상
몬스터의 근접 공격처럼 명확한 방향이 있는 피격은 정상적으로 처리됐다.
하지만 용암 함정 위에 있어서 도트데미지를 입는 것에는 정상 처리 되지 않았다.
⚠️ 원인 분석
문제의 원인은 "모든 데미지는 공격 방향 정보를 포함할 것이다"라는 섣부른 가정이었다.
몬스터 공격에만 집중하다 보니 환경 데미지를 놓쳤다.
언리얼 엔진의 데미지 이벤트는 세 가지로 나뉜다.
| 데미지 타입 | 특징 | 예시 |
| FDamageEvent | 방향 정보 없음 (기본형) | 도트 데미지, 환경 피해 |
| FPointDamageEvent | ShotDirection 방향 정보 포함 | 탄환, 근접 공격 |
| FRadialDamageEvent | Origin 발생 위치 정보 포함 | 폭발, 범위 공격 |
화염 함정의 도트 데미지는 방향 정보가 없는 기본 FDamageEvent로 처리되고 있었고,
코드는 이 경우를 전혀 고려하지 않았다.
🛠️ 해결 과정
'Omni(전 방향)'라는 새로운 인디케이터 타입을 시스템에 추가했다.
핵심은 데미지 처리부와 UI 위젯 간의 명확한 약속을 정의하는 것이었다.

- 1단계 - 데미지 판정부:
- 들어온 데미지 이벤트가 방향 정보를 포함하지 않는 FDamageEvent일 경우, 방향 벡터를 ZeroVector로 설정하여 UI 시스템에 전달한다. 이것이 '방향을 특정할 수 없는 전방위 공격’임을 알리는 약속된 신호다.
- 2단계 - UI 위젯:
- ZeroVector 신호를 ‘Omni’ 타입으로 해석한다.
- Omni 타입으로 판정되면 화면의 상하좌우 모든 방향의 인디케이터 이미지 투명도를 동시에 높여 화면 전체에 붉은색 테두리가 번지는 효과를 연출한다.
💡 핵심 교훈
방어적 프로그래밍의 기본은 최악의 경우를 가정하는 것이다.
모든 입력 케이스를 예측하고 else 분기를 철저히 처리해야 한다.
게임플레이의 명확성과 직결되는 UI 피드백은 누락되어서는 안 된다.
정상 동작에만 집중하다가 엣지 케이스(극단적이거나 예외적인 입력 상황)를 놓칠 수 있다.
2. 수평 공격이 '아래 방향’으로 잘못 표시되는 현상
🔍 문제 현상
정면에서 수평으로 날아오는 프로젝타일에 맞았는데, 캐릭터의 하체(다리나 발)에 충돌했을 때
피격 인디케이터가 ‘아래(Down)’ 방향으로 잘못 표시됐다.
플레이어는 잘못된 정보로 인해 엉뚱한 방향을 경계하게 된다...
⚠️ 원인 분석
문제의 근본은 GetActorLocation() 함수가 반환하는 값의 의미를 오해한 데 있었다.
이 함수는 캐릭터의 실제 피격 부위가 아닌, 액터의 중심점(주로 캡슐 컴포넌트의 중앙, 허리 높이) 좌표를 반환한다.
수평으로 날아온 공격이 캐릭터의 발에 맞았다고 가정해 보자.
- 캐릭터의 위치는 GetActorLocation()을 통해 ‘허리’ 높이로 계산된다.
- 공격의 발생 위치는 실제 충돌 지점인 ‘발’ 높이다.
- 결과적으로 공격 위치가 캐릭터 위치보다 Z축 기준으로 아래에 있게 되므로, 공격자 방향 벡터(DirToAttacker)의 Z 성분 값이 음수가 된다.
기존 로직은 이 Z값이 특정 임계값보다 낮으면 무조건 '아래에서 온 공격’으로 판정했다.
기술적으로는 맞지만, 플레이어 경험 측면에서는 틀린 결과였다.

🛠️ 해결 과정
단순한 절대값 임계치 비교 방식의 한계를 깨닫고,
공격 방향 벡터의 수직 성분과 수평 성분의 크기를 ‘상대적으로 비교’하는 새로운 로직을 도입했다.
핵심 질문은 이것이었다:
“이 공격은 수평 성분에 비해 수직 성분이 큰가?”
| 상황 | UpDot | MaxHorizontal | 개선 전 | 개선 후 |
| 정면 프로젝타일이 발에 맞음 | -0.6 | 0.7 | Down ❌ | Front ✅ |
| 천장에서 낙석 | 0.9 | 0.2 | Up ✅ | Up ✅ |
| 바닥 스파이크 | -0.85 | 0.15 | Down ✅ | Down ✅ |
이 방식을 적용하면 수평으로 날아온 공격이 발에 맞아 Z축 값이 다소 발생하더라도,
훨씬 더 큰 수평 성분 값 덕분에 올바른 ‘수평(Front)’ 공격으로 판정할 수 있다.
💡 핵심 교훈
- 좌표계의 이해:
- GetActorLocation()과 실제 충돌 위치의 차이처럼, 엔진이 제공하는 함수의 의미를 정확히 파악해야 한다. 함수 이름만 보고 추측하면 안 된다.
- 판정 로직 설계:
- 절대값 비교만으로는 한계가 명확한 상황에서는 '비율 기반의 상대적 비교’가 훨씬 더 견고하고 정확한 결과를 만든다.
3. 크래시와 NaN
🔍 문제 현상 1: Null Pointer 크래시
게임 실행 중 Access Violation 오류가 발생하며 프로그램이 강제 종료됐다.
원인을 추적해보니, 도트 데미지를 처리하는 과정에서 DamageTypeClass 포인터가 nullptr인 상태에서 멤버 함수인 GetName()을 호출하려다 발생한 크래시였다.
⚠️ 원인 분석
근본 원인은 외부 시스템(도트 데미지 시스템)에서 DamageTypeClass를 설정하지 않은 채 데미지 이벤트를 보냈기 때문이었다. 코드는 “당연히” 올바른 데이터를 보내줄 것이라고 가정했다.
🛠️ 해결 과정
&& 연산자의 단축 평가(short-circuit evaluation, 앞의 조건이 거짓이면 뒤의 조건은 검사하지 않는 특성)를 활용해 포인터의 유효성을 먼저 검사하는 Null 체크 로직을 추가했다.
// 수정 전: DamageTypeClass가 nullptr이면 크래시 발생
// if (DamageEvent.DamageTypeClass->GetName() == "DT_DotDamage") { ... }
// 수정 후: DamageTypeClass가 유효할 때만 GetName()을 호출
if (DamageEvent.DamageTypeClass && DamageEvent.DamageTypeClass->GetName() == "DT_DotDamage")
{
// 포인터가 유효함이 보장됨
}
🔍 문제 현상 2: Zero Vector 정규화로 인한 NaN 발생
직접적인 크래시는 아니었지만, 더 잠재적 위험이 발견됐다.
플레이어가 순수하게 수직 방향(정 위나 정 아래)에서 공격을 받으면,
공격 방향의 수평 성분 벡터가 (0, 0, 0), 즉 'Zero Vector’가 될 수 있었다.
⚠️ 원인 분석
Zero Vector에 Normalize()(정규화, 벡터의 방향은 유지하면서 길이를 1로 만드는 연산) 연산을 수행하면,
그 결과는 (NaN, NaN, NaN)이 된다.
NaN(Not a Number)은 이후 모든 벡터 연산을 오염시킬 수 있다.
NaN이 한 번 발생하면 그것과 연산하는 모든 값이 NaN으로 전염되어,
결국 화면 전체가 이상하게 동작하거나 UI가 깨진다.
🛠️ 해결 과정
벡터를 정규화하기 전에 IsNearlyZero() 함수를 사용해
해당 벡터가 제로 벡터에 매우 가까운지 먼저 확인하는 ‘조건부 정규화’ 로직을 추가했다.
// 수정 전: HorizontalDir이 ZeroVector일 경우 NaN 발생 가능
// HorizontalDir.Normalize();
// 수정 후: ZeroVector가 아닐 때만 안전하게 정규화
if (!HorizontalDir.IsNearlyZero())
{
HorizontalDir.Normalize();
}
💡 핵심 교훈
이 두 사례는 방어적 프로그래밍의 핵심 원칙 두 가지를 명확히 보여준다.
✅ 모든 포인터는 사용 전에 유효성을 검사해야 한다.
✅ 수학적 연산의 잠재적 위험(0으로 나누기, 제로 벡터 정규화 등)을 항상 인지하고 예외를 처리해야 한다.
📋 체크리스트: 비슷한 문제를 겪을 때를 위해
피격 인디케이터를 구현하거나 비슷한 방향 기반 UI 시스템을 만들 때, 아래 항목들을 점검하면 많은 삽질을 줄일 수 있다.
데미지 시스템 설계 시:
- [ ] 방향 없는 데미지(DoT, 환경 피해)에 대한 처리 로직이 있는가?
- [ ] FDamageEvent, FPointDamageEvent, FRadialDamageEvent 각각에 대한 분기 처리가 되어 있는가?
방향 계산 로직 점검:
- [ ] GetActorLocation()이 반환하는 값이 실제 피격 지점이 아님을 인지하고 있는가?
- [ ] 수직/수평 성분을 절대값이 아닌 상대적 비율로 비교하고 있는가?
안정성 확보:
- [ ] 외부에서 받은 포인터는 사용 전에 Null 체크를 하고 있는가?
- [ ] 벡터 정규화 전에 IsNearlyZero() 체크를 하고 있는가?
- [ ] Super:: 호출이 오버라이드 함수 최상단에 위치하는가?
플레이어 경험 검증:
- [ ] 기술적 정확성과 플레이어 직관이 일치하는가?
- [ ] 모든 엣지 케이스에서 플레이어에게 명확한 피드백이 제공되는가?
엔진 API의 반환 값을 의심하고, 외부 시스템이 넘겨준 데이터의 유효성을 의심하며, 정상적인 입력값의 범위를 의심하고, 마지막으로 기술적 구현이 플레이어의 경험적 진실과 일치하는지 의심해야 한다.
이 삽질의 기록이 프로젝트에서 비슷한 문제를 피해 가는 데 도움이 되기를 바란다.

저는 AI를 적극적으로 활용하는 개발자입니다.
코드 구현은 AI 도구와 협업하고, 저는 문제 분석, 기술 설계, 트러블슈팅, 최종 검증에 집중합니다.
모든 기술적 의사결정과 트러블슈팅은 제가 직접 수행한 것이며, AI는 그 과정을 가속화하는 도구였습니다.
이 블로그는 그 판단과 사고의 기록입니다.
"어떤 도구를 쓰느냐"보다 "어떤 문제를 해결하느냐"가 진짜 개발자의 가치라고 믿습니다.
I believe a developer's value lies in "what problems they solve," not "what tools they use."
'Dev. > UE 언리얼 엔진' 카테고리의 다른 글
| [GAS] 1월 초 구현한 시스템 정리 (0) | 2026.01.16 |
|---|---|
| [GAS] Unreal Engine 죽음 시네마틱 카메라 시스템 (2) | 2026.01.14 |
| [GAS] Wwise 오디오 오클루전 시스템 구축 (0) | 2026.01.09 |
| [GAS] 나나이트(Nanite) 메시의 거리 컬링 최적화 (0) | 2026.01.07 |
| [GAS] LOD 설정 실수로 인한 빌드 크래시, 원인 분석과 해결까지 (0) | 2026.01.06 |