NoSQL이 유행하기 시작한 지도 벌써 여러 해가 지났고 마비노기 듀얼에서도 Redis를 적극적으로 활용했습니다. 그러나 실버바인 서버 엔진 2는 MariaDB에 데이터를 저장합니다. 그렇게 결정한 주된 이유는 신뢰성입니다. 아래 링크의 내용을 참고하세요.
-
http://www.quora.com/Quora-Infrastructure/Why-does-Quora-use-MySQL-as-the-data-store-instead-of-NoSQLs-such-as-Cassandra-MongoDB-or-CouchDB/answer/Adam-DAngelo
(번역: http://yonght.tumblr.com/post/47028978120/nosql-vs-mysql )
마비노기 듀얼에서는 Redis를 매우 적극적으로 활용했습니다. Redis 안에서 복잡한 루아 스크립트를 실행할 수 있게 되면서 동시성 문제와 DB 접근 지연시간 문제에서 해방되었고, Redis 스크립트를 게임 서버에서 매끈하게 호출할 수 있어서 단기 생산성도 높이는 효과를 누렸습니다.
그러나 트랜잭션을 적용할 수 없다는 점 때문에 라이브 서비스에 활용하기에는 위험합니다. 실제로 마비노기 듀얼에서는 Redis 스크립트 오류가 발생하여 데이터 무결성이 깨지는 사고가 있었고, 고통스러운 데이터 복원 작업을 거쳤습니다.
트랜잭션이라는 개념이 있습니다. 은행 송금 같은 것이 트랜잭션에 속합니다. 마리가 퍼거스에게 돈을 보냈으면, 마리의 계좌에서 돈이 줄어드는 일과 퍼거스의 계좌에서 돈이 늘어나는 일이 함께 발생해야 합니다. 마리의 계좌에서 돈이 줄기만 하고 퍼거스의 계좌에선 변화가 없다거나, 그 반대가 되어서는 안됩니다. SQLServer나 MySQL 등의 RDBMS는 트랜잭션을 지원하기 때문에, 유저의 소중한 재산을 영속적으로 저장하면서 다른 유저와의 거래를 안전하게 구현하기 좋습니다.
DB 장비 한 대가 낼 수 있는 성능에는 한계가 있습니다. 게임이 대박났을 때, DB 장비가 모든 유저의 데이터를 처리할 수 없어서, 게임이 심각하게 느려지게 되고, 심지어는 게임 서버가 이런 상황을 버티지 못하고 죽어버리기도 합니다. 게임이 아닌 일반적인 스타트업에서는 DB 장비 한 대만 사용하도록 서버를 만들고, 유저가 점점 많아짐에 따라 장비 여러 대를 사용할 수 있게 코드를 고치면 되지만, 게임은 출시 직후에 유저가 몰리고, 뒤늦게 대처하기에는 시간이 부족하며, 이 때를 놓치면 상당한 수의 유저(그리고 상당 부분의 매출)를 영영 잃게 됩니다. 그런데, RDBMS에서 트랜잭션은 한 대의 DB 장비에서만 사용 가능합니다. RDBMS의 트랜잭션 기능은 비싼 버전에서만 쓸 수 있거나, 여러 DB 장비에 걸쳐서 사용하지 못하거나, 사용하려고 해도(*1) 성능이 크게 떨어집니다. 예전 온라인 게임을 크게 MMORPG와 캐주얼 게임으로 구분하던 시절에는 이런 문제를 걱정할 필요가 별로 없었습니다. MMORPG들은 게임을 여러 개의 독립적인 논리 서버로 나누고, 각각의 논리 서버에 속하는 데이터는 한 대의 DB 장비에 몰아넣어서 문제를 해결했습니다. 캐주얼 게임은 여러 유저를 대상으로 하는 트랜잭션을 쓸 일이 별로 없어서 문제가 되지 않았습니다.
*1: 분산 트랜잭션, 혹은 XA 트랜잭션이라고 합니다. 저는 이걸 직접 겪어보지는 못했고, 아직도 왜 느릴 수밖에 없는지, 정확히 어떤 경우에 느린지 궁금합니다. 마비노기 듀얼을 만들면서 경험 많은 DBA 세 분께 자문해봤을 때 전부 근처에도 가지 말라고 조언해 주셔서 사용을 포기했습니다.
그러나 요즘은 논리적으로 단일 서버이면서 백엔드 플레이(메타게임)가 복잡한 게임이 늘어났습니다. 모바일 게임이 대체로 그렇습니다. 이런 게임은 전통적인 방식으로 RDBMS를 사용해서 만들기가 쉽지 않습니다. 많은 게임 서버들이 NoSQL을 사용해서 만들어졌습니다. 그러나 실버바인 서버 엔진 2는 MariaDB를 쓰되, 엔진이 샤딩(수평 파티셔닝), 분산 트랜잭션, 도큐먼트 변경 로그를 자동으로 처리해주는 방식을 택했습니다.
데이터 동기화 방식은 크게 낙관적 동기화와 비관적 동기화로 구분할 수 있습니다.
낙관적 동기화는 데이터를 일단 고쳐 보고, 결과를 반영하는 사이에 다른 곳에서 데이터를 고치지 않았으면 그대로 두며, 다른 곳에서 데이터를 고쳤으면 롤백하고 처음부터 다시 시작합니다. 낙관적 동기화 방식은 재시도 확률이 낮을 경우 시스템 전체의 성능이 좋지만, 재시도 과정에 버그가 있을 경우 재현하기 매우 힘들다는 심각한 단점이 있습니다.
비관적 동기화는 데이터를 고치기 전에 미리 락을 잡아서, 다른 곳에서 같은 데이터를 읽고 고치지 못하도록 하는 것입니다. 비관적 동기화에도 문제는 있습니다.
- 락 빠뜨리기: 어떤 데이터를 건드릴 때 어떤 락을 잡아야 할지에 관한 규칙을 지켜야 합니다.
- 데드락: 락을 잡는 순서를 정확히 지켜야 합니다.
- 과도한 락으로 인한 성능 저하: 락 단위를 크게 잡을수록(coarse-grained) 시스템 전체에서 동시에 진행할 수 있는 트랜잭션 수가 감소합니다. 극단적인 예를 들어 보겠습니다. DB 전체를 하나의 락으로 감쌌다고 생각해 봅시다. 이렇게 해도 동작이 잘못된 것은 아닙니다. 그러나 동시에 진행할 수 있는 트랜잭션은 단 하나입니다. 트랜잭션 한 번에 10ms 걸린다고 치면 때 시스템 전체가 초당 100트랜잭션밖에 처리를 못하게 됩니다.
2번과 3번은 서로 긴장 관계에 있습니다. 락 단위를 크게 설정할수록 시스템 전체의 트랜잭션 처리량이 감소하고, 락 단위를 작게 설정할수록 잡아야 되는 락의 종류가 많아져서 순서를 정확히 맞추기가 어려워집니다.
그러나 비관적 동기화 방식에 있는 문제는 엔진 API를 사려깊게 설계해서 막을 수 있습니다.
실버바인 서버 엔진 2의 데이터 동기화 방식을 아래와 같이 요약할 수 있습니다.
- 락 기반 비관적 동기화를 사용합니다. 락을 잡고 푸는 일은 엔진이 자동으로 실행합니다.
- 데드락을 방지하기 위해, 트랜잭션 시작할 때 어떤 종류의 락을 잡을지 미리 결정하고, 중간에 락을 추가할 때는 미리 정의한 순서대로만 허용합니다.
- 락 단위를 너무 세분화하면 2번이 고통스러워지므로, 락 단위가 적당히 성기도록 (coarse-grained) 합니다. 이런 원칙에 따라 유저 한 명의 데이터를 하나의 Document에 담도록 권장하고, Document 단위가 곧 락 단위가 되도록 했고, 글로벌 테이블에서는 개별 행이 락 단위가 되도록 했습니다.
엔진이 제공하는 API만 가지고 프로그래밍하면 데이터 정합성이 깨지는 경우는 발생하지 않습니다. 다만, 잘못 사용했을 경우 성능이 떨어질 수는 있습니다. 이를테면, 모든 유저가 접근하는 데이터를 하나의 도큐먼트에 몰아넣으면 매우 느려집니다. 이런 점을 이해하고 데이터 구조를 잡아야 합니다.