플라네타리움 엔지니어링 스낵

게임 개발자, Libplanet을 처음 만났을 때 😂

안녕하세요. 플라네타리움에서 게임을 만들고 있는 현승민입니다. 이번 글에서는 일반적인 게임의 클라이언트–서버 구조가 아닌 Libplanet을 활용하여 P2P 구조를 적용하는 방법을 찾아가는 이야기를 소개해드릴게요. 아래의 내용은 전적으로 경험에 기반한 내용이라는 것을 고려해 주세요.

먼저 클라이언트–서버 구조에 관해서 이야기해 볼게요. 저는 대부분 프로젝트에서 클라이언트–서버 구조로 개발했어요. 당연하게도 통신을 위해서 프로토콜을 작성했는데, 이 프로토콜은 웹 통신과 비슷하게 디자인했어요. 클라이언트 측에서 요청을 만들어 서버에 넘기면 서버 측에서 응답을 만들어서 클라이언트로 돌려주는 구조이죠. 요청은 주로 유저가 입력한 값을 포함했고, 응답은 프로토콜의 성패 정보를 담는 에러 코드와 요청에 의해 영향을 받은 상태 값(이하 변경점)을 포함했어요. (골드를 사용하는 구매 요청에 대한 응답에 구매 후 남은 골드량을 포함하는 등) 물론 클라이언트 측에서 예측 가능한 변경점은 응답에 포함하지 않아도 되었죠.

다음은 지금 제가 개발하는 환경인 P2P 구조를 살펴볼게요. Libplanet에서는 위에서 언급한 프로토콜을 IAction 인터페이스를 구현한 클래스(이하 액션)로 작성해요. 클라이언트에서 액션을 만들어 노드에 넘기면 노드는 액션들을 모아서 트랜잭션을 만들고, 트랜잭션들을 모아서 블록을 만들어요. 이 과정에서 각 액션의 Render(이하 렌더)와 Unrender(이하 언렌더) 이벤트가 발생하는데, 이를 통해서 클라이언트 측은 액션이 반영되었다거나 그 액션이 취소(롤백)되었다는 상태를 알 수 있는 구조이죠.

언뜻 비슷한 구조로 보일 수 있지만, 클라이언트–서버에서는 요청과 응답이 분리되어 있어서 응답이 요청의 정보(성패, 실패했다면 그 자세한 이유)는 물론 변경점까지 포함될 수 있는데, P2P에서는 요청(액션) 하나만 존재하고 요청(액션)의 정보(렌더 혹은 언렌더 여부, 이마저도 알려면 노드가 멈추지 않는 조건을 만족해야 함)만 알 수 있다는 차이가 있어요. 대신에 각 액션의 렌더, 언렌더 단계에서는 해당 액션의 전후 상태를 알 수 있도록 인터페이스를 제공하고 있어요.

위의 내용들은 제게 한 가지 고민을 만들어 주었어요.

변경점은 어떻게 알 수 있을까?

캐릭터 인벤토리에 아이템을 하나 추가해주는 액션을 수행했을 때, 인벤토리 전부를 다시 그리는 것은 피하고 싶었거든요.

1. 액션의 렌더 전후 상태값 비교하기

처음 생각한 방식은 액션의 렌더 전과 후의 상태 값을 비교해서 변경점을 뽑아 내는 방법이었어요. 하지만, 블록에 직렬화되어 있는 정보를 역직렬화와 캐스팅을 통해서 상태 값 A와 B로 만들어 내고, 그 둘을 비교하는 과정을 매 렌더·언렌더 단계에서 수행하는 것이 성능에 무리가 생길 것이라는 걱정이 생겼어요. 상태 값 A와 B는 이미 덩치가 컸고, 앞으로 더 커질 여지가 다분했기 때문이었죠.

2. 변경점을 각 액션에 포함 시키기

이 방법이면 기존 구조를 변경하지 않는 선에서 목표를 완수할 수 있겠다는 생각으로 액션들을 척척 작성해가고 있었어요. 모든 것이 원하는 대로 잘 돌아가는 줄로만 알고 있었던 어느 날, 이제까지의 테스트가 싱글 노드에서 진행되었고 멀티 노드 환경에서는 문제가 생길 거라는 것을 알게 되었죠. 그 이유는 다음과 같았어요.

