2011년 넥슨에 처음 들어왔을 땐 분산 서버 아키텍처나 클라우드 인프라에 대한 지식이 없었다. 당시 내 프로젝트의 퍼블리셔는 넥슨 아메리카였는데, 종종 고가용성을 달성하기 위한 아키텍처를 요구하곤 했다. 이때 AWS, Membase, Cassandra, MongoDB 등을 배우고 분산 서버 아키텍처가 필요한 이유를 깨우쳤다.
넥슨 아메리카의 김태현 님(지금은 블리자드로 옮김)의 주옥같은 NDC 발표에서도 많은 영감을 얻었다.
SPOF 배제
이전에 몸 담은 프로젝트인 〈카트라이더 대시 & 코인러시〉에서 AWS를 사용하다가 자연재해로 인해 서비스 장애를 겪은 일이 있었다. 2011년 8월에 미국 동부를 강타했던 허리케인 아이린에 의해
us-east-1
EC2 호스트 머신 일부가 고장났고 거기에 있던 EC2 인스턴스의 데이터를 복구할 수 없게 됐다.고장난 EC2 인스턴스에선 DB 노드가 구동되고 있었다. DB 클러스터엔 노드가 2개 운영되고 있었고 서로를 완전히 복제하는 관계였다. 다행히 살아있는 쪽 노드를 뒤져서 모든 데이터를 복원할 순 있었지만 고통스러운 경험이었다. 이 경험이 내게 강박관념을 남겼다. 어떤 인스턴스든 불시에 죽을 수 있고, 이때에도 서비스에 지장이 없으려면 서버 아키텍처에 SPOF를 허용해선 안 된다고 생각하게 됐다.
〈야생의 땅: 듀랑고〉 서비스에서 SPOF를 완전히 배제하는 건 불가능했다. 듀랑고에선 일관성(consistency) 요구사항이 강했는데 고성능까지 함께 추구하는 곳에선 SPOF를 피할 수 없었다. 이땐 완벽한 가용성 대신 몇 분 내에 자동으로 복구할 수 있는 고가용성(HA)을 추구했다(예: Couchbase, Aurora). 일관성이 필요하지만 저성능을 감수할 수 있는 곳엔 Paxos나 RAFT같은 투표 기반 기술로 SPOF를 제거할 수 있었다(예: etcd).
EC2 장애율은 0.5%로 대단히 낮다(99.5% 가용). 하지만 EC2 인스턴스를 아주 많이 쓴다면 그만큼 EC2 장애도 자주 겪게 된다. 〈야생의 땅: 듀랑고〉는 출시 시점에 성능 문제로 인해 이례적으로 많은 EC2 인스턴스를 써야 했는데, 그 때문에 EC2 장애도 일상적으로 겪었다. 만약 게임서버가 SPOF에 취약했다면 매일 겪는 EC2 장애를 견딜 수 없었을 것이다.
SPOF를 배제하려는 의도로 게임서버 아키텍처에서 중앙서버를 허용하지 않았다. 플레이어의 관찰로 실체화된 수많은 개체를 탈중앙화(decentralized)된 노드에서 처리하는 방향으로 발전시켰다. 전반적으로 오케스트레이션보다 코레오그래피를 선호한 아키텍처다. 이점이 게임플레이에서 유연함을 많이 제한했다. 모든 게임플레이 로직을 탈중앙화시키는 건 쉬운 일이 아니었다. 이때 Orleans의 VActor를 떠올렸으면 좋았을 걸 하는 후회가 남는다.
메시지큐
요청을 보내는 쪽(producer)보다 처리하는 쪽(consumer)이 느릴 때 메시지큐는 그 사이에서 완충장치 역할을 한다. 2013년까지만 해도 게임업계에서 메시지큐를 쓰는 사례를 찾기 힘들었는데 요즘엔 비교적 쉽게 찾을 수 있는 것 같다.
난 이전 프로젝트에서 RabbitMQ와 Redis(
LPUSH
, BRPOP
, PUB
, SUB
)를 써봤다. 둘 다 메시지 브로커로서 작동한다. 메시지가 머무를 큐를 관리하는 별도의 서버를 메시지 브로커라고 한다. 브로커가 있으면 네트워크 구조가 단순해진다. 하지만 브로커 자체의 안정성과 과부하, 운영에도 신경 써야 한다는 단점이 있다.그때 매력적으로 보였던 건 ZeroMQ였다. ZeroMQ도 RabbitMQ같은 다른 메시지큐와 거의 동등한 역할을 하지만 브로커를 따로 두진 않는다. 대신 서버끼리 ZeroMQ 소켓으로 직접 연결을 맺는다. ZeroMQ 소켓으로 보낸 메시지는 각 서버에서 돌고있는 ZeroMQ 라이브러리가 관리하는 인메모리 큐에 머무른다. 브로커를 쓰는 메시지큐에 비해 브로커를 쓰지 않는 ZeroMQ는 압도적으로 높은 처리량을 자랑한다. 이 점에서 밀도 높은 노드 간 RPC라는 용도에 어울렸다.
ZeroMQ 라이브러리엔 크고 작은 버그가 있었다. ZeroMQ는 예기치 못한 상황을 안고 가느니 프로세스를 크래시시킨다(assertion 오류). 드물지만 segfault가 발생하는 경우도 없지 않다. 듀랑고 출시 직후에야 해결된 클러스터 공멸현상도 ZeroMQ의 버그에서 기인했다(libzmq#2942).
클러스터
듀랑고 서버의 모든 노드가 PUB/SUB 채널을 기반으로 서로 자유롭게 통신할 수 있길 의도했다. 그러기 위해 모든 노드를 PUB/SUB 패턴 ZeroMQ 소켓으로 서로 연결시켰다. 마지막 듀랑고 베타테스트 때까지만 해도 디스트릭트라는 개념이 없었고, 클러스터는 완전연결(fully-connected) 네트워크를 이뤘다.
그런데 ZeroMQ 연결은 TCP 연결에 비하면 해야 하는 일이 훨씬 많다. 기본적으로 서로 살아있는지 확인하는 하트비트를 유지해야 하고, PUB/SUB 패턴일 경우엔 네트워크 전체의
SUBSCRIBE
, UNSUBSCRIBE
메시지가 PUB 소켓으로 유입되기도 한다. ZeroMQ의 우수한 성능 덕분에 수많은 연결도 감당할 수 있긴 하지만, 이런 네트워크 구조가 확장에 용이한 것(scalable)은 아니었다.정말 큰 네트워크를 구성하려면 여러개의 소규모 네트워크로 나눠야 했다. 인터넷과 LAN, 라우터의 관계를 참고해보자. 인터넷 상의 모든 컴퓨터는 서로 연결돼있지 않다. 라우터가 소규모 네트워크를 책임지고, 그 라우터가 또 다른 소규모 네트워크에 속하게 돼있다.
듀랑고 서버의 노드 수의 단위가 수십 개에서 수백 개, 수천 개로 바뀔 때마다 다양한 허들을 넘었었다. 기본 커널 설정은 이렇게 많은 파일디스크립터와 포트 개수를 허용하지 않기 때문에 별도의 튜닝이 필요했다(
ulimit
, nf_conntrack
등). 해결책은 인터넷에서 처럼 소규모 네트워크로 나누는 것이었다. 그래서 디스트릭트가 도입됐다. 이미 섬 단위 게임플레이를 받아들였기 때문에 가능한 선택이었다. 만약 처음 목표였던 초거대 대륙을 고집했더라면 여기서 쓸만한 수는 특별히 없었을 것이다.클러스터 디스커버리
중앙서버를 피하기 위해, 포트와 주소를 설정파일로 따로 관리하지 않기 위해 클러스터의 각 노드가 자신의 주소를 다른 노드에게 스스로 광고하도록 해야 했다. 이 과정으로 노드끼리 서로 발견하고 자동으로 연결을 맺는 것이 클러스터 디스커버리다.
맨 처음엔 UDP 멀티캐스팅을 쓰려고 했었다. UDP 멀티캐스팅을 이용하면 같은 네트워크에 있는 모든 호스트가 원할 때 받을 수 있는 패킷을 보낼 수 있다. 하지만 EC2에선 쓸 수 없는 사양이었다. 그 대신 도입한 건 ZeroMQ XPUB/XSUB 브로커다. ZeroMQ 자체는 브로커를 제공하지 않지만 ZeroMQ를 써서 직접 브로커를 만드는 건 제법 쉽다. 하지만 이렇게 만든 브로커는 이중화돼있지 않아서 불안했다.
어느날 넥슨토크에서 Apache ZooKeeper 발표를 들었다. 발표자는 아이펀팩토리 대표였다. 이때 컨센서스 코디네이터의 존재를 처음 알았다. ZooKeeper는 Paxos 알고리즘을 기반으로 SPOF 없이 분산환경에서 강한 일관성을 제공한다. Paxos는 설명을 듣고 봐도 이해하기 힘든 프로토콜이었다. 그래서 오늘날엔 훨씬 단순한 RAFT가 각광받는다. 찾아보니 HashiCorp의 Consul이나 CoreOS의 etcd를 ZooKeeper 대신 쓸 수 있었다. 둘 다 RAFT 기반이다.
ZooKeeper, Consul, etcd 중에서 etcd를 선택했다. ZooKeeper에 비해선 TCP 연결을 유지하지 않는 점이 좋았고(etcd는 RESTful API를 제공한다), Consul에 비해선 역할의 경계가 좁고 명확한 점이 좋았다(etcd는 컨센서스 키밸류 스토리지에만 집중한다). etcd를 이용해 클러스터 디스커버리에서 SPOF를 제거할 수 있었다.
클러스터 디스커버리엔 내가 끝내 해결하지 못 한 파편화라는 문제가 남아있다. A 노드는 B 노드의 존재를 감지했지만, B 노드가 A 노드의 존재를 모르는 상태다. RPC는 기본적으로 요청-응답 쌍으로 이뤄져있는데 이 경우엔 파편화가 직접적인 문제를 일으키지 않는다. 하지만
Touch
같이 요청-요청 쌍으로 이뤄져있는 일부 RPC에선 문제가 발생한다. 요청-요청 쌍은 첫 요청의 응답이 직접적인 답변이 아닌, 간접적인 새로운 요청으로 전달되는 패턴을 말한다. A 노드가 B 노드에 요청을 보냈을 때 B 노드가 새로운 요청을 A 노드에 보내야 하는데, B 노드가 A 노드의 존재를 모르는 상태라면 보낼 수 없다. 파편화는 클러스터 디스커버리 로직의 단순한 버그로 예상되지만 아직 미스테리로 남아있다.NoSQL
애플리케이션이 DB를 쓸 때 샤딩을 고려하게 하고 싶지 않았다. 샤드를 설정하고 규모에 따라 데이터를 재분배하는 과정에 애플리케이션이 직접적으로 관여하는 건 피곤한 일이기 때문이다. 그래서 처음부터 RDB를 배제했다. 하지만 넥슨은 다수의 MySQL, MSSQL 전문가를 보유하고 있다. 이미 축적된 운영 노하우를 끌어쓸 수 있다는 점을 간과했던 게 아쉽다.
NoSQL을 사용할 때 "CAP 정리"를 많이 접하게 된다. CAP 정리는 일관성(consistency), 가용성(availability), 분할내성(partition tolerance)을 모두 만족하는 분산 서버 아키텍처가 있을 수 없음을 증명하는 이론이다. 노드 A와 노드 B가 서로의 정보를 모두 복제하는 관계라고 할 때, 방금 노드 A에 저장한 정보를 뒤이어 노드 B에서 찾더라도 같은 정보를 얻을 수 있으면 일관성 있다고 한다. 노드 A에 장애가 생기더라도 언제나 노드 B를 통해 적절한 정보를 얻을 수 있으면 가용성을 제공하는 것이다. CAP 정리를 처음 들었을 땐 마지막 분할내성을 이해하기 어려웠는데, 네트워크 분할(network partition)에 대해 모르기 때문이었다. 노드 A와 노드 B가 모두 살아있는데 서로 통신만 못 하는 상태를 네트워크 분할이라고 한다. 노드 A와 노드 B 사이에 네트워크 분할이 발생한 상황을 상상해보자. 분할돼있는 동안 노드 A에 새로 저장한 정보는 노드 B에 복제될 수 없다. 이때 노드 A가 처리하는 저장 연산이, 분할 문제가 해결되길 기다리면 일관성은 보장되지만 가용성을 잃는다. 정보가 노드 B에 복제되든 말든 저장 연산이 즉시 완료되면 가용성은 보장되지만, 노드 B에서 최신이 아닌 정보를 읽을 수 있어서 일관성이 깨진다. 이렇듯 분할내성을 보장하려면 일관성과 가용성 중 하나는 포기해야 한다.
이전 프로젝트에선 Couchbase의 전신인 Membase를 사용했다. Memcached와 호환되는 영속적 키밸류 저장소인데, CAP 중 CP를 선택한 NoSQL이다. 단순한 저장 모델 덕분에 압도적으로 우수한 성능을 자랑한다. 성능 면에서 Couchbase는 여전히 좋은 선택지로 보인다.
하지만 문서 간 ACID 트랜잭션을 지원하지 않는다는 점에서 여러번 발목을 잡혔다. 부동산이 핵심 게임플레이인 듀랑고에선 다른 게임과는 비교할 수 없을 정도로 문서 간 트랜잭션의 빈도가 높았다. 개발 초기에 이점을 낙관적으로 예측한 점이 후회된다.
(지난 몇 년동안 NoSQL은 문서 간 트랜잭션을 지원하지 못 했다. 하지만 최근에 주목할 만한 제품이 등장했다: Google의 Spanner와 Spanner를 기반으로 만든 CockroachDB)
Cartographer
〈블레이드앤소울〉처럼 뚜렷한 지역 구분(zone)이 있는 경우엔 한 지역을 특정 서버에 종속시킬 수 있다. 반면 심리스 오픈월드에선 그 종속성이 모호하다.
Frontend, Gardener, Zookeeper, Colosseum의 기반인 Cartographer는 심리스 오픈월드 상에서 동시접속자를 부드럽게 분배하기 위해 구상했다. 플레이어에게 심리스한 경험을 주기 위해 지역이 서버에 종속되지 않도록 했다. 플레이어는 접속할 때 서있던 지역을 기준으로 서버를 배정받고, 연결을 끊지 않은 채 오픈월드 어디든 오갈 수 있다. 서버가 처리할 지역을 정해놓고 거기 있는 플레이어만 받는 게 아니라, 반대로 처리할 플레이어를 정해놓고 그 플레이어가 있는 지역을 처리한다. 같은 지역을 여러 서버가 처리할 땐 PUB/SUB으로 서로 동기화한다.
게임 세계는 서버 메모리에 실체하지 않는다. 플레이어가 있는 곳 주변만 부분적으로 실체한다. 이 정보들을 조각조각 모으면 전체가 완성된다(활성된 부분만). 이 점이 지도를 만드는 것처럼 보여서 Cartographer라고 이름 붙였다.