포스트

히트 판정과 데미지 시스템 인터페이스 디커플링

발사 방식이 안 정해져도 피격 반응을 먼저 만들기

히트스캔이냐 프로젝타일이냐가 안 정해진 상태에서 히트리액트·이펙트·히트스톱을 먼저 만들어야 했다. 인터페이스 하나로 의존을 끊는 설계와, 코드 리뷰에서 드러난 콜리전 채널 미스매치.

히트 판정과 데미지 시스템 인터페이스 디커플링

총이 히트스캔인지 프로젝타일인지 아직 정해지지 않았는데, 맞았을 때의 반응(히트리액트·이펙트·히트스톱)은 지금 만들어야 했다. 발사 방식이 정해질 때까지 기다리면 일주일이 그냥 날아가기 때문이다. 이 글에서는 히트 판정과 데미지 처리를 서로 모르게 떼어 놓는 설계를 이야기하려 한다 — 인터페이스 하나로 발사 방식과 피격 반응의 의존을 끊는 구조가 앞이고, 코드 리뷰에서 잡아낸 콜리전 채널 인덱스 미스매치가 뒤의 트러블슈팅이다.

같은 날 정리한 CS 노트(find/iterator, list::sort)와 UE5 모듈 폴더 구조 작업은 이 글의 주제와 달라 덜어냈다. 각각 find vs binary_search, std::list::sort, UE5 모듈 폴더 분리에 정리돼 있다.

기술 구현 — 히트 판정과 데미지 시스템 디커플링

중간 점검 회의에서 P0 5건을 결정한 뒤 곧장 본 작업에 들어갔다. 담당 항목인 히트 판정 / 데미지 시스템의 1차 골격을 작성했다.

VoidUnreal 코드 → NBC_Ch3 Combat 모듈 이식

기반 자료는 내가 이전 프로젝트(VoidUnreal)에서 만들었던 무기/체력 시스템. 그대로 가져오면 안 되는 이유가 있어서 이식 = 디커플링(특정 클래스에 대한 의존을 끊어내는 재설계)이 됐다.

1
2
3
4
5
6
7
VoidUnreal (원본)
├── 무기 → AVOIDBaseCharacter 캐스팅 → ApplyDamage
└── 강결합: BaseCharacter 클래스 없으면 동작 X

NBC_Ch3 (이식)
├── 무기 → FindComponentByClass<UHealthComponent>() → ApplyDamage
└── 디커플링: 어떤 Actor든 HealthComponent만 붙어 있으면 OK

강결합 → 동적 탐색 — 1줄 변경으로 BaseCharacter 의존성 제거

원본 패턴:

1
2
3
4
5
6
7
8
// VoidUnreal — 강결합
void UWeaponComponent::HandleHit(const FHitResult& Hit)
{
    if (AVOIDBaseCharacter* Char = Cast<AVOIDBaseCharacter>(Hit.GetActor()))
    {
        Char->ApplyDamage(WeaponConfig->BaseDamage);
    }
}

이게 강결합인 이유: 무기 모듈이 AVOIDBaseCharacter 헤더를 include 해야 한다. 즉 무기를 쓰려면 BaseCharacter 클래스가 반드시 존재해야 함. 팀 프로젝트에서는 다른 팀원이 쓸 캐릭터 클래스 이름을 모르고, 또 좀비 / 보스 / 인터랙티브 오브젝트 등 BaseCharacter를 안 상속하는 대상도 데미지 받아야 한다.

이식한 패턴:

1
2
3
4
5
6
7
8
9
10
11
// NBC_Ch3 — 동적 탐색 (디커플링)
void UWeaponComponent::HandleHit(const FHitResult& Hit)
{
    if (AActor* HitActor = Hit.GetActor())
    {
        if (UHealthComponent* Health = HitActor->FindComponentByClass<UHealthComponent>())
        {
            Health->ApplyDamage(WeaponConfig->BaseDamage, Hit);
        }
    }
}

이 1줄 변경의 효과:

  • 무기 모듈이 캐릭터 클래스 이름을 모름
  • HealthComponent만 붙어 있으면 어떤 Actor든 데미지 받음 (캐릭터 / 좀비 / 박스 / 보스 모두 통합)
  • 팀원이 자기 캐릭터 클래스를 만들든 말든 무기는 그대로 동작
  • 새 데미지 대상 추가 = HealthComponent만 붙이면 끝 (무기 코드 변경 0)