네트워크에 참여하는 모든 노드가 특정 액션을 처리하는데, 이를 다른 노드가 처리해서 전파 받더라도 두 처리 결과가 동일할 수 있도록 보장하는 역할을 IAction.PlainValue 속성과 IAction.LoadPlainValue() 메서드가 해요. 싱글 노드에서는 얼렁뚱땅 되는 것 처럼 보였어도 멀티 노드에서 잘 되려면 액션의 다른 곳이 아니라 해당 속성에 포함시켜야 되는 것을 깨달은 것이죠. 그렇다고 진짜 포함시키면 (저는 진짜 그냥 단순히 포함시켜 봤어요) InvalidTxSignatureException 예외가 발생해요. 이것은 액션의 상태값이 바뀔 때 발생하는데, 당연한 것이 변경점은 액션을 만들 때는 비어있고 액션이 렌더된 이후에 채워지기 때문이죠. 이 과정에서 액션의 상태값은 바뀌지 않도록 작성한다는 깨달음을 얻었어요.

그렇다면 액션을 만들 때 예측 가능한 변경점을 포함시키면 되지 않을까 생각했어요. 이것은 클라이언트가 만들어준 값을 노드가 믿는 구조가 되기 때문에 해킹에 용이해서 3초 만에 머리 속에서 지웠죠. 하지만 그 예측 가능한 변경점을 액션 내에서 검증할 수 있다면 가능하겠다는 생각이 드네요!? 그래서 엔진팀에 문의해 봤더니 엔진에서 제공하는 IRandom 인터페이스가 완전히 공정하기 때문에 예측 가능한 변경점은 존재하지 않는다는 감동만이 남았네요. 감동.

3. 변경점을 각 액션이 변경시키는 대상의 상태 값(이하 대상 상태 값)에 포함 시키기

이 방법도 괜찮아 보였어요. 액션의 변경점을 대상 상태 값에 포함시키면 구조 수정 없이 확장 만으로 목표를 완수할 수 있지 않을까 하는 기대를 갖고 액션들을 척척 작성해 가고 있었어요. 대상 상태 값에는 각 액션의 변경점들이 쌓이기 시작했고, 클라이언트는 특정 액션의 렌더 단계에서 대상 상태 값에 쌓여 있는 변경점을 참조하는 방법을 사용했죠. 이미 한 번 계산한 변경점을 다시 계산할 필요가 없어졌고, 변경점의 참조 타이밍도 문맥상 안전해서 클라이언트 개발이 순조로웠어요. 하지만 문제는 생기기 마련이었죠.

대상 상태 값에 쌓이는 특정 액션에 대한 변경점의 생명 주기는 어떻게 관리할 것인가?

상태 값의 변경은 액션을 통해서만 이루어지는 특성상, 더 이상 필요가 없는 변경점을 제거하기 위해서는 별도의 액션으로 처리해야 했어요. A액션의 변경점을 쌓아두기 위해 대상 상태 값을 변경했는데, 이미 사용해서 쌓아둘 필요가 없는 변경점을 대상 상태 값에서 제거하기 위해 B액션을 사용할 때, 그 변경점은 다시 대상 상태 값에 담겨야 하는가? 네, 로직에 예외가 생기더라고요. 이 안은 보류했어요.


이 글을 작성하는 동안에도 Libplanet은 꾸준히 강력해졌어요. 액션의 실행 단계에서 확보되는 IActionContext형 인자는 액션의 상태값과 무관하게 모든 노드에서 같은 결과를 결정적으로 확보할 수 있도록 IRandom 인터페이스를 제공하고 있어요. 유니티에서 제공하는 랜덤 객체는 모든 노드에서 같은 결과를 얻을 수 없지만, IRandom 인터페이스는 이를 보장해주고 있어요. 다시 2번의 접근이 가능하겠다는 각이 보이죠?

다음 글에서는 IRandom 인터페이스를 파해치고, 아름다운 클라이언트 환경을 어떻게 만들어 내는지 이야기해 볼게요.

플라네타리움은 게임에 특화된 오픈 소스 P2P 라이브러리 Libplanet과, 그 위에서 중앙 서버 없는 온라인 게임 나인 크로니클을 만들고 있습니다. 저희와 흥미로운 기술적 도전을 함께 하실 분들을 모시고 있습니다. 지금 채용 페이지를 확인해주세요!