지난 글에서는 서버-클라이언트 구조를 어떻게 가져갈지 고민한 기록을 남겼다. 이번 글에서는 그 설계를 실제 구현으로 옮기기 위한 첫 단계를 다룬다. 팀의 작업을 어떻게 나눴는지, 그리고 그중 내가 맡은 계약 확정, 서버용 콘텐츠 내보내기 도구, 동기화 엔드포인트와 적용 엔진을 정리한다.
서버 관련 작업을 맡게된 인원은 나를 포함한 3명이다. 역할은 다음과 같이 나눴다.
| 담당 | 역할 | 영역 |
|---|---|---|
| 리드 (나) | 데이터 구조와 계약의 설계·결정, 판단이 필요한 핵심 로직 | 서버 + 클라이언트 |
| 서버 | 명세대로 서버 구현 | 서버 |
| 클라이언트 | 명세대로 클라이언트 구현 | 클라이언트 |
서버와 클라이언트 역할을 분리한 이유는 각 팀원이 비교적 명확히 서버나 클라이언트 중 한 쪽 작업을 더 희망했고, 작업 영역이 충돌하지 않는 편이 병렬 개발에 유리하기 때문이다.
클라이언트 개발 때는 업무를 유연하게 정의하고 각자 판단하면서 진행할 수 있었다. 하지만 서버 개발은 모두 경험이 적어서 같은 방식으로 하기가 어렵다. "이 기능을 서버에서 어떻게 만들지 알아서 정하자"고 하면 판단에 시간이 오래 걸리고, 사람마다 다르게 해석한 결과가 나중에 맞지 않을 위험이 크다.
특히 클라이언트는 작업 영역을 기준으로 분배하면 거의 독립적인 기능을 구현하면 되기에 자율성에 맡길 수 있지만, 서버 작업의 경우 명세대로 데이터를 주고받는 것이 핵심이기 때문에 정확하게 업무를 정의하지 않으면 병합했을 때 동작하지 않을 가능성이 매우 높다.
그래서 작업 방식을 다음과 같이 정했다.
dev에 병합한다. 서버와 클라이언트 담당은 그 계약만 보고 구현하므로 서로를 기다리지 않는다.작업 하나하나는 목표 / 참조 / 산출물 / 완료 조건 / 선행 항목으로 적어서, "무엇이 끝나면 완료인지"를 구현자가 해석할 필요가 없게 했다.
계약을 확정하고, 판단이 많이 필요한 핵심 로직을 맡았다. 이번 글에서는 다음 세 가지를 다룬다.
계약 문서(docs/server-sync-contracts.md)만 쓰고 끝내지 않았다.
| 장 | 정한 것 |
|---|---|
| 1. 콘텐츠 | 서버가 가질 콘텐츠의 범위와 파일 구조, 콘텐츠 버전 계산법, 서버의 전투 규칙 인터페이스 |
| 2. 클라이언트 인증 경계 | 로그인 흐름(백그라운드, 타임아웃), 서버가 거부한 것과 서버에 닿지 않은 것의 구분 |
| 3. 로그·동기화 JSON | 필드 단위의 로그 형식, 거부 코드 전체 목록, 요청 한도 |
| 4. 서버 스키마와 처리기 | DB 마이그레이션, 엔진과 처리기의 책임 분리와 인터페이스 |
| 5. 클라이언트 로그·저장 경계 | 로그 생성과 저장, 전송, 응답 반영의 흐름 |
세부 구현까지 적지는 않고, 계약을 정하면서 어떤 판단을 했는지를 몇 가지만 남긴다.
오프라인 플레이를 허용하면서 서버가 사후에 검증하는 구조에서는, 서버도 노드와 전투의 규칙·수치를 알아야 한다. 그런데 이 콘텐츠의 원본은 클라이언트에 있다.
그래서 "원본은 클라이언트 하나, 서버는 내보낸 사본" 원칙을 정했다. 서버 저장소에서 콘텐츠 파일을 손으로 고치지 않고, 클라이언트의 에디터 도구가 서버 형식으로 내보낸 파일만 그대로 커밋한다.
서버는 검증에 필요한 값만 가진다. 검증에 쓰지 않는 값은 내보내지 않는다.
| 서버에 보유해야 함 | 보유하지 않음 |
|---|---|
| 노드, 노드 비용, 노드 효과, 배치(시작 노드, 연결) | 적 HP, 이동 속도, 반지름, 배치·출현 띠 |
| 성장 단계별 레벨 경험치 표, 마일스톤(레벨, 목표 골드) | 블랙홀 공격 수치(피해, 간격, 범위, 치명) |
| 전투 규칙: 기본 제한 시간, 처치 시 시간 보너스 | 황금 외 사망 효과(번개, 폭발, 레이저 등) |
| 적 종류: id, 수거형 여부, 색 등급별 골드·경험치, 변환 대상, 성질 목록 | 주기 출현(혜성)의 확률·간격 |
| 황금 성질의 기본 수치 | |
| 업그레이드 수치가 어떤 적의 어떤 값에 닿는지의 연결 | |
| 시작 공급 목록 |
내보낸 묶음은 서버 저장소의 src/main/resources/content/ 아래에 둔다.
content/
├─ manifest.json 목차와 콘텐츠 버전
├─ nodes/
│ ├─ UpgradeStats.csv 업그레이드 수치 정의
│ ├─ Nodes.csv 노드와 Rank 수
│ ├─ NodeCost.csv (노드, Rank)별 비용
│ ├─ NodeEffects.csv (노드, Rank)별 효과
│ └─ layout.json 노드 배치, 시작 노드, 연결
└─ battle/
└─ battle.json 서버 결산 검증에 필요한 전투 콘텐츠
| 서버 파일 | 클라이언트의 원본 | 변환 |
|---|---|---|
nodes/*.csv | 노드 시트 CSV 4개 | 서버가 읽는 열과 Memo만 남기고 열 이름을 영문으로 바꾼다. 열 안의 값은 그대로다 |
nodes/layout.json | 노드 카탈로그 에셋의 배치 | 노드 하나를 한 줄로 쓴다 |
battle/battle.json | 게임 콘텐츠 세트 에셋이 가리키는 전투 데이터 | 위 표의 "보유한다" 항목만 추려 JSON으로 쓴다 |
battle.json은 서버의 결산 검증이 쓰는 값들로 구성된다. 몇 가지 대표 필드는 다음과 같다.
rules: 기본 제한 시간, 처치 시간 보너스growth: 레벨별 누적 경험치(levelExp), 마일스톤(레벨, 목표 골드)enemies[]: 종류별 id, 수거형 여부, 변환 대상, 색 등급별 골드·경험치, 성질 목록, 그리고 업그레이드 수치와의 연결(stats)startSupply[]: 시작 공급manifest.json에 목차와 콘텐츠 버전을 적는다. 버전은 목차 순서대로 각 파일의 경로 + 바이트를 이어 붙여 SHA-256을 구하고 앞 16자를 쓴 값이다.Unity 에디터 메뉴 BlackHole > Server Content > Export...로 실행한다.
content/)에 쓴다. 목차는 마지막에 쓴다.Log Content Version)도 있다.작업 흐름은 이렇게 된다.
클라이언트에서 콘텐츠 편집 → 에디터 메뉴로 내보내기 → 서버 저장소에서 바뀐 파일 커밋
→ 서버 시작 시 버전 재계산·검증 → 클라이언트와 서버가 같은 콘텐츠·같은 버전을 쓴다
이번 글에서 가장 중심이 되는 부분이다. 3편에서 정한 "로그를 순서대로 적용하며 검증하는 서버"를 실제로 구현한 POST /api/v1/me/sync와 그 안의 적용 엔진이다.
logs). 빈 배열도 허용한다. (로그 없이 진행도만 받고 싶을 때){
"appliedThroughSeq": 11,
"rejected": { "seq": 12, "type": "SETTLEMENT", "code": "SETTLEMENT_EXP_MISMATCH", "message": "..." },
"contentVersion": "a1b2c3d4e5f60718",
"progress": { "lastAppliedSeq": 11, "gold": 0, "growthStage": 0, "nodes": [ ... ] }
}
appliedThroughSeq: 이 번호까지의 로그는 모두 반영되었다.rejected: 거부된 로그와 사유 코드. 없으면 null이다.progress로 덮어쓴다.여기서 말하는 엔진은, 로그의 종류와 상관없이 항상 똑같이 하는 일을 맡는 부분을 가리킨다.
seq 순서대로 꺼내고, 이미 반영된 로그는 건너뛰고, 형식과 콘텐츠 버전을 확인하고, 거부가 나오면 멈추고, 결과를 기록하고, 서버 진행도를 응답한다.LogHandler) 가 맡는다. 엔진은 처리기를 인터페이스로만 알고, 로그 종류에 맞는 처리기를 찾아 일을 넘긴다.| 엔진 | 처리기 | |
|---|---|---|
| 아는 것 | 순서, 멱등성, 거부 후 멈춤, 트랜잭션, 기록 | 로그 종류별 게임 규칙 |
| 모르는 것 | 각 로그의 의미 | HTTP, 저장소, 트랜잭션 |
| 코드 | sync/controller, sync/service 등 | sync/handler의 NodePurchaseHandler, SettlementHandler 등 |
게임 엔진이나 룰 엔진처럼, 정해진 절차를 돌리면서 그 안에 끼워 넣은 구체적인 부품을 실행해 주는 틀을 흔히 엔진이라고 부르는 관례를 따랐다. 실제 구현은 @Transactional이 붙은 서비스 메서드(SyncService.sync)와 보조 클래스 몇 개이다. 이 구분 덕분에 새로운 로그 종류가 생겨도 엔진은 고치지 않고 처리기만 추가하면 된다.
요청 하나를 하나의 트랜잭션으로 처리한다.
1. 요청 본문을 읽는다 (요청 전체의 오류는 여기서 가린다)
2. 계정의 진행도 행을 잠근다
3. 로그를 seq 순서대로 하나씩 처리한다
- 이미 반영된 seq → 건너뛴다 (SKIPPED)
- 앞 로그가 비어 있음 → 거부 (SEQ_GAP)
- 형식 위반 → 거부 (MALFORMED_LOG)
- 콘텐츠 버전 불일치 → 거부 (CONTENT_VERSION_MISMATCH)
- 타입별 처리기가 규칙을 검사 → 통과하면 진행도를 바꾼다 / 아니면 거부
- 거부가 나오면 그 로그에서 멈춘다. 남은 로그는 처리하지 않는다 (NOT_PROCESSED)
4. 받은 로그를 결과와 함께 기록한다 (sync_log)
5. 서버 진행도를 응답한다
한 로그의 검사 순서는 seq → 형식 → 콘텐츠 버전 → 타입별 규칙이다. 이미 반영된 로그는 형식이나 버전이 어긋나도 건너뛴다.
요청이 잘못되었을 때 어디까지를 "요청 전체의 오류"로 볼지가 중요한 설계 지점이었다.
| 상황 | 결과 |
|---|---|
본문이 JSON이 아님, logs가 없음, seq를 읽을 수 없음, 요청 안에서 seq가 1씩 증가하지 않음 | 400 (아무것도 적용하지 않음) |
| 로그가 500개 초과, 본문이 1MB 초과 | 413 |
로그의 seq는 읽히지만 나머지가 형식에 어긋남 | 그 로그만 MALFORMED_LOG로 거부하고 멈춘다. 앞의 로그는 반영된다 |
보통은 Spring 컨트롤러에서 요청 본문을 DTO로 받아 @Valid로 검증한다. 하지만 이 API에서는 그렇게 하지 않았다.
@Valid로 검증하면 로그 하나만 잘못되어도 요청 전체가 400이 된다.형식 검사 자체는 Bean Validation(필수, 범위, 길이, UUID 형식)을 쓰되, 컨트롤러 대신 서비스 안에서 로그 하나씩 실행한다. 여기에 중복 항목과 실수의 유한성(NaN, 무한대 방지)을 따로 본다.
seq는 계정 단위로 1부터 1씩 증가한다. 서버는 마지막으로 반영한 seq(lastAppliedSeq)를 진행도에 가지고 있다.seq ≤ lastAppliedSeq: 이미 반영된 로그다. 응답이 유실되어 클라이언트가 같은 로그를 다시 보낸 경우이므로 건너뛴다. 두 번 적용하지 않는다.seq > lastAppliedSeq + 1: 앞 로그가 빠졌다. SEQ_GAP으로 거부한다.이 규칙 덕분에 네트워크가 끊기거나 응답을 못 받아도 같은 요청을 안전하게 다시 보낼 수 있다.
NOT_PROCESSED로 기록만 한다. 서버 진행도가 항상 이기므로, 클라이언트는 응답의 진행도를 채택한다.SELECT ... FOR UPDATE)으로 조회한다.엔진은 순서, 형식, 버전, 기록, 응답을 맡고, 타입별 규칙 검사와 진행도 변경은 처리기(LogHandler)가 맡는다. 둘의 경계가 인터페이스이다.
public interface LogHandler<P> {
LogType type();
Class<P> payloadType();
/** 규칙을 검사하고, 통과하면 context로 진행도를 바꾼다. 거부하면 아무것도 바꾸지 않는다. */
HandleResult handle(P payload, SyncContext context);
}
SyncContext(진행도를 읽고 바꾸는 창구)와 콘텐츠 데이터만 쓴다. 그래서 처리기는 DB 없이도 단위 테스트할 수 있다.SyncContext의 변경 메서드(spendGold, earnGold, setNodeRank, resetProgress, openRun 등)는 검사를 모두 마친 뒤에만 부른다. 거부할 때는 아무것도 바꾸지 않는다. 한 로그를 처리하다 중간에서 일부만 반영되는 일이 없게 하기 위해서다.SyncContext의 구현이 변경 횟수를 세고, 처리기가 거부하면서 진행도를 바꾼 경우를 엔진이 발견하면 버그로 보고 예외를 던져 요청 전체를 롤백한다. 처리기를 구현하는 사람이 실수해도 진행도가 어긋난 채로 저장되지 않게 하는 안전장치이다.sync_log)받은 로그는 처리 결과와 함께 sync_log 테이블에 남긴다.
| 결과 | 의미 |
|---|---|
APPLIED | 반영됨 |
REJECTED | 거부됨 (거부 코드 포함) |
SKIPPED | 이미 반영된 로그라 건너뜀 |
NOT_PROCESSED | 앞 로그가 거부되어 처리하지 않음 |
seq가 여러 번 들어올 수 있으므로(재전송, 거부 뒤 재사용) seq에는 유니크 제약을 걸지 않았다. 적용 여부의 기준은 잠금 아래의 lastAppliedSeq이다.이 글을 쓰는 시점의 dev 기준이다.
구현됨
last_applied_seq, 판(battle_run), 받은 로그(sync_log)MALFORMED_LOG)POST /api/v1/me/syncSEQ_GAP, 형식 오류(필드 타입, 알 수 없는 타입, 잘못된 runId, 중복 처치 칸), 콘텐츠 버전 불일치, 처리기가 거부하면서 진행도를 바꾸면 전체 롤백, 판 시작 값의 불일치는 의심 표시만 남기고 거부하지 않음, 다음 판 시작이 열린 판을 포기로 닫음, 400과 413, 인증 필요아직 구현되지 않음
| 작업 | 핵심 |
|---|---|
| 작업 분배 | 서버 경험이 적은 팀이라 계약을 먼저 확정하고, 각자 확정된 명세 안에서 병목 없이 구현하게 했다 |
| 계약 확정 | 문서뿐 아니라 컴파일되는 뼈대 코드와 양쪽 저장소의 같은 JSON 샘플로 경계를 고정했다. 변경은 리드만 한다 |
| 콘텐츠 내보내기 | 원본은 클라이언트 하나. 서버는 에디터 도구가 내보낸 사본만 쓰고, 파일 바이트의 해시를 버전으로 양쪽이 맞춘다 |
| 동기화 엔진 | seq 순차 적용, 멱등 재전송, 거부 직전까지 커밋, 계정 단위 잠금, 엔진과 처리기의 분리 |