포스트

TIL 2026-05-06

TIL 2026-05-06

2026-05-06 STL 알고리즘 보강(17/18번) + Ch3 히트·데미지 시스템 디커플링 + UE5 모듈 구조 트러블슈팅

목차


오늘 한 일 요약

  1. CS 면접 17번 보강find vs binary_search 노트에 두 섹션 추가. §9(iterator·동치판단·lower_bound vs find·equal_range 5개 서브섹션) + §10(큰 데이터 시나리오 5가지·k > log n 손익분기점·캐시 친화성 7개 서브섹션)
  2. CS 면접 18번 신규std::list::sort vs std::sort 11개 섹션. iterator 카테고리(RandomAccess vs Bidirectional) → 컴파일 에러 → introsort vs merge sort → 노드 재연결 → “멤버 vs 알고리즘” 컨벤션
  3. Ch3 팀플 히트 판정 / 데미지 시스템 (C-TR-01) — VoidUnreal 코드를 NBC_Ch3 Combat 모듈로 이식. 7개 파일(CombatTypes.h / HealthComponent.h+cpp / WeaponConfig.h+cpp / WeaponComponent.h+cpp) 작성
  4. 시스템 설계 디커플링Cast<AVOIDBaseCharacter> 강결합을 FindComponentByClass<UHealthComponent>() 동적 탐색으로 변경 → BaseCharacter 의존성 제거. 이게 오늘의 메인 학습
  5. UE5 모듈 구조 트러블슈팅 — 처음에 Combat/Public + Combat/Private 중첩 구조로 시작 → 빌드 실패 → NBC_Master 패턴(flat) 확인 후 회복. UE 모듈 컨벤션 두 가지 정리
  6. .gitignore + 커밋 위생.idea/ / *.DotSettings.user / Content/_Fab/ 추가. .uproject AdditionalDependencies 자동 주입은 커밋. Conventional Commits + 브랜치 전략으로 3개 커밋(1fea0a0 chore / fc117f6 feat / 03adb8f chore _Fab)
  7. 5시 코드 리뷰 자료 + 채널 인덱스 미스매치 수정scrum/Ch3-TeamProject/2026-05-06_코드리뷰_히트데미지시스템.md 신규 8섹션. 빌드 후 코드 GameTraceChannel3 vs ini GameTraceChannel1 불일치 발견 → 수정 커밋
  8. 오늘 스크럼 작성scrum/2026-05-06.md. P0 5건 회의 진행 + 잔여 블로커(LeetCode 50 미완 / 노션 X-PR-03 archive 충돌)


CS 면접 — 17번 보강 (find / iterator / 큰 데이터 시나리오)

어제까지 17번 노트(find vs binary_search)는 1~8번 섹션에서 멈춰 있었다. 모의면접 시뮬레이션을 돌려보니 꼬리질문이 두 방향으로 갈라졌다 — (A) “iterator가 정확히 뭐예요?” / “동치판단이란?” 같은 개념 보강 요구, (B) “큰 데이터에서는요?” / “정렬 비용까지 포함하면 손익은?” 같은 선택 기준 요구. 두 갈래를 §9 / §10 으로 분리해 박았다.

Section 9 — iterator · 동치판단 · lower_bound vs find

5개 서브섹션 구조.

1
2
3
4
5
9-1  iterator란                        — 추상 포인터, 카테고리 4단계, past-the-end
9-2  find는 인덱스? iterator?           — last 반환의 의미, not-found 표현 라이브러리별 차이
9-3  동치판단(equivalence) — < 두 번    — !(a<b)&&!(b<a), strict weak ordering
9-4  lower_bound vs find — 결정적 차이  — 두 단계 검사 트랩 + 정렬 유지 삽입 위치
9-5  equal_range — 사용법과 반환값       — pair<It,It>, 못 찾으면 .first==.second

iterator 카테고리 = 알고리즘 선택 기준

가장 큰 깨달음. iterator는 그냥 “포인터 같은 객체”가 아니라 알고리즘이 어떤 기능을 쓸 수 있는지의 계약이다.

