L4와 L7그리고 서버

2021. 11. 4. 23:18게임서버/win socket 프로그래밍

320x100

안녕하세요 대학생 개발자입니다.

오늘은 주제가 다양하고 내용이 상당부분 겹치는 부분이있습니다...

내용이 많이 겹칠수도 있으니 이미 이전에 보셨던 분이라면 그 부분은 스킵을 하고 넘어 가셔도 좋을듯 싶습니다.

그럼 시작하겠습니다.

 

우선 마지막으로 올렸던 글에서는 winsocket api의 가장 기본이 되는 echo server에 나오는 비동기 send recv에 대해서 정리를 해봤습니다.

오늘은 살짝 주제를 바꿔보려고 합니다.

먼저 L2에는 ARP RARP등의 프로토콜이 존재합니다.

L3에는 IP, ICMP등의 프로토콜이 존재합니다.

L4에는 TCP, UDP등의 프로토콜이 존재합니다.

그들은 각각 자신의 프로토콜과 그에 맞는 syntax와 semantics를 가지고있습니다.

그것들을 가지고 통신을 정의하는 것입니다.

그렇다면 게임서버(L7 어플리케이션레벨)의 경우에는 어떨까요?? 당연히 L7인 서버에서도 메시지를 주고 받으며 서버에서 로직을 처리할겁니다.

그렇게 된다면 우리도 따로 프로토콜을 만들어야 될겁니다.

우리는 구조체를 만들때 필요한 변수들을 선언하겠지만 각 컴파일러는 구조체의 패딩을 완벽하게 동일하게 정의하지 않습니다.

즉... 컴파일러마다 패딩이 달라질 수도 있으니 우리는 이 패딩을 다 0으로 만들어 줘야합니다.

 

그리고 구조체는 단하나만 정의되지 않을 확률이 매우높습니다. 왜냐면 게임서버는 기본으로 몇백개의 메시지를 가지고 있으니까요 이걸 한 구조체에 다 담을 수 없을겁니다.

그러면 당연히 여러 메시지의 구조체가 등장할것이고 그걸 구분하기 위해서 우리는 헤더에 type을 둘것입니다.

우리는 이 타입을 가지고 어떤 목적을 가진 메시지인지 구분할 것입니다.

그리고 메시지의 끝을 알려면 어떠한 특정한 값을 담거나 혹은 헤더에 사이즈를 담아야겠죠.

저같은 경우에는 헤더에 사이즈를 담는걸 선호하는 편입니다. 다른분들은 어떻게하실지 모르겠네요.

그리고 마지막으로 체크섬 정도 이정도만 있으면 일단 헤더의 모양은 갖춘 것 같습니다. 나머지는 이 헤더를 상속받아서 작성해야겠죠.

음... 근데 제가 배울때는 이런식으로 배웠던거 같아요 헤더에 첫번째 1바이트는 내가 지정한 고정값으로 가자 그리고 이 고정값이 틀리다면 무조건적으로 그 메시지는 버린다가 되는거죠.

 

아 여기서 체크섬에 대해서 오해를 하시는 분들이 간혹 있으십니다.

L7에서 체크섬이란 데이터에 노이즈가 끼었는지를 확인하는 단계는 아닙니다. 여기서의 체크섬이란 플레이어의 이상한 의도적 개입을 방지하기 위한 (즉 핵같은걸 걸러내기위한) 체크섬 기능입니다. 당연히 데이터 자체의 변질이 있었다면 L2에서 CRC를 돌렸을 때 부터 버려졌을거구요.

물론 이 체크섬이 완벽하진 않습니다 그렇기 때문에 1차적인 네트워크 방어가 필요하고 2차적은 컨텐츠적 내부방어또한 필요합니다.

여기서 고민해봐야 할 것이있죠.. 어쩌다가 단 한번이라도 이상한 데이터가 도착한 플레이어에 대해서 어떻게 처리해야되는가입니다.

게임서버는 이런 경우에 그냥 당연히 끊는게 정상입니다. 단 한번이라도 이상한 데이터가 들어온 유저는 이상한 행동을 한 유저가 되는겁니다. 다른 선량한 유저들을 위해서라도 막아내야합니다. 하지만 이런경우 상대가 메시지를 조작해서 보내는 경우에 어떤 경우에는 서버가 연결을 끊더라 라는 부분을 학습해서 하나씩 다돌리다보면 정상범주의 범위를 찾게 될수도있는데 이런 경우를 방지하기 위해서는 잘못된 데이터를 처리는 하지 않되 되는것 처럼 보이게 만들어 주는 방법이 있습니다. 뭐.. 악성 유저들의 분석을 방해하는 케이스가 되는 거겠죠

 

