워크래프트 럼블

워크래프트 럼블 심층 탐구: 미니 경험치 계산

Blizzard Entertainment

반갑습니다, 럼블 플레이어 여러분!

저는 워크래프트 럼블 서버를 담당하는 수석 엔지니어 앤디 림입니다. 서버팀은 네트워크, 클라우드 컴퓨팅, 스토리지와 같은 일반적인 서버 관련 업무에 더해, 캠페인 진행과 퀘스트 등의 게임 기능 또한 구축합니다. 여기서는 저희가 경험치를 저장한 후 각 미니의 레벨을 계산할 때, 내부적으로 사용하는 방식을 살펴보겠습니다.


Cassandra 안내

경고! 기술적인 설명이 진행될 예정입니다. 이야기가 다소 무거워질 수 있으니 잘 따라와 주세요.

먼저, 수많은 동시 사용자의 플레이어 데이터가 빠르게 변화하는 수치를 추적해야 하는데요. 여기서 저희가 이용하는 스토리지 솔루션 'Cassandra'에 대해 잠시 살펴보죠. 오픈 소스인 Cassandra는 폭넓게 사용되고 확장성이 뛰어난 분산형 데이터베이스로, 데이터의 일관성과 가용성 사이에 적절한 균형을 제공합니다. 데이터 자체에 대해 하드 스키마 방식을 강요하지 않으면서 방대한 데이터 세트를 처리할 때도 Cassandra가 유용합니다. 저희는 각 기능에 필요한 데이터베이스 테이블과 테이블 스키마를 엔지니어가 정의할 수 있도록 일련의 툴을 구축했습니다. 덕분에 의도했던 대로 데이터베이스의 구조와 조직을 유연하게 다룰 수 있게 됐죠. 스키마와 쿼리를 손쉽게 작성하고 검증할 수 있게 된 겁니다.

스키마란? 데이터베이스 스키마는 테이블에서 발견하게 되는 것처럼 관계형 데이터베이스 내의 데이터 구성 방식을 정의합니다.


장부 개념의 경험치 저장

Cassandra는 데이터를 굉장히 빠른 속도로 작성할 수 있지만 읽는 속도는 느린 편입니다. 데이터를 수정하려면 먼저 데이터를 읽은 후 작성하는 작업이 진행되므로 전체 속도가 읽는 속도에 맞춰 자연스럽게 느려집니다. 이를 극복하기 위해, 저희는 플레이어 데이터가 장부 형식으로 보관되도록 설계했습니다. 장부 내에서 변경 사항을 한 줄씩 일일이 작성하고, 읽을 때는 항목 전체를 읽어서 필요한 내용을 계산하는 방식입니다.

신용 카드가 장부를 설명하는 좋은 예시가 되겠군요. 거래할 때마다 각 액수가 양수 또는 음수로 하나씩 작성됩니다. 카드를 사용할 때마다 음수 거래가 발생하고, 지불할 때마다 양수 거래가 발생하는 식으로요. 남은 금액이 얼마인지는 어떻게 알 수 있을까요? 장부 전체를 살펴보면서 각 값을 더하고 빼는 식으로 계산하면 됩니다.

인플레이스 데이터의 경우에는 저장할 수 있는 단일 항목이 있다고 생각하세요. 해당 수치를 변경하려면 일단 데이터를 읽고 나서 변경한 후 새로운 값을 작성하는 식인데요. 축구의 최종 득점으로 비유해 보죠. 어느 순간 팀이 득점하면 득점 이전의 점수 총합을 읽어서 확인한 뒤에 새로운 골을 추가하고, 그걸 최종 득점 수치에 작성하는 겁니다.

이걸 아크라이트에 대입하면 각 미니의 경험치에 대한 장부가 있는 셈이죠. 임무가 끝날 때마다 하나의 미니에 대한 장부에 행 하나가 추가됩니다. 그 미니가 획득한 경험치를 알려주는 내용으로요. 다섯 개의 임무를 완료한 뒤에는 다음과 같은 항목을 보유하게 될 수 있겠군요.

테이블 1

플레이어

미니

수치

시간

앤디

놀 투사

3

월요일 오후 2:00

앤디

그리핀 기수

3

월요일 오후 2:05

앤디

놀 투사

3

월요일 오후 2:10

앤디

안심지대 조종사

3

월요일 오후 2:15

앤디

놀 투사

3

월요일 오후 2:20