카테고리가능 연산대표 컨테이너
Input++it, *it(읽기 전용, 1회)istream_iterator
Forward+ *it로 여러 번 읽기forward_list
Bidirectional+ --it 역방향list, set, map
Random Access+ it + n, it[n], it < it2vector, deque, array

이 계약이 18번의 list::sort 와 직접 연결됐다 — std::sort가 RandomAccess를 요구하는데 list::iterator가 Bidirectional이라 컴파일 에러. 그래서 멤버 함수가 따로 있는 것. 17번의 §9-1이 그대로 18번의 §3 으로 흘러가는 구조가 자연스럽게 만들어졌다.

동치(equivalence) vs 동등(equality) — < 두 번의 의미

1
2
3
4
5
// equality (find의 방식)
a == b

// equivalence (binary_search · set · map의 방식)
!(a < b) && !(b < a)

<만 정의돼 있어도 동작하게 하려는 STL의 일관된 설계. 사용자 정의 클래스에서 ==를 안 만들고 <만 만들어도 binary_search / set / map 모두 동작.

함정 사례:

1
2
3
4
5
6
7
8
9
10
struct Point {
    int x, y;
    bool operator<(const Point& o) const { return x < o.x; }
    // operator== 없어도 OK
};

std::vector<Point> pts = { {1,0}, {3,0}, {5,0} };
std::binary_search(pts.begin(), pts.end(), Point{3, 99});
// → x좌표만 비교 → !(3<3) && !(3<3) = true → 동치로 판단 → true
// y가 달라도 "동치"로 봄 — 이게 equivalence와 equality의 핵심 차이

set에서 같은 키로 취급되는 두 값이 ==로는 다를 수 있다는 함정. 비교자가 strict weak ordering을 만족하기만 하면 자료구조는 그걸 동치로 본다.

lower_bound 두 단계 검사 트랩

findlower_bound의 가장 큰 함정 — 둘 다 iterator를 돌려주지만 못 찾았을 때의 의미가 다르다.

1
2
3
4
5
6
7
8
9
10
std::vector<int> v = {1, 3, 5, 7};   // 정렬됨, 4는 없음

// (A) find — 한 번만 검사하면 됨
auto it1 = std::find(v.begin(), v.end(), 4);
if (it1 != v.end()) { /* 찾음 */ }   // it1 == end() 라서 진입 안 함 ← 명확

// (B) lower_bound — it는 5를 가리킴 (4 이상 첫 위치)
auto it2 = std::lower_bound(v.begin(), v.end(), 4);
// it2 != v.end() 만 검사하면 안 됨!
if (it2 != v.end() && *it2 == 4) { /* 찾음 */ }   // ← 두 단계 검사 필수
  • findlast = “없다
  • lower_boundlast = “value 이상인 게 하나도 없다” (모든 원소가 value 미만)
  • lower_boundlast가 아닌 위치를 가리켜도 그게 value인지는 별도 검사 필요

대신 lower_bound만의 능력 — 정렬 유지 삽입 위치:

1
2
3
auto pos = std::lower_bound(v.begin(), v.end(), 4);
v.insert(pos, 4);
// v = {1, 3, 4, 5, 7} — 정렬 유지

find로는 불가능. lower_bound는 “탐색 + 삽입 위치 결정” 두 역할 겸업.

equal_range — pair 반환 + 못 찾으면 .first == .second

1
2
3
4
5
6
7
8
9
v = {1, 2, 2, 2, 3, 4}      (인덱스 0 1 2 3 4 5)
                ^
       equal_range(v.begin(), v.end(), 2)

         ┌──── .first  (lower_bound) → 인덱스 1, *it == 2 (첫 2)
         │   ┌── .second (upper_bound) → 인덱스 4, *it == 3 (2 다음 첫 위치)
         ▼   ▼
[1] [2] [2] [2] [3] [4]
     └─── 동치 범위 [first, second) = 인덱스 1~3 ───┘

핵심 4가지:

  • .first = value 이상 첫 위치 (= 첫 등장)
  • .second = value 초과 첫 위치 (= 마지막 등장 + 1)
  • [.first, .second) = value와 동치인 원소 전체
  • 못 찾으면 .first == .second 가 같은 위치를 가리킴 → 그 자리가 정렬 유지 삽입 위치