우선 헤더는 제가봤을때 이정도만 해도 괜찮을 것 같습니다. 물론 게임이라면 여기에 아이디 정보나 이런 모든 메시지에 공통적으로 들어가는 요소들을 담겠습니다만 아직 우리는 프로토콜을 구현하려는게 아니라 그저 제너럴한 프로토콜은 이런 기능은 기본으로 제공해야 된다에 대한 내용을 말하는 거니까요.

 

그럼 프로토콜의 남은 얘기를 해보겠습니다. 우리 프로토콜은 메시지가 굉장히 많습니다. 당연히 매번 사이즈가 들어올거고 그 사이즈 만큼 읽겠지만 만약 네이글 알고리즘에 의해서든 윈도우 사이즈가 문제든간에 해서 두 데이터가 잘리게 된다면요?

그러면 이걸 어떻게 처리할거냐에 문제를 고민해봐야됩니다.

무작정 다뽑아내서 남는건 어떻게 할건데요??가 되는겁니다. 하나의 메세지가 완성이 안된다면 그대로 둬야되고 만약 메시지가 1개 이상 완성이 된다면 그 메시지들은 다 뽑아내야될 것입니다. 한번에 recv에 처리할 수 있는 모든 데이터를 다 처리하고 나오는거죠.

 

우리는 이런 기능을 위해서 새로운 버퍼가 필요할겁니다. 하나의 윈도우처럼 읽은 부분과 들어온 부분을 나누는 그런 두개의 포인터로 이루어진 버퍼를 말이죠 하지만 버퍼는 무한하지 않습니다. 그래서 하나의 원형 큐를 만들어서 처리해야될겁니다. 이것을 링버퍼라고 하고 바이트 단위의 Enqueue와 Dequeue를 지원할겁니다.

 

 

 

그럼 다음으로 소켓 옵션에 대해서 간단하게 짚고 넘어가보려고 합니다.

SO_BROADCASTING -> 말그대로 브로드캐스팅입니다.

SO_KEEPALIVE ->  이건 주기적으로 연결상태를 확인하라는 의미입니다. 기본으로는 시간단위로 조정이 가능하지만 winsock api를 통해 초단위로 걸러낼 수도있습니다.

 원리는 단순합니다. 헤더르 보내고 어크가 돌아오는지 확인해서 상대가 살아있음을 확인하는 겁니다.

 이걸 쓸지 안쓸지에 대한 결정을 하는 부분이 이 옵션입니다.

  서버의 경우에는 KEEP_ALIVE를 끄고갈겁니다. 이유가 많은데요

  첫번째로 L4에서 반응을 체크하긴 합니다만, L7에서도 반응체크를 해줘야되기때문에 입니다.

  그럼 왜 L7에서 반응을 체크해야되는가 하면 게임이 먹통이 되어도 KEEP_ALIVE는 작동을 합니다 어플리케이션이랑 전혀 관계없는 커널에서의 일이니까요 작동을 보장받습니다. 하지만 우리 서버의 경우에는 비정상 클라이언트를 다 잡아내야할 이유가 있습니다. 하지만 게임을 켜놓고 잠수를 타는 클라이언트도 분명 있을겁니다. 이런경우에는 게임 로직에서 시간을 두고 한번씩 서버에 나 살아있어 라고 알려주는 겁니다. 당연히 L7에서의 keep alive timer보단 짧은 시간이어야겠습니다 하하

이런 케이스를 클라이언트에서 heart beating을 한다고 합니다 최소한의 심장박동은 뛰고있음을 알려줌으로써 서버에게 클라이언트가 생존신고를 하는거죠.

 

개인적으로 여기가 가장 중요하다고 생각합니다

SO_LINGER인데요

링거의 경우에는 4way hand shake후의 TIME_WAIT부분을 조절하는 기능이라고 보셔도 될 것 같습니다.

우선 TIME_WAIT이 존재하는 이유를 본다면 일단 끊기자 말자 연결이 바로 되었다면 network에 떠돌던 늦게 도착한 메시지가 도착했을때 문제가 될 수있습니다. 물론 이 케이스는 확률이 아주 낮고 이런 조건들이 붙습니다.

 - 방금 연결이 끊어졌던 상대의 아이피와 포트와 동일해야된다

 - 어크번호와 시퀀스 번호가 동일하게 일치해야된다.

 -> 이런이유로 거의 불가능하다고 생각하면 됩니다. 이부분은 TCP가 너무 과민하게 안전추구를 하는 케이스라고 볼 수 있습니다.

 

하지만 반대로 TIME_WAIT이 없어도 문제가 되는 케이스도 분명 있습니다.

멀티스레드로 가게된다면 분명 1번 스레드에서는 send에서 에러가 나서 closesocket()을 했고 루틴을 돌아 recv()에서 0이 나와서 또 closesocket()을 하게됩니다. 뭐... 여기까진 문제될것 전혀 없습니다. 근데 만약 send와 recv사이에 closesocket이 된 그 똑같은 소켓을 이용해서 바로 다른 클라이언트를 서비스를 해버린다면...? 그렇게 된다면 이 클라이언트는 웃기게도 들어오자말자 closesocket이 되어버리는 어처구니 없는 상황이 생길겁니다.

