네트워크 프로토콜 선택은 게임의 성능과 플레이어 경험을 결정한다. TCP는 모든 패킷의 도착을 보장하지만 Head-of-Line Blocking으로 인한 지연이 발생한다. UDP는 패킷 손실이 있지만 즉시 최신 데이터를 전달한다. 이는 신뢰성(Reliability)과 성능(Performance)의 트레이드오프다.
게임 장르에 따라 우선순위가 다르다. FPS는 최신 플레이어 위치(성능)가 중요하고, 턴제 게임은 모든 명령의 정확한 전달(신뢰성)이 중요하다. 프로토콜 선택은 이 트레이드오프를 이해하고, 게임 특성에 맞는 결정을 내리는 과정이다.
현대 게임 엔진은 하이브리드 접근을 사용한다. 위치 업데이트는 UDP로 전송하고, 아이템 획득은 TCP 또는 Reliable UDP로 보장한다. 이 문서는 두 프로토콜의 동작 원리와 실전 적용을 다룬다.
애플리케이션 계층 ← 게임 로직
전송 계층 ← TCP/UDP가 여기 있음
네트워크 계층 ← IP
데이터링크 계층 ← 이더넷, WiFi
물리 계층 ← 케이블, 전파
TCP와 UDP는 모두 전송 계층(Transport Layer) 프로토콜이다.
TCP 패킷:
계층 | 필드 | 크기 | 설명 |
---|---|---|---|
IP Header | (전체) | 20 bytes | IP 주소, 프로토콜 정보 |
TCP Header | Source Port | 16 bits | 출발지 포트 번호 |
Destination Port | 16 bits | 목적지 포트 번호 | |
Sequence Number | 32 bits | 데이터 순서 번호 | |
Acknowledgment Number | 32 bits | 수신 확인 번호 | |
Flags | - | SYN, ACK, FIN 등 제어 플래그 | |
Window Size | 16 bits | 수신 버퍼 크기 | |
Checksum | 16 bits | 오류 검사 | |
Options | 0-40 bytes | 선택적 기능 | |
(Header 전체) | 20-60 bytes | ||
Data | Payload | 가변 | 실제 전송 데이터 |
UDP 패킷:
계층 | 필드 | 크기 | 설명 |
---|---|---|---|
IP Header | (전체) | 20 bytes | IP 주소, 프로토콜 정보 |
UDP Header | Source Port | 16 bits | 출발지 포트 번호 |
Destination Port | 16 bits | 목적지 포트 번호 | |
Length | 16 bits | UDP 전체 길이 | |
Checksum | 16 bits | 오류 검사 (선택적) | |
(Header 전체) | 8 bytes | ||
Data | Payload | 가변 | 실제 전송 데이터 |
핵심 차이:
단계 | 송신 | 수신 | 패킷 타입 | Sequence Number | Acknowledgment | 의미 |
---|---|---|---|---|---|---|
1 | 클라이언트 | 서버 | SYN | 1000 | - | 연결 요청 (초기 seq 전송) |
2 | 서버 | 클라이언트 | SYN-ACK | 5000 | 1001 | 연결 수락 + 서버의 초기 seq |
3 | 클라이언트 | 서버 | ACK | 1001 | 5001 | 연결 확인 |
연결 완료 → ESTABLISHED 상태
단계 | 클라이언트 상태 | 서버 상태 | 주고받는 정보 |
---|---|---|---|
0 | CLOSED | LISTEN | 서버는 연결 대기 중 |
1 | SYN_SENT | LISTEN | 클라이언트가 SYN 전송 |
2 | SYN_SENT | SYN_RECEIVED | 서버가 SYN-ACK 응답 |
3 | ESTABLISHED | SYN_RECEIVED | 클라이언트가 ACK 전송 |
4 | ESTABLISHED | ESTABLISHED | 연결 완료 |
시간 소요:
게임에서의 의미:
// ❌ TCP 연결 지연
TcpClient client;
auto start = GetTime();
client.Connect("game-server.com", 7777); // 3-way handshake
// → 75ms 소요
client.Send(loginPacket); // 이제야 데이터 전송 가능
// → 추가 50ms
auto elapsed = GetTime() - start; // 총 125ms
// ✅ UDP는 즉시 전송
UdpSocket socket;
socket.SendTo(loginPacket, serverAddr); // 즉시 전송, 50ms
// TCP 송신 과정
클라이언트 → 서버
패킷 1: seq=1000, len=100, data="Hello"
→ 서버: ack=1100 (다음 기대하는 번호)
패킷 2: seq=1100, len=50, data="World"
→ 서버: ack=1150
패킷 3: seq=1150, len=30, data="!"
→ 손실! ACK 없음
// 재전송 타이머 만료 (보통 200-500ms)
패킷 3: seq=1150, len=30, data="!" (재전송)
→ 서버: ack=1180
// 특징:
// - 시퀀스 번호는 바이트 단위 (패킷 단위 아님)
// - ACK는 누적 (1180 = "1180까지 다 받았음")
타임아웃 기반 재전송:
class TcpRetransmission {
std::map<uint32_t, Packet> sentPackets;
std::map<uint32_t, Timer> retransmitTimers;
// RTO (Retransmission Timeout) 계산
double srtt = 0; // Smoothed RTT
double rttvar = 0; // RTT variation
void UpdateRTO(double measuredRTT) {
// Jacobson/Karels 알고리즘
if (srtt == 0) {
srtt = measuredRTT;
rttvar = measuredRTT / 2;
} else {
rttvar = 0.75 * rttvar + 0.25 * abs(srtt - measuredRTT);
srtt = 0.875 * srtt + 0.125 * measuredRTT;
}
rto = srtt + 4 * rttvar;
rto = std::max(rto, 1.0); // 최소 1초
}
void Send(Packet packet) {
sentPackets[packet.seq] = packet;
socket.Send(packet);
// RTO 후 재전송
retransmitTimers[packet.seq].Start(rto, [=]() {
// 타임아웃! 재전송
Retransmit(packet.seq);
});
}
void OnAck(uint32_t ackSeq) {
// ACK 받은 패킷은 제거
sentPackets.erase(ackSeq);
retransmitTimers[ackSeq].Cancel();
}
};
Fast Retransmit (빠른 재전송):
// 3개의 중복 ACK를 받으면 즉시 재전송
패킷 1: seq=1000 → ack=1100 ✅
패킷 2: seq=1100 → 손실! ❌
패킷 3: seq=1200 → ack=1100 (중복 1)
패킷 4: seq=1300 → ack=1100 (중복 2)
패킷 5: seq=1400 → ack=1100 (중복 3)
// 3개 중복 ACK → 타임아웃 기다리지 않고 즉시 재전송!
패킷 2: seq=1100 (재전송)
시점 | 서버 버퍼 상태 | Window Size | 클라이언트 동작 |
---|---|---|---|
초기 | [□□□□□□□□□□] 0% | 64 KB | 최대 64KB 전송 가능 |
데이터 수신 후 | [■■■■□□□□□□] 40% | 38 KB | 최대 38KB 전송 가능 |
버퍼 거의 찬 상태 | [■■■■■■■■■□] 90% | 6 KB | 최대 6KB만 전송 가능 |
원리: 서버가 Window Size를 통해 "내가 받을 수 있는 크기"를 알려줌
단계 | 서버 (수신자) | Window Size | 클라이언트 (송신자) |
---|---|---|---|
1 | [■■■■■■■■■■] 버퍼 꽉 참 | 0 KB | ⚠️ 전송 중단, 대기 |
2 | 애플리케이션이 데이터 읽음 | 0 KB | 주기적으로 윈도우 확인 (probe) |
3 | [■■■■□□□□□□] 공간 확보 | 32 KB | ✅ 전송 재개 |
Zero Window Probe: 클라이언트는 주기적으로 1바이트 패킷을 보내 윈도우 상태 확인
// AIMD: Additive Increase, Multiplicative Decrease
int cwnd = 1; // Congestion Window (혼잡 윈도우)
int ssthresh = 64; // Slow Start Threshold
// Slow Start 단계
while (cwnd < ssthresh) {
SendPackets(cwnd);
if (AckReceived()) {
cwnd *= 2; // 지수 증가
}
}
// Congestion Avoidance 단계
while (true) {
SendPackets(cwnd);
if (AckReceived()) {
cwnd += 1; // 선형 증가
} else if (PacketLoss()) {
ssthresh = cwnd / 2;
cwnd = ssthresh; // 절반으로 감소
break;
}
}
// 그래프:
// cwnd
// ^
// | /\
// | / \___/\
// | / \___
// | /
// | /
// +─────────────────> time
// ↑ ↑ ↑
// loss loss loss
TCP CUBIC (Linux 기본):
// CUBIC 함수
W_cubic(t) = C × (t - K)³ + W_max
// 변수:
// - W_max: 손실 직전 윈도우 크기
// - K: W_max로 돌아가는 시간
// - C: 상수 (0.4)
// - t: 손실 후 경과 시간
// 특징:
// 1. RTT에 독립적 (공평성 개선)
// 2. 빠른 회복 (W_max 근처에서 aggressive)
// 3. 고속 네트워크에 최적화
float cwnd = 10; // 초기
float W_max = 100; // 손실 직전
float K = pow((W_max - cwnd) / 0.4, 1.0/3.0);
for (float t = 0; t < 10; t += 0.1) {
float W = 0.4 * pow(t - K, 3) + W_max;
if (W > cwnd) {
cwnd = W;
}
}
실제 시나리오:
// FPS 게임, 60fps (16ms마다 업데이트)
t=0ms: 패킷1 전송 (position frame 0)
t=16ms: 패킷2 전송 (position frame 1) → 손실!
t=32ms: 패킷3 전송 (position frame 2)
t=48ms: 패킷4 전송 (position frame 3)
// TCP 수신 버퍼:
// 패킷1 [처리됨]
// 패킷2 [없음] ← 블로킹!
// 패킷3 [대기 중]
// 패킷4 [대기 중]
t=200ms: 패킷2 재전송 도착
→ 패킷2, 3, 4 모두 한꺼번에 애플리케이션에 전달
// 플레이어 입장:
// - 200ms 동안 오래된 frame 0 위치 표시
// - 갑자기 frame 1, 2, 3, 4가 동시에 처리
// - 순간이동처럼 보임!
트레이드오프 분석:
게임은 "최신 상태"가 중요함:
- Frame 1 (200ms 전) → 필요 없음
- Frame 4 (현재) → 이게 필요함!
하지만 TCP는:
- 순서 보장이 최우선
- Frame 1 없으면 4를 못 줌
- 결과: 오래된 데이터를 늦게 받음
→ 신뢰성을 위해 성능을 희생
단계 | 클라이언트 상태 | 동작 | 서버 상태 | 비고 |
---|---|---|---|---|
0 | CLOSED | CLOSED | 초기 상태 | |
1 | socket() + bind() + listen() | LISTEN | 서버 대기 시작 | |
2 | SYN_SENT | connect() → SYN 전송 → | LISTEN | 연결 요청 |
3 | SYN_SENT | ← SYN-ACK ← | SYN_RCVD | 서버 응답 |
4 | ESTABLISHED | → ACK → | ESTABLISHED | ✅ 연결 완료 |
상태 | 클라이언트 | 서버 | 설명 |
---|---|---|---|
ESTABLISHED | send() / recv() | send() / recv() | 양방향 데이터 교환 |
단계 | 클라이언트 상태 | 동작 | 서버 상태 | 설명 |
---|---|---|---|---|
1 | FIN_WAIT_1 | close() → FIN 전송 → | ESTABLISHED | 클라이언트 종료 시작 |
2 | FIN_WAIT_1 | ← ACK ← | CLOSE_WAIT | 서버가 FIN 확인 |
3 | FIN_WAIT_2 | 대기 | CLOSE_WAIT | 서버는 남은 데이터 전송 가능 |
4 | FIN_WAIT_2 | ← FIN ← | LAST_ACK | 서버가 close() 호출 |
5 | TIME_WAIT | → ACK → | CLOSED | 서버 종료 완료 |
6 | TIME_WAIT | 2×MSL 대기 (~2분) | - | 지연 패킷 대기 |
7 | CLOSED | - | ✅ 클라이언트 종료 완료 |
게임 서버에서 흔한 문제:
TIME_WAIT 소켓 고갈:
// ❌ 서버가 먼저 close() 호출 시
for (int i = 0; i < 10000; i++) {
int sockfd = accept(listenfd, ...);
// 게임 로직 처리
close(sockfd); // ← TIME_WAIT 상태로 진입!
}
// 결과:
// - 포트가 TIME_WAIT에 잡혀있음 (2분)
// - 새 연결 불가 (EADDRINUSE)
// ✅ 해결: SO_REUSEADDR
int optval = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
CLOSE_WAIT 누적:
// ❌ close() 호출 안 함
void HandleClient(int sockfd) {
while (true) {
char buffer[1024];
int n = recv(sockfd, buffer, sizeof(buffer), 0);
if (n == 0) {
// 클라이언트가 연결 종료
break; // ← close(sockfd) 호출 안 함!
}
ProcessData(buffer, n);
}
// 여기 도달 안 하면 소켓 누수!
}
// ✅ 수정
void HandleClient(int sockfd) {
while (true) {
char buffer[1024];
int n = recv(sockfd, buffer, sizeof(buffer), 0);
if (n == 0) {
close(sockfd); // ← 반드시 close() 호출!
break;
}
ProcessData(buffer, n);
}
}
반쯤 열린 연결 (Half-Open):
시점 | 클라이언트 | 서버 | 문제점 |
---|---|---|---|
정상 | ESTABLISHED | ESTABLISHED | 양쪽 연결 유지 |
장애 발생 | 네트워크 장애 발생 (라우터 재시작, 케이블 단선 등) | ESTABLISHED | 서버는 장애를 모름 |
장애 후 | CLOSED (재부팅됨) | ESTABLISHED (여전히 대기 중) | ❌ 서버가 죽은 연결 유지 |
// ✅ 해결: TCP Keepalive
int optval = 1;
setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &optval, sizeof(optval));
// Keepalive 설정 (Linux)
int keepidle = 60; // 60초 idle 후 keepalive 시작
int keepintvl = 10; // 10초마다 probe 전송
int keepcnt = 3; // 3번 실패 시 연결 끊김
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle));
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl));
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));
// ✅ UDP는 메시지 경계를 보존
// 송신
udp.SendTo(b"Hello", addr); // 5바이트
udp.SendTo(b"World", addr); // 5바이트
// 수신
data1 = udp.RecvFrom(1024); // "Hello" (정확히 5바이트)
data2 = udp.RecvFrom(1024); // "World" (정확히 5바이트)
// 절대 "HelloWorld"로 합쳐지지 않음!
// 각 SendTo() = 독립적인 데이터그램
vs TCP (스트림):
// ❌ TCP는 메시지 경계 없음
// 송신
tcp.Send(b"Hello"); // 5바이트
tcp.Send(b"World"); // 5바이트
// 수신
data = tcp.Recv(1024); // "HelloWorld" (10바이트)
// 또는 "Hell" (4바이트)
// 또는 "HelloWorldXXX" (다음 것 포함)
// 애플리케이션이 직접 메시지 경계 처리해야 함
측정 데이터:
네트워크 환경별 UDP 패킷 손실률:
유선 LAN (1Gbps):
- 손실률: 0.001% 미만
- 거의 안 일어남
WiFi (좋은 신호):
- 손실률: 0.1-1%
- 100개 중 1개
WiFi (약한 신호):
- 손실률: 5-10%
- 10개 중 1개
모바일 (4G/LTE):
- 손실률: 1-5%
- 핸드오버 시 더 높음
모바일 (3G):
- 손실률: 5-15%
- 불안정
게임에서의 영향:
// 60fps 게임, WiFi (1% 손실)
초당 60개 패킷 전송
→ 1초에 0.6개 손실 (대략)
→ 1.6초마다 1개 손실
플레이어 경험:
- 대부분 부드러움
- 가끔 미세한 끊김
- 눈치채기 어려움
// 30fps 게임, 모바일 (5% 손실)
초당 30개 패킷
→ 1.5개 손실
→ 0.6초마다 1개 손실
플레이어 경험:
- 자주 끊김
- 눈에 띄는 랙
- 플레이 방해
→ 성능을 위해 신뢰성을 희생
class UDPReordering {
uint32_t expectedSeq = 0;
std::map<uint32_t, Packet> outOfOrderBuffer;
const int MAX_BUFFER_SIZE = 256;
void OnPacket(Packet packet) {
uint32_t seq = packet.sequence;
// 1. 너무 오래된 패킷 (이미 처리됨)
if (IsOlderThan(seq, expectedSeq)) {
return; // 버림 (중복 또는 재정렬)
}
// 2. 너무 미래 패킷 (버퍼 넘침 방지)
if (IsNewerThan(seq, expectedSeq + MAX_BUFFER_SIZE)) {
return; // 버림 (너무 늦게 도착)
}
// 3. 정확한 순서
if (seq == expectedSeq) {
ProcessPacket(packet);
expectedSeq++;
// 버퍼에 다음 패킷들이 있는지 확인
while (outOfOrderBuffer.count(expectedSeq)) {
ProcessPacket(outOfOrderBuffer[expectedSeq]);
outOfOrderBuffer.erase(expectedSeq);
expectedSeq++;
}
}
// 4. 미래 패킷 (순서 뒤바뀜)
else if (IsNewerThan(seq, expectedSeq)) {
outOfOrderBuffer[seq] = packet;
}
}
// 32비트 랩어라운드 처리
bool IsNewerThan(uint32_t s1, uint32_t s2) {
return ((s1 > s2) && (s1 - s2 <= 0x80000000)) ||
((s1 < s2) && (s2 - s1 > 0x80000000));
}
};
class FPSNetworking {
UDPSocket unreliableSocket; // 위치, 상태
ReliableUDP reliableSocket; // 중요 이벤트
void SendPlayerState() {
// 매 프레임 (60fps)
StatePacket packet {
.sequence = seq++,
.position = player.position,
.rotation = player.rotation,
.velocity = player.velocity,
.health = player.health
};
unreliableSocket.Send(packet); // 손실 OK
}
void FireWeapon() {
// 중요! 반드시 전달
ShootPacket packet {
.weaponId = currentWeapon,
.position = muzzlePosition,
.direction = aimDirection,
.timestamp = GetTime()
};
reliableSocket.SendReliable(packet); // 재전송
}
void PickupItem(int itemId) {
// 매우 중요! TCP 수준 신뢰성
PickupPacket packet { .itemId = itemId };
// ACK 받을 때까지 재전송
reliableSocket.SendReliable(packet);
}
};
class MMONetworking {
TCPSocket mainSocket; // 대부분의 통신
UDPSocket voiceSocket; // 음성 채팅 (선택)
void MoveCharacter(Vector3 destination) {
// TCP로 이동 명령
MovePacket packet {
.destination = destination,
.timestamp = GetTime()
};
mainSocket.Send(packet);
// 서버 응답 대기 (50-100ms)
auto response = mainSocket.Receive();
if (response.success) {
// 이동 시작
character.StartMoveTo(destination);
}
}
void UseSkill(int skillId) {
// TCP로 스킬 사용
SkillPacket packet {
.skillId = skillId,
.targetId = currentTarget
};
mainSocket.Send(packet);
// 서버가 검증 (쿨타임, 마나 등)
auto response = mainSocket.Receive();
if (response.success) {
PlaySkillAnimation();
} else {
ShowError(response.errorMessage);
}
}
};
// 왜 MMO는 TCP?
// 1. 이동이 빈번하지 않음 (클릭 이동)
// 2. 모든 행동이 서버 검증 필요 (치팅 방지)
// 3. 약간의 지연 허용 가능 (실시간 대전 아님)
// 4. 신뢰성이 매우 중요 (아이템, 경험치, 퀘스트)
class RacingNetworking {
UDPSocket socket;
void SendCarState() {
// 매우 빈번 (120fps)
CarStatePacket packet {
.position = car.position,
.rotation = car.rotation,
.velocity = car.velocity,
.angularVelocity = car.angularVelocity,
.wheelRotations = car.wheels,
.timestamp = GetTime()
};
socket.SendTo(packet, server);
}
void OnServerState(RaceState state) {
// 다른 차량 위치 업데이트
for (auto& otherCar : state.cars) {
// 보간으로 부드럽게
InterpolateCarPosition(otherCar);
}
}
};
// 왜 UDP?
// 1. 매우 높은 업데이트 주파수 (120Hz)
// 2. 물리 시뮬레이션 (최신 상태가 중요)
// 3. 손실되어도 다음 프레임에 보정
class TurnBasedNetworking {
TCPSocket socket;
void MakeTurn(Move move) {
TurnPacket packet {
.turnNumber = currentTurn,
.move = move,
.checksum = CalculateChecksum(move)
};
socket.Send(packet);
// 상대방 턴 대기 (시간 제한 없음)
auto response = socket.Receive();
// 턴 결과 적용
ApplyTurnResult(response);
}
};
// 왜 TCP?
// 1. 실시간 아님 (턴 사이 수 초~수 분)
// 2. 모든 턴이 정확해야 함
// 3. 체스, 카드 게임 등
class ReliableUDP {
public:
struct Config {
int maxRetries = 5;
int initialTimeout = 50; // ms
int maxTimeout = 1000; // ms
int bufferSize = 256;
};
private:
UDPSocket socket;
Config config;
// 송신 버퍼
struct SentPacket {
Packet data;
uint64_t sentTime;
int retryCount;
int timeout;
};
std::map<uint32_t, SentPacket> sentPackets;
// 수신 버퍼 (재정렬용)
uint32_t nextExpectedSeq = 0;
std::map<uint32_t, Packet> receivedPackets;
// ACK 관리
std::set<uint32_t> pendingAcks;
uint32_t currentSeq = 0;
public:
void SendReliable(const Packet& packet) {
Packet reliablePacket = packet;
reliablePacket.sequence = currentSeq++;
reliablePacket.needsAck = true;
// 전송
socket.SendTo(Serialize(reliablePacket), serverAddr);
// 버퍼에 저장
sentPackets[reliablePacket.sequence] = {
.data = reliablePacket,
.sentTime = GetCurrentTime(),
.retryCount = 0,
.timeout = config.initialTimeout
};
}
void SendUnreliable(const Packet& packet) {
Packet unreliablePacket = packet;
unreliablePacket.sequence = currentSeq++;
unreliablePacket.needsAck = false;
socket.SendTo(Serialize(unreliablePacket), serverAddr);
// 버퍼에 저장 안 함
}
void Update() {
// 1. 타임아웃 체크 및 재전송
CheckRetransmissions();
// 2. 수신 처리
ProcessIncoming();
// 3. ACK 전송
SendPendingAcks();
}
private:
void CheckRetransmissions() {
uint64_t now = GetCurrentTime();
for (auto& [seq, sent] : sentPackets) {
if (now - sent.sentTime > sent.timeout) {
// 타임아웃!
sent.retryCount++;
if (sent.retryCount >= config.maxRetries) {
// 최대 재시도 도달
OnPacketLost(seq);
sentPackets.erase(seq);
continue;
}
// 재전송
socket.SendTo(Serialize(sent.data), serverAddr);
sent.sentTime = now;
// Exponential backoff
sent.timeout = std::min(
sent.timeout * 2,
config.maxTimeout
);
}
}
}
void ProcessIncoming() {
while (socket.HasData()) {
auto [data, addr] = socket.ReceiveFrom();
Packet packet = Deserialize(data);
if (packet.isAck) {
OnAckReceived(packet.ackSequence);
} else {
OnDataReceived(packet);
}
}
}
void OnAckReceived(uint32_t ackSeq) {
// 버퍼에서 제거
sentPackets.erase(ackSeq);
}
void OnDataReceived(const Packet& packet) {
// ACK 전송 예약
if (packet.needsAck) {
pendingAcks.insert(packet.sequence);
}
// 순서대로 처리
if (packet.sequence == nextExpectedSeq) {
// 정확한 순서
OnPacketReady(packet);
nextExpectedSeq++;
// 버퍼된 다음 패킷들 처리
while (receivedPackets.count(nextExpectedSeq)) {
OnPacketReady(receivedPackets[nextExpectedSeq]);
receivedPackets.erase(nextExpectedSeq);
nextExpectedSeq++;
}
}
else if (IsNewerThan(packet.sequence, nextExpectedSeq)) {
// 미래 패킷, 버퍼에 저장
receivedPackets[packet.sequence] = packet;
}
// 과거 패킷은 무시 (중복)
}
void SendPendingAcks() {
if (pendingAcks.empty()) return;
// 여러 ACK를 하나의 패킷으로
AckPacket ackPacket;
ackPacket.isAck = true;
for (uint32_t seq : pendingAcks) {
ackPacket.ackSequences.push_back(seq);
}
socket.SendTo(Serialize(ackPacket), serverAddr);
pendingAcks.clear();
}
};
class GameClient {
ReliableUDP network;
void FireWeapon() {
ShootPacket packet {
.weaponId = 1,
.timestamp = GetTime()
};
// ✅ 중요! 신뢰성 보장
network.SendReliable(packet);
}
void UpdatePosition() {
PositionPacket packet {
.position = player.position,
.rotation = player.rotation
};
// ✅ 손실 OK
network.SendUnreliable(packet);
}
void Update() {
// 매 프레임
network.Update(); // 재전송, 수신, ACK 처리
UpdatePosition(); // 60fps
}
};
// ❌ 나쁜 예: 88바이트
struct BadPacket {
uint64_t timestamp; // 8바이트
double position[3]; // 24바이트
double rotation[4]; // 32바이트 (quaternion)
std::string playerName; // 가변
int health; // 4바이트
};
// ✅ 좋은 예: 22바이트
struct GoodPacket {
uint32_t timestamp; // 4바이트 (밀리초면 충분)
int16_t position[3]; // 6바이트 (센티미터 정밀도)
int16_t rotation[3]; // 6바이트 (Euler, 0.01도 정밀도)
uint8_t playerId; // 1바이트 (256명 충분)
uint8_t health; // 1바이트 (0-100)
uint32_t buttons; // 4바이트 (비트 플래그)
};
// 양자화 (Quantization)
int16_t QuantizePosition(float pos) {
// -327.68m ~ +327.67m 범위, 0.01m 정밀도
return (int16_t)(pos * 100.0f);
}
float DequantizePosition(int16_t quantized) {
return quantized / 100.0f;
}
// 성능 향상: 88 → 22바이트 (75% 절약)
struct DeltaPacket {
uint8_t flags; // 어떤 필드가 변경되었는지
// 플래그가 켜진 필드만 포함
Vector3 position; // flags & 0x01
Vector3 rotation; // flags & 0x02
uint8_t health; // flags & 0x04
uint32_t buttons; // flags & 0x08
};
class DeltaEncoder {
GameState lastSent;
DeltaPacket Encode(const GameState& current) {
DeltaPacket packet;
packet.flags = 0;
if (current.position != lastSent.position) {
packet.flags |= 0x01;
packet.position = current.position;
}
if (current.rotation != lastSent.rotation) {
packet.flags |= 0x02;
packet.rotation = current.rotation;
}
if (current.health != lastSent.health) {
packet.flags |= 0x04;
packet.health = current.health;
}
if (current.buttons != lastSent.buttons) {
packet.flags |= 0x08;
packet.buttons = current.buttons;
}
lastSent = current;
return packet;
}
};
// 효과:
// 모든 필드: 22바이트
// 위치만: 7바이트 (플래그 1 + 위치 6) → 68% 절약
// 버튼만: 5바이트 (플래그 1 + 버튼 4) → 77% 절약
// ✅ 부울 플래그를 비트로
struct CompactFlags {
uint32_t data;
bool isJumping() { return data & (1 << 0); }
bool isCrouching() { return data & (1 << 1); }
bool isShooting() { return data & (1 << 2); }
bool isReloading() { return data & (1 << 3); }
// ... 최대 32개 플래그
void setJumping(bool v) {
if (v) data |= (1 << 0);
else data &= ~(1 << 0);
}
};
// 32바이트 → 4바이트 (88% 절약)
class AdaptiveUpdateRate {
const int MAX_FPS = 60;
const int MIN_FPS = 20;
float currentFPS = MAX_FPS;
float packetLoss = 0.0f;
int bandwidth = 0;
void Update() {
// 패킷 손실 측정
packetLoss = MeasurePacketLoss();
// 대역폭 측정
bandwidth = MeasureBandwidth();
// ❌ 네트워크 상태 나쁨: 주파수 낮춤
if (packetLoss > 0.05 || bandwidth > 90) {
currentFPS = std::max(currentFPS - 5, (float)MIN_FPS);
}
// ✅ 네트워크 상태 좋음: 주파수 높임
else if (packetLoss < 0.01 && bandwidth < 50) {
currentFPS = std::min(currentFPS + 5, (float)MAX_FPS);
}
}
bool ShouldSendUpdate() {
float interval = 1.0f / currentFPS;
return (GetTime() - lastSendTime) >= interval;
}
};
// 가까운 플레이어는 자주, 먼 플레이어는 덜 자주
class PriorityBasedUpdate {
void SendUpdates() {
for (auto& player : allPlayers) {
float distance = Distance(localPlayer, player);
int updateRate = CalculateUpdateRate(distance);
if (ShouldUpdate(player, updateRate)) {
SendPlayerState(player);
}
}
}
int CalculateUpdateRate(float distance) {
if (distance < 10.0f) return 60; // 60fps
if (distance < 50.0f) return 30; // 30fps
if (distance < 100.0f) return 15; // 15fps
return 5; // 5fps
}
};
// 대역폭 절약: 거리 100m 밖 플레이어는 92% 절약 (60fps → 5fps)
// ❌ 일반 send(): 2번 메모리 복사
애플리케이션 버퍼 → 커널 버퍼 → NIC 버퍼
복사1 복사2
char buffer[1024];
send(sockfd, buffer, 1024, 0);
// ✅ sendfile() (Linux): 0번 복사
int filefd = open("game_asset.dat", O_RDONLY);
off_t offset = 0;
size_t size = 1024 * 1024; // 1MB
// 파일 → 소켓 (복사 0회!)
ssize_t sent = sendfile(sockfd, filefd, &offset, size);
// ✅ MSG_ZEROCOPY (Linux 4.14+)
char buffer[1024 * 1024]; // 1MB
ssize_t sent = send(sockfd, buffer, sizeof(buffer), MSG_ZEROCOPY);
// 성능 비교:
// 일반 send(): CPU 80%, 전송 1.2초
// sendfile(): CPU 20%, 전송 0.8초 (67% 향상)
// MSG_ZEROCOPY: CPU 30%, 전송 0.9초 (50% 향상)
사용 가이드:
✅ Zero-Copy 사용 시:
- 대용량 파일 (> 1MB)
- 게임 에셋 전송
- 리플레이 전송
- 스트리밍
❌ Zero-Copy 비추천:
- 작은 패킷 (< 10KB) → 오버헤드
- 빈번한 전송
- 버퍼 즉시 재사용 필요
STUN (Session Traversal Utilities for NAT):
// 클라이언트가 자신의 공인 IP 확인
STUNClient stun("stun.l.google.com", 19302);
std::string publicIP;
uint16_t publicPort;
if (stun.GetPublicAddress(publicIP, publicPort)) {
std::cout << "Public: " << publicIP << ":" << publicPort << std::endl;
}
// TURN (Traversal Using Relays around NAT):
// STUN 실패 시 서버를 통해 릴레이
// 성공률 100% but 비용 높음
// ICE (Interactive Connectivity Establishment):
// STUN + TURN 결합
// WebRTC 표준
성공률:
STUN만: 90%
ICE (STUN + TURN): 99.9%
권장: ICE 사용
TCP와 UDP의 선택은 신뢰성-성능 트레이드오프의 결과다:
UDP를 선택:
TCP를 선택:
하이브리드 접근:
대부분의 현대 게임은 둘 다 사용한다:
프로토콜 선택은 게임 장르와 네트워크 특성을 고려한 트레이드오프 결정이다.