multiset / multimap에서 같은 키 모두 가져오는 유일한 정석 방법. 반복 find로는 첫 개만 잡힘.

1
2
3
4
5
std::multimap<std::string, int> scores = {
    {"Alice", 90}, {"Alice", 85}, {"Bob", 70}, {"Alice", 95}
};
auto [lo, hi] = scores.equal_range("Alice");   // 멤버 함수 — O(log n)
for (auto it = lo; it != hi; ++it) { /* Alice 3개 모두 순회 */ }

Section 10 — 큰 데이터에서 find vs binary_search 선택

“큰 데이터면 무조건 binary_search”는 절반만 맞다. 정렬 비용·조회 횟수·캐시 친화성 세 변수를 따져야 한다.

상황정답이유
이미 정렬됨 + 단발binary_search / lower_bound정렬 비용 0
정렬 안 됨 + 1회find정렬 O(n log n) > 탐색 O(n). 정렬은 손해
정렬 안 됨 + k회 반복k가 크면 정렬+binary_search손익분기점 k ≳ log n
n 매우 큼 + 조회 잦음unordered_* (해시)평균 O(1). 자료구조 자체 교체
n 작음 (~수십~수백)find캐시·분기 예측 효과

손익분기점 계산 — 조회 k번 가정.

1
2
(A) 매번 find             : k * O(n)        = O(k·n)
(B) 정렬 1회 + binary_search k번 : O(n log n) + k * O(log n)

(B) < (A) 가 되려면 대략 k > log n. n=10⁶이면 log₂n ≈ 20 → 20번 넘게 찾으면 정렬이 이득.

캐시 친화성 — n이 작으면 선형이 더 빠를 수 있다.

측면선형 (find)이분 (binary_search)
메모리 접근순차 — prefetcher 활용무작위 점프 — 캐시 미스 多
분기 예측단순분기 多
n 작을 때빠름 (~수백)오버헤드가 이득 초과
n 클 때O(n) — 못 씀압승

체감 손익분기:

  • n < 약 64~128 → 선형이 더 빠르거나 비슷
  • n > 약 1,000 → 이분 명확히 이득
  • n > 100,000 → 이분 압승, 선형 사실상 못 씀

표준 라이브러리들이 작은 구간에선 insertion sort로 폴백하는 하이브리드 정렬(introsort)과 같은 원리. 알고리즘 복잡도 ≠ 실측 속도 — 메모리 접근 패턴이 더 큰 변수가 될 때가 있다.



CS 면접 — 18번 신규 (std::list::sort vs std::sort)

16번 → 17번 → 18번 진행에서 자연스럽게 이어진 주제. 17번 §9-1에서 iterator 카테고리를 정리하면서 “그럼 RandomAccess 아닌 컨테이너는 정렬을 어떻게 하지?”가 자동으로 따라왔고, 그 답이 바로 18번이다.

핵심 = iterator 카테고리 차이

1
2
3
std::list<int> ll = {3, 1, 4, 1, 5, 9, 2, 6};
std::sort(ll.begin(), ll.end());
// error: no match for 'operator-' (operand types are 'std::_List_iterator<int>' ...)

이게 컴파일 에러 가 나는 이유 — std::sort는 quicksort partition을 위해 it + n, it < it2, it[i]RandomAccessIterator 연산을 요구하는데, std::list::iteratorBidirectionalIterator 까지만 지원(++it, --it만). 컴파일러가 그 차이를 정확히 잡아낸다.

카테고리가능 연산정렬 알고리즘
RandomAccess (vector/deque)it + n 가능std::sort (introsort)
Bidirectional (list/set/map)++it, --itstd::list::sort (merge sort)
Forward (forward_list)++itstd::forward_list::sort (merge sort)

introsort vs merge sort — 알고리즘이 다른 게 자연스럽다

std::sort = introsort:

  • quicksort 시작 → 재귀 깊이 깊어지면 heapsort 폴백 → 작은 구간은 insertion sort
  • 무작위 접근으로 partition 빠르게 처리 가능하다는 전제

