
UDP·KCP 기반 실시간 액션 동기화 서버 제작: 지연과 패킷 손실을 다루는 설계
UDP의 낮은 지연과 KCP의 선택적 재전송을 결합해 실시간 액션 게임 서버를 설계한다. 입력 동기화, 스냅샷 보간, 순서 보장, 과부하 제어와 운영 지표까지 구현 관점에서 정리한다.
TL;DR
실시간 액션 게임은 모든 상태를 즉시 신뢰성 있게 보내기보다 조작 입력은 빠르게 처리하고 중요한 이벤트만 보장 전송하는 구조가 유리하다. UDP 위에 KCP를 올리면 애플리케이션별 연결 상태와 선택적 재전송을 제어할 수 있지만 KCP 채널에 모든 패킷을 넣으면 지연이 누적될 수 있으므로 메시지를 성격별로 분리해야 한다.
이 글의 권장 구조는 서버 권위형(Server Authoritative)이다. 클라이언트는 입력을 전송하고 서버는 틱 단위로 시뮬레이션한 뒤 스냅샷을 배포한다.
KCP는 UDP 위에서 동작하는 고속 신뢰성 전송 프로토콜(Fast and Reliable ARQ Protocol)입니다. TCP처럼 데이터의 유실 없는 전달과 순서 보장을 제공하면서도 TCP의 단점인 지연 시간(Latency)을 대폭 줄이기 위해 설계되었습니다.
UDP와 KCP는 실시간 액션 동기화에 어떻게 역할을 나눌까?
UDP는 연결 설정과 신뢰성, 순서 보장을 제공하지 않는 대신 헤더가 작고 지연을 낮게 유지하기 쉽다. 반면 KCP는 UDP 데이터그램 위에서 시퀀스 번호, ACK, 재전송, 혼잡 제어를 구현하는 전송 계층 라이브러리다.
| 전송 대상 | 권장 방식 | 이유 |
|---|---|---|
| 이동·조준 입력 | UDP 비신뢰성 | 최신 입력만 중요하며 이전 입력은 빠르게 폐기 가능 |
| 월드 스냅샷 | UDP 비신뢰성 | 다음 스냅샷이 이전 상태를 대체 |
| 피격, 처치, 아이템 획득 | KCP 신뢰성 | 누락되면 게임 결과가 달라짐 |
| 방 입장, 준비 완료, 로딩 완료 | KCP 신뢰성 | 순서와 도착 보장이 필요 |
| 채팅, 운영 명령 | KCP 신뢰성 | 유실보다 정확성이 우선 |
KCP는 TCP와 같은 스트림이 아니라 메시지 단위로 다루기 쉬운 UDP 기반 프로토콜이다. 다만 KCP 재전송 대기열에 스냅샷을 계속 넣으면 오래된 스냅샷이 늦게 도착해 최신 상태를 방해한다. 따라서 상태 갱신에는 순수 UDP를 결과 확정 메시지에는 KCP를 사용하는 혼합 설계를 권장한다.

