클라우드 세이브 충돌 QA: 로컬·원격 병합 규칙을 시나리오별로 검증하는 무결성 테스트
클라우드 세이브 충돌의 원인을 로컬·원격 리비전과 해시로 분류하고 자동 병합·사용자 선택·복구 규칙을 시나리오별 테스트와 검증 오라클로 점검하는 실무 QA 가이드입니다.

핵심 요약
클라우드 세이브 충돌은 updated_at만 비교하지 말고 content_revision, 부모 리비전, payload_hash를 기준으로 분류해야 합니다. 같은 데이터는 중복 제거하고 한쪽이 다른 쪽의 조상인 경우에는 자동 병합하며 분기된 세이브는 필드별 병합 정책과 무결성 검증을 통과한 경우에만 병합합니다. 통화·인벤토리처럼 손실이나 복제가 치명적인 데이터는 자동 병합보다 보류와 복구 지점을 우선해야 합니다.
클라우드 세이브 충돌은 왜 발생하는가?
클라우드 세이브 충돌은 두 기기가 같은 세이브 슬롯을 서로 다른 시점에 수정한 뒤 동기화할 때 발생합니다. 대표적인 원인은 다음과 같습니다.
- 오프라인 상태에서 PC와 콘솔이 각각 게임을 진행함
- 저장 직후 게임이 종료되거나 네트워크가 끊겨 업로드가 완료되지 않음
- 패치 전후의
schema_version이 다름 - 기기 시간이 어긋나
updated_at순서가 실제 저장 순서와 달라짐 - 클라우드 공급자가 재시도 과정에서 동일 업로드를 여러 번 전달함
- 다운로드한 파일 일부가 손상되었거나 중간 버전으로 되돌아감
따라서 최신 시간의 파일을 선택한다는 규칙은 충분하지 않습니다. QA는 먼저 충돌 유형을 판정하고 그 유형에 맞는 기대 결과를 정의해야 합니다.
세이브 충돌 판정에 필요한 메타데이터
세이브 본문과 함께 다음 메타데이터를 저장하면 충돌 재현과 자동 판정이 쉬워집니다.
| 필드 | 역할 | 테스트 기준 |
|---|---|---|
slot_id | 세이브 슬롯 식별자 | 다른 슬롯 데이터가 섞이지 않아야 함 |
save_id | 논리적으로 같은 세이브인지 식별 | 새 게임과 기존 세이브를 구분 |
schema_version | 데이터 구조 버전 | 마이그레이션 가능 여부 판정 |
content_revision | 저장 순서를 나타내는 단조 증가 리비전 | 동일 계보에서는 감소하면 안 됨 |
parent_revision | 현재 저장이 기반으로 삼은 리비전 | 조상·분기 관계 판정 |
device_id | 저장을 생성한 기기 식별 | 동일 리비전의 동률 처리 |
updated_at | 운영 로그용 실제 시각 | 충돌 우선순위의 단독 기준으로 사용하지 않음 |
payload_hash | 정규화된 본문의 무결성 확인 | 동일 데이터와 변조를 구분 |
본문 해시는 JSON 키 순서를 고정하고 숫자 표현과 null 처리 규칙을 정한 뒤 계산해야 합니다.
암호화는 기밀성을 제공하지만 데이터가 올바르게 전송되었다는 뜻은 아닙니다. 변조 탐지가 필요하다면 서버 검증용 HMAC 또는 서명까지 별도로 검토해야 합니다. 클라이언트가 가진 비밀키만으로 HMAC을 계산하면 치팅 방지 수단이 되기 어렵기 때문에 권위 있는 검증은 서버에서 수행해야 합니다.
로컬·원격 세이브 병합 규칙은 어떻게 정의할까?
먼저 충돌을 다음 네 가지 상태로 분류합니다.
| 상태 | 판정 | 기본 동작 |
|---|---|---|
| 동일 데이터 | content_revision과 payload_hash가 모두 같음 | 중복 제거 후 성공 처리 |
| 로컬이 원격의 조상 이후 | 로컬 리비전이 원격의 기반보다 앞서고 분기가 없음 | 로컬 업로드 또는 원격 갱신 |
| 원격이 로컬의 조상 이후 | 원격 리비전이 로컬의 기반보다 앞서고 분기가 없음 | 원격 다운로드 |
| 분기된 데이터 | 두 세이브가 같은 부모에서 서로 다른 변경을 가짐 | 필드별 병합 또는 사용자 선택 |
updated_at은 로그와 분석에 활용하되 최종 승자를 정하는 단독 규칙으로 사용하지 않는 것이 안전합니다. 기기 시계가 틀리거나 서버 재시도 순서가 바뀌면 오래된 데이터가 최신 데이터처럼 보일 수 있기 때문입니다.
필드별 병합 정책 예시
전체 파일을 한 번에 덮어쓰지 말고 데이터 성격에 따라 정책을 나눕니다.
| 데이터 | 권장 정책 | 주의점 |
|---|---|---|
| 체크포인트 번호 | 단조 증가 조건을 만족할 때 max | 이전 상태로 돌아가는 기능과 충돌할 수 있음 |
| 퀘스트 상태 | 상태 머신의 허용 전이만 적용 | 완료와 실패를 단순 문자열 비교로 합치지 않음 |
| 아이템 | 고유 item_instance_id 기준 집합 병합 | 같은 아이템 복제 여부를 서버에서 검증 |
| 재화·경험치 | 자동 합산·max 금지 | 트랜잭션 로그 또는 권위 서버 값 사용 |
| 설정·그래픽 옵션 | 명시적인 마지막 수정 규칙 | 타임스탬프와 기기 우선순위를 함께 기록 |
| 플레이 시간 | 파생 값으로 재계산 | 두 파일의 값을 단순 합산하지 않음 |
| 업적 | 서버에서 검증된 이벤트 집합으로 병합 | 클라이언트 플래그만 신뢰하지 않음 |
자동 병합 결과는 원본 두 개를 삭제한 뒤 저장해서는 안 됩니다. local_before_merge, remote_before_merge, merged_candidate를 모두 보존하는 복구 지점을 만들고 검증 실패 시 원본으로 되돌릴 수 있어야 합니다.
시나리오별 무결성 QA 테스트
각 테스트는 입력 세이브 두 개와 기대 결과인 검증 오라클을 함께 가져야 합니다. 검증 오라클은 단순히 앱이 실행되는지 확인하는 것이 아니라 리비전, 해시, 아이템 수량, 재화 잔액, 퀘스트 상태까지 확인해야 합니다.
| ID | 시나리오 | 기대 결과 | 무결성 오라클 |
|---|---|---|---|
| C-01 | 로컬과 원격이 동일한 파일 | 충돌 없이 중복 제거 | 병합 후 해시가 원본 해시와 같음 |
| C-02 | 로컬이 원격보다 최신인 선형 이력 | 로컬을 유지하고 업로드 | 리비전 증가, 원격 슬롯 갱신 |
| C-03 | 원격이 로컬보다 최신인 선형 이력 | 원격을 다운로드 | 로컬 로드 결과가 원격 해시와 일치 |
| C-04 | 같은 부모에서 서로 다른 설정을 변경 | 설정 정책에 따라 결정 | 선택된 필드만 변경되고 나머지는 보존 |
| C-05 | 두 기기에서 서로 다른 고유 아이템 획득 | 집합 병합 또는 보류 | item_instance_id 중복 없음 |
| C-06 | 두 기기에서 같은 재화를 변경 | 자동 합산하지 않음 | 잔액이 검증된 권위 값과 일치 |
| C-07 | 한쪽 세이브의 해시 불일치 | 손상 데이터 격리 | 손상 파일이 활성 슬롯으로 승격되지 않음 |
| C-08 | schema_version이 낮은 로컬 세이브 | 마이그레이션 후 비교 | 마이그레이션 전후 핵심 값 보존 |
| C-09 | 네트워크가 업로드 중 반복 단절 | 재시도는 멱등적으로 처리 | 동일 save_id와 리비전이 중복 생성되지 않음 |
| C-10 | 사용자가 충돌 화면에서 로컬을 선택 | 로컬 원본을 복구 | 원격 데이터가 삭제되지 않고 복구 가능 |
C-05: 아이템 집합 병합 검증
아이템을 단순 배열 인덱스로 병합하면 두 기기에서 생성된 아이템이 덮어써질 수 있습니다. 각 아이템에 고유 식별자를 부여하고 다음 조건을 검사합니다.
- 두 입력의
item_instance_id를 수집한다. - 동일 ID가 서로 다른 속성을 가지면 충돌로 분류한다.
- 신규 ID는 집합에 추가한다.
- 서버가 허용한 생성 이벤트와 수량 범위를 검증한다.
- 병합 후 정규화된 해시를 다시 계산한다.
동일한 ID가 서로 다른 아이템으로 나타나는 경우에는 어느 쪽을 선택할지 추측하지 말고 복구 큐로 보내는 편이 안전합니다.
C-06: 재화 중복 지급 검증
재화는 local_gold + remote_gold 또는 max(local_gold, remote_gold)로 병합하면 중복 지급이나 손실이 발생할 수 있습니다. 재화 변경을 이벤트로 기록할 수 있다면 다음처럼 이벤트의 고유 키를 기준으로 중복 제거합니다.
public sealed record CurrencyEvent(
string EventId,
string AccountId,
long Delta,
long Sequence
);
public static long CalculateBalance(
long verifiedBase,
IEnumerable<CurrencyEvent> events)
{
return verifiedBase + events
.DistinctBy(e => e.EventId)
.OrderBy(e => e.Sequence)
.Sum(e => e.Delta);
}
테스트에서는 같은 EventId를 양쪽 세이브에 넣고 한 번만 반영되는지 확인합니다. Sequence가 뒤섞이거나 음수 Delta가 허용 범위를 벗어나는 경우에는 병합을 중단하고 서버 검증 실패로 기록해야 합니다.
충돌 병합을 어떻게 단계별로 검증할까?
1단계: 입력과 계보를 고정한다
테스트 픽스처에 다음 값을 명시합니다.
schema_versionsave_id,slot_idcontent_revision,parent_revisiondevice_id- 로컬·원격의 정규화 전 JSON
- 예상
payload_hash - 네트워크 상태와 패치 버전
테스트 실행 전에 입력 해시를 기록하면 픽스처가 무의식적으로 변경되는 문제를 찾을 수 있습니다.
2단계: 충돌 판정 결과를 검증한다
병합 함수는 바로 파일을 쓰기보다 판정 결과를 반환하도록 분리하는 것이 좋습니다.
public enum SaveDecision
{
Deduplicate,
KeepLocal,
KeepRemote,
Merge,
ManualRecovery
}
public sealed record SaveResult(
SaveDecision Decision,
string ResultHash,
long ResultRevision,
bool IsValid
);
각 시나리오에서 Decision이 예상 값과 같은지 확인하고 IsValid가 거짓인 결과가 활성 세이브로 저장되지 않는지 검사합니다.
3단계: 병합 후 불변 조건을 검사한다
모든 자동 병합 결과에는 다음 불변 조건을 적용합니다.
slot_id와save_id가 입력과 일치한다.schema_version이 현재 빌드가 지원하는 범위에 있다.content_revision이 입력 리비전보다 작아지지 않는다.- 정규화 후 계산한
payload_hash가 저장된 해시와 같다. - 아이템 고유 ID가 중복되지 않는다.
- 재화가 허용 범위를 벗어나지 않는다.
- 퀘스트 상태가 정의된 상태 머신 전이를 벗어나지 않는다.
- 병합에 사용하지 않은 원본 세이브가 복구 지점에 남아 있다.
네트워크 단절과 패치 버전 차이는 어떻게 재현할까?
충돌 QA는 정상 네트워크만으로는 부족합니다. 업로드 직전, 업로드 중간, 서버 응답 수신 직후에 연결을 끊고 재시도해야 합니다. 재시도 요청에는 save_id, content_revision, operation_id를 포함해 같은 요청이 여러 번 처리되어도 결과가 한 번만 반영되는지 확인합니다.
패치 검증에서는 다음 조합을 최소 단위로 구성합니다.
| 클라이언트 | 세이브 | 검증 항목 |
|---|---|---|
| 이전 빌드 | 이전 스키마 | 정상 로드 |
| 이전 빌드 | 새 스키마 | 명확한 호환 불가 처리 |
| 새 빌드 | 이전 스키마 | 마이그레이션 후 값 보존 |
| 새 빌드 | 새 스키마 | 정상 저장·동기화 |
마이그레이션은 한 번에 최신 버전으로만 이동하지 말고 지원 범위를 명확히 해야 합니다. 예를 들어 schema_version = 3에서 5로 이동할 때 3→4, 4→5 변환을 각각 테스트하고 변환 후 역직렬화가 가능한지 확인합니다.
프로퍼티 기반 테스트와 회귀 테스트를 함께 운영하기
고정 시나리오는 알려진 회귀 버그를 잡는 데 유리하고 프로퍼티 기반 테스트는 예상하지 못한 조합을 찾는 데 유리합니다. 다음 속성을 무작위 세이브에 반복 적용할 수 있습니다.
- 동일한 입력을 두 번 병합해도 결과가 달라지지 않는다.
- 병합 순서를 바꿔도 정책상 순서 비의존 필드는 같은 결과를 낸다.
- 병합 결과를 다시 병합해도 아이템과 이벤트가 중복되지 않는다.
- 유효하지 않은 입력은 항상
ManualRecovery또는 명시적 오류를 반환한다. - 저장과 로드 사이에 해시가 변하지 않는다.
다만 재화나 퀘스트처럼 순서가 의미를 가지는 데이터는 가환성이나 결합성을 무조건 요구하면 안 됩니다. 필드별 정책이 순서 의존적인지 테스트 명세에 표시해야 합니다.
라이브 운영에서 확인할 충돌 지표
빌드 검증이 끝난 뒤에는 운영 로그로 다음 지표를 추적합니다.
- 세이브 충돌 발생률: 충돌 세션 수 / 동기화 세션 수
- 자동 병합 성공률
- 수동 복구 전환율
- 해시 불일치 비율
- 마이그레이션 실패율
- 재시도 요청의 중복 처리율
- 복구 지점 생성 및 복원 성공률
로그에는 세이브 본문 전체나 개인정보를 남기지 말고 save_id의 익명화된 식별자, 리비전, 스키마 버전, 해시, 판정 코드, 클라이언트 빌드만 기록하는 것이 좋습니다.
자주 묻는 질문 (FAQ)
클라우드 세이브 충돌에서 시간만 비교하면 안 되는 이유는 무엇인가요?
기기 시계 오차와 네트워크 재시도 때문에 실제 저장 순서와 updated_at이 달라질 수 있기 때문입니다. 부모 리비전과 단조 증가하는 콘텐츠 리비전을 우선 사용해야 합니다.
로컬과 원격 데이터를 항상 자동으로 합치면 안 되나요?
아이템·재화·퀘스트처럼 중복 지급이나 상태 손실이 발생할 수 있는 데이터는 안전한 병합 규칙이 없으면 자동 병합하면 안 됩니다. 이 경우 원본 보존 후 사용자 선택이나 서버 복구 절차로 전환해야 합니다.
해시가 같으면 세이브가 완전히 안전한가요?
해시는 동일한 정규화 데이터인지 확인하는 도구입니다. 서버 권위 검증, 이벤트 중복 제거, 스키마 마이그레이션 검증까지 대신하지는 않습니다.
정리
클라우드 세이브 무결성 QA의 핵심은 충돌을 하나의 예외 화면으로 취급하지 않고 데이터 계보와 필드 정책에 따라 분해하는 것입니다. content_revision과 parent_revision으로 충돌 유형을 판정하고 payload_hash로 전송 무결성을 확인한 뒤 아이템·재화·퀘스트별 불변 조건을 검사해야 합니다. 마지막으로 원본 복구 지점과 멱등적 재시도를 포함한 시나리오를 빌드 회귀 테스트와 라이브 지표에 연결하면 세이브 손실과 중복 지급을 함께 줄일 수 있습니다.



