7. 설계 목표가 아니었던 것
다른 게임 만들기에 편할 것
엔진 코드와 게임 코드의 구분이 모호하다.
재사용할 수 있는 부분을 분리하는 데 상당한 시간이 필요하다.
다양한 장르 게임을 만들 수 있을 것
서버의 이벤트를 전달받는 지연시간이 길어서,
실시간 멀티플레이는 턴제 게임 이외의 것을 만들 수 없다.
장기적으로 유지보수성이 좋을 것
로직을 동적 타입 언어(Lua)로 만들었기 때문에 리팩토링이 고통스럽다.
테스트 빡빡하게 붙어있으니 할 수는 있지만…
7
14. 양해 부탁드립니다 1
실버바인 서버엔진 2의 설계 결정은,
• 2016~2018년의 데브캣 스튜디오라는 특수성에
크게 의존할 수도 있습니다.
• 상황이 변함에 따라, 그리고 경험을 쌓아감에 따라,
앞으로 설계 결정을 번복할지도 모릅니다.
이 발표에서는 현재 시점을 기준으로 이야기합니다.
14
33. Lua
마비노기 듀얼에 사용됨.
Gideros와 언어를 맞출 수 있다.
프로그래머의 단순한 실수를 잡아주지 않는다.
정적 타입체크되는 루아 변종 언어를 만들어서 돌파하겠다고 계획함.
그리고 실제로 해냈습니다
33
34. 초기 결정
게임 로직 언어로 C#과 Lua를 지원하자.
클라이언트 유니티 → 서버 C#
클라이언트 Gideros → 서버 Lua
엔진의 공통 부분은 C++로 작성하자.
34
35. 현재
스튜디오에 Gideros 프로젝트가 더 이상 없다.
유니티와 언리얼로 통일됨. 프로토타이핑에는 또다른 도구를 쓴다.
엔진 API의 많은 부분을 C#으로 만들게 되었다.
언어 바뀌는 경계에서 프로그래밍하기 번거롭더라.
현재는 게임 로직 언어로 C#만 지원하고 있다.
35
37. 마비노기 듀얼 (2014~2015)
MariaDB
시스템 엔지니어링 조직(현 모바일인프라실)의 권고에 따라 선택.
전체적인 DB 설계 가이드를 받음.
Redis
잘 알고 있고, 서버간 통신에도 활용할 수 있어서 선택함.
디스크 저장 옵션을 켜고 중요한 정보도 저장했다.
롱 폴링과 PUB/SUB을 엮어서 멀티플레이를 구현했다.
37
38. 바꿀까?
단점이 없는 데이터베이스는 없다. 아마도.
뭘로 바꾸더라도 지뢰를 한번은 밟을 것이다.
지뢰가 어디 있는지 알고 피하는 것이 낫다.
바꾸지 않기로 결정.
38
39. 목차
• 시작
• 목표
• 선택 게임 로직 작성하는 언어 / 데이터베이스
• 설계 동시성 처리 / 데이터 저장 / 네트워킹 / 백오피스
• 의미
39
41. 동시성 처리
서버엔진 설계의 가장 핵심.
다른 모든 부분은 교체할 수 있지만 이것만은 교체할 수 없다.
동시성을 처리하는 방법이 같아야 조립해서 쓸 수 있다.
41
42. 문제: 서버에는
외부 시스템에 요청하고 결과를 기다려서 처리하는 일이 많다.
• 예: DB 접근
한번에 하나씩만 처리한다면
• CPU는 결과를 기다리느라 대부분의 시간 동안 놀게 된다.
• 1ms가 걸리는 일은 1초에 1,000번 할 수 있다.
• 10ms가 걸리는 일은 1초에 100번 할 수 있다.
42
43. 동시성 처리
요청을 보내 놓고 나서 응답을 기다리고만 있지 말고
다른 작업을 처리해야 한다.
다르게 표현하면,
• 기다림을 처리하는 방법
• 동시에 여러 일을 진행하는 방법
• 요청의 결과가 돌아왔을 때 원래 맥락을 되찾는 방법
43
44. 설계 선택지: 멀티스레드일 때
작업과 작업 사이에 메모리에 상태를 저장하는가?
메모리에 저장한 상태를 여러 스레드가 함께 변경하는가?
공유 자료구조의 접근을 어떻게 제어하는가?
예
아니오
예
아니오
비관적으로 낙관적으로
트랜잭셔널 메모리락 기반 멀티스레딩
액터
스테이트리스
44
55. async/await
훨씬 편하고 튼튼하게 짤 수 있다
기존 싱글스레드 장점을 유지
• 여러 절차를 동시에 실행 + 공유 자료구조 접근 제어가 쉽다
TypeScript는 1.7부터 (2015.11)
Node.js는 7.6부터 (2017.3)
55
56. 실은 매우 오래되었다
• Stackless Python (1998)
• Game Programming Gems 2권 ‘게임 객체 AI를 위한 마이크로 스레드’ (2002)
• Lua 5.0 coroutine (2003)
• 마비노기 1 (2004년 출시)
56
57. C#은?
async/await가 있다!
C# Task 라이브러리는 await 지나고 나면 스레드가 바뀜
우리는 RunSynchronously를 사용해서 스레드를 강제
SynchronizationContext를 쓰면 된다는데 해보진 않음
57
58. CPU를 충분히 활용하지 못하지 않는가?
로직 스레드는 전혀 블로킹 없이 최대 속력으로 실행
더 많은 요청을 더 빠르게 처리하는게 중요하지,
CPU를 바쁘게 만드는 게 중요한 게 아니다
I/O를 다른 스레드에서 실행
• 네트워킹 전담 스레드 있음
• DB/Redis 호출에 쓰는 스레드 수십 개
58
59. 로직 스레드가 병목이 되면?
한 인스턴스에 여러 프로세스를 띄우거나
vCPU 개수가 적은 클라우드 인스턴스를 여러 개 사용하면 된다
하나의 프로세스에 5천명씩 받는 건 못함.
59
66. DB ~파멸편~
• 인덱스 안 걸린 컬럼으로 검색하는 것 막아야 한다.
• WHERE 빼먹는 것 막아야 한다.
• DB 스키마 맞추는 것 너무 번거롭고 오래 걸린다.
• 유저 데이터 변경을 자동으로 기록하고 싶다.
• 수평 확장을 게임 로직이 모르게 하고 싶다.
• 한 트랜잭션에서 여러 DB를 변경하고 싶다.
• DB 데드락 미리 방지하고 싶다.
66
67. 발상
엔진을 통해서만 DB에 접근하자.
• SQL 테이블 정의는 어떤 게임이든 똑같다.
• 데이터 형식을 자체 문법 파일로 작성하면 C# 코드가 자동 생성된다.
• 로직 프로그래머가 직접 SQL 작성할 수 없다
67
68. 장점
장점!
• SQL 배우지 않아도 된다.
• DB 스키마가 소스코드에 있으니 맞추기 쉽다.
• 인덱스 타지 않는 쿼리를 만드는 것이 불가능하다.
• 수평 확장, 분산 트랜잭션, 변경 로그 작성을 엔진이 알아서 한다.
• DB 데드락이 발생할 가능성이 있으면 엔진이 미리 알려준다.
68
70. DB에 저장할 때는
• 필드를 json으로 인코딩해서 저장하고 있다.
• 더 타이트한 포맷으로 바꾸는 것 고려하고 있었으나
• 타 시스템과의 연동 때문에 놔둬야 할 듯;;
70
71. 데이터의 동시성 제어
비관적 동기화를 사용한다.
개별 작업의 반응시간은 크게 중요하지 않다.
드물게 발생하는 버그를 내지 않는 게 더 중요하다.
락 관련 문제는 엔진이 감지하고 경고한다.
락을 잡지 않고 읽는 문제.
미리 정의한 락 순서를 지키지 않는 문제.
71
72. 데이터 모델
락 기반 비관적 동기화 + 명시적인 락을 채택
게임 로직 프로그래머가 락을 의식하게 해야 한다
그럼 락의 단위는?
너무 작으면 프로그래밍하기 괴롭고
반응시간도 길어진다
너무 크면 동시성이 떨어진다
72
73. 락을 유저 단위로 잡자!
게임 데이터의 특징: 대체로 데이터가 한 유저에게 속해 있다.
길드 창고 같은 경우 길드를 가상의 유저로 볼 수 있다.
유저별로 DB 테이블들이 있는 것 같은 느낌을 주자!
유저를 일반화해서 엔진에서는 Document라고 부르고 있음.
Document가 락 단위이기도 하다.
73
74. Global Table
특정 유저에게 속하지 않은 데이터를 저장할 수 있어야 한다.
• Key-Value 테이블을 제공한다.
• Global Table이라고 부름.
• Key가 락 단위가 된다.
74
75. Document의 수평 확장
한 Document는 같은 DB에 넣는다.
어느 Document가 어느 DB에 들어있는지 별도 테이블로 관리.
변경되지 않으므로 공격적으로 캐싱할 수 있다.
라이브 추가도 가능.
75
76. Global Table의 수평 확장
• HASH(Key) % N 으로 샤드 번호를 구하고 있다.
• 부하가 몰릴 때 샤드를 추가하기 쉽지 않다.
• Document와 유사한 구조로 수정할 예정.
76
77. 데드락 방지
어떤 락 A를 잡은 상태에서 락 B를 추가로 잡을 수 있는지를
.schema 파일에 적는다.
규칙에 모순이 없는지 .schema 컴파일 단계에서 확인한다.
락을 잡을 때 규칙을 어겼는지 엔진이 확인해서 예외를 던진다.
77
78. 변경 로그 자동 작성
모든 데이터 변경 시점마다 변경 로그가 자동으로 남는다.
이것을 되감아서 임의 시점의 유저 DB 상태를 재현할 수 있다.
78
79. 클라이언트에 Document 변화를 동기화하기
Document 내용을 변경할 때마다 변경사항이 자동 요약됨.
직렬화할 수 있는 형태라서 클라이언트에 전달하기 쉽다.
유저 데이터의 최신 읽기 전용 사본을 클라에 유지할 수 있다.
79
80. Global Table과 실시간 랭킹
글로벌 테이블에 랭킹을 매기도록 설정할 수 있다.
데이터 쓸 때 Redis의 복제본이 자동으로 갱신된다.
랭킹 관련 조회는 Redis로부터.
불일치가 생기면 점진적으로 데이터 복원 가능.
80
82. 보조 저장소
주 저장소로 처리하기 어려운 특이한 요구사항이 가끔 있다.
이런 경우 Redis를 사용해서 만들었다.
82
83. Cache
만든 값이 일정 시간이 지나면 사라져야 한다.
캐시 용도나 세션 저장 용도로 사용한다.
EXPIRE로 구현.
83
84. Ephemeral
값을 생성한 프로세스가 죽으면 값이 자동으로 지워져야 한다.
Apache ZooKeeper™의 ephemeral node를 모방함.
세션 키 저장에 사용한다.
GET_LOCK으로 프로세스 죽음을 감지하고,
죽은 프로세스가 만든 값들을 다른 프로세스가 지워주게 구현.
84
85. TimeSeriesData
시간이 흐름에 따라 변하는 데이터를 요약해서 저장해야 한다.
성능 프로파일러와 실시간 라이브 지표 요약기에서 사용한다.
미리 정한 시간 간격마다, 사건이 발생한 횟수, 데이터의 최소값/최대값/총합으로 요약한다.
원본 데이터 분량이 매우 많을 수 있다.
프로세스에서 먼저 한 번 요약한다.
Redis에 저장하면서 추가 요약한다.
85
86. 보조 저장소의 수평 확장
• (HASH(Key) % N) 해서 샤드 번호를 구한다.
86
87. Redis 데이터 저장 옵션을 켜는가?
처음에는 켰지만, 끄는 쪽으로 엔진 수정 중.
일부 기능은 MySQL로 옮기거나 합치고 일부 기능은 제거
• 이유 1: 저장 옵션에 의한 Redis 성능 저하
자세한 설명은 생략
• 이유 2: 유연한 하드웨어 사용
HASH(Key) % N 으로 샤딩하기 때문에, Redis 인스턴스를 추가하거나 줄이기 매우 어렵다.
따라서 부하를 정확히 예측해야 하는데, 쉽지 않은 일이다.
데이터를 아예 저장 안하기로 결정하면, 오픈 시점에 Redis를 많이 투입해 두었다가 점검 때 줄이면 됨.
점검 후에 데이터가 사라져도 괜찮다면 인스턴스 개수를 바꾸는 일이 대단히 쉬워진다.
87
88. Redis와 MySQL을 통합하는 방법
• MySQL이 원본이고 Redis가 사본인 경우
Global Table의 실시간 랭킹 처리.
MySQL→Redis로 재동기화하는 절차를 언제 쓸 지 모르니 미리 만들어둬야 한다.
• Redis에서 요약하고 가끔씩 MySQL로 옮기는 경우
TimeSeriesData.
장애가 생기면 일부 데이터는 손실될 수 있다.
• Redis에만 값이 있고 날아가면 사라지는 경우
Cache와 Ephemeral.
원래 그런 성격의 데이터이니까 괜찮다. 별다른 조치가 필요 없다.
88
91. 네트워킹: 목차
• 클라이언트와 통신
– 지원하는 프로토콜
– 세션 관리
– 재접속 처리
• 서버끼리 통신
– 서버를 상대로 메시지 전달
– 세션 핸들러를 상대로 메시지 전달
– VActor
91
92. 클라이언트와 통신: 지원하는 프로토콜
TCP 자체 프로토콜
boost::asio로 구현
HTTP와 WebSocket
uWebSockets로 구현
UDP는 아직
92
93. 요청 처리하기
TCP, WebSocket : 1 세션 = 1 파이버
같은 세션에서 연속 발생하는 요청은 동시 실행되지 않게
HTTP : 1 요청 = 1 파이버
도착하는 요청마다 일단 파이버 스폰부터
93
94. 재접속 처리
Wifi에서 LTE로 옮겨타도 스트림이 유지되어야 한다
스트림이 유지된다 = 손실되는 메시지가 없다
애플리케이션 레벨에서 아직 수신 확인 받지 않은 메시지를 보관
연결이 복구되면 재전송
94
95. 재접속 처리: 로드밸런싱
AWS Elastic Load Balancer를 쓰려니…
끊겨서 재접속하니까 아까 연결했던 그 서버가 아니네?;;
재접속할 때는 서버 고유의 public IP를 쓰도록 급히 수정
95
96. 재접속 처리: 무점검 패치
서버를 업데이트하면서 유저가 느끼지 못하게 하려면?
세션 핸드오버
클라이언트가 로직 코드 모르게 다른 서버로 이동할 수 있게 하기
로직 데이터와 아직 수신 확인 받지 못한 메시지를 Redis에 저장
클라이언트가 다른 서버로 연결하면 Redis에서 데이터를 꺼내와서 스트림을 복구
같은 서버로 연결해야만 하는 경우 (MMORPG) 세션 핸드오버를 사용할 수 없음
96
97. 네트워킹: 목차
• 클라이언트와 통신
– 지원하는 프로토콜
– 세션 관리
– 재접속 처리
• 서버끼리 통신
– 서버를 상대로 메시지 전달
– 세션 핸들러를 상대로 메시지 전달
– VActor
97
98. 서버를 상대로 메시지 전달
서버 프로세스의 UPID를 지정해서 메시지를 발송할 수 있다
UPID = Unique Process ID. 프로세스 시작할 때 자동 발급되는 고유번호
현재는 메시지가 Redis를 경유하게 구현함
이걸 게임 로직에서 그대로 활용하기에는 좋지 않다
어느 서버가 떠 있는지 + 어떤 역할을 하는지 알 방법이 필요
다른 서버간 통신의 기초 부품으로 활용하고 있음
98
99. 세션 핸들러를 상대로 메시지 전달 1
다른 유저에게 메시지를 보내려면?
목적지 = <상대 유저가 접속한 프로세스의 UPID, SessionID>
둘 다 모른다!
99
100. 세션 핸들러를 상대로 메시지 전달 2
어느 프로세스에 접속했는지 모르는 유저에게 메시지를 보내려면?
세션 핸들러를 초기화할 때 고유 식별자(세션 키)를 등록하게 함
세션 핸들러 = 클라이언트가 서버에 접속하면 서버에 생기는 객체
세션 키로는 유저 id를 사용할 것을 권장
세션 키를 대상으로 메시지를 발송하면 된다
엔진이 세션 키로부터 <UPID, SessionId> 를 자동으로 알아내서 보내준다
100
101. VActor 1
다른 서버 프로세스에 있는 기능을 호출하려면?
예) 길드 채팅, 파티 멤버 관리, 턴제 게임 PVP
어느 서버가 어떤 기능을 처리할 수 있는지 알아내고
메시지를 보내고
응답을 기다려야 한다
절차 전체를 하나로 묶음.
MS Research의 Project Orleans를 크게 참고함.
101
102. VActor 2
서버 프로세스가 부팅하는 시점에,
자신이 어떤 기능을 처리할 수 있는지(VActor 타입)를 등록한다.
호출 측에서는 VActor의 타입과 이름을 상대로 요청을 보낸다.
처음으로 호출이 일어날 때 서버 어딘가에 VActor가 생겨난다.
102
103. VActor 3
VActor에는 명시적인 생성/파괴가 없다.
존재한다고 치고 그냥 사용하면 됨
한동안 쓰이지 않으면 사라진다 (스왑아웃)
자신의 데이터를 Redis나 DB에 저장할 수 있다
다음 호출이 일어날 때 활성화된다 (스왑인)
저장했던 데이터를 가지고 원래 상태로 복구할 수 있다
103
104. VActor 4
장점: 자연스러운 스케일아웃
해당 기능을 어느 서버 프로세스에서 제공하는지는 호출자의 관심사가 아니다.
생성/파괴도 자동이다.
장점: 무점검 패치 가능!
서버가 종료하면서 모든 VActor를 스왑아웃한다.
그 VActor들에 호출이 일어나면 다른 게임서버 프로세스로 스왑인된다.
104
106. 백오피스란
• 서비스에 꼭 필요한 기능을 백오피스로 분류했다.
• 상대적으로 개발 진도가 가장 뒤떨어져 있음.
106
107. 에러리포트
• 서버에서 발생하는 오류를 수집하고 보고할 수 있어야 한다.
• C#이므로 스택 트레이스를 보내는 정도로 해두었음.
• 아직 C++ 부분의 오류를 분석해야 하는 상황은 만나지 못함.
107
108. 디플로이먼트 1
서버 프로그램을 서버 장비에 배포하고 작동시켜야 한다.
특히 오토스케일링 될 때.
서버 환경을 간단하게 생성할 방법이 필요하다.
개발/테스트용 서버 구축하는 일이 꽤 귀찮고, 은근히 많다.
108
109. 디플로이먼트 2
Docker, Kubernetes, …?
장비(클라우드 인스턴스)는 모바일인프라실에서 다 해주심!
게임서버를 자동 업데이트하고 장비 종류별로 실행
개발/테스트 서버는 모든 게임이 하나의 장비를 공유해서 씀
109
110. 성능 프로파일링
느릴 때 왜 느린지 알 수 있어야 한다.
CPU, 메모리 지표로는 부족하다.
어디에서 시간을 잡아먹는지
어떤 기능을 빈번하게 호출하는지 바로 알 수 있어야 한다.
110
111. 모니터링
주요 지표를 기록하고, 문제가 있으면 알람을 띄워야 한다.
성능 지표도 CPU나 메모리 사용량만으로는 부족하다.
초당 요청 처리량
세션 개수
연결되어 있는 소켓 개수
등록되어 있는 타이머 개수
DB 조회 횟수
활성화된 VActor 개수
메인 스레드 점유율, 지연 시간
백그라운드 스레드 지연 시간
파이버별 실제 CPU 사용 시간
111
112. 분석/배치 작업
질문에 대한 대답을 데이터로부터 얻어낼 수 있어야 한다.
대량의 데이터를 일괄 변경할 수 있어야 한다.
어뷰저 찾아내기, 부정 획득 재화 회수하기 등에 필요.
데이터가 메타데이터 없는 json으로 저장되어 있으므로 DB 쿼리를 사용하기는 어렵다.
NDC 2015 <쿠키런 로그 시스템> 을 참고하려고 함.
112
113. 운영툴
게임 내의 데이터를 조회하거나
서버를 원격제어할 수 있는 도구가 필요하다.
웹으로 운영툴 기반을 만들었다.
JQuery + 자체 제작 웹 프론트엔드 프레임워크(Jul8) 를 사용한다.
너무 빨리 바뀌는 웹 트렌드 일일이 따라잡지 않아도 되도록.
이와 별개로 사내 표준 운영툴 GUI도 사용한다.
운영 조직이 사용하실 도구를 만드는 데 쓴다.
113
114. 목차
• 시작
• 목표
• 선택 게임 로직 작성하는 언어 / 데이터베이스
• 설계 동시성 처리 / 데이터 저장 / 네트워킹 / 백오피스
• 의미
114