마지막으로 총 미니 경험치를 산출하려면 모든 장부 항목을 검색하고 미니별로 묶은 후 경험치 수치를 추가하기만 하면 됩니다. 그 결과는 다음과 같이 나타날 수 있습니다.

테이블 2

플레이어

미니

총합

앤디

놀 투사

9

앤디

그리핀 기수

3

앤디

안심지대 조종사

3


롤업 과정 시행

경험치를 저장하는 방법을 정했으니, 이번에는 각 미니에 대해 필요한 계산을 정리하는 방법을 살펴보겠습니다. 각 미니의 경험치에 대한 개별 행을 저장하면 다시 읽을 때 많은 시간이 소요될 것입니다. 행이 굉장히 많이 존재하며, 그 숫자가 끝없이 늘어지기 때문입니다. 럼블을 일반적으로 즐기는 경우를 가정해 보죠. 하루에 15회의 퀘스트나 PVP, 한 주에 20회의 쇄도 (골드 획득), 그리고 매주 던전을 진행해서 군대를 업그레이드할 겁니다. 한 주에 생성되는 항목이 100개가량 되는군요. 이렇게 3개월이 지나면 1,260개가량의 장부 항목이 생성됩니다. 자, 이제 총합을 계산해 볼까요. 값을 검색해야 할 텐데 안타깝게도 우리의 Cassandra 시스템은 읽는 속도가 느린 편이죠. 야단났군요.

이런 사태를 방지하고자 롤업이라는 솔루션을 도입했습니다. 롤업이란 특정 시점까지의 값을 계산하는 행위인데요. 이 경우에는 모든 값을 더해서 하나의 행으로 나타냅니다. 그리고 그걸 다른 테이블에 따로 저장하죠. 여러분은 이제 타임스탬프가 얼마나 유용한지 확인하시게 될 겁니다. 앞선 표로 미루어 보아, 이번에도 핵심은 플레이어와 미니, 그리고 계산은 합산이 될 것입니다. 화요일 자정까지 모든 장부 항목에 대해 롤업을 수행하고, 그 결과를 두 번째 Cassandra 테이블에 저장하겠습니다. 결과는 다음과 같습니다.

테이블 3

플레이어

미니

총합

종료일

앤디

놀 투사

9

화요일 오전 12:00

앤디

그리핀 기수

3

화요일 오전 12:00

앤디

안심지대 조종사

3

화요일 오전 12:00

수요일에 게임을 더 많이 해서 경험치 항목을 더 생성합니다. 이때 기존 경험치 테이블은 아래처럼 늘어났을 것입니다.

테이블 4

플레이어

미니

수치

시간

앤디

놀 투사

3

월요일 오후 2:00

앤디

그리핀 기수

3

월요일 오후 2:05

앤디

놀 투사

3

월요일 오후 2:10

앤디

안심지대 조종사

3

월요일 오후 2:15

앤디

놀 투사

3

월요일 오후 2:20

앤디

안심지대 조종사

3

수요일 오후 12:00

앤디

연쇄 번개

3

수요일 오후 12:05

앤디

그리핀 기수

3

수요일 오후 12:10

이제 이 게임을 플레이한 후 전체 수치를 조회해서 미니의 실제 레벨을 계산하려 합니다. 단일 항목에 대한 롤업 테이블과 특정 시점 이후의 항목들에 관한 장부가 존재합니다. 이 2개의 테이블을 쿼리로 처리해 수치를 합산하여 최종 경험치 합산 테이블을 생성합니다. 다시 말해, 테이블 3을 읽은 후 테이블 3의 각 행 이후에 존재하는 데이터만 테이블 4에서 쿼리로 처리합니다. 작업을 수행하는 단계는 다음과 같습니다.

  1. 테이블 3의 행 하나를 읽습니다.
     

그리핀 기수

3

화요일 오전 12:00

  1. 화요일 자정 이후 그리핀 기수에 대한 테이블 3의 행들을 읽습니다.
     

그리핀 기수

3

수요일 오후 12:10

  1. 이제 두 데이터를 더해서 합산 데이터를 생성합니다.

그리핀 기수

6

이렇게 접근하면 테이블 4에서만 계산할 때에 비해 판독 과정을 상당 부분 단축할 수 있음을 알 수 있습니다.