이게 컴포넌트 기반 설계의 본질 — 기능을 클래스 상속이 아니라 컴포넌트 조합으로 구성하면 의존 그래프가 평면화된다. 설계 정렬 단계에서 “OnZombieHit 인터페이스 단일화 = 발사 방식 N개 지원”이라고 했던 것과 같은 패턴이 한 단계 더 위에서 적용됨.

생성한 7개 파일 구조

1
2
3
4
5
6
7
8
NBC_Ch3/Source/NBC_Ch3/Combat/
├── CombatTypes.h              — ECC_Weapon 채널 별칭 매크로 + EDamageType enum
├── HealthComponent.h          — ApplyDamage / OnDeath 델리게이트
├── HealthComponent.cpp
├── WeaponConfig.h             — DataAsset (BaseDamage / 펠릿 수 / 산란각 / 반동 곡선)
├── WeaponConfig.cpp
├── WeaponComponent.h          — Fire / LineTrace / 반동 누적 / FInterpTo 회복
└── WeaponComponent.cpp

각 파일의 책임:

파일책임
CombatTypes.hECC_Weapon 트레이스 채널 별칭 (ECollisionChannel::ECC_GameTraceChannel1을 의미 있는 이름으로) + EDamageType enum (Hit/Critical/Execution/Explosion)
HealthComponent.h/cpp체력 보유 + ApplyDamage(float, FHitResult) + OnDamaged / OnDeath 멀티캐스트 델리게이트. 무기와 캐릭터를 잇는 단일 접점
WeaponConfig.h/cppDataAsset — 무기 스펙 데이터(BaseDamage / 펠릿 수 / 산란각 / 발사 간격 / 반동 곡선 Curve). BP에서 무기 종류별 인스턴스 생성
WeaponComponent.h/cpp발사 동작 — LineTrace 발사 / 펠릿 산탄 분배 / FInterpTo 반동 회복. HealthComponent를 동적 탐색

LineTrace + 펠릿 산탄 + 반동 누적/회복

발사 로직 핵심:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 1. 펠릿 N발 = LineTrace N번 (산란각 분배)
for (int32 i = 0; i < WeaponConfig->PelletCount; ++i)
{
    FVector EndDir = ApplySpread(BaseDir, WeaponConfig->SpreadAngle);
    FHitResult Hit;
    GetWorld()->LineTraceSingleByChannel(
        Hit, MuzzleLoc, MuzzleLoc + EndDir * WeaponConfig->Range,
        ECC_Weapon                     // CombatTypes.h의 별칭
    );
    if (Hit.bBlockingHit) HandleHit(Hit);
}

// 2. 반동 누적 (발사 시점)
RecoilOffset += WeaponConfig->RecoilPerShot;

// 3. 반동 회복 (Tick — FInterpTo)
RecoilOffset = FMath::VInterpTo(
    RecoilOffset, FVector::ZeroVector,
    DeltaTime, WeaponConfig->RecoilRecoverySpeed
);

ECC_Weapon 매크로를 만든 이유: ECollisionChannel::ECC_GameTraceChannel1 같은 raw 채널 인덱스를 코드 곳곳에 박으면 (1) 의미가 안 보이고 (2) ini의 채널 인덱스가 바뀌면 코드를 다 수정해야 함. 매크로로 한 곳에서 관리. (이게 5시 리뷰에서 미스매치 발견의 단서가 됐다 — 아래 참조.)



트러블슈팅 — 콜리전 채널 인덱스 미스매치

빌드 성공 후 5시에 코드 리뷰 진행. 그 자료로 scrum/Ch3-TeamProject/2026-05-06_코드리뷰_히트데미지시스템.md 작성 (8개 섹션).

리뷰 도중 발견한 버그:

1
2
3
4
5
6
7
코드 (CombatTypes.h):
  #define ECC_Weapon ECollisionChannel::ECC_GameTraceChannel3

ini (DefaultEngine.ini):
  +DefaultChannelResponses=(Channel=ECC_GameTraceChannel1,Name="Weapon",DefaultResponse=ECR_Block)
                                                         ↑
                                                     채널 1 정의

미스매치 — 코드는 GameTraceChannel3 을 Weapon 으로 부르는데, ini는 GameTraceChannel1 에 Weapon 이름을 등록. 이러면:

  • LineTrace가 Channel3 으로 발사
  • 그런데 Weapon 이름의 콜리전 응답이 정의된 건 Channel1
  • → 모든 Trace 가 의도와 다른 채널로 동작 → 히트 판정 안 됨 (운이 나쁘면 컴파일은 되지만 런타임에 무반응)

