Chatting Server

2021. 12. 30. 21:50게임서버/namespace univ_dev

320x100

 

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

 

요새 날씨가 많이 춥습니다... 어디 나갈때마다 손발이 얼어붙을거같은데요

역시 이런날에는 집에 짱박혀서 코딩이나 하면서 블로그 쓰는게 제일 좋은거 같아요

 

그럼 시작하도록 하겠습니다.

 

이번 글에서 제가 다뤄볼 내용은 채팅서버입니다.

 

채팅서버는 서버 중에서 가장 난이도가 쉬운 서버중 하나인데요...

이유가 채팅의 경우에는 온 데이터를 따로 가공하지 않고 그대로 돌려보내는 에코서버와 비슷한 방향으로 구현이 되기 때문입니다.

물론 채팅도 중간에 내용을 변경해야되는 경우가 있다면 변경해야되겠지만(욕설 필터링 과같은) 이번 서버에서는 그런게 없기 때문에 그냥 온 패킷 그대로 돌려주는 경우가 많았습니다.

그리고 가장 중요한것은 채팅서버는 온 경우 바로 그자리에서 반응을 취하는 구조이기때문에 따로 업데이트 로직이 존재하지 않습니다...

업데이트 로직이 빠진다는건 큰차이가 있죠...

게임서버의 경우 데이터가 왔을 때 처리할 수 있는 로직도 존재하지만 이번 프레임에 처리할 수없는 로직은 업데이트로 넘겨야 하기애문이죠... 그런부분에서 본다면 채팅서버는 가장 난이도가 낮은 서버중 하나로 보셔도 될 것 같습니다.

 

 

물론 채팅서버이긴 하지만 얘도 서버이기 때문에 메시지가 정상적인 메시지인지 안정성검사와 올바른 메시지인지에 대한 처리 과정이 들어가있습니다. 채팅 서버든 아니든 그런 과정은 필수일 것입니다...

 

자 그럼 먼저 프로토콜에 대한 내용을 정리하고 넘어가겠습니다.

우선 저는 이전에 직렬화 버퍼를 구현해 뒀었기 때문에 모든 패킷은 직렬화 버퍼로 대체합니다.

그러므로 실질적인 패킷에 대한 구조체 정의가 단 하나도 없습니다.

 

그러므로 패킷 해더만 실제로 구조체로 존재하고 나머지는 그냥 직렬화 버퍼를 이용해서 보내게 될 겁니다.

struct st_PACKET_HEADER
{
	BYTE	byCode;
	BYTE	byCheckSum;

	WORD	wMsgType;
	WORD	wPayloadSize;
};

패킷 헤더는 다음과 같습니다.

우선 네트워크 선에서 문제가 있어서 변형된 데이터는 L2~L4까지 거치는동안 체크섬에 의해서 다 걸러질겁니다.

그리고 모든 메시지의 첫 바이트는 특정 코드가 들어가게됩니다. 특정코드를 정해두고 그 코드가 맞지 않다면 그 클라이언트는 깔끔하게 걸러버리는겁니다.

 

그리고 두번째로 체크섬이 존재합니다.

이 체크섬은 아까 말했듯 네트워크 단에서의 패킷 변형(유실)을 감지하기 위함이 아니라 유저가 의도적인 조작을 했을 가능성을 찾기 위함입니다. (패킷이 만들어진 이후에 유저의 조작으로 인해서 패킷이 틀어진다면 체크섬에서 걸러질것입니다.)

 

체크섬은 메시지 타입 + 페이로드의 각 바이트 모두의 합을 256으로 나눈 나머지값이 됩니다.

일반적인 체크섬을 쓰지않은 이유는 뭐... 제맘입니다...

 

그리고 세번째 부터는 서버 컨텐츠에서 사용될 메시지 타입과 메시지 길이에 대한 바이트수가 담겨있습니다.

 

그 외에 패킷에 대한 구조체는 존재하지 않습니다.

하지만 패킷에 들어가는 데이터에 대한 정의는 필수적이기 때문에 그점에 대해서 간단히 짚고 넘어가도록 하겠습니다.

우선 패킷은

서버 -> 클라이언트

클라이언트 -> 서버

방향성을 띈 패킷으로 구분을 했습니다.

뭐 당연한거겠죠 클라이언트가 서버에 줘야할 데이터와 서버에서 클라이언트에 줘야할 데이터가 다른 경우가 많으니까요...

 

 

먼저 로그인 요청에 대한 패킷을 정의했습니다.

로그인에 대한 패킷 요청은 유저의 희망 아이디인 WCHAR[15] 배열하나를 던져 주는 걸로 했습니다.

 