실시간 액션 서버의 동기화 루프는 왜 서버 권위형이어야 할까?
클라이언트가 자신의 위치나 피해량을 최종 결정하면 속도 핵, 순간이동, 공격 속도 변조를 막기 어렵다. 서버 권위형에서는 클라이언트가 의도를 담은 입력만 보내고 서버가 물리와 전투 규칙을 계산한다.
flowchart LR
C[클라이언트] -->|InputCommand: UDP| S[서버 입력 버퍼]
S --> T[고정 틱 시뮬레이션]
T -->|WorldSnapshot: UDP| C
T -->|HitEvent / RewardEvent: KCP| C
C --> R[렌더링 보간 및 예측]
예를 들어 서버 틱을 60Hz로 설정하면 시뮬레이션 간격은 다음과 같다.
서버는 각 플레이어의 마지막으로 유효한 입력을 보관하고 해당 틱에 새 입력이 없어도 마지막 입력 또는 중립 입력으로 시뮬레이션한다. 네트워크 수신 시점에 따라 게임 결과가 흔들리지 않도록 수신 콜백에서 즉시 물리를 계산하지 않는 것이 핵심이다.
UDP·KCP 기반 동기화 서버를 어떻게 구현할까?
1. 패킷 헤더와 메시지 타입을 고정한다
패킷은 버전, 세션 ID, 시퀀스 번호, 클라이언트 틱을 명확히 포함해야 한다. 세션 ID는 단순 IP:포트보다 안전한 연결 식별자이며 재접속과 NAT 포트 변경 처리에도 도움이 된다.
public enum MessageType : byte
{
InputCommand = 1,
WorldSnapshot = 2,
HitEvent = 3,
JoinRoom = 4,
Ping = 5
}
public readonly record struct InputCommand(
uint ClientTick,
ushort Sequence,
sbyte MoveX,
sbyte MoveY,
ushort AimAngle,
byte Buttons
);
MoveX, MoveY는 -127~127의 정수로 양자화하고 조준 각도는 0~65535 범위의 ushort로 저장하면 부동소수점 문자열 변환 없이 일정한 패킷 크기를 유지할 수 있다. 입력 패킷은 가능한 한 100바이트 이하로 유지하고 여러 입력을 한 데이터그램에 묶을 때도 MTU를 넘기지 않도록 제한한다.
2. 서버 틱과 네트워크 수신을 분리한다
수신 스레드는 UDP 소켓에서 데이터그램을 읽어 검증한 뒤 플레이어별 입력 버퍼에 넣는다. 게임 스레드는 고정 틱으로 버퍼에서 처리 가능한 최신 입력을 꺼내 시뮬레이션한다.
void SimulateTick(uint serverTick)
{
foreach (var player in room.Players)
{
var input = player.InputBuffer.TakeLatestAtOrBefore(serverTick)
?? InputCommand.Neutral(serverTick);
player.ApplyInput(input, fixedDeltaTime);
}
room.ResolveCollisions();
room.ResolveCombat();
BroadcastSnapshot(serverTick);
}
입력 버퍼에는 이미 처리한 Sequence를 기록해 중복 패킷을 제거한다. 너무 미래의 ClientTick은 제한하고 지나치게 오래된 입력은 폐기한다. 이 검증은 동기화 안정성뿐 아니라 클라이언트가 임의 틱 번호를 주입하는 공격을 줄이는 기본 방어선이다.
3. 스냅샷은 전체 상태가 아닌 델타로 보낸다
모든 엔티티의 전체 상태를 매 틱 보낼 필요는 없다. 서버는 플레이어가 관심을 가질 수 있는 영역(AOI, Area of Interest)을 계산하고 이전에 보낸 기준 스냅샷과 달라진 필드만 전송한다.
| 필드 | 전송 주기 예시 | 인코딩 방식 |
|---|---|---|
| 위치·회전 | 20Hz | 정수 양자화 + 델타 |
| 속도 | 10~20Hz | 정수 양자화 |
| HP·상태 이상 | 변경 시 | 신뢰성 이벤트 또는 델타 |
| 생성·제거 | 즉시 | KCP 신뢰성 메시지 |
스냅샷 전송 주기를 20Hz로 두고 렌더링은 60Hz 이상으로 수행하면 클라이언트는 두 스냅샷 사이를 보간할 수 있다. 보간 지연을 100ms로 설정했다면 렌더링 시각은 다음과 같이 잡는다.
이 방식은 약간 늦은 상태를 부드럽게 보여 주는 대신 패킷 도착 순서가 바뀌어도 화면 떨림을 줄인다.
패킷 손실과 지연은 어떻게 보정할까?
클라이언트 예측과 서버 재조정
내 캐릭터는 서버 응답만 기다리면 조작감이 둔해진다. 클라이언트는 로컬에서 입력을 즉시 적용하고 전송한 입력을 Sequence별로 보관한다. 서버 스냅샷이 도착하면 서버의 확정 위치로 되돌린 뒤 아직 서버가 확인하지 않은 입력만 순서대로 다시 적용한다.
1. 클라이언트가 입력 120을 로컬 캐릭터에 즉시 적용한다.
2. 입력 120을 UDP로 서버에 전송하고 재적용 버퍼에 저장한다.
3. 서버 스냅샷이 마지막 처리 입력 118을 포함해 도착한다.
4. 클라이언트는 서버 상태를 적용한 뒤 입력 119, 120만 재시뮬레이션한다.
다른 플레이어와 투사체는 보간 중심으로 표현한다. 원격 객체까지 무리하게 예측하면 충돌과 피격 판정이 어긋나 시각적 오류가 커질 수 있다.
KCP 파라미터는 측정값으로 조정한다
KCP의 nodelay, interval, resend, nc 설정은 만능값이 아니다. 일반적인 액션 게임의 출발점으로는 빠른 업데이트 간격을 검토할 수 있지만 모바일 네트워크에서는 과도한 재전송이 혼잡을 키울 수 있다.
| 항목 | 의미 | 운영 시 확인할 지표 |
|---|---|---|
interval | KCP 업데이트 주기 | CPU 사용량, RTT 변동 |
resend | 빠른 재전송 임계값 | 재전송률, 중복 전송량 |
| 송신 윈도우 | 미확인 세그먼트 상한 | 대기열 길이, 메모리 |
nc | KCP 혼잡 제어 비활성화 여부 | 손실률, 네트워크 공정성 |
ikcp_update 또는 해당 언어 바인딩의 업데이트 함수는 일정한 간격으로 호출해야 한다. 실서비스에서는 RTT 중앙값, p95 RTT, 패킷 손실률, KCP 재전송률, 송신 대기열 길이를 방 단위와 리전 단위로 수집한 뒤 파라미터를 조정한다.
치트와 장애를 막으려면 무엇을 검증해야 할까?
UDP는 출발지 주소를 위조한 트래픽과 대량 패킷에 노출되기 쉽다. 게임 규칙 검증과 인프라 보호를 함께 설계해야 한다.
- 인증 후 발급한 예측하기 어려운 세션 토큰을 패킷에 포함한다.
- IP·세션별 초당 패킷 수와 바이트 수를 제한한다.
- 입력 범위, 입력 빈도, 이동 가능 거리, 쿨다운을 서버에서 검증한다.
- KCP 세션 생성 전에는 짧은 핸드셰이크와 타임아웃을 둔다.
- 패킷 길이와 메시지 타입을 역직렬화 전에 검사한다.
- 서버 시간과 클라이언트 시간을 분리하고 클라이언트 타임스탬프를 권한 판단 근거로 사용하지 않는다.
특히 clientPosition을 그대로 신뢰해 충돌 판정을 하는 방식은 피해야 한다. 클라이언트는 InputCommand를 보내고 서버가 속도, 가속도, 지형 충돌을 계산해야 한다.
자주 묻는 질문 (FAQ)
KCP만 사용하고 순수 UDP 채널은 없애도 될까?
가능하지만 빠르게 폐기돼야 하는 위치 스냅샷까지 재전송 대기열에 쌓일 수 있다. 액션 게임이라면 최신성이 중요한 상태 패킷을 별도 UDP 채널로 두는 편이 보통 더 안정적이다.
TCP 대신 UDP와 KCP를 써야 하는 이유는 무엇인가?
TCP는 유실된 앞선 바이트를 복구할 때까지 뒤 데이터를 애플리케이션에 전달하지 않는 특성이 있다. 실시간 이동 상태에서는 오래된 데이터를 기다리기보다 최신 상태를 처리하는 편이 유리하다. 다만 턴제 게임이나 관리 API처럼 정확성이 우선인 통신에는 TCP가 더 단순한 선택일 수 있다.
서버 틱은 반드시 60Hz여야 할까?
아니다. 30Hz도 많은 게임에서 충분하며 전투 판정 정밀도와 서버 CPU 비용, 동시 접속자 수를 함께 고려해야 한다. 먼저 30Hz 또는 60Hz 중 하나를 고정하고 부하 테스트와 플레이 테스트로 결정하는 것이 좋다.
마무리
UDP·KCP 기반 액션 서버의 핵심은 프로토콜 자체보다 메시지 분류와 서버 권위다. 입력과 스냅샷은 최신성을 우선해 UDP로 보내고 게임 결과와 세션 절차는 KCP로 보장하며 서버 틱에서 모든 규칙을 검증하자. 마지막으로 실제 네트워크 환경의 RTT, 손실률, 재전송률을 관측해야 동기화 품질을 안정적으로 개선할 수 있다.


