728x90
게임 서버 프로그래밍 교과서
4장. 게임 서버와 클라이언트
4.1 패키지 게임에서 게임서버
- 데디케이티드(decidated server) 렌더링과 사용자 입력 처리를 전혀 받지 않고, 순전히 클라이언트의 연결을 받는 세션을 처리만 하는 프로그램이 따로 들어있는 경우
4.2 서버의 역할
싱글 플레이 게임의 게임루프(game loop)
입력받기 → 게임 로직 처리하기 → 렌더링
4.3 게임 클라이언트와 서버의 상호작용
- 게임 클라이언트가 서버에 데이터, 즉 메시지를 전달하면 서버는 이를 수신하여 메시지 내용에 따라 특정한 행동을 한다. 반대로 서버에서 클라이언트에 메시지를 전달하면 클라이언트는 수신한 메시지 내용에 따라 특정한 행동을 한다. 이렇게 메시지가 오가는 과정을 상호작용(interaction)이라고 한다.
- 게임 클라이언트와 서버의 상호작용은 크게 네 가지로 구별된다.
- 연결
- 요청 - 응답
- 능동적 통보
- 연결 해제
- 연결
- 연결이란 최초로 클라이언트가 서버와 데이터를 주고 받을 준비를 하는 것이다. 클라이언트에서 서버에 연결을 요청하면 서버는 이를 수락하여 클라이언트와 연결을 맺는 것이다.
- 요청 - 응답
- 연결을 마쳤으면 클라이언트는 서버에 메시지를 보내고, 서버는 이를 처리한 후 결과를 응답해준다. 모든 메시징이 요청 - 응답의 형식을 취할 필요는 없다. 클라이언트는 서버에 어떤 상황을 통보하고, 그 통보에 대한 서버 반응을 굳이 받지 않아도 될 때가 있다. 반대로 클라이언트에서 요청을 보낸적도 없는데 서버에서 능동적으로 통보해야 할 때도 있다. 이를 능동적 통보라고 한다.
- 능동적 통보
- 게임 서버는 세션을 하나 이상 가지고 있는 상태 기계(state machine)이다. 그리고 이 세션의 상태는 1인용 게임에서 컴퓨터 안의 세션처럼 시간이 지나면서 변화한다. 이 변화를 클라이언트에 일정시간마다 통보해야 할 때도 있다. 능동적 통보를 쓰는 대표적인 예이다.
4.4 게임 서버가 하는 일
- 여러 사용자와 상호작용
- 클라이언트에서 해킹당하면 안되는 처리
- 플레이어의 상태 보관
- 모든 게임 로직처리를 클라이언트에서 할 수는 없다. 기본적으로 서버에서 모든 게임 플레이 판정을 할 수 있게 만들되, 쾌적한 품질과 타협하기 위해 일부 처리를 게임 클라이언트 쪽에 맡기는 것이 현실적이다.
4.5 게임 서버의 품질
- 게임 서버의 품질을 위해 서버를 개발할 때 목표로 두어야 하는 것
- 안정성(stability)
- 확장성(scalability)
- 성능(performance)
- 관리 편의성
안정성
- 게임 서버가 얼마나 죽지 않는가를 의미한다. 또 게임 서버가 얼마나 오작동을 하지 않는가도 포함된다.
- 게임 안정성을 위해 노력할 것
- 치밀한 개발과 유닛 테스트
- 프로그래밍 결과를 한줄 한줄을 검수하고 코딩 가이드라인을 따르는 것이다. 모든 소스는 알기 쉽게 작성하라는 규정, 모든 함수 호출의 반환값을 반드시 체크하는 루틴이다.
- 그리고 개발 된 프로그램의 각 부분은 반드시 자동화된 자가검증, 즉 유닛 테스트를 만들어야 한다는 의무 규정을 정하고 시행하는 것이다.
- 80 : 20 법칙
- 모든 프로그램의 성능의 80%는 20% 소스 코드에서 나타난다는 파레토 법칙이다. 성능에 지대한 영향을 주는 일부분의 소스코드에서만 프로그램 구조가 복잡해지더라도 성능을 최적화해서 개발하고, 나머지 대부분은 성능보다 유지보수하기 쉬운 단순한 구조로 개발하는 것이다.
- 1인 이상의 코드 리뷰
- 가정하지 말고 검증하라
- 봇 테스트, 더미 클라이언트 테스트
- 서버를 띄우고 대량의 더미 클라이언트를 실행시킨다. 더미 클라이언트는 입력처리와 렌더링 과정이 생략되어 있으며, 미리 프로그래밍된 행동을 반복한다. 이것을 대량으로 서버에 접속 시킨다.
- 치밀한 개발과 유닛 테스트
- 서버가 불안정해지면 다음과 같은 방법으로 극복할 수 있다.
- 서버가 죽더라도 최대한 빨리 다시 살아나게 한다.
- 서버는 죽더라도 최대한 적은 서비스만 죽게한다.
- 서버 오작동에 대해서 기록(로그)을 남길 수 있게 한다.
확장성
- 서버를 얼마나 많이 설치할 수 있느냐, 게임 사용자 측면에서 사용자 수가 늘어나더라도 서비스 품질이 떨어지지 않고 유지되느냐가 곧 확장성을 의미한다.
- 서버 확장성을 올리는 방법은 수직적 확장(scale - up) 과 수평적 확장(scale - out)이 있다.
- 수직적 확장
- 서버 머신의 부품을 업그레이드 혹은 서버 머신안의 CPU, RAM 증설한다.
- 서버 소프트웨어 설계 비용이 낮다.
- 확장 비용이 높다.(기하급수적으로 상승)
- 과부하 지점이 서버 컴퓨터이다.
- 오류 가능성이 낮다.(로컬 프로그래밍 방식으로 작동하므로)
- 로컬 컴퓨터의 CPU와 RAM만 사용하므로 단위 처리 속도가 높다.
- 서버 컴퓨터 한 대의 성능만 사용하므로 처리 가능 총량이 낮다.
- 수평적 확장
- 서버 머신의 개수를 증설한다.
- 서버 소프트웨어 설계 비용이 높다.
- 확장 비용이 낮다(선형적으로 상승)
- 네트워크 장치에서 과부하가 일어난다.
- 여러 머신에 걸쳐 비동기 프로그래밍 방식으로 작동하므로 오류 가능성이 높다.
- 여러 서버 컴퓨터간의 메시징이 오가면서 처리하므로 단위 처리 속도가 낮다.
- 여러 서버 컴퓨터로 부하가 분산되므로 처리 가능 총량이 높다.
성능
- 기본적으로 얼마나 빨리 처리하는지
- 온라인 게임의 처리 성능은 서버뿐만 아니라 네트워크 환경에 따라 결정된다. 서버가 아무리 빠르게 처리하더라도 클라이언트와 서버 사이의 네트워크가 느리면 소용없다. 서버 성능을 높이려면 서버의 단위처리 속도를 높이는 것이 기본이다. 서버는 클라이언트의 요청 메시지를 받으면 최대한 이를 빨리 처리해서 응답 메시지를 전송해 줄 수 있어야 한다.
- 게임 서버의 성능을 높이는 기본적인 원칙은 단위 처리 속도를 높이는 것이다. 서버의 단위 처리 속도를 높이려면, 당연하겠지만 프로그램이 더 빠르게 실행할 수 있게 코드 최적화나 알고리즘 최적화를 하는 것이다.
- 더 빠른 속도로 실행되는 프로그래밍 언어를 쓰는 것도 좋은 방법이다.
- 서버 성능을 높이는 또 다른 방법은 서버의 과부하 영역을 분산하는 것이다. 게임 서버 뿐만 아니라 일반적인 프로그램의 처리 속도를 높이고자 먼저 해보는 방법 중 하나는 코드 프로필링(code profiling)을 이용하는 것이다. 어떤 함수가 처리 시간을 많이 차지하는지 발견한 후 그것에 집중해서 성능을 개선하는 것이다. 함수 A가 처리 시간을 가장 많이 차지하지만 함수 A의 처리 속도를 더 높일 수 있는 방법이 더이상 없고, 그렇다고 함수 A를 실행할 빈도를 낮출 수 있는 방법도 없다면 분산을 할 때이다.
- 플레이어가 느끼는 처리 성능을 높이는 또 다른 방법은 네트워크 프로토콜을 최적화 하는 것이다.
- 네트워크 프로토콜 최적화의 첫 번쨰 방법은 메시지의 양을 줄이는 것이다. 서버는 수신과 송신을 해야하는 메시지 수가 많거나 메시지가 차지하는 총량(바이트 수)이 많으면 처리 부담이 증가한다. 따라서 메시지의 양 자체를 줄여야만 서버 부담이 줄어든다.
- 메시지를 압축하는 방법으로 메시지의 양을 줄일 수도 있다. 일반적으로 메시지를 압축하고 풀기 위해 CPU가 연산하는데 걸리는 시간은 메시지의 양에 비례하므로, 서버가 처리하는데 걸리는 시간은 여타 시간보다 짧다. 큰 데이터에서는 데이터 ZLib를 이용한 압축과 같은 무손실 알고리즘이 잘 동작한다. 작은 데이터 같은 경우 다른 방법의 압축 기법을 써야한다. 그 중 하나가 양자화(Quantization)이다.
- 네트워크 프로토콜 최적화의 또 다른 방법은 메시지 교환 횟수를 줄이는 것이다.
- 서비스 성능을 개선하려면 서버뿐만 아니라 네트워크 전송 시간을 줄이는 것도 매우 효과적이다. 클라이언트와 서버간 데이터를 줄이는 비싸지만 빠른 방법은 고품질 네트워크 회선을 가진 데이터센터에 서버를 설치하는 것이다.
- 서버를 거치지 않고 클라이언트끼리 직접 통신하게 하는 것도 방법 중 하나이다. 클라이언트끼리 직접 메시지를 주고 받는 것을 P2P(Peer - to - Peer) 네트워킹이라고 한다. 클라이언트끼리는 레이턴시가 짧지만, 클라이언트와 서버간 레이턴시가 긴 경우 P2P 네트워킹이 효과적이다.
4.6 플레이어 정보의 저장
- 보통 싱글 플레이 게임의 플레이어 정보는 게임을 구동하는 컴퓨터 자체의 디스크에 저장된다.
- 온라인 게임에서 플레이어 정보는 게임을 구동하는 컴퓨터, 즉 클라이언트에 잘 저장하지 않는다.
- 두 가지의 이유가 있다.
- 해킹에 취약하다. 클라이언트에 저장된 데이터는 사용자가 얼마든지 건드릴 수 있다.
- 같은 사용자가 다른 기기를 사용할 경우 문제가 된다.
- 플레이어 데이터를 디스크 같은 통상적인 파일 시스템에 저장하는 방법도 있지만, 데이터 베이스 소프트웨어를 이용해서 저장할 때가 더 많다. 그 이유는 다음과 같다.
- 데이터 관리와 분석을 빠르게 할 수 있다.
- 강력한 데이터 복원 기능이 있다.
- ‘전부 아니면 전무’로 데이터를 변경할 수 있다. 데이터 베이스는 트랜젝션이라는 기능을 제공하는 데 문제가 생기는 경우 원상복구해서 없던 일로 만들 수 있다.
- 데이터 일관성을 유지시켜준다.
- 데이터 베이스는 처리가 2개 이상 동시에 실행될 때 한 데이터가 동시에 여러 데이터를 액세스 하면서 이상한 결과가 나오는 문제를 막아주는 기능이 있다.
- 장애에 내성이 강하다. 데이터 베이스는 데이터를 기록하기 전에 로그 버퍼라는 별도 파일에 할 일을 미리 기록한 후 이를 실제 데이터 베이스에 적용하면서 로그 버퍼를 지워나간다. 데이터 베이스가 중간에 죽어버린 후 재시작을 하면 로그 버퍼에서 아직 안 한 일을 마저 찾아서 복원한다.
서버 공부
최흥배님의 유튜브를 보면서 정리했다
retval = send(client_sock, buf, data_len, 0);
- 버퍼에 데이터를 send한다.
- send를 할 때 send API 함수를 부르고 보통 binary 데이터로 보낸다. binary data를 어떻게 만드냐 하면 c++에서는 구조체를 보통 사용한다. 구조체에는 멤버가 있고 멤버가 data가 된다. 이 구조체를 Network send를 보낼 때 character 형으로 형 변환해서 보낸다. 그러면 결론적으로 2진 바이너리 형태가 된다. 다른 방법으로는 버퍼를 만들고 memcpy를 통해 버퍼에 데이터를 넣고 보낼 수 있다.
char buf[BUFSIZE];
retval = recv(client_sock, buf, BUFSIZE, 0);
- 상대방이 보낸 데이터를 buf에 받는다.
- 클라이언트와 서버가 통신하려면 프로토콜을 정해야 한다.
struct PktHeader // 헤더
{
short TotalSize; // 패킷의 헤더와 바디의 총 사이즈
short Id; // 패킷 요청이 어떤 것인지 알려주는 역할
unsigned char Reserve;
}
struct PktLobbyEnterRes // body 부분
{
...
member를, 데이터를 바이너리 데이터로 형변환 해서 사용한다.
...
}
- 새로운 요청과 응답은 enum class로 PACKET_ID 정의를 한다.
- 어떤 기능을 만들 때 헤더는 동일하다. body가 있다면 body data를 정의하는 구조체를 정의하고, enum 값에다가 요청과 응답에 맞는 상수를 정의한다.
NetLib에서 중요한 부분
- TCP Network Class
- init 초기화 - 제일 처음 한 번 호출된다.
- run 함수 - run이라는 함수를 통해서 네트워크 이벤트를 처리한다.
- 네트워크 이벤트는 크게 3가지가 있다.
- 새로운 접속이 발생했는가?
- 접속된 클라이언트가 끊어졌는가?
- 접속된 클라이언트에서 패킷을 보냈는가?
- 네트워크 이벤트는 크게 3가지가 있다.
- run에서 select API를 호출하고, select API를 호출하면 알고 싶은 네트워크 이벤트를 알 수 있다.
- select, 어느 소켓의 fd에 read,write,exception이 발생했는지 확인하는 함수이다.
- fd_set을 전달하여 호출하면 변화가 발생한 소켓의 디스크립터만 1로 설정한다.
- fd_set에 대한 주소값을 전달하고 각 액션에 대한 결과를 저장하기 때문에 원본을 복사하여 복사본을 전달해야 한다.
- 알아낸 이벤트를 RecvPacketInfo로 반환해준다.
- 게임을 종료할 때 Release를 실행한다.
- init, run, getPacketInfo, Release는 한 번만 호출하면 되고, SendData는 요청이 올 때마다 응답을 보내야 할 때 호출한다.
SendData(SessionIndex, PacketId, Size, pMsg)
- SessionIndex : 어떤 클라이언트에게 보내야 하는지
- PacketId : 어떤 패킷인지
- Size : body의 크기
- pMsg : bodydata 없으면 NULL
main()
- server 인스턴스를 만들고 init
- 내부적으로 Network의 init을 실행한다.
- server.Run 내부적으로 m_pNetwork→Run() 호출
- select() 호출
- 네트워크 이벤트가 발생하기 전까지 대기가 발생한다.
- server가 멈추게 되는데 멈추는 것을 피하기 위해서 work thread를 만들어서 Run을 호출한다.
- 프로그램이 그냥 종료되는 것을 막기 위해 getchar 함수를 호출한다.
- key를 누르면 서버Stop()을 호출한다.
- runthread를 종료시키고 메인 스레드가 종료된다.
- join() : 이 스레드가 종료될 때 까지 메인스레드, 이 함수를 호출한 스레드를 멈춘다.
- server.Run은 m_isRun이 false가 될 때까지 while문으로 반복된다.
- 반복하면서 계속적으로 Network→Run()을 호출한다.
- GetPacketInfo를 통해 네트워킹 이벤트가 있다면 계속해서 받는다. 이벤트가 발생이 없다면 break로 나와서 다시 Network→Run을 호출시킨다.
- 패킷이 있다면 PacketProcess에서 Process 함수를 호출한다.
Packet Process
- 패킷을 처리하는 곳
- 서버를 만들 때 주 작업이 여기서 이루어진다.
- 새로운 PacketId가 생기면 PacketId를 case문으로 만들어주고 Packet을 처리할 함수를 만들어서 함수를 호출한다.
- 구현하는 기능이 늘어날수록 case문이 길어진다.
- 간단하게 하려면 함수 포인터를 사용하거나 한다.
server.init
- 로그 클래스를 생성하고 로그 설정 정보를 설정한다.
- 포트번호
- 클라이언트를 최대 몇 개까지 받을 건지
- 소켓 버퍼 send, receive 크기 커널쪽, 클라쪽
- Room수
- Room에 들어갈 수 있는 유저 수
- 서버를 제대로 만드려면 서버를 하드코딩 하는 것이 아니라 inet 파일이나 json파일 sn파일로 설정 정보 파일을 만들어서 그 곳에 값을 넣고 load_config()라는 함수가 호출될 때 그 파일을 읽어들여서 그 데이터를 파싱해서 값을 넣어주는 것이 제일 좋다.
user manager
- 유저 관리, user는 로그인 됐을 때 한명이 추가 된다고 생각하면 된다.
- adduser, removeuser가 있다.
- 유저 객체를 새로운 로그인이 발생할 때마다 새로 만들지 않고, APP에서 최대 몇명까지 받을건지 정해놨기 때문에 미리 객체 풀을 만들고 사용하는 것과 사용하지 않는 것이 구별 가능해야 하기 때문에 userpoolindex로 사용한다.
728x90
'Study > TIL(Today I Learned)' 카테고리의 다른 글
24.04.11 서버 프로그래밍 (0) | 2024.04.12 |
---|---|
24.04.10 서버 프로그래밍 (0) | 2024.04.10 |
24.04.08 서버 프로그래밍, 백준 (0) | 2024.04.09 |
24.04.07 서버 최적화 (1) | 2024.04.07 |
24.04.06 서버 프로그래밍, 서버 최적화, 백준 (0) | 2024.04.06 |