IOCP로 서버를 개발하면서 겪었던 고충 혹은 개발하고 나서 발견한 개선사항에 대해 정리해보았습니다.
지극히 개인적인 경험에 의한 저의 생각이지만 다른 초보 개발자분들에게도 유용할 것이라 생각합니다.
현재까지는 이러한 문제점들 중 아직 해결하지 못한 것이 있으며, 계속 공부하여 해결할 예정입니다.
서버 확장성
서버 확장성을 고려한 설계는 어찌보면 가장 중요하다고 볼 수 있다. 잘 설계된 서버 프레임워크를 갖춘다면 추후 다른 프로젝트에 쉽게 import 해서 사용할 수 있다. 또 다시 힘들게 서버 설계할 필요가 없는 것이다. IOCP의 동작을 수행하는 모듈을 구축해놓고, 실제 서버에서 사용할 기능들은 이 모듈을 상속해서 추가적으로 구현하면 된다. 로그인 서버, 로비 서버, DB서버, 로직 서버 등등을 분리하여 개발하고 서버 간의 연결 기능, 다른 서버에 클라이언트를 넘겨주는 기능도 생각해봐야 한다. 예를 들어 로그인 서버에서 클라이언트의 접속을 허용했다면 이 클라이언트를 로비 서버나 매칭 서버에 넘겨주어야 하는데 이를 어떻게 하는 지 고려해보는 것만으로 좋은 설계가 나올 수 있다.
UDP
TCP는 구현하기 아주 쉽다. 그냥 Listen 소켓에서 연결을 기다리고, 연결이 되면 WSASend, WSARecv만 하면 된다. 하지만 UDP는 아주 까다롭다. 연결성이 없으니 하나부터 열까지 고려해야 할 사항이 생긴다.
내가 겪었던 고충을 얘기해보자면...
첫번째로, 서버는 몇개의 UDP 소켓을 가져야 할까이다. 결론적으로 서버는 하나만 갖고 있어도 충분하다. 하지만 하나의 소켓만 가지고 있을 경우 SendTo 함수가 여러 쓰레드에서 동시다발적으로 접근하여 위험하지 않을까 우려스럽다. 그래서 이와 관련해 인터넷을 검색하여 찾아보았다.
Linux socket using multiple threads to send - Stack Overflow
"The kernel will synchronize access to underlying file descriptor for you, so you don't need a separate mutex"
위 글에서 보다시피 UDP는 커널 단계에서 동시적 접근을 동기화해준다고 하니 걱정할 필요는 없을 것 같다.
다만 TCP는 이를 보장하지 않는다.
두번째는, NAT에 의해 서버에서 클라이언트로 보내는 패킷이 막힌다는 점이다. TCP는 클라이언트의 요청에 의해 서로 연결된 한 쌍의 소켓을 생성하며, NAT에 의한 차단 현상을 걱정하지 않아도 된다. 반면 UDP는 이렇지 않으니, 서버에서 클라이언트로 보내는 UDP 패킷이 닿지 않는 현상이 발생했다. 이러한 현상은 간단하게 클라이언트에서 서버로 먼저 UDP 패킷을 보내는 것으로 해결할 수 있다. 이 과정에서 NAT table에 클라이언트의 정보가 맵핑되고 이후에 서버에서 UDP 패킷이 온다면 mapping된 정보에 의해 올바르게 클라이언트에게로 전달된다. 이를 UDP hole punching 이라고 한다. 하지만 나는 이 점을 전혀 몰랐고, 서버에서 먼저 클라이언트로 UDP를 보내는 구조로 설계하였다. 일단 NAT에 mapping 정보를 올리는 것부터 생각해야 서버와 클라이언트의 원활한 소통이 가능하다.
세번째, UDP 패킷으로 월드 상태를 업데이트하는 부분이다. 서버는 UDP 패킷을 일정 주기마다 보내며, 클라이언트는 이를 기준으로 게임 상태를 업데이트한다. 다음 패킷을 받을 때까지 보간을 이용하여 부드럽게 연출할 필요가 있다. 그렇지 않으면 엄청 끊겨 보일 것이다. 그런데 이 보간이 쉽지가 않다. UDP 패킷은 드랍되기도 하고, 순서가 보장되지 않기 때문에 부드러운 보간이 나오지 않는다. 드랍율을 줄이기 위해, 또 순서를 보장하기 위해 RUDP를 구현해볼 예정이다. 이와 관련된 공부가 더 필요하다.
TCP Send의 Thread Safety
위의 글처럼, TCP 소켓은 동시성을 보장해주지 않는다. IOCP에서는 Recv 작업을 하나의 쓰레드에서 하고 다시 Recv 작업을 등록하기 때문에 Recv에서는 동시성이 보장된다. 하지만 Send 작업은 여러 스레드에서 호출될 수 있다. 따라서 Thread-safe하게 동작하기 위한 별도의 Send thread 혹은 mutex가 필요해보인다. Send thread의 경우 하나의 thread에서 모든 클라이언트의 send 작업이 쌓일 수 있어 비효율적으로 보인다. 그리고 send 할 데이터가 쌓여있는 버퍼도 thread-safe하게 동작하여야 한다. send를 즉시 하지 않고 미리 데이터를 쌓아두었다가 한번에 send하는 경우도 있으므로, 중간에 다른 스레드에 의해 버퍼가 훼손될 수 있다.
Graceful Shutdown
보통 서버는 콘솔 앱을 띄워서 개발한다. 서버 프로그램을 종료할 때도 콘솔 창을 닫아서 종료한다. 하지만 이러한 방식은 서버의 적재되어 있는 메모리 또는 클라이언트 상태를 온전하게 처리하지 않고 강제종료하는 결과를 낳는다. 종료 과정에서 메모리 누수가 발생했는지도 알 수 없고, 클라이언트의 자원이 DB에 잘 저장되지 않을 수도 있다. 그래서 콘솔 앱을 종료할 때에는 종료 시 발생하는 이벤트를 꼭 설정해주어야 한다. 콘솔 창의 X 버튼 클릭 시 이벤트를 설정하거나(이런 함수가 존재하는지는 모름) Ctrl + C를 눌렀을 때 signal을 설정하는 방법(내가 사용하는 방법) 혹은 기타 여러 방법들을 사용하여 이벤트가 발생했을 시 서버의 자원을 안전하게 해제하고 접속되어 있는 클라이언트의 정보를 DB에 저장하는 프로세스를 구축해야 한다.
기타
- TCP 소켓의 NO_DELAY 옵션을 활성화한다. 즉, Nagle 알고리즘을 해제해야 한다. Nagle 알고리즘을 해제하지 않아 패킷이 바로바로 오지 않는 상황이 있었는데 이를 몰라서 교수님한테 혼난 기억이 있다.
- Remote 테스트를 꼭 해봐야 한다. 혼자 개발해야 하고 컴퓨터도 하나 뿐인 학생들이라면 어려운 것이 현실이지만, 어떻게든 친구나 팀원에게 부탁해서 원격으로 서버를 테스트해봐야 한다. Local host로 아무리 잘 동작해도 그건 네트워크 프로그램이라고 할 수 없다. 앞서 말한 NAT에 의해 발생하는 문제점 또는 지연시간과 관련된 문제를 잘 해결하기 위해서는 꼭 원격으로 테스트를 해보는 걸 강력 추천한다.
- 이외 사항으로는 메모리 풀, 패킷 직렬화 구조 등이 있다.