std::list::sort = merge sort:

  • 두 정렬 리스트를 합치는 게 핵심 — 연결 리스트는 포인터 재연결만으로 병합 가능
  • 데이터 자체를 옮길 필요가 없음

자료구조에 맞는 알고리즘이 다르다 — 이게 두 함수가 따로 존재해야 하는 두 번째 이유.

노드 재연결 = 데이터 이동 비용 0

이게 18번의 가장 강력한 메시지. 둘 다 시간복잡도는 O(n log n) 이지만 이동 비용 구조가 완전히 다르다.

측면std::sort (vector)std::list::sort
시간복잡도O(n log n)O(n log n)
데이터 이동swap 다수 (객체 자체 복사)0 (prev/next 포인터만 재연결)
안정성unstablestable
추가 메모리O(log n) 스택O(1) in-place

큰 객체를 담은 컨테이너라면 — 예: list<HugeStruct> — 이 차이가 실측 성능에 큰 영향. vector라면 swap(HugeStruct, HugeStruct)가 매 partition마다 수십~수백 바이트를 옮기는데, list는 노드 4~8바이트 포인터 두 개만 재연결.

1
2
3
4
정렬 전:   [3] ⇄ [1] ⇄ [4] ⇄ [1] ⇄ [5]
                     ↓ list::sort (포인터만 재연결)
정렬 후:   [1] ⇄ [1] ⇄ [3] ⇄ [4] ⇄ [5]
                     ↑ 데이터 자체는 그대로, prev/next만 바뀜

“멤버 vs 알고리즘” 컨벤션 — set::find / list::sort 같은 패턴

이게 STL 전반의 일관된 설계. 자료구조의 특성을 알고리즘이 활용할 수 없을 때, 멤버 함수가 따로 존재한다.

상황알고리즘멤버 함수
vector 정렬std::sort (RandomAccess)(멤버 없음 — 알고리즘으로 충분)
list 정렬(컴파일 에러)list::sort (merge sort)
forward_list 정렬(컴파일 에러)forward_list::sort
vector에서 검색std::find (O(n))(멤버 없음)
set/map에서 검색std::find 가능 but O(n) 손해set/map::find (O(log n))
unordered_map에서 검색std::find 가능 but O(n) 손해unordered_map::find (O(1))

핵심 원칙: “멤버 함수가 따로 있다면 거의 항상 그게 더 빠르다”. 알고리즘 함수는 가장 일반적인 인터페이스라 컨테이너 내부 구조를 모르고 동작. 멤버 함수는 자료구조의 특성(트리 / 해시 / 노드 재연결)을 활용하므로 동일 작업을 빠르게 처리.



Ch3 팀플 — 히트 판정 / 데미지 시스템 (C-TR-01) 디커플링 설계

오늘 회의에서 P0 5건 결정 후 곧장 본 작업 진입. 내 담당인 C-TR-01 (히트 판정 / 데미지 시스템) 의 1차 골격을 NBC_Ch3 프로젝트에 작성.

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)

이게 컴포넌트 기반 설계의 본질 — 기능을 클래스 상속이 아니라 컴포넌트 조합으로 구성하면 의존 그래프가 평면화된다. 5월 1일 팀플 D-Day 정리할 때 “C 의 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시 리뷰에서 미스매치 발견의 단서가 됐다 — 아래 참조.)



UE5 모듈 구조 시행착오 — 중첩 Public/Private 함정

오늘 가장 시간을 많이 쓴 트러블슈팅. 코드 작성 자체보다 모듈 구조 정리에 1시간 넘게 들어갔다.

처음 시도한 중첩 구조와 빌드 실패

처음에 깔끔해 보여서 만든 구조:

1
2
3
4
5
6
7
8
9
10
11
12
13
NBC_Ch3/Source/NBC_Ch3/
├── NBC_Ch3.Build.cs
├── Combat/
│   ├── Public/
│   │   ├── CombatTypes.h
│   │   ├── HealthComponent.h
│   │   ├── WeaponConfig.h
│   │   └── WeaponComponent.h
│   └── Private/
│       ├── HealthComponent.cpp
│       ├── WeaponConfig.cpp
│       └── WeaponComponent.cpp
└── (다른 모듈들)

