Akka.NET 으로 만드는 온라인 게임 서버 김이선 veblush@gmail | github/veblush
목차 도입 왜 Akka.NET ? Akka.NET 소개 Akka.NET 에 더해 만들 것들 틱택토 대전으로 살펴보는 사용 예 결론
과거 프로젝트의 서버? 카트라이더 (2004) 에버플래닛 (2010) 돌격전차 (2015) P2P 온라인 게임 서버 C++ / IOCP Socket 온라인 MMORPG 서버 C++ / IOCP Socket 모바일 온라인 게임 서버 C# / IOCP Socket
개발 과정 소규모 팀 서버 개발팀이 따로 없음. 모두가 전부를 커버하는 구조. 클라이언트 중심으로 개발하다가 적당한 시점에 서버를 만들어 붙임. 카트라이더의 경우 3차 클베 직전에 서버 작성. (단일 서버)
개발 과정 빠른 개발의 명암 2~4주 안에 만들다 보면 요구 조건만 빠듯하게 만족하는 구조가 됨 기민하고 낭비 없는 개발이지만… 업데이트 컨텐츠를 개발하는 과정에서 한계가 일찍 드러남. 서버 구조를 라이브 시점에 크게 바꾸기는 어렵다
리서치 좀 더 시간을 써서 유연한 틀을 개발/확보해보자 Akka.Net + α 로 결정 총 3개월! 리서치 + 테스트 게임 만들기 적당한 수준까지만 리서치: Akka.NET, Orleans,… 레퍼런스 앱 제작: Chatty, TicTacToe, Snake Akka.Net + α 로 결정 - 왜?
왜 Akka.NET ? 뭐지?
Akka.NET ? 액터 모델을 제공하는 Java/Scala 툴킷 Akka 의 .NET 포팅 라이브러리.
왜 액터 모델? 온라인 게임은 보통 Stateful 액터 모델은 많은 수의 Stateful 개체를 다루기 좋음 Actor 유저 상태, 월드 상태, … 실시간으로 유저 상호작용 발생 액터 모델은 많은 수의 Stateful 개체를 다루기 좋음 때문에 많은 온라인 게임에서 사용하고 있음 M3 M2 M1 Behaviour
왜 액터 모델? UserActor _count OnChat // actor.receiver M1 M2 M3 UserActor // actor.receiver UserActor.OnChat(Chat m) { _count += 1; Print(m.Message); } // sender GetUserActor().Send( new Chat { Message = "Hello?" });
왜 .NET? 클라이언트와 언어/환경을 맞추기 위해 괜찮은 플랫폼 클라이언트는 Unity3D 클라이언트/서버 로직 코드 공유 가능 양쪽 모두를 작업할 때 컨텍스트 전환 비용 적음 괜찮은 플랫폼 적당한 성능 :) .NET Core 를 지원하면 서비스 플랫폼 선택의 폭이 넓어짐!
왜 Akka.NET ? 선택 때 염두 한 것 후보 활동 중인 오픈 소스 바로 사용할 수 있을 정도로 성숙한 상태 .NET 3.5 지원이 어렵지 않을 것 (Unity3D) 후보 내부 라이브러리 Orleans http://dotnet.github.io/orleans Akka.NET http://getakka.net
후보: 내부 라이브러리 내부에 있는걸 개선하는 방향 빠른 포기 직전 프로젝트 “돌격전차” 때 싸게 만든 라이브러리를 발전시키는 방향 특정 게임에 특화된 라이브러리를 범용화 하는 것은 많은 작업 필요 빠른 포기 작은 팀이 (게임도 하면서만들면서 하기엔) 할 일이 많다고 판단 재미있겠지만…
후보: Orleans MS 리서치의 오픈 소스 프로젝트 Virtual Actor 몇 년의 연구 끝에 2015년에 1.0 발표 여러 클라우드에서 동작 가능 Azure Service Fabric 과는 다르다 Virtual Actor 기존 라이브러리와 다르게 Virtual Actor 개념 도입 메모리의 GC 처럼 개발을 쉽게 하기 위한 개념 좋아 보이지만…
후보: Akka.NET Akka 의 .NET 포팅 라이브러리 모듈구조 필요한 기능의 모듈만 쓸 수 있다 (Remote, Cluster 도 모듈) 모듈을 교체해서 사용할 수 있다 (Serializer 등)
선정: Akka.NET 강점 약점 모듈 구조라 상황에 맞춰 커스터마이징을 쉽게 할 수 있다. 오픈 소스라 구조 파악 / 버그 수정이 가능하다. 클래식 모델이지만 검증된 모델이다. 약점 1.0 이 나온 지 얼마 되지 않아 여러 이슈에 시달릴 수 있다 Remote 성능 개선 작업이 아직 진행 중이다 다만 API 는 Akka 의 것이라 안정적이다
Akka.NET 소개 기본적인 내용만 간략히
Actor Actor State 상태를 가짐 메시지를 받음 한 번에 하나의 메시지를 처리함 M3 M2 M1 Behaviour
Actor HelloActor _count OnHello class HelloActor : ReceiveActor { M1 M2 M3 HelloActor class HelloActor : ReceiveActor { int _count; public HelloActor() { Receive<Hello>(m => { _count += 1; Console.WriteLine($"Hello {m.Who}")}); }
ActorRef Actor 액터는 ActorRef 라는 핸들로만 접근 ActorRef 를 통해 액터에 메시지 전송 State Behaviour M1 M2 M3 Actor 액터는 ActorRef 라는 핸들로만 접근 ActorRef 를 통해 액터에 메시지 전송 원격에 있는 액터에도 보낼 수 있음 ActorRef
ActorRef greeter greeter _count OnHello // create actor M1 M2 M3 greeter // create actor IActorRef greeter = system.ActorOf<HelloActor>("greeter"); // send message greeter.Tell(new Hello("World")); greeter Hello(“World”)
Actor 계층 구조 액터가 새 액터를 생성할 때 자식으로 만들 수 있음 자식 액터가 예외를 던지면 부모가 처리 할 수 있음 Resume|Restart| Stop|Escalate Actor Actor Exception
Actor 계층 구조 class Worker : UntypedActor { IActorRef counterService = Context.ActorOf<CounterService>("counter"); override SupervisorStrategy SupervisorStrategy() { return new OneForOneStrategy(ex => { if (ex is ServiceUnavailableException) return Directive.Stop; return Directive.Escalate; }); }
원격 원격 Actor 에 메시지를 보낼 수 있음 원격 Actor 를 만들 수 있음 Actor NodeA 원격 State Behaviour M1 M2 M3 Actor 원격 Actor 에 메시지를 보낼 수 있음 ActorRef 에 대상 주소 정보 위치 투명성 원격 Actor 를 만들 수 있음 ActorRef NodeA:Actor NodeB
원격 // 서버 using (var system = ActorSystem.Create("MyServer", config)) { system.ActorOf<HelloActor>("greeter"); ... } // 클라이언트 using (var system = ActorSystem.Create("MyClient", config)) { IActorRef greeter = system.ActorSelection( "akka.tcp://MyServer@localhost:8080/user/greeter"); greeter.Tell(new Hello("World"));
클러스터 Remote 를 확장해 클러스터 구현 멤버쉽 관리 클러스터 유틸리티 제공 NodeA NodeB Remote 를 확장해 클러스터 구현 멤버쉽 관리 Gossip 프로토콜 사용 (SPOF/B 없음) 노드 Role 지정 클러스터 유틸리티 제공 Singleton, Sharding, 분산 Pub/Sub 등 NodeC
클러스터 NodeA B C NodeB A C NodeC A B class SimpleCluster : UntypedActor { Cluster Cluster = Cluster.Get(System); override void OnReceive(object message) { var up = message as ClusterEvent.MemberUp; if (up != null) { Print("Up: {0}", up.Member); } A C NodeC A B
Akka.NET 에 더해 만들 것들 이것만으로 온라인 게임을 만들기엔 부족하지…
Discovery / Table NodeA NodeB Interface Actor DB Client ? State Sync
Akka.Interfaced Actor NodeA NodeB https://github.com/SaladLab/Akka.Interfaced Actor NodeA NodeB Interface
Akka.Interfaced 코드를 좀 더 간결하게 작성할 수 있도록 메시지 클래스 불필요 메시지 처리는 인터페이스 메소드 구현으로 Orleans, WCF 에 영향 받음 액터 구현과 사용에 있어 타입 오류가 없도록 인터페이스 상속으로 구현 여부를 컴파일 시점에 확인 인터페이스 호출로 메시지가 올바른지 컴파일 시점에 확인
Akka.Interfaced greeter greeter greeter greeter IHello _count _count OnHello M1 M2 M3 greeter _count IHello .Hello M1 M2 M3 greeter IHello greeter greeter Hello(…) IHello.Hello(…)
Akka.NET 스타일 // define message class Hello { public string Name; } class HelloResult { public string Say; } // implement actor class HelloActor : ReceiveActor { public HelloActor() { Receive<Hello>(m => { Sender.Tell(new HelloResult($"Hello {m.Name}!")}); }); // use actor var result = await actorRef.Ask<HelloResult>(new Hello("World")); Print(result.Say);
Akka.Interfaced 스타일 // define interface interface IHello : IInterfacedActor { Task<string> SayHello(string name); } // implement actor class HelloActor : InterfacedActor<HelloActor>, IHello { async Task<string> IHello.SayHello(string name) { return $"Hello {name}!"; // use actor Print(await helloRef.SayHello("World"));
Akka.Interfaced.SlimSocket https://github.com/SaladLab/Akka.Interfaced.SlimSocket NodeA NodeB Interface Actor Client
Akka.Interfaced.SlimSocket 기본적으로 모든 액터에게 메시지를 보낼 수 있음 SlimSocket 은 이를 제한함 클라이언트는 허용된 액터에게 허용된 인터페이스만 사용 가능 이 시점에서 .NET 3.5 지원 Actor 에게 메시지만 보내는 기능만 필요하므로 구현할 것이 적음 Actor 생성, 계층구조, 클러스터, ... 다 필요 없음
Akka.Interfaced.SlimSocket Client Server Actor1 Actor2 Actor1Ref Actor2Ref ClientSession SlimSocket.Client SlimSocket.Server protobuf/tcp
Akka.Cluster.Utility ? Discovery / Table NodeA NodeB Actor https://github.com/SaladLab/Akka.Cluster.Utility Discovery / Table NodeA NodeB Actor ?
Akka.Cluster.Utility: ActorDiscovery
Akka.Cluster.Utility: ActorDiscovery NodeA NodeB UserActor ServiceActor UserActor UserActor Register Listen Notify Listen Notify ActorDiscovery ActorDiscovery share state
Akka.Cluster.Utility: ActorDiscovery // register actor class ServiceActor { override void PreStart(){ Discovery.Tell(new Register(Self, nameof(ServiceActor))); } // discover registered actor class UserActor { override void PreStart() { Discovery.Tell(new Monitor(nameof(ServiceActor))); void OnActorUp(ActorUp m) { ... } void OnActorDown(ActorDown m) { ... }
Akka.Cluster.Utility: DistributedActorTable 여러 클러스터 노드에 분산되는 액터 테이블 Sharding 과 유사하나 좀 더 단순한 구현체 마스터 테이블 존재 (SPOF/B)
Akka.Cluster.Utility: DistributedActorTable NodeA NodeB NodeC ActorTable Actor1 Actor3 ID ActorRef 1 Actor1 2 Actor2 3 Actor3 Actor2 Actor4
Akka.Cluster.Utility: DistributedActorTable // create table var table = System.ActorOf( Props.Create(() => new Table("Test", ...))); // create actor on table var reply = await table.Ask<CreateReply>(new Create(id, ...)); reply.Actor; // get actor from table var reply = await table.Ask<GetReply>(new Get(id));
TrackableData NodeA NodeB Actor DB Client State Sync https://github.com/SaladLab/TrackableData NodeA NodeB Actor DB Client State Sync
TrackableData 서버와 클라이언트 상태를 동기화 하기 위해 사용 프로덕션 수준 안정화 데이터의 상태 변경을 클라이언트 혹은 다른 서버, DB 에 전파 ORM 보다는 Change Tracking 라이브러리 .NET 3.5 / Unity3D 지원 지원 Json, Protobuf MSSQL, MySQL, postgreSQL, MongoDB, Redis 프로덕션 수준 안정화 Monster Sweeperz 에 사용
TrackableData Client Server DB User Gold=10 Cash=20 User Gold=10 Snapshot Create Change Gold+=5 Save User Gold=15 Cash=20 User Gold=15 Cash=20 User Gold=15 Cash=20
TrackableData interface IUserData : ITrackablePoco<IUserData> { string Name { get; set; } int Level { get; set; } int Gold { get; set; } } var u = new TrackableUserData(); // make changes u.Name = "Bob"; u.Level = 1; u.Gold = 10; Print(u.Tracker); // { Name:->Bob, Level:0->1, Gold:0->10 }
틱택토 대전으로 살펴보는 사용 예 라이브러리가 잘 동작하는지 확인 하기 위한 레퍼런스 게임
기능 유저 계정 유저간 실시간 대전 유저 ID, 패스워드로 로그인 유저의 전적과 업적이 DB에 저장됨 턴제 (턴 타임아웃 있음) 대전 상대를 매치메이킹 서버를 통해 찾음 못찾으면 봇이 대신 참여
https://github.com/SaladLab/TicTacToe
프로젝트 구성 Domain 450 cloc Domain.Tests 965 cloc GameServer GameServer.Tests 1141 cloc GameClient
클러스터 노드 구성 Master User Game User Game GamePairMaker User Game User Table User Login GameBot GameBot Game Table
액터 구성 MongoDB User Login Client Client Session User GamePairMaker Game GameBot
주요 동작 로그인 매치메이킹 게임 입장 게임 진행 게임 종료
로그인 로그인 액터를 통해 계정 인증을 하고 유저 액터를 받는다 클라이언트는 허용된 액터와 인터페이스로만 서버와 통신 가능 로그인 액터는 클라이언트가 접속하면 가장 처음 받는 액터 클라이언트는 허용된 액터와 인터페이스로만 서버와 통신 가능 허용된 액터라도 허용받지 않은 인터페이스로 통신할 수 없다.
로그인: 액터 MongoDB User Login Login Create Client Client Session User Bind
로그인: 코드 // client requests login to server var t1 = login.Login(id, password, observerId); yield return t1.WaitHandle; // check account, create user actor and bind it to client async Task<LoginResult> IUserLogin.Login(id, password) { var userId = CheckAccount(id, password); var user = CreateUserActor(userId); await UserTable.AddUserAsync(userId, user); var bindId = await ClientSession.BindAsync(user, typeof(IUser)); // client gets user actor var user = new UserRef(t1.Result);
매치메이킹 동작 클라이언트는 매칭 요청을 서버에 보내고 일정 시간 기다림. 매칭 서버는 요청을 큐에 쌓으며 2명이 되면 짝을 지어줌. 타임아웃이 되면 봇 액터와 짝을 지어줌
매치메이킹: 액터 Register Client Client Session User GamePairMaker Created
매치메이킹: 코드 // client requests pairing from UserActor yield return G.User.RegisterPairing(observerId).WaitHandle; // UserActor forwards a request to GamePairMakerActor class GamePairMakerActor : ... { void RegisterPairing(userId, userName, ...) { AddToQueue(userId); } void OnSchedule() { if (Queue.Count >= 2) { user0, user1 = Queue.Pop(2); CreateNewGame(); user0.SendNoticeToUser(gameId); user1.SendNoticeToUser(gameId);
게임 입장 동작 서버는 매 게임마다 새 GameRoom 액터 생성 유저는 매치 메이킹 결과로 입장할 GameRoom ActorRef 받음 유저는 GameRoom 에 입장하면서 GameObserver 를 등록 GameObserver 를 통해 유저는 게임 이벤트를 받음
게임 입장: 액터 Join Client Client Session User Join Bind Game
게임 입장: 코드 // client sends a join request to UserActor. var ret = G.User.JoinGame(roomId, observerId); yield return ret.WaitHandle; // UserActor forwards a request to GameActor. // when done, grant GameActor to Client as IGamePlayer. class UserActor : ... { async Task<...> IUser.JoinGame(long gameId, int observerId) { var game = await GameTable.GetAsync(gameId); await game.Join(...); var bindId = await BindAsync(game.Actor, typeof(IGamePlayer)); return ...; }
게임 진행 동작 클라이언트는 입장 때 받은 GamePlayer 액터로 게임 진행 게임 이벤트 (상대의 턴, 게임 승패 이벤트 등) 은 GameObserver 로 처리
게임 진행: 턴 명령: 액터 MakeMove Client Client Session Game GameRef.MakeMove(2,1)
게임 진행: 턴 명령: 코드 // client sends a move command to GameActor class GameScene : MonoBehaviour, IGameObserver { void OnBoardGridClicked(int x, int y) { _myPlayer.MakeMove(new PlacePosition(x, y)); } // GameActor proceeds the game by command public class GameActor : ... { void MakeMove(PlacePosition pos, long playerUserId) { DoGameLogic(); ...
게임 진행: 진행 이벤트: 액터 MakeMove Game Client Client Session MakeMove GameObserver.MakeMove(2,1)
게임 진행: 진행 이벤트: 코드 // GameActor broadcasts game events to clients public class GameActor : ... { void MakeMove(PlacePosition pos, long playerUserId) { ... NotifyToAllObservers((id, o) => o.MakeMove(...)); } // client consumes game events class GameScene : MonoBehaviour, IGameObserver { void IGameObserver.MakeMove(...) { Board.SetMark(...);
게임 종료 동작 게임 액터 소멸 유저 액터에게 게임 종료를 알리고 유저 상태 업데이트 상태 변경을 TrackableData 로 클라이언트와 DB 에 전파
게임 종료: 액터 MongoDB Update Update Client Client Session User End End Game Kill
게임 종료: 코드 // UpdateActor updates user state when the game is over void IGameUserObserver.End(long gameId, GameResult result) { _userContext.Data.GameCount += 1; // flush changes to client and storage _userEventObserver.UserContextChange(_userContext.Tracker); MongoDbMapper.SaveAsync(_userContext.Tracker, _id); _userContext.Tracker = new TrackableUserContextTracker(); } // when no one left in GameActor, kill actor void Leave(long userId) { NotifyToAllObservers((id, o) => o.Leave(playerId)); if (_players.Count() == 0) { Self.Tell(InterfacedPoisonPill.Instance);
결론
Akka.NET 쓸만하다! Akka.NET 은 쓸만한 빌딩 블록. 부분적으로 필요한 곳에서만 쓸 수 있다. Akka.Interfaced 등을 통해 Unity3D 에서도 손쉽게 사용 가능. 라이브러리는 아직 초기 상태임을 염두 거의 대부분 잘 돌아간다. “성숙했다” 라고 하기엔 아직 시간이 필요. 문제 발생 때 늘 의심해야 하는 단계
시도해 보기 TicTacToe 라이브러리 써보기 소스를 받아 실행해 보기: https://github.com/SaladLab/TicTacToe 라이브러리 써보기 프로젝트 Github 방문 Nuget / Github Release 에서 라이브러리 받기
오픈소스 모든 것이 오픈 소스! 참여해보자! 사용하고 작성한 모든 것이 오픈 소스 기대 품질이 보다 더 좋아지지 않을까? 개발된 라이브러리가 게임과 같이 소멸하지는 않겠지? 참여해보자! 버그 리포트, 기능 제안 더 나아가서 내부 라이브러리, 툴을 오픈 소스로 만들어 보자!
감사합니다.