그간 Unity, C# 등 클라이언트 기술에 대한 이야기만 했는데, 오늘은 서버에 대한 이야기도 해볼까 합니다.
제가 게임 개발을 맡았을 때 두 팀 중 한 팀은 이미 1년 가까이 프로젝트를 진행 중이었고, 다른 MMORPG에 사용했던 C++로 작성된 게임 서버를 이용해 서버 로직을 작성하고 있었습니다. 당시 서버/서버팀에는 다음과 같은 몇 가지 문제점이 있었습니다.
- 개발자 생산성 저하 (C++로 서버 로직 작성)
- 서버 엔진에 대한 경험 부족
- 서버 자체의 안정성 문제
사실 더 큰 문제는 프로젝트 성격에 맞지 않는 게임 서버 엔진을 사용하고 있다는 점였습니다. MMORPG 게임 서버는 보통 DB 접근의 오버헤드를 줄이기 위해 로직 서버가 게임 진행에 대한 상태 정보를 메모리에 가지고 있는 구조이고, 여러 서버 간의 상태를 동기화를 위한 방법도 제공합니다. 하지만 우리가 제작하는 게임은 네트워크 플레이를 지원하지 않는 모바일 RPG게임이고 서버가 하는 일은 단순히 요청을 처리하고 DB에 기록하고 응답을 주는 일뿐인데, 불필요하게 복잡한 서버 아키텍처를 사용하고 있는 상황이었습니다.
아직 개발 기간도 충분히 남은 상태이고, 앞으로 추가해야 할 서버-클라이언트 간의 프로토콜도 많이 남아있던 상황이라 결국 출시까지 이 문제를 계속 끌고 가는 부담을 지는 대신에 서버를 교체하기로 결정내렸습니다. 개발자의 생산성, 서버의 규모 가변성(scalability) 등 여러 면을 검토한 결과 내린 결론은 DB만 증설하면 게임 로직 서버 자체의 규모 가변성을 쉽게 확보할 수 있는 웹서버를 채택하는 것이었습니다.
웹서버의 장점은 명확합니다. 일단 규모가변성 면에서는 DB 외에는 상태를 저장하지 않기 때문에 로직 서버에 부하가 걸리면 로직 서버만 증설해주면 됩니다. (물론 자주 쓰는 데이터는 Redis나 Memcached로 캐싱하는 옵션이 있습니다.) node.js, Django, Ruby on Rails 등 이미 수많은 웹서버 프레임워크가 존재하기 때문에 이 중 하나를 골라서 쉽게 로직 코드를 작성할 수 있습니다. Unity는 HTTP 요청을 쉽게 하기 위한 WWW 클래스를 제공합니다.
node.js, Django, Ruby on Rails 등은 모두 검증된 웹서버 프레임워크지만 저의 선택은 node.js였습니다. 일단 게임 로직 서버는 REST API 서버와 유사한 구조이고, Django나 Ruby on Rails가 제공하는 템플릿 엔진이나 ORM 등이 크게 필요하지 않았기 때문입니다. node.js에 REST API를 쉽게 작성할 수 있는 모듈인 restify와 MySQL에 접근하기 위한 드라이버인 node-mysql 정도면 충분히 필요한 기능을 다 제공한다고 판단하였습니다.
node.js를 선택한 또 다른 이유는 성능입니다. node.js 엔진인 V8의 성능이 Python이나 Ruby 보다 뛰어나다는 점도 고려 대상이지만, 더 중요한 이유는 node.js가 제공하는 비동기 IO 방식에 있었습니다. C10K 문제로 알려져 있는 서버 성능 이슈의 결론은 요청 하나 하나를 별도의 쓰레드를 만들어서 동기 IO를 하는 방식으로 서버 성능을 확보할 수가 없고, 운영체제가 제공하는 epoll이나 kqueue 같은 시스템을 콜을 사용해서 비동기 IO를 해야한다는 것입니다. node.js는 운영체제에 상관 없이 비동기 IO 메커니즘을 제공하는 libuv라는 라이브러리를 이용해 작성되어 있고, 내부적으로 epoll, kqueue, IOCP 등을 사용하고 있습니다.
물론 세상에 공짜는 없습니다. node.js가 비동기 I/O를 통해 확보한 성능 이점은 불편한 비동기 프로그래밍 모델로 그대로 노출되기 때문입니다. 다음 할 일을 계속 콜백(callback) 함수로 넘기는 스타일로 코딩을 하다 보니 Pyramid of Doom이라고 알려져 있는 다음과 같은 스타일의 코드가 나오게 됩니다. 이 문제를 해결하기 위해 비동기 제어 흐름을 좀 더 쉽게 표현하기 위한 async 모듈, underscore 등도 사용했지만, 이러한 프로그래밍 스타일은 Django나 Ruby on Rails에 비교해서 코드의 가독성을 현저히 떨어뜨리고 있는 것이 사실입니다.
step1(function (value1) { step2(function (value2) { step3(function (value3) { step4(function (value4) { step5(function (value5) { step6(function (value6) { // Do something with value6 }); }); }); }); }); });
다음 할 일을 계속 콜백으로 넘기는 방식은 프로그래밍 언어에서 Continuation Passing Style(CPS)로 알려져 있습니다. CPS는 제너레이터(generator), 코루틴(coroutine), 예외(exception) 등 다양한 프로그래밍의 제어 흐름을 표현할 수 있는 강력한 개념이지만, 사실 컴파일러가 아닌 사람이 사용하기에 편리한 방식이 아닙니다. node.js가 제공하는 프로그래밍 모델은 따지고 보면 비동기 IO를 통해 성능 이점을 제공할 테니, CPS 스타일의 불편함을 감수하라는 얘기가 됩니다.
다행히 이 문제는 여러 차원에서 해결책이 논의 중입니다. 자바스크립트 Promise 라이브러리와 EcmaScript 6의 제너레이터(Generator)의 결합으로 이 문제를 해결하기 위한 방법들이 논의되고 있고, EcmaScript 7은 아에 프로그래밍 언어 자체에 비동기 프로그래밍 지원하기 위한 기능을 추가할 움직임을 보이고 있습니다. (이 부분에 대해서는 다음에 조금 더 자세히 다루겠습니다.)
결론은 모바일 RPG 게임 서버로 node.js를 선택한 것에 대해서는 나름대로 만족하고 있습니다. 다만, 비동기 프로그래밍이 프로그램을 복잡하게 만들고 가독성을 떨어뜨리는 문제에 대해서는 좀 더 고민이 필요하다는 교훈도 얻었습니다. 서버 프로그래밍의 본질은 결국 성능 혹은 규모 가변성이라는 걱정거리(concern)를 어떻게 코드 내에서 분리(seperation)할 수 있는지에 대한 고민인 것 같습니다.
Pingback: 게임 서버: node.js의 장점과 단점 | 서광열의 코딩 스쿨
Pingback: 자바스크립트의 약속(Promise) | Kwang Yul Seo
최근에 찾아본 정보글 중 가장 잘 읽힌 글이었던 것 같습니다.
좋은 정보 감사합니다.