새로운 장부 항목을 저장한 후 새롭게 계산한 데이터만 보관하면 된다고 생각할 수도 있습니다. 이는 언뜻 보기에는 좋을지 몰라도, 데이터 불일치 및 성능 저하가 발생할 가능성이 있는 방식입니다. 성능 저하의 사례를 들자면, 데이터를 읽은 후 작성해야 하는 경우가 많아서 데이터 재계산 및 저장 과정이 시스템의 많은 부분을 차지할 수 있습니다. 모든 플레이어가 적합한 성능으로 게임을 즐길 수 있어야 하므로 계산 과정의 균형을 맞출 필요가 있습니다.

데이터 불일치의 사례를 들자면, 게임 종료 후 계산에서 문제가 발생할 수 있습니다. 저희의 서버 인프라와 플랫폼은 메시지 지연 도착 방식을 지원합니다. 예를 들어, A와 B 두 개의 데이터 센터에서 일시적인 문제가 발생한다고 가정합시다. 플레이어에게 미니 경험치를 제공하는 메시지가 A에서 전송되었지만 아직 B에는 도착하지 못했습니다. 시나리오는 이런 식이 되겠죠. 여러분은 자정 직전에 게임을 플레이하고 있습니다. 경기에서 이긴 시간이 수요일 11시 59분이고요. 롤업 프로세서는 당일의 모든 데이터에 대해 매일 시행되도록 설정되어 있습니다. 따라서 수요일 자정부터 목요일 자정까지의 경험치 합산 수치를 계산할 것입니다. 이 데이터는 두 번째 테이블에 저장되며, 경험치에 대한 새로운 판독은 마지막으로 저장된 수치를 찾아서 목요일 자정 이후의 모든 값을 합산합니다. 그런데 메시지 지연 도착이 발생할 경우, 해당 메시지를 수신하면서도 게임이 수요일 오후 11시 59분에 완료되었다는 것을 확인해서 해당 시간으로 경험치 항목을 작성하게 됩니다. 이때 롤업 테이블에는 해당 항목이 포함되지 않으며, 따라서 이후 데이터 쿼리 결과에도 해당 항목이 나타나지 않을 것입니다. 이렇듯 불완전한 데이터는 문제가 될 수 있습니다. 그렇다면 데이터가 날짜 단위로 늦는다면 어떻게 될까요? 저희는 모니터링 및 경고 기능을 이용하고 있으므로 거의 항상 새로운 정보가 다음 롤업에 맞춰 처리될 것입니다.


미니 레벨 계산

총합 경험치 테이블을 돌이켜보면 레벨 계산이 존재하지 않음을 알 수 있습니다. 그곳에는 오로지 획득한 경험치 총합만 존재합니다. 게임 디자이너가 설정한 레벨 관련 정적 테이블은 별개로 존재하는데, 구성은 대략 다음과 같습니다. 미니는 레벨 1로 시작해서 경험치를 1 획득하면 레벨 2가 됩니다. 그리고 경험치 3을 더 획득하면 레벨 3이 되는 방식입니다.

 레벨

다음 레벨 필요 경험치

1

1

2

3

3

6

4

10

5

20

10

250

경험치 테이블과 레벨 테이블을 결합하면 수치의 누적 합계를 산출해서 프로그램 실행 중 미니의 실제 레벨을 계산할 수 있습니다. 그 최종 데이터 세트는 다음과 같습니다.

미니

레벨

경험치

다음 레벨 필요 경험치

놀 투사

3

5

1

그리핀 기수

3

2

4

안심지대 조종사

3

2

4

연쇄 번개

2

2

1

게임 디자이너들은 레벨을 올리기 위해 필요한 경험치에 관해 유연한 시각을 갖고 있습니다. 낮은 레벨에서 레벨을 올리는 게 너무 힘들다고 판단하면 다음 레벨 필요 경험치를 줄일 수도 있겠죠. 이때 데이터베이스에 저장된 플레이어 데이터를 변경하지 않고도 미니들의 레벨이 기존에 비해 다소 오를 것이고, 또 다음 레벨에 도달하는 데 필요한 경험치가 감소할 것입니다. 이는 반대 방향으로도 작동합니다. 레벨이 너무 빨리 올라서 필요 수치를 늘려야겠다고 게임 디자이너들이 판단할 경우, 미니가 보유한 경험치를 변경하지 않으면서도 미니의 레벨은 변경 이전으로 복원할 수 있게 됩니다. 물론 미니에게 경험치를 부여해서 고통을 덜어주는 방식을 사용할 수 없다는 뜻은 아닙니다.