그렇기 때문에 이런 동기화 문제를 잡아줘야겠죠... 물론 커널리소스를 쓰지 않는 방법으로 한다면 가장 좋구요.

 

그럼 TIME_WAIT이 길다면 생길 문제에 대해서 정리하겠습니다.

클라이언트의 경우에는 포트의 고갈 가능성이 있습니다. 물론... 뭐 미친듯이 connect를 하지 않는이상... 그럴일은 없지만 혹여나 TIME_WAIT이 몇시간이라면 그사이에 포트를 다 고갈할 가능성은 충분히 존재하긴 합니다.

 

반대로 서버의 경우에는 큰 의미가 없습니다. 이미 내가 서버의 포트는 리슨상태로 열어둔 것이고 time_wait상태는 연결에서의 의미지 포트 사용과 전혀 무관하게 됩니다. 하지만 서버가 꺼졌다가 다시 켜질때 바인딩은 안되겠지만 이미 바인딩이 된 상황이라면 그 연결의 불가능을 의미하지 포트를 사용할 수 없다가 되는것은 아닙니다.

 

하지만 그럼에도 불구하고 서버에서는 TIME_WAIT을 안남기는 이유는 일단 소켓, TCP는 커널입니다. 그리고 논페이지풀에 들어가게됩니다. 그렇기 때문에 악질적인 클라이언트가 연결만 주구장창 계속 해버린다면...(물론 이건 L3에서 막겠지만) 계속 리소스를 잡아먹게 되고 위험해집니다. 아무리 짧은 TIME_WAIT이라도 위험합니다. 그렇기 때문에 서버는 TIME_WAIT을 무조건 끄는 방향으로 갑니다.

 

일단 TIME_WAIT이 생기는 이유에 대해서 보겠습니다 TIME_WAIT은 FIN신호를 보낼때 그 클라이언트가 상대의 FIN을 받고 자신의 ACK를 보내면서 상태를 TIME_WAIT으로 처리해 버립니다. 그럼 우리에게 TIME_WAIT을 안 받는 방법은 단 하나뿐입니다. RST시그널을 보내서 무자비하게 끊어버리는 방법입니다.

 

그럼 이렇게 되어야 겠죠

closesocket을 통해 fin을전달 -> closesocket을 하지만 rst를 전달

바로 이기능을 이용하기 위해서 우리는 SO_LINGER옵션을 이용할 것입니다.

링거의 기능에는 대표적으로 3개정도가 있습니다.

1-> 바로 리턴이 되면서 데이터를 다 보내고 간다.

2-> 바로 리턴이 되면서 송신버퍼를 없애고 TCP를 강제종료한다

3-> 데이터를 보낼때까지 리턴을 하지않는다(FIN) + 시간안에 안되면 강제종료한다(RST)

 

우리가 여기서 필요한건 2번기능입니다.

링거에는 2가지 옵션이 있는데요 켤거냐 말거냐에 대한 기능과 시간을 조절하는 기능이있습니다.

끄면 1번입니다.

켜면 2번아니면 3번인데 여기서 타이머를 0으로두면 2번입니다. 타이머에 값을 0보다 큰값으로 두면 3번이 되는거죠

 

저는 이부분에서 궁금한점이 생겼습니다.

애초에 fin을 보내버리면 recv()의 리턴이 0이나오게 됩니다. 우선처리 된다는거죠.

그렇다면 못받는 데이터를 왜 보내는가에 대한 궁금증에 빠지게 되는데... 이부분은 L7에서 구현해서 마지막 데이터를 받고 종료에 대한 기능을 구현할 수 있는 부분이기 때문에 아마 마지막 데이터가 꼭 필요한 경우에 이런식으로라도 써라고 해놓은 것 같습니다.

 

사실 연결종료하는 클라이언트에게 다음 정보를 줘야되는 경우가 꼭있나요 ㅋㅋㅋ...

만약 연결을 종료하는 타이밍과 동시에 맞춰서 아이템을 구매했다거나... 이런경우라면 또 모를까 하지만 이런경우도 다음번 로그인때 확인할 수 있으니... 크게 중요하진 않은 것 같습니다.

뭐... 이정도 하면 충분한 것 같네요...

 

 

사실 링거와 클로즈소켓 관련된 부분은 다음번 글에 올리려고 했었는데( 다음번 글에 나올 shutdown이라는 함수와 매우 연관이 깊기때문에...) 이번에 어쩌다보니 적게되었습니다...

 

음... 오늘은 힘든하루여서 순살이 되어가고있는데요

오늘도 긴글 읽으시느라 고생 많으셨고 저는 다음글에서 또 찾아뵙겠습니다.

그럼 안녕히계세요

 

 

 

320x100