이게 5시 리뷰의 수확. 코드는 컴파일/실행 모두 되지만 “왜 데미지가 안 들어가지?” 부터 디버깅 시작했으면 ini 파일까지 가는 데 한참 걸렸을 것. 사전 리뷰가 한 번에 잡아 줌.

수정 후 커밋 반영:

1
+#define ECC_Weapon ECollisionChannel::ECC_GameTraceChannel1   // 3 → 1

이 사례의 교훈: #define 매크로로 채널 별칭을 만든 게 결과적으로 정답. raw 인덱스를 코드 전체에 박았으면 수정 지점이 N개. 매크로 1줄만 바꿔서 전체 일관성 회복.



정리 — 디커플링 설계에서 남은 것

STL 쪽. iterator 카테고리는 “포인터 같은 객체”가 아니라 알고리즘이 어떤 연산을 쓸 수 있는지 명세하는 계약이다. std::sort가 list에서 컴파일 에러가 나는 것, binary_search가 list에서 의미 없는 것, std::find가 모든 컨테이너에서 되는 것이 전부 이 기준 하나로 설명된다. 그 위에 동치(< 두 번)와 동등(==)의 구분, lower_bound는 못 찾았을 때 *it == value 두 단계 검사가 필수라는 함정, “큰 데이터 = 무조건 binary_search”가 아니라 손익분기점 k > log n과 캐시 친화성까지 따져야 한다는 선택 기준을 §9~10으로 쌓았다. list::sort가 따로 존재하는 이유는 iterator 카테고리 차이에 더해 노드 재연결로 데이터 이동 비용이 0이기 때문이고, 일반화하면 “멤버 함수가 따로 있다면 거의 항상 그게 더 빠르다”는 STL 컨벤션이 된다.

팀플 쪽 메인 학습은 디커플링이다. Cast<AVOIDBaseCharacter> 강결합을 FindComponentByClass<UHealthComponent>() 동적 탐색으로 바꾸는 1줄 변경으로, 무기 모듈이 캐릭터 클래스 이름을 몰라도 되고 HealthComponent만 붙으면 어떤 Actor든 데미지를 받는다. 이전 프로젝트(VoidUnreal) 코드 이식이 단순 복사가 아니라 “의존하던 컨텍스트의 재설계”가 되는 이유가 여기 있다.

모듈 구조 트러블슈팅의 결론은 하나 — Public/Private 분리는 모듈 루트에서만 자동 인식된다. 하위 폴더에 중첩하면 빌드가 실패하고, 잘 동작하는 다른 프로젝트(NBC_Master)의 flat 패턴을 확인한 게 가장 빠른 진단 단서였다. 커밋 위생에서는 “자동 생성물 = ignore”가 항상 맞지는 않다는 것(.uproject의 AdditionalDependencies는 커밋 대상), 커밋을 chore/feat으로 분리하니 PR diff가 깨끗해진다는 것을 확인했다. #define 채널 별칭 덕분에 GameTraceChannel3 → 1 수정이 한 줄로 끝난 것도 기록해 둘 만하다 — raw 인덱스를 코드 곳곳에 박았으면 수정 지점이 N개였다.

5시 리뷰는 ini-코드 채널 미스매치를 10분 만에 잡았다. PIE부터 띄웠으면 “왜 데미지가 안 들어가지?”에서 시작해 LineTrace → 콜리전 응답 → 채널 정의 순으로 한참 거슬러 올라갔을 함정이다. 리뷰의 가치는 동작 검증이 아니라 두 사람이 다른 시점에서 같은 코드를 읽는 것 자체에 있다는 걸 체감했다. 하루를 돌아보면 코드 작성보다 설계 결정(Cast → 동적 탐색)과 모듈 구조 시행착오에 시간이 더 들었는데, 오래 갈 구조를 잡는 비용은 원래 코드 라인 수보다 크다.

핵심 요약Cast<특정클래스> 강결합을 FindComponentByClass<UHealthComponent>() 동적 탐색으로 바꾸면, 무기 모듈이 캐릭터 클래스를 몰라도 HealthComponent가 붙은 어떤 Actor든 데미지를 처리할 수 있다. UE 모듈의 Public/Private 자동 인식은 모듈 루트에서만 동작한다는 것도 빌드 실패로 확인했다.

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