언리얼 모듈 표준이 “Public 헤더 / Private 구현 분리” 라고 알고 있어서 그대로 적용. 그런데 빌드 실패:

1
2
fatal error: 'CombatTypes.h' file not found
fatal error: 'HealthComponent.h' file not found

다른 cpp에서 #include "CombatTypes.h" 가 안 잡힘.

원인 — UE는 모듈 루트의 Public/Private만 자동 인식

UnrealBuildTool 동작 원리:

  • 모듈 루트 (.Build.cs 가 있는 폴더) 의 Public/ 폴더는 자동으로 PublicIncludePaths에 추가
  • 모듈 루트Private/ 폴더는 자동으로 PrivateIncludePaths에 추가
  • 하위 폴더의 Public/Private은 자동 인식 안 됨 — Build.cs에 PublicIncludePaths.Add("Combat/Public") 명시 필요

내가 만든 구조는 모듈 루트가 NBC_Ch3/Source/NBC_Ch3/ 인데, 그 아래의 Combat/Public/ 은 자동 인식되지 않았다. 그래서 cpp에서 #include "CombatTypes.h" 했을 때 컴파일러가 그 헤더를 못 찾음.

NBC_Master 패턴 = flat 구조

해결 단서는 잘 동작하는 다른 프로젝트(NBC_Master)의 구조를 보는 것이었다.

1
2
3
4
5
6
NBC_Master/Source/NBC_Master/
├── NBC_Master.Build.cs
├── NBC_MasterCharacter.h        ← flat
├── NBC_MasterCharacter.cpp      ← flat
├── NBC_MasterPlayerController.h ← flat
└── NBC_MasterPlayerController.cpp

폴더에 .h 와 .cpp 가 그냥 같이 있다. Public/Private 폴더 분리 자체가 없음. 이게 가장 단순한 패턴이고, 자동 인식 문제도 없다 — 모듈 루트 직속이라 자동으로 include path에 들어감.

NBC_Master 패턴을 따라서 내 코드도 flat 구조로 변경:

1
2
3
4
5
6
7
8
NBC_Ch3/Source/NBC_Ch3/Combat/
├── CombatTypes.h        ← .h .cpp 같이
├── HealthComponent.h
├── HealthComponent.cpp
├── WeaponConfig.h
├── WeaponConfig.cpp
├── WeaponComponent.h
└── WeaponComponent.cpp

Combat/ 하위 폴더는 그대로 두되 그 안에서 .h .cpp 분리 안 함. UnrealBuildTool이 모듈 루트 하위 모든 폴더를 자동으로 스캔해서 .h .cpp 를 다 찾아준다 (기본 IncludeOrderVersion 동작). 빌드 정상.

두 가지 정상 컨벤션

오늘의 결론 — UE 모듈 구조는 두 가지 컨벤션 중 하나만 골라야 한다.

패턴구조장단
A. Public/Private 분리 (모듈 루트만)Module/Public/, Module/Private/API 계약 명확. 단 모듈 루트에서만. 하위 폴더는 직속 flat
B. Flat 구조폴더 = 카테고리, 안은 .h .cpp 섞어 둠단순. NBC_Master 등 학습용 프로젝트에서 흔함
(X) 중첩 Public/PrivateModule/Combat/Public/, Module/Combat/Private/Build.cs에 명시 필요 — 자동 인식 X

핵심 함정: “Public/Private 분리는 모듈 루트에서만 자동 인식된다”. 하위 폴더에서 같은 패턴을 쓰면 동작 안 함. 깔끔해 보인다는 이유로 중첩하면 빌드 실패. 회피하려면 Build.cs에 PublicIncludePaths.Add(Path.Combine(ModuleDirectory, "Combat/Public")) 같은 코드를 직접 추가해야 한다.

1
2
3
4
5
// Build.cs에 명시적으로 추가하면 중첩도 가능 (단 권장 X)
PublicIncludePaths.AddRange(new string[] {
    Path.Combine(ModuleDirectory, "Combat/Public"),
    Path.Combine(ModuleDirectory, "Combat/Private")
});

