이전 글들에서는 프로젝트의 토대와 인증을 다뤘다. 이번 글에서는 구현보다 한 단계 앞선 고민, 즉 이 게임에서 서버와 클라이언트가 어떻게 동작하는 것이 최선인가를 정리한다. 처음 생각했던 구조와, 그것을 바꾸게 된 과정, 그리고 최종 결정까지의 기록이다.
1. 출발점: 온라인 필수, 서버가 정본
서버-클라이언트 구조의 게임이니, 처음에는 다음과 같이 생각했다.
- 온라인 필수: 언제나 로그인한 뒤에야 게임을 할 수 있다.
- 모든 데이터의 정본은 서버: 진행도, 노드 구매 같은 경제 요소의 최종 결정은 서버가 한다.
- 클라이언트에는 최대한 데이터를 가린다: 지금 순간 게임에 필요한 정보만 받는다.
- 서버는 업그레이드 노드 트리 전체를 가지고, 클라이언트는 본인이 해금한 노드와 그에 인접한(구매 가능한) 노드만 안다.
- 서버는 플레이어의 진행도에 따라 전투 환경을 구성하는 규칙을 알고, 클라이언트는 그렇게 조립된 판을 받기만 한다. 클라이언트는 스스로 전투 환경을 조립할 정보가 없다.
이 구상은 API 명세 초안에도 그대로 반영했다.
- 노드 목록 조회(
GET /me/nodes)는 드러난 노드만 내려주고, 숨겨진 노드는 존재 여부조차 알리지 않는다. 없는 노드와 아직 드러나지 않은 노드는 같은 응답(404)으로 처리했다.
- 노드 구매는 서버 요청으로만 일어난다. 서버가 비용과 선행 조건을 검사하고 골드를 차감한다.
- 전투를 시작하면(
POST /battles) 서버가 노드 Rank로 계산한 완성된 스탯 표와 시드를 내려주고, 클라이언트는 노드 규칙을 모른 채 그 값으로 전투를 조립한다.
- 서버 권한은 단계적으로 넓힌다. 진행 데이터 저장·불러오기, 노드 구매의 서버 처리, 전투 결과 제출·검증 순이다.
여기서 전투 자체(실시간 시뮬레이션)는 클라이언트가 실행하되, 결과의 인정 여부는 서버가 판단하는 구조였다.
2. 방향 전환: 오프라인 플레이 허용
개발을 진행하면서 점차 생각이 바뀌어, 오프라인 플레이를 허용하는 구조로 방향을 바꾸었다. 이유는 두 가지이다.
1. 싱글 플레이 게임에 온라인을 강제하는 비용
- 이 게임은 싱글 플레이 인크리멘탈 게임이다. 서버를 두는 목적은 진행도 보호와 (후보로 있는) 랭킹 정도이지, 모든 상호작용에 서버가 필요해서가 아니다.
- 로그인을 강제하면 네트워크 상태가 곧 플레이 가능 여부가 된다. 싱글 플레이 게임에서는 사용성 비용이 과하다.
- 로컬 진행도와 행위 로그를 두고 서버가 사후에 검증하는 방식은, 서버를 두면서 오프라인도 허용하는 싱글 플레이 게임의 보편적인 절충이다.
2. 개발 순서: 클라이언트가 먼저 만들어졌다
- 서버가 유일한 처리 주체이려면, 이미 클라이언트에 구현된 게임 로직을 걷어내고 서버에서 다시 작성한 뒤, 클라이언트는 서버 응답을 그리는 구조로 다시 바꿔야 한다. 같은 기능을 두 번 만들고 클라이언트 구조를 두 번 바꾸는 셈이다.
- 이런 방식은 처음부터 서버와 함께 개발할 때 효과적이다. 이 프로젝트는 클라이언트가 먼저 개발된 상태이므로 "클라이언트를 먼저 만들고, 서버가 역할을 더하는" 흐름에 맞는 구조가 자연스럽다.
- 이걸 고려하고 클라이언트를 먼저 단독으로 구현한 것은 아니다. 다만, 이미 클라이언트 레벨에서 모든 게임 플레이 구조가 구현된 상황에서, 방향성을 정한다면 서버에 권위를 두고 클라이언트를 덜어내는 것보다 현재의 클라이언트에 온라인 구조를 더하는 방향이 더 자연스럽다고 판단한 것 뿐이다.
| 서버 단독 처리 | 로그 제출 + 서버 검증 |
|---|
| 클라이언트 | 기존 로직을 제거하고 서버 응답을 그리는 구조로 재작성 | 기존 로직 유지. 행위 로그를 기록해 보내는 기능만 추가 |
| 서버 | 게임 로직 전체를 다시 구현 | 로직 전체가 아니라 검증에 필요한 규칙만 구현 |
| 클라이언트-서버 계약 | 요청/응답 API 전반 | 로그 형식 |
| 조정 여지 | 낮음. 전부 구현해야 동작한다 | 높음. 검증 수위에 따라 서버 부담을 조절할 수 있다 |
바뀐 역할 분담
결론적으로 서버와 클라이언트의 역할을 다음과 같이 다시 정했다.
| 이전 | 이후 |
|---|
| 서버 | 유일한 처리 주체. 게임 로직을 실행하고 결과를 내려준다 | 정본을 보관하고 검증하는 역할 |
| 클라이언트 | 서버가 가공한 결과를 받아 그린다 | 게임을 실행하고 행위를 기록하는 역할. 진행도의 사본을 로컬에 가진다 |
| 진행도 | 서버에만 있다 | 서버에 정본, 클라이언트에 사본 |
동작의 핵심 원칙은 다음과 같다.
- 로그가 원본이고, 진행도는 파생값이다. 클라이언트가 보낸 진행도를 믿지 않고, 서버가 자신의 상태에 로그를 적용해 얻은 결과를 쓴다.
- 서버가 계산한 진행도가 항상 이긴다. 클라이언트가 보낸 결산 골드 같은 값은 검증 대상일 뿐이고, 정산에는 서버가 계산한 값을 쓴다.
- 서버는 조작을 불가능하게 만드는 것이 아니라 조작의 비용을 올리고 비현실적인 값을 걸러내는 것을 목표로 한다. 로그도 결국 클라이언트가 만들기 때문에, 규칙을 아는 사람이 규칙에 맞는 가짜 로그를 만드는 것은 막을 수 없다. 이를 전제로 설계했다.
포기해야 하는 것
- "클라이언트에서 데이터를 가린다"는 목표를 버렸다. 오프라인에서 플레이하려면 클라이언트도 노드의 비용, 효과, 해금 조건을 알아야 한다. 데이터는 더 이상 비밀이 아니다.
- 같은 규칙이 두 곳에 있게 된다. 서버는 Java, 클라이언트는 C#이라 코드를 공유할 수 없다. 두 구현이 같은 결과를 내도록 해야 하고, 어긋나면 정상 사용자의 진행도가 되돌려지는 오탐이 생긴다. 이 오탐이 이 구조의 가장 큰 운영 위험이다.
- 서버와 클라이언트의 콘텐츠 버전을 맞춰야 한다. 이 부분은 5절에서 다룬다.
3. 서버의 중요한 역할: 검증
싱글 플레이 게임에서 서버가 맡는 중요한 역할 하나는 검증이다. 클라이언트가 보낸 기록이 "합리적인가"를 서버가 판단하지 않으면, 로컬에서 골드를 마음대로 늘려 올려도 서버가 그대로 받아들이게 된다. 그래서 검증 수위를 세 단계로 나누어 고려했다.
| 수위 | 클라이언트가 기록해 보내는 것 | 서버가 하는 검증 |
|---|
| 1 | 노드 구매, 전투 결산 등 주요 이벤트 로그만 | 각 로그의 합리성을 평가한다 |
| 2 | 전투 중 발생하는 세세한 이벤트 로그까지 | 전투 규칙에 따라 전투의 유효성까지 검증한다 |
| 3 | 사용자 입력까지 | 클라이언트와 동일한 전투 로직을 서버에서 돌려 결정론적으로 재현한다 |
3단계를 택하지 않은 이유
- C# 클라이언트와 Java 서버라서, 클라이언트의 전투 로직을 서버에서 똑같이 돌리려면 전투 코드를 통째로 Java로 옮기고, 두 구현의 결과가 일치하도록 계속 유지해야 한다. 게다가 전투는 프레임마다 다른
deltaTime과 부동소수점 계산, 조준 입력의 순서에 따라 결과가 달라지므로, 시드만으로는 결과가 재현되지 않는다.
- 이중 시뮬레이션은 비효율적이다. 클라이언트가 한 번 돌린 전투를 서버가 또 돌린다.
- 이중 시뮬레이션이 싫다면 서버가 세션을 열어 전투를 계산하고 클라이언트는 그리기만 하는 방식이 그나마 낫다.
- 하지만 이 역시 서버에 기껏 구현한 전투 로직을 다시 폐기해야 한다.
- 그리고 자바 서버와 Unity 클라이언트의 조합에서는 이것도 적절하지 않다고 보았다. 게임 엔진이 주는 많은 편의를 버려야 하기 때문이다.
- 무엇보다 작업량이 너무 많아진다. 거의 서버에 게임을 다시 구현하는 수준이다.
2단계가 부담스러웠던 이유
처음에는 오프라인 플레이를 허용하면서 로그를 전투 중 이벤트 단위(예를 들어, 매 타격이 언제 일어났고, 누구를 공격했고, 얼마의 피해를 주었는지 등)까지 세세하게 기록하는 방향으로 생각했다. 그런데 이것도 남은 기간으로는 쉽지 않다고 판단했다.
수위 1에서 2로 가는 것은 단순히 로그의 개수가 늘어나는 문제가 아니다. 로그를 검증하려면 서버가 알아야 할 것이 훨씬 많아진다. 설계 문서에 정리하면서 확인한 예는 다음과 같다.
- 적이 어떤 규칙으로 언제 생성되는가: 시작 공급, 레벨업마다의 성장 공급, 죽을 때의 확률적 재생성, 주기적으로 나타나는 오브젝트
- 버프 스택이 쌓이고 만료되는 구간(스택마다 독립적인 지속 시간) 때문에 공격 속도와 피해가 틱마다 달라지는 것
- 사망 시의 연쇄 번개, 레이저, 폭발 같은 효과와 그 확률, 치명타의 판정
- 프레임마다 다른 시간 단위(Step)를 고려한 시각 기록과 허용 오차
- 확률 효과를 정확히 재현하려면 클라이언트의 난수 알고리즘까지 서버에 옮기는 것
게다가 위치를 검증하지 않기로 하면, 범위 공격이 한 번에 몇 마리를 맞혔는지에는 이론적인 상한이 없다. 그러니 이벤트까지 다 받아도 완벽한 검증이 되지는 않는다. 비용은 큰데 얻는 것은 한정적이었다.
4. 최종 결정: 네 가지 로그와 일관성 검증
결국 수위 1로 정했다. 클라이언트는 다음 네 가지 경우만 로그로 기록해 서버에 보낸다.
| 로그 | 발생 시점 | 서버가 하는 일 |
|---|
NODE_PURCHASE 노드 구매 | 노드를 샀을 때 | 노드 존재, 최대 Rank, 노출(그래프 인접), 골드, 비용을 구매 규칙 그대로 정확히 검증한다 |
RUN_START 전투 시작 | 전투(판)를 시작할 때 | 진행도에 영향은 없다. 서버가 계산한 시작 상태와 비교하고, 다르면 의심 표시만 남기고 계속 진행한다 |
SETTLEMENT 전투 결산 | 전투가 끝났을 때 | 진행도를 바꾸는 핵심 로그. 값들의 일관성을 검증한다 |
RESET 새 게임 시작 | 새 게임을 시작할 때 | 별도 검증 없이 진행도를 초기화한다 |
로그에는 계정 단위로 1씩 증가하는 순번(seq)과 종류, 시각, 콘텐츠 버전, 내용이 담긴다.
{
"seq": 12,
"type": "SETTLEMENT",
"occurredAtUtc": "2026-10-12T10:00:00Z",
"contentVersion": "a1b2c3d4e5f60718",
"payload": { }
}
서버가 로그를 적용하는 방식
- 서버는 DB의 진행도를 기준으로 로그를
seq 순서대로 하나씩 적용한다.
- 잘못된 로그를 만나면 그 직전 로그까지만 반영하고 멈춘다. 그 이후 로그는 적용하지 않는다.
- 어떤 경우든 응답으로 서버의 현재 진행도를 돌려준다. 클라이언트는 로컬 진행도를 이것으로 덮어쓰고, 거부된 로그와 그 이후 로그는 폐기하고, 사용자에게 텍스트로 알린다. (서버 우선)
- 이미 반영된
seq가 다시 오면(응답 유실 뒤 재전송) 건너뛰고, 빠진 seq가 있으면 거부한다. 같은 로그를 두 번 적용하는 일이 없도록 하기 위해서다.
- 온라인이면 로그를 즉시 전송하고, 오프라인이면 쌓아 두었다가 연결될 때 한 번에 전송한다. 전송 API는 하나(
POST /me/sync)로 통일해서 온라인과 오프라인을 같은 방식으로 처리한다.
전투 결산의 검증: 값들이 서로 일관적인가
수위 1에서는 전투 중에 무슨 일이 있었는지 알 수 없다. 그래서 처치 수 자체에 상한을 두지 않고, 결산에 담긴 값들이 서로 맞아떨어지는지 확인한다. 클라이언트는 처치한 적을 (종류, 색 등급, 크기, 황금 여부) 칸별로 센 장부를 함께 보낸다.
| 검사 | 내용 | 방식 |
|---|
| 판 확인 | 열린 판이 있고 판 식별자가 일치 | 정확 |
| 처치 칸의 유효성 | 이번 판에서 나올 수 있는 종류·등급·크기의 조합인가 | 정확 |
| 경험치 | 시작 경험치 + Σ(칸 처치 수 × 칸 경험치)와 같은가 | 정확 |
| 레벨과 마일스톤 | 경험치와 레벨 표로 계산한 값과 같은가 | 정확 |
| 획득 골드 | Σ(칸 처치 수 × 칸 골드) + 치명 보너스와 같은가 | 정확 |
| 치명, 시간 추가 횟수 | 처치 수와 가능한 범위 안인가 | 범위 |
| 플레이 시간 | 제한 시간 + 레벨업에 따른 연장 + 시간 추가 보너스 이내인가 | 범위 |
| 정산 골드, 성장 단계 | 서버가 다시 계산해 일치하는가. 통과하면 서버가 계산한 값을 적용 | 정확 |
골드를 부풀리려면 처치 수를 늘려야 하고, 그러면 경험치와 레벨이 함께 올라가고, 플레이 시간도 그에 맞아야 한다. 값들이 서로 묶여 있으므로 골드 하나만 올리는 단순한 조작은 통과하지 못한다.
조작 가능성
이 방식은 한계가 분명한 약한 검증이다.
- 처치 수를 부풀리고 그에 딱 맞는 경험치와 골드, 시간을 갖춘 로그를 만들어 보내면, 서버는 속는다. 획득 자원의 상한을 잡는 것은 이 방식으로는 어렵다.
- 처치 수에 상한을 두는 안도 검토했다. 처치한 수는 생성된 적의 수를 넘을 수 없으니, 생성된 적의 수를 상한(공급 상한)으로 쓰자는 것이다.
- 문제는 그 상한이 되는 "생성된 적의 수"부터 정확히 잡을 수 없다는 점이다. 이 게임에서는 적을 처치하면 확률적으로 같은 종류의 적이 다시 생성된다. 생성 수가 처치 수에 영향을 받는 순환 구조라서 확정된 값이 없다.
- 시작 공급과 레벨업마다의 성장 공급으로 기본 수량은 계산할 수 있지만, 재생성은 확률이라 통계적인 여유를 따로 두어야 한다. 여유를 좁게 잡으면 운이 좋은 정상 플레이가 거절되고, 넓게 잡으면 상한의 의미가 줄어든다.
- 재생성 확률이 높아질수록 상한도 함께 커지고, 확률이 1에 가까워지면 상한은 사실상 무한이 된다.
- 게다가 이 상한을 계산하려면 시작·성장 공급, 재생성 확률, 색 분포 같은 적 구성 규칙을 서버에 더 옮겨야 한다.
- 정상 플레이를 거절하는 오탐의 위험은 크고 얻는 보호는 한정적이라, 최종 계획에서는 처치 수 자체의 상한은 두지 않기로 했다.
- 그래도 검증이 없는 경우와는 다르다. 결산에서 "골드만 무한대로 펌핑하는" 식의 단순한 조작은 통하지 않는다.
이 정도의 조작 가능성을 용인하기로 한 근거는 다음과 같다.
- 이 게임에서 획득한 자원은 오직 인게임 노드 업그레이드에만 쓰인다.
- 아웃게임 요소나 다른 플레이어와의 상호작용, 실제 현금이 관여하지 않는다.
- 따라서 조작을 시도하더라도 그 부작용이 심각하지 않다고 판단했다.
즉 "어디까지 막을 것인가"를 조작의 피해가 얼마나 큰가와 막는 데 드는 비용이 얼마인가를 저울질해서 정했다.
기간을 고려하여 구현을 보류한 것
- 전투 중 이벤트 단위 검증 (수위 2)
- 한 계정을 한 기기에서만 로그인하도록 강제하는 것. 오프라인에서는 어차피 강제할 수 없고, 게스트 계정은 자격이 기기에 묶여 있어 같은 계정이 두 기기에 있기가 어렵다.
- 서버에 최근 여러 버전의 콘텐츠를 보관해서, 옛 버전에서 쌓은 로그도 하위 호환으로 받는 것
5. 오프라인 구조에서의 동작과 데이터의 일치
로그인과 오프라인
- 게임을 실행하면 백그라운드에서 게스트 로그인을 한다. 정해진 시간 안에 실패하면 오프라인 모드로 실행한다.
- 첫 실행부터 오프라인이면 계정 없이 플레이한다. 처음 연결되는 시점에 게스트로 등록하고, 그때까지 쌓인 로그를 모두 전송한다.
- 오프라인으로 쌓은 로그는 앱을 시작할 때 연결되면 전송한다. (연결을 다시 시도하는 시점을 넓히는 것은 후속으로 미뤘다.)
- 전투 도중에 앱이 종료되면, 서버는 다음
RUN_START나 RESET을 받을 때 열린 판을 포기로 닫는다. 보상은 없다.
콘텐츠 버전
양쪽이 같은 규칙으로 계산해야 검증이 맞으므로, 노드와 전투 데이터가 서로 일치해야 한다.
- 콘텐츠의 원본은 클라이언트 저장소에 둔다. 밸런스를 조정하는 작업이 Unity 에디터에서 이루어지기 때문이다. 서버는 버전마다 클라이언트에서 내보낸 데이터를 받아 둔다.
- 내보낸 묶음의 해시를 콘텐츠 버전으로 쓰고, 모든 로그에 그 버전을 싣는다.
- 서버와 버전이 다른 로그는 거부한다. 그래서 서버를 먼저 배포하고 클라이언트를 나중에 배포해야 한다. 오프라인으로 쌓은 로그가 클라이언트 업데이트 이후에 올라오면 반영되지 못하는 한계도 있다.
- C#으로 작성된 규칙을 Java로 옮겼으므로, 클라이언트 코드로 만든 기대값 표를 서버 테스트가 같은 결과로 내는지 검증해 일치를 유지한다.
6. 앞으로의 확장: 랭킹이나 결제가 필요해진다면
현재 계획에는 없지만, 랭킹 시스템이나 현금 결제 같은 것을 도입해야 한다면 어떻게 될까. (이 부분은 튜터님의 피드백 질문에 답하며 정리한 내용이다.)
- 이런 요청은 게임 도중에 일어나지 않는다. 게임이 진행되는 동안 데이터가 실시간으로 변하는 상황이 아니라, 데이터가 변하지 않는 정적인 상황에서 발생한다.
- 그래서 오프라인 모드에서 이런 기능을 시도하면, 온라인 연결이 필요하다는 것을 안내하고 온라인 연결, 즉 로그인을 다시 시도하도록 구현하면 된다.
- 랭킹은 클라이언트가 점수를 주장하는 방식이 아니라, 일반 진행도 동기화를 마쳐 서버 정본이 최신임을 확인한 뒤 검증된 서버 상태에서 서버가 직접 계산하는 방식이 안전하다. 검증되지 않은 진행도로 만든 기록이 랭킹에 들어가지 않게 하기 위해서이다.
오프라인 허용과 온라인이 필요한 기능은 충돌하지 않는다. 필요한 순간에만 온라인을 요구하면 된다.
7. 정리
| 항목 | 처음 생각 | 최종 선택 | 이유 |
|---|
| 온라인 | 필수 | 오프라인 플레이 허용 | 싱글 플레이에 온라인 강제의 비용이 크고, 클라이언트가 먼저 개발되어 있다 |
| 데이터 정본 | 서버 | 서버 (동일) | 진행도의 최종 결정은 서버가 한다 |
| 클라이언트 데이터 | 최대한 가림 | 규칙도 클라이언트가 가짐 | 오프라인 플레이를 위해 규칙이 필요하다 |
| 게임 로직 | 서버가 처리하고 결과만 내려줌 | 클라이언트가 실행, 서버는 사후 검증 | 로직을 두 번 만들지 않는다 |
| 검증 수위 | 서버가 전투 결과를 평가 | 수위 1: 4종 로그의 일관성 검증 | 수위 2는 서버가 알아야 할 것이 많고, 수위 3은 이중 시뮬레이션이다 |
| 조작 가능성 | 최대한 차단 | 일부 용인 | 획득 자원이 인게임 업그레이드에만 쓰여 피해가 작다 |
| 랭킹·결제 | (범위 밖) | 온라인 연결이 필요할 때 로그인 재시도 | 게임 도중이 아닌 정적인 상황에 일어나는 요청이다 |
- 처음 구상은 "서버가 모든 것을 알고 클라이언트는 그리기만 한다"였고, 최종 구조는 "클라이언트가 게임을 실행하고, 서버는 정본을 보관하며 사후에 검증한다"이다.
- 정답이 있는 문제가 아니라 요구되는 보호 수준과 작업 가능한 범위 사이의 균형을 찾는 문제였다. 이 게임에서는 조작의 피해가 작다는 점이 약한 검증을 택할 수 있게 해 주었다.