2026-07-30 TIL (108일차)
A* 길찾기 / RAII와 스마트 포인터 / TCP vs UDP
모의면접때 부족했던 부분을 다시 공부했습니다.
Part 1. A* 알고리즘
1. A*가 필요한 이유
| 알고리즘 | 탐색 방식 | 한계 |
|---|---|---|
| BFS | 가까운 순서로 전방위 확장 | 가중치(지형 이동 비용)를 반영 못 함 |
| 다익스트라 | 미확정 정점 중 g가 최소인 것 확장 | 목표 방향을 몰라 반대편까지 균등하게 퍼짐 |
| A* | g + h가 최소인 것 확장 | 휴리스틱 설계에 따라 최적성이 좌우됨 |
다익스트라는 목표가 어디인지 모르기 때문에 시작점을 중심으로 원형으로 퍼집니다. 목표가 특정 한 곳임을 알고 있다면 그 정보를 쓰는 게 당연히 유리하고, 그 정보를 넣은 것이 A*입니다.
2. 핵심 수식
\[f(n) = g(n) + h(n)\]| 항 | 의미 | 성질 |
|---|---|---|
| $g(n)$ | 시작점부터 n까지의 실제 누적 비용 | 이미 확정된 과거 정보 |
| $h(n)$ | n부터 목표까지의 예상 비용(휴리스틱) | 추정치, 설계자가 정함 |
| $f(n)$ | n을 경유하는 총 예상 비용 | 이 값이 최소인 노드를 먼저 확장 |
- $h(n) = 0$ → 다익스트라와 완전히 동일해집니다. (A*는 다익스트라의 일반화)
- $g(n) = 0$ → 목표 방향으로만 달리는 Greedy Best-First Search. 매우 빠르지만 최단 경로가 아닙니다.
- 즉 A*는 “이미 쓴 비용”과 “남은 비용 추정”의 균형을 잡는 알고리즘입니다.
3. 동작 절차
- 시작 노드를 Open List에 넣고
g=0,f=h(start)로 초기화 - Open List에서
f가 최소인 노드를 꺼냄 → 이 노드를current로 확정 current가 목표면 종료 →parent를 역추적해 경로 복원current를 Closed List로 이동- 인접 노드마다
tentative_g = g[current] + cost(current, neighbor)계산- 기존
g[neighbor]보다 작으면 갱신하고parent[neighbor] = current기록 후 Open List에 push
- 기존
- Open List가 빌 때까지 2번으로 반복 → 비면 경로 없음
| 목록 | 역할 | 자료구조 | 이유 |
|---|---|---|---|
| Open List | 아직 확장하지 않은 후보 | 우선순위 큐(최소 힙) | f 최소를 $O(\log n)$에 추출 |
| Closed List | 확장을 끝낸 노드 | 해시셋 / 불리언 배열 | 재방문 여부 조회가 $O(1)$ |
“왜 그 자료구조인가”를 설명할 수 있어야 합니다. Open List에
vector를 쓰고 매번 최소를 선형 탐색하면 $O(n)$이 되어 전체 성능이 무너집니다.
4. 격자 기반 구현 (C++)
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
struct Node {
int idx; // y * W + x
float f;
bool operator>(const Node& o) const { return f > o.f; } // 최소 힙용
};
std::vector<int> AStar(int startIdx, int goalIdx, int W, int H,
const std::vector<bool>& blocked)
{
const int INF = std::numeric_limits<int>::max();
std::vector<float> g(W * H, INF);
std::vector<int> parent(W * H, -1);
std::vector<bool> closed(W * H, false);
auto heuristic = [&](int a, int b) { // 맨해튼 (4방향 이동)
int ax = a % W, ay = a / W, bx = b % W, by = b / W;
return static_cast<float>(std::abs(ax - bx) + std::abs(ay - by));
};
std::priority_queue<Node, std::vector<Node>, std::greater<Node>> open;
g[startIdx] = 0.f;
open.push({startIdx, heuristic(startIdx, goalIdx)});
const int dx[4] = {1, -1, 0, 0}, dy[4] = {0, 0, 1, -1};
while (!open.empty()) {
Node cur = open.top(); open.pop();
if (closed[cur.idx]) continue; // ★ stale 항목 스킵
if (cur.idx == goalIdx) break;
closed[cur.idx] = true;
int cx = cur.idx % W, cy = cur.idx / W;
for (int d = 0; d < 4; ++d) {
int nx = cx + dx[d], ny = cy + dy[d];
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
int nIdx = ny * W + nx;
if (blocked[nIdx] || closed[nIdx]) continue;
float tentative = g[cur.idx] + 1.f; // 이동 비용(지형별로 가변 가능)
if (tentative < g[nIdx]) { // 더 좋은 경로 발견
g[nIdx] = tentative;
parent[nIdx] = cur.idx;
open.push({nIdx, tentative + heuristic(nIdx, goalIdx)});
}
}
}
std::vector<int> path; // 경로 복원
if (parent[goalIdx] == -1 && startIdx != goalIdx) return path; // 실패
for (int at = goalIdx; at != -1; at = parent[at]) path.push_back(at);
std::reverse(path.begin(), path.end());
return path;
}
구현 포인트 3가지
if (closed[cur.idx]) continue;— STL 우선순위 큐는 decrease-key를 지원하지 않아 같은 노드가 여러 번 push됩니다. 이 한 줄로 오래된 항목을 걸러내야 하며, 없으면 성능이 크게 나빠집니다. (다익스트라와 동일한 패턴)parent배열이 없으면 비용만 알고 경로는 모릅니다. 게임에서는 이 복원된 배열이 실제 AI 이동 포인트가 됩니다.- 이동 비용을
1.f고정이 아니라 지형별 값으로 바꾸면 그대로 가중 그래프 탐색이 됩니다. (늪 3배, 도로 0.5배 등)
5. 휴리스틱 — A*의 성패를 결정하는 부분
두 가지 조건
| 조건 | 수식 | 만족하면 |
|---|---|---|
| 허용성 (Admissible) | $h(n) \le$ 실제 최단 비용 | 최적해 보장 |
| 일관성 (Consistent) | $h(n) \le cost(n, n’) + h(n’)$ | Closed List 노드를 재방문할 필요 없음 → 구현 단순화 |
핵심은 절대 과대평가하지 않는 것입니다. 과대평가하면 실제로는 더 짧은 경로를 “비싸 보인다”고 판단해 건너뛰기 때문에 최단 경로가 아닌 답이 나옵니다. 반대로 과소평가는 안전하지만, 0에 가까울수록 다익스트라처럼 느려집니다.
정리: $h$가 작으면 정확하지만 느리고, 크면 빠르지만 부정확합니다. 실제 비용에 최대한 가깝게, 단 넘지 않게 설계하는 것이 목표입니다.
이동 방식별 올바른 휴리스틱
| 이동 방식 | 휴리스틱 | 수식 |
|---|---|---|
| 4방향 (상하좌우) | 맨해튼 | $\lvert dx \rvert + \lvert dy \rvert$ |
| 8방향 (대각선 허용) | 옥타일 | $(dx+dy) + (\sqrt{2}-2)\min(dx,dy)$ |
| 자유 방향 (NavMesh) | 유클리드 | $\sqrt{dx^2 + dy^2}$ |
| 어떤 방향이든 1턴 | 체비셰프 | $\max(\lvert dx \rvert, \lvert dy \rvert)$ |
흔한 실수: 8방향 이동인데 맨해튼을 쓰면 과대평가가 됩니다. 대각선 1칸의 실제 비용은 $\sqrt{2} \approx 1.41$인데 맨해튼은 2로 추정하기 때문입니다. → 최적성이 깨집니다.
튜닝 기법
- Tie-breaking: $h$에 1.001배를 곱하면 동일한
f값 후보가 줄어 탐색 노드 수가 크게 감소합니다. (최적성을 미세하게 희생) - Weighted A*: $f = g + w \cdot h$ ($w > 1$). 최적성을 포기하고 속도를 얻습니다. 실시간 게임에서 “충분히 좋은 경로”면 되는 경우 자주 씁니다.
6. 복잡도와 변형
- 시간 복잡도는 휴리스틱 품질에 의존합니다. 최악에는 다익스트라와 같은 $O(E \log V)$, 휴리스틱이 완벽하면 경로 길이에 비례합니다.
- 메모리가 실질적 병목입니다. Open/Closed List가 노드 수만큼 커지므로 대형 맵에서는 아래 변형을 씁니다.
| 변형 | 개선 포인트 |
|---|---|
| JPS (Jump Point Search) | 균일 격자에서 대칭 경로를 건너뛰어 노드 수를 극적으로 감소 |
| HPA* | 맵을 구역으로 나눠 추상 레벨에서 먼저 탐색 (계층적) |
| IDA* | 반복 심화로 메모리를 $O(깊이)$로 축소 |
| D* Lite | 맵이 변할 때 전체 재계산 없이 증분 갱신 (동적 장애물) |
| Flow Field | 목표가 하나이고 유닛이 수백 개면 역방향 BFS로 방향 필드를 한 번 만들어 공유 → 유닛당 $O(1)$ |
7. 언리얼 NavMesh와의 관계
NavMesh는 이동 가능 영역을 볼록 폴리곤으로 미리 분할해 둔 그래프입니다. 폴리곤이 정점, 인접 관계가 간선, 중심 간 거리가 가중치입니다.
- A*로 폴리곤 시퀀스(코리도) 탐색 — 어떤 폴리곤들을 지나갈지 결정
- Funnel / String Pulling으로 경로 평활화 — 폴리곤 중심을 잇는 지그재그를 실제 최단 직선으로 당김
타일 그리드보다 유리한 이유는 넓은 평지를 폴리곤 하나로 표현해 노드 수가 훨씬 적고, 임의 방향 이동이 자연스럽다는 점입니다.
- 전역/지역 2계층: A*는 전역 경로만 담당하고, 다른 유닛과의 실시간 충돌은 RVO / Detour Crowd가 처리합니다.
- 동적 장애물:
NavModifier또는 런타임 리빌드로 처리하되 비용이 크므로 지역 회피와 병용합니다. - AI가 100마리일 때: ① 경로 요청 Time-slicing ② 워커 스레드 비동기화 ③ 리더 경로 공유 + 오프셋(플로킹) ④ HPA* ⑤ 거리·화면 여부에 따른 갱신 주기 LOD ⑥ RTS라면 Flow Field.
Part 2. RAII와 스마트 포인터
1. RAII란 무엇인가
Resource Acquisition Is Initialization — 자원의 획득을 객체의 초기화(생성자) 에, 해제를 소멸자에 묶는 기법입니다. 결과적으로 자원의 생명주기가 객체의 스코프와 일치하게 됩니다.
C++에는 GC가 없지만, 대신 스택 객체의 소멸자가 스코프를 벗어날 때 반드시 호출된다는 강력한 보장이 있습니다. RAII는 이 보장을 자원 관리에 활용하는 것입니다.
왜 필요한가 — 수동 관리의 3가지 실패
1
2
3
4
5
6
7
8
9
void Process() {
Resource* r = new Resource();
if (!Validate()) return; // ① 조기 반환 → 누수
DoWork(); // ② 예외 발생 → delete 미실행, 누수
// ③ 유지보수 중 return 추가 → 누수
delete r;
}
1
2
3
4
5
6
void Process() {
auto r = std::make_unique<Resource>(); // 획득 = 초기화
if (!Validate()) return; // 자동 해제
DoWork(); // 예외가 나도 스택 되감기(unwinding) 중 소멸자 호출
} // 스코프 종료 = 해제
핵심: RAII의 진짜 가치는 편의성이 아니라 예외 안전성(Exception Safety) 입니다. 예외가 발생하면
delete문은 실행되지 않지만 소멸자는 반드시 호출됩니다.
표준 라이브러리의 RAII 예시
| 자원 | RAII 래퍼 |
|---|---|
| 힙 메모리 | unique_ptr / shared_ptr / vector / string |
| 뮤텍스 락 | lock_guard / unique_lock / scoped_lock |
| 파일 핸들 | ifstream / ofstream |
| 스레드 | jthread (C++20) |
- Rule of Zero: 자원을 직접 관리하지 않고 RAII 타입에 위임하면 복사·이동·소멸자를 아예 작성할 필요가 없습니다. 가장 좋은 상태입니다.
- Rule of Five: 직접 관리해야 한다면 소멸자, 복사 생성자, 복사 대입, 이동 생성자, 이동 대입 다섯 개를 모두 정의해야 합니다.
2. 세 가지 스마트 포인터
| 종류 | 소유권 | 복사 | 오버헤드 | 용도 |
|---|---|---|---|---|
unique_ptr | 단독 소유 | 불가 (이동만) | 0 (raw pointer와 동일) | 기본 선택 |
shared_ptr | 공유 소유 | 가능 (참조 카운트 증가) | 포인터 2개 크기 + 원자적 카운트 | 소유자가 여럿일 때만 |
weak_ptr | 소유하지 않음(관찰) | 가능 | 카운트 조작 없음 | 순환 참조 해결, 캐시 |
unique_ptr — 기본값으로 삼을 것
1
2
3
auto p = std::make_unique<Actor>(args); // new 직접 호출 금지
auto q = std::move(p); // 소유권 이전, p는 nullptr
// auto r = p; // ❌ 컴파일 에러 — 복사 불가가 곧 안전장치
- 런타임 오버헤드가 0입니다. 크기도 raw pointer와 같고, 소멸자 호출은 컴파일 타임에 결정됩니다. 즉 성능을 이유로 raw pointer를 고집할 근거가 없습니다.
- 배열은
std::unique_ptr<T[]>가delete[]를 호출합니다. (다만 실무에선vector가 나음) - 커스텀 deleter로
FILE*, 소켓, API 핸들 등 메모리가 아닌 자원도 감쌀 수 있습니다.
shared_ptr — 비용을 알고 써야 함
1
2
auto a = std::make_shared<Texture>(); // 제어 블록 + 객체를 한 번에 할당
auto b = a; // 참조 카운트 2
내부적으로 객체 포인터 + 제어 블록 포인터 두 개를 가지며, 제어 블록에 강한 참조 카운트와 약한 참조 카운트가 있습니다.
make_shared를 쓰는 이유: ① 할당이 1회로 줄어 캐시에 유리 ② 생성 중 예외가 나도 누수 없음. 단,weak_ptr이 하나라도 살아 있으면 제어 블록이 유지되어 객체 메모리도 함께 붙잡혀 있습니다. 큰 객체 + 오래 사는weak_ptr조합에서는 오히려shared_ptr<T>(new T)가 나을 수 있습니다.- 참조 카운트는 원자적 연산이라 멀티스레드에서 비용이 있습니다. 루프에서
shared_ptr을 값으로 복사하면 카운트가 계속 오르내리므로, 읽기만 할 때는const&로 받아야 합니다. - 카운트 자체는 스레드 안전하지만 가리키는 객체의 데이터는 스레드 안전하지 않습니다. 자주 나오는 함정입니다.
weak_ptr — 순환 참조 끊기
1
2
3
4
5
6
7
8
struct Node {
std::shared_ptr<Node> child;
std::weak_ptr<Node> parent; // ← shared_ptr이면 카운트가 0이 안 됨
};
if (auto p = node->parent.lock()) { // 사용 시점에 승격
p->DoSomething(); // 살아 있으면 유효한 shared_ptr
} // 죽었으면 nullptr
부모와 자식이 서로 shared_ptr을 들면 카운트가 서로 1씩 남아 영원히 해제되지 않습니다. 소유권이 없는 방향(보통 역방향 참조, 옵저버, 캐시)은 weak_ptr로 잡습니다.
3. 함수 인터페이스 가이드
| 의도 | 시그니처 |
|---|---|
| 소유권 이전 | void f(std::unique_ptr<T> p) |
| 소유권 공유 | void f(std::shared_ptr<T> p) |
| 그냥 사용만 (null 불가) | void f(T& t) |
| 그냥 사용만 (null 허용) | void f(T* t) |
| 스마트 포인터 자체를 재지정 | void f(std::unique_ptr<T>& p) |
자원을 소유하지 않는 함수에
shared_ptr을 넘기는 것은 불필요한 카운트 조작 + 의도 왜곡입니다. “이 함수는 수명에 관여하지 않는다”를 시그니처로 표현하세요.
4. 흔한 실수
- ❌ 같은 raw pointer로
shared_ptr을 두 번 만들기 → 제어 블록이 두 개 생겨 이중 해제 - ❌ 멤버 함수에서
shared_ptr<T>(this)반환 → 위와 같은 이중 해제.enable_shared_from_this+shared_from_this()를 써야 합니다. - ❌
get()으로 얻은 포인터를delete - ❌ 순환 참조 방치 → 조용한 누수 (가장 찾기 어려움)
- ❌ 성능 걱정으로
unique_ptr회피 → 오버헤드가 0이므로 근거 없음
5. 언리얼에서의 대응 관계 (중요)
언리얼은 표준 스마트 포인터를 쓰지 않고, UObject인지 아닌지에 따라 관리 방식이 완전히 갈립니다.
| 대상 | 관리 방법 |
|---|---|
UObject 파생 | GC(가비지 컬렉션) 가 담당. UPROPERTY()로 선언해 참조를 GC에 알림 |
UObject 약한 참조 | TWeakObjectPtr<T> — 파괴되면 자동으로 무효화, IsValid() 체크 |
UObject 포인터 필드 (UE5) | TObjectPtr<T> — 에디터에서 지연 로딩·접근 추적 지원 |
| 일반 C++ 객체 | TSharedPtr / TSharedRef / TWeakPtr / TUniquePtr |
UObject에std::shared_ptr이나TSharedPtr을 쓰면 안 됩니다. GC와 참조 카운트가 이중으로 수명을 관리해 크래시가 납니다.UPROPERTY()를 빼면 GC가 참조를 모르기 때문에 살아 있어야 할 객체가 수거되어 댕글링 포인터가 됩니다. 원인 찾기 어려운 크래시의 대표 원인입니다.TSharedRef는 null이 될 수 없는 버전이라, null 체크가 불필요한 곳에 쓰면 의도가 코드로 드러납니다.- RAII 사고방식은 언리얼에서도 유효합니다.
FScopeLock,FScopedSlowTask등이 같은 패턴입니다.
Part 3. TCP vs UDP
1. 위치와 역할
둘 다 전송 계층(Transport Layer) 프로토콜로, IP가 제공하는 “최선 노력 전달(Best-effort)” 위에서 애플리케이션 간 통신(포트 단위) 을 담당합니다. IP는 패킷의 도착·순서·중복을 보장하지 않으며, 그 신뢰성을 TCP가 얹고, UDP는 얹지 않습니다.
2. 핵심 비교
| 항목 | TCP | UDP |
|---|---|---|
| 연결 | 연결 지향 (핸드셰이크 필요) | 비연결형 |
| 신뢰성 | 재전송으로 전달 보장 | 보장 없음 (유실 허용) |
| 순서 | 보장 | 보장 없음 |
| 중복 제거 | 함 | 안 함 |
| 전송 단위 | 바이트 스트림 (경계 없음) | 데이터그램 (경계 유지) |
| 흐름 제어 | 슬라이딩 윈도우 | 없음 |
| 혼잡 제어 | 있음 (네트워크 상황에 따라 속도 조절) | 없음 |
| 헤더 크기 | 20바이트 이상 | 8바이트 |
| 속도·지연 | 상대적으로 느림, 지연 변동 큼 | 빠름, 지연 일정 |
| 1:N 전송 | 불가 (1:1) | 브로드캐스트 / 멀티캐스트 가능 |
바이트 스트림 vs 데이터그램이 실무에서 가장 자주 문제를 일으킵니다. TCP는
send를 3번 해도 상대가recv1번에 뭉쳐 받을 수 있어(TCP는 메시지 경계를 보존하지 않음) 반드시 길이 헤더 기반 파싱이 필요합니다. UDP는 보낸 단위 그대로 도착합니다.
3. TCP의 연결 관리
3-way handshake (연결 수립)
1
2
3
4
Client Server
│──────── SYN (seq=x) ────────▶│ 내 초기 순서번호는 x
│◀──── SYN-ACK (seq=y,ack=x+1)─│ 확인했고, 내 초기 순서번호는 y
│──────── ACK (ack=y+1) ──────▶│ 너의 번호도 확인
왜 3번인가: 양쪽이 서로의 초기 순서번호(ISN)를 교환하고 확인해야 합니다. 2번이면 서버는 자기 SYN이 도달했는지 알 수 없고, 지연된 옛 SYN이 도착해 잘못된 연결이 열릴 수 있습니다.
4-way handshake (연결 종료)
1
2
3
4
│──────── FIN ───────▶│
│◀─────── ACK ────────│ "종료 요청 받았음"
│◀─────── FIN ────────│ (남은 데이터 다 보낸 뒤)
│──────── ACK ───────▶│ → TIME_WAIT 상태로 대기
왜 4번인가: TCP는 전이중(full-duplex)이라 방향별로 따로 닫아야 합니다. 한쪽이 끝냈어도 반대쪽은 아직 보낼 데이터가 남아 있을 수 있습니다(Half-Close).
- TIME_WAIT: 마지막 ACK가 유실됐을 때 재전송에 응답하고, 옛 패킷이 새 연결에 섞이는 것을 막기 위해 일정 시간(보통 2×MSL) 대기합니다. 서버가 짧은 연결을 대량 처리하면 포트가 고갈되는 원인이 됩니다.
4. TCP가 신뢰성을 만드는 방법
| 메커니즘 | 동작 |
|---|---|
| 순서번호 / ACK | 바이트 단위 번호 부여, 수신측이 누적 확인 응답 |
| 재전송 타이머 (RTO) | ACK가 안 오면 재전송. RTT 측정으로 동적 조정 |
| 빠른 재전송 | 중복 ACK 3개를 받으면 타이머 만료 전에 즉시 재전송 |
| SACK | 받은 구간을 선택적으로 알려 필요한 부분만 재전송 |
| 슬라이딩 윈도우(흐름 제어) | 수신 버퍼 여유만큼만 보내 수신자를 보호 |
| 혼잡 제어 | Slow Start → 혼잡 회피(AIMD) → 유실 시 윈도우 축소, 네트워크를 보호 |
게임에서 TCP가 불리한 이유
- Head-of-Line Blocking: 패킷 1개가 유실되면 그 뒤에 이미 도착한 패킷들도 순서 보장 때문에 애플리케이션에 전달되지 않습니다. 재전송에 최소 1 RTT가 걸리므로 그 시간 동안 캐릭터가 멈춥니다. 실시간 게임에서는 오래된 위치 정보를 정확히 받는 것보다 최신 정보를 빨리 받는 것이 중요하므로 이 특성이 치명적입니다.
- 혼잡 제어의 부작용: 유실을 혼잡 신호로 해석해 전송 속도를 스스로 줄입니다. 무선 환경의 일시적 유실에도 반응해 불필요하게 느려집니다.
- Nagle + Delayed ACK: 작은 패킷을 모아 보내는 Nagle 알고리즘과 지연 ACK가 겹치면 수십~수백 ms 지연이 생깁니다. → 게임·실시간 통신에서는
TCP_NODELAY로 Nagle을 끕니다.
5. UDP와 “자체 신뢰성”
UDP 헤더는 출발지 포트 / 목적지 포트 / 길이 / 체크섬 8바이트뿐입니다. 그래서 필요한 신뢰성만 골라 직접 구현할 수 있습니다.
| 필요한 것 | 직접 구현 방식 |
|---|---|
| 유실 감지 | 자체 시퀀스 번호 부여 |
| 확인 응답 | ACK 비트필드(최근 32개 수신 여부를 정수 하나로) |
| 순서 보정 | 시퀀스 기준 정렬 + 오래된 패킷은 버림 |
| 중요 데이터만 보장 | 채널 분리 — Reliable / Unreliable 채널 |
| 끊김 은폐 | 클라이언트 예측(Prediction) + 보간(Interpolation) + 서버 보정 |
핵심 사고방식은 “모든 걸 보장하지 않고, 보장이 필요한 것만 골라 보장한다” 입니다. 위치 정보는 유실돼도 다음 패킷이 최신 상태를 담고 있으므로 재전송할 필요가 없습니다.
6. 언리얼 네트워킹과의 연결
- 언리얼의
NetDriver(IpNetDriver)는 UDP 기반이며, 위 신뢰성 계층을 엔진이 자체 구현합니다. - RPC 지정자:
Reliable은 도착과 순서를 보장하지만 큐가 밀리면 연결이 끊길 수 있어 꼭 필요한 이벤트(사망, 결제, 상태 전이)에만 씁니다.Unreliable은 매 프레임 갱신되는 값(이펙트, 잦은 위치 갱신)에 적합합니다. - 프로퍼티 리플리케이션은 “최신 상태만 맞으면 된다”는 원리로 동작합니다. 중간 값이 유실돼도 최종 값이 도착하면 동기화가 맞습니다. 이게 재전송보다 상태 동기화가 우월한 대표 사례입니다.
- 대역폭 관리는
NetUpdateFrequency,NetCullDistanceSquared, 우선순위(Priority)로 조절합니다.
7. 선택 기준
| 용도 | 선택 | 이유 |
|---|---|---|
| 로그인, 결제, 인벤토리 | TCP | 한 번의 유실도 허용 불가 |
| 채팅, 매치메이킹 | TCP | 순서와 전달 보장 필요, 지연에 둔감 |
| 캐릭터 위치·입력 동기화 | UDP | 최신성이 정확성보다 중요 |
| 음성 채팅(VoIP) | UDP | 한 프레임 유실보다 지연이 치명적 |
| 실시간 대전 | UDP + 자체 신뢰성 | 선택적 보장 |
| 스트리밍(HLS/DASH) | TCP(HTTP) | 버퍼링으로 지연을 흡수 |
- QUIC / HTTP3: UDP 위에 신뢰성·암호화·멀티플렉싱을 재구현한 프로토콜입니다. 스트림별로 독립적이라 TCP의 Head-of-Line Blocking을 해결하고, 연결 수립도 더 빠릅니다. “UDP는 신뢰성이 없어서 못 쓴다”가 아니라 “신뢰성을 애플리케이션이 설계한다” 는 흐름을 보여주는 사례입니다.
오늘의 한 줄 정리
- A*: 다익스트라 + 목표 방향 정보. 휴리스틱은 과대평가하지 않는 선에서 최대한 정확하게.
- RAII: 자원 수명을 스코프에 묶어 예외 상황에서도 누수가 없게 만드는 것. 기본은
unique_ptr, 공유가 진짜 필요할 때만shared_ptr, 순환은weak_ptr. - TCP/UDP: 신뢰성을 프로토콜이 주느냐, 애플리케이션이 설계하느냐의 차이. 실시간 게임은 후자를 택한다.