오늘 결론: 신규 모듈은 flat 구조로 시작. 규모 커지면 모듈 자체를 분리 (Combat을 별도 모듈로 떼서 CombatModule.Build.cs + CombatModule/Public + Private).



.gitignore / 커밋 위생

언리얼 프로젝트는 .gitignore 미설정 시 IDE 개인 설정 / 마켓 에셋 / 자동 생성물이 다 따라온다. 오늘 무기 시스템 작업하면서 발견한 항목들 정리.

.idea / DotSettings.user / _Fab — 개인 설정과 마켓 에셋

추가한 ignore 패턴:

1
2
3
4
5
6
7
8
# Rider 개인 설정 (IDE)
.idea/

# ReSharper per-user 설정
*.DotSettings.user

# Fab 마켓플레이스 다운로드 에셋
Content/_Fab/

각 항목의 이유:

패턴이유
.idea/Rider IDE 의 개인 설정 (창 위치 / 최근 파일 / 검색 히스토리). 팀원마다 다름. 절대 공유 X
*.DotSettings.userReSharper per-user 설정 (개인 inspection severity 등). per-team 인 *.DotSettings 는 공유 가능, .user 만 ignore
Content/_Fab/Epic Fab 마켓에서 받은 에셋. 매우 큼(수백 MB ~ GB). 공유 시 LFS 또는 별도 에셋 서버 필요. 일단 ignore

.uproject의 AdditionalDependencies는 커밋 대상

함정 발견: .uprojectAdditionalDependencies: ["Engine"] 가 빌드/IDE 동작 중 자동 주입 됨. 이걸 보고 “내가 안 만들었으니 ignore?” 하면 안 됨.

1
2
3
4
5
6
7
8
9
{
    "FileVersion": 3,
    "EngineAssociation": "5.x",
    "Modules": [...],
    "Plugins": [...],
    "AdditionalDependencies": [          //  자동 주입됨
        "Engine"
    ]
}

이건 프로젝트 메타데이터의 일부라 팀원이 클론할 때 같이 받아야 정상 동작. ignore하면 다른 팀원의 IDE가 자동으로 또 추가하면서 매번 dirty 상태가 됨. 자동 생성물 ≠ ignore 대상 — 의미 있는 데이터면 커밋이 정답.

Conventional Commits + 브랜치 전략 — 3개 커밋

CONTRIBUTING.md 에 합의된 컨벤션 준수. 오늘 작업을 3개 커밋으로 분리.

1
2
3
4
5
6
develop 브랜치
├── 1fea0a0  chore: gitignore (.idea / DotSettings.user / Content/_Fab)
└── 03adb8f  chore: gitignore _Fab 패턴 확장

feat/combat-hit-damage-system (develop 하위)
└── fc117f6  feat(combat): add hit detection / damage system (HealthComponent + WeaponComponent)

3개 커밋 분리 의도:

  1. 1fea0a0 chore (develop) — 무기 작업 시작 전 ignore 정리. 이걸 무기 커밋과 섞으면 diff 가 지저분
  2. fc117f6 feat (브랜치) — 무기 시스템 본 구현. 별도 브랜치(feat/combat-hit-damage-system)에서 작업해야 PR 흐름이 정상
  3. 03adb8f chore _Fab 확장 (develop) — 무기 작업 도중 _Fab 패턴 보강(다른 변형 폴더 발견). develop에 직접 푸시해서 다른 브랜치들도 즉시 받게

브랜치 명: feat/combat-hit-damage-system — Conventional Commits + Git Flow 결합 패턴(<type>/<scope>-<description>).



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줄만 바꿔서 전체 일관성 회복.