그럼 서버에서는 닉네임 중복, 사용자 초과, 기타 오류등을 검사해서 문제가 되지 않았다면 로비 채널에 접속시키고 사용자 고유 ID(정수값) 을 전달해줄겁니다. 만약 첫 바이트가 성공에 대한 값이 이아닌 다른 값이라면 클라이언트에서는 접속을 종료합니다.

 

 

그리고 다음 패킷으로 대화방 리스트 요청입니다.

대화방 리스트를 달라고 하는 패킷에는 따로 보내야할 데이터가 없기때문에 payloadSize는 0일것이고 헤더만 덩그러니 날라올겁니다.

TCP로 따진다면 syn, rst와같은 녀석들을 보낼경우를 생각해보면 될겁니다... 헤더자체가 목적성을 띄고있는 경우겠죠

 

서버에서는 먼저 그 유저가 로그인이 되었는지 확인을 할겁니다. 로그인이 되어있지 않은 유저라면 그 프레임에 바로 연결을 끊어버리겠죠... 로그인이 안되어있는데 방 리스트를 달라고 하는것은 로직상 말이 안되니까요...

이런 과정을 거치고 난뒤 방이 담겨있는 컨테이너를 순회하면서 방번호 방이름 참여인원등을 직렬화 패킷에 담은다음 보내면 됩니다.

 

 

다음 패킷으로는 방생성입니다.

클라이언트는 방 제목에 대한 유니코드 문자 바이트 길이와 실제 방제목을 보내면됩니다.

그럼 이것도 마찬가지고 서버에서는 로그인이 되어있는지 확인후, 그유저가 이미 방에 들어가있는지 여부를 판단할 겁니다. 만약 로그인이 되어있지 않다면 그 프레임에 클라이언트를 끊어버릴겁니다.

만약 방에 들어있는데 그런 패킷이왔다면..? 뭐 실수로 누른거겠죠??(실수로 누를수있게 클라이언트가 그렇게 생겼습니다.)

뭐... 이것도 돌려주는 값은 마찬가지입니다.

성공에 대한값 + 방이름 중복 혹은 방 최대갯수 초과, 기타오류등을 체크해서 성공한다면 방번호와 방제목 바이트수 그리고 방제목을 보내주게 될겁니다.

 

 

다음 패킷으로는 대화방 입장에 대한 패킷입니다.

대화방 입장의 경우에는 패킷이 두개로 나눠지게 될겁니다.

1. 이방에 이미 있던사람들.

2. 방에 들어가려는 사람

우선 공통적으로 검사하는 부분이있습니다.

서버에서는 로그인이 되어있는지 확인하고 그다음으로 로비에 있던 사람이 맞는지를 확인할 겁니다.

로그인이 안되어있다면 걸러버릴거고 로비에 있는 사람이 아니라면 뭐... 실수로 고양이가 누른거겠죠 그건 봐줘야합니다.

자 그럼 이런 과정을 거쳐서 걸러내야할 것을 걸러냈다면. 패킷을 돌려줘야할 때입니다.

 

1번의 경우에는 그냥 새로운 사람에대한 고유 ID, 닉네임을 담은 패킷을 보내면 됩니다.

2번의 경우에는 조금 거쳐야할 작업이 있습니다.

방 번호와, 방제목에 대한 바이트, 방제목과 방인원수와 그 인원에 맞는 닉네임WCHAR[15] + 사용자 고유ID를 모두 담아서 줘야합니다.

 

 

다음 패킷으로는 채팅 송신입니다.

클라이언트는 채팅의 문자열 바이트수와 뒤이어 내용을 쭉 보낼겁니다.

서버에서는 똑같은 체크를 합니다.

로그인되어있는지, 로비에 있는데 보낸거아닌지... 등등

두경우 모두 문제가 되는 케이스이기 때문에 그 프레임에 연결을 끊어버립니다.

 

 

다음 패킷은 방 퇴장 패킷입니다.

클라이언트는 방퇴장의 경우 따로 뭔가를 보내지 않습니다.

방리스트 요구건과 동일합니다. 헤더 자체가 데이터를 포함하는 경우입니다.

따로 보낼건 없겠죠... 어차피 로비로 보내달라는거니까요.

그래서 헤더만 덩그러니 도착합니다.

이 경우에도 서버는 동일합니다... 먼저 로그인 되어있는지 확인하고, 로비가 아닌 다른방에 있는게 맞는지 확인하고, 그리고 나서 그 방사람 모두에게 방에서 퇴장되었다는 메시지를 브로드캐스팅 하고 플레이어를 방에서 꺼내버립니다.