대안적 접근 방식 고려

현재 사용 중인 솔루션을 결정하기 전에, 획득한 경험치를 저장하고 미니의 레벨을 계산하는 방식에 대해 다양한 방식으로 접근해 봤습니다. 개중엔 점검 시간을 통해 플레이어 경험치를 변경하는 방식도 있었고, 플레이어의 진행 상황을 끊지 않으면서 경험치를 전하기에 적절한 방식도 있었죠.

'미니의 레벨과 경험치, 그리고 다음 레벨 필요 경험치를 항상 저장하기'

그냥 표준 SQL 패턴과 트랜젝셔널 업데이트 방식을 사용하는 것은 어땠을까요? 트랜젝셔널 업데이트를 사용하면 데이터를 집합 단위로 업데이트하거나, 아니면 아예 업데이트하지 않게 됩니다. 데이터 일부를 작성하지 못할 경우 모든 데이터가 기존 수치로 돌아가는 셈이죠. 원자성, 일관성, 고립성, 내구성을 갖추고 있다고 하여 각 단어의 첫 알파벳을 따서 A.C.I.D. 속성을 제공한다고 표현합니다.

이때 하나의 행이 각 미니의 경험치와 총 레벨 정보를 보유하므로 읽기 과정이 매우 가벼워집니다.

각 미니가 경험치를 획득할 때, 해당 미니가 보유한 경험치는 2점으로 변경되고 다음 레벨까지 8점이 남아있으며 레벨은 3입니다. 이러한 변경 방식은 데이터를 읽고 다시 계산한 후 데이터베이스에 작성하는 과정을 강제합니다. 아무래도 Cassandra 솔루션과 그 장점에 잘 어울린다고 보기는 힘든 패턴이죠. 게다가 다음 레벨 필요 경험치를 변경하려면 모든 플레이어의 모든 미니에 대해 데이터를 조정해야 하므로 게임을 오프라인 상태로 만들어야 한다는 단점 또한 존재합니다.

'백분율로 저장하기'

더불어, 다음 레벨까지 필요한 경험치를 백분율로 저장하는 방식도 고려했습니다.

미니

레벨

다음 레벨까지

놀 투사

3

20%

그리핀 기수

2

20%

안심지대 조종사

2

20%

한 번의 경기를 끝내고 나면 곧바로 계산이 이뤄집니다. 예를 들면, 놀 투사가 경험치를 +3 획득할 때 백분율은 10 대비 +3으로 다음 레벨까지 30% 경험치를 획득한 것이 됩니다.

미니

레벨

다음 레벨까지

놀 투사

3

50%

그리핀 기수

2

20%

안심지대 조종사

2

20%

이 방식을 채택하면 미니 레벨 곡선을 변경해도 미니의 레벨에는 변화가 없는 유연성을 확보할 수 있습니다. 또한 추가적인 계산 없이 막대의 30%를 채우기만 하면 되므로 게임 사용자 인터페이스에 시각적으로 표현하기가 더 쉬워집니다. 단, 이 패턴은 레벨이 높아질수록 비율이 급격히 감소하는 결과를 초래합니다. 미니가 경기당 경험치를 10 획득하며 다음 레벨 필요 경험치라 100,000일 경우, 해당 값은 0.01%로 표현됩니다. CPU가 부동 소수점을 처리할 능력을 지니긴 하지만, 부동 소수점 연산의 특성을 고려하지 않으면 정확성에서 오류가 발생할 수 있습니다.


다음 시간까지 안녕히...

이렇듯 데이터베이스 기술, 어떤 툴을 사용할지, 그리고 값을 저장하고 계산하는 방식에 관해 수많은 선택지를 고려했습니다. 개발 중인 콘텐츠가 다 그렇듯 더 좋은 방식을 발견하면 모든 내용이 바뀔 수 있습니다. 다만 이 게임 개발을 시작하기에 일단은 좋은 지점을 찾아낸 듯합니다.

이번 엔지니어링 업데이트를 함께 살펴봐 주셔서 감사합니다!

~ 앤디 림

번외로, 땡그란 눈알 사건은 제 짓이 아니란 사실을 이 자리에서 분명하게 밝히는 바입니다. 제가 팀에 들어온 첫날에, 새로 산 모니터에 누군가가 그 눈알을 달아놨어요. 그 눈알은 온종일... 저를 빤히 쳐다보고 있고요.