오늘 배운 것 정리

  1. iterator 카테고리는 알고리즘 선택의 계약이다. “포인터 같은 객체”가 아니라 알고리즘이 어떤 기능을 쓸 수 있는지 명세하는 인터페이스 계약. RandomAccess(vector) → Bidirectional(list) → Forward(forward_list) → Input. std::sort가 list에서 컴파일 에러 나는 것 / binary_search가 list에서 의미 없는 것 / std::find는 모든 컨테이너에서 가능한 것 모두 같은 기준 한 줄로 설명됨. 17번 §9-1이 18번 § 3 으로 그대로 흘러감 — STL 학습은 카테고리 한 번 잡으면 후속 주제가 자동 정렬.

  2. 동치(equivalence)와 동등(equality)은 다르다. == 한 번 vs < 두 번. <만 정의돼 있어도 strict weak ordering 만족하면 STL이 같다고 본다. 그래서 Point{3, 0}Point{3, 99}<가 x좌표만 비교하면 동치로 묶임. set/map 키 충돌 / binary_search 매칭 모두 이 정의로 동작. ==를 안 만들어도 STL이 작동하는 이유가 이거.

  3. lower_bound는 두 단계 검사가 필수다. findlast = “없음” 이지만 lower_boundlast가 아닌 위치는 “value인지 아닌지 모름” 상태. *it == value 검사를 추가로 해야 한다. 대신 lower_bound만의 능력 — 정렬 유지 삽입 위치를 한 번에 알려줌. “탐색 + 삽입”을 겸업. find가 못하는 이 한 가지 때문에 정렬 자료에서는 lower_bound가 더 자주 쓰임.

  4. “큰 데이터 = 무조건 binary_search” 는 함정. 정렬 비용을 빼먹은 답. 손익분기점은 대략 k > log n (k = 조회 횟수). n = 10⁶ 이면 20번 이상 조회해야 정렬+이분이 이득. 한두 번이면 그냥 find. 그리고 n이 작으면 캐시·분기 예측 효과로 선형이 더 빠를 때도 있다 — 알고리즘 복잡도와 실측 속도가 다른 경우. 자료구조 자체를 바꾸는 게 정답일 때도 많음(unordered_*).

  5. std::list::sort가 따로 있는 이유 = “자료구조 특성을 알고리즘이 활용 못 할 때”. iterator 카테고리 차이 → 컴파일 에러 → 멤버 함수 필요. 그리고 list는 노드 포인터 재연결로 정렬할 수 있어 데이터 이동 비용이 0. 이게 큰 객체 담은 list에서 std::sort 가능했더라도 list::sort가 더 빠른 이유. 시간복잡도 같아도 비용 구조 다름 — 이론값과 실측 차이의 또 다른 사례.

  6. “멤버 vs 알고리즘” 컨벤션 — 멤버 함수가 따로 있다면 거의 항상 그게 더 빠르다. set::find / map::find / unordered_map::find / list::sort / forward_list::sort 모두 같은 패턴. 자료구조의 특성(트리/해시/노드)을 활용하려면 알고리즘으로는 안 되고 멤버 함수가 필요. “표준 라이브러리에서 멤버 함수가 있다면 일단 그걸 의심” 이 일반화된 룰.

  7. 컴포넌트 기반 디커플링 — Cast<특정클래스>FindComponentByClass<> 1줄 변경의 위력. VoidUnreal 의 Cast<AVOIDBaseCharacter> 강결합을 FindComponentByClass<UHealthComponent> 동적 탐색으로 바꾸니 무기 모듈이 캐릭터 클래스 이름을 모르게 된다. HealthComponent만 붙은 어떤 Actor든 데미지 받음 — 캐릭터·좀비·박스·보스 모두 통합. 새 데미지 대상 추가 = HealthComponent만 붙이면 끝, 무기 코드 변경 0. 이게 컴포넌트 기반 설계의 본질이고, 클래스 상속이 아닌 컴포넌트 조합으로 의존 그래프를 평면화하는 방법.

  8. UE5 모듈 구조 컨벤션 두 가지 — flat vs Public/Private 분리. 단 후자는 모듈 루트에서만. 가장 큰 함정은 하위 폴더의 Public/Private은 자동 인식 안 됨. 깔끔해 보인다고 Combat/Public + Combat/Private 만들면 빌드 실패. UnrealBuildTool은 .Build.cs 가 있는 모듈 루트의 Public/Private만 자동 include path에 추가. 중첩하려면 Build.cs에 PublicIncludePaths.Add(...) 명시 필요. 잘 동작하는 다른 프로젝트(NBC_Master) 패턴이 가장 빠른 진단 단서 — 신규 모듈은 flat 구조로 시작이 안전.

  9. “자동 생성물 = ignore” 가 항상 맞는 건 아니다. .uprojectAdditionalDependencies: ["Engine"] 는 빌드/IDE 가 자동 주입하지만 프로젝트 메타데이터의 일부 라 커밋 대상. ignore 하면 팀원 클론할 때 또 추가되며 매번 dirty 상태. 자동 생성 ≠ 무의미 — 의미 있는 데이터면 커밋이 정답. ignore 결정은 “공유 가치가 있나 / 개인 설정인가 / 매우 크고 외부에서 받을 수 있나” 세 기준으로.

  10. #define 으로 의미 있는 별칭을 만들어 두면 단일 지점 수정이 가능하다. ECC_Weapon 매크로 한 줄로 GameTraceChannel3 → 1 수정이 끝났다. 만약 raw 인덱스를 LineTrace 호출 곳곳에 박았으면 N곳 수정이 필요했고 그중 하나만 빼먹으면 또 미스매치. 매크로 별칭은 의도 표현 + 단일 수정 지점 두 가지를 동시에 해결. 5시 리뷰가 ini-코드 미스매치를 한 번에 잡아낸 것도 별칭이 있어서 가능했음.

  11. Conventional Commits + 브랜치 분리 = PR 흐름이 자연스러워진다. 무기 작업 3개 커밋을 (chore develop / feat 브랜치 / chore develop) 으로 나눠서 (1) ignore 변경과 기능 변경이 섞이지 않고 (2) PR diff 가 feat 만 보여서 리뷰 부담 감소 (3) develop 직접 푸시 vs 브랜치 PR 의 의도가 명확. 커밋 분리는 시간이 더 들지만 리뷰 시간을 더 많이 단축 — 트레이드오프 자체가 항상 분리 쪽으로 기움.

  12. 사전 코드 리뷰가 디버깅 시간 1시간 = 리뷰 10분으로 압축. 5시 리뷰에서 ini-코드 채널 인덱스 미스매치 발견. 이걸 그대로 PIE 띄웠으면 “왜 데미지가 안 들어가지?” 부터 시작해서 LineTrace 결과 / 콜리전 응답 / 채널 정의 순으로 거슬러 올라가야 1시간 이상 걸렸을 함정. 코드 흐름을 한 번 같이 따라가는 것만으로 짧은 시간에 함정 발견. 리뷰는 동작 검증이 아니라 “두 사람이 다른 시점에서 같은 코드를 읽는 것” 자체가 가치.

  13. 이전 프로젝트(VoidUnreal) 코드 이식 = “복붙 + 디커플링”이 정석. 그대로 가져오면 안 되는 이유가 거의 항상 있다 — 이전 프로젝트의 캐릭터 클래스 / 모듈 이름 / 매크로 / 의존성. 이식 작업의 가치는 단순 복사가 아니라 그 함수가 의존하던 컨텍스트를 새 프로젝트에 맞게 재설계 하는 것. 오늘 Cast<AVOIDBaseCharacter>FindComponentByClass 변경이 정확히 그 사례. 이전 코드 = 학습 자료, 그대로 못 쓰는 게 정상.

  14. 시스템 설계 디커플링과 모듈 구조 트러블슈팅이 코드 작성보다 시간이 더 걸렸다. 오늘 작성한 cpp 라인 수보다 (1) 설계 의사결정 (Cast → 동적 탐색) (2) 모듈 구조 시도/실패/회복 시간이 더 길었다. 이게 정상 — “동작하는 코드를 빨리 쓰는 것”이 아니라 “오래 갈 코드 구조를 잡는 것” 이 시니어급 작업의 본질이고, 그 비용은 항상 코드 라인 수보다 크다. 8번 과제 / Ch3 D-Day 정리 때 강조한 “튜닝 가능 / 인터페이스 통일” 같은 메시지가 오늘 디커플링으로 또 한 번 검증됨.

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