물론 들어가는 데이터는 사용자의 고유 ID(정수)값 밖에없습니다.

 

이경우에는 방금 방을 나간 사람도 기존에 방에 있던 사람에 포함되기 때문에 모두 똑같은 패킷을 받게될겁니다.

 

그리고 다음으로 방삭제입니다.

지금의 경우에는 방을 삭제하는 기능이 따로 없습니다. 그냥 방에 들어갔다가 나왔을때 그 방의 인원이 0명이된다면 삭제하는 방식으로 되어있는데요... 서버가 주도적으로 뭔가를 보내는 케이스입니다.

그냥 모든 유저(접속자)에게 그 방번호를 브로드캐스팅 하게되면 그 방은 삭제되는겁니다.

 

 

자... 여기까지하면 패킷에 대한 정리가 끝났습니다.

이것만 해도 벌써부터 글이 많이 길어진 것 같은데요...

 

 

 

소켓 모델은 select 모델입니다... 물론 select가 느리지 않냐고 하시는 분도 계시겠지만 select모델은 아직 현역모델입니다. 물론 느리긴합니다. 하지만 MO류 게임이나 채팅서버에서는 충분히 쓸만큼 적당한 속도를 가지고있습니다.

 

당연한거겠지만 MMO류에서는 적합하지 않습니다. 왜냐면 ... 써보시면 알겁니다 기본적으로 반복을 너무 많이하기 때문입니다... 뭐 그렇다고해서 IOCP와 차이가 어마어마하게 많이 나는것은 또 아닙니다. 약간의 차이가 있을것이지만... 아마 MO나 채팅과 같은 서버라면 문제 큰 차이 없다고 생각합니다.

 

자 그럼 시작하도록 하겠습니다.

 

먼저 서버의 경우에 큰 루프는 이렇게 될겁니다.

while(true)
{
	Networking();
    
    Update();
    //이걸 무한반복
}

채팅서버의 경우에는 왔을때 답장하는 방식입니다.

게임서버는 자체적으로 월드가 계속 움직이고 돌아가야되기 때문에 Update가 필수적이지만...

채팅서버의 경우에는 왔던 메시지의 처리가 그자리에서 처리할 수 있는 데이터이기 때문에 그냥 처리해버리면 끝나게 됩니다. Update로 넘겨서 처리해야될 로직이 없다는 의미입니다.

그러므로 Update가 없어집니다.

while(true)
{
	Networking();
    //이걸 무한반복
}


Networking()
{
	fd_set wSet,rSet;
	FD_ZERO(rset도 하고wset도 하고);
	FD_SET(listenSocket);
    SOCKET sockets[64]
    sockets[0] = listenSocket;
    for(63번)
    {
    	FD_SET(iter->sock);
        socket[i+1] = iter->sock;
    }
    select(rset,wset); //wset rset전부다 select돌림
    
    if(FD_ISSET(listenSocket))
    	AcceptProc();
    for(63)
    {
    	if(FD_ISSET(socket[i+1],&rSet))
        	ReadProc(currentSession);
    }
    //send와 disconnect는 한번에 모아서...
    SendProc();
    Disconnect(); 
}

여기 코드블럭에는 인텔리센스가 없다보니까 코드에 문제가 많습니다...

의사코드 정도로 봐주시면 될 것 같습니다.

그리고 대충 이런식으로 돌아갑니다.

 

벌써부터 쉬운것 같지 않나요?

하지만 우선 select 모델의 최대 단점을 생각해봐야 할것 같습니다.

 

제가 생각하는 단점은 반복하면서 도는것과 하나의 셋에 64개밖에 못넣는다는 겁니다.

유저가 만약 1000명이라면요? 하나의 셋으로는 턱없이 부족할거고

만약 0~1000번 반복하면서 확인을 하는 부분이 있다면 (1000/64) * 1000번 해야될겁니다...

일단 반복해서 도는건... select의 특성이기 때문에 제가 어쩔수 없습니다.

그러니까 우리가 노력해서 줄여야 하는건 모든 사람의 숫자만큼 확인을 하지 않게 해야하는 겁니다.

 

그래서 저는 한번의  select를 위해서 Session컨테이너를 돌면서 64개의 소켓배열을 따로 뽑아냅니다.

그리고 Session컨테이너를 순회하는게 아니라 64개의 소켓 배열을 순회하면서 그 소켓이 FD_ISSET이 되었는지 확인하는 과정을 거칩니다. 뭐 대부분이 이렇게 생각하셨을겁니다... 그래도 뭐 일단 나름 처음 고민해보는 입장에서는 획기적인 아이디어라고 생각했습니다...(멋슥)

 

