이번 프로젝트에서는 TNet3를 기반으로 실시간 멀티플레이에 사용할 수 있는 룸/채널 서버를 만들었다.
TNet3에서 제공하는 네트워크 처리 구조를 그대로 활용하고, 그 위에 프로젝트에서 필요한 서버 기능을 붙이는 형태로 개발했다.
처음부터 네트워크 엔진을 직접 만드는 것이 목적은 아니었다. 이미 검증된 네트워크 기반을 사용하고 그 위에서 실제 게임 서버에 필요한 상태 관리와 요청 처리를 어떻게 구성할지를 보는 쪽에 가까웠다.
구현한 기능은 크게 다음과 같다.
- 로그인 및 세션 인증
- 사용자 관리
- 룸 생성 / 입장 / 퇴장
- 채널 할당 및 재사용
- 룸 데이터 요청
- Disconnect 처리
- 패킷 암복호화
- Command 기반 패킷 처리
사용한 기술은 C++, TNet3, Crypto++, STL 등이었다.
서버 구조
전체 구조를 처음부터 복잡하게 만들지는 않았다.
TNet3가 연결과 네트워크 통신을 담당하고, 서버에서 실제로 관리해야 하는 상태는 별도의 Manager에서 처리하도록 나눴다.
대략적인 흐름은 다음과 같다.
Client -> TNet3 -> Packet 처리 -> 인증 -> Dispatcher -> Manager -> Response
여기서 내가 주로 구현한 부분은 TNet3에서 전달받은 요청을 해석하고 실제 서버 상태를 변경하는 부분이었다.
예를 들어 클라이언트에서 룸 생성 요청이 들어오면 네트워크 처리 코드에서 바로 룸을 만드는 게 아니라 Dispatcher를 거쳐 RoomManager가 처리하도록 했다.
이렇게 분리해두니 네트워크 코드 안에 룸이나 사용자 관련 로직이 계속 섞이는 문제를 피할 수 있었다.
Command 처리
초기에는 패킷 종류가 많지 않아서 요청에 따라 직접 분기해도 큰 문제가 없었다.
그런데 기능을 추가하다 보니 로그인부터 룸 생성, 룸 입장, 퇴장, 룸 데이터 요청까지 하나씩 늘어났다.
이대로 계속 조건문을 추가하는 것은 나중에 수정하기 불편하겠다는 생각이 들었다.
그래서 Command와 Handler를 연결하는 Dispatcher를 따로 만들었다.
예를 들어 서버에서 다음과 같은 요청을 받는다.
REQ/LOGIN
REQ/ROOM/CREATE
REQ/ROOM/JOIN
REQ/ROOM/EXIT
REQ/ROOM/DATAS
각 Command에 대응하는 Handler를 등록해두고 요청이 들어오면 해당 함수를 호출하는 방식이다.
내부적으로는 std::map<std::string, std::function<...>>를 사용했다.
덕분에 새로운 요청이 추가됐을 때 기존 처리 코드를 크게 건드릴 필요가 없었다.
개인적으로 이 구조는 이번 프로젝트에서 꽤 편하게 사용했다. 서버 기능을 추가할 때마다 기존 패킷 처리 부분을 수정하는 것보다 Handler 하나를 추가하는 편이 훨씬 단순했다.
사용자와 룸 관리
서버에서 실제로 신경을 많이 쓴 부분은 네트워크보다 상태 관리였다.
사용자가 접속하면 단순히 소켓만 가지고 있으면 되는 게 아니다.
현재 로그인했는지, 어느 룸에 들어가 있는지, 룸에서 나갔는지 등을 계속 가지고 있어야 한다.
그래서 UserManager와 RoomManager를 분리했다.
UserManager에서는 세션과 사용자 정보를 연결하고 현재 사용자가 어느 룸에 들어가 있는지를 관리했다.
RoomManager에서는 룸의 생성과 삭제, 입장과 퇴장, 현재 인원 등을 관리했다.
채널 번호는 ChannelManager에서 따로 관리했다.
처음에는 채널 번호를 계속 증가시키는 방식도 생각했지만 룸이 생성되고 삭제되는 구조라면 이미 사용한 번호를 버릴 필요가 없었다.
그래서 삭제된 룸의 채널을 큐에 다시 넣어두고 다음 룸 생성 때 먼저 꺼내서 사용하도록 했다.
Room 생성 -> Channel 할당
Room 삭제 -> Channel 반환
다음 Room 생성 -> 반환된 Channel 재사용
복잡한 기능은 아니지만 실제로 룸을 계속 만들고 삭제하는 상황을 생각하면 이런 식으로 리소스를 돌려 쓰는 게 관리하기 편했다.
Disconnect를 처리하면서 생긴 문제
개발하면서 가장 골치 아팠던 부분은 정상적인 퇴장이 아니었다.
처음에는 룸에서 나갈 때 클라이언트가 REQ/ROOM/EXIT를 보내면 서버에서 처리하면 된다고 생각했다.
그런데 실제로는 클라이언트가 그 패킷을 보내기 전에 종료될 수 있다.
프로그램이 강제 종료될 수도 있고 네트워크가 갑자기 끊길 수도 있다.
이 경우 서버에는 사용자가 아직 룸에 있는 것으로 남는다.
예를 들어 실제로는 아무도 없는 방인데 서버에서는 사용자가 한 명 있는 것으로 기록되어 룸이 계속 살아 있는 상황이 생길 수 있다.
그래서 Disconnect 자체를 룸 퇴장과 비슷한 하나의 상태 변화로 처리했다.
연결이 끊긴 세션을 확인하면 UserManager에서 해당 사용자를 찾고, 룸에 들어가 있다면 해당 룸에서도 제거한다.
그리고 사람이 더 이상 없다면 룸을 삭제하고 사용하던 채널을 다시 ChannelManager에 돌려준다.
Disconnect -> User 확인 -> Room 확인 -> User 제거 -> Room 정리 -> Channel 반환
이 부분을 넣고 나서 서버 내부에 남아 있던 유령 상태를 정리할 수 있었다.
이 작업을 하면서 네트워크 서버에서는 정상적인 요청만 가지고 상태를 관리하면 안 된다는 것을 확실히 알게 됐다.
클라이언트가 아무것도 보내지 않고 사라지는 상황도 서버에서는 처리해야 했다.
암호화와 인증
클라이언트와 서버 사이의 데이터는 Crypto++을 이용해 암호화했다.
AES-CBC를 사용했고 전송 과정에서 Base64 Encoding / Decoding을 적용했다.
서버에서 받은 데이터는 대략 다음 순서로 처리했다.
Receive -> Base64 Decode -> AES Decrypt -> 인증 확인 -> Command 처리
인증에는 라이선스 코드와 세션 토큰을 사용했다.
로그인 과정에서 인증을 진행하고 이후 요청에서는 발급된 세션 토큰을 확인하는 식이다.
여기서 신경 쓴 부분은 잘못된 데이터가 들어왔을 때였다.
암호문이 정상적이지 않으면 Crypto++에서 예외가 발생할 수 있는데, 이걸 그대로 놔두면 패킷 하나 때문에 Worker Thread의 처리 흐름에 문제가 생길 수 있었다.
그래서 복호화 부분에서 예외를 잡고 실패한 패킷은 이후 처리 단계로 넘기지 않았다.
정상적인 패킷만 Dispatcher까지 내려가도록 경계를 만들어뒀다.
TCP 패킷 처리에서 뒤늦게 발견한 부분
프로젝트를 진행하면서 TCP를 사용하는 방식에 대해서도 다시 생각하게 됐다.
처음에는 서버에서 데이터를 받으면 그 안에 하나의 메시지가 들어있다고 생각하고 처리하는 부분이 있었다.
그런데 TCP는 메시지 단위로 데이터를 보내는 게 아니다.
하나의 데이터가 여러 번 나눠서 들어올 수도 있고, 반대로 여러 메시지가 한 번에 들어올 수도 있다.
예를 들어
Packet A -> A1 + A2
처럼 나눠질 수도 있고,
Packet A + Packet B -> 한 번에 수신
되는 것도 가능하다.
현재 구현에서는 이 부분이 완전히 독립된 Packet Framing 계층으로 구성되어 있지는 않았다.
다시 만든다면 패킷 Header에 Payload 크기를 넣고 세션별 수신 버퍼에 데이터를 쌓은 뒤 완성된 패킷만 꺼내서 처리하는 방식으로 만들 것 같다.
예를 들면
Magic + Payload Length + Packet Type + Payload
정도의 구조를 사용할 수 있다.
이건 개발 당시에는 크게 문제되지 않았지만 서버 구조를 다시 살펴보면서 가장 먼저 개선해야 할 부분 중 하나라고 생각하게 됐다.
멀티스레드 환경에서의 상태 관리
TNet3를 기반으로 서버를 구성하면서 여러 요청이 동시에 처리될 수 있다는 점도 계속 신경 써야 했다.
UserManager나 RoomManager에서 사용하는 Container를 여러 스레드가 동시에 접근할 수 있기 때문이다.
예를 들어 한 스레드가 Room을 검색하고 있는데 다른 스레드에서 해당 Room을 삭제하는 상황이 생길 수 있다.
현재 구조에서는 이 부분의 동시성 제어가 충분히 세밀하지 않다.
규모가 더 커진다면 std::shared_mutex 등을 사용해서 읽기와 쓰기를 구분하는 방법을 생각할 수 있다.
룸 단위로 데이터를 나누고 Lock 범위를 줄이는 방법도 있다.
처음부터 이런 구조를 모두 적용하기보다는 실제 서버에서 어느 부분에 경합이 생기는지 확인한 다음 적용하는 편이 맞다고 생각한다.
메모리 할당에 대해서
서버 코드를 작성하다 보니 작은 동적 할당이 생각보다 많이 발생하는 부분도 있었다.
룸을 생성하거나 데이터를 파싱할 때 임시 객체가 만들어지고 문자열도 여러 번 복사된다.
요청이 많지 않을 때는 크게 신경 쓰이지 않지만 서버가 오래 실행되고 처리량이 올라가면 이런 부분도 결국 비용이 된다.
그래서 이후 개선한다면 자주 생성되는 객체는 Pool을 이용해서 재사용하고 패킷 버퍼도 가능하면 재활용할 생각이다.
C++17을 사용하는 경우에는 std::string_view를 이용해서 불필요한 문자열 복사를 줄일 수도 있다.
다만 이런 부분은 무조건 최적화할 문제는 아니라고 봤다.
실제로 병목이 되는지 측정하고 필요한 부분만 손대는 게 맞다.
확장성을 생각하면서
이번 서버는 하나의 프로세스가 사용자와 룸 상태를 가지고 있는 구조다.
처음 만들 때는 이게 가장 단순했다.
문제는 서버를 여러 대로 늘리려고 하면 이야기가 달라진다는 것이다.
A 서버에 연결된 사용자와 B 서버에 연결된 사용자가 같은 룸에 들어가야 한다면 두 서버가 룸 상태를 공유할 방법이 필요하다.
이런 상황까지 고려하면 Redis 같은 외부 저장소에 룸 정보를 분리하거나 서버 간 통신 구조를 추가할 수 있다.
하지만 이번 프로젝트에서 분산 서버까지 구현하지는 않았다.
프로젝트에서 해결하려고 했던 문제는 단일 서버 안에서 사용자와 룸의 상태를 제대로 관리하는 것이었고, 그 범위에서는 현재 구조가 더 단순했다.
마무리
이번 프로젝트에서 TNet3를 사용하면서 네트워크 엔진을 직접 만드는 것과 그 엔진을 이용해서 실제 서버 기능을 만드는 것은 상당히 다른 문제라는 것을 느꼈다.
TNet3가 네트워크 처리를 담당해준다고 해도 서버에서 관리해야 할 상태는 직접 설계해야 한다.
특히 룸 서버에서는 사용자가 언제 들어왔는지보다 언제 나갔는지를 제대로 처리하는 게 더 중요했다.
정상적인 EXIT 요청만 처리하면 끝나는 게 아니라 연결이 끊겼을 때도 사용자와 룸, 채널 상태가 같이 정리되어야 했다.
이번 프로젝트에서는 이 부분을 직접 구현하면서 세션 생명주기를 꽤 많이 고민했다.
또 개발을 끝내고 다시 코드를 보면서 TCP Packet Framing이나 동시성 제어처럼 처음에는 크게 신경 쓰지 않았던 부분도 눈에 들어왔다.
모든 문제를 처음부터 완벽하게 해결한 프로젝트는 아니다.
오히려 직접 구현하고 운영 구조를 생각해보면서 어디까지가 현재 구조에서 충분하고, 어디부터 다시 설계해야 하는지를 구분할 수 있게 된 것이 더 큰 수확이었다.
TNet3라는 네트워크 기반 위에 실제 게임 서버에서 필요한 기능을 붙여보면서 세션 관리, 상태 관리, 리소스 생명주기, 비정상 종료 처리를 직접 경험해본 프로젝트였다.