TIL 2026-05-06
2026-05-06 STL 알고리즘 보강(17/18번) + Ch3 히트·데미지 시스템 디커플링 + UE5 모듈 구조 트러블슈팅
목차
- 2026-05-06 STL 알고리즘 보강(17/18번) + Ch3 히트·데미지 시스템 디커플링 + UE5 모듈 구조 트러블슈팅
오늘 한 일 요약
- CS 면접 17번 보강 —
find vs binary_search노트에 두 섹션 추가. §9(iterator·동치판단·lower_bound vs find·equal_range 5개 서브섹션) + §10(큰 데이터 시나리오 5가지·k > log n손익분기점·캐시 친화성 7개 서브섹션) - CS 면접 18번 신규 —
std::list::sort vs std::sort11개 섹션. iterator 카테고리(RandomAccess vs Bidirectional) → 컴파일 에러 → introsort vs merge sort → 노드 재연결 → “멤버 vs 알고리즘” 컨벤션 - Ch3 팀플 히트 판정 / 데미지 시스템 (C-TR-01) — VoidUnreal 코드를 NBC_Ch3 Combat 모듈로 이식. 7개 파일(
CombatTypes.h/HealthComponent.h+cpp/WeaponConfig.h+cpp/WeaponComponent.h+cpp) 작성 - 시스템 설계 디커플링 —
Cast<AVOIDBaseCharacter>강결합을FindComponentByClass<UHealthComponent>()동적 탐색으로 변경 → BaseCharacter 의존성 제거. 이게 오늘의 메인 학습 - UE5 모듈 구조 트러블슈팅 — 처음에
Combat/Public + Combat/Private중첩 구조로 시작 → 빌드 실패 → NBC_Master 패턴(flat) 확인 후 회복. UE 모듈 컨벤션 두 가지 정리 - .gitignore + 커밋 위생 —
.idea//*.DotSettings.user/Content/_Fab/추가..uproject AdditionalDependencies자동 주입은 커밋. Conventional Commits + 브랜치 전략으로 3개 커밋(1fea0a0chore /fc117f6feat /03adb8fchore _Fab) - 5시 코드 리뷰 자료 + 채널 인덱스 미스매치 수정 —
scrum/Ch3-TeamProject/2026-05-06_코드리뷰_히트데미지시스템.md신규 8섹션. 빌드 후 코드 GameTraceChannel3 vs ini GameTraceChannel1 불일치 발견 → 수정 커밋 - 오늘 스크럼 작성 —
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 < it2 | vector, 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 두 단계 검사 트랩
find와 lower_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) { /* 찾음 */ } // ← 두 단계 검사 필수
find의last= “없다”lower_bound의last= “value 이상인 게 하나도 없다” (모든 원소가 value 미만)lower_bound가last가 아닌 위치를 가리켜도 그게 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::iterator는 BidirectionalIterator 까지만 지원(++it, --it만). 컴파일러가 그 차이를 정확히 잡아낸다.
| 카테고리 | 가능 연산 | 정렬 알고리즘 |
|---|---|---|
| RandomAccess (vector/deque) | it + n 가능 | std::sort (introsort) |
| Bidirectional (list/set/map) | ++it, --it | std::list::sort (merge sort) |
| Forward (forward_list) | ++it만 | std::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 포인터만 재연결) |
| 안정성 | unstable | stable |
| 추가 메모리 | 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.h | ECC_Weapon 트레이스 채널 별칭 (ECollisionChannel::ECC_GameTraceChannel1을 의미 있는 이름으로) + EDamageType enum (Hit/Critical/Execution/Explosion) |
HealthComponent.h/cpp | 체력 보유 + ApplyDamage(float, FHitResult) + OnDamaged / OnDeath 멀티캐스트 델리게이트. 무기와 캐릭터를 잇는 단일 접점 |
WeaponConfig.h/cpp | DataAsset — 무기 스펙 데이터(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/Private | Module/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.user | ReSharper per-user 설정 (개인 inspection severity 등). per-team 인 *.DotSettings 는 공유 가능, .user 만 ignore |
Content/_Fab/ | Epic Fab 마켓에서 받은 에셋. 매우 큼(수백 MB ~ GB). 공유 시 LFS 또는 별도 에셋 서버 필요. 일단 ignore |
.uproject의 AdditionalDependencies는 커밋 대상
함정 발견: .uproject 의 AdditionalDependencies: ["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개 커밋 분리 의도:
1fea0a0chore (develop) — 무기 작업 시작 전 ignore 정리. 이걸 무기 커밋과 섞으면 diff 가 지저분fc117f6feat (브랜치) — 무기 시스템 본 구현. 별도 브랜치(feat/combat-hit-damage-system)에서 작업해야 PR 흐름이 정상03adb8fchore _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줄만 바꿔서 전체 일관성 회복.
오늘 배운 것 정리
iterator 카테고리는 알고리즘 선택의 계약이다. “포인터 같은 객체”가 아니라 알고리즘이 어떤 기능을 쓸 수 있는지 명세하는 인터페이스 계약. RandomAccess(vector) → Bidirectional(list) → Forward(forward_list) → Input.
std::sort가 list에서 컴파일 에러 나는 것 /binary_search가 list에서 의미 없는 것 /std::find는 모든 컨테이너에서 가능한 것 모두 같은 기준 한 줄로 설명됨. 17번 §9-1이 18번 § 3 으로 그대로 흘러감 — STL 학습은 카테고리 한 번 잡으면 후속 주제가 자동 정렬.동치(equivalence)와 동등(equality)은 다르다.
==한 번 vs<두 번.<만 정의돼 있어도 strict weak ordering 만족하면 STL이 같다고 본다. 그래서Point{3, 0}과Point{3, 99}가<가 x좌표만 비교하면 동치로 묶임. set/map 키 충돌 / binary_search 매칭 모두 이 정의로 동작.==를 안 만들어도 STL이 작동하는 이유가 이거.lower_bound는 두 단계 검사가 필수다.find의last= “없음” 이지만lower_bound의last가 아닌 위치는 “value인지 아닌지 모름” 상태.*it == value검사를 추가로 해야 한다. 대신lower_bound만의 능력 — 정렬 유지 삽입 위치를 한 번에 알려줌. “탐색 + 삽입”을 겸업. find가 못하는 이 한 가지 때문에 정렬 자료에서는 lower_bound가 더 자주 쓰임.“큰 데이터 = 무조건 binary_search” 는 함정. 정렬 비용을 빼먹은 답. 손익분기점은 대략 k > log n (k = 조회 횟수). n = 10⁶ 이면 20번 이상 조회해야 정렬+이분이 이득. 한두 번이면 그냥 find. 그리고 n이 작으면 캐시·분기 예측 효과로 선형이 더 빠를 때도 있다 — 알고리즘 복잡도와 실측 속도가 다른 경우. 자료구조 자체를 바꾸는 게 정답일 때도 많음(unordered_*).
std::list::sort가 따로 있는 이유 = “자료구조 특성을 알고리즘이 활용 못 할 때”. iterator 카테고리 차이 → 컴파일 에러 → 멤버 함수 필요. 그리고 list는 노드 포인터 재연결로 정렬할 수 있어 데이터 이동 비용이 0. 이게 큰 객체 담은 list에서std::sort가능했더라도 list::sort가 더 빠른 이유. 시간복잡도 같아도 비용 구조 다름 — 이론값과 실측 차이의 또 다른 사례.“멤버 vs 알고리즘” 컨벤션 — 멤버 함수가 따로 있다면 거의 항상 그게 더 빠르다.
set::find/map::find/unordered_map::find/list::sort/forward_list::sort모두 같은 패턴. 자료구조의 특성(트리/해시/노드)을 활용하려면 알고리즘으로는 안 되고 멤버 함수가 필요. “표준 라이브러리에서 멤버 함수가 있다면 일단 그걸 의심” 이 일반화된 룰.컴포넌트 기반 디커플링 —
Cast<특정클래스>→FindComponentByClass<>1줄 변경의 위력. VoidUnreal 의Cast<AVOIDBaseCharacter>강결합을FindComponentByClass<UHealthComponent>동적 탐색으로 바꾸니 무기 모듈이 캐릭터 클래스 이름을 모르게 된다. HealthComponent만 붙은 어떤 Actor든 데미지 받음 — 캐릭터·좀비·박스·보스 모두 통합. 새 데미지 대상 추가 = HealthComponent만 붙이면 끝, 무기 코드 변경 0. 이게 컴포넌트 기반 설계의 본질이고, 클래스 상속이 아닌 컴포넌트 조합으로 의존 그래프를 평면화하는 방법.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 구조로 시작이 안전.“자동 생성물 = ignore” 가 항상 맞는 건 아니다.
.uproject의AdditionalDependencies: ["Engine"]는 빌드/IDE 가 자동 주입하지만 프로젝트 메타데이터의 일부 라 커밋 대상. ignore 하면 팀원 클론할 때 또 추가되며 매번 dirty 상태. 자동 생성 ≠ 무의미 — 의미 있는 데이터면 커밋이 정답. ignore 결정은 “공유 가치가 있나 / 개인 설정인가 / 매우 크고 외부에서 받을 수 있나” 세 기준으로.#define으로 의미 있는 별칭을 만들어 두면 단일 지점 수정이 가능하다.ECC_Weapon매크로 한 줄로GameTraceChannel3 → 1수정이 끝났다. 만약 raw 인덱스를 LineTrace 호출 곳곳에 박았으면 N곳 수정이 필요했고 그중 하나만 빼먹으면 또 미스매치. 매크로 별칭은 의도 표현 + 단일 수정 지점 두 가지를 동시에 해결. 5시 리뷰가 ini-코드 미스매치를 한 번에 잡아낸 것도 별칭이 있어서 가능했음.Conventional Commits + 브랜치 분리 = PR 흐름이 자연스러워진다. 무기 작업 3개 커밋을 (chore develop / feat 브랜치 / chore develop) 으로 나눠서 (1) ignore 변경과 기능 변경이 섞이지 않고 (2) PR diff 가 feat 만 보여서 리뷰 부담 감소 (3) develop 직접 푸시 vs 브랜치 PR 의 의도가 명확. 커밋 분리는 시간이 더 들지만 리뷰 시간을 더 많이 단축 — 트레이드오프 자체가 항상 분리 쪽으로 기움.
사전 코드 리뷰가 디버깅 시간 1시간 = 리뷰 10분으로 압축. 5시 리뷰에서 ini-코드 채널 인덱스 미스매치 발견. 이걸 그대로 PIE 띄웠으면 “왜 데미지가 안 들어가지?” 부터 시작해서 LineTrace 결과 / 콜리전 응답 / 채널 정의 순으로 거슬러 올라가야 1시간 이상 걸렸을 함정. 코드 흐름을 한 번 같이 따라가는 것만으로 짧은 시간에 함정 발견. 리뷰는 동작 검증이 아니라 “두 사람이 다른 시점에서 같은 코드를 읽는 것” 자체가 가치.
이전 프로젝트(VoidUnreal) 코드 이식 = “복붙 + 디커플링”이 정석. 그대로 가져오면 안 되는 이유가 거의 항상 있다 — 이전 프로젝트의 캐릭터 클래스 / 모듈 이름 / 매크로 / 의존성. 이식 작업의 가치는 단순 복사가 아니라 그 함수가 의존하던 컨텍스트를 새 프로젝트에 맞게 재설계 하는 것. 오늘
Cast<AVOIDBaseCharacter>→FindComponentByClass변경이 정확히 그 사례. 이전 코드 = 학습 자료, 그대로 못 쓰는 게 정상.시스템 설계 디커플링과 모듈 구조 트러블슈팅이 코드 작성보다 시간이 더 걸렸다. 오늘 작성한 cpp 라인 수보다 (1) 설계 의사결정 (Cast → 동적 탐색) (2) 모듈 구조 시도/실패/회복 시간이 더 길었다. 이게 정상 — “동작하는 코드를 빨리 쓰는 것”이 아니라 “오래 갈 코드 구조를 잡는 것” 이 시니어급 작업의 본질이고, 그 비용은 항상 코드 라인 수보다 크다. 8번 과제 / Ch3 D-Day 정리 때 강조한 “튜닝 가능 / 인터페이스 통일” 같은 메시지가 오늘 디커플링으로 또 한 번 검증됨.