그리고 64개의 배열중 0번째에는 무조건 리슨소켓이 들어가게됩니다. 리슨소켓을 넣는이유는... 뭐 accept 하는 속도를 늘려주려고 그런거니까요 뭐... 이건 제맘인겁니다.

 

그리고 나머지 63개의 소켓에 대해서 FD_ISSET을 돌리고 true가 튀어나온다면 그걸로 ReadProc함수를 호출할겁니다.

 

ReadProc에서는 Session.RQ의 DirectEnqueueSize만큼 데이터를 받기를 희망할 겁니다. 물론 그보다 적으면 그전에 리턴이 될겁니다.

간단한 recvRet값을 이용한 에러체크를 끝내고 나서 링버퍼에서 꺼낼 수 없을때까지 계속 데이터를 뽑아내면서 헤더의 msgType을 구해 PacketProc작업을 할겁니다.

 

PacketProc에서는 메시지 타입에 따른 분기를 타고있구요... 뭐 나중에 분기가 너무 많아진다 싶으면 테이블이나 해쉬등을 이용할겁니다. 그리고 패킷에 대한 프로시져 함수포인터를 이용할 것이기 때문에 함수원형이 모두 동일하게 나오도록 설계했습니다.

 

받은 패킷에 따라서 행동을 할겁니다. 아까 패킷 설명할때 적혀있던 그행동들 하는겁니다.

추가로 운이 좋아서 패킷 코드도 맞았고... 체크섬도 맞았다면 네트워크 단에서는 더이상 해줄 안정성 검사가 없습니다.

그럼 이제부터는 컨텐츠쪽에서 방어를 해야되는데요... 그건 일단은 패킷 타입을 통해서 하고 default 케이스로 떨어진다면 당연히... 존재해선 안되는 케이스이니까... 그 프레임에 그 클라이언트는 연결이 종료될겁니다.

채팅이기 때문에 값에대한 체크를 할 수는 없을것이구요... 게임이라면 비정상적인 수치를 걸러낼 수 있겠지만... 채팅은 비정상적인 수치 검사가 힘들기 때문입니다... 그러니까 타입에 대한 체크면 충분한 방어를 했다고 보입니다...

 

음... 생각보다 로직은 단순합니다만...

패킷을 만드는 함수, 패킷을 처리하는 프로시저를 만드는데 꽤나 시간이 걸렸던 개발이었던 것 같습니다.

 

끝으로 실행영상을 올리고 마무리하려고합니다.

 

첫번째 영상은 그냥 몇명씩 들어갔다 나왔다 하면서 채팅 치는 모습을 할거구요...

두번째는 스트레스 테스트입니다.

3000명이 들어와서 초당 5만건 가까운 데이터를 뿌려댈 때 서버가 잘 버티는지 검증차 하는겁니다.

스트레스 프로그램은 제가 만든건 아니고... 이미 검증되어있는 프로그램입니다.

 

Chat Server - Chat Client로 테스트

Chat Server + Chat Client Test

클라이언트는 제공받은 겁니다. 제꺼 아닙니다.

 

Chat Server - Stress Program 으로 테스트

Chat Server - Stress Program Test

 

음... 스트레스 프로그램을 보면서 느낀점이 하나 있습니다...

지금 구현상 브로드캐스팅을 할때 한큐(한프레임)에 모든 사람에게 다 보내게되는데요... 그렇게 된다면 서버의 프레임이 순식간에 팡하고 떨어져 버리는게 문제라고 생각합니다... 그래서 이경우에는... 여러 프레임에 걸쳐서 모든 유저들에게 줘야할 것 같습니다... 이렇게 하지 않으면 프레임드랍이 너무 크게 일어나기 때문에... 이 부분을 빠른 시일내에 처리해야 될 것 같습니다...

 

당장 떠오르는 방법은... 한프레임당 정해진 인원까지만 처리하는 방식으로 하는 방법밖에 떠오르지가 않네요... 더 좋은 아이디어가 있으신 분이 있다면 댓글로 남겨주시면 감사하겠습니다..!

 

 

그럼 오늘도 긴 글 읽어주셔서 감사합니다. 다음에 더 좋은 글로 돌아오도록 하겠습니다.

그럼 안녕히계세요.

 

 

 

 

 

 

 

 

 

320x100

'게임서버 > namespace univ_dev' 카테고리의 다른 글

Network Library - LanServer,LanClient  (0) 2022.03.03
MMO_TCPFighter  (1) 2022.01.17
Red-Black tree delete  (2) 2021.12.23
Red-Black Tree (basic + insert)  (0) 2021.12.22
Jump Point Search PathFinding Algorithm  (0) 2021.12.21