TCP와 UDP: 신뢰성과 성능의 트레이드오프

REIN·2025년 10월 3일
0

게임 개발 CS

목록 보기
6/20

들어가며

네트워크 프로토콜 선택은 게임의 성능과 플레이어 경험을 결정한다. TCP는 모든 패킷의 도착을 보장하지만 Head-of-Line Blocking으로 인한 지연이 발생한다. UDP는 패킷 손실이 있지만 즉시 최신 데이터를 전달한다. 이는 신뢰성(Reliability)성능(Performance)의 트레이드오프다.

게임 장르에 따라 우선순위가 다르다. FPS는 최신 플레이어 위치(성능)가 중요하고, 턴제 게임은 모든 명령의 정확한 전달(신뢰성)이 중요하다. 프로토콜 선택은 이 트레이드오프를 이해하고, 게임 특성에 맞는 결정을 내리는 과정이다.

현대 게임 엔진은 하이브리드 접근을 사용한다. 위치 업데이트는 UDP로 전송하고, 아이템 획득은 TCP 또는 Reliable UDP로 보장한다. 이 문서는 두 프로토콜의 동작 원리와 실전 적용을 다룬다.


1. 프로토콜 기초

OSI 7계층에서의 위치

애플리케이션 계층  ← 게임 로직
전송 계층         ← TCP/UDP가 여기 있음
네트워크 계층     ← IP
데이터링크 계층   ← 이더넷, WiFi
물리 계층         ← 케이블, 전파

TCP와 UDP는 모두 전송 계층(Transport Layer) 프로토콜이다.

패킷 구조 비교

TCP 패킷:

계층필드크기설명
IP Header(전체)20 bytesIP 주소, 프로토콜 정보
TCP HeaderSource Port16 bits출발지 포트 번호
Destination Port16 bits목적지 포트 번호
Sequence Number32 bits데이터 순서 번호
Acknowledgment Number32 bits수신 확인 번호
Flags-SYN, ACK, FIN 등 제어 플래그
Window Size16 bits수신 버퍼 크기
Checksum16 bits오류 검사
Options0-40 bytes선택적 기능
(Header 전체)20-60 bytes
DataPayload가변실제 전송 데이터

UDP 패킷:

계층필드크기설명
IP Header(전체)20 bytesIP 주소, 프로토콜 정보
UDP HeaderSource Port16 bits출발지 포트 번호
Destination Port16 bits목적지 포트 번호
Length16 bitsUDP 전체 길이
Checksum16 bits오류 검사 (선택적)
(Header 전체)8 bytes
DataPayload가변실제 전송 데이터

핵심 차이:

  • TCP 헤더: 최소 20바이트
  • UDP 헤더: 고정 8바이트
  • TCP는 시퀀스 번호, ACK, 윈도우 크기 등 복잡한 정보 포함

2. TCP의 동작 원리

3-Way Handshake (연결 설정)

연결 과정

단계송신수신패킷 타입Sequence NumberAcknowledgment의미
1클라이언트서버SYN1000-연결 요청 (초기 seq 전송)
2서버클라이언트SYN-ACK50001001연결 수락 + 서버의 초기 seq
3클라이언트서버ACK10015001연결 확인

연결 완료 → ESTABLISHED 상태

각 단계 상세

단계클라이언트 상태서버 상태주고받는 정보
0CLOSEDLISTEN서버는 연결 대기 중
1SYN_SENTLISTEN클라이언트가 SYN 전송
2SYN_SENTSYN_RECEIVED서버가 SYN-ACK 응답
3ESTABLISHEDSYN_RECEIVED클라이언트가 ACK 전송
4ESTABLISHEDESTABLISHED연결 완료

시간 소요:

  • RTT(Round Trip Time) 1.5배
  • 50ms 핑이면 연결에 75ms 소요
  • 이 시간 동안 데이터 전송 불가

게임에서의 의미:

// ❌ 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

신뢰성 메커니즘

1. 시퀀스 번호와 ACK

// 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까지 다 받았음")

2. 재전송 메커니즘

타임아웃 기반 재전송:

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 (재전송)

3. 흐름 제어 (Flow Control)

정상 동작 시나리오
시점서버 버퍼 상태Window Size클라이언트 동작
초기[□□□□□□□□□□] 0%64 KB최대 64KB 전송 가능
데이터 수신 후[■■■■□□□□□□] 40%38 KB최대 38KB 전송 가능
버퍼 거의 찬 상태[■■■■■■■■■□] 90%6 KB최대 6KB만 전송 가능

