Today I Learn
[TIL][C++] 250502 MMO 서버 개발 9일차: TCP 특성으로 인한 WSARecv 사용 시 주의할 점, 같은 공간, 같은 상태로 동기화를 위한 MaxLatency 기법
pledge24
2025. 5. 2. 22:46
주의사항: 해당 글은 일기와 같은 기록용으로, 다듬지 않은 날것 그대로인 글입니다.
TCP 특성으로 인한 WSARecv 사용 시 주의할 점
- TCP에서의 송수신 데이터는 경계가 존재하지 않는다. 즉, CP에 등록한 소켓으로부터 데이터가 수신되었다는 것이 클라이언트가 보낸 데이터가 전부 도착했다는 의미가 되진 않는다. 그림으로 표현하자면 아래와 같다.
// Client가 데이터 A -> 데이터 B 순서로 Server에게 보냄
// Server의 OS 수신 버퍼의 경우의 수
// 1. A의 일부만 도착: [OOOOOOOOO____]
// 2. A가 전부 도착: [OOOOOOOOOOOO]
// 3. A + 일부 B 도착: [OOOOOOOOOOOOO][OOOOOOO_______]
- WSARecv는 해당 소켓으로부터 데이터가 수신되었음을 “감지”만 되어도 완료 패킷이 CP에 들어가기 때문에 서버 프로그램의 워커 스레드가 GQCS를 통해 WSARecv에 대한 결과를 받은 상황이라도 정확히 하나의 온전한 데이터가 들어왔다는 것을 보장하지 않는다.
- 이러한 상황에서 온전한 데이터가 들어왔음을 보장하고 온전한 데이터의 경계를 알아내기 위해 각 송수신 데이터 앞에 “헤더”를 추가한다. 고정된 헤더 크기를 사용할 경우, 최소한 헤더 크기만큼을 수신받을 때 작업하게 한 다음, 헤더 크기만큼 이 들어오면 헤더를 까서 앞으로 받아야 할 온전한 하나의 데이터 크기를 알 수 있게 되기 때문에 위 문제가 해결된다.
// 송수신 데이터 구조(패킷 구조)
// [[Header][------Payload-------]]
// Header: 앞으로 받을 데이터의 크기(페이로드의 크기)와 패킷 식별 id와 같은 데이터
// Payload : 실제 데이터.
// 각 상황에 따른 판단 과정
// recvBuffer: [[A-Hea__________________________________] => 온전한 헤더가 다 들어올때까지 대기
// recvBuffer: [[A-Header]______________________________] => 헤더가 다 수신되면 헤더를 까서 패킷 id와 payload 크기를 알아낸다.
// recvBuffer: [[A-Header][---A-Paylo___________________] => 온전한 payload가 들어올때까지 대기한다
// recvBuffer: [[A-Header][---A-Payload---][B-Hea_______] => 온전한 패킷 크기만큼(Header + Payload) 패킷 파싱
// recvBuffer: [[B-Hea__________________________________] => 파싱한 크기만큼 flush
- 위 상황은 등록한 WSARecv에 대한 완료 패킷 일감을 워커 스레드가 GQCS로 받았을 때 해당 WSARecv의 인자로 넣어준 recvBuffer의 상황이다. 워커 스레드는 각 recvBuffer의 상황에 따라 패킷을 파싱 할 건지, 아니면 WSARecv 등록만 하고 파싱은 하지 않을 것인지 선택하여 실행된다. (이 부분에 대한 함수는 아마 processRecv()와 같은 곳일 거다.) 이를 통해, 경계가 없는 바이트 스트림 형식의 데이터를 의미 단위로 쪼개서(파싱 해서) 사용할 수 있게 된다.
같은 공간, 같은 상태로 동기화를 위한 MaxLatency 기법
- 서버는 각 클라이언트가 같은 방에 존재했을 때 전부 같은 상태로 보여야 하는 책임이 있다. 하지만 같은 공간에 존재하는 각각의 클라이언트들은 물리적 거리, 네트워크 회선의 품질 등에 따라 RTT가 전부 다르다.
- RTT는 Round Trip Time의 약자로, 클라이언트가 보낸 패킷이 서버를 찍고 다시 돌아오는 시간을, 즉, 패킷 왕복 시간을 의미한다.
- RTT가 다른 경우, 각 클라이언트가 동시에 데이터를 보내더라고 RTT가 작은 클라이언트의 패킷이 서버에 먼저 도달하기 때문에 패킷 처리의 우선권은 가져간다. 이를 그대로 처리해서 서버가 관리하는 같은 공간에 대한 상태를 갱신한다면, 당연히 RTT가 큰 클라이언트가 불리할 수밖에 없다.
- 반대의 상황도 똑같다. 서버가 관리하는 같은 공간의 상태를 broadcast 하여 각 클라이언트에게 동시에 뿌린다고 해도 RTT가 낮은 클라이언트에 먼저 도착할 것이다. 즉, RTT가 낮은 클라이언트가 먼저 갱신된 상태를 보고 더 빠르게 반응할 수 있다는 것이다. 이는 공평하지 않다.
- 서버는 클라이언트가 같은 상태를 바라보게끔 조정해야 하며, RTT가 낮은 클라이언트에 갱신된 상태가 먼저 도착하여 발생하는 이점을 없애야 한다. 이를 해결하는 가장 대표적인 방법은 각 호스트에 지연을 넣는, 즉, latency를 주는 것이다.
latency 종류
- latency를 넣으면 패킷이 수신되어도 곧바로 처리하지 않고 설정한 latency값만큼 뒤에 처리한다. 이때 RTT가 높은 클라이언트는 latency를 낮게, RTT가 낮은 클라이언트는 latency를 높게 잡아 패킷이 처리되어 상태를 갱신하는 시간을 조절한다.
- latency값을 설정하는 데 있어 사용하는 정책은 다양하다. 가장 낮은 RTT값을 latency로 잡는 minLatency, 평균 RTT값을 latency로 잡는 avgLatency, 가장 높은 RTT값을 latency로 잡는 MaxLatency 등이 있다. (백분율로 잡는 경우도 있다) 여기서 중요한 점은 어찌 되었든 낮은 값으로 latency를 설정하면 반응성이 높아지는 대신 안정성이 떨어지며, 높은 값으로 latency를 설정할수록 반응성이 낮아지는 대신, 안정성이 높아진다.
- FPS와 같은 게임의 경우, 클라이언트가 총을 쏘자마자 서버가 반응해야 하기 때문에 반응성이 중요하게 여겨진다. 반면, MMORPG와 같은 장르는 반응성이 FPS만큼 중요하게 여겨지지 않기 때문에(예로, FPS는 2~3m, MMO 장르는 50ms까지도 허용된다.) 서버의 안정성을 챙길 수 있는 MaxLatency를 선택한다.
maxLatency
- maxLatency는 가장 높은 RTT를 기준으로 latency를 설정하기 때문에 모든 패킷은 네트워크 패킷 처리에 대해 지연을 가진다. (MaxLatency - myRTT = myLatency, myLatency ≥ 0)
- 즉, 서버와 클라이언트 모두 패킷이 수신됨과 동시에 처리하는 것이 latency만큼 뒤에 처리가 된다면 양측은 수신된 데이터가 전부 같은 시각에 처리될 것이다.
Client1's RTT = 30ms
Client2's RTT = 70ms
Client3's RTT = 40ms
// RTT는 왕복 시간, latency는 편도로 계산해야한다.
=> maxLatency = 70 / 2 = 35ms
// RTT/2 => 평균 전송 시간(편도)
Client1's Latency = 35 - 15 = 20ms
Client2's Latency = 35 - 35 = 0ms
Client3's Latency = 35 - 20 = 15ms
// Client -> Server
// _-[서버 입장]-_
Client1 send packet = [[---전송 시간(15ms)--->][----Latency(20ms)----]] => 패킷 실행
Client2 send packet = [[---------------전송 시간(35ms)-------------->]] => 패킷 실행
Client3 send packet = [[-----전송 시간(20ms)----->][--Latency(15ms)--]] => 패킷 실행
// Server -> Client
// _-[클라 입장]-_
Client1 recv packet = [[----Latency(20ms)----][<---전송 시간(15ms)---]] <= 패킷 전송(broadcast)
Client2 recv packet = [[<---------------전송 시간(35ms)--------------]] <= 패킷 전송(broadcast)
Client3 recv packet = [[--Latency(15ms)--][<-----전송 시간(20ms)-----]] <= 패킷 전송(broadcast)
maxLatency의 장단점
- 다른 정책보다 서버가 안정적이다. 모든 클라이언트가 지연율을 가지기 때문에 서버는 상태 갱신을 위해 RTT가 높은 클라이언트의 상태를 예측할 필요가 없기 때문이다.(패킷이 유실되는 등 예외적인 상황이 있겠지만) 따라서, 반응성을 굳이? 높게 가져갈 필요가 없다면 maxLatency가 적합하다.
- 하지만 maxLatency는 치명적인 약점이 하나 존재하는데, 가장 높은 latency값이 들쭉날쭉 수시로 바뀌거나, 말도 안 되게 latency가 높은 한 유저가 있을 때처럼 최악의 latency 상태가 유지되면 유저의 경험이 굉장히 나빠진다는 것이다. 즉, maxLatency는 똥컴인 유저 하나만 있어도 해당 공간에 있는 모든 유저가 다 같이 느려지거나, 반응 속도가 이랬던 저랬다 요동치는 경우가 발생한다.
- 따라서, 생으로 maxLatency를 쓰는 건 조금 위험할 수 있기 때문에 특정 상황에선 max대신 백분율로 계산하여 비정상적인 latency를 가지는 일부 유저를 제외하고 계산하기도 한다.