원리: 서버가 Window Size를 통해 "내가 받을 수 있는 크기"를 알려줌

윈도우 = 0 상황 (Zero Window)
단계서버 (수신자)Window Size클라이언트 (송신자)
1[■■■■■■■■■■] 버퍼 꽉 참0 KB⚠️ 전송 중단, 대기
2애플리케이션이 데이터 읽음0 KB주기적으로 윈도우 확인 (probe)
3[■■■■□□□□□□] 공간 확보32 KB✅ 전송 재개

Zero Window Probe: 클라이언트는 주기적으로 1바이트 패킷을 보내 윈도우 상태 확인

4. 혼잡 제어 (Congestion Control)

// 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;
    }
}

Head-of-Line Blocking 문제

실제 시나리오:

// 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를 못 줌
- 결과: 오래된 데이터를 늦게 받음

→ 신뢰성을 위해 성능을 희생

TCP 상태 기계

연결 설정 (3-Way Handshake)

단계클라이언트 상태동작서버 상태비고
0CLOSEDCLOSED초기 상태
1socket() + bind() + listen()LISTEN서버 대기 시작
2SYN_SENTconnect() → SYN 전송 →LISTEN연결 요청
3SYN_SENT← SYN-ACK ←SYN_RCVD서버 응답
4ESTABLISHED→ ACK →ESTABLISHED✅ 연결 완료

데이터 전송

상태클라이언트서버설명
ESTABLISHEDsend() / recv()send() / recv()양방향 데이터 교환

연결 종료 (4-Way Handshake)

단계클라이언트 상태동작서버 상태설명
1FIN_WAIT_1close() → FIN 전송 →ESTABLISHED클라이언트 종료 시작
2FIN_WAIT_1← ACK ←CLOSE_WAIT서버가 FIN 확인
3FIN_WAIT_2대기CLOSE_WAIT서버는 남은 데이터 전송 가능
4FIN_WAIT_2← FIN ←LAST_ACK서버가 close() 호출
5TIME_WAIT→ ACK →CLOSED서버 종료 완료
6TIME_WAIT2×MSL 대기 (~2분)-지연 패킷 대기
7CLOSED-✅ 클라이언트 종료 완료

게임 서버에서 흔한 문제:

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):

시점클라이언트서버문제점
정상ESTABLISHEDESTABLISHED양쪽 연결 유지
장애 발생네트워크 장애 발생
(라우터 재시작, 케이블 단선 등)
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));

3. UDP의 특성

데이터그램 특성

// ✅ 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의 실제 손실률

측정 데이터:

네트워크 환경별 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개 손실

플레이어 경험:
- 자주 끊김
- 눈에 띄는 랙
- 플레이 방해

→ 성능을 위해 신뢰성을 희생

UDP 순서 재정렬

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));
    }
};

4. 게임별 선택 가이드

FPS 게임: UDP + Reliable Events

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);
    }
};

MMO 게임: TCP 주력 + UDP 보조

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. 신뢰성이 매우 중요 (아이템, 경험치, 퀘스트)

레이싱 게임: UDP

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. 손실되어도 다음 프레임에 보정

턴제 게임: TCP

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. 체스, 카드 게임 등

5. Reliable UDP 구현

기본 구조

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
    }
};

6. 성능 최적화

패킷 크기 최적화

// ❌ 나쁜 예: 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)

Zero-Copy 최적화

// ❌ 일반 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) → 오버헤드
- 빈번한 전송
- 버퍼 즉시 재사용 필요

NAT Traversal

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를 선택:

  • 높은 업데이트 주파수 (30fps+)
  • 실시간 액션 (FPS, 레이싱)
  • 최신 데이터가 중요
  • 손실 허용 가능

TCP를 선택:

  • 낮은 업데이트 주파수
  • 턴제 게임
  • 모든 데이터가 중요
  • 약간의 지연 허용

하이브리드 접근:
대부분의 현대 게임은 둘 다 사용한다:

  • UDP: 게임플레이 (위치, 상태) → 성능 우선
  • Reliable UDP: 중요 이벤트 (아이템, 스킬) → 신뢰성 보장

프로토콜 선택은 게임 장르와 네트워크 특성을 고려한 트레이드오프 결정이다.

profile
RL Researcher, Video Game